diff --git a/docs/use/memory.md b/docs/use/memory.md index 32cddf955f..6fc47f4bcf 100644 --- a/docs/use/memory.md +++ b/docs/use/memory.md @@ -71,6 +71,9 @@ Use these through **`execute`**: - **`meta_memory_get`** — load one stored memory by id - **`meta_memory_search`** — browse/search stored memories directly +Memory records can also include optional **`source_uris`** — opaque canonical +document URLs such as GitHub files, R2 object URLs, or Notion pages. + ## Categories Memory categories are freeform strings. Kody does not force a closed list. diff --git a/packages/worker/migrations/0018-mcp-memory-source-uris.sql b/packages/worker/migrations/0018-mcp-memory-source-uris.sql new file mode 100644 index 0000000000..99b14187b1 --- /dev/null +++ b/packages/worker/migrations/0018-mcp-memory-source-uris.sql @@ -0,0 +1,2 @@ +ALTER TABLE mcp_memories +ADD COLUMN source_uris_json TEXT NOT NULL DEFAULT '[]'; diff --git a/packages/worker/src/mcp/capabilities/meta/meta-memory-delete.ts b/packages/worker/src/mcp/capabilities/meta/meta-memory-delete.ts index 82ec1a3d98..921f914a68 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-memory-delete.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-memory-delete.ts @@ -100,6 +100,7 @@ function formatMemoryRecord( summary: memory.summary, details: memory.details, tags: memory.tags, + source_uris: memory.sourceUris, dedupe_key: memory.dedupeKey, created_at: memory.createdAt, updated_at: memory.updatedAt, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-memory-get.ts b/packages/worker/src/mcp/capabilities/meta/meta-memory-get.ts index 9ca5534ba4..9ff5786a8c 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-memory-get.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-memory-get.ts @@ -35,6 +35,7 @@ export const metaMemoryGetCapability = defineDomainCapability( summary: memory.summary, details: memory.details, tags: memory.tags, + source_uris: memory.sourceUris, dedupe_key: memory.dedupeKey, created_at: memory.createdAt, updated_at: memory.updatedAt, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-memory-search.ts b/packages/worker/src/mcp/capabilities/meta/meta-memory-search.ts index 09411f23d0..dead6f2c67 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-memory-search.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-memory-search.ts @@ -64,6 +64,7 @@ export const metaMemorySearchCapability = defineDomainCapability( summary: match.summary, details: match.details, tags: match.tags, + source_uris: match.sourceUris, dedupe_key: match.dedupeKey, created_at: match.createdAt, updated_at: match.updatedAt, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-memory-shared.ts b/packages/worker/src/mcp/capabilities/meta/meta-memory-shared.ts index 7e3a1db3ed..8b0ffe5847 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-memory-shared.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-memory-shared.ts @@ -11,7 +11,11 @@ export const memoryCategoryField = z 'Optional freeform category label for the memory. Suggested examples: preference, identifier, relationship, workflow, project, profile.', ) +const maxSourceUriLength = 2_048 +const maxSourceUriCount = 12 + export const memoryTagInputSchema = z.string().min(1).max(80) +export const memorySourceUriSchema = z.string().url().max(maxSourceUriLength) export const memoryTagsField = z .array(memoryTagInputSchema) @@ -19,6 +23,14 @@ export const memoryTagsField = z .optional() .describe('Optional short tags for retrieval and filtering.') +export const memorySourceUrisField = z + .array(memorySourceUriSchema) + .max(maxSourceUriCount) + .optional() + .describe( + 'Optional canonical source document URLs for the memory. Treat these as opaque references.', + ) + export const memoryBaseInputSchema = { subject: z .string() @@ -37,6 +49,7 @@ export const memoryBaseInputSchema = { .describe('Optional supporting details for the durable memory record.'), category: memoryCategoryField, tags: memoryTagsField, + source_uris: memorySourceUrisField, dedupe_key: z .string() .min(1) @@ -81,6 +94,7 @@ export const memoryRecordSchema = z.object({ summary: z.string(), details: z.string(), tags: z.array(z.string()), + source_uris: z.array(memorySourceUriSchema).optional(), dedupe_key: z.string().nullable(), created_at: z.string(), updated_at: z.string(), diff --git a/packages/worker/src/mcp/capabilities/meta/meta-memory-upsert.ts b/packages/worker/src/mcp/capabilities/meta/meta-memory-upsert.ts index 96fc083990..ebc6b6948a 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-memory-upsert.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-memory-upsert.ts @@ -5,6 +5,7 @@ import { type CapabilityContext } from '#mcp/capabilities/types.ts' import { upsertMemory } from '#mcp/memory/service.ts' import { memoryRecordSchema, + memorySourceUrisField, memoryTagInputSchema, } from '#mcp/capabilities/meta/meta-memory-shared.ts' import { requireMcpUser } from './require-user.ts' @@ -44,6 +45,7 @@ const inputSchema = z.object({ .max(12) .optional() .describe('Optional tags for retrieval and filtering.'), + source_uris: memorySourceUrisField, dedupe_key: z .string() .max(160) @@ -110,6 +112,7 @@ export const metaMemoryUpsertCapability = defineDomainCapability( summary: args.summary, details: args.details ?? '', tags: args.tags ?? [], + sourceUris: args.source_uris ?? [], dedupeKey: args.dedupe_key ?? null, status: args.status ?? 'active', verificationReference: args.verification_reference ?? null, @@ -124,6 +127,7 @@ export const metaMemoryUpsertCapability = defineDomainCapability( summary: result.memory.summary, details: result.memory.details, tags: result.memory.tags, + source_uris: result.memory.sourceUris, dedupe_key: result.memory.dedupeKey, created_at: result.memory.createdAt, updated_at: result.memory.updatedAt, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-memory-verify.ts b/packages/worker/src/mcp/capabilities/meta/meta-memory-verify.ts index 31a64dbaaf..6b17f00470 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-memory-verify.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-memory-verify.ts @@ -5,6 +5,7 @@ import { type CapabilityContext } from '#mcp/capabilities/types.ts' import { verifyMemoryCandidate } from '#mcp/memory/service.ts' import { memoryRecordSchema, + memorySourceUriSchema, memoryVerifyInputSchema, requireMcpUser, } from './meta-memory-shared.ts' @@ -21,6 +22,7 @@ const relatedMemorySchema = memoryRecordSchema subject: true, summary: true, tags: true, + source_uris: true, dedupe_key: true, }) .extend({ @@ -34,6 +36,7 @@ const outputSchema = z.object({ details: z.string(), category: z.string().nullable(), tags: z.array(z.string()), + source_uris: z.array(memorySourceUriSchema).optional(), dedupe_key: z.string().nullable(), }), related_memories: z.array(relatedMemorySchema), @@ -65,6 +68,7 @@ export const metaMemoryVerifyCapability = defineDomainCapability( summary: args.summary, details: args.details ?? null, tags: args.tags ?? null, + sourceUris: args.source_uris ?? null, dedupeKey: args.dedupe_key ?? null, }, limit: args.limit, @@ -91,6 +95,7 @@ function formatMemoryMatch( subject: string summary: string tags: Array + sourceUris: Array dedupeKey: string | null }, score: number, @@ -102,6 +107,7 @@ function formatMemoryMatch( subject: match.subject, summary: match.summary, tags: match.tags, + source_uris: match.sourceUris, dedupe_key: match.dedupeKey, score, } diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts index 4af3712647..69b1bac755 100644 --- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts +++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts @@ -59,6 +59,10 @@ test('authenticated mcp client can list tools, execute codemode, and search memo summary: 'Always use npm commands in this repository.', category: 'preference', tags: ['package-manager', 'repo-workflow'], + source_uris: [ + 'https://docs.npmjs.com/cli/v11/commands/npm-install', + 'https://github.com/kentcdodds/kody/blob/main/AGENTS.md', + ], verified_by_agent: true, verification_reference: 'verify-search-fallback-1', }) @@ -66,9 +70,63 @@ test('authenticated mcp client can list tools, execute codemode, and search memo }, }) const upsertStructured = (upsertResult as CallToolResult).structuredContent as - | { result?: { memory?: { id?: string } } } + | { result?: { memory?: { id?: string; source_uris?: Array } } } | undefined expect(typeof upsertStructured?.result?.memory?.id).toBe('string') + expect(upsertStructured?.result?.memory?.source_uris).toEqual([ + 'https://docs.npmjs.com/cli/v11/commands/npm-install', + 'https://github.com/kentcdodds/kody/blob/main/AGENTS.md', + ]) + + const memoryId = upsertStructured?.result?.memory?.id + if (!memoryId) { + throw new Error('missing memoryId') + } + + const getResult = await mcpClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.meta_memory_get({ + memory_id: ${JSON.stringify(memoryId)}, + }) + }`, + }, + }) + const getStructured = (getResult as CallToolResult).structuredContent as + | { result?: { source_uris?: Array } } + | undefined + expect(getStructured?.result?.source_uris).toEqual([ + 'https://docs.npmjs.com/cli/v11/commands/npm-install', + 'https://github.com/kentcdodds/kody/blob/main/AGENTS.md', + ]) + + const memoryCapabilitySearchResult = await mcpClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.meta_memory_search({ + query: 'npm over pnpm', + limit: 3, + }) + }`, + }, + }) + const memoryCapabilitySearchStructured = ( + memoryCapabilitySearchResult as CallToolResult + ).structuredContent as + | { + result?: { + matches?: Array<{ source_uris?: Array }> + } + } + | undefined + expect( + memoryCapabilitySearchStructured?.result?.matches?.[0]?.source_uris, + ).toEqual([ + 'https://docs.npmjs.com/cli/v11/commands/npm-install', + 'https://github.com/kentcdodds/kody/blob/main/AGENTS.md', + ]) const query = 'npm over pnpm' const memorySearchResult = await mcpClient.client.callTool({ diff --git a/packages/worker/src/mcp/memory/memory-search.ts b/packages/worker/src/mcp/memory/memory-search.ts index 6059783319..953c624feb 100644 --- a/packages/worker/src/mcp/memory/memory-search.ts +++ b/packages/worker/src/mcp/memory/memory-search.ts @@ -107,6 +107,7 @@ export async function searchMemories(input: { summary: row.summary, details: row.details, tags: parseTags(row.tags_json), + sourceUris: parseJsonStringArray(row.source_uris_json), dedupeKey: row.dedupe_key, createdAt: row.created_at, updatedAt: row.updated_at, @@ -122,6 +123,10 @@ export async function searchMemories(input: { } function parseTags(raw: string): Array { + return parseJsonStringArray(raw) +} + +function parseJsonStringArray(raw: string): Array { try { const parsed = JSON.parse(raw) as unknown if (!Array.isArray(parsed)) return [] diff --git a/packages/worker/src/mcp/memory/repo.ts b/packages/worker/src/mcp/memory/repo.ts index 860ce2fb6d..97085feecd 100644 --- a/packages/worker/src/mcp/memory/repo.ts +++ b/packages/worker/src/mcp/memory/repo.ts @@ -13,6 +13,7 @@ function mapMemoryRow(row: Record): McpMemoryRow { summary: String(row['summary']), details: String(row['details'] ?? ''), tags_json: String(row['tags_json'] ?? '[]'), + source_uris_json: String(row['source_uris_json'] ?? '[]'), dedupe_key: row['dedupe_key'] == null ? null : String(row['dedupe_key']), created_at: String(row['created_at']), updated_at: String(row['updated_at']), @@ -47,8 +48,8 @@ export async function insertMemory( .prepare( `INSERT INTO mcp_memories ( id, user_id, category, status, subject, summary, details, tags_json, - dedupe_key, created_at, updated_at, last_accessed_at, deleted_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + source_uris_json, dedupe_key, created_at, updated_at, last_accessed_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( row.id, @@ -59,6 +60,7 @@ export async function insertMemory( row.summary, row.details, row.tags_json, + row.source_uris_json, row.dedupe_key, row.created_at ?? now, row.updated_at ?? now, @@ -76,7 +78,7 @@ export async function getMemoryById( const row = await db .prepare( `SELECT id, user_id, category, status, subject, summary, details, tags_json, - dedupe_key, created_at, updated_at, last_accessed_at, deleted_at + source_uris_json, dedupe_key, created_at, updated_at, last_accessed_at, deleted_at FROM mcp_memories WHERE user_id = ? AND id = ? LIMIT 1`, @@ -97,6 +99,7 @@ export async function updateMemory( summary: string details: string tags_json: string + source_uris_json: string dedupe_key: string | null last_accessed_at: string | null deleted_at: string | null @@ -111,6 +114,7 @@ export async function updateMemory( summary = ?, details = ?, tags_json = ?, + source_uris_json = ?, dedupe_key = ?, last_accessed_at = ?, deleted_at = ?, @@ -124,6 +128,7 @@ export async function updateMemory( fields.summary, fields.details, fields.tags_json, + fields.source_uris_json, fields.dedupe_key, fields.last_accessed_at, fields.deleted_at, @@ -164,7 +169,7 @@ export async function listMemoriesByUserId( const { results } = await db .prepare( `SELECT id, user_id, category, status, subject, summary, details, tags_json, - dedupe_key, created_at, updated_at, last_accessed_at, deleted_at + source_uris_json, dedupe_key, created_at, updated_at, last_accessed_at, deleted_at FROM mcp_memories WHERE user_id = ? ${statusClause} @@ -182,7 +187,7 @@ export async function listAllMemories(input: { const { results } = await input.db .prepare( `SELECT id, user_id, category, status, subject, summary, details, tags_json, - dedupe_key, created_at, updated_at, last_accessed_at, deleted_at + source_uris_json, dedupe_key, created_at, updated_at, last_accessed_at, deleted_at FROM mcp_memories ORDER BY updated_at DESC`, ) diff --git a/packages/worker/src/mcp/memory/service.node.test.ts b/packages/worker/src/mcp/memory/service.node.test.ts index af68604bee..f2831bad88 100644 --- a/packages/worker/src/mcp/memory/service.node.test.ts +++ b/packages/worker/src/mcp/memory/service.node.test.ts @@ -92,6 +92,7 @@ function createMemoryTestDb() { summary, details, tagsJson, + sourceUrisJson, dedupeKey, createdAt, updatedAt, @@ -107,6 +108,7 @@ function createMemoryTestDb() { summary: String(summary), details: String(details ?? ''), tags_json: String(tagsJson ?? '[]'), + source_uris_json: String(sourceUrisJson ?? '[]'), dedupe_key: dedupeKey == null ? null : String(dedupeKey), created_at: String(createdAt), updated_at: String(updatedAt), @@ -125,6 +127,7 @@ function createMemoryTestDb() { summary, details, tagsJson, + sourceUrisJson, dedupeKey, lastAccessedAt, deletedAt, @@ -144,6 +147,7 @@ function createMemoryTestDb() { summary: String(summary), details: String(details ?? ''), tags_json: String(tagsJson ?? '[]'), + source_uris_json: String(sourceUrisJson ?? '[]'), dedupe_key: dedupeKey == null ? null : String(dedupeKey), last_accessed_at: lastAccessedAt == null ? null : String(lastAccessedAt), @@ -252,11 +256,15 @@ test('memory service upserts, verifies, and soft deletes', async () => { details: 'Applies to code editors and dashboards.', category: 'preference', tags: ['theme', 'dark-mode'], + sourceUris: ['https://docs.example.com/preferences/editor-theme'], verificationReference: 'verify-1', }) expect(created.mode).toBe('created') expect(created.memory.subject).toBe('Preferred editor theme') + expect(created.memory.sourceUris).toEqual([ + 'https://docs.example.com/preferences/editor-theme', + ]) const verify = await verifyMemoryCandidate({ env: runtimeEnv, @@ -266,11 +274,18 @@ test('memory service upserts, verifies, and soft deletes', async () => { summary: 'User likes dark mode in editing interfaces.', category: 'preference', tags: ['theme'], + sourceUris: ['https://docs.example.com/preferences/editor-theme'], }, }) expect(verify.relatedMemories).toHaveLength(1) expect(verify.relatedMemories[0]?.memory.id).toBe(created.memory.id) + expect(verify.candidate.source_uris).toEqual([ + 'https://docs.example.com/preferences/editor-theme', + ]) + expect(verify.relatedMemories[0]?.memory.sourceUris).toEqual([ + 'https://docs.example.com/preferences/editor-theme', + ]) const updated = await upsertMemory({ env: runtimeEnv, @@ -280,11 +295,19 @@ test('memory service upserts, verifies, and soft deletes', async () => { summary: 'User prefers dark mode everywhere.', category: 'preference', tags: ['theme', 'dark-mode'], + sourceUris: [ + 'https://docs.example.com/preferences/editor-theme', + 'https://github.com/kentcdodds/kody/blob/main/docs/use/memory.md', + ], verificationReference: 'verify-2', }) expect(updated.mode).toBe('updated') expect(updated.memory.summary).toBe('User prefers dark mode everywhere.') + expect(updated.memory.sourceUris).toEqual([ + 'https://docs.example.com/preferences/editor-theme', + 'https://github.com/kentcdodds/kody/blob/main/docs/use/memory.md', + ]) const deleted = await deleteMemory({ env: runtimeEnv, @@ -301,6 +324,10 @@ test('memory service upserts, verifies, and soft deletes', async () => { memoryId: created.memory.id, }) expect(loaded?.status).toBe('deleted') + expect(loaded?.sourceUris).toEqual([ + 'https://docs.example.com/preferences/editor-theme', + 'https://github.com/kentcdodds/kody/blob/main/docs/use/memory.md', + ]) }) test('memory surfacing suppresses repeated memories per conversation', async () => { @@ -346,3 +373,46 @@ test('memory surfacing suppresses repeated memories per conversation', async () expect(search.matches).toHaveLength(0) expect(search.suppressedCount).toBeGreaterThanOrEqual(1) }) + +test('memory service rejects invalid source uris and tolerates missing stored values', async () => { + const testDb = createMemoryTestDb() + const runtimeEnv = env(testDb.db) + + await expect( + upsertMemory({ + env: runtimeEnv, + userId: 'user-123', + subject: 'Invalid source URIs', + summary: 'This write should fail validation.', + sourceUris: ['not-a-url'], + verificationReference: 'verify-4', + }), + ).rejects.toThrow('Memory source_uris entries must be valid URLs.') + + testDb.memories.set( + 'legacy-memory', + { + id: 'legacy-memory', + user_id: 'user-123', + category: 'profile', + status: 'active', + subject: 'Legacy memory', + summary: 'Stored before source URIs existed.', + details: '', + tags_json: '["legacy"]', + dedupe_key: null, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + last_accessed_at: null, + deleted_at: null, + } as unknown as McpMemoryRow, + ) + + const loaded = await getMemory({ + env: { APP_DB: testDb.db }, + userId: 'user-123', + memoryId: 'legacy-memory', + }) + + expect(loaded?.sourceUris).toEqual([]) +}) diff --git a/packages/worker/src/mcp/memory/service.ts b/packages/worker/src/mcp/memory/service.ts index 4f8cba23a0..2cf789e972 100644 --- a/packages/worker/src/mcp/memory/service.ts +++ b/packages/worker/src/mcp/memory/service.ts @@ -26,6 +26,8 @@ const maxCategoryLength = 80 const maxDedupeKeyLength = 160 const maxTagLength = 80 const maxTagCount = 16 +const maxSourceUriLength = 2_048 +const maxSourceUriCount = 12 const defaultSuppressionTtlMs = 30 * 24 * 60 * 60 * 1_000 function logMemoryVectorSyncError(input: { @@ -61,6 +63,7 @@ type MemoryUpsertInput = MemoryOwnerContext & { summary: string details?: string | null tags?: Array | null + sourceUris?: Array | null dedupeKey?: string | null status?: 'active' | 'archived' verificationReference?: string | null @@ -95,6 +98,7 @@ type MemoryVerifyInput = MemoryOwnerContext & { details?: string | null category?: string | null tags?: Array | null + sourceUris?: Array | null dedupeKey?: string | null } limit?: number @@ -132,6 +136,7 @@ export async function upsertMemory(input: MemoryUpsertInput): Promise<{ summary: normalized.summary, details: normalized.details, tags_json: JSON.stringify(normalized.tags), + source_uris_json: JSON.stringify(normalized.sourceUris), dedupe_key: normalized.dedupeKey, updated_at: now, deleted_at: null, @@ -145,6 +150,7 @@ export async function upsertMemory(input: MemoryUpsertInput): Promise<{ summary: normalized.summary, details: normalized.details, tags_json: JSON.stringify(normalized.tags), + source_uris_json: JSON.stringify(normalized.sourceUris), dedupe_key: normalized.dedupeKey, created_at: now, updated_at: now, @@ -160,6 +166,7 @@ export async function upsertMemory(input: MemoryUpsertInput): Promise<{ summary: row.summary, details: row.details, tags_json: row.tags_json, + source_uris_json: row.source_uris_json, dedupe_key: row.dedupe_key, last_accessed_at: row.last_accessed_at, deleted_at: row.deleted_at, @@ -252,6 +259,7 @@ export async function deleteMemory( summary: existing.summary, details: existing.details, tags_json: existing.tags_json, + source_uris_json: existing.source_uris_json, dedupe_key: existing.dedupe_key, last_accessed_at: existing.last_accessed_at, deleted_at: now, @@ -355,6 +363,7 @@ export async function verifyMemoryCandidate(input: MemoryVerifyInput): Promise<{ details: string category: string | null tags: Array + source_uris: Array dedupe_key: string | null } relatedMemories: Array<{ memory: MemoryRecord; score: number }> @@ -379,6 +388,7 @@ export async function verifyMemoryCandidate(input: MemoryVerifyInput): Promise<{ details: candidate.details, category: candidate.category, tags: candidate.tags, + source_uris: candidate.sourceUris, dedupe_key: candidate.dedupeKey, }, relatedMemories: result.matches.map((match) => ({ @@ -444,6 +454,7 @@ function normalizeMemoryPayload(input: { summary: string details?: string | null tags?: Array | null + sourceUris?: Array | null dedupeKey?: string | null }) { const subject = normalizeRequiredString( @@ -462,6 +473,7 @@ function normalizeMemoryPayload(input: { summary, details: normalizeOptionalString(input.details, maxDetailsLength) ?? '', tags: normalizeTags(input.tags ?? []), + sourceUris: normalizeSourceUris(input.sourceUris ?? []), dedupeKey: normalizeOptionalString(input.dedupeKey, maxDedupeKeyLength), } } @@ -518,6 +530,25 @@ function normalizeTags(tags: Array) { ).slice(0, maxTagCount) } +function normalizeSourceUris(sourceUris: Array) { + return Array.from( + new Set( + sourceUris.map((sourceUri) => { + const normalized = sourceUri.trim() + if (!normalized || normalized.length > maxSourceUriLength) { + throw new Error('Memory source_uris entries must be valid URLs.') + } + try { + new URL(normalized) + } catch { + throw new Error('Memory source_uris entries must be valid URLs.') + } + return normalized + }), + ), + ).slice(0, maxSourceUriCount) +} + function normalizeLimit(value: number | undefined | null) { if (!Number.isFinite(value)) return 5 return Math.max(1, Math.min(20, Math.trunc(value!))) @@ -550,7 +581,7 @@ async function filterSuppressedMatches(input: { } } -function parseTags(raw: string) { +function parseJsonStringArray(raw: string) { try { const parsed = JSON.parse(raw) as unknown if (!Array.isArray(parsed)) return [] @@ -560,6 +591,14 @@ function parseTags(raw: string) { } } +function parseTags(raw: string) { + return parseJsonStringArray(raw) +} + +function parseSourceUris(raw: string) { + return parseJsonStringArray(raw) +} + function mapSearchMatchToMemoryRecord(match: MemorySearchMatch): MemoryRecord { return { id: match.id, @@ -569,6 +608,7 @@ function mapSearchMatchToMemoryRecord(match: MemorySearchMatch): MemoryRecord { summary: match.summary, details: match.details, tags: match.tags, + sourceUris: match.sourceUris, dedupeKey: match.dedupeKey, createdAt: match.createdAt, updatedAt: match.updatedAt, @@ -586,6 +626,7 @@ function toMemoryRecord(row: McpMemoryRow): MemoryRecord { summary: row.summary, details: row.details, tags: parseTags(row.tags_json), + sourceUris: parseSourceUris(row.source_uris_json), dedupeKey: row.dedupe_key, createdAt: row.created_at, updatedAt: row.updated_at, diff --git a/packages/worker/src/mcp/memory/types.ts b/packages/worker/src/mcp/memory/types.ts index 641ceedb62..6f74ae995f 100644 --- a/packages/worker/src/mcp/memory/types.ts +++ b/packages/worker/src/mcp/memory/types.ts @@ -13,6 +13,7 @@ export type MemoryRow = { summary: string details: string tags_json: string + source_uris_json: string dedupe_key: string | null created_at: string updated_at: string @@ -42,6 +43,7 @@ export type MemoryMetadata = { summary: string details: string tags: Array + sourceUris: Array dedupeKey: string | null createdAt: string updatedAt: string diff --git a/packages/worker/src/mcp/tools/memory-tool-context.ts b/packages/worker/src/mcp/tools/memory-tool-context.ts index 11431a64b5..da6b6f3711 100644 --- a/packages/worker/src/mcp/tools/memory-tool-context.ts +++ b/packages/worker/src/mcp/tools/memory-tool-context.ts @@ -12,6 +12,7 @@ export type MemoryToolSummary = { summary: string details: string tags: Array + sourceUris: Array updatedAt: string }> suppressedCount: number @@ -148,6 +149,11 @@ function formatRelevantMemoriesMarkdown(memorySummary: MemoryToolSummary) { ` - Tags: ${memory.tags.map((tag) => `\`${tag}\``).join(', ')}`, ) } + if (memory.sourceUris.length > 0) { + lines.push( + ` - Sources: ${memory.sourceUris.map((sourceUri) => `\`${sourceUri}\``).join(', ')}`, + ) + } lines.push(` - Updated: \`${memory.updatedAt}\``) } if (memorySummary.suppressedCount > 0) { @@ -168,6 +174,7 @@ function toMemoryToolSummaryItem(memory: MemoryRecord) { summary: memory.summary, details: memory.details, tags: memory.tags, + sourceUris: memory.sourceUris, updatedAt: memory.updatedAt, } }