diff --git a/CLAUDE.md b/CLAUDE.md index 02d400e..cb01347 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,7 +70,7 @@ Auth uses timing-safe token comparison. Origin allowlisting supports `"*"` wildc | `remember_context` | Single write tool for chat and admin clients: save durable memory with optional topic, draft flag or explicit branch state, image input, and artifact refs | | `search_context` | Query → embedding → Firestore vector similarity search (cosine, top-K) with metadata filters | | `fetch_context` | Retrieve one stored memory by document ID after search | -| `deprecate_context` | Soft-delete: mark document as deprecated, record superseding document ID | +| `deprecate_context` | Soft-delete: mark document as deprecated, record superseding document ID, and record supersession_reason ("changed" sets valid_until, "corrected" does not) | | `consolidate_context` | Merge N related memories into one canonical active memory via LLM; deprecates all sources with `superseded_by` pointing to the merged result. Defaults to WIP queue for a topic; accepts explicit `source_ids` for targeted consolidation | ### Key Source Files (all under `functions/src/`) @@ -95,11 +95,11 @@ Auth uses timing-safe token comparison. Origin allowlisting supports `"*"` wildc **remember_context**: Chat/admin input → server defaults/inference for metadata and lifecycle state → Gemini multimodal normalization (if image) → canonical `content` + internal `retrieval_text` → Gemini embedding (deployment currently pinned to 768-dim) → Firestore document with vector + metadata -**search_context**: Query text → Gemini embedding → Firestore `findNearest()` (cosine distance, top-K) with required `branch_state` and optional topic filter +**search_context**: Query text → Gemini embedding → Firestore `findNearest()` (cosine distance, top-K) with required `branch_state` and optional topic filter; optional `valid_at` post-filters results in the service layer by temporal validity window **fetch_context**: Document ID → direct Firestore read of one stored memory -**deprecate_context**: Document ID + superseding ID → update `branch_state` to "deprecated", set `superseded_by` +**deprecate_context**: Document ID + superseding ID → update `branch_state` to "deprecated", set `superseded_by`; `supersession_reason` ("changed" default sets `valid_until` to now, "corrected" does not) and optional `initiator` are recorded on the deprecated document **consolidate_context**: Topic (default WIP queue) or explicit `source_ids` → gather source memories → Gemini merge of N contents into one → store merged result as `active` → deprecate every source with `superseded_by` pointing to the merged ID diff --git a/functions/src/mcpServer.ts b/functions/src/mcpServer.ts index 7006a7f..c65c647 100644 --- a/functions/src/mcpServer.ts +++ b/functions/src/mcpServer.ts @@ -18,6 +18,7 @@ import { } from "./service.js"; import { BRANCH_STATES, + SUPERSESSION_REASONS, type BranchState, type McpToolName } from "./types.js"; @@ -281,7 +282,11 @@ export function createMetaCortexMcpServer( .min(1) .max(20) .optional() - .describe("Max results to return. Defaults to 5.") + .describe("Max results to return. Defaults to 5."), + valid_at: z + .number() + .optional() + .describe("Optional epoch-ms timestamp. When provided, only returns memories valid at that point in time (valid_from <= valid_at < valid_until, excluding corrected records).") } }, async args => { @@ -293,7 +298,8 @@ export function createMetaCortexMcpServer( query_length: args.query.trim().length, filter_topic: normalizedFilterTopic, filter_state: requestedFilterState, - limit: args.limit + limit: args.limit, + valid_at: args.valid_at }; const result = await observeToolCall( "search_context", @@ -424,13 +430,23 @@ export function createMetaCortexMcpServer( superseding_id: z .string() .min(1) - .describe("The id of the new memory that replaces it.") + .describe("The id of the new memory that replaces it."), + supersession_reason: z + .enum(SUPERSESSION_REASONS) + .optional() + .describe("Why the memory is being superseded. 'changed' (default) means the old fact was true of its era, records valid_until. 'corrected' means the old record was never true, excluded from valid-time results but kept for audit."), + initiator: z + .enum(["user", "agent"]) + .optional() + .describe("Who initiated this deprecation, for audit purposes.") } }, async args => { const requestSummary = { id: args.id, - superseding_id: args.superseding_id + superseding_id: args.superseding_id, + supersession_reason: args.supersession_reason, + initiator: args.initiator }; const result = await observeToolCall( "deprecate_context", @@ -439,7 +455,8 @@ export function createMetaCortexMcpServer( record => ({ id: record.id, superseding_id: record.superseding_id, - previous_state: record.previous_state + previous_state: record.previous_state, + supersession_reason: record.supersession_reason }) ); diff --git a/functions/src/memoryRepository.ts b/functions/src/memoryRepository.ts index 3c8b75e..6f84191 100644 --- a/functions/src/memoryRepository.ts +++ b/functions/src/memoryRepository.ts @@ -5,7 +5,8 @@ import type { BranchState, MemoryDocument, MemoryMedia, - MemoryMetadata + MemoryMetadata, + SupersessionReason } from "./types.js"; export interface StoreMemoryParams { @@ -28,7 +29,11 @@ export interface MemoryRepository { store(params: StoreMemoryParams): Promise<{ document: MemoryDocument; created: boolean }>; search(params: SearchMemoryParams): Promise; get(documentId: string): Promise; - deprecate(documentId: string, supersedingDocumentId: string): Promise<{ previousState: BranchState }>; + deprecate( + documentId: string, + supersedingDocumentId: string, + options?: { supersessionReason?: SupersessionReason; initiator?: "user" | "agent" } + ): Promise<{ previousState: BranchState }>; getConsolidationQueue(moduleName?: string): Promise; } @@ -162,7 +167,8 @@ export class FirestoreMemoryRepository implements MemoryRepository { async deprecate( documentId: string, - supersedingDocumentId: string + supersedingDocumentId: string, + options?: { supersessionReason?: SupersessionReason; initiator?: "user" | "agent" } ): Promise<{ previousState: BranchState }> { const docRef = this.firestore .collection(this.collectionName) @@ -177,11 +183,25 @@ export class FirestoreMemoryRepository implements MemoryRepository { const data = snapshot.data() as FirestoreMemoryDocument; const previousState = data.metadata.branch_state; - await docRef.update({ + const now = Date.now(); + const resolvedReason = options?.supersessionReason ?? "changed"; + + const updates: Record = { "metadata.branch_state": "deprecated", "metadata.superseded_by": supersedingDocumentId, - "metadata.updated_at": Date.now() - }); + "metadata.updated_at": now, + "metadata.supersession_reason": resolvedReason + }; + + if (resolvedReason === "changed") { + updates["metadata.valid_until"] = now; + } + + if (options?.initiator) { + updates["metadata.initiator"] = options.initiator; + } + + await docRef.update(updates); return { previousState }; } diff --git a/functions/src/service.ts b/functions/src/service.ts index 4622902..5e9bcbd 100644 --- a/functions/src/service.ts +++ b/functions/src/service.ts @@ -18,11 +18,13 @@ import type { FetchContextInput, FetchContextResult, MemoryDocument, + MemoryMetadata, RememberContextInput, SearchContextInput, SearchContextResult, StoreContextInput, - StoreContextResult + StoreContextResult, + SupersessionReason } from "./types.js"; import type { MemoryRepository } from "./memoryRepository.js"; @@ -62,6 +64,12 @@ export class MetaCortexService { modality: preparedContent.modality, ...(normalizedArtifactRefs.length > 0 ? { artifact_refs: normalizedArtifactRefs } + : {}), + ...(typeof input.valid_from === "number" + ? { valid_from: input.valid_from } + : {}), + ...(typeof input.valid_until === "number" + ? { valid_until: input.valid_until } : {}) } as const; @@ -101,7 +109,9 @@ export class MetaCortexService { branch_state: branchState, artifact_refs: input.artifact_refs, image_base64: normalizeOptionalText(input.image_base64), - image_mime_type: normalizeOptionalText(input.image_mime_type) + image_mime_type: normalizeOptionalText(input.image_mime_type), + valid_from: input.valid_from, + valid_until: input.valid_until }); } @@ -113,13 +123,17 @@ export class MetaCortexService { text: normalizedQuery, taskType: "RETRIEVAL_QUERY" }); - const matches = await this.repository.search({ + let matches = await this.repository.search({ queryVector, limit: input.limit ?? this.config.topK, filterModule: filterTopic, filterState }); + if (typeof input.valid_at === "number") { + matches = matches.filter(match => matchesValidAt(match.metadata, input.valid_at!)); + } + return { matches, appliedFilters: { @@ -141,15 +155,21 @@ export class MetaCortexService { } async deprecateContext(input: DeprecateContextInput): Promise { + const resolvedReason: SupersessionReason = input.supersession_reason ?? "changed"; const { previousState } = await this.repository.deprecate( input.id, - input.superseding_id + input.superseding_id, + { + supersessionReason: resolvedReason, + initiator: input.initiator + } ); return { id: input.id, superseding_id: input.superseding_id, - previous_state: previousState + previous_state: previousState, + supersession_reason: resolvedReason }; } @@ -300,7 +320,8 @@ export function buildDeprecatePayload( item: { id: result.id, branch_state: "deprecated", - superseded_by: result.superseding_id + superseded_by: result.superseding_id, + supersession_reason: result.supersession_reason }, previous_state: result.previous_state }; @@ -406,3 +427,11 @@ function normalizePublicModality(value: string): "text" | "image" | "mixed" { return "text"; } + +function matchesValidAt(metadata: MemoryMetadata, validAt: number): boolean { + const validFromOk = typeof metadata.valid_from === "undefined" || metadata.valid_from <= validAt; + const validUntilOk = typeof metadata.valid_until === "undefined" || metadata.valid_until > validAt; + const reasonOk = metadata.supersession_reason !== "corrected"; + + return validFromOk && validUntilOk && reasonOk; +} diff --git a/functions/src/types.ts b/functions/src/types.ts index 359f0be..03406bc 100644 --- a/functions/src/types.ts +++ b/functions/src/types.ts @@ -13,6 +13,8 @@ export const BRANCH_STATES = [ "wip" ] as const; +export const SUPERSESSION_REASONS = ["changed", "corrected"] as const; + export const MEMORY_MODALITIES = [ "text", "image", @@ -20,6 +22,7 @@ export const MEMORY_MODALITIES = [ ] as const; export type BranchState = (typeof BRANCH_STATES)[number]; +export type SupersessionReason = (typeof SUPERSESSION_REASONS)[number]; export type MemoryModality = (typeof MEMORY_MODALITIES)[number]; export type McpToolName = (typeof MCP_TOOL_NAMES)[number]; @@ -36,6 +39,10 @@ export interface MemoryMetadata { modality: MemoryModality; artifact_refs?: string[]; superseded_by?: string; + valid_from?: number; + valid_until?: number; + supersession_reason?: SupersessionReason; + initiator?: "user" | "agent"; } export interface MemoryDocument { @@ -54,6 +61,8 @@ export interface StoreContextInput { artifact_refs?: string[]; image_base64?: string; image_mime_type?: string; + valid_from?: number; + valid_until?: number; } export interface SearchContextInput { @@ -61,6 +70,7 @@ export interface SearchContextInput { filter_topic?: string; filter_state?: BranchState; limit?: number; + valid_at?: number; } export interface RememberContextInput { @@ -71,6 +81,8 @@ export interface RememberContextInput { artifact_refs?: string[]; image_base64?: string; image_mime_type?: string; + valid_from?: number; + valid_until?: number; } export interface StoreContextResult { @@ -102,12 +114,15 @@ export interface FetchContextResult { export interface DeprecateContextInput { id: string; superseding_id: string; + supersession_reason?: SupersessionReason; + initiator?: "user" | "agent"; } export interface DeprecateContextResult { id: string; superseding_id: string; previous_state: BranchState; + supersession_reason: SupersessionReason; } export interface ConsolidationQueueInput { diff --git a/functions/test/mcp.integration.test.ts b/functions/test/mcp.integration.test.ts index c863678..64be376 100644 --- a/functions/test/mcp.integration.test.ts +++ b/functions/test/mcp.integration.test.ts @@ -218,6 +218,157 @@ describe("MCP integration", () => { ); }); + it("supports temporal validity fields on deprecate_context and search_context", async () => { + const runtime = createTestRuntime(); + const baseUrl = await startServer( + createMetaCortexApp({ + getConfig: () => runtime.config, + getObserver: () => runtime.observer, + getRuntime: () => runtime + }), + cleanup + ); + + const client = new Client({ + name: "test-client", + version: "1.0.0" + }); + const transport = new StreamableHTTPClientTransport(new URL(`${baseUrl}/mcp`), { + requestInit: { + headers: { + [authorizationHeaderName]: bearerHeader("test") + } + } + }); + + cleanup.push(async () => { + await client.close(); + }); + + await client.connect(transport); + + // Call remember_context to create a memory + const rememberResult = await client.callTool({ + name: "remember_context", + arguments: { + content: "We are using Ktor for networking.", + topic: "kmp-networking" + } + }); + + const rememberPayload = parseJsonTextContent(rememberResult) as any; + expect(rememberPayload).toMatchObject({ + write_status: "created" + }); + const firstId = rememberPayload.item.id; + + // Call remember_context again to create a second "replacement" memory + const replacementResult = await client.callTool({ + name: "remember_context", + arguments: { + content: "We standardized on Ktor 3 for shared networking.", + topic: "kmp-networking" + } + }); + + const replacementPayload = parseJsonTextContent(replacementResult) as any; + expect(replacementPayload).toMatchObject({ + write_status: "created" + }); + const secondId = replacementPayload.item.id; + + // Call deprecate_context with corrected reason and initiator user + const deprecateResult = await client.callTool({ + name: "deprecate_context", + arguments: { + id: firstId, + superseding_id: secondId, + supersession_reason: "corrected", + initiator: "user" + } + }); + + expect(parseJsonTextContent(deprecateResult)).toMatchObject({ + item: { + id: firstId, + branch_state: "deprecated", + superseded_by: secondId, + supersession_reason: "corrected" + }, + previous_state: "active" + }); + + // Call search_context with valid_at: Date.now() - firstId must not be in matches + const searchResult = await client.callTool({ + name: "search_context", + arguments: { + query: "networking", + filter_state: "deprecated", + valid_at: Date.now() + } + }); + + const searchPayload = parseJsonTextContent(searchResult) as any; + expect(searchPayload.matches.map((m: any) => m.id)).not.toContain(firstId); + + // Additionally call remember_context to create a third memory + const thirdResult = await client.callTool({ + name: "remember_context", + arguments: { + content: "We use Ktor for Android and iOS networking.", + topic: "kmp-networking" + } + }); + + const thirdPayload = parseJsonTextContent(thirdResult) as any; + expect(thirdPayload).toMatchObject({ + write_status: "created" + }); + const thirdId = thirdPayload.item.id; + + // Capture beforeDeprecation timestamp + const beforeDeprecation = Date.now(); + + // Call deprecate_context with supersession_reason "changed" (or omit, but we'll specify "changed" as requested) + const deprecateThirdResult = await client.callTool({ + name: "deprecate_context", + arguments: { + id: thirdId, + superseding_id: secondId, + supersession_reason: "changed" + } + }); + + const deprecateThirdPayload = parseJsonTextContent(deprecateThirdResult) as any; + expect(deprecateThirdPayload.item.supersession_reason).toBe("changed"); + + // Search with valid_at before the deprecation - thirdId IS included + const searchBeforeResult = await client.callTool({ + name: "search_context", + arguments: { + query: "networking", + filter_state: "deprecated", + valid_at: beforeDeprecation - 1000 + } + }); + + const searchBeforePayload = parseJsonTextContent(searchBeforeResult) as any; + expect(searchBeforePayload.matches.map((m: any) => m.id)).toContain(thirdId); + + // Search with valid_at after the deprecation - thirdId is NOT included + const searchAfterResult = await client.callTool({ + name: "search_context", + arguments: { + query: "networking", + filter_state: "deprecated", + valid_at: Date.now() + 1000 + } + }); + + const searchAfterPayload = parseJsonTextContent(searchAfterResult) as any; + expect(searchAfterPayload.matches.map((m: any) => m.id)).not.toContain(thirdId); + }); + it("enforces tool scoping on client-specific endpoints", async () => { const runtime = createTestRuntime({ clientProfiles: [ diff --git a/functions/test/service.test.ts b/functions/test/service.test.ts index 451f28b..17e9852 100644 --- a/functions/test/service.test.ts +++ b/functions/test/service.test.ts @@ -181,6 +181,157 @@ describe("MetaCortexService", () => { expect(result.previous_state).toBe("active"); }); + it("defaults supersession_reason to changed and sets valid_until on deprecate", async () => { + const { service, repository } = createService(); + const stored = await service.storeContext({ + content: + "We are using Ktor for the Android/iOS networking layer.", + module_name: "kmp-networking", + branch_state: "active" + }); + const replacement = await service.storeContext({ + content: + "We migrated from Ktor to OkHttp for the Android/iOS networking layer.", + module_name: "kmp-networking", + branch_state: "active" + }); + const result = await service.deprecateContext({ + id: stored.id, + superseding_id: replacement.id + }); + + expect(result.supersession_reason).toBe("changed"); + + const deprecatedDoc = await repository.get(stored.id); + expect(deprecatedDoc).not.toBeNull(); + expect(typeof deprecatedDoc?.metadata.valid_until).toBe("number"); + }); + + it("does not set valid_until when supersession_reason is corrected", async () => { + const { service, repository } = createService(); + const stored = await service.storeContext({ + content: + "We are using Ktor for the Android/iOS networking layer.", + module_name: "kmp-networking", + branch_state: "active" + }); + const replacement = await service.storeContext({ + content: + "We migrated from Ktor to OkHttp for the Android/iOS networking layer.", + module_name: "kmp-networking", + branch_state: "active" + }); + const result = await service.deprecateContext({ + id: stored.id, + superseding_id: replacement.id, + supersession_reason: "corrected" + }); + + expect(result.supersession_reason).toBe("corrected"); + + const deprecatedDoc = await repository.get(stored.id); + expect(deprecatedDoc).not.toBeNull(); + expect(deprecatedDoc?.metadata.valid_until).toBeUndefined(); + }); + + it("records initiator on deprecate when provided", async () => { + const { service, repository } = createService(); + const stored = await service.storeContext({ + content: + "We are using Ktor for the Android/iOS networking layer.", + module_name: "kmp-networking", + branch_state: "active" + }); + const replacement = await service.storeContext({ + content: + "We migrated from Ktor to OkHttp for the Android/iOS networking layer.", + module_name: "kmp-networking", + branch_state: "active" + }); + await service.deprecateContext({ + id: stored.id, + superseding_id: replacement.id, + initiator: "user" + }); + + const deprecatedDoc = await repository.get(stored.id); + expect(deprecatedDoc).not.toBeNull(); + expect(deprecatedDoc?.metadata.initiator).toBe("user"); + }); + + it("search with valid_at excludes documents outside their valid window", async () => { + const { service } = createService(); + const stored = await service.storeContext({ + content: "We are using Ktor for networking.", + module_name: "kmp-networking", + branch_state: "active", + valid_from: 1000, + valid_until: 2000 + }); + + const inside = await service.searchContext({ + query: "networking", + valid_at: 1500 + }); + expect(inside.matches.map(m => m.id)).toContain(stored.id); + + const after = await service.searchContext({ + query: "networking", + valid_at: 2500 + }); + expect(after.matches.map(m => m.id)).not.toContain(stored.id); + + const before = await service.searchContext({ + query: "networking", + valid_at: 500 + }); + expect(before.matches.map(m => m.id)).not.toContain(stored.id); + }); + + it("search with valid_at excludes corrected documents", async () => { + const { service } = createService(); + const stored = await service.storeContext({ + content: "We are using Ktor for networking.", + module_name: "kmp-networking", + branch_state: "active" + }); + const replacement = await service.storeContext({ + content: "We migrated to OkHttp.", + module_name: "kmp-networking", + branch_state: "active" + }); + + await service.deprecateContext({ + id: stored.id, + superseding_id: replacement.id, + supersession_reason: "corrected" + }); + + const result = await service.searchContext({ + query: "networking", + filter_state: "deprecated", + valid_at: 1500 + }); + + expect(result.matches.map(m => m.id)).not.toContain(stored.id); + }); + + it("search with valid_at includes documents with no temporal fields set", async () => { + const { service } = createService(); + const stored = await service.storeContext({ + content: "We are using Ktor for networking.", + module_name: "kmp-networking", + branch_state: "active" + }); + + const result = await service.searchContext({ + query: "networking", + valid_at: 1500 + }); + + expect(result.matches.map(m => m.id)).toContain(stored.id); + }); + it("fetches a stored document by id", async () => { const { service } = createService(); const stored = await service.storeContext({ diff --git a/functions/test/support/fakes.ts b/functions/test/support/fakes.ts index 98878c8..06736a7 100644 --- a/functions/test/support/fakes.ts +++ b/functions/test/support/fakes.ts @@ -18,7 +18,8 @@ import type { BranchState, MemoryDocument, MemoryMedia, - MemoryMetadata + MemoryMetadata, + SupersessionReason } from "../../src/types.js"; import { MCP_TOOL_NAMES } from "../../src/types.js"; import { @@ -189,7 +190,8 @@ export class InMemoryMemoryRepository implements MemoryRepository { async deprecate( documentId: string, - supersedingDocumentId: string + supersedingDocumentId: string, + options?: { supersessionReason?: SupersessionReason; initiator?: "user" | "agent" } ): Promise<{ previousState: BranchState }> { const record = this.records.find(r => r.id === documentId); @@ -198,11 +200,17 @@ export class InMemoryMemoryRepository implements MemoryRepository { } const previousState = record.metadata.branch_state; + const resolvedReason = options?.supersessionReason ?? "changed"; + const now = Date.now(); + record.metadata = { ...record.metadata, branch_state: "deprecated", superseded_by: supersedingDocumentId, - updated_at: Date.now() + updated_at: now, + supersession_reason: resolvedReason, + ...(resolvedReason === "changed" ? { valid_until: now } : {}), + ...(options?.initiator ? { initiator: options.initiator } : {}) }; return { previousState }; diff --git a/metacortexplan.md b/metacortexplan.md index 2287e87..0cca7c9 100644 --- a/metacortexplan.md +++ b/metacortexplan.md @@ -56,7 +56,7 @@ The first hardening release addressed Firestore collection scaling, payload opti * **Effort:** Medium. ### 2. Temporal Validity / Fact Versioning -* **Status:** Proposed +* **Status:** Implemented 2026-07-11 * **Goal:** Enable the agent to distinguish old facts from current ones beyond `branch_state`. * **Proposal:** Add optional `valid_from` and `valid_until` fields to stored memories. Search results can filter by temporal validity. Update deprecation to set `valid_until` automatically. @@ -99,7 +99,7 @@ The first hardening release addressed Firestore collection scaling, payload opti | MCP native | ❌ | ❌ | ❌ | ❌ | ✅ (thin) | ✅ (full) | | Client/tenant scoping | Basic | ❌ | ❌ | ❌ | Basic (Spaces) | ✅ (profiles) | | Context tiering | ❌ | ❌ | ❌ | ✅ (L0/L1/L2) | ❌ | 🔜 (proposed) | -| Temporal validity | ❌ | ❌ | ✅ (bi-temporal) | ❌ | ❌ | 🔜 (proposed) | +| Temporal validity | ❌ | ❌ | ✅ (bi-temporal) | ❌ | ❌ | ✅ (bi-temporal-lite) | | Graph relationships | ✅ (hybrid) | ❌ | ✅ (core) | ❌ | ❌ | ❌ | | Auto memory evolution | ✅ | ✅ (self-edit) | ✅ | ✅ | ✅ | ❌ | | Serverless-native | ❌ | ❌ | ❌ | ❌ | ✅ (CF Workers) | ✅ (Firebase) |