diff --git a/.github/workflows/publish-ai-sdk.yml b/.github/workflows/publish-ai-sdk.yml index 3f69b421..848c7a71 100644 --- a/.github/workflows/publish-ai-sdk.yml +++ b/.github/workflows/publish-ai-sdk.yml @@ -23,8 +23,12 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: + node-version: '24' registry-url: 'https://registry.npmjs.org' + - name: Upgrade npm for trusted publishing support + run: npm install -g npm@latest + - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -34,11 +38,24 @@ jobs: - name: Install dependencies run: bun install + - name: Check if version changed + id: version-check + run: | + PACKAGE_NAME=$(jq -r '.name' package.json) + LOCAL_VERSION=$(jq -r '.version' package.json) + NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0") + if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then + echo "Version $LOCAL_VERSION already published, skipping." + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + - name: Build + if: steps.version-check.outputs.changed == 'true' run: bun run build - name: Publish - run: pnpm publish --access public --verbose - env: - NPM_CONFIG_PROVENANCE: true - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} \ No newline at end of file + if: steps.version-check.outputs.changed == 'true' + run: npm publish --access public --provenance diff --git a/.github/workflows/publish-memory-graph.yml b/.github/workflows/publish-memory-graph.yml index 65b24e8d..295a45f6 100644 --- a/.github/workflows/publish-memory-graph.yml +++ b/.github/workflows/publish-memory-graph.yml @@ -23,8 +23,12 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: + node-version: '24' registry-url: 'https://registry.npmjs.org' + - name: Upgrade npm for trusted publishing support + run: npm install -g npm@latest + - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -34,11 +38,24 @@ jobs: - name: Install dependencies run: bun install + - name: Check if version changed + id: version-check + run: | + PACKAGE_NAME=$(jq -r '.name' package.json) + LOCAL_VERSION=$(jq -r '.version' package.json) + NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0") + if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then + echo "Version $LOCAL_VERSION already published, skipping." + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + - name: Build + if: steps.version-check.outputs.changed == 'true' run: bun run build - name: Publish - run: pnpm publish --access public --verbose - env: - NPM_CONFIG_PROVENANCE: true - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + if: steps.version-check.outputs.changed == 'true' + run: npm publish --access public --provenance diff --git a/.github/workflows/publish-tools.yml b/.github/workflows/publish-tools.yml index b8d271de..9d745203 100644 --- a/.github/workflows/publish-tools.yml +++ b/.github/workflows/publish-tools.yml @@ -23,8 +23,12 @@ jobs: - name: Setup Node uses: actions/setup-node@v4 with: + node-version: '24' registry-url: 'https://registry.npmjs.org' + - name: Upgrade npm for trusted publishing support + run: npm install -g npm@latest + - name: Setup Bun uses: oven-sh/setup-bun@v2 @@ -34,11 +38,24 @@ jobs: - name: Install dependencies run: bun install + - name: Check if version changed + id: version-check + run: | + PACKAGE_NAME=$(jq -r '.name' package.json) + LOCAL_VERSION=$(jq -r '.version' package.json) + NPM_VERSION=$(npm view "$PACKAGE_NAME" version 2>/dev/null || echo "0.0.0") + if [ "$LOCAL_VERSION" = "$NPM_VERSION" ]; then + echo "Version $LOCAL_VERSION already published, skipping." + echo "changed=false" >> "$GITHUB_OUTPUT" + else + echo "Publishing $LOCAL_VERSION (npm has $NPM_VERSION)" + echo "changed=true" >> "$GITHUB_OUTPUT" + fi + - name: Build + if: steps.version-check.outputs.changed == 'true' run: bun run build - name: Publish - run: pnpm publish --access public --verbose - env: - NPM_CONFIG_PROVENANCE: true - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + if: steps.version-check.outputs.changed == 'true' + run: npm publish --access public --provenance diff --git a/apps/docs/integrations/mastra.mdx b/apps/docs/integrations/mastra.mdx index 57a6e5e7..4848bee0 100644 --- a/apps/docs/integrations/mastra.mdx +++ b/apps/docs/integrations/mastra.mdx @@ -98,8 +98,10 @@ sequenceDiagram |--------|------|---------|-------------| | `apiKey` | `string` | `SUPERMEMORY_API_KEY` env | Your Supermemory API key | | `baseUrl` | `string` | `https://api.supermemory.ai` | Custom API endpoint | -| `mode` | `"profile" \| "query" \| "full"` | `"profile"` | Memory search mode | +| `mode` | `"profile" \| "query" \| "full"` | `"profile"` | Memory retrieval mode | | `addMemory` | `"always" \| "never"` | `"always"` | Auto-save conversations | +| `searchMode` | `"memories" \| "hybrid" \| "documents"` | `"memories"` | Search endpoint mode | +| `searchLimit` | `number` | `10` | Max search results per endpoint | | `verbose` | `boolean` | `false` | Enable debug logging | | `promptTemplate` | `function` | - | Custom memory formatting | @@ -134,6 +136,55 @@ const agent = new Agent(withSupermemory(config, { containerTag: "user-123", cust --- +## Search Mode Options (RAG) + +The `searchMode` option controls which search endpoints are used when `mode` is `"query"` or `"full"`: + +**Memories (Default)** - Searches memory entries only. Low latency, best for conversational context: + +```typescript +const agent = new Agent(withSupermemory(config, { + containerTag: "user-123", + customId: "conv-456", + mode: "full", + searchMode: "memories" +})) +``` + +**Documents** - Searches document chunks only. Best for RAG with uploaded documents: + +```typescript +const agent = new Agent(withSupermemory(config, { + containerTag: "user-123", + customId: "conv-456", + mode: "full", + searchMode: "documents", + searchLimit: 20 // Get more chunks for RAG +})) +``` + +**Hybrid** - Searches both memories AND document chunks in parallel. Comprehensive retrieval: + +```typescript +const agent = new Agent(withSupermemory(config, { + containerTag: "user-123", + customId: "conv-456", + mode: "full", + searchMode: "hybrid", + searchLimit: 10 // 10 from each endpoint +})) +``` + +### Search Mode Comparison + +| Search Mode | Endpoint | Use Case | +|-------------|----------|----------| +| `memories` | `/v4/search` | Conversational memory, low latency | +| `documents` | `/v3/search` | RAG with uploaded docs | +| `hybrid` | Both | Comprehensive context | + +--- + ## Saving Conversations Enable automatic conversation saving with `addMemory: "always"`. The `customId` parameter groups messages into the same document: @@ -404,6 +455,8 @@ interface SupermemoryMastraOptions { baseUrl?: string mode?: "profile" | "query" | "full" addMemory?: "always" | "never" + searchMode?: "memories" | "hybrid" | "documents" // Search endpoint mode + searchLimit?: number // Max search results per endpoint (default: 10) verbose?: boolean promptTemplate?: (data: MemoryPromptData) => string } diff --git a/packages/cartesia-sdk-python/pyproject.toml b/packages/cartesia-sdk-python/pyproject.toml index 18b3b299..81dac7fd 100644 --- a/packages/cartesia-sdk-python/pyproject.toml +++ b/packages/cartesia-sdk-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "supermemory-cartesia" -version = "0.1.0" +version = "0.1.1" description = "Supermemory integration for Cartesia Line - memory-enhanced voice agents" readme = "README.md" license = "MIT" diff --git a/packages/tools/README.md b/packages/tools/README.md index b18dd851..5e89af83 100644 --- a/packages/tools/README.md +++ b/packages/tools/README.md @@ -518,8 +518,10 @@ main() #### Memory Search Modes +The `mode` option controls whether profile data is fetched: + - **`profile`** (default): Fetches user profile memories (static facts + dynamic context) -- **`query`**: Searches memories based on the user's message +- **`query`**: Searches memories based on the user's message (no profile data) - **`full`**: Combines both profile and query results ```typescript @@ -545,6 +547,42 @@ const { input } = createSupermemoryProcessors({ }) ``` +#### Search Mode Options (RAG) + +The `searchMode` option controls which search endpoints are used when `mode` is `"query"` or `"full"`: + +- **`memories`** (default): Searches memory entries only - low latency, best for conversational context +- **`documents`**: Searches document chunks only - best for RAG with uploaded documents +- **`hybrid`**: Searches both memories AND document chunks in parallel - comprehensive retrieval + +```typescript +// Search only memories (default) - fast, conversational +const { input } = createSupermemoryProcessors({ + containerTag: "user-123", + customId: "conv-456", + mode: "full", + searchMode: "memories" +}) + +// Search only document chunks - RAG use case +const { input } = createSupermemoryProcessors({ + containerTag: "user-123", + customId: "conv-456", + mode: "full", + searchMode: "documents", + searchLimit: 20 // Get more chunks for RAG +}) + +// Hybrid search - both memories and documents +const { input } = createSupermemoryProcessors({ + containerTag: "user-123", + customId: "conv-456", + mode: "full", + searchMode: "hybrid", + searchLimit: 10 // 10 from each endpoint +}) +``` + #### Custom Prompt Templates Customize how memories are formatted in the system prompt: @@ -606,8 +644,10 @@ interface SupermemoryMastraOptions { customId: string // Required: Conversation ID to group messages apiKey?: string // Supermemory API key (or use SUPERMEMORY_API_KEY env var) baseUrl?: string // Custom API endpoint - mode?: "profile" | "query" | "full" // Memory search mode (default: "profile") + mode?: "profile" | "query" | "full" // Memory retrieval mode (default: "profile") addMemory?: "always" | "never" // Auto-save conversations (default: "always") + searchMode?: "memories" | "hybrid" | "documents" // Search endpoint mode (default: "memories") + searchLimit?: number // Max search results per endpoint (default: 10) verbose?: boolean // Enable debug logging (default: false) promptTemplate?: (data: MemoryPromptData) => string // Custom memory formatting } diff --git a/packages/tools/package.json b/packages/tools/package.json index bdf07b63..b7399f6b 100644 --- a/packages/tools/package.json +++ b/packages/tools/package.json @@ -1,7 +1,7 @@ { "name": "@supermemory/tools", "type": "module", - "version": "1.5.0", + "version": "2.0.0", "description": "Memory tools for AI SDK and OpenAI function calling with supermemory", "scripts": { "build": "tsdown", diff --git a/packages/tools/src/mastra/processor.ts b/packages/tools/src/mastra/processor.ts index 0539580e..926b6a7a 100644 --- a/packages/tools/src/mastra/processor.ts +++ b/packages/tools/src/mastra/processor.ts @@ -22,6 +22,7 @@ import { type Logger, type MemoryMode, type PromptTemplate, + type SearchMode, } from "../shared" import { addConversation, @@ -48,6 +49,8 @@ interface ProcessorContext { baseUrl: string mode: MemoryMode addMemory: "always" | "never" + searchMode: SearchMode + searchLimit: number logger: Logger promptTemplate?: PromptTemplate memoryCache: MemoryCache @@ -92,6 +95,8 @@ function createProcessorContext( baseUrl, mode: options.mode ?? "profile", addMemory: options.addMemory ?? "always", + searchMode: options.searchMode ?? "memories", + searchLimit: options.searchLimit ?? 10, logger, promptTemplate: options.promptTemplate, memoryCache: new MemoryCache(), @@ -204,6 +209,8 @@ export class SupermemoryInputProcessor implements Processor { apiKey: this.ctx.apiKey, logger: this.ctx.logger, promptTemplate: this.ctx.promptTemplate, + searchMode: this.ctx.searchMode, + searchLimit: this.ctx.searchLimit, }) if (memories) { diff --git a/packages/tools/src/mastra/types.ts b/packages/tools/src/mastra/types.ts index 9870ea3a..dda93eb5 100644 --- a/packages/tools/src/mastra/types.ts +++ b/packages/tools/src/mastra/types.ts @@ -9,6 +9,7 @@ import type { PromptTemplate, MemoryMode, AddMemoryMode, + SearchMode, MemoryPromptData, SupermemoryBaseOptions, } from "../shared" @@ -48,4 +49,10 @@ export interface SupermemoryMastraOptions extends SupermemoryBaseOptions { customId: string } -export type { PromptTemplate, MemoryMode, AddMemoryMode, MemoryPromptData } +export type { + PromptTemplate, + MemoryMode, + AddMemoryMode, + SearchMode, + MemoryPromptData, +} diff --git a/packages/tools/src/shared/index.ts b/packages/tools/src/shared/index.ts index 5a6e0f7b..884c7dc5 100644 --- a/packages/tools/src/shared/index.ts +++ b/packages/tools/src/shared/index.ts @@ -4,6 +4,7 @@ export type { PromptTemplate, MemoryMode, AddMemoryMode, + SearchMode, Logger, ProfileStructure, ProfileMarkdownData, diff --git a/packages/tools/src/shared/memory-client.ts b/packages/tools/src/shared/memory-client.ts index 58754c89..a55be299 100644 --- a/packages/tools/src/shared/memory-client.ts +++ b/packages/tools/src/shared/memory-client.ts @@ -1,3 +1,4 @@ +import Supermemory from "supermemory" import { deduplicateMemories } from "../tools-shared" import type { Logger, @@ -5,11 +6,13 @@ import type { MemoryPromptData, ProfileStructure, PromptTemplate, + SearchMode, } from "./types" import { convertProfileToMarkdown, defaultPromptTemplate, } from "./prompt-builder" +import { createSupermemoryClient } from "./context" /** * Fetches profile and search results from the Supermemory API. @@ -72,12 +75,148 @@ export interface BuildMemoriesTextOptions { apiKey: string logger: Logger promptTemplate?: PromptTemplate + /** Search mode: "memories", "hybrid", or "documents" (default: "memories") */ + searchMode?: SearchMode + /** Maximum number of search results (default: 10) */ + searchLimit?: number +} + +/** + * Search result structure for unified handling. + */ +interface SearchResult { + content: string + metadata?: Record +} + +/** + * Searches for memories using the Supermemory SDK. + * + * @param client - Supermemory client instance + * @param containerTag - Container tag for scoping + * @param query - Search query text + * @param limit - Maximum number of results + * @returns Array of search results + */ +async function searchMemoriesSDK( + client: Supermemory, + containerTag: string, + query: string, + limit: number, +): Promise { + const response = await client.search.memories({ + q: query, + containerTag, + limit, + include: { chunks: false }, + }) + return (response.results || []).map((r) => ({ + content: r.memory || "", + metadata: r.metadata ?? undefined, + })) +} + +/** + * Searches for document chunks using the Supermemory SDK. + * Only includes chunks marked as relevant (isRelevant: true). + * + * @param client - Supermemory client instance + * @param containerTag - Container tag for scoping + * @param query - Search query text + * @param limit - Maximum number of results + * @returns Array of search results containing only relevant chunks + */ +async function searchDocumentsSDK( + client: Supermemory, + containerTag: string, + query: string, + limit: number, +): Promise { + const response = await client.search.documents({ + q: query, + containerTags: [containerTag], + limit, + }) + // Extract only relevant chunks from each document result + const results: SearchResult[] = [] + for (const doc of response.results || []) { + for (const chunk of doc.chunks || []) { + // Only include chunks marked as relevant + if (chunk.isRelevant) { + results.push({ + content: chunk.content || "", + metadata: doc.metadata ?? undefined, + }) + } + } + } + return results +} + +/** + * Performs search based on the specified search mode. + * + * @param client - Supermemory client instance + * @param containerTag - Container tag for scoping + * @param query - Search query text + * @param searchMode - Search mode: "memories", "hybrid", or "documents" + * @param limit - Maximum number of results per search type + * @param logger - Logger instance + * @returns Combined array of search results + */ +async function performSearch( + client: Supermemory, + containerTag: string, + query: string, + searchMode: SearchMode, + limit: number, + logger: Logger, +): Promise { + logger.debug("Performing search", { searchMode, containerTag, limit }) + + switch (searchMode) { + case "memories": + return searchMemoriesSDK(client, containerTag, query, limit) + + case "documents": + return searchDocumentsSDK(client, containerTag, query, limit) + + case "hybrid": { + // Run both searches in parallel + const [memoriesResults, documentsResults] = await Promise.all([ + searchMemoriesSDK(client, containerTag, query, limit), + searchDocumentsSDK(client, containerTag, query, limit), + ]) + + logger.debug("Hybrid search completed", { + memoriesCount: memoriesResults.length, + documentsCount: documentsResults.length, + }) + + // Combine results, memories first + return [...memoriesResults, ...documentsResults] + } + + default: + logger.warn(`Unknown search mode: ${searchMode}, defaulting to memories`) + return searchMemoriesSDK(client, containerTag, query, limit) + } } /** * Fetches memories from the API, deduplicates them, and formats them into * the final string to be injected into the system prompt. * + * When searchMode is specified, uses the Supermemory SDK search endpoints: + * - "memories": Uses search.memories() for memory entries + * - "documents": Uses search.documents() for document chunks + * - "hybrid": Uses both endpoints in parallel + * + * The mode option controls whether profile data is included: + * - "profile": Only profile data (static/dynamic), no search + * - "query": Only search results, no profile data + * - "full": Both profile data and search results + * * @param options - Configuration for building memories text * @returns The final formatted memories string ready for injection */ @@ -92,48 +231,77 @@ export const buildMemoriesText = async ( apiKey, logger, promptTemplate = defaultPromptTemplate, + searchMode = "memories", + searchLimit = 10, } = options - const memoriesResponse = await supermemoryProfileSearch( - containerTag, - queryText, - baseUrl, - apiKey, - ) + // Fetch profile data when mode includes profile (profile or full) + let profileData: ProfileStructure | null = null + if (mode !== "query") { + profileData = await supermemoryProfileSearch( + containerTag, + mode === "profile" ? "" : queryText, // Only send query for full mode + baseUrl, + apiKey, + ) - const memoryCountStatic = memoriesResponse.profile.static?.length || 0 - const memoryCountDynamic = memoriesResponse.profile.dynamic?.length || 0 + const memoryCountStatic = profileData.profile.static?.length || 0 + const memoryCountDynamic = profileData.profile.dynamic?.length || 0 - logger.info("Memory search completed", { - containerTag, - memoryCountStatic, - memoryCountDynamic, - queryText: - queryText.substring(0, 100) + (queryText.length > 100 ? "..." : ""), - mode, - }) + logger.info("Profile search completed", { + containerTag, + memoryCountStatic, + memoryCountDynamic, + mode, + }) + } + // Perform SDK-based search when mode includes query (query or full) + let searchResults: SearchResult[] = [] + if (mode !== "profile" && queryText) { + const client = createSupermemoryClient({ apiKey, baseUrl }) + searchResults = await performSearch( + client, + containerTag, + queryText, + searchMode, + searchLimit, + logger, + ) + + logger.info("Search completed", { + containerTag, + searchMode, + searchLimit, + resultCount: searchResults.length, + queryText: + queryText.substring(0, 100) + (queryText.length > 100 ? "..." : ""), + }) + } + + // Deduplicate profile memories const deduplicated = deduplicateMemories({ - static: memoriesResponse.profile.static, - dynamic: memoriesResponse.profile.dynamic, - searchResults: memoriesResponse.searchResults?.results, + static: profileData?.profile.static, + dynamic: profileData?.profile.dynamic, + searchResults: searchResults.map((r) => ({ memory: r.content })), }) logger.debug("Memory deduplication completed", { static: { - original: memoryCountStatic, + original: profileData?.profile.static?.length || 0, deduplicated: deduplicated.static.length, }, dynamic: { - original: memoryCountDynamic, + original: profileData?.profile.dynamic?.length || 0, deduplicated: deduplicated.dynamic.length, }, searchResults: { - original: memoriesResponse.searchResults?.results?.length, + original: searchResults.length, deduplicated: deduplicated.searchResults?.length, }, }) + // Build user memories from profile (static + dynamic) const userMemories = mode !== "query" ? convertProfileToMarkdown({ @@ -144,9 +312,11 @@ export const buildMemoriesText = async ( searchResults: { results: [] }, }) : "" + + // Build search results text const generalSearchMemories = - mode !== "profile" - ? `Search results for user's recent message: \n${deduplicated.searchResults + mode !== "profile" && deduplicated.searchResults.length > 0 + ? `Search results for user's recent message:\n${deduplicated.searchResults .map((memory) => `- ${memory}`) .join("\n")}` : "" @@ -154,7 +324,10 @@ export const buildMemoriesText = async ( const promptData: MemoryPromptData = { userMemories, generalSearchMemories, - searchResults: memoriesResponse.searchResults?.results ?? [], + searchResults: searchResults.map((r) => ({ + memory: r.content, + metadata: r.metadata, + })), } const memories = promptTemplate(promptData) diff --git a/packages/tools/src/shared/types.ts b/packages/tools/src/shared/types.ts index 3103a88f..c02e5aa6 100644 --- a/packages/tools/src/shared/types.ts +++ b/packages/tools/src/shared/types.ts @@ -54,6 +54,14 @@ export type MemoryMode = "profile" | "query" | "full" */ export type AddMemoryMode = "always" | "never" +/** + * Search mode for memory retrieval: + * - "memories": Search only memory entries (default) + * - "hybrid": Search both memories AND document chunks (recommended for RAG) + * - "documents": Search only document chunks + */ +export type SearchMode = "memories" | "hybrid" | "documents" + /** * Logger interface for consistent logging across integrations. */ @@ -119,6 +127,10 @@ export interface SupermemoryBaseOptions { mode?: MemoryMode /** Memory persistence mode */ addMemory?: AddMemoryMode + /** Search mode for memory retrieval (default: "memories") */ + searchMode?: SearchMode + /** Maximum number of search results to return (default: 10) */ + searchLimit?: number /** Enable detailed logging of memory search and injection */ verbose?: boolean /** Custom function to format memory data into the system prompt */ diff --git a/packages/tools/test/mastra/integration.test.ts b/packages/tools/test/mastra/integration.test.ts index 88546e08..5b843e6e 100644 --- a/packages/tools/test/mastra/integration.test.ts +++ b/packages/tools/test/mastra/integration.test.ts @@ -169,19 +169,19 @@ describe.skipIf(!shouldRunIntegration)( }), ) + // In query mode, profile API should NOT be called (only SDK search endpoints) const profileCalls = fetchSpy.mock.calls.filter( (call) => typeof call[0] === "string" && call[0].includes("/v4/profile"), ) - expect(profileCalls.length).toBeGreaterThan(0) + expect(profileCalls.length).toBe(0) - const profileCall = profileCalls[0] - if (profileCall?.[1]) { - const body = JSON.parse( - (profileCall[1] as RequestInit).body as string, - ) - expect(body.q).toBe("What are my favorite programming languages?") - } + // SDK search endpoint should be called (search.memories uses /v4/search) + const searchCalls = fetchSpy.mock.calls.filter( + (call) => + typeof call[0] === "string" && call[0].includes("/v4/search"), + ) + expect(searchCalls.length).toBeGreaterThan(0) fetchSpy.mockRestore() }) diff --git a/packages/tools/test/shared/memory-client.test.ts b/packages/tools/test/shared/memory-client.test.ts new file mode 100644 index 00000000..2005e366 --- /dev/null +++ b/packages/tools/test/shared/memory-client.test.ts @@ -0,0 +1,373 @@ +/** + * Unit tests for the buildMemoriesText function with searchMode support + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import { + buildMemoriesText, + type BuildMemoriesTextOptions, +} from "../../src/shared/memory-client" +import { createLogger } from "../../src/shared/logger" +import "dotenv/config" + +// Track mock instances +let mockSearchDocuments: ReturnType +let mockSearchMemories: ReturnType +let supermemoryConstructorCalls: any[] = [] + +// Mock the Supermemory SDK +vi.mock("supermemory", () => { + return { + default: vi.fn().mockImplementation((config: any) => { + supermemoryConstructorCalls.push(config) + return { + search: { + documents: mockSearchDocuments, + memories: mockSearchMemories, + }, + } + }), + } +}) + +describe("buildMemoriesText with searchMode", () => { + let originalFetch: typeof globalThis.fetch + let fetchMock: ReturnType + const logger = createLogger(false) + + const createMockProfileResponse = ( + staticMemories: string[] = [], + dynamicMemories: string[] = [], + searchResults: string[] = [], + ) => ({ + profile: { + static: staticMemories.map((memory) => ({ memory })), + dynamic: dynamicMemories.map((memory) => ({ memory })), + }, + searchResults: { + results: searchResults.map((memory) => ({ memory })), + }, + }) + + beforeEach(() => { + originalFetch = globalThis.fetch + fetchMock = vi.fn() + globalThis.fetch = fetchMock as unknown as typeof fetch + + // Reset mocks + mockSearchDocuments = vi.fn().mockResolvedValue({ results: [] }) + mockSearchMemories = vi.fn().mockResolvedValue({ results: [] }) + supermemoryConstructorCalls = [] + vi.clearAllMocks() + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + describe("searchMode: memories (default)", () => { + it("should call profile API AND search.memories when searchMode is memories", async () => { + // Mock profile API response + fetchMock.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve( + createMockProfileResponse(["User profile fact"], [], []), + ), + }) + + // Mock search.memories response + mockSearchMemories.mockResolvedValue({ + results: [ + { memory: "Memory about TypeScript" }, + { memory: "Memory about React" }, + ], + }) + + const options: BuildMemoriesTextOptions = { + containerTag: "test-user", + queryText: "test query", + mode: "full", + baseUrl: "https://api.supermemory.ai", + apiKey: "test-key", + logger, + searchMode: "memories", + } + + const result = await buildMemoriesText(options) + + // Should call profile API (controlled by mode) + expect(fetchMock).toHaveBeenCalledTimes(1) + + // Should instantiate Supermemory SDK and call search.memories + expect(supermemoryConstructorCalls.length).toBe(1) + expect(mockSearchMemories).toHaveBeenCalledWith({ + q: "test query", + containerTag: "test-user", + limit: 10, + include: { chunks: false }, + }) + + // Should NOT call search.documents + expect(mockSearchDocuments).not.toHaveBeenCalled() + + // Result should contain both profile AND search memories + expect(result).toContain("User profile fact") + expect(result).toContain("Memory about TypeScript") + expect(result).toContain("Memory about React") + }) + }) + + describe("searchMode: hybrid", () => { + it("should call profile API AND both search.memories and search.documents", async () => { + // Mock profile API response + fetchMock.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve( + createMockProfileResponse(["User likes coding"], [], []), + ), + }) + + // Mock search.memories + mockSearchMemories.mockResolvedValue({ + results: [{ memory: "Memory about coding preferences" }], + }) + + // Mock search.documents + mockSearchDocuments.mockResolvedValue({ + results: [ + { + documentId: "doc-1", + chunks: [ + { + content: "Document chunk about TypeScript best practices", + isRelevant: true, + }, + { content: "Another chunk about React", isRelevant: true }, + ], + }, + ], + }) + + const options: BuildMemoriesTextOptions = { + containerTag: "test-user", + queryText: "TypeScript tips", + mode: "full", + baseUrl: "https://api.supermemory.ai", + apiKey: "test-key", + logger, + searchMode: "hybrid", + searchLimit: 5, + } + + const result = await buildMemoriesText(options) + + // Should call profile API (controlled by mode) + expect(fetchMock).toHaveBeenCalledTimes(1) + + // Should instantiate Supermemory SDK + expect(supermemoryConstructorCalls.length).toBe(1) + expect(supermemoryConstructorCalls[0]).toEqual({ + apiKey: "test-key", + }) + + // Should call BOTH search.memories and search.documents + expect(mockSearchMemories).toHaveBeenCalledWith({ + q: "TypeScript tips", + containerTag: "test-user", + limit: 5, + include: { chunks: false }, + }) + expect(mockSearchDocuments).toHaveBeenCalledWith({ + q: "TypeScript tips", + containerTags: ["test-user"], + limit: 5, + }) + + // Result should contain profile, memories, and document chunks + expect(result).toContain("User likes coding") + expect(result).toContain("Memory about coding preferences") + expect(result).toContain("Document chunk about TypeScript best practices") + expect(result).toContain("Another chunk about React") + }) + }) + + describe("searchMode: documents", () => { + it("should call profile API and search.documents for chunks only", async () => { + // Mock profile API + fetchMock.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve( + createMockProfileResponse(["User profile fact"], [], []), + ), + }) + + // Mock Supermemory search.documents + mockSearchDocuments.mockResolvedValue({ + results: [ + { + documentId: "doc-1", + chunks: [ + { content: "RAG chunk 1", isRelevant: true }, + { content: "RAG chunk 2", isRelevant: true }, + { content: "Irrelevant chunk", isRelevant: false }, + ], + }, + ], + }) + + const options: BuildMemoriesTextOptions = { + containerTag: "test-user", + queryText: "What's in my documents?", + mode: "full", + baseUrl: "https://api.supermemory.ai", + apiKey: "test-key", + logger, + searchMode: "documents", + searchLimit: 10, + } + + const result = await buildMemoriesText(options) + + // Should call profile API + expect(fetchMock).toHaveBeenCalledTimes(1) + + // Should call search.documents + expect(mockSearchDocuments).toHaveBeenCalledWith({ + q: "What's in my documents?", + containerTags: ["test-user"], + limit: 10, + }) + + // Result should contain profile and relevant chunks only + expect(result).toContain("User profile fact") + expect(result).toContain("RAG chunk 1") + expect(result).toContain("RAG chunk 2") + // Irrelevant chunk should NOT be included + expect(result).not.toContain("Irrelevant chunk") + }) + }) + + describe("searchLimit option", () => { + it("should use default searchLimit of 10", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(createMockProfileResponse([], [], [])), + }) + + mockSearchMemories.mockResolvedValue({ results: [] }) + mockSearchDocuments.mockResolvedValue({ results: [] }) + + const options: BuildMemoriesTextOptions = { + containerTag: "test-user", + queryText: "test", + mode: "full", + baseUrl: "https://api.supermemory.ai", + apiKey: "test-key", + logger, + searchMode: "hybrid", + // searchLimit not specified - should default to 10 + } + + await buildMemoriesText(options) + + expect(mockSearchMemories).toHaveBeenCalledWith( + expect.objectContaining({ limit: 10 }), + ) + }) + + it("should use custom searchLimit when specified", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(createMockProfileResponse([], [], [])), + }) + + mockSearchDocuments.mockResolvedValue({ results: [] }) + + const options: BuildMemoriesTextOptions = { + containerTag: "test-user", + queryText: "test", + mode: "full", + baseUrl: "https://api.supermemory.ai", + apiKey: "test-key", + logger, + searchMode: "documents", + searchLimit: 25, + } + + await buildMemoriesText(options) + + expect(mockSearchDocuments).toHaveBeenCalledWith( + expect.objectContaining({ limit: 25 }), + ) + }) + }) + + describe("edge cases", () => { + it("should call profile API but not search APIs when queryText is empty", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve(createMockProfileResponse(["Profile only"], [], [])), + }) + + const options: BuildMemoriesTextOptions = { + containerTag: "test-user", + queryText: "", // Empty query + mode: "profile", + baseUrl: "https://api.supermemory.ai", + apiKey: "test-key", + logger, + searchMode: "documents", + } + + const result = await buildMemoriesText(options) + + // Should call profile API (mode controls this) + expect(fetchMock).toHaveBeenCalledTimes(1) + + // Should NOT instantiate Supermemory SDK when no query + expect(supermemoryConstructorCalls.length).toBe(0) + expect(mockSearchDocuments).not.toHaveBeenCalled() + + // Should still return profile + expect(result).toContain("Profile only") + }) + + it("should handle custom baseUrl correctly", async () => { + fetchMock.mockResolvedValue({ + ok: true, + json: () => Promise.resolve(createMockProfileResponse([], [], [])), + }) + + mockSearchMemories.mockResolvedValue({ results: [] }) + + const options: BuildMemoriesTextOptions = { + containerTag: "test-user", + queryText: "test", + mode: "full", + baseUrl: "https://custom.api.example.com", + apiKey: "test-key", + logger, + searchMode: "hybrid", + } + + await buildMemoriesText(options) + + // Profile API should use custom base URL + expect(fetchMock).toHaveBeenCalledWith( + "https://custom.api.example.com/v4/profile", + expect.any(Object), + ) + + // Supermemory SDK should be configured with custom base URL + expect(supermemoryConstructorCalls.length).toBe(1) + expect(supermemoryConstructorCalls[0]).toEqual({ + apiKey: "test-key", + baseURL: "https://custom.api.example.com", + }) + }) + }) +})