From 1d1bf7ad0abd44433bf9c4e3c7cdb32f857cb602 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Tue, 20 Oct 2026 11:09:30 +0800 Subject: [PATCH 1/7] fix: decouple device memory from account uid --- .../src/memory-l3-world-model.ts | 2 +- .../skill-writer/codex/tests/target.test.ts | 7 + .../outbound/skill-writer/hermes/target.ts | 2 +- .../skill-writer/memmy-runtime-config.ts | 2 +- .../templates/tests/memmy-resume-hook.test.ts | 2 +- .../workspace-bridge/runtime.test.ts | 8 +- .../skill-writer/workspace-bridge/runtime.ts | 4 +- .../src/infrastructure/memmy-config/index.ts | 4 +- .../tests/account-model-projection.test.ts | 2 +- App/backend/src/services/index.ts | 5 +- .../panel-task-identity.integration.test.ts | 12 +- App/memmy-agent/src/memmy-memory/config.ts | 6 +- .../agent-loop-integration.test.ts | 2 +- .../tests/memmy-memory/discovery.test.ts | 2 +- .../agent-source/integration/hermes/target.ts | 2 +- .../integration/memmy-runtime-config.ts | 2 +- .../integration/workspace-bridge/runtime.ts | 4 +- Memory/src/algorithm/plugin-algorithms.ts | 2 +- Memory/src/algorithm/trace-direct-skill.ts | 8 +- Memory/src/cli/config.ts | 6 +- Memory/src/cli/setup.ts | 2 +- Memory/src/config/index.ts | 4 +- Memory/src/contracts/memory-l3-world-model.ts | 2 +- Memory/src/server/http.ts | 1 + .../embedding/embedding-job-processor.ts | 4 +- .../evolution/evolution-job-processor.ts | 2 +- .../evolution/l3-world-model-pipeline.ts | 12 +- .../evolution/negative-experience-pipeline.ts | 2 +- .../src/service/evolution/policy-induction.ts | 14 +- .../src/service/evolution/reward-pipeline.ts | 2 +- .../src/service/evolution/skill-pipeline.ts | 5 +- Memory/src/service/evolution/span-pipeline.ts | 2 +- .../service/feedback/feedback-experience.ts | 6 +- .../service/import/import-job-processor.ts | 6 +- Memory/src/service/memory-service.ts | 91 +-- .../src/service/namespace/namespace-scope.ts | 6 +- .../service/namespace/workspace-identity.ts | 6 +- .../project-environment/profile-pipeline.ts | 7 +- .../project-environment-service.ts | 3 +- .../src/service/project-environment/types.ts | 2 +- Memory/src/service/read-model/episode.ts | 10 +- .../read-model/l3-world-model-context.ts | 6 +- Memory/src/service/read-model/panel-read.ts | 53 +- Memory/src/service/read-model/skill.ts | 8 +- .../cross-account-candidate-dedup.ts | 146 ++++ .../retrieval/indexed-candidate-pool.ts | 6 +- .../service/retrieval/retrieval-service.ts | 9 +- .../service/session/session-turn-service.ts | 83 +- .../service/trials/skill-trial-resolver.ts | 2 +- Memory/src/service/user-memory/user-memory.ts | 2 +- .../work-memory/work-memory-pipeline.ts | 34 +- Memory/src/service/worker/job-handlers.ts | 2 +- Memory/src/storage/db.ts | 2 + Memory/src/storage/device-scope.ts | 141 ++++ Memory/src/storage/repositories.ts | 762 +++++++++++------- Memory/src/storage/schema.ts | 100 ++- Memory/src/types.ts | 10 +- Memory/tests/cli-command-map.test.ts | 4 +- Memory/tests/cli-setup.test.ts | 2 - Memory/tests/codex-native-hook.test.ts | 2 +- Memory/tests/config.test.ts | 8 +- .../tests/contract/source-turn-http.test.ts | 4 +- Memory/tests/fixtures/schema-v9.sql | 641 +++++++++++++++ Memory/tests/native-scanner-lifecycle.test.ts | 4 +- .../repository/device-memory-scope.test.ts | 209 +++++ .../repository/memory-retrieval-index.test.ts | 2 +- .../service/evolution/l3-world-model.test.ts | 4 +- .../evolution/negative-experience.test.ts | 4 +- .../service/evolution/orchestration.test.ts | 20 +- .../evolution/policy-induction.test.ts | 42 +- .../service/evolution/skill-lifecycle.test.ts | 52 +- .../tests/service/feedback/experience.test.ts | 10 +- .../import/memory-capture-dedup.test.ts | 8 +- .../memory-token-budget-review.test.ts | 8 +- .../read-model/l3-world-model-context.test.ts | 8 +- .../service/read-model/panel-read.test.ts | 2 +- .../service/session/episode-relation.test.ts | 16 +- .../service/session/session-lifecycle.test.ts | 16 +- .../session/source-turn-capture.test.ts | 82 +- .../service/user-memory/user-memory.test.ts | 6 +- .../work-memory/work-memory-pipeline.test.ts | 19 +- .../tests/storage/token-usage-outbox.test.ts | 6 +- 82 files changed, 2137 insertions(+), 681 deletions(-) create mode 100644 Memory/src/service/retrieval/cross-account-candidate-dedup.ts create mode 100644 Memory/src/storage/device-scope.ts create mode 100644 Memory/tests/fixtures/schema-v9.sql create mode 100644 Memory/tests/repository/device-memory-scope.test.ts diff --git a/App/backend/local-api-contracts/src/memory-l3-world-model.ts b/App/backend/local-api-contracts/src/memory-l3-world-model.ts index 269d5f023..4d6d85f07 100644 --- a/App/backend/local-api-contracts/src/memory-l3-world-model.ts +++ b/App/backend/local-api-contracts/src/memory-l3-world-model.ts @@ -28,7 +28,7 @@ const L3WorldModelRuntimeNamespaceShape = { workspaceId: OptionalNonEmptyStringSchema, workspacePath: OptionalNonEmptyStringSchema, sessionKey: OptionalNonEmptyStringSchema, - userId: OptionalNonEmptyStringSchema, + userId: OptionalNonEmptyStringSchema.nullable(), tenantId: OptionalNonEmptyStringSchema } as const; diff --git a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts index 5a3e6855e..a734cc46d 100644 --- a/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/codex/tests/target.test.ts @@ -189,8 +189,10 @@ describe("codex skill target", () => { it("installs a UserPromptSubmit hook that blocks resume commands with top L1 candidates", async () => { const { rootDirectory, memmyConfigPath } = createFixture(); let requestBody: Record | undefined; + let requestCount = 0; let authorization = ""; const server = createServer(async (request: IncomingMessage, response: ServerResponse) => { + requestCount += 1; authorization = String(request.headers.authorization || ""); const url = new URL(request.url || "/", "http://127.0.0.1"); if (request.method === "POST" && url.pathname === "/api/v1/memory/search") { @@ -295,6 +297,11 @@ describe("codex skill target", () => { }; expect(hooksAfter.hooks?.UserPromptSubmit).toBeUndefined(); expect(hooksAfter.hooks?.Stop).toBeUndefined(); + const requestsBeforeRestart = requestCount; + const afterUninstall = await runNodeHook(hookScriptPath, JSON.stringify({hook_event_name:"Stop",session_id:"fresh-process"})); + expect(afterUninstall.status).not.toBe(0); + expect(afterUninstall.stderr).toContain("MODULE_NOT_FOUND"); + expect(requestCount).toBe(requestsBeforeRestart); expect(readFileSync(join(rootDirectory, "AGENTS.md"), "utf8")).toBe(existingTargetFile); expect(existsSync(join(rootDirectory, "skills", "memmy-memory"))).toBe(false); expect(existsSync(join(rootDirectory, "skills", "memmy-resume"))).toBe(false); diff --git a/App/backend/src/adapters/outbound/skill-writer/hermes/target.ts b/App/backend/src/adapters/outbound/skill-writer/hermes/target.ts index 72ce0aafb..eb9cb9124 100644 --- a/App/backend/src/adapters/outbound/skill-writer/hermes/target.ts +++ b/App/backend/src/adapters/outbound/skill-writer/hermes/target.ts @@ -1338,7 +1338,7 @@ def _load_runtime() -> Dict[str, str]: return { "baseUrl": base_url, "token": token, - "userId": _clean_text(app.get("userId")) or _clean_text(memory.get("userId")) or _clean_text(plugin_config.get("userId")) or "local-user", + "userId": "local-user", "workspaceHostId": _clean_text(plugin_config.get("workspaceHostId")), } diff --git a/App/backend/src/adapters/outbound/skill-writer/memmy-runtime-config.ts b/App/backend/src/adapters/outbound/skill-writer/memmy-runtime-config.ts index 58ac07d55..45e728890 100644 --- a/App/backend/src/adapters/outbound/skill-writer/memmy-runtime-config.ts +++ b/App/backend/src/adapters/outbound/skill-writer/memmy-runtime-config.ts @@ -28,7 +28,7 @@ export async function readMemmyMemoryServiceConfig(configPath: string): Promise< token: normalizeString(storage.token) || normalizeString(memmyMemory.token) || normalizeString(legacyStorage.token), - userId: normalizeString(app.userId) || normalizeString(memmyMemory.userId) || "local-user", + userId: "local-user", workspaceHostId: deriveWorkspaceHostId(getOrCreateInstallationId()) }; } diff --git a/App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts b/App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts index 82278434d..7ef35d6d3 100644 --- a/App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/templates/tests/memmy-resume-hook.test.ts @@ -294,7 +294,7 @@ describe("memmy resume hook stop capture", () => { workspaceHostId: "a".repeat(64), namespace: { source, - userId: "installed-owner", + userId: "local-user", sessionKey: `${source}-memory-host-session`, }, }); diff --git a/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts b/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts index 604c72e99..1e2bb66aa 100644 --- a/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts +++ b/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.test.ts @@ -23,7 +23,7 @@ afterEach(() => { }); describe("Memory lifecycle runtime", () => { - it("reads Memory connection and owner settings without a workspace scanning flag", async () => { + it("reads Memory connection with account-independent identity without a workspace scanning flag", async () => { const fixture = createFixture(); const configUrl = pathToFileURL(join(fixture, "memmy-memory-config.json")); const configPath = join(fixture, "config.yaml"); @@ -45,12 +45,12 @@ describe("Memory lifecycle runtime", () => { await expect(readRuntimeConfig(configUrl, true)).resolves.toEqual({ endpoint: "http://127.0.0.1:18888", token: "test-token", - userId: "installed-owner", + userId: "local-user", workspaceHostId: "a".repeat(64), }); }); - it.each([undefined, "pending-session"])("submits the pinned owner with pending Session %s across account changes", async (sessionId) => { + it.each([undefined, "pending-session"])("submits device identity with pending Session %s across account changes", async (sessionId) => { const fixture = createFixture(); const requests: Array<{ path: string; body: Record }> = []; const server = createServer(async (request, response) => { @@ -77,7 +77,7 @@ describe("Memory lifecycle runtime", () => { for (const request of requests) { expect(request.path).toBe("/api/v1/source-turns/complete"); expect(request.body.namespace).toEqual({ - source: "codex", profileId: "work", userId: "installed-owner", sessionKey: "native-conversation", + source: "codex", profileId: "work", userId: "local-user", sessionKey: "native-conversation", }); expect(request.body).toMatchObject({ sourceTurn: { source: "codex", profileId: "work", conversationId: "native-conversation" }, diff --git a/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts b/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts index 32b881c67..b6109a8c3 100644 --- a/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts +++ b/App/backend/src/adapters/outbound/skill-writer/workspace-bridge/runtime.ts @@ -83,9 +83,7 @@ export async function readRuntimeConfig(configUrl: URL, pinnedOwner = false): Pr return { endpoint: text(storage.endpoint) || text(memory.endpoint) || text(legacyStorage.endpoint) || text(snapshot.endpoint) || DEFAULT_ENDPOINT, token: text(storage.token) || text(memory.token) || text(legacyStorage.token) || text(snapshot.token), - userId: pinnedOwner - ? text(snapshot.userId) || "local-user" - : text(app.userId) || text(memory.userId) || text(snapshot.userId) || "local-user", + userId: "local-user", workspaceHostId: text(snapshot.workspaceHostId), }; } diff --git a/App/backend/src/infrastructure/memmy-config/index.ts b/App/backend/src/infrastructure/memmy-config/index.ts index 6c0937c1c..33545b017 100644 --- a/App/backend/src/infrastructure/memmy-config/index.ts +++ b/App/backend/src/infrastructure/memmy-config/index.ts @@ -662,7 +662,7 @@ export async function writeAccountModelProjectionToMemmyConfig( // inherit agent_chat or retain a stale BYOK memory endpoint. roleRouting.summary = "fixed"; roleRouting.evolution = "fixed"; - memory.userId = ownerAccountId; + delete memory.userId; memory.roleRouting = roleRouting; memory.summary = { ...withoutMemoryConnection(previousMemoryRecord(existingMemory.summary)), @@ -683,7 +683,7 @@ export async function writeAccountModelProjectionToMemmyConfig( mode: "cloud" }; memoryConfigAffected = memoryConfigAffected - || existingString(existingMemory.userId) !== ownerAccountId + || existingMemory.userId !== undefined || !accountMemoryConnectionMatches( existingMemory.summary, ACCOUNT_MODELS.memory_summary, diff --git a/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts b/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts index 15e43fde3..0b1d04bca 100644 --- a/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts +++ b/App/backend/src/infrastructure/memmy-config/tests/account-model-projection.test.ts @@ -118,8 +118,8 @@ describe("account model projection current catalog", () => { }); } expect(saved.modelAssignments.byok).toEqual(beforeByok); + expect(saved.memmyMemory).not.toHaveProperty("userId"); expect(saved.memmyMemory).toMatchObject({ - userId: "owner-a", roleRouting: { summary: "fixed", evolution: "fixed" }, summary: { provider: "openai_compatible", diff --git a/App/backend/src/services/index.ts b/App/backend/src/services/index.ts index 606e1ce35..4878cb459 100644 --- a/App/backend/src/services/index.ts +++ b/App/backend/src/services/index.ts @@ -141,10 +141,7 @@ export function createBackendServices(options: CreateBackendServicesOptions): Ba const mode = options.appStateStore.repositories.bootstrap.getAppSettings().userMode; return mode === "account" || mode === "byok" ? mode : null; }; - const resolveMemoryUserId = () => { - const session = accountSessionRepository.get(); - return session.authenticated ? session.profile.userId : "local-user"; - }; + const resolveMemoryUserId = () => "local-user"; const memoryAddAnalytics = createMemoryDesktopAddAnalytics({ getUserId: resolveAnalyticsUserId, getUserMode: resolveAnalyticsUserMode, diff --git a/App/backend/src/services/tests/panel-task-identity.integration.test.ts b/App/backend/src/services/tests/panel-task-identity.integration.test.ts index d4ff9429a..39d3a561e 100644 --- a/App/backend/src/services/tests/panel-task-identity.integration.test.ts +++ b/App/backend/src/services/tests/panel-task-identity.integration.test.ts @@ -95,21 +95,21 @@ describe("desktop task identity / issue 409", () => { expect(await panel.tasks({ q: "no-matching-task" }, ctx)).toMatchObject({ total: 0, tasks: [] }); } - // Displaying local data must not migrate the original task ownership. - for (const [userId, total] of [["local-user", 21], ["account-user", 1], ["previous-account", 1]] as const) { - expect(f.service.panelTasks({ namespace: { userId } }).total).toBe(total); + // A legacy user namespace no longer partitions this device library. + for (const userId of ["local-user", "account-user", "previous-account"]) { + expect(f.service.panelTasks({ namespace: { userId } }).total).toBe(23); } }); - it("preserves Memory service authentication and token-bound user scopes", async () => { + it("preserves authentication and operation permissions for the shared device library", async () => { const f = await fixture(); const localId = f.addTask("local-user", "local-scope-task"); const accountId = f.addTask("account-user", "account-scope-task"); for (const token of ["scoped-panel-token", "cloud-panel-token"]) { const panel = f.panel(token, () => "account-user"); const result = await panel.tasks({}, ctx); - expect(result.total).toBe(1); - expect(result.tasks.map(task => task.id)).toEqual([accountId]); + expect(result.total).toBe(2); + expect(result.tasks.map(task => task.id).sort()).toEqual([localId,accountId].sort()); if (token === "scoped-panel-token") { await expect(panel.deleteTask(localId, ctx)).rejects.toMatchObject({ code: "forbidden" }); } diff --git a/App/memmy-agent/src/memmy-memory/config.ts b/App/memmy-agent/src/memmy-memory/config.ts index 4122661a8..caac5b557 100644 --- a/App/memmy-agent/src/memmy-memory/config.ts +++ b/App/memmy-agent/src/memmy-memory/config.ts @@ -7,15 +7,11 @@ export function resolveMemmyMemoryConfig(config: Config | Record | const raw = (config as any)?.memmyMemory ?? {}; return { enabled: Boolean(raw?.enabled ?? raw?.enable ?? true), - userId: stringOrUndefined(raw?.userId) ?? "local-user", + userId: "local-user", retrievalLayers: memoryLayersOrUndefined(raw?.retrievalLayers), }; } -function stringOrUndefined(value: any): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - function memoryLayersOrUndefined(value: any): MemmyMemoryLayer[] | undefined { if (!Array.isArray(value)) return undefined; return [...new Set(value.filter((layer): layer is MemmyMemoryLayer => MEMORY_LAYERS.has(layer)))]; diff --git a/App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts b/App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts index c3031501b..5430de6be 100644 --- a/App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts +++ b/App/memmy-agent/tests/memmy-memory/agent-loop-integration.test.ts @@ -198,7 +198,7 @@ describe("AgentLoop memmy memory integration", () => { l3WorldModelProtocolVersion: 2, workspaceUri: expect.stringMatching(/^file:\/\//u), workspaceHostId: expect.stringMatching(/^[a-f0-9]{64}$/u), - namespace: { sessionKey: "websocket:memory-project", userId: "loop-user" }, + namespace: { sessionKey: "websocket:memory-project", userId: "local-user" }, }); expect(JSON.stringify(opened.body)).not.toContain(project.id); expect(requests.filter((request) => request.path === "/api/v1/sessions/open")).toHaveLength(1); diff --git a/App/memmy-agent/tests/memmy-memory/discovery.test.ts b/App/memmy-agent/tests/memmy-memory/discovery.test.ts index 14007326b..33b2d6632 100644 --- a/App/memmy-agent/tests/memmy-memory/discovery.test.ts +++ b/App/memmy-agent/tests/memmy-memory/discovery.test.ts @@ -124,7 +124,7 @@ describe("memmy memory discovery", () => { expect(defaultConfig.memmyMemory.enabled).toBe(true); expect(resolveMemmyMemoryConfig(enabled).enabled).toBe(true); expect(resolveMemmyMemoryConfig(defaultConfig).enabled).toBe(true); - expect(resolveMemmyMemoryConfig(enabled).userId).toBe("user_config_1"); + expect(resolveMemmyMemoryConfig(enabled).userId).toBe("local-user"); expect(resolveMemmyMemoryConfig(enabled).retrievalLayers).toEqual(["L1", "L3"]); expect(resolveMemmyMemoryConfig(disabled).enabled).toBe(false); expect(resolveMemmyMemoryConfig(disabled).userId).toBe("local-user"); diff --git a/Memory/src/agent-source/integration/hermes/target.ts b/Memory/src/agent-source/integration/hermes/target.ts index 12e80ff2c..d58904b8f 100644 --- a/Memory/src/agent-source/integration/hermes/target.ts +++ b/Memory/src/agent-source/integration/hermes/target.ts @@ -1338,7 +1338,7 @@ def _load_runtime() -> Dict[str, str]: return { "baseUrl": base_url, "token": token, - "userId": _clean_text(app.get("userId")) or _clean_text(memory.get("userId")) or _clean_text(plugin_config.get("userId")) or "local-user", + "userId": "local-user", "workspaceHostId": _clean_text(plugin_config.get("workspaceHostId")), } diff --git a/Memory/src/agent-source/integration/memmy-runtime-config.ts b/Memory/src/agent-source/integration/memmy-runtime-config.ts index f5a15c88d..06ac2a895 100644 --- a/Memory/src/agent-source/integration/memmy-runtime-config.ts +++ b/Memory/src/agent-source/integration/memmy-runtime-config.ts @@ -28,7 +28,7 @@ export async function readMemmyMemoryServiceConfig(configPath: string): Promise< token: normalizeString(storage.token) || normalizeString(memmyMemory.token) || normalizeString(legacyStorage.token), - userId: normalizeString(app.userId) || normalizeString(memmyMemory.userId) || "local-user", + userId: "local-user", workspaceHostId: deriveWorkspaceHostId(getOrCreateInstallationId()) }; } diff --git a/Memory/src/agent-source/integration/workspace-bridge/runtime.ts b/Memory/src/agent-source/integration/workspace-bridge/runtime.ts index 70e25f49e..c6cc55ec3 100644 --- a/Memory/src/agent-source/integration/workspace-bridge/runtime.ts +++ b/Memory/src/agent-source/integration/workspace-bridge/runtime.ts @@ -83,9 +83,7 @@ export async function readRuntimeConfig(configUrl: URL, pinnedOwner = false): Pr return { endpoint: text(storage.endpoint) || text(memory.endpoint) || text(legacyStorage.endpoint) || text(snapshot.endpoint) || DEFAULT_ENDPOINT, token: text(storage.token) || text(memory.token) || text(legacyStorage.token) || text(snapshot.token), - userId: pinnedOwner - ? text(snapshot.userId) || "local-user" - : text(app.userId) || text(memory.userId) || text(snapshot.userId) || "local-user", + userId: "local-user", workspaceHostId: text(snapshot.workspaceHostId), }; } diff --git a/Memory/src/algorithm/plugin-algorithms.ts b/Memory/src/algorithm/plugin-algorithms.ts index 5881dff18..387b3337b 100644 --- a/Memory/src/algorithm/plugin-algorithms.ts +++ b/Memory/src/algorithm/plugin-algorithms.ts @@ -55,7 +55,7 @@ export interface TraceMemoryMeta { rawTurnId?: string; episodeId?: string; sessionId?: string; - userId: string; + userId: string | null; summary: string; userText: string; agentText: string; diff --git a/Memory/src/algorithm/trace-direct-skill.ts b/Memory/src/algorithm/trace-direct-skill.ts index 62d01861e..3bfad77b3 100644 --- a/Memory/src/algorithm/trace-direct-skill.ts +++ b/Memory/src/algorithm/trace-direct-skill.ts @@ -91,7 +91,7 @@ export interface OutcomeThresholds { export interface EpisodeSkillFeatures { episodeId: string; - userId: string; + userId: string | null; projectId?: string; tools: string[]; artifacts: string[]; @@ -104,7 +104,7 @@ export interface EpisodeSkillFeatures { export interface SkillClusterFeatures { id: string; - userId: string; + userId: string | null; projectId?: string; tools: string[]; artifacts: string[]; @@ -282,7 +282,7 @@ export function firstUserQuery(turns: RawTurnLike[]): string { export function extractEpisodeSkillFeatures(input: { episodeId: string; - userId: string; + userId: string | null; projectId?: string; rTask?: number; turns: RawTurnLike[]; @@ -421,7 +421,7 @@ export function decideClusterAssignment( stage: "coarse" | "fine"; } | undefined; for (const cluster of clusters) { - if (cluster.userId !== episode.userId) continue; + if (episode.projectId && cluster.projectId && episode.projectId !== cluster.projectId) continue; const scored = scoreClusterJoin(episode, cluster, config); if (scored.score === null) continue; diff --git a/Memory/src/cli/config.ts b/Memory/src/cli/config.ts index 4156cb5ed..adad3b4d5 100644 --- a/Memory/src/cli/config.ts +++ b/Memory/src/cli/config.ts @@ -35,11 +35,7 @@ export function loadCliMemoryConfig(configPath?: string): { optionalString(process.env.MEMORY_SERVICE_TOKEN) ?? optionalString(storage.token) ?? optionalString(memmyMemory.token), - userId: optionalString(process.env.MEMMY_MEMORY_USER_ID) ?? - optionalString(process.env.MEMMY_USER_ID) ?? - optionalString(process.env.MEMORY_SERVICE_USER_ID) ?? - optionalString(memmyMemory.userId) ?? - optionalString(app.userId) + userId: undefined }, path: selectedPath }; diff --git a/Memory/src/cli/setup.ts b/Memory/src/cli/setup.ts index df8298243..008744bff 100644 --- a/Memory/src/cli/setup.ts +++ b/Memory/src/cli/setup.ts @@ -334,7 +334,7 @@ function setupMemmyMemoryConfig( const memmyMemory: Record = { ...existing, version: 1, - userId: optionalString(existing.userId) ?? options.appUserId ?? "local-user", + userId: undefined, roleRouting: { ...roleRouting, // Account mode has platform-owned models for both memory roles. Never diff --git a/Memory/src/config/index.ts b/Memory/src/config/index.ts index bdf82c0dd..d7615a91e 100644 --- a/Memory/src/config/index.ts +++ b/Memory/src/config/index.ts @@ -589,7 +589,7 @@ function parseConfigFile(path: string): Record { function configFromEnv(): Record { return compactRecord({ domain: process.env.MEMMY_MEMORY_DOMAIN ?? process.env.MEMMY_DOMAIN, - userId: process.env.MEMMY_MEMORY_USER_ID ?? process.env.MEMMY_USER_ID ?? process.env.MEMORY_SERVICE_USER_ID, + userId: undefined, storage: compactRecord({ mode: process.env.MEMMY_MEMORY_MODE, backend: process.env.MEMMY_MEMORY_BACKEND, @@ -689,7 +689,7 @@ function normalizeConfig(input: Record): MemmyConfig { version: 1, domain: memoryDomainName(input.domain, DEFAULT_MEMMY_CONFIG.domain), roleRouting: normalizeRoleRouting(asRecord(input.roleRouting)), - userId: optionalString(input.userId), + userId: undefined, ...(language ? { language } : {}), storage, summary, diff --git a/Memory/src/contracts/memory-l3-world-model.ts b/Memory/src/contracts/memory-l3-world-model.ts index 269d5f023..4d6d85f07 100644 --- a/Memory/src/contracts/memory-l3-world-model.ts +++ b/Memory/src/contracts/memory-l3-world-model.ts @@ -28,7 +28,7 @@ const L3WorldModelRuntimeNamespaceShape = { workspaceId: OptionalNonEmptyStringSchema, workspacePath: OptionalNonEmptyStringSchema, sessionKey: OptionalNonEmptyStringSchema, - userId: OptionalNonEmptyStringSchema, + userId: OptionalNonEmptyStringSchema.nullable(), tenantId: OptionalNonEmptyStringSchema } as const; diff --git a/Memory/src/server/http.ts b/Memory/src/server/http.ts index 4dae7f1f0..5ca17e0f8 100644 --- a/Memory/src/server/http.ts +++ b/Memory/src/server/http.ts @@ -1544,6 +1544,7 @@ function strictEnvelopeWithPrincipal( "source" ]; for (const field of fields) { + if (field === "userId" && (principal.kind === "local" || principal.kind === "anonymous")) continue; const requested = requestNamespace?.[field]; const scoped = principalNamespace?.[field]; if (typeof requested === "string" && requested && typeof scoped === "string" && scoped && requested !== scoped) { diff --git a/Memory/src/service/embedding/embedding-job-processor.ts b/Memory/src/service/embedding/embedding-job-processor.ts index ae965d5bd..9e96e91b3 100644 --- a/Memory/src/service/embedding/embedding-job-processor.ts +++ b/Memory/src/service/embedding/embedding-job-processor.ts @@ -83,7 +83,7 @@ export interface PreparedEmbeddingJob { export interface EnqueueWorkerJobInput { jobType: JobType; - userId: string; + userId: string | null; sessionId?: string; episodeId?: string; targetMemoryId?: string; @@ -495,7 +495,7 @@ export class EmbeddingJobProcessor { : undefined; if ( decision.userMemoryAction === "correct_existing" && - (!correctionTarget || correctionTarget.userId !== sourceMemory.userId || correctionTarget.status !== "active") + (!correctionTarget || correctionTarget.status !== "active") ) return undefined; const candidate = buildUserMemory({ id: `user_memory_${stableHash(`${sourceTurnId}:${content}`).slice(0, 20)}`, diff --git a/Memory/src/service/evolution/evolution-job-processor.ts b/Memory/src/service/evolution/evolution-job-processor.ts index 5f0d8ec64..957017a2c 100644 --- a/Memory/src/service/evolution/evolution-job-processor.ts +++ b/Memory/src/service/evolution/evolution-job-processor.ts @@ -67,7 +67,7 @@ export interface EvolutionJobProcessorDeps { trigger: "episode_rewarded" ): EvolutionJobRecord[]; resolvePendingSkillTrialsForReward(input: { - userId: string; + userId: string | null; episodeId: string; rHuman: number; feedbackId?: string; diff --git a/Memory/src/service/evolution/l3-world-model-pipeline.ts b/Memory/src/service/evolution/l3-world-model-pipeline.ts index 75fef7cb2..70f9b9459 100644 --- a/Memory/src/service/evolution/l3-world-model-pipeline.ts +++ b/Memory/src/service/evolution/l3-world-model-pipeline.ts @@ -62,7 +62,6 @@ export class L3WorldModelTraceFieldPipeline { } if ( job.jobType !== "l3_world_model_update" || - job.userId !== batch.userId || job.sessionId !== batch.sessionId || job.scopeKey !== target.fieldScopeKey || job.scopeSeq !== target.scopeSeq || @@ -70,7 +69,7 @@ export class L3WorldModelTraceFieldPipeline { ) { throw new L3WorldModelTerminalEvidenceError("L3 World Model job ownership mismatch"); } - if (sha256Hex(canonicalJson(batchPayloadForHash(batch))) !== batch.payloadHash) { + if (sha256Hex(canonicalJson(batchPayloadForHash(this.deps.repos.l3WorldModels.getStoredBatch(batch.id)!))) !== batch.payloadHash) { throw new L3WorldModelTerminalEvidenceError("L3 World Model batch payload hash mismatch"); } @@ -132,7 +131,7 @@ export class L3WorldModelTraceFieldPipeline { const batch = this.deps.repos.l3WorldModels.getBatch(batchId); if (!batch) throw new L3WorldModelTerminalEvidenceError(`missing batch: ${batchId}`); const session = this.deps.repos.runtime.getSession(batch.sessionId); - if (!session || session.userId !== batch.userId || (session.projectId ?? null) !== (batch.projectId ?? null)) { + if (!session || (session.projectId ?? null) !== (batch.projectId ?? null)) { throw new L3WorldModelTerminalEvidenceError("L3 World Model batch session scope mismatch"); } const traces = this.deps.repos.l3WorldModels.listBatchTraces(batchId); @@ -156,7 +155,6 @@ export class L3WorldModelTraceFieldPipeline { : undefined; if ( !trace || - feedback.userId !== batch.userId || feedback.sessionId !== batch.sessionId || (feedback.projectId ?? null) !== (batch.projectId ?? null) ) { @@ -182,7 +180,7 @@ export class L3WorldModelTraceFieldPipeline { for (const trace of traces) { const memory = this.deps.repos.memories.get(trace.l1MemoryId); if (!memory) continue; - if (memory.userId !== batch.userId || memory.sessionId !== batch.sessionId) { + if (memory.sessionId !== batch.sessionId) { throw new L3WorldModelTerminalEvidenceError(`L1 scope mismatch: ${memory.id}`); } if (!memory.deletedAt && memory.status !== "deleted" && usableRawTurnIds.has(trace.rawTurnId)) { @@ -264,8 +262,8 @@ function assertJsonArray(value: unknown[], label: string): JsonValue[] { } } -function assertRawTurnScope(rawTurn: RawTurnRecord, userId: string, sessionId: string): void { - if (rawTurn.userId !== userId || rawTurn.sessionId !== sessionId) { +function assertRawTurnScope(rawTurn: RawTurnRecord, userId: string | null, sessionId: string): void { + if (rawTurn.sessionId !== sessionId) { throw new L3WorldModelTerminalEvidenceError(`RawTurn scope mismatch: ${rawTurn.id}`); } } diff --git a/Memory/src/service/evolution/negative-experience-pipeline.ts b/Memory/src/service/evolution/negative-experience-pipeline.ts index 0a557770a..893414345 100644 --- a/Memory/src/service/evolution/negative-experience-pipeline.ts +++ b/Memory/src/service/evolution/negative-experience-pipeline.ts @@ -263,7 +263,7 @@ export class NegativeExperiencePipeline { const episode = job.episodeId ? this.deps.repos.runtime.getEpisode(job.episodeId) : undefined; - if (!source || !episode || episode.userId !== job.userId) return undefined; + if (!source || !episode) return undefined; const feedbackId = text(job.payload.feedbackId); const repairId = text(job.payload.repairId); diff --git a/Memory/src/service/evolution/policy-induction.ts b/Memory/src/service/evolution/policy-induction.ts index 70a3aa6c1..fcdcdc5e5 100644 --- a/Memory/src/service/evolution/policy-induction.ts +++ b/Memory/src/service/evolution/policy-induction.ts @@ -69,13 +69,13 @@ type ReposPort = { runtime: { pruneCandidatePool(now: string): number; listPendingCandidatePool(input: { - userId?: string; + userId?: string | null; now?: string; limit?: number; }): CandidatePoolRecord[]; upsertCandidatePoolTrace(input: { id: string; - userId: string; + userId: string | null; sessionId?: string; sourceMemoryId: string; candidateKey: string; @@ -87,20 +87,20 @@ type ReposPort = { expiresAt?: string | null; }): void; markCandidatePoolPromoted(input: { - userId?: string; + userId?: string | null; candidateKey: string; sourceMemoryIds: string[]; policyId: string; at: string; }): void; listTracePolicyLinks(input: { - userId?: string; + userId?: string | null; l1MemoryId?: string; l2MemoryId?: string; limit?: number; }): PolicyTraceLink[]; insertTracePolicyLink(input: { - userId: string; + userId: string | null; l1MemoryId: string; l2MemoryId: string; relation?: string; @@ -137,7 +137,7 @@ export interface PolicyInductionDeps { kind: string; op: JobChangeKind; entityId: string; - userId: string; + userId: string | null; changeType: string; before?: unknown; after: unknown; @@ -828,7 +828,7 @@ export class PolicyInductionEngine { } private markCandidatePoolPromoted( - userId: string, + userId: string | null, signature: string, sourceMemoryIds: string[], policyId: string, diff --git a/Memory/src/service/evolution/reward-pipeline.ts b/Memory/src/service/evolution/reward-pipeline.ts index 0b35e1e69..1ab6d175a 100644 --- a/Memory/src/service/evolution/reward-pipeline.ts +++ b/Memory/src/service/evolution/reward-pipeline.ts @@ -54,7 +54,7 @@ export interface RewardPipelineDeps { enqueueJob(input: EnqueueJobInput): EvolutionJobRecord; finalizeClosedEpisode(episode: EpisodeRecord, at: string, trigger: "episode_rewarded"): EvolutionJobRecord[]; resolvePendingSkillTrialsForReward(input: { - userId: string; + userId: string | null; episodeId: string; rHuman: number; feedbackId?: string; diff --git a/Memory/src/service/evolution/skill-pipeline.ts b/Memory/src/service/evolution/skill-pipeline.ts index 640aa9c3b..f4d44b539 100644 --- a/Memory/src/service/evolution/skill-pipeline.ts +++ b/Memory/src/service/evolution/skill-pipeline.ts @@ -470,7 +470,6 @@ export class SkillPipeline { .map(skillMetaFromMemory) .filter((skill): skill is SkillMeta => Boolean( skill && - skill.memory.userId === policy.memory.userId && skill.status !== "archived" && !isReadOnlySkillMemory(skill.memory) )); @@ -1349,7 +1348,7 @@ function skillPolicyCompatibility( skill: NonNullable>, policy: PolicyMeta ): number { - if (skill.memory.userId !== policy.memory.userId) return 0; + const skillProject = projectIdFromMemory(skill.memory); const policyProject = projectIdFromMemory(policy.memory); if (skillProject && policyProject && skillProject !== policyProject) return 0; @@ -1371,7 +1370,7 @@ function skillPolicyCompatibility( } function skillsAreCompatibleForMerge(left: SkillMeta, right: SkillMeta): boolean { - if (left.memory.userId !== right.memory.userId) return false; + const leftProject = projectIdFromMemory(left.memory); const rightProject = projectIdFromMemory(right.memory); if (leftProject && rightProject && leftProject !== rightProject) return false; diff --git a/Memory/src/service/evolution/span-pipeline.ts b/Memory/src/service/evolution/span-pipeline.ts index 4d3ab5bf9..277348e9f 100644 --- a/Memory/src/service/evolution/span-pipeline.ts +++ b/Memory/src/service/evolution/span-pipeline.ts @@ -873,7 +873,7 @@ private reflectionDownstreamPreview(job: EvolutionJobRecord, memory: MemoryRow): ? recall.userMemoryCandidateIds : injectedIds; return this.deps.repos.userMemories.getMany(recalledUserMemoryIds) - .filter((memory) => memory.userId === trace.userId && memory.status === "active") + .filter((memory) => memory.status === "active") .map((memory) => ({ id: memory.id, memoryTypes: memory.memoryTypes, diff --git a/Memory/src/service/feedback/feedback-experience.ts b/Memory/src/service/feedback/feedback-experience.ts index c2bdc827d..eab072316 100644 --- a/Memory/src/service/feedback/feedback-experience.ts +++ b/Memory/src/service/feedback/feedback-experience.ts @@ -63,7 +63,7 @@ import { export interface ResolvedContext { - userId: string; + userId: string | null; conversationId?: string; namespace: RuntimeNamespace; } @@ -614,7 +614,7 @@ maybeCreateDecisionRepair( } enqueueDeferredDecisionRepair(input: { - userId: string; + userId: string | null; sessionId?: string; episodeId?: string; repairId: string; @@ -642,7 +642,7 @@ maybeCreateDecisionRepair( } enqueueDeferredFeedbackExperience(input: { - userId: string; + userId: string | null; sessionId?: string; episodeId?: string; feedbackId: string; diff --git a/Memory/src/service/import/import-job-processor.ts b/Memory/src/service/import/import-job-processor.ts index 138ec6bd1..347333beb 100644 --- a/Memory/src/service/import/import-job-processor.ts +++ b/Memory/src/service/import/import-job-processor.ts @@ -43,7 +43,7 @@ export const IMPORT_DEFAULT_PRIORITY = 0.5; type EnqueueInput = { jobType: JobType; - userId: string; + userId: string | null; sessionId?: string; episodeId?: string; targetMemoryId?: string; @@ -62,7 +62,7 @@ export interface ImportJobProcessorDeps { assertMemoryAddEnabled(): void; assertMemoryInScope(memory: MemoryRow, namespace: unknown): void; sanitizeMemoryAddRequest(request: MemoryAddRequest): MemoryAddRequest; - resolveContext(request: MemoryAddRequest): { userId: string; namespace: { source?: string; projectId?: string; profileId?: string } }; + resolveContext(request: MemoryAddRequest): { userId: string | null; namespace: { source?: string; projectId?: string; profileId?: string } }; requireSession(id: string): SessionRecord; assertSessionInScope(session: ReturnType, namespace: unknown): void; normalizeMemoryAddCreatedAt(value: string | undefined, timeZone?: string): string | undefined; @@ -113,7 +113,7 @@ export interface ImportJobProcessorDeps { }; captureClaims: { claim(input: { - userId: string; + userId: string | null; source: string; qaHash: string; primaryMemoryId: string; diff --git a/Memory/src/service/memory-service.ts b/Memory/src/service/memory-service.ts index 252d44cdf..71351efd6 100644 --- a/Memory/src/service/memory-service.ts +++ b/Memory/src/service/memory-service.ts @@ -1,3 +1,4 @@ +import { deviceNamespace, memoryFingerprint, matchesLegacyMemoryFingerprint } from "../storage/device-scope.js"; import { assertJsonValue, canonicalJson, @@ -276,6 +277,10 @@ function isAbortError(error: unknown): boolean { } export class MemoryService { + private normalizeDeviceNamespace(namespace?: RuntimeNamespace) { + return normalizeNamespace(deviceNamespace(this.repos.db, normalizeNamespace(namespace))); + } + private readonly embeddingJobs: EmbeddingJobProcessor; private readonly evolutionJobs: EvolutionJobProcessor; private readonly feedbackExperience: FeedbackExperienceService; @@ -579,6 +584,9 @@ export class MemoryService { }); this.episodeReadModel = new EpisodeReadModel({ repos: this.repos, + projectMemory: (memory) => isStrictL3WorldModelV2Memory(memory) + ? this.repos.l3WorldModels.getMemory(null, typeof memory.info.project_id === "string" ? memory.info.project_id : null) ?? memory + : memory, assertMemorySearchEnabled: this.assertMemorySearchEnabled.bind(this), resolveContext: this.resolveContext.bind(this), requireSession: this.requireSession.bind(this), @@ -1117,10 +1125,11 @@ export class MemoryService { if (!idempotencyKey) { return scopedRun(); } - const requestHash = stableHash({ operation, fingerprint }); + const hashFingerprint = (value: unknown) => stableHash({ operation, fingerprint: value }); + const requestHash = hashFingerprint(memoryFingerprint(this.repos.db, fingerprint)); const existing = this.repos.runtime.getIdempotency(idempotencyKey); if (existing) { - if (existing.requestHash !== requestHash) { + if (existing.requestHash !== requestHash && !matchesLegacyMemoryFingerprint(this.repos.db, fingerprint, existing.requestHash, hashFingerprint)) { throw new MemoryServiceError("conflict", "idempotency key reused with different request body"); } return withDuplicateFlag(existing.response) as T; @@ -1142,10 +1151,11 @@ export class MemoryService { ? `${operation}:${request.adapterId}:${request.requestId}` : undefined; if (!idempotencyKey) return scopedRun(); - const requestHash = sha256Hex(canonicalJson(assertJsonValue({ operation, fingerprint }))); + const hashFingerprint = (value: unknown) => sha256Hex(canonicalJson(assertJsonValue({ operation, fingerprint: value }))); + const requestHash = hashFingerprint(memoryFingerprint(this.repos.db, fingerprint)); const existing = this.repos.runtime.getIdempotency(idempotencyKey); if (existing) { - if (existing.requestHash !== requestHash) { + if (existing.requestHash !== requestHash && !matchesLegacyMemoryFingerprint(this.repos.db, fingerprint, existing.requestHash, hashFingerprint)) { throw new MemoryServiceError("conflict", "idempotency key reused with different request body"); } return existing.response as T; @@ -1172,7 +1182,7 @@ export class MemoryService { panel: boolean; }; effectiveNamespace: { - userId: string; + userId: string | null; projectId?: string; workspaceId?: string; profileId?: string; @@ -1180,7 +1190,7 @@ export class MemoryService { expiresAt?: string; serverTime: string; } { - const namespace = normalizeNamespace(request.namespace); + const namespace = this.normalizeDeviceNamespace(request.namespace); return { adapterId: request.adapterId ?? "anonymous", serviceVersion: PROJECT_VERSION, @@ -1202,7 +1212,7 @@ export class MemoryService { openSession(request: SessionOpenRequest): { sessionId: string; - userId: string; + userId: string | null; source: string; profileId: string; projectId?: string | null; @@ -1355,8 +1365,7 @@ export class MemoryService { } completeSourceTurn(request: SourceTurnCompleteRequest): SourceTurnCompleteResponse { - // Native scans have no Hook envelope. Use the configured owner only when - // the request (including authenticated scope) did not provide one. + // Old scanners may still send an account UID; local memory has no account owner. const response = this.sessionTurns.completeSourceTurn(this.withTimeZone({ ...request, namespace: { @@ -1364,7 +1373,7 @@ export class MemoryService { profileId: request.sourceTurn?.profileId, sessionKey: request.sourceTurn?.conversationId, ...request.namespace, - userId: request.namespace?.userId ?? this.config.userId + userId: null } })); serviceLogger.info("source_turn.complete", { @@ -2119,7 +2128,7 @@ export class MemoryService { } timeline(input: RequestEnvelope & { - userId?: string; + userId?: string | null; sessionId?: string; episodeId?: string; layers?: MemoryLayer[]; @@ -2161,7 +2170,7 @@ export class MemoryService { } listSkills(input: RequestEnvelope & { - userId?: string; + userId?: string | null; q?: string; tags?: string[]; limit?: number; @@ -2467,10 +2476,6 @@ export class MemoryService { this.assertMemoryAddEnabled(); const userMemory = this.repos.userMemories.get(id); if (userMemory) { - const namespaceUserId = request.namespace?.userId; - if (namespaceUserId && namespaceUserId !== userMemory.userId) { - throw new MemoryServiceError("forbidden", "user memory belongs to a different user"); - } const deleted = this.repos.userMemories.softDelete(userMemory.id, nowIso()); if (!deleted) throw new MemoryServiceError("not_found", `user memory not found: ${id}`); const changeSeq = this.repos.runtime.appendChange({ @@ -2523,12 +2528,8 @@ export class MemoryService { throw new MemoryServiceError("conflict", "invalid L3 World Model v2 record"); } if (strictV2WorldModel) { - const effectiveUserId = normalizeNamespace(request.namespace).userId; const projectId = typeof memory.info.project_id === "string" ? memory.info.project_id : null; - if (effectiveUserId !== memory.userId) { - throw new MemoryServiceError("forbidden", "L3 World Model belongs to a different user"); - } - if (request.namespace?.projectId && request.namespace.projectId !== projectId) { + if (request.namespace?.projectId && this.normalizeDeviceNamespace(request.namespace).projectId !== projectId) { throw new MemoryServiceError("forbidden", "L3 World Model belongs to a different project"); } } else { @@ -2605,9 +2606,6 @@ export class MemoryService { this.assertMemorySearchEnabled(); const event = this.repos.runtime.getRecallEventByQueryId(queryId); if (!event) throw new MemoryServiceError("not_found", `recall event not found: ${queryId}`); - if (request.namespace?.userId && request.namespace.userId !== event.userId) { - throw new MemoryServiceError("forbidden", "recall event belongs to a different user"); - } const eventRequest = isRecord(event.request) ? event.request : {}; const evidence = isRecord(eventRequest.recallEvidence) ? eventRequest.recallEvidence : {}; const storedHits = Array.isArray(evidence.hits) @@ -2761,7 +2759,7 @@ export class MemoryService { } auditLogs(input: RequestEnvelope & { - userId?: string; + userId?: string | null; targetKind?: string; targetId?: string; limit?: number; @@ -2773,7 +2771,7 @@ export class MemoryService { } serviceLogs(input: RequestEnvelope & { - userId?: string; + userId?: string | null; limit?: number; cursor?: string; } = {}): { @@ -2782,7 +2780,7 @@ export class MemoryService { type: "change" | "audit" | "job"; id: string; at: string; - userId?: string; + userId?: string | null; action: string; targetKind?: string; targetId?: string; @@ -2814,7 +2812,7 @@ export class MemoryService { return this.panelReadModel.apiLogs(input); } - serviceMetrics(input: RequestEnvelope & { userId?: string } = {}): { + serviceMetrics(input: RequestEnvelope & { userId?: string | null } = {}): { storage: StorageBackendCapabilities; schema: { version: number; lastMigrationId?: string }; memory: ReturnType["stats"]; @@ -2830,7 +2828,7 @@ export class MemoryService { return this.panelReadModel.serviceMetrics(input); } - adminStatus(input: RequestEnvelope & { userId?: string } = {}, routes: string[] = []): { + adminStatus(input: RequestEnvelope & { userId?: string | null } = {}, routes: string[] = []): { health: HealthResponse; overview: ReturnType; failedJobs: EvolutionJobRecord[]; @@ -2849,7 +2847,7 @@ export class MemoryService { return this.panelReadModel.configStatus(_input); } - panelOverview(input: RequestEnvelope & { userId?: string } = {}): { + panelOverview(input: RequestEnvelope & { userId?: string | null } = {}): { stats: { byLayer: Record; byStatus: Record<"activated" | "resolving" | "archived" | "deleted", number>; @@ -2868,7 +2866,7 @@ export class MemoryService { return this.panelReadModel.panelOverview(this.withTimeZone(input)); } - panelOverviewSummary(input: RequestEnvelope & { userId?: string } = {}): { + panelOverviewSummary(input: RequestEnvelope & { userId?: string | null } = {}): { counts: { memories: number; userMemories: number; @@ -2886,7 +2884,7 @@ export class MemoryService { return this.panelReadModel.panelOverviewSummary(this.withTimeZone(input)); } - panelAnalysis(input: RequestEnvelope & { userId?: string } = {}): { + panelAnalysis(input: RequestEnvelope & { userId?: string | null } = {}): { metrics: { avgRecallScore: number; recallEvents: number; @@ -2906,7 +2904,7 @@ export class MemoryService { } panelItems(input: RequestEnvelope & { - userId?: string; + userId?: string | null; layer?: RecallMemoryLayer; status?: "activated" | "resolving" | "archived" | "deleted"; q?: string; @@ -2951,7 +2949,7 @@ export class MemoryService { } panelChanges(input: RequestEnvelope & { - userId?: string; + userId?: string | null; limit?: number; cursor?: string; } = {}): { @@ -2973,7 +2971,7 @@ export class MemoryService { } panelJobs(input: RequestEnvelope & { - userId?: string; + userId?: string | null; status?: "queued" | "leased" | "succeeded" | "failed" | "dead_letter"; limit?: number; } = {}): { @@ -3024,7 +3022,7 @@ export class MemoryService { if (memories.length < 250) break; offset += memories.length; } - const userId = this.config.userId?.trim() || "local-user"; + const userId = null; offset = 0; for (;;) { const userMemories = this.repos.userMemories.listForPanel({ @@ -3059,7 +3057,7 @@ export class MemoryService { dimMismatch: number; needsRepair: number; } { - const userId = this.config.userId?.trim() || "local-user"; + const userId = null; const regular = this.repos.vectors.maintenanceDimensionCounts(); const user = this.repos.userMemories.embeddingDimensionCounts(userId); const dimensions = new Map(); @@ -3161,7 +3159,7 @@ export class MemoryService { private buildMemory(input: { id?: string; - userId: string; + userId: string | null; conversationId?: string; sessionId?: string; agentId?: string; @@ -3238,7 +3236,7 @@ export class MemoryService { } private readOnlyCursor(namespace?: RuntimeNamespace): { changeSeq: number; syncCursor: string } { - const scoped = namespace ? normalizeNamespace(namespace) : undefined; + const scoped = namespace ? this.normalizeDeviceNamespace(namespace) : undefined; const changeSeq = this.repos.runtime.latestChangeSeq( scoped?.userId, scoped ? namespaceIdFromContext(scoped) : undefined @@ -3250,7 +3248,7 @@ export class MemoryService { } private openSessionNoWrite(request: SessionOpenRequest): ReturnType { - const namespace = normalizeNamespace(request.namespace); + const namespace = this.normalizeDeviceNamespace(request.namespace); const existing = request.sessionId ? this.repos.runtime.getSession(request.sessionId) : namespace.sessionKey @@ -3493,9 +3491,8 @@ export class MemoryService { if (session.meta.l3_world_model_protocol_version !== 2) { throw new MemoryServiceError("conflict", "l3_world_model_protocol_v2_required"); } - const normalized = normalizeNamespace(namespace); + const normalized = this.normalizeDeviceNamespace(namespace); const conflicts = [ - normalized.userId !== session.userId, normalized.source !== session.source, normalized.profileId !== session.profileId, (normalized.projectId ?? null) !== (session.projectId ?? null), @@ -3535,7 +3532,7 @@ export class MemoryService { v: 1, backendId: capabilities.backendId, schemaVersion: capabilities.schemaVersion, - namespaceId: namespace ? namespaceIdFromContext(normalizeNamespace(namespace)) : "", + namespaceId: namespace ? namespaceIdFromContext(this.normalizeDeviceNamespace(namespace)) : "", seq }; return `cur_${Buffer.from(JSON.stringify(payload)).toString("base64url")}`; @@ -3571,8 +3568,8 @@ export class MemoryService { return this.sessionTurns.ensureEpisode(session, episodeId); } - private resolveContext(request: RequestEnvelope & { sessionId?: string; userId?: string }): { - userId: string; + private resolveContext(request: RequestEnvelope & { sessionId?: string; userId?: string | null }): { + userId: string | null; conversationId?: string; namespace: RuntimeNamespace; } { @@ -3587,8 +3584,8 @@ export class MemoryService { }; } } - const namespace = normalizeNamespace(request.namespace); - const userId = request.userId ?? namespace.userId; + const namespace = this.normalizeDeviceNamespace(request.namespace); + const userId = null; return { userId, namespace: { diff --git a/Memory/src/service/namespace/namespace-scope.ts b/Memory/src/service/namespace/namespace-scope.ts index 126b727d4..fc48e0e0f 100644 --- a/Memory/src/service/namespace/namespace-scope.ts +++ b/Memory/src/service/namespace/namespace-scope.ts @@ -6,7 +6,7 @@ import { type ResolvedWorkspaceIdentity } from "./workspace-identity.js"; -export function normalizeNamespace(namespace?: RuntimeNamespace): RuntimeNamespace & { userId: string; source: string; profileId: string } { +export function normalizeNamespace(namespace?: RuntimeNamespace): RuntimeNamespace & { userId: string | null; source: string; profileId: string } { return { source: namespace?.source ?? DEFAULT_NAMESPACE_SOURCE, profileId: namespace?.profileId ?? "default", @@ -15,7 +15,7 @@ export function normalizeNamespace(namespace?: RuntimeNamespace): RuntimeNamespa workspaceId: namespace?.workspaceId, workspacePath: namespace?.workspacePath, sessionKey: namespace?.sessionKey, - userId: namespace?.userId ?? "local-user", + userId: null, tenantId: namespace?.tenantId }; } @@ -32,7 +32,7 @@ export function sessionScopeForOpenRequest(request: SessionOpenRequest, namespac export function resolveV2WorkspaceIdentityForOpenRequest( request: SessionOpenRequest, - namespace: RuntimeNamespace & { userId: string } + namespace: RuntimeNamespace & { userId: string | null } ): ResolvedWorkspaceIdentity { return resolveWorkspaceIdentity(namespace.userId, { workspaceUri: request.workspaceUri, diff --git a/Memory/src/service/namespace/workspace-identity.ts b/Memory/src/service/namespace/workspace-identity.ts index 02c5b1315..85ef7ecc9 100644 --- a/Memory/src/service/namespace/workspace-identity.ts +++ b/Memory/src/service/namespace/workspace-identity.ts @@ -20,11 +20,9 @@ export interface ResolvedWorkspaceIdentity { * the Agent Adapter that owns the workspace. */ export function resolveWorkspaceIdentity( - effectiveUserId: string, + effectiveUserId: string | null, input: WorkspaceIdentityFields ): ResolvedWorkspaceIdentity { - const userId = effectiveUserId.trim(); - if (!userId) throw new TypeError("effectiveUserId must be non-empty"); const parsed = WorkspaceIdentityFieldsSchema.parse(input); if (!parsed.workspaceUri) { return { @@ -38,7 +36,7 @@ export function resolveWorkspaceIdentity( const workspaceIdentity = isLocalWorkspaceUri(parsed.workspaceUri) ? `local\0${parsed.workspaceHostId}\0${parsed.workspaceUri}` : `remote\0${parsed.workspaceUri}`; - const workspaceId = sha256Hex(`${userId}\0${workspaceIdentity}`); + const workspaceId = sha256Hex(workspaceIdentity); return { workspaceUri: parsed.workspaceUri, workspaceHostId: parsed.workspaceHostId ?? null, diff --git a/Memory/src/service/project-environment/profile-pipeline.ts b/Memory/src/service/project-environment/profile-pipeline.ts index 17941c4ea..f8ed1085d 100644 --- a/Memory/src/service/project-environment/profile-pipeline.ts +++ b/Memory/src/service/project-environment/profile-pipeline.ts @@ -72,7 +72,6 @@ export class ProjectEnvironmentProfilePipeline { async process(job: EvolutionJobRecord, derived: ProjectEnvironmentDerivedEvidence): Promise { const payload = projectEnvironmentProfileJobPayload(job.payload); - if (job.userId !== payload.userId) throw new Error("project_environment_job_owner_mismatch"); const state = this.deps.repos.projectEnvironments.getState(payload.userId, payload.projectId); if (!state || state.currentScanId !== payload.scanId) return; @@ -132,16 +131,16 @@ export class ProjectEnvironmentProfilePipeline { } export function projectEnvironmentProfileJobPayload(value: Record): { - userId: string; + userId: string | null; projectId: string; scanId: string; trigger: "session_start" | "token_compaction"; } { - const userId = stringValue(value.userId); + const userId = null; const projectId = stringValue(value.projectId); const scanId = stringValue(value.scanId); const trigger = value.trigger; - if (!userId || !projectId || !scanId || (trigger !== "session_start" && trigger !== "token_compaction")) { + if (!projectId || !scanId || (trigger !== "session_start" && trigger !== "token_compaction")) { throw new TypeError(`invalid project environment job payload: ${canonicalJson(value as never)}`); } return { userId, projectId, scanId, trigger }; diff --git a/Memory/src/service/project-environment/project-environment-service.ts b/Memory/src/service/project-environment/project-environment-service.ts index b4ff673a5..c26d56456 100644 --- a/Memory/src/service/project-environment/project-environment-service.ts +++ b/Memory/src/service/project-environment/project-environment-service.ts @@ -63,10 +63,9 @@ export class ProjectEnvironmentService { async processProfileJob(job: EvolutionJobRecord): Promise { const payload = projectEnvironmentProfileJobPayload(job.payload); - if (job.userId !== payload.userId) throw new Error("project_environment_job_owner_mismatch"); if (!this.deps.repos.projectEnvironments.beginScan(payload.userId, payload.projectId, payload.scanId)) return; try { - const scope = this.deps.repos.l3WorldModels.getScope(payload.userId, payload.projectId); + const scope = this.deps.repos.l3WorldModels.ensureScope(payload.userId, payload.projectId); if (!scope?.workspaceUri) throw new Error("project_environment_workspace_uri_missing"); const scan = await scanLocalProject(scope.workspaceUri); const classification = classifyProjectInventory(scan.entries); diff --git a/Memory/src/service/project-environment/types.ts b/Memory/src/service/project-environment/types.ts index 244b21eae..12555862d 100644 --- a/Memory/src/service/project-environment/types.ts +++ b/Memory/src/service/project-environment/types.ts @@ -58,7 +58,7 @@ export interface ProjectEnvironmentDerivedEvidence { } export interface ProjectEnvironmentStateRecord { - userId: string; + userId: string | null; projectId: string; projectKind: ProjectEnvironmentKind; status: ProjectEnvironmentScanStatus; diff --git a/Memory/src/service/read-model/episode.ts b/Memory/src/service/read-model/episode.ts index c7ae681da..492f971fb 100644 --- a/Memory/src/service/read-model/episode.ts +++ b/Memory/src/service/read-model/episode.ts @@ -69,7 +69,7 @@ export type MemoryGetResponse = MemoryDetailResponse | (EpisodeDetailItem & { }); export interface TimelineRequest extends RequestEnvelope { - userId?: string; + userId?: string | null; sessionId?: string; episodeId?: string; layers?: MemoryLayer[]; @@ -95,13 +95,13 @@ export interface EpisodeReadModelRepositories { getRawTurn(id: string): RawTurnRecord | undefined; listRawTurnsByEpisode(episodeId: string, limit?: number): RawTurnRecord[]; listTracePolicyLinks(input: { - userId?: string; + userId?: string | null; l1MemoryId?: string; l2MemoryId?: string; limit?: number; }): TracePolicyLinkRecord[]; listSkillTrials(input: { - userId?: string; + userId?: string | null; skillMemoryId?: string; sessionId?: string; episodeId?: string; @@ -121,6 +121,7 @@ export interface EpisodeReadModelRepositories { */ export interface EpisodeReadModelDependencies { repos: EpisodeReadModelRepositories; + projectMemory?(memory: MemoryRow): MemoryRow; assertMemorySearchEnabled(): void; resolveContext(request: RequestEnvelope): unknown; requireSession(id: string): SessionRecord; @@ -226,7 +227,8 @@ export class EpisodeReadModel { throw this.deps.invalidArgument("raw turn ids are internal; use the containing trace_ or episode_ id"); } - const memory = this.deps.repos.memories.get(id); + const storedMemory = this.deps.repos.memories.get(id); + const memory = storedMemory && (this.deps.projectMemory?.(storedMemory) ?? storedMemory); if (!memory) throw this.deps.notFound(`memory not found: ${id}`); this.deps.assertMemoryInScope(memory, request.namespace); const detail = this.deps.detailFromMemory(memory, this.deps.repos.processing.get(memory.id)); diff --git a/Memory/src/service/read-model/l3-world-model-context.ts b/Memory/src/service/read-model/l3-world-model-context.ts index 0fcce5af5..38bc60185 100644 --- a/Memory/src/service/read-model/l3-world-model-context.ts +++ b/Memory/src/service/read-model/l3-world-model-context.ts @@ -39,7 +39,7 @@ export class L3WorldModelContextReadModel { } private environmentProfileIsApplied( - userId: string, + userId: string | null, projectId: string, memoryScanId: unknown ): boolean { @@ -47,8 +47,8 @@ export class L3WorldModelContextReadModel { const row = this.repos.db.prepare( `SELECT applied_scan_id FROM l3_world_model_project_environment_state - WHERE user_id = ? AND project_id = ?` - ).get(userId, projectId) as { applied_scan_id: string | null } | undefined; + WHERE memory_project_id(project_id) = ? AND applied_scan_id = ? LIMIT 1` + ).get(projectId, memoryScanId) as { applied_scan_id: string | null } | undefined; return row?.applied_scan_id === memoryScanId; } } diff --git a/Memory/src/service/read-model/panel-read.ts b/Memory/src/service/read-model/panel-read.ts index 8a2586826..3bb1ec9e6 100644 --- a/Memory/src/service/read-model/panel-read.ts +++ b/Memory/src/service/read-model/panel-read.ts @@ -74,8 +74,8 @@ export interface PanelReadModelDependencies { schemaVersion: () => { version: number; lastMigrationId?: string }; health: (routes?: string[]) => HealthResponse; models: () => HealthResponse["models"]; - resolveContext: (request: RequestEnvelope & { sessionId?: string; userId?: string }) => { - userId: string; + resolveContext: (request: RequestEnvelope & { sessionId?: string; userId?: string | null }) => { + userId: string | null; conversationId?: string; namespace: RuntimeNamespace; }; @@ -94,7 +94,7 @@ export class PanelReadModel { } auditLogs(input: RequestEnvelope & { - userId?: string; + userId?: string | null; targetKind?: string; targetId?: string; limit?: number; @@ -114,7 +114,7 @@ export class PanelReadModel { } serviceLogs(input: RequestEnvelope & { - userId?: string; + userId?: string | null; limit?: number; cursor?: string; } = {}): { @@ -123,7 +123,7 @@ export class PanelReadModel { type: "change" | "audit" | "job"; id: string; at: string; - userId?: string; + userId?: string | null; action: string; targetKind?: string; targetId?: string; @@ -262,7 +262,7 @@ export class PanelReadModel { return typeof goal === "string" && goal.trim() ? goal.trim() : undefined; } - serviceMetrics(input: RequestEnvelope & { userId?: string } = {}): { + serviceMetrics(input: RequestEnvelope & { userId?: string | null } = {}): { storage: StorageBackendCapabilities; schema: { version: number; lastMigrationId?: string }; memory: ReturnType["stats"]; @@ -287,7 +287,7 @@ export class PanelReadModel { }; } - adminStatus(input: RequestEnvelope & { userId?: string } = {}, routes: string[] = []): { + adminStatus(input: RequestEnvelope & { userId?: string | null } = {}, routes: string[] = []): { health: HealthResponse; overview: ReturnType; failedJobs: EvolutionJobRecord[]; @@ -318,7 +318,7 @@ export class PanelReadModel { }; } - panelOverview(_input: RequestEnvelope & { userId?: string } = {}): { + panelOverview(_input: RequestEnvelope & { userId?: string | null } = {}): { stats: { byLayer: Record; byStatus: Record<"activated" | "resolving" | "archived" | "deleted", number>; @@ -357,19 +357,19 @@ export class PanelReadModel { }; } - panelOverviewSummary(input: RequestEnvelope & { userId?: string } = {}): { + panelOverviewSummary(input: RequestEnvelope & { userId?: string | null } = {}): { counts: { memories: number; userMemories: number; skills: number; experiences: number; worldModels: number }; sourceDistribution: Array<{ source: string; count: number; percentage: number }>; dailyActivity: Array<{ date: string; count: number }>; } { const memories = this.deps.repos.memories.listStats(); - const userId = input.userId ?? input.namespace?.userId; + const userId = null; const timeZone = resolveTimeZone(input.timeZone); const dates = panelDateKeys(this.now(), PANEL_DAILY_ACTIVITY_DAYS, timeZone); return { counts: { memories: memories.filter((memory) => memory.memoryLayer === "L1").length, - userMemories: userId ? this.deps.repos.userMemories.countForPanel({ userId }) : 0, + userMemories: this.deps.repos.userMemories.countForPanel({ userId }), skills: memories.filter((memory) => memory.memoryLayer === "Skill").length, experiences: memories.filter((memory) => memory.memoryLayer === "L2").length, worldModels: memories.filter((memory) => memory.memoryLayer === "L3").length @@ -379,7 +379,7 @@ export class PanelReadModel { }; } - panelAnalysis(input: RequestEnvelope & { userId?: string } = {}): { + panelAnalysis(input: RequestEnvelope & { userId?: string | null } = {}): { metrics: { avgRecallScore: number; recallEvents: number; @@ -422,7 +422,7 @@ export class PanelReadModel { } panelItems(input: RequestEnvelope & { - userId?: string; + userId?: string | null; layer?: RecallMemoryLayer; status?: "activated" | "resolving" | "archived" | "deleted"; q?: string; @@ -446,9 +446,9 @@ export class PanelReadModel { } { const pageSize = normalizePanelItemsLimit(input.limit); if (input.layer === "UserMemory") { - const userId = input.userId ?? input.namespace?.userId; + const userId = null; const requestedPage = normalizePageNumber(input.page); - if (!userId || input.status === "resolving") { + if (input.status === "resolving") { return emptyPanelItems(requestedPage, pageSize, this.now()); } const status = input.status === "activated" @@ -493,14 +493,27 @@ export class PanelReadModel { agentId: input.sourceAgent, excludedAgentIds: input.excludedSourceAgents }; - const total = input.q?.trim() + const projectedWorlds = input.layer === "L3" ? (() => { + const seen = new Set(); + return this.deps.repos.memories.list(filter, 100000, 0).flatMap(memory => { + if (memory.properties.internal_info.schema_version !== 2) return [memory]; + const project = typeof memory.info.project_id === "string" ? memory.info.project_id : null; + if (seen.has(project ?? "")) return []; + seen.add(project ?? ""); + const shared = this.deps.repos.l3WorldModels.getMemory(null, project); + if (!shared) return [memory]; + if (shared.status === "archived" && input.status !== "archived") return []; + return [shared]; + }).filter(memory => !input.q?.trim() || memory.memoryValue.toLowerCase().includes(input.q.trim().toLowerCase())); + })() : undefined; + const total = projectedWorlds ? projectedWorlds.length : input.q?.trim() ? this.deps.repos.memories.searchCount(input.q, { ...filter, status: filter.status ?? ["activated", "resolving"] }) : this.deps.repos.memories.count(filter); const totalPages = Math.max(1, Math.ceil(total / pageSize)); const requestedPage = normalizePageNumber(input.page); const page = Math.min(requestedPage, totalPages); const offset = normalizeOffsetCursor(input.cursor) ?? ((page - 1) * pageSize); - const memories = input.q?.trim() + const memories = projectedWorlds ? projectedWorlds.slice(offset, offset + pageSize) : input.q?.trim() ? this.deps.repos.memories.getMany(this.deps.repos.memories.searchPanelIds( input.q, { ...filter, status: filter.status ?? ["activated", "resolving"] }, @@ -537,7 +550,7 @@ export class PanelReadModel { const item = sourceText ? { ...listed, sourceText } : listed; const scope = scopesByMemoryId.get(memory.id); const worldModelScope = memory.memoryLayer === "L3" && scope && - scope.memoryId === memory.id && scope.userId === memory.userId + scope.memoryId === memory.id && (scope.projectId ?? null) === (memory.info.project_id ?? null) ? panelWorldModelScope(scope) : undefined; return worldModelScope ? { ...item, worldModelScope } : item; @@ -605,7 +618,7 @@ export class PanelReadModel { } panelChanges(input: RequestEnvelope & { - userId?: string; + userId?: string | null; limit?: number; cursor?: string; } = {}): { @@ -629,7 +642,7 @@ export class PanelReadModel { } panelJobs(input: RequestEnvelope & { - userId?: string; + userId?: string | null; status?: "queued" | "leased" | "succeeded" | "failed" | "dead_letter"; limit?: number; } = {}): { diff --git a/Memory/src/service/read-model/skill.ts b/Memory/src/service/read-model/skill.ts index e21157406..3b6cba143 100644 --- a/Memory/src/service/read-model/skill.ts +++ b/Memory/src/service/read-model/skill.ts @@ -74,7 +74,7 @@ export interface SkillRuntimeRepository { } | undefined; saveIdempotency(key: string, requestHash: string, response: unknown): void; listSkillTrials(input?: { - userId?: string; + userId?: string | null; skillMemoryId?: string; sessionId?: string; episodeId?: string; @@ -84,7 +84,7 @@ export interface SkillRuntimeRepository { outcome?: SkillTrialRecord["outcome"]; limit?: number; }): SkillTrialRecord[]; - latestChangeSeq(userId?: string, namespaceId?: string): number; + latestChangeSeq(userId?: string | null, namespaceId?: string): number; insertSkillTrial(trial: SkillTrialRecord): SkillTrialRecord; appendEpisodeDerivedMemory( episodeId: string, @@ -98,7 +98,7 @@ export interface SkillRuntimeRepository { kind: "skill_trial"; op: "created"; entityId: string; - userId: string; + userId: string | null; changeType: "skill_trial"; after: SkillTrialRecord; source: "skill.use"; @@ -144,7 +144,7 @@ export interface SkillReadModelDeps { } export interface ListSkillsRequest extends RequestEnvelope { - userId?: string; + userId?: string | null; q?: string; tags?: string[]; limit?: number; diff --git a/Memory/src/service/retrieval/cross-account-candidate-dedup.ts b/Memory/src/service/retrieval/cross-account-candidate-dedup.ts new file mode 100644 index 000000000..eda8fbf0e --- /dev/null +++ b/Memory/src/service/retrieval/cross-account-candidate-dedup.ts @@ -0,0 +1,146 @@ +import { canonicalNamespaceKey } from "../../storage/device-scope.js"; +import type { SeededChannelScores } from "../../algorithm/plugin-algorithms.js"; +import type { Repositories } from "../../storage/repositories.js"; +import { attachMemoryVector, memoryVector, memoryVectorEntries } from "../../storage/memory-vector-state.js"; +import type { MemoryRow } from "../../types.js"; +import { stableHash } from "../../utils/id.js"; +import { isRecord } from "../../utils/json.js"; + +export interface CrossAccountCandidateDedupResult { + memories: MemoryRow[]; + channelScoresByMemory: ReadonlyMap; + memoryIdByMemoryId: ReadonlyMap; +} + +/** + * Collapse copies of the same locally sourced memory before ranking. Account IDs + * are retained only on historical rows; the same native source turn may exist + * under more than one account before cross-account capture deduplication existed. + */ +export function deduplicateCrossAccountMemoryCandidates(input: { + memories: readonly MemoryRow[]; + repos: Repositories; + preferredUserId: string | null; + channelScoresByMemory?: ReadonlyMap; +}): CrossAccountCandidateDedupResult { + const groups = new Map(); + const nativeConversationBySessionId = new Map(); + input.memories.forEach((memory, index) => { + const groupState = input.repos.memories.nativeDuplicates(memory); + if (groupState.deleted) return; + const key = groupState.ids.length > 1 ? `native:${groupState.ids[0]}` : duplicateKey(memory, input.repos, nativeConversationBySessionId); + if (!key) { + groups.set(`unique:${index}:${memory.id}`, { firstIndex: index, memories: [memory] }); + return; + } + const group = groups.get(key); + if (group) group.memories.push(memory); + else groups.set(key, { firstIndex: index, memories: [memory] }); + }); + + const selected = [...groups.values()] + .sort((left, right) => left.firstIndex - right.firstIndex) + .map((group) => { + const representative = [...group.memories].sort((left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id) + )[0]!; + return { representative, members: group.memories }; + }); + + const channelScoresByMemory = new Map(); + const memoryIdByMemoryId = new Map(); + for (const group of selected) { + if (group.members.length === 1) { + const memory = group.representative; + memoryIdByMemoryId.set(memory.id, memory.id); + const scores = input.channelScoresByMemory?.get(memory.id); + if (scores) channelScoresByMemory.set(memory.id, scores); + continue; + } + + const merged: SeededChannelScores = {}; + for (const memory of group.members) { + memoryIdByMemoryId.set(memory.id, group.representative.id); + for (const vector of memoryVectorEntries(memory)) { + if (!memoryVector(group.representative, vector.vectorField)) { + attachMemoryVector(group.representative, vector); + } + } + for (const [channel, score] of Object.entries(input.channelScoresByMemory?.get(memory.id) ?? {})) { + const key = channel as keyof SeededChannelScores; + const current = merged[key]; + if (typeof score === "number" && (current === undefined || score > current)) { + merged[key] = score; + } + } + } + if (Object.keys(merged).length > 0) channelScoresByMemory.set(group.representative.id, merged); + } + + return { + memories: selected.map((group) => group.representative), + channelScoresByMemory, + memoryIdByMemoryId + }; +} + +function duplicateKey( + memory: MemoryRow, + repos: Repositories, + nativeConversationBySessionId: Map +): string | undefined { + if (memory.memoryLayer !== "L1" || memory.properties.internal_info.memory_kind === "work_memory") { + return undefined; + } + const nativeConversation = nativeConversationKey(memory, repos, nativeConversationBySessionId); + if (!nativeConversation) return undefined; + const internal = memory.properties.internal_info; + const trace = isRecord(internal.trace) + ? internal.trace + : isRecord(memory.info.trace) + ? memory.info.trace + : undefined; + const turnId = text(memory.info.turn_id) ?? text(trace?.turn_id); + if (!turnId) return undefined; + const stepIndex = typeof trace?.step_index === "number" && Number.isFinite(trace.step_index) + ? trace.step_index + : memory.contentHash ?? stableHash(memory.memoryValue); + return `native-turn:${stableHash({ nativeConversation, turnId, stepIndex })}`; +} + +function nativeConversationKey( + memory: MemoryRow, + repos: Repositories, + cache: Map +): string | undefined { + if (!memory.sessionId) return undefined; + if (cache.has(memory.sessionId)) return cache.get(memory.sessionId); + const session = repos.runtime.getSession(memory.sessionId); + if (!session) { + cache.set(memory.sessionId, undefined); + return undefined; + } + const conversationId = session.conversationId?.trim() || memory.conversationId?.trim(); + const source = session.source.trim(); + const profileId = session.profileId.trim(); + if (!conversationId || !source || !profileId) { + cache.set(memory.sessionId, undefined); + return undefined; + } + + const storedNamespaceKey = text(session.meta.source_namespace_key); + const tenantId = text(session.meta.tenant_id) ?? text(session.meta.tenantId) ?? null; + const namespaceKey = storedNamespaceKey ? canonicalNamespaceKey(repos.db, storedNamespaceKey) : stableHash({ + tenantId, + projectId: session.projectId ?? null, + workspaceId: session.workspaceId ?? null + }); + const key = stableHash({ source, profileId, namespaceKey, conversationId }); + cache.set(memory.sessionId, key); + return key; +} + +function text(value: unknown): string | undefined { + return typeof value === "string" && value.trim() ? value.trim() : undefined; +} diff --git a/Memory/src/service/retrieval/indexed-candidate-pool.ts b/Memory/src/service/retrieval/indexed-candidate-pool.ts index f907e2d4d..b160a2e32 100644 --- a/Memory/src/service/retrieval/indexed-candidate-pool.ts +++ b/Memory/src/service/retrieval/indexed-candidate-pool.ts @@ -35,7 +35,7 @@ export class IndexedCandidatePool { constructor(private readonly deps: IndexedCandidatePoolDependencies) {} retrievalCandidateCount(input: { - userId: string; + userId: string | null; layers: MemoryLayer[]; tags?: string[]; projectId?: string | null; @@ -51,7 +51,7 @@ export class IndexedCandidatePool { } hasRetrievalVectorCandidates(input: { - userId: string; + userId: string | null; layers: MemoryLayer[]; tags?: string[]; projectId?: string | null; @@ -68,7 +68,7 @@ export class IndexedCandidatePool { } async indexedRetrievalCandidatePool(input: { - userId: string; + userId: string | null; compiledQuery: CompiledRetrievalQuery; queryVector?: number[]; layers: MemoryLayer[]; diff --git a/Memory/src/service/retrieval/retrieval-service.ts b/Memory/src/service/retrieval/retrieval-service.ts index 28b74d396..201772a70 100644 --- a/Memory/src/service/retrieval/retrieval-service.ts +++ b/Memory/src/service/retrieval/retrieval-service.ts @@ -1826,8 +1826,8 @@ interface RetrievalDependencies { memorySearchEnabled(): boolean; queryRewriteEnabled(): boolean; requireEpisode(episodeId: string): EpisodeRecord; - resolveContext(request: RequestEnvelope & { sessionId?: string; userId?: string }): { - userId: string; + resolveContext(request: RequestEnvelope & { sessionId?: string; userId?: string | null }): { + userId: string | null; conversationId?: string; namespace: RuntimeNamespace; }; @@ -2198,6 +2198,7 @@ export class RetrievalService { timeFilter: RetrievalTimeFilter; tags?: string[]; limit: number; + preferredUserId: string | null; }): { retrieval: RetrievalResult; memories: MemoryRow[] } { const filter: MemoryFilter = { memoryLayer: "L1", @@ -2241,7 +2242,7 @@ export class RetrievalService { } private async retrieveUserMemories(input: { - userId: string; + userId: string | null; query: string; queryVectorText: string; queryExtract: RetrievalQueryExtract | null; @@ -2298,7 +2299,7 @@ export class RetrievalService { } private async retrieveSearchMemories(input: { - userId: string; + userId: string | null; projectId?: string | null; query: string; queryVectorText: string; diff --git a/Memory/src/service/session/session-turn-service.ts b/Memory/src/service/session/session-turn-service.ts index afbd694a2..8b017dc32 100644 --- a/Memory/src/service/session/session-turn-service.ts +++ b/Memory/src/service/session/session-turn-service.ts @@ -1,3 +1,5 @@ +import type { RuntimeNamespace } from "../../types.js"; +import { canonicalProjectId, deviceNamespace } from "../../storage/device-scope.js"; import { captureTurnSteps, classifyIntent, @@ -467,6 +469,29 @@ export function failureBurstAntiPattern(burst: ToolFailureBurst, reason: string) } export class SessionTurnService { + private normalizeDeviceNamespace(namespace?: RuntimeNamespace) { + return normalizeNamespace(deviceNamespace(this.deps.repos.db, normalizeNamespace(namespace))); + } + + private idempotencyRequest(request: T): T { + return { ...request, namespace: this.normalizeDeviceNamespace(request.namespace) }; + } + + private legacyIdempotencyMatches( + savedHash: string, request: RequestEnvelope, sessionId: string | undefined, + hash: (request: RequestEnvelope) => string + ): boolean { + if (savedHash === hash(request)) return true; + if (!sessionId || !request.namespace) return false; + const original = this.deps.repos.db.prepare("SELECT user_id FROM sessions WHERE id = ?") + .get(sessionId) as { user_id: string | null } | undefined; + // Only substitute the historical owner, leaving every semantic request field + // intact. A reused request ID with different content must still conflict. + return original?.user_id != null && savedHash === hash({ + ...request, namespace: { ...request.namespace, userId: original.user_id } + }); + } + private readonly toolFailureStates = new Map(); private readonly toolSuccessSteps = new Map(); private readonly toolStepCounters = new Map(); @@ -475,7 +500,7 @@ export class SessionTurnService { openSession(request: SessionOpenRequest, options: { createNew?: boolean; at?: string } = {}): { sessionId: string; - userId: string; + userId: string | null; source: string; profileId: string; projectId?: string | null; @@ -495,17 +520,22 @@ export class SessionTurnService { const idempotencyKey = request.adapterId && request.requestId ? `session.open:${request.adapterId}:${request.requestId}` : undefined; - const requestHash = stableHash({ operation: "session.open", request }); + const hashRequest = (body: RequestEnvelope) => stableHash({ operation: "session.open", request: body }); + const requestHash = hashRequest(this.idempotencyRequest(request)); if (idempotencyKey) { const existing = this.deps.repos.runtime.getIdempotency(idempotencyKey); if (existing) { - if (existing.requestHash !== requestHash) { + const response = existing.response as ReturnType; + if (existing.requestHash !== requestHash && !this.legacyIdempotencyMatches(existing.requestHash, request, response.sessionId, hashRequest)) { throw new MemoryServiceError("conflict", "idempotency key reused with different session.open request body"); } - return this.deps.withDuplicateFlag(existing.response) as ReturnType; + return this.deps.withDuplicateFlag({ ...response, userId: null, + projectId: canonicalProjectId(this.deps.repos.db, response.projectId), + workspaceId: canonicalProjectId(this.deps.repos.db, response.workspaceId) ?? undefined + }) as ReturnType; } } - const namespace = normalizeNamespace(request.namespace); + const namespace = this.normalizeDeviceNamespace(request.namespace); const at = options.at ?? nowIso(); if (request.l3WorldModelProtocolVersion === undefined && ( request.l3WorldModelTransition !== undefined || @@ -671,7 +701,8 @@ export class SessionTurnService { userId: namespace.userId, source, profileId, - hostSessionKey: namespace.sessionKey! + hostSessionKey: namespace.sessionKey!, + projectId: workspace.projectId ?? namespace.projectId }); if (request.sessionId && !existing) { @@ -774,10 +805,9 @@ export class SessionTurnService { ): void { const savedWorkspaceUri = optionalMetaString(session.meta, "workspace_uri"); const savedWorkspaceHostId = optionalMetaString(session.meta, "workspace_host_id"); - const requestProjectId = request.projectId ?? request.namespace?.projectId; - const requestWorkspaceId = request.workspaceId ?? request.namespace?.workspaceId; - const mismatch = session.userId !== namespace.userId || - session.source !== (request.source ?? namespace.source) || + const requestProjectId = request.projectId === undefined ? namespace.projectId : canonicalProjectId(this.deps.repos.db, request.projectId) ?? undefined; + const requestWorkspaceId = request.workspaceId === undefined ? namespace.workspaceId : canonicalProjectId(this.deps.repos.db, request.workspaceId) ?? undefined; + const mismatch = session.source !== (request.source ?? namespace.source) || session.profileId !== (request.profileId ?? namespace.profileId) || session.hostSessionKey !== namespace.sessionKey || (requestProjectId !== undefined && requestProjectId !== session.projectId) || @@ -1234,7 +1264,7 @@ export class SessionTurnService { if (request.channel !== "hook" && request.channel !== "agent_source_scan") { throw new MemoryServiceError("invalid_argument", "invalid source capture channel"); } - const namespace = normalizeNamespace(request.namespace ?? { + const namespace = this.normalizeDeviceNamespace(request.namespace ?? { source: identity.source, profileId: identity.profileId, sessionKey: identity.conversationId }); if (namespace.source !== identity.source || namespace.profileId !== identity.profileId || @@ -1356,7 +1386,7 @@ export class SessionTurnService { const episode = requestedEpisodeId ? this.deps.repos.runtime.getEpisode(requestedEpisodeId) : this.deps.repos.runtime.latestEpisodeForSession(session.id); - if (request.episodeId && (!episode || episode.sessionId !== session.id || episode.userId !== session.userId)) { + if (request.episodeId && (!episode || episode.sessionId !== session.id)) { throw new MemoryServiceError("forbidden", "source_episode_scope_conflict"); } if (episode && episode.status !== "open") { @@ -1417,8 +1447,13 @@ export class SessionTurnService { `memory.add:agent-source:${source}:turn:${legacyTurnId}` ).filter((memory) => legacyImportMatchesScope(memory, namespace, source, legacyTurnId)); if (memories.length === 0) return undefined; - if (memories.length > 1) return { status: "pending", reason: "legacy_identity_unresolved" }; - const memory = memories[0]!; + const distinctContent = new Set(memories.filter(memory => !memory.deletedAt && memory.status !== "deleted") + .map(memory => stableHash(legacyImportTexts(memory)))); + if (distinctContent.size > 1) return { status: "pending", reason: "legacy_identity_unresolved" }; + // An explicit deletion wins over another account's historical copy. + const memory = [...memories].sort((a, b) => + Number(Boolean(b.deletedAt || b.status === "deleted")) - Number(Boolean(a.deletedAt || a.status === "deleted")) || + a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))[0]!; const texts = legacyImportTexts(memory); const deleted = memory.status === "deleted" || Boolean(memory.deletedAt); if (!deleted && texts.user && texts.agent && (texts.user !== normalized.query || texts.agent !== normalized.answer)) { @@ -1447,7 +1482,7 @@ export class SessionTurnService { scope: SourceTurnCaptureScope, contentHash: string ): SourceTurnCompleteResponse { - const namespace = normalizeNamespace(request.namespace); + const namespace = this.normalizeDeviceNamespace(request.namespace); this.assertExplicitEpisodeInSourceScope(request, namespace, sourceTurn, scope); if (this.completedLegacySourceTurn(namespace, scope, sourceTurn.turnId)) { return { status: "pending", reason: "legacy_source_turn_already_completed" }; @@ -1500,8 +1535,6 @@ export class SessionTurnService { const episode = this.deps.repos.runtime.getEpisode(request.episodeId); const episodeSession = episode ? this.deps.repos.runtime.getSession(episode.sessionId) : undefined; const inScope = Boolean(episode && episodeSession && - episode.userId === namespace.userId && - episodeSession.userId === namespace.userId && episodeSession.source === sourceTurn.source && episodeSession.profileId === sourceTurn.profileId && (!episodeSession.conversationId || episodeSession.conversationId === sourceTurn.conversationId) && @@ -1580,7 +1613,7 @@ export class SessionTurnService { scope: SourceTurnCaptureScope, mappedSessionId?: string ): SessionRecord | { status: "pending"; reason: string } { - const namespace = normalizeNamespace(request.namespace); + const namespace = this.normalizeDeviceNamespace(request.namespace); const inScope = (candidate: SessionRecord): boolean => candidate.userId === namespace.userId && candidate.source === identity.source && candidate.profileId === identity.profileId && (!candidate.conversationId || candidate.conversationId === identity.conversationId) && @@ -1650,15 +1683,13 @@ export class SessionTurnService { const idempotencyKey = !sourceTurn && request.adapterId && request.requestId ? `turn.complete:${request.adapterId}:${request.requestId}` : undefined; - const requestHash = stableHash({ - turnId, - request - }); + const hashRequest = (body: RequestEnvelope) => stableHash({ turnId, request: body }); + const requestHash = hashRequest(this.idempotencyRequest(request)); if (idempotencyKey) { const existing = this.deps.repos.runtime.getIdempotency(idempotencyKey); if (existing) { - if (existing.requestHash !== requestHash) { + if (existing.requestHash !== requestHash && !this.legacyIdempotencyMatches(existing.requestHash, request, request.sessionId, hashRequest)) { throw new MemoryServiceError("conflict", "idempotency key reused with different request body"); } return { @@ -3076,7 +3107,7 @@ export class SessionTurnService { const correction = request.userMemoryCorrection; if (correction) { const target = this.deps.repos.userMemories.get(correction.targetMemoryId); - if (!target || target.userId !== rawTurn.userId || target.status !== "active") { + if (!target || target.status !== "active") { throw new MemoryServiceError("not_found", `active user memory not found: ${correction.targetMemoryId}`); } const content = correction.revisedContent.trim(); @@ -3139,7 +3170,7 @@ export class SessionTurnService { } private userMemoryEmbeddingJobs( - memory: { id: string; userId: string; content: string }, + memory: { id: string; userId: string | null; content: string }, rawTurn: RawTurnRecord, at: string ): EvolutionJobRecord[] { @@ -3157,7 +3188,7 @@ export class SessionTurnService { } private appendUserMemoryChange( - memory: { id: string; userId: string }, + memory: { id: string; userId: string | null }, before: unknown, op: "created" | "updated" | "archived", at: string diff --git a/Memory/src/service/trials/skill-trial-resolver.ts b/Memory/src/service/trials/skill-trial-resolver.ts index 93d512929..680e2dde5 100644 --- a/Memory/src/service/trials/skill-trial-resolver.ts +++ b/Memory/src/service/trials/skill-trial-resolver.ts @@ -211,7 +211,7 @@ updateSkillTrialStats(trial: SkillTrialRecord, at: string): void { } resolvePendingSkillTrialsForReward(input: { - userId: string; + userId: string | null; episodeId: string; rHuman: number; feedbackId?: string; diff --git a/Memory/src/service/user-memory/user-memory.ts b/Memory/src/service/user-memory/user-memory.ts index ddc9e42af..d17f58c53 100644 --- a/Memory/src/service/user-memory/user-memory.ts +++ b/Memory/src/service/user-memory/user-memory.ts @@ -52,7 +52,7 @@ export function userMemoryTextHash(text: string): string { export function buildUserMemory(input: { id: string; sourceTurnId: string; - userId: string; + userId: string | null; memoryTypes: UserMemoryType[]; content: string; createdAt: string; diff --git a/Memory/src/service/work-memory/work-memory-pipeline.ts b/Memory/src/service/work-memory/work-memory-pipeline.ts index da59a64aa..03e631d88 100644 --- a/Memory/src/service/work-memory/work-memory-pipeline.ts +++ b/Memory/src/service/work-memory/work-memory-pipeline.ts @@ -162,7 +162,7 @@ export class WorkMemoryPipeline { */ private takeWindow( chunk: readonly L3WorldModelInputTraceRecord[], - scope: { userId: string; sessionId: string } + scope: { userId: string | null; sessionId: string } ): { qa: WorkMemoryQaPair[]; throughTraceSeq: number; truncated: boolean } { const rawTurnIds = [...new Set(chunk.map((trace) => trace.rawTurnId))]; const lastTraceByRawTurnId = new Map(); @@ -244,17 +244,23 @@ export class WorkMemoryPipeline { throw new Error(`work memory trajectory hash mismatch: ${job.id}`); } const session = job.sessionId ? this.deps.repos.runtime.getSession(job.sessionId) : undefined; - if (!session || session.userId !== job.userId) { + if (!session) { throw new Error(`work memory job session scope mismatch: ${job.id}`); } const projectId = normalizeProjectId(session.projectId); const source = session.source.trim() || "unknown"; - const expectedScopeKey = stableHash(["work_memory", job.userId, projectId]); + const stored = this.deps.repos.db.prepare(`SELECT job.user_id, session.project_id + FROM evolution_jobs job JOIN sessions session ON session.id = job.session_id WHERE job.id = ?`) + .get(job.id) as {user_id: string | null; project_id: string | null} | undefined; + // Immutable legacy jobs retain their original hash; new jobs use device identity, + // including when their session was created before the schema upgrade. + const hashProjectId = stored?.user_id != null ? stored.project_id : projectId; + const expectedScopeKey = stableHash(["work_memory", stored?.user_id ?? null, hashProjectId]); const expectedDedupeKey = stableHash([ "work_memory_extract", - job.userId, + stored?.user_id ?? null, source, - projectId, + hashProjectId, payload.trajectoryHash ]); if (job.scopeKey !== expectedScopeKey || job.dedupeKey !== expectedDedupeKey) { @@ -293,7 +299,7 @@ export class WorkMemoryPipeline { const batch = this.deps.repos.l3WorldModels.getBatch(batchId); if (!batch) throw new Error(`work memory batch not found: ${batchId}`); const session = this.deps.repos.runtime.getSession(batch.sessionId); - if (!session || session.userId !== batch.userId || normalizeProjectId(session.projectId) !== normalizeProjectId(batch.projectId)) { + if (!session || normalizeProjectId(session.projectId) !== normalizeProjectId(batch.projectId)) { throw new Error(`work memory batch session scope mismatch: ${batchId}`); } return this.scheduleQaInTransaction({ @@ -315,11 +321,11 @@ export class WorkMemoryPipeline { */ private qaForRawTurn( rawTurnId: string, - scope: { userId: string; sessionId: string } + scope: { userId: string | null; sessionId: string } ): WorkMemoryQaPair | undefined { const rawTurn = this.deps.repos.runtime.getRawTurn(rawTurnId); if (!rawTurn || rawTurn.deletedAt || rawTurn.redactedAt) return undefined; - if (rawTurn.userId !== scope.userId || rawTurn.sessionId !== scope.sessionId) { + if (rawTurn.sessionId !== scope.sessionId) { throw new Error(`work memory RawTurn scope mismatch: ${rawTurnId}`); } const user = normalizeQaText(rawTurn.userText ?? ""); @@ -332,14 +338,14 @@ export class WorkMemoryPipeline { private scheduleQaInTransaction( input: { qa: readonly WorkMemoryQaPair[]; - userId: string; + userId: string | null; sessionId: string; projectId: string | null; }, at: string ): EvolutionJobRecord | undefined { const session = this.deps.repos.runtime.getSession(input.sessionId); - if (!session || session.userId !== input.userId) { + if (!session) { throw new Error(`work memory session scope mismatch: ${input.sessionId}`); } const qa = input.qa; @@ -353,7 +359,12 @@ export class WorkMemoryPipeline { projectId, trajectoryHash ]); - const existing = this.deps.repos.runtime.getJobByDedupeKey(dedupeKey); + const historical = this.deps.repos.db.prepare(`SELECT job.id FROM evolution_jobs job + JOIN sessions session ON session.id = job.session_id + WHERE job.job_type = 'work_memory_extract' AND session.source = ? + AND memory_project_id(session.project_id) IS ? AND json_extract(job.payload_json, '$.trajectoryHash') = ? + ORDER BY job.created_at, job.id LIMIT 1`).get(source, projectId, trajectoryHash) as {id: string} | undefined; + const existing = historical ? this.deps.repos.runtime.getJob(historical.id) : this.deps.repos.runtime.getJobByDedupeKey(dedupeKey); if (existing?.status === "queued" || existing?.status === "leased" || existing?.status === "succeeded" || existing?.status === "dead_letter") { return existing; } @@ -773,7 +784,6 @@ function validateDecisionOutput( function isWorkMemoryInScope(memory: MemoryRow, filter: MemoryFilter): boolean { if (memory.memoryLayer !== "L1" || memory.properties.internal_info.memory_kind !== "work_memory") return false; - if (filter.workMemoryUserId !== undefined && memory.userId !== filter.workMemoryUserId) return false; const project = normalizeProjectId(typeof memory.info.project_id === "string" ? memory.info.project_id : null); return filter.workMemoryProjectId === undefined || project === filter.workMemoryProjectId; } diff --git a/Memory/src/service/worker/job-handlers.ts b/Memory/src/service/worker/job-handlers.ts index 929db3d4a..22226754f 100644 --- a/Memory/src/service/worker/job-handlers.ts +++ b/Memory/src/service/worker/job-handlers.ts @@ -38,7 +38,7 @@ export type ClosedEpisodeTrigger = "topic_boundary" | "session_closed" | "episod export interface EnqueueJobInput { jobType: JobType; - userId: string; + userId: string | null; sessionId?: string; episodeId?: string; targetMemoryId?: string; diff --git a/Memory/src/storage/db.ts b/Memory/src/storage/db.ts index 2fbba53fd..5fde05896 100644 --- a/Memory/src/storage/db.ts +++ b/Memory/src/storage/db.ts @@ -1,3 +1,4 @@ +import { installDeviceScope } from "./device-scope.js"; import Database from "better-sqlite3"; import { copyFileSync, existsSync, mkdirSync } from "node:fs"; import { basename, dirname, join, resolve } from "node:path"; @@ -38,6 +39,7 @@ export class MemoryDb { this.createPreMigrationBackup(); migrate(this.db); } + installDeviceScope(this.db); } catch (error) { this.db.close(); throw error; diff --git a/Memory/src/storage/device-scope.ts b/Memory/src/storage/device-scope.ts new file mode 100644 index 000000000..278cde5fd --- /dev/null +++ b/Memory/src/storage/device-scope.ts @@ -0,0 +1,141 @@ +import { stableHash } from "../utils/id.js"; +import type { RuntimeNamespace } from "../types.js"; +import type Database from "better-sqlite3"; +import { resolveWorkspaceIdentity } from "../service/namespace/workspace-identity.js"; + +const installed = new WeakSet(); +const namespaces = new WeakMap>(); +const aliases = new WeakMap>(); + +/** Account identities are never memory scopes. Only evidence-backed workspace aliases are persisted. */ +export function installDeviceScope(db: Database.Database): void { + if (installed.has(db)) return; + aliases.set(db, new Map((db.prepare("SELECT legacy_id, project_id FROM memory_project_aliases").all() as Array<{legacy_id: string; project_id: string}>).map(row => [row.legacy_id, row.project_id]))); + db.function("memory_project_id", (value: unknown) => canonicalProjectId(db, typeof value === "string" ? value : null)); + namespaces.set(db, new Map()); + db.function("memory_namespace_key", (value: unknown) => typeof value === "string" ? canonicalNamespaceKey(db, value) : null); + installed.add(db); + refreshProjectAliases(db); +} + +export function canonicalProjectId(db: Database.Database, id: string | null | undefined): string | null { + if (!id?.trim()) return null; + return aliases.get(db)?.get(id) ?? id; +} + +export function registerProjectAlias(db: Database.Database, legacyId: string, uri: string, host?: string | null): string { + const identity = resolveWorkspaceIdentity(null, { workspaceUri: uri, ...(host ? { workspaceHostId: host } : {}) }); + if (!identity.projectId) return legacyId; + const existing = canonicalProjectId(db, legacyId); + if (existing !== legacyId && existing !== identity.projectId) throw new Error("conflicting legacy workspace identity"); + if (!db.readonly) db.prepare(`INSERT INTO memory_project_aliases(legacy_id, project_id, workspace_uri, workspace_host_id, created_at) + VALUES (?, ?, ?, ?, ?) ON CONFLICT(legacy_id) DO NOTHING`) + .run(legacyId, identity.projectId, uri, host ?? null, new Date().toISOString()); + aliases.get(db)?.set(legacyId, identity.projectId); + return identity.projectId; +} + +export function refreshProjectAliases(db: Database.Database): void { + namespaces.set(db, new Map()); + aliases.set(db, new Map((db.prepare("SELECT legacy_id, project_id FROM memory_project_aliases").all() as Array<{legacy_id:string;project_id:string}>).map(row => [row.legacy_id,row.project_id]))); + const rows = db.prepare(`SELECT project_id, workspace_id, meta_json FROM sessions WHERE project_id IS NOT NULL`).all() as Array<{ project_id: string; workspace_id: string | null; meta_json: string }>; + // Resolve all evidence before writing any mapping; conflicting identities remain isolated. + const candidates = new Map>(); + const scopeRows = db.prepare(`SELECT project_id, workspace_uri FROM l3_world_model_scopes + WHERE project_id IS NOT NULL AND workspace_uri IS NOT NULL`).all() as Array<{project_id:string;workspace_uri:string}>; + // Remote URIs are self-contained; local paths still require the originating host ID. + const evidence = [...rows, ...scopeRows.map(row => ({project_id:row.project_id, + meta_json:JSON.stringify({workspace_uri:row.workspace_uri})}))]; + for (const row of evidence) { + try { + const meta = JSON.parse(row.meta_json); + const uri = typeof meta.workspace_uri === "string" ? meta.workspace_uri : undefined; + const host = typeof meta.workspace_host_id === "string" ? meta.workspace_host_id : undefined; + if (!uri) continue; + const identity = resolveWorkspaceIdentity(null, { workspaceUri: uri, ...(host ? { workspaceHostId: host } : {}) }); + if (!identity.projectId) continue; + const group = candidates.get(row.project_id) ?? new Map(); + group.set(identity.projectId, { uri, host }); + candidates.set(row.project_id, group); + } catch { /* Incomplete legacy evidence must never merge unrelated projects. */ } + } + db.transaction(() => { + for (const [id, candidatesForId] of candidates) { + const existing = aliases.get(db)?.get(id); + if (candidatesForId.size !== 1 || (existing && !candidatesForId.has(existing))) { + // Later imports can reveal conflicting evidence. Never keep a stale merge. + if (!db.readonly) db.prepare("DELETE FROM memory_project_aliases WHERE legacy_id = ?").run(id); + aliases.get(db)?.delete(id); + continue; + } + const candidate = [...candidatesForId.values()][0]!; + registerProjectAlias(db, id, candidate.uri, candidate.host); + } + })(); + const workspaceTargets = new Map>(); + for (const row of rows) { + const projectId = canonicalProjectId(db, row.project_id); + if (row.workspace_id) { + const targets = workspaceTargets.get(row.workspace_id) ?? new Set(); + targets.add(projectId !== row.project_id && projectId?.startsWith("ws_") ? projectId.slice(3) : row.workspace_id); + workspaceTargets.set(row.workspace_id, targets); + } + } + for (const [id, targets] of workspaceTargets) { + const target = targets.size === 1 ? [...targets][0]! : id; + if (target === id) { + if (!db.readonly) db.prepare("DELETE FROM memory_project_aliases WHERE legacy_id = ? AND workspace_uri = ''").run(id); + aliases.get(db)?.delete(id); + continue; + } + if (!db.readonly) db.prepare(`INSERT INTO memory_project_aliases(legacy_id, project_id, workspace_uri, created_at) + VALUES (?, ?, '', ?) ON CONFLICT(legacy_id) DO UPDATE SET project_id = excluded.project_id`) + .run(id, target, new Date().toISOString()); + aliases.get(db)?.set(id, target); + } + for (const row of rows) { + const projectId = canonicalProjectId(db, row.project_id); + let meta: Record; + try { meta = JSON.parse(row.meta_json); } catch { continue; } + if (!meta || typeof meta !== "object") continue; + const tenantId = meta.tenant_id ?? meta.tenantId ?? null; + const current = stableHash({tenantId, projectId, workspaceId: canonicalProjectId(db, row.workspace_id)}); + const old = stableHash({tenantId, projectId: row.project_id, workspaceId: row.workspace_id}); + namespaces.get(db)?.set(old, current); + if (meta.source_namespace_key === old) namespaces.get(db)?.set(meta.source_namespace_key, current); + } +} + +export function canonicalNamespaceKey(db: Database.Database, key: string): string { + return namespaces.get(db)?.get(key) ?? key; +} + +export function deviceNamespace(db: Database.Database, namespace: RuntimeNamespace): RuntimeNamespace { + return {...namespace, userId: null, + projectId: canonicalProjectId(db, namespace.projectId) ?? undefined, + workspaceId: canonicalProjectId(db, namespace.workspaceId) ?? undefined}; +} + +/** Normalize only protocol envelopes, never user content or nested tool payloads. */ +export function memoryFingerprint(db: Database.Database, value: unknown, legacyUserId?: string): unknown { + if (!value || typeof value !== "object" || Array.isArray(value)) return value; + const record = value as Record; + const result = { ...record }; + if (record.namespace && typeof record.namespace === "object" && !Array.isArray(record.namespace)) { + result.namespace = legacyUserId === undefined + ? deviceNamespace(db, record.namespace as RuntimeNamespace) + : { ...record.namespace, userId: legacyUserId }; + } + for (const key of ["request", "skill"]) { + if (record[key] && typeof record[key] === "object") result[key] = memoryFingerprint(db, record[key], legacyUserId); + } + return result; +} + +export function matchesLegacyMemoryFingerprint(db: Database.Database, fingerprint: unknown, expected: string, + hash: (value: unknown) => string): boolean { + if (hash(fingerprint) === expected) return true; + const owners = db.prepare(`SELECT DISTINCT user_id FROM sessions WHERE user_id IS NOT NULL + UNION SELECT DISTINCT user_id FROM memories WHERE user_id IS NOT NULL`).pluck().all() as string[]; + return owners.some(owner => hash(memoryFingerprint(db, fingerprint, owner)) === expected); +} diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index 4a3213a59..6471e4e80 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -1,3 +1,4 @@ +import { canonicalNamespaceKey, installDeviceScope, canonicalProjectId, refreshProjectAliases } from "./device-scope.js"; import type Database from "better-sqlite3"; import { canonicalJson, @@ -58,6 +59,8 @@ import { compactHistoryChangePayload, compactHistoryChangeRow } from "./history- type SqlValue = string | number | Buffer | null; const BUNDLE_TABLES = [ + "memory_project_aliases", + "memory_l3_overrides", "memories", "memory_capture_claims", "source_turn_captures", @@ -88,7 +91,8 @@ const BUNDLE_TABLES = [ "audit_logs" ] as const; const CLEAR_MEMORY_TABLES = [ - ...BUNDLE_TABLES.filter((table) => table !== "source_turn_captures"), + "memory_l3_overrides", + ...BUNDLE_TABLES.filter((table) => table !== "source_turn_captures" && table !== "memory_project_aliases"), "memories_fts", "user_memories_fts", "memory_vector_entries", @@ -130,7 +134,7 @@ interface MemorySqlRow { export interface SessionRecord { id: string; - userId: string; + userId: string | null; source: string; profileId: string; profileLabel?: string; @@ -150,7 +154,7 @@ export interface SessionRecord { export interface EpisodeRecord { id: string; sessionId: string; - userId: string; + userId: string | null; projectId?: string; conversationId?: string; status: "open" | "processing" | "closed"; @@ -177,7 +181,7 @@ export interface EpisodeRecord { export interface SkillClusterRecord { id: string; - userId: string; + userId: string | null; projectId?: string; tools: string[]; artifacts: string[]; @@ -204,7 +208,7 @@ export interface RawTurnRecord { sessionId: string; episodeId: string; turnId: string; - userId: string; + userId: string | null; conversationId?: string; userText?: string; assistantText?: string; @@ -223,7 +227,7 @@ export interface RawTurnRecord { export type MemoryCaptureClaimOrigin = "turn_complete" | "agent_source_scan"; export interface MemoryCaptureClaimRecord { - userId: string; + userId: string | null; source: string; qaHash: string; primaryMemoryId: string; @@ -233,7 +237,7 @@ export interface MemoryCaptureClaimRecord { export interface FeedbackRecord { id: string; - userId: string; + userId: string | null; projectId?: string; conversationId?: string; sessionId?: string; @@ -255,7 +259,7 @@ export interface RecallEventRecord { sessionId?: string; episodeId?: string; turnId?: string; - userId: string; + userId: string | null; query: string; queryHash?: string; layers: MemoryLayer[]; @@ -310,7 +314,7 @@ export interface EvolutionJobRecord { jobType: JobType; status: JobStatus; dedupeKey?: string; - userId: string; + userId: string | null; sessionId?: string; episodeId?: string; targetMemoryId?: string; @@ -336,7 +340,7 @@ export type L3WorldModelTargetField = Exclude; action: string; @@ -559,6 +563,7 @@ export class MemoryRepository { ) {} insert(memory: MemoryRow): MemoryRow { + memory = { ...memory, userId: null }; const prepared = prepareMemoryForStorage(memory); this.db .prepare( @@ -581,6 +586,7 @@ export class MemoryRepository { } update(memory: MemoryRow): MemoryRow { + memory = { ...memory, userId: null }; return this.updateRow(memory, true); } @@ -602,7 +608,6 @@ export class MemoryRepository { .prepare( `UPDATE memories SET timeline = @timeline, - user_id = @userId, conversation_id = @conversationId, session_id = @sessionId, agent_id = @agentId, @@ -685,14 +690,14 @@ export class MemoryRepository { const row = this.db .prepare(`SELECT * FROM memories WHERE id = ? AND deleted_at IS NULL`) .get(id) as MemorySqlRow | undefined; - return row ? this.hydrate(memoryFromSql(row)) : undefined; + return row ? this.hydrate(memoryFromSql(row, this.db)) : undefined; } getIncludingDeleted(id: string): MemoryRow | undefined { const row = this.db .prepare(`SELECT * FROM memories WHERE id = ?`) .get(id) as MemorySqlRow | undefined; - return row ? this.hydrate(memoryFromSql(row)) : undefined; + return row ? this.hydrate(memoryFromSql(row, this.db)) : undefined; } getByKey(memoryLayer: MemoryLayer, key: string): MemoryRow | undefined { @@ -707,7 +712,7 @@ export class MemoryRepository { LIMIT 1` ) .get(memoryLayer, key) as MemorySqlRow | undefined; - return row ? this.hydrate(memoryFromSql(row)) : undefined; + return row ? this.hydrate(memoryFromSql(row, this.db)) : undefined; } getByKeyIncludingDeleted(memoryLayer: MemoryLayer, key: string): MemoryRow | undefined { @@ -718,7 +723,7 @@ export class MemoryRepository { ORDER BY updated_at DESC, id DESC LIMIT 1` ) .get(memoryLayer, key) as MemorySqlRow | undefined; - return row ? this.hydrate(memoryFromSql(row)) : undefined; + return row ? this.hydrate(memoryFromSql(row, this.db)) : undefined; } listMemoryTags(ids: readonly string[]): string[] { @@ -740,13 +745,13 @@ export class MemoryRepository { })); } - listUserMemoriesByKeyIncludingDeleted(userId: string, memoryKey: string): MemoryRow[] { + listUserMemoriesByKeyIncludingDeleted(userId: string | null, memoryKey: string): MemoryRow[] { const rows = this.db.prepare(` SELECT * FROM memories - WHERE user_id = ? AND memory_layer = 'L1' AND memory_key = ? + WHERE memory_layer = 'L1' AND memory_key = ? ORDER BY updated_at DESC, id DESC - `).all(userId, memoryKey) as MemorySqlRow[]; - return rows.map((row) => this.hydrate(memoryFromSql(row))); + `).all(memoryKey) as MemorySqlRow[]; + return rows.map((row) => this.hydrate(memoryFromSql(row, this.db))); } archivePriorReadOnlySkillVersions(input: { @@ -773,7 +778,7 @@ export class MemoryRepository { input.sourceSkillIdentity ) as MemorySqlRow[]; return rows.map((row) => { - const memory = this.hydrate(memoryFromSql(row)); + const memory = this.hydrate(memoryFromSql(row, this.db)); const internalSkill = isRecordLike(memory.properties.internal_info.skill) ? memory.properties.internal_info.skill : {}; @@ -804,7 +809,7 @@ export class MemoryRepository { .all(...ids) as MemorySqlRow[]; const vectors = this.vectors.getMany(rows.map((row) => row.id)); const byId = new Map(rows.map((row) => { - const memory = memoryFromSql(row); + const memory = memoryFromSql(row, this.db); attachMemoryVectors(memory, vectors.get(row.id) ?? []); return [row.id, memory]; })); @@ -822,7 +827,7 @@ export class MemoryRepository { LIMIT ? OFFSET ?` ) .all(...built.params, limit, offset) as MemorySqlRow[]; - return this.hydrateMany(rows.map(memoryFromSql)); + return this.hydrateMany(rows.map((row) => memoryFromSql(row, this.db))); } listStats(): MemoryStatsRow[] { @@ -904,7 +909,7 @@ export class MemoryRepository { LIMIT ?` ) .all(...(targetMemoryIds ?? []), limit) as MemorySqlRow[]; - return this.hydrateMany(rows.map(memoryFromSql)); + return this.hydrateMany(rows.map((row) => memoryFromSql(row, this.db))); } listUnprocessedAgentSourceImports(limit = 10000): MemoryRow[] { @@ -933,7 +938,7 @@ export class MemoryRepository { LIMIT ?` ) .all(limit) as MemorySqlRow[]; - return this.hydrateMany(rows.map(memoryFromSql)); + return this.hydrateMany(rows.map((row) => memoryFromSql(row, this.db))); } listImportMemoriesNeedingSummary(limit = 10000): MemoryRow[] { @@ -961,7 +966,7 @@ export class MemoryRepository { LIMIT ?` ) .all(limit) as MemorySqlRow[]; - return this.hydrateMany(rows.map(memoryFromSql)); + return this.hydrateMany(rows.map((row) => memoryFromSql(row, this.db))); } /** Bounded, idempotent upgrade pass; deleted observations never re-enter processing. */ @@ -1005,7 +1010,7 @@ export class MemoryRepository { LIMIT ?` ) .all(limit) as MemorySqlRow[]; - return this.hydrateMany(rows.map(memoryFromSql)); + return this.hydrateMany(rows.map((row) => memoryFromSql(row, this.db))); } count(filter: MemoryFilter = {}): number { @@ -1038,7 +1043,7 @@ export class MemoryRepository { const tagFilter = filter.tags?.map((tag) => tag.toLowerCase()) ?? []; const scored = rows - .map(memoryFromSql) + .map((row) => memoryFromSql(row, this.db)) .filter((memory) => tagFilter.length === 0 ? true @@ -1227,6 +1232,42 @@ export class MemoryRepository { }); } + /** Native source identity, never text alone, defines historical L1 duplicates. */ + nativeDuplicates(memory: MemoryRow): { ids: string[]; deleted: boolean } { + const fallback = {ids: [memory.id], deleted: Boolean(memory.deletedAt)}; + if (memory.memoryLayer !== "L1" || memory.properties.internal_info.memory_kind === "work_memory" || !memory.sessionId) return fallback; + const session = this.db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(memory.sessionId) as SqlSessionRow | undefined; + if (!session?.conversation_id) return fallback; + const trace = memory.properties.internal_info.trace as Record | undefined; + const turn = memory.info.turn_id ?? trace?.turn_id; + if (typeof turn !== "string" || !turn) return fallback; + const meta = parseJson>(session.meta_json, {}); + const tenantId = meta.tenant_id ?? meta.tenantId ?? null; + const namespaceKey = stableHash({tenantId, projectId:canonicalProjectId(this.db,session.project_id), + workspaceId:canonicalProjectId(this.db,session.workspace_id)}); + const sourceNamespaceKey = typeof meta.source_namespace_key === "string" + ? canonicalNamespaceKey(this.db,meta.source_namespace_key) : namespaceKey; + const rows = this.db.prepare(`SELECT m.id, m.deleted_at, m.status + FROM memories m JOIN sessions s ON s.id = m.session_id + WHERE m.memory_layer = 'L1' AND s.source = ? AND s.profile_id = ? AND s.conversation_id = ? + AND memory_project_id(s.project_id) IS memory_project_id(?) + AND memory_project_id(s.workspace_id) IS memory_project_id(?) + AND COALESCE(json_extract(s.meta_json, '$.tenant_id'), json_extract(s.meta_json, '$.tenantId'), '') = ? + AND COALESCE(memory_namespace_key(json_extract(s.meta_json, '$.source_namespace_key')), ?) = ? + AND COALESCE(json_extract(m.info_json, '$.turn_id'), json_extract(m.properties_json, '$.internal_info.trace.turn_id')) = ? + AND (json_extract(m.properties_json, '$.internal_info.trace.step_index') = ? + OR (? IS NULL AND json_extract(m.properties_json, '$.internal_info.trace.step_index') IS NULL + AND m.content_hash IS ? AND m.memory_value = ?)) + AND COALESCE(json_extract(m.properties_json, '$.internal_info.memory_kind'), 'trace') = ? + ORDER BY m.created_at, m.id`).all(session.source, session.profile_id, session.conversation_id, + session.project_id, session.workspace_id, + tenantId ?? "", namespaceKey, sourceNamespaceKey, + turn, typeof trace?.step_index === "number" ? trace.step_index : null, + typeof trace?.step_index === "number" ? trace.step_index : null, memory.contentHash ?? null, memory.memoryValue, + memory.properties.internal_info.memory_kind ?? "trace") as Array<{id: string; deleted_at: string | null; status: string}>; + return {ids: rows.map(row => row.id), deleted: rows.some(row => row.deleted_at || row.status === "deleted")}; + } + softDelete(id: string, deletedAt = nowIso()): MemoryRow | undefined { const memory = this.get(id); if (!memory) { @@ -1244,7 +1285,7 @@ export class MemoryRepository { }); } - countByLayer(userId?: string): Record { + countByLayer(userId?: string | null): Record { void userId; const rows = this.db .prepare( @@ -1263,7 +1304,7 @@ export class MemoryRepository { }; } - countByStatus(userId?: string): Record { + countByStatus(userId?: string | null): Record { void userId; const rows = this.db .prepare( @@ -1364,31 +1405,23 @@ export class MemoryCaptureClaimRepository { claimed: boolean; claim: MemoryCaptureClaimRecord; } { - const result = this.db.prepare( - `INSERT OR IGNORE INTO memory_capture_claims ( - user_id, source, qa_hash, primary_memory_id, captured_by, created_at - ) VALUES (?, ?, ?, ?, ?, ?)` - ).run( - input.userId, - input.source, - input.qaHash, - input.primaryMemoryId, - input.capturedBy, - input.createdAt - ); - const claim = this.get(input.userId, input.source, input.qaHash); - if (!claim) { - throw new Error("memory capture claim was not persisted"); - } - return { claimed: result.changes === 1, claim }; + return this.db.transaction(() => { + const existing = this.get(null, input.source, input.qaHash); + if (existing) return { claimed: false, claim: existing }; + this.db.prepare(`INSERT INTO memory_capture_claims ( + user_id, source, qa_hash, primary_memory_id, captured_by, created_at + ) VALUES (NULL, ?, ?, ?, ?, ?)`) + .run(input.source, input.qaHash, input.primaryMemoryId, input.capturedBy, input.createdAt); + return { claimed: true, claim: { ...input, userId: null } }; + })(); } - get(userId: string, source: string, qaHash: string): MemoryCaptureClaimRecord | undefined { + get(userId: string | null, source: string, qaHash: string): MemoryCaptureClaimRecord | undefined { const row = this.db.prepare( `SELECT user_id, source, qa_hash, primary_memory_id, captured_by, created_at FROM memory_capture_claims - WHERE user_id = ? AND source = ? AND qa_hash = ?` - ).get(userId, source, qaHash) as { + WHERE source = ? AND qa_hash = ? ORDER BY created_at, primary_memory_id LIMIT 1` + ).get(source, qaHash) as { user_id: string; source: string; qa_hash: string; @@ -1397,7 +1430,7 @@ export class MemoryCaptureClaimRepository { created_at: string; } | undefined; return row ? { - userId: row.user_id, + userId: null, source: row.source, qaHash: row.qa_hash, primaryMemoryId: row.primary_memory_id, @@ -1407,6 +1440,23 @@ export class MemoryCaptureClaimRepository { } } +// Historical equal-text UserMemory rows remain on disk. Corrections/deletions +// suppress older copies; a genuinely new expression after the tombstone may be stored. +const ACTIVE_USER_MEMORY_VIEW_SQL = ` + NOT EXISTS (SELECT 1 FROM user_memories tombstone + WHERE tombstone.normalized_user_text_hash = user_memories.normalized_user_text_hash + AND (tombstone.deleted_at IS NOT NULL OR tombstone.replaced_by_memory_id IS NOT NULL) + AND tombstone.updated_at >= user_memories.created_at) + AND NOT EXISTS (SELECT 1 FROM user_memories earlier + WHERE earlier.normalized_user_text_hash = user_memories.normalized_user_text_hash + AND earlier.status = 'active' AND earlier.deleted_at IS NULL + AND NOT EXISTS (SELECT 1 FROM user_memories tombstone + WHERE tombstone.normalized_user_text_hash = earlier.normalized_user_text_hash + AND (tombstone.deleted_at IS NOT NULL OR tombstone.replaced_by_memory_id IS NOT NULL) + AND tombstone.updated_at >= earlier.created_at) + AND (earlier.created_at < user_memories.created_at OR + (earlier.created_at = user_memories.created_at AND earlier.id < user_memories.id)))`; + export class UserMemoryRepository { constructor(private readonly db: Database.Database) {} @@ -1430,13 +1480,13 @@ export class UserMemoryRepository { confirmExisting(input: { id: string; - userId: string; + userId: string | null; sourceTurnId: string; memoryTypes: UserMemoryType[]; updatedAt: string; }): { memory: UserMemoryRecord; previous: UserMemoryRecord } | undefined { const previous = this.get(input.id); - if (!previous || previous.userId !== input.userId || previous.status !== "active") return undefined; + if (!previous || previous.status !== "active") return undefined; const memory = this.update({ ...previous, memoryTypes: uniq([...previous.memoryTypes, ...input.memoryTypes]), @@ -1449,6 +1499,7 @@ export class UserMemoryRepository { } insert(memory: UserMemoryRecord): UserMemoryRecord { + memory = { ...memory, userId: null }; this.db.prepare( `INSERT INTO user_memories ( id, source_turn_id, user_id, memory_types_json, content, @@ -1458,7 +1509,7 @@ export class UserMemoryRepository { created_at, updated_at, deleted_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` ).run( - memory.id, memory.sourceTurnId, memory.userId, toJson(memory.memoryTypes), memory.content, + memory.id, memory.sourceTurnId, null, toJson(memory.memoryTypes), memory.content, memory.normalizedUserTextHash, toJson(memory.sourceTurnRefs), memory.status, memory.replacesMemoryId ?? null, memory.replacedByMemoryId ?? null, memory.archivedAt ?? null, memory.archiveReason ?? null, @@ -1471,16 +1522,17 @@ export class UserMemoryRepository { } update(memory: UserMemoryRecord): UserMemoryRecord { + memory = { ...memory, userId: null }; this.db.prepare( `UPDATE user_memories SET - source_turn_id = ?, user_id = ?, memory_types_json = ?, content = ?, + source_turn_id = ?, memory_types_json = ?, content = ?, normalized_user_text_hash = ?, source_turn_refs_json = ?, status = ?, replaces_memory_id = ?, replaced_by_memory_id = ?, archived_at = ?, archive_reason = ?, embedding_json = ?, embedding_model = ?, embedding_provider = ?, updated_at = ?, deleted_at = ? WHERE id = ?` ).run( - memory.sourceTurnId, memory.userId, toJson(memory.memoryTypes), memory.content, + memory.sourceTurnId, toJson(memory.memoryTypes), memory.content, memory.normalizedUserTextHash, toJson(memory.sourceTurnRefs), memory.status, memory.replacesMemoryId ?? null, memory.replacedByMemoryId ?? null, memory.archivedAt ?? null, memory.archiveReason ?? null, @@ -1515,16 +1567,16 @@ export class UserMemoryRepository { return ids.map((id) => byId.get(id)).filter((item): item is UserMemoryRecord => Boolean(item)); } - listActive(userId: string, limit = 2000): UserMemoryRecord[] { + listActive(userId: string | null, limit = 2000): UserMemoryRecord[] { return (this.db.prepare( `SELECT * FROM user_memories - WHERE user_id = ? AND status = 'active' AND deleted_at IS NULL + WHERE status = 'active' AND deleted_at IS NULL AND ${ACTIVE_USER_MEMORY_VIEW_SQL} ORDER BY updated_at DESC, id DESC LIMIT ?` - ).all(userId, limit) as UserMemorySqlRow[]).map(userMemoryFromSql); + ).all(limit) as UserMemorySqlRow[]).map(userMemoryFromSql); } listForPanel(input: { - userId: string; + userId: string | null; status?: UserMemoryStatus; query?: string; sourceAgent?: string; @@ -1541,7 +1593,7 @@ export class UserMemoryRepository { } countForPanel(input: { - userId: string; + userId: string | null; status?: UserMemoryStatus; query?: string; sourceAgent?: string; @@ -1552,31 +1604,31 @@ export class UserMemoryRepository { return row.count; } - embeddingDimensionCounts(userId: string): { + embeddingDimensionCounts(userId: string | null): { totalSlots: number; dimensions: Array<{ dimension: number; count: number }>; } { - const where = "user_id = ? AND status = 'active' AND deleted_at IS NULL"; + const where = `status = 'active' AND deleted_at IS NULL AND ${ACTIVE_USER_MEMORY_VIEW_SQL}`; const totalSlots = Number(this.db.prepare( `SELECT COUNT(*) FROM user_memories WHERE ${where}` - ).pluck().get(userId) ?? 0); + ).pluck().get() ?? 0); const dimensions = this.db.prepare( `SELECT json_array_length(embedding_json) AS dimension, COUNT(*) AS count FROM user_memories WHERE ${where} AND embedding_json IS NOT NULL GROUP BY json_array_length(embedding_json) ORDER BY count DESC, dimension DESC` - ).all(userId) as Array<{ dimension: number; count: number }>; + ).all() as Array<{ dimension: number; count: number }>; return { totalSlots, dimensions }; } - getActiveByNormalizedText(userId: string, hash: string): UserMemoryRecord | undefined { + getActiveByNormalizedText(userId: string | null, hash: string): UserMemoryRecord | undefined { const row = this.db.prepare( `SELECT * FROM user_memories - WHERE user_id = ? AND normalized_user_text_hash = ? - AND status = 'active' AND deleted_at IS NULL + WHERE normalized_user_text_hash = ? + AND status = 'active' AND deleted_at IS NULL AND ${ACTIVE_USER_MEMORY_VIEW_SQL} ORDER BY updated_at DESC, id DESC LIMIT 1` - ).get(userId, hash) as UserMemorySqlRow | undefined; + ).get(hash) as UserMemorySqlRow | undefined; return row ? userMemoryFromSql(row) : undefined; } @@ -1627,39 +1679,38 @@ export class UserMemoryRepository { }) : undefined; } - searchFtsIds(userId: string, ftsMatch: string | undefined | null, limit: number): MemorySearchIdHit[] { + searchFtsIds(userId: string | null, ftsMatch: string | undefined | null, limit: number): MemorySearchIdHit[] { if (!ftsMatch || limit <= 0) return []; try { const rows = this.db.prepare( `SELECT user_memories.id AS id FROM user_memories_fts JOIN user_memories ON user_memories.id = user_memories_fts.id - WHERE user_memories.user_id = ? - AND user_memories.status = 'active' - AND user_memories.deleted_at IS NULL + WHERE user_memories.status = 'active' + AND user_memories.deleted_at IS NULL AND ${ACTIVE_USER_MEMORY_VIEW_SQL} AND user_memories_fts MATCH ? ORDER BY rank LIMIT ?` - ).all(userId, ftsMatch, limit) as Array<{ id: string }>; + ).all(ftsMatch, limit) as Array<{ id: string }>; return rows.map((row, index) => ({ id: row.id, score: 1 / (index + 1), channel: "fts" })); } catch { return []; } } - searchPatternIds(userId: string, terms: readonly string[], limit: number): MemorySearchIdHit[] { + searchPatternIds(userId: string | null, terms: readonly string[], limit: number): MemorySearchIdHit[] { const normalized = terms.map((term) => term.trim().toLowerCase()).filter(Boolean).slice(0, 16); if (normalized.length === 0 || limit <= 0) return []; const clauses = normalized.map(() => `lower(content) LIKE ? ESCAPE '\\'`); const rows = this.db.prepare( `SELECT id FROM user_memories - WHERE user_id = ? AND status = 'active' AND deleted_at IS NULL + WHERE status = 'active' AND deleted_at IS NULL AND ${ACTIVE_USER_MEMORY_VIEW_SQL} AND (${clauses.join(" OR ")}) ORDER BY updated_at DESC, id DESC LIMIT ?` - ).all(userId, ...normalized.map((term) => `%${escapeLikePattern(term)}%`), limit) as Array<{ id: string }>; + ).all(...normalized.map((term) => `%${escapeLikePattern(term)}%`), limit) as Array<{ id: string }>; return rows.map((row, index) => ({ id: row.id, score: 1 / (index + 1), channel: "pattern" })); } - searchVectorIds(userId: string, query: readonly number[], limit: number): MemorySearchIdHit[] { + searchVectorIds(userId: string | null, query: readonly number[], limit: number): MemorySearchIdHit[] { if (query.length === 0 || limit <= 0) return []; return this.listActive(userId) .flatMap((memory) => memory.embedding?.length === query.length @@ -1779,7 +1830,7 @@ export class MemoryProcessingRepository { } export interface SourceTurnCaptureScope { - userId: string; + userId: string | null; source: string; profileId: string; namespaceKey: string; @@ -1801,7 +1852,7 @@ export interface SourceTurnCaptureRecord extends SourceTurnCaptureScope { function sourceTurnCaptureFromSql(row: Record): SourceTurnCaptureRecord { return { - userId: String(row.user_id), source: String(row.source), profileId: String(row.profile_id), + userId: null, source: String(row.source), profileId: String(row.profile_id), namespaceKey: String(row.namespace_key), conversationId: String(row.conversation_id), turnId: String(row.turn_id), contentHash: String(row.content_hash), sessionId: typeof row.session_id === "string" ? row.session_id : undefined, episodeId: typeof row.episode_id === "string" ? row.episode_id : undefined, @@ -1889,16 +1940,17 @@ export class RuntimeRepository { getSourceTurnCapture(scope: SourceTurnCaptureScope, turnId: string): SourceTurnCaptureRecord | undefined { const row = this.db.prepare(`SELECT * FROM source_turn_captures - WHERE user_id = @userId AND source = @source AND profile_id = @profileId - AND namespace_key = @namespaceKey AND conversation_id = @conversationId AND turn_id = @turnId` + WHERE source = @source AND profile_id = @profileId + AND memory_namespace_key(namespace_key) = memory_namespace_key(@namespaceKey) AND conversation_id = @conversationId AND turn_id = @turnId + ORDER BY created_at ASC, raw_turn_id ASC LIMIT 1` ).get({ ...scope, turnId }) as Record | undefined; return row ? sourceTurnCaptureFromSql(row) : undefined; } latestSourceTurnCapture(scope: SourceTurnCaptureScope): SourceTurnCaptureRecord | undefined { const row = this.db.prepare(`SELECT * FROM source_turn_captures - WHERE user_id = @userId AND source = @source AND profile_id = @profileId - AND namespace_key = @namespaceKey AND conversation_id = @conversationId AND session_id IS NOT NULL + WHERE source = @source AND profile_id = @profileId + AND memory_namespace_key(namespace_key) = memory_namespace_key(@namespaceKey) AND conversation_id = @conversationId AND session_id IS NOT NULL ORDER BY completed_at DESC, source_sequence DESC LIMIT 1` ).get(scope) as Record | undefined; return row ? sourceTurnCaptureFromSql(row) : undefined; @@ -1909,8 +1961,8 @@ export class RuntimeRepository { after?: SourceTurnCaptureRecord; } { const prefix = `SELECT * FROM source_turn_captures - WHERE user_id = @userId AND source = @source AND profile_id = @profileId - AND namespace_key = @namespaceKey AND conversation_id = @conversationId AND session_id IS NOT NULL`; + WHERE source = @source AND profile_id = @profileId + AND memory_namespace_key(namespace_key) = memory_namespace_key(@namespaceKey) AND conversation_id = @conversationId AND session_id IS NOT NULL`; const before = this.db.prepare(`${prefix} AND started_at < @startedAt ORDER BY started_at DESC LIMIT 1`) .get({ ...scope, startedAt }) as Record | undefined; const after = this.db.prepare(`${prefix} AND started_at > @startedAt ORDER BY started_at ASC LIMIT 1`) @@ -1925,7 +1977,7 @@ export class RuntimeRepository { session_id, episode_id, raw_turn_id, response_json, started_at, completed_at, source_sequence, created_at ) VALUES (@userId, @source, @profileId, @namespaceKey, @conversationId, @turnId, @contentHash, @sessionId, @episodeId, @rawTurnId, @responseJson, @startedAt, @completedAt, @sequence, @createdAt)` - ).run({ ...capture, sessionId: capture.sessionId ?? null, episodeId: capture.episodeId ?? null, + ).run({ ...capture, userId: null, sessionId: capture.sessionId ?? null, episodeId: capture.episodeId ?? null, rawTurnId: capture.rawTurnId ?? null, responseJson: toJson(capture.response), sequence: capture.sequence ?? null }); } @@ -1946,11 +1998,11 @@ export class RuntimeRepository { .run(toJson(rawTurnIds), toJson(l1MemoryIds), episodeId); } - sourceConversationSessions(input: { userId: string; source: string; profileId: string; conversationId: string }): SessionRecord[] { - return (this.db.prepare(`SELECT * FROM sessions WHERE user_id = @userId AND source = @source + sourceConversationSessions(input: { userId?: string | null; source: string; profileId: string; conversationId: string }): SessionRecord[] { + return (this.db.prepare(`SELECT * FROM sessions WHERE source = @source AND profile_id = @profileId AND (host_session_key = @conversationId OR conversation_id = @conversationId OR host_session_key = @source || '-memory-' || @conversationId) - ORDER BY opened_at DESC`).all(input) as SqlSessionRow[]).map(sessionFromSql); + ORDER BY opened_at DESC`).all(input) as SqlSessionRow[]).map((row) => sessionFromSql(row, this.db)); } bindSessionSourceConversation(id: string, conversationId: string): boolean { @@ -1965,13 +2017,14 @@ export class RuntimeRepository { conversationId: string ): boolean { const result = this.db.prepare(`UPDATE raw_turns SET conversation_id = @conversationId - WHERE id = @id AND session_id = @sessionId AND user_id = @userId AND turn_id = @turnId + WHERE id = @id AND session_id = @sessionId AND turn_id = @turnId AND (conversation_id IS NULL OR conversation_id = '' OR conversation_id = @conversationId)`) .run({ ...scope, conversationId }); return result.changes === 1; } createSession(session: SessionRecord): SessionRecord { + session = { ...session, userId: null }; this.db .prepare( `INSERT INTO sessions ( @@ -1986,6 +2039,7 @@ export class RuntimeRepository { ) .run({ ...session, + userId: null, profileLabel: session.profileLabel ?? null, projectId: session.projectId ?? null, workspaceId: session.workspaceId ?? null, @@ -2003,29 +2057,24 @@ export class RuntimeRepository { const row = this.db .prepare(`SELECT * FROM sessions WHERE id = ?`) .get(id) as SqlSessionRow | undefined; - return row ? sessionFromSql(row) : undefined; + return row ? sessionFromSql(row, this.db) : undefined; } findOpenSessionByHostKey(input: { - userId: string; + userId: string | null; source: string; profileId: string; hostSessionKey: string; + projectId?: string | null; }): SessionRecord | undefined { - const row = this.db - .prepare( - `SELECT * - FROM sessions - WHERE user_id = ? - AND source = ? - AND profile_id = ? - AND host_session_key = ? - AND status = 'open' - ORDER BY updated_at DESC - LIMIT 1` - ) - .get(input.userId, input.source, input.profileId, input.hostSessionKey) as SqlSessionRow | undefined; - return row ? sessionFromSql(row) : undefined; + const rows = this.db.prepare(`SELECT * FROM sessions + WHERE source = ? AND profile_id = ? AND host_session_key = ? AND status = 'open' + ORDER BY opened_at ASC, id ASC`) + .all(input.source,input.profileId,input.hostSessionKey) as SqlSessionRow[]; + const candidates = rows.map(row => sessionFromSql(row,this.db)).filter(session => + input.projectId === undefined || (session.projectId ?? null) === canonicalProjectId(this.db,input.projectId)); + if (new Set(candidates.map(session=>session.projectId ?? "")).size > 1) return undefined; + return candidates[0]; } updateSessionScope( @@ -2116,7 +2165,7 @@ export class RuntimeRepository { ORDER BY updated_at DESC` ) .all(sessionId) as SqlEpisodeRow[]; - return rows.map(episodeFromSql); + return rows.map((row) => episodeFromSql(row, this.db)); } listIdleEpisodes( @@ -2158,7 +2207,7 @@ export class RuntimeRepository { ORDER BY episodes.opened_at ASC, episodes.id ASC` ) .all(...params) as SqlEpisodeRow[]; - return rows.map(episodeFromSql); + return rows.map((row) => episodeFromSql(row, this.db)); } closeOpenEpisodesForSession(sessionId: string, at = nowIso()): EpisodeRecord[] { @@ -2176,6 +2225,7 @@ export class RuntimeRepository { .run(at, at, sessionId); return rows.map((episode) => ({ ...episode, + userId: null, status: "closed" as const, closedAt: episode.closedAt ?? at, updatedAt: at @@ -2203,6 +2253,7 @@ export class RuntimeRepository { if (result.changes === 0) return undefined; return { ...episode, + userId: null, status: "closed", closedAt: episode.closedAt ?? at, meta, @@ -2232,6 +2283,7 @@ export class RuntimeRepository { .run(toJson(meta), at, episodeId); return { ...episode, + userId: null, status: "open", closedAt: null, rTask: undefined, @@ -2241,7 +2293,7 @@ export class RuntimeRepository { }; } - countEpisodesByStatus(userId?: string): Record<"open" | "processing" | "closed", number> { + countEpisodesByStatus(userId?: string | null): Record<"open" | "processing" | "closed", number> { void userId; const clauses = ["1=1"]; const params: SqlValue[] = []; @@ -2262,7 +2314,7 @@ export class RuntimeRepository { return counts; } - countEpisodes(userId?: string, query?: string, sourceAgent?: string): number { + countEpisodes(userId?: string | null, query?: string, sourceAgent?: string): number { const built = buildEpisodeWhere(userId, query, sourceAgent); const row = this.db .prepare( @@ -2274,7 +2326,7 @@ export class RuntimeRepository { return Number(row?.count ?? 0); } - listEpisodes(userId?: string, limit = 50, offset = 0, query?: string, sourceAgent?: string): EpisodeRecord[] { + listEpisodes(userId?: string | null, limit = 50, offset = 0, query?: string, sourceAgent?: string): EpisodeRecord[] { const built = buildEpisodeWhere(userId, query, sourceAgent); const rows = this.db .prepare( @@ -2285,7 +2337,7 @@ export class RuntimeRepository { LIMIT ? OFFSET ?` ) .all(...built.params, limit, offset) as SqlEpisodeRow[]; - return rows.map(episodeFromSql); + return rows.map((row) => episodeFromSql(row, this.db)); } deleteEpisode(id: string): boolean { @@ -2294,6 +2346,7 @@ export class RuntimeRepository { } createEpisode(episode: EpisodeRecord): EpisodeRecord { + episode = { ...episode, userId: null }; this.db .prepare( `INSERT INTO episodes ( @@ -2314,6 +2367,7 @@ export class RuntimeRepository { ) .run({ ...episode, + userId: null, projectId: episode.projectId ?? null, conversationId: episode.conversationId ?? null, title: episode.title ?? null, @@ -2341,7 +2395,7 @@ export class RuntimeRepository { const row = this.db .prepare(`SELECT * FROM episodes WHERE id = ?`) .get(id) as SqlEpisodeRow | undefined; - return row ? episodeFromSql(row) : undefined; + return row ? episodeFromSql(row, this.db) : undefined; } updateEpisodeMeta(episodeId: string, patch: Record, at = nowIso()): EpisodeRecord | undefined { @@ -2361,6 +2415,7 @@ export class RuntimeRepository { .run(toJson(meta), at, episodeId); return { ...episode, + userId: null, meta, updatedAt: at }; @@ -2386,6 +2441,7 @@ export class RuntimeRepository { .run(input.title, input.summary, toJson(meta), at, episodeId); return { ...episode, + userId: null, title: input.title, summary: input.summary, meta, @@ -2411,7 +2467,7 @@ export class RuntimeRepository { LIMIT 1` ) .get(sessionId) as SqlEpisodeRow | undefined; - return row ? episodeFromSql(row) : undefined; + return row ? episodeFromSql(row, this.db) : undefined; } appendEpisodeTurn(episodeId: string, rawTurnId: string, l1MemoryId: string, at = nowIso()): EpisodeRecord { @@ -2433,6 +2489,7 @@ export class RuntimeRepository { .run(toJson(rawTurnIds), toJson(l1MemoryIds), rawTurnIds.length, at, episodeId); return { ...episode, + userId: null, rawTurnIds, l1MemoryIds, turnCount: rawTurnIds.length, @@ -2457,6 +2514,7 @@ export class RuntimeRepository { .run(toJson(rawTurnIds), rawTurnIds.length, at, episodeId); return { ...episode, + userId: null, rawTurnIds, turnCount: rawTurnIds.length, updatedAt: at @@ -2539,6 +2597,7 @@ export class RuntimeRepository { .run(input.rTask, toJson(input.rewardDetail), toJson(meta), at, episodeId); return { ...episode, + userId: null, rTask: input.rTask, rewardDetail: input.rewardDetail, meta, @@ -2569,12 +2628,14 @@ export class RuntimeRepository { .run(toJson(values), at, episodeId); return { ...episode, + userId: null, [field]: values, updatedAt: at }; } insertRawTurn(rawTurn: RawTurnRecord): RawTurnRecord { + rawTurn = { ...rawTurn, userId: null }; this.db .prepare( `INSERT INTO raw_turns ( @@ -2591,6 +2652,7 @@ export class RuntimeRepository { ) .run({ ...rawTurn, + userId: null, conversationId: rawTurn.conversationId ?? null, userText: rawTurn.userText ?? null, assistantText: rawTurn.assistantText ?? null, @@ -2607,6 +2669,7 @@ export class RuntimeRepository { } updateRawTurn(rawTurn: RawTurnRecord): RawTurnRecord { + rawTurn = { ...rawTurn, userId: null }; this.db .prepare( `UPDATE raw_turns @@ -2716,7 +2779,7 @@ export class RuntimeRepository { } hasCompletedSourceTurnInScope(input: { - userId: string; + userId: string | null; source: string; profileId: string; conversationId: string; @@ -2730,14 +2793,12 @@ export class RuntimeRepository { const row = this.db.prepare(` SELECT 1 AS found FROM raw_turns - WHERE raw_turns.user_id = @userId - AND raw_turns.turn_id = @turnId + WHERE raw_turns.turn_id = @turnId AND json_type(raw_turns.message_payload_json, '$.turn_complete') = 'object' AND EXISTS ( SELECT 1 FROM sessions WHERE sessions.id = raw_turns.session_id - AND sessions.user_id = @userId AND sessions.source = @source AND sessions.profile_id = @profileId AND ( @@ -2746,15 +2807,15 @@ export class RuntimeRepository { OR sessions.host_session_key = @source || '-memory-' || @conversationId ) AND ( - json_extract(sessions.meta_json, '$.source_namespace_key') = @namespaceKey + memory_namespace_key(json_extract(sessions.meta_json, '$.source_namespace_key')) = memory_namespace_key(@namespaceKey) OR ( json_extract(sessions.meta_json, '$.source_namespace_key') IS NULL AND @tenantId IS NULL AND ( @namespaceKey = @defaultNamespaceKey OR ( - sessions.project_id IS @storedProjectId - AND sessions.workspace_id IS @workspaceId + memory_project_id(sessions.project_id) IS memory_project_id(@storedProjectId) + AND memory_project_id(sessions.workspace_id) IS memory_project_id(@workspaceId) ) ) ) @@ -2832,7 +2893,7 @@ export class RuntimeRepository { ) .run({ id: cluster.id, - userId: cluster.userId, + userId: null, projectId: cluster.projectId ?? null, toolsJson: toJson(cluster.tools), artifactsJson: toJson(cluster.artifacts), @@ -2884,7 +2945,7 @@ export class RuntimeRepository { const row = this.db .prepare(`SELECT * FROM skill_clusters WHERE id = ?`) .get(id) as SqlSkillClusterRow | undefined; - return row ? skillClusterFromSql(row) : undefined; + return row ? skillClusterFromSql(row, this.db) : undefined; } getSkillClusterForEpisode(episodeId: string): SkillClusterRecord | undefined { @@ -2898,11 +2959,11 @@ export class RuntimeRepository { LIMIT 1` ) .get(episodeId) as SqlSkillClusterRow | undefined; - return row ? skillClusterFromSql(row) : undefined; + return row ? skillClusterFromSql(row, this.db) : undefined; } listSkillClustersByScope(input: { - userId: string; + userId: string | null; projectId?: string; limit?: number; }): SkillClusterRecord[] { @@ -2911,22 +2972,21 @@ export class RuntimeRepository { .prepare( `SELECT * FROM skill_clusters - WHERE user_id = ? - AND (project_id = ? OR project_id IS NULL) + WHERE (memory_project_id(project_id) = memory_project_id(?) OR project_id IS NULL) ORDER BY updated_at DESC LIMIT ?` ) - .all(input.userId, input.projectId, input.limit ?? 200) as SqlSkillClusterRow[] + .all(input.projectId, input.limit ?? 200) as SqlSkillClusterRow[] : this.db .prepare( `SELECT * FROM skill_clusters - WHERE user_id = ? + WHERE 1=1 ORDER BY updated_at DESC LIMIT ?` ) - .all(input.userId, input.limit ?? 200) as SqlSkillClusterRow[]; - return rows.map(skillClusterFromSql); + .all(input.limit ?? 200) as SqlSkillClusterRow[]; + return rows.map((row) => skillClusterFromSql(row, this.db)); } upsertSkillClusterMember(member: SkillClusterMemberRecord): SkillClusterMemberRecord { @@ -3000,7 +3060,7 @@ export class RuntimeRepository { } listFeedback(input: { - userId?: string; + userId?: string | null; sessionId?: string; episodeId?: string; rawTurnId?: string; @@ -3050,7 +3110,7 @@ export class RuntimeRepository { event.sessionId ?? null, event.episodeId ?? null, event.turnId ?? null, - event.userId, + null, event.query, event.queryHash ?? stableHash(event.query), toJson(event.layers), @@ -3150,7 +3210,7 @@ export class RuntimeRepository { return seq; } - latestChangeSeq(userId?: string, namespaceId?: string): number { + latestChangeSeq(userId?: string | null, namespaceId?: string): number { void userId; void namespaceId; const clauses = ["1=1"]; @@ -3163,7 +3223,7 @@ export class RuntimeRepository { return Number(row?.seq ?? 0); } - listChanges(userId?: string, limit = 50, cursor?: number, namespaceId?: string): ChangeLogRecord[] { + listChanges(userId?: string | null, limit = 50, cursor?: number, namespaceId?: string): ChangeLogRecord[] { void userId; void namespaceId; const clauses = ["1=1"]; @@ -3188,7 +3248,7 @@ export class RuntimeRepository { kind: row.kind ?? undefined, op: row.op ?? undefined, entityId: row.entity_id ?? undefined, - userId: row.user_id, + userId: null, changeType: row.change_type, version: row.version ?? undefined, before: row.before_json ? parseJson(row.before_json, undefined) : undefined, @@ -3228,6 +3288,7 @@ export class RuntimeRepository { } enqueueJobInTransaction(job: EvolutionJobRecord): EvolutionJobRecord { + job = { ...job, userId: null }; const existing = job.dedupeKey ? this.getActiveJobByDedupeKey(job.dedupeKey) : undefined; if (existing) { const updatedAt = job.updatedAt ?? nowIso(); @@ -3358,7 +3419,7 @@ export class RuntimeRepository { */ armWorkMemoryIdleFlush(input: { sessionId: string; - userId: string; + userId: string | null; lastActivityAt: string; runAfter: string; at?: string; @@ -3385,14 +3446,14 @@ export class RuntimeRepository { target_memory_id, scope_key, scope_seq, payload_json, attempts, max_attempts, leased_until, last_error, created_at, updated_at ) VALUES (?, 'work_memory_idle_flush', 'queued', ?, ?, ?, NULL, NULL, NULL, NULL, ?, 0, 3, NULL, NULL, ?, ?)` - ).run(newId("job"), dedupeKey, input.userId, input.sessionId, payload, at, at); + ).run(newId("job"), dedupeKey, null, input.sessionId, payload, at, at); } const job = this.getJobByDedupeKey(dedupeKey); if (!job) throw new Error(`failed to arm work memory idle flush: ${input.sessionId}`); return job; } - listJobs(status?: JobStatus, limit = 50, userId?: string): EvolutionJobRecord[] { + listJobs(status?: JobStatus, limit = 50, userId?: string | null): EvolutionJobRecord[] { void userId; const clauses: string[] = []; const params: SqlValue[] = []; @@ -3655,6 +3716,21 @@ export class RuntimeRepository { AND leased_l3_job.status = 'leased' AND leased_l3_job.id <> evolution_jobs.id ) + AND NOT EXISTS ( + SELECT 1 FROM evolution_jobs AS shared_job + LEFT JOIN sessions AS shared_session ON shared_session.id = shared_job.session_id + WHERE shared_job.job_type = evolution_jobs.job_type + AND shared_job.id <> evolution_jobs.id + AND shared_job.scope_key <> evolution_jobs.scope_key + AND memory_project_id(shared_session.project_id) IS + (SELECT memory_project_id(project_id) FROM sessions WHERE id = evolution_jobs.session_id) + AND json_extract(shared_job.payload_json, '$.targetField') IS json_extract(evolution_jobs.payload_json, '$.targetField') + AND (shared_job.status = 'leased' OR ( + shared_job.status IN ('queued', 'failed') AND + (shared_job.created_at < evolution_jobs.created_at OR + (shared_job.created_at = evolution_jobs.created_at AND shared_job.id < evolution_jobs.id)) + )) + ) AND NOT EXISTS ( SELECT 1 FROM evolution_jobs AS earlier_l3_job @@ -3928,15 +4004,14 @@ export class RuntimeRepository { } if (status === "dead_letter" && row.job_type === "project_environment_profile") { const payload = parseJson>(row.payload_json, {}); - const userId = typeof payload.userId === "string" ? payload.userId : undefined; const projectId = typeof payload.projectId === "string" ? payload.projectId : undefined; const scanId = typeof payload.scanId === "string" ? payload.scanId : undefined; - if (userId && projectId && scanId) { + if (projectId && scanId) { this.db.prepare( `UPDATE l3_world_model_project_environment_state SET status = 'failed', last_error = ?, updated_at = ? - WHERE user_id = ? AND project_id = ? AND current_scan_id = ?` - ).run(error, at, userId, projectId, scanId); + WHERE memory_project_id(project_id) = memory_project_id(?) AND current_scan_id = ?` + ).run(error, at, projectId, scanId); } } return this.getJob(id); @@ -4030,7 +4105,7 @@ export class RuntimeRepository { listEmbeddingRetries( status?: EmbeddingRetryStatus, limit = 50, - userId?: string, + userId?: string | null, offset = 0 ): EmbeddingRetryRecord[] { void userId; @@ -4055,7 +4130,7 @@ export class RuntimeRepository { countEmbeddingRetriesByStatus( status: EmbeddingRetryStatus, - userId?: string + userId?: string | null ): number { void userId; const row = this.db @@ -4279,7 +4354,7 @@ export class RuntimeRepository { ) .run( trial.id, - trial.userId, + null, trial.projectId ?? null, trial.skillMemoryId, trial.sessionId ?? null, @@ -4305,7 +4380,7 @@ export class RuntimeRepository { } listSkillTrials(input: { - userId?: string; + userId?: string | null; skillMemoryId?: string; sessionId?: string; episodeId?: string; @@ -4368,7 +4443,7 @@ export class RuntimeRepository { sessionId?: string; episodeId?: string; rawTurnId?: string; - userId: string; + userId: string | null; projectId?: string; contextHash?: string; issue: string; @@ -4401,7 +4476,7 @@ export class RuntimeRepository { input.sessionId ?? null, input.episodeId ?? null, input.rawTurnId ?? null, - input.userId, + null, input.projectId ?? null, input.contextHash ?? null, input.issue, @@ -4422,7 +4497,7 @@ export class RuntimeRepository { sessionId: input.sessionId, episodeId: input.episodeId, rawTurnId: input.rawTurnId, - userId: input.userId, + userId: null, projectId: input.projectId, contextHash: input.contextHash, issue: input.issue, @@ -4441,17 +4516,13 @@ export class RuntimeRepository { } listDecisionRepairs(input: { - userId?: string; + userId?: string | null; contextHash?: string; since?: string; limit?: number; } = {}): DecisionRepairRecord[] { const clauses = ["1=1"]; const params: SqlValue[] = []; - if (input.userId) { - clauses.push("user_id = ?"); - params.push(input.userId); - } if (input.contextHash) { clauses.push("context_hash = ?"); params.push(input.contextHash); @@ -4513,7 +4584,7 @@ export class RuntimeRepository { upsertCandidatePoolTrace(input: { id: string; - userId: string; + userId: string | null; sessionId?: string; sourceMemoryId: string; candidateKey: string; @@ -4540,7 +4611,7 @@ export class RuntimeRepository { ) .run( input.id, - input.userId, + null, input.sessionId ?? null, input.sourceMemoryId, input.candidateKey, @@ -4554,7 +4625,7 @@ export class RuntimeRepository { } listPendingCandidatePool(input: { - userId?: string; + userId?: string | null; candidateKey?: string; now?: string; limit?: number; @@ -4574,7 +4645,17 @@ export class RuntimeRepository { LIMIT ?` ) .all(...params, input.limit ?? 1000) as SqlCandidatePoolRow[]; - return rows.map(candidatePoolFromSql); + const memories = new MemoryRepository(this.db, new SqliteVecStore(this.db)); + const seen = new Set(); + return rows.filter(row => { + const memory = memories.get(row.source_memory_id); + if (!memory) return false; + const group = memories.nativeDuplicates(memory); + const key = `${row.candidate_key}:${group.ids[0] ?? memory.id}`; + if (group.deleted || seen.has(key)) return false; + seen.add(key); + return true; + }).map(candidatePoolFromSql); } pruneCandidatePool(now = nowIso()): number { @@ -4585,13 +4666,17 @@ export class RuntimeRepository { } markCandidatePoolPromoted(input: { - userId?: string; + userId?: string | null; candidateKey: string; sourceMemoryIds: string[]; policyId: string; at: string; }): void { - const sourceMemoryIds = uniq(input.sourceMemoryIds); + const memories = new MemoryRepository(this.db, new SqliteVecStore(this.db)); + const sourceMemoryIds = uniq(input.sourceMemoryIds.flatMap(id => { + const memory = memories.get(id); + return memory ? memories.nativeDuplicates(memory).ids : [id]; + })); if (sourceMemoryIds.length === 0) return; const placeholders = sourceMemoryIds.map(() => "?").join(", "); this.db @@ -4608,7 +4693,7 @@ export class RuntimeRepository { insertTracePolicyLink(input: { id?: string; - userId: string; + userId: string | null; l1MemoryId: string; l2MemoryId: string; relation?: string; @@ -4624,7 +4709,7 @@ export class RuntimeRepository { ) .run( id, - input.userId, + null, input.l1MemoryId, input.l2MemoryId, input.relation ?? "supports", @@ -4635,7 +4720,7 @@ export class RuntimeRepository { } listTracePolicyLinks(input: { - userId?: string; + userId?: string | null; l1MemoryId?: string; l2MemoryId?: string; limit?: number; @@ -4669,7 +4754,7 @@ export class RuntimeRepository { }): AuditLogRecord { const row: AuditLogRecord = { id: input.id ?? newId("audit"), - userId: input.userId, + userId: null, sessionId: input.sessionId, actor: input.actor, action: input.action, @@ -4705,7 +4790,7 @@ export class RuntimeRepository { } listAudit(input: { - userId?: string; + userId?: string | null; targetKind?: string; targetId?: string; limit?: number; @@ -4905,6 +4990,7 @@ export class RuntimeRepository { } } })(); + refreshProjectAliases(this.db); this.scheduleLogTablesPrune(); this.scheduleHistoryLogCompaction(true); return result; @@ -4934,12 +5020,12 @@ export class RuntimeRepository { return existing ? "replaced" : "inserted"; } - private rowExists(table: BundleTableName, columns: string[], values: Array): boolean { + private rowExists(table: BundleTableName, columns: string[], values: Array): boolean { const tableColumns = this.tableColumns(table); if (columns.length === 0 || columns.some((column) => !tableColumns.includes(column))) { return false; } - const where = columns.map((column) => `${column} = ?`).join(" AND "); + const where = columns.map((column) => `${column} IS ?`).join(" AND "); const row = this.db.prepare(`SELECT 1 AS ok FROM ${table} WHERE ${where} LIMIT 1`).get(...values) as | { ok: number } | undefined; @@ -4999,7 +5085,7 @@ export class RuntimeRepository { sessionId?: string; episodeId?: string; rawTurnId?: string; - userId: string; + userId: string | null; kind: string; uri?: string; payload: unknown; @@ -5018,7 +5104,7 @@ export class RuntimeRepository { input.sessionId ?? null, input.episodeId ?? null, input.rawTurnId ?? null, - input.userId, + null, input.kind, input.uri ?? null, toJson(input.payload), @@ -5041,31 +5127,31 @@ export class L3WorldModelRepository { private readonly memories: MemoryRepository ) {} - getScope(userId: string, projectId?: string | null): L3WorldModelScopeRecord | undefined { + getScope(_userId: string | null, projectId?: string | null): L3WorldModelScopeRecord | undefined { const row = this.db.prepare( `SELECT * FROM l3_world_model_scopes - WHERE user_id = ? AND project_id IS ?` - ).get(userId, projectId ?? null) as SqlL3WorldModelScopeRow | undefined; - return row ? l3WorldModelScopeFromSql(row) : undefined; + WHERE user_id IS NULL AND project_id IS ?` + ).get(canonicalProjectId(this.db, projectId)) as SqlL3WorldModelScopeRow | undefined; + return row ? l3WorldModelScopeFromSql(row, this.db) : undefined; } - ensureScope(userId: string, projectId?: string | null, at = nowIso()): L3WorldModelScopeRecord { - const scopeKey = l3WorldModelScopeKey(userId, projectId); + ensureScope(_userId: string | null, projectId?: string | null, at = nowIso()): L3WorldModelScopeRecord { + projectId = canonicalProjectId(this.db, projectId); + const scopeKey = l3WorldModelScopeKey(null, projectId); + const uris = this.db.prepare(`SELECT DISTINCT workspace_uri FROM l3_world_model_scopes + WHERE memory_project_id(project_id) IS ? AND workspace_uri IS NOT NULL`).all(projectId) as Array<{workspace_uri: string}>; + const workspaceUri = uris.length === 1 ? uris[0]!.workspace_uri : null; this.db.prepare( `INSERT INTO l3_world_model_scopes ( - scope_key, user_id, project_id, memory_id, next_scope_seq, updated_at - ) VALUES (?, ?, ?, NULL, 1, ?) + scope_key, user_id, project_id, workspace_uri, memory_id, next_scope_seq, updated_at + ) VALUES (?, NULL, ?, ?, NULL, 1, ?) ON CONFLICT(scope_key) DO NOTHING` - ).run(scopeKey, userId, projectId ?? null, at); - const scope = this.getScope(userId, projectId); - if (!scope || scope.scopeKey !== scopeKey || scope.userId !== userId || (scope.projectId ?? null) !== (projectId ?? null)) { - throw new Error("corrupt L3 World Model scope ownership"); - } - return scope; + ).run(scopeKey, projectId, workspaceUri, at); + return this.getScope(null, projectId)!; } bindWorkspaceUri( - userId: string, + userId: string | null, projectId: string, workspaceUri: WorkspaceUri, at = nowIso() @@ -5094,7 +5180,7 @@ export class L3WorldModelRepository { const placeholders = ids.map(() => "?").join(", "); return (this.db.prepare( `SELECT * FROM l3_world_model_scopes WHERE memory_id IN (${placeholders})` - ).all(...ids) as SqlL3WorldModelScopeRow[]).map(l3WorldModelScopeFromSql); + ).all(...ids) as SqlL3WorldModelScopeRow[]).map((row) => l3WorldModelScopeFromSql(row, this.db)); } registerInputTrace(input: { @@ -5220,7 +5306,14 @@ export class L3WorldModelRepository { const row = this.db.prepare( `SELECT * FROM l3_world_model_evidence_batches WHERE id = ?` ).get(batchId) as SqlL3WorldModelEvidenceBatchRow | undefined; - return row ? l3WorldModelEvidenceBatchFromSql(row) : undefined; + return row ? l3WorldModelEvidenceBatchFromSql(row, this.db) : undefined; + } + + /** Integrity checks must hash the immutable stored identity, before alias projection. */ + getStoredBatch(batchId: string): L3WorldModelEvidenceBatchRecord | undefined { + const row = this.db.prepare(`SELECT * FROM l3_world_model_evidence_batches WHERE id = ?`) + .get(batchId) as SqlL3WorldModelEvidenceBatchRow | undefined; + return row ? { ...l3WorldModelEvidenceBatchFromSql(row), userId: row.user_id } : undefined; } getTarget(batchId: string, targetField: L3WorldModelTargetField): L3WorldModelBatchTargetRecord | undefined { @@ -5243,22 +5336,62 @@ export class L3WorldModelRepository { .map(l3WorldModelInputTraceFromSql); } - getMemory(userId: string, projectId?: string | null): MemoryRow | undefined { - const scope = this.getScope(userId, projectId); - if (!scope?.memoryId) return undefined; - const memory = this.memories.get(scope.memoryId); - if (!memory) throw new Error(`corrupt L3 World Model scope memory: ${scope.memoryId}`); - validateL3WorldModelMemory(memory, userId, projectId); - return memory; + getMemory(_userId: string | null, projectId?: string | null): MemoryRow | undefined { + projectId = canonicalProjectId(this.db, projectId); + const scopes = this.db.prepare(`SELECT * FROM l3_world_model_scopes + WHERE memory_project_id(project_id) IS ? AND memory_id IS NOT NULL + ORDER BY (user_id IS NULL) DESC, updated_at DESC, scope_key ASC`) + .all(projectId) as SqlL3WorldModelScopeRow[]; + const candidates = scopes.flatMap(scope => { + const memory = this.memories.get(scope.memory_id!); + if (!memory || memory.deletedAt) return []; + validateL3WorldModelMemory(memory, null, projectId); + return [{ memory, canonical: scope.user_id === null }]; + }); + if (!candidates.length) return undefined; + const overrides = this.db.prepare(`SELECT field, deleted FROM memory_l3_overrides WHERE project_key = ?`) + .all(projectId ?? '') as Array<{field: keyof L3WorldModelFields; deleted: number}>; + const fields = emptyL3WorldModelFields(); + let profileScanId: unknown; + for (const property of Object.keys(fields) as Array) { + const override = overrides.find(row => row.field === property); + if (override?.deleted) continue; + for (const candidate of candidates) { + if (override && !candidate.canonical) continue; + const value = fieldsFromL3WorldModelMemory(candidate.memory)[property]; + if (value !== null) { + fields[property] = value; + if (property === "projectEnvironmentProfile") profileScanId = candidate.memory.info.project_environment_applied_scan_id; + break; + } + } + } + const base = candidates[0]!.memory; + const memoryValue = renderL3WorldModelFields(fields); + return { + ...base, memoryValue, contentHash: sha256Hex(memoryValue), + info: {...base.info, project_environment_applied_scan_id: profileScanId}, + status: memoryValue ? "activated" : "archived", + version: candidates.length > 1 ? Number.parseInt(sha256Hex(canonicalJson(fields)).slice(0, 12), 16) : base.version, + properties: { ...base.properties, internal_info: { + ...base.properties.internal_info, body: memoryValue, + world_model: { + general_rules_and_safety_constraints: fields.generalRulesAndSafetyConstraints, + project_environment_profile: fields.projectEnvironmentProfile, + project_contract: fields.projectContract, + domain_knowledge: fields.domainKnowledge + } + }} + }; } - fields(userId: string, projectId?: string | null): L3WorldModelFields { + fields(userId: string | null, projectId?: string | null): L3WorldModelFields { const memory = this.getMemory(userId, projectId); return memory ? fieldsFromL3WorldModelMemory(memory) : emptyL3WorldModelFields(); } upsertField(input: { - userId: string; + userId: string | null; projectId?: string | null; targetField: L3WorldModelFieldName; value: string | null; @@ -5297,7 +5430,7 @@ export class L3WorldModelRepository { throw new Error(`L3 World Model target is terminal: ${input.batchId}:${input.targetField}`); } const expectedScopeKey = l3WorldModelFieldScopeKey( - l3WorldModelScopeKey(batch.userId, batch.projectId), + batch.scopeKey, input.targetField ); if (target.fieldScopeKey !== expectedScopeKey || target.scopeSeq !== batch.scopeSeq) { @@ -5363,7 +5496,11 @@ export class L3WorldModelRepository { `SELECT * FROM l3_world_model_scopes WHERE memory_id = ?` ).get(memoryId) as SqlL3WorldModelScopeRow | undefined; if (!scopeRow) return undefined; - const scope = l3WorldModelScopeFromSql(scopeRow); + const scope = l3WorldModelScopeFromSql(scopeRow, this.db); + for (const field of Object.keys(emptyL3WorldModelFields())) { + this.db.prepare(`INSERT INTO memory_l3_overrides(project_key, field, deleted) VALUES (?, ?, 1) + ON CONFLICT(project_key, field) DO UPDATE SET deleted = 1`).run(scope.projectId ?? "", field); + } const before = this.memories.get(memoryId); if (!before) throw new Error(`corrupt L3 World Model scope memory: ${memoryId}`); validateL3WorldModelMemory(before, scope.userId, scope.projectId); @@ -5377,8 +5514,8 @@ export class L3WorldModelRepository { `SELECT target.batch_id, target.target_field FROM l3_world_model_batch_targets AS target JOIN l3_world_model_evidence_batches AS batch ON batch.id = target.batch_id - WHERE batch.scope_key = ? AND target.status = 'queued'` - ).all(scope.scopeKey) as Array<{ batch_id: string; target_field: L3WorldModelTargetField }>; + WHERE memory_project_id(batch.project_id) IS ? AND target.status = 'queued'` + ).all(scope.projectId ?? null) as Array<{ batch_id: string; target_field: L3WorldModelTargetField }>; const affectedBatchIds = new Set(); for (const target of pendingTargets) { const job = this.db.prepare( @@ -5409,22 +5546,22 @@ export class L3WorldModelRepository { `UPDATE evolution_jobs SET status = 'succeeded', leased_until = NULL, last_error = NULL, updated_at = ? WHERE job_type = 'project_environment_profile' - AND user_id = ? - AND json_extract(payload_json, '$.projectId') = ? + AND memory_project_id(json_extract(payload_json, '$.projectId')) = ? AND status IN ('queued', 'failed')` - ).run(at, scope.userId, scope.projectId); + ).run(at, scope.projectId); this.db.prepare( `UPDATE l3_world_model_project_environment_state SET project_kind = 'unknown', status = 'uninitialized', current_scan_id = NULL, applied_scan_id = NULL, fingerprint = NULL, last_error = NULL, updated_at = ? - WHERE user_id = ? AND project_id = ?` - ).run(at, scope.userId, scope.projectId); + WHERE memory_project_id(project_id) = ?` + ).run(at, scope.projectId); } return { before, deleted, scope }; })(); } insertImmutableJob(job: EvolutionJobRecord): EvolutionJobRecord { + job = { ...job, userId: null }; if (job.jobType !== "l3_world_model_update" && job.jobType !== "project_environment_profile") { throw new TypeError("immutable L3 job must use an L3 World Model job type"); } @@ -5543,7 +5680,7 @@ export class L3WorldModelRepository { const payload = { scopeKey: scope.scopeKey, scopeSeq: claimed.scope_seq, - userId: session.userId, + userId: null, projectId: session.projectId ?? null, sessionId: session.id, trigger: input.trigger, @@ -5565,7 +5702,7 @@ export class L3WorldModelRepository { batchId, scope.scopeKey, claimed.scope_seq, - session.userId, + null, session.projectId ?? null, session.id, input.trigger, @@ -5593,7 +5730,7 @@ export class L3WorldModelRepository { jobType: "l3_world_model_update", status: "queued", dedupeKey: `l3_world_model:${batchId}:${targetField}`, - userId: session.userId, + userId: null, sessionId: session.id, scopeKey: fieldScopeKey, scopeSeq: claimed.scope_seq, @@ -5621,7 +5758,7 @@ export class L3WorldModelRepository { } private upsertFieldInTransaction(input: { - userId: string; + userId: string | null; projectId?: string | null; targetField: L3WorldModelFieldName; value: string | null; @@ -5630,16 +5767,19 @@ export class L3WorldModelRepository { at?: string; source?: string; }): MemoryRow | undefined { - const projectId = input.projectId ?? null; + const projectId = canonicalProjectId(this.db, input.projectId); assertL3WorldModelFieldOwnership(projectId, input.targetField); const at = input.at ?? nowIso(); const scope = this.ensureScope(input.userId, projectId, at); const existing = scope.memoryId ? this.memories.get(scope.memoryId) : undefined; if (scope.memoryId && !existing) throw new Error(`corrupt L3 World Model scope memory: ${scope.memoryId}`); if (existing) validateL3WorldModelMemory(existing, input.userId, projectId); - const fields = existing ? fieldsFromL3WorldModelMemory(existing) : emptyL3WorldModelFields(); + const fields = this.fields(null, projectId); const property = l3WorldModelFieldProperty(input.targetField); fields[property] = normalizeL3WorldModelFieldValue(input.value); + this.db.prepare(`INSERT INTO memory_l3_overrides(project_key, field, deleted) VALUES (?, ?, ?) + ON CONFLICT(project_key, field) DO UPDATE SET deleted = excluded.deleted`) + .run(projectId ?? "", property, fields[property] === null ? 1 : 0); const memoryValue = renderL3WorldModelFields(fields); if (!existing && !memoryValue) return undefined; @@ -5670,7 +5810,7 @@ export class L3WorldModelRepository { const memory: MemoryRow = { id: existing?.id ?? newId("memory"), timeline: existing?.timeline ?? at, - userId: input.userId, + userId: null, memoryType: "LongTermMemory", status, visibility: "private", @@ -5722,7 +5862,7 @@ export class L3WorldModelRepository { scope.scopeKey, existing ? (status === "archived" ? "archived" : "updated") : "created", saved.id, - input.userId, + null, existing ? "l3_world_model_update" : "l3_world_model_create", saved.version, existing ? toJson(existing) : null, @@ -5777,7 +5917,7 @@ export class L3WorldModelRepository { private requireV2Session(sessionId: string): SessionRecord { const row = this.db.prepare(`SELECT * FROM sessions WHERE id = ?`).get(sessionId) as SqlSessionRow | undefined; if (!row) throw new Error(`session not found: ${sessionId}`); - const session = sessionFromSql(row); + const session = sessionFromSql(row, this.db); if (session.meta.l3_world_model_protocol_version !== 2) { throw new Error("l3_world_model_protocol_v2_required"); } @@ -5804,16 +5944,17 @@ export class ProjectEnvironmentRepository { private readonly runtime: RuntimeRepository ) {} - getState(userId: string, projectId: string): ProjectEnvironmentStateRecord | undefined { + getState(userId: string | null, projectId: string): ProjectEnvironmentStateRecord | undefined { const row = this.db.prepare( `SELECT * FROM l3_world_model_project_environment_state - WHERE user_id = ? AND project_id = ?` - ).get(userId, projectId) as SqlProjectEnvironmentStateRow | undefined; - return row ? projectEnvironmentStateFromSql(row) : undefined; + WHERE memory_project_id(project_id) = ? + ORDER BY (user_id IS NULL) DESC, updated_at DESC, project_id ASC LIMIT 1` + ).get(canonicalProjectId(this.db, projectId)) as SqlProjectEnvironmentStateRow | undefined; + return row ? projectEnvironmentStateFromSql(row, this.db) : undefined; } requestScan(input: { - userId: string; + userId: string | null; projectId: string; sessionId: string; trigger: "session_start" | "token_compaction"; @@ -5831,19 +5972,19 @@ export class ProjectEnvironmentRepository { user_id, project_id, project_kind, status, current_scan_id, applied_scan_id, fingerprint, last_error, updated_at ) VALUES (?, ?, 'unknown', 'queued', ?, NULL, NULL, NULL, ?) - ON CONFLICT(user_id, project_id) DO UPDATE SET + ON CONFLICT(project_id) WHERE user_id IS NULL DO UPDATE SET status = 'queued', current_scan_id = excluded.current_scan_id, last_error = NULL, updated_at = excluded.updated_at` - ).run(input.userId, input.projectId, scanId, at); + ).run(null, canonicalProjectId(this.db, input.projectId), scanId, at); const job: EvolutionJobRecord = { id: newId("job"), jobType: "project_environment_profile", status: "queued", dedupeKey: input.dedupeKey, - userId: input.userId, + userId: null, sessionId: input.sessionId, payload: { - userId: input.userId, + userId: null, projectId: input.projectId, scanId, trigger: input.trigger @@ -5858,26 +5999,26 @@ export class ProjectEnvironmentRepository { })(); } - beginScan(userId: string, projectId: string, scanId: string, at = nowIso()): boolean { + beginScan(userId: string | null, projectId: string, scanId: string, at = nowIso()): boolean { const result = this.db.prepare( `UPDATE l3_world_model_project_environment_state SET status = 'scanning', last_error = NULL, updated_at = ? - WHERE user_id = ? AND project_id = ? AND current_scan_id = ?` - ).run(at, userId, projectId, scanId); + WHERE memory_project_id(project_id) = memory_project_id(?) AND current_scan_id = ?` + ).run(at, projectId, scanId); return result.changes === 1; } - markSummarizing(userId: string, projectId: string, scanId: string, at = nowIso()): boolean { + markSummarizing(userId: string | null, projectId: string, scanId: string, at = nowIso()): boolean { const result = this.db.prepare( `UPDATE l3_world_model_project_environment_state SET status = 'summarizing', updated_at = ? - WHERE user_id = ? AND project_id = ? AND current_scan_id = ?` - ).run(at, userId, projectId, scanId); + WHERE memory_project_id(project_id) = memory_project_id(?) AND current_scan_id = ?` + ).run(at, projectId, scanId); return result.changes === 1; } markCleanWithoutModel(input: { - userId: string; + userId: string | null; projectId: string; scanId: string; projectKind: Exclude; @@ -5887,11 +6028,10 @@ export class ProjectEnvironmentRepository { const result = this.db.prepare( `UPDATE l3_world_model_project_environment_state SET project_kind = ?, status = 'clean', last_error = NULL, updated_at = ? - WHERE user_id = ? AND project_id = ? AND current_scan_id = ?` + WHERE memory_project_id(project_id) = memory_project_id(?) AND current_scan_id = ?` ).run( input.projectKind, at, - input.userId, input.projectId, input.scanId ); @@ -5899,7 +6039,7 @@ export class ProjectEnvironmentRepository { } applyProfile(input: { - userId: string; + userId: string | null; projectId: string; scanId: string; projectKind: Exclude; @@ -5941,7 +6081,7 @@ export class ProjectEnvironmentRepository { const existingMemory = this.l3WorldModels.getMemory(input.userId, input.projectId); if (!emptyNoop && (nextProfile !== null || existingMemory)) { this.l3WorldModels.upsertField({ - userId: input.userId, + userId: null, projectId: input.projectId, targetField: "project_environment_profile", value: nextProfile, @@ -5954,13 +6094,12 @@ export class ProjectEnvironmentRepository { `UPDATE l3_world_model_project_environment_state SET project_kind = ?, status = 'clean', applied_scan_id = ?, fingerprint = ?, last_error = NULL, updated_at = ? - WHERE user_id = ? AND project_id = ? AND current_scan_id = ?` + WHERE memory_project_id(project_id) = memory_project_id(?) AND current_scan_id = ?` ).run( input.projectKind, emptyNoop ? null : input.scanId, emptyNoop ? null : input.fingerprint, at, - input.userId, input.projectId, input.scanId ); @@ -5968,18 +6107,18 @@ export class ProjectEnvironmentRepository { })(); } - failCurrentScan(userId: string, projectId: string, scanId: string, error: string, at = nowIso()): void { + failCurrentScan(userId: string | null, projectId: string, scanId: string, error: string, at = nowIso()): void { this.db.prepare( `UPDATE l3_world_model_project_environment_state SET status = 'failed', last_error = ?, updated_at = ? - WHERE user_id = ? AND project_id = ? AND current_scan_id = ?` - ).run(error, at, userId, projectId, scanId); + WHERE memory_project_id(project_id) = memory_project_id(?) AND current_scan_id = ?` + ).run(error, at, projectId, scanId); } } -function projectEnvironmentStateFromSql(row: SqlProjectEnvironmentStateRow): ProjectEnvironmentStateRecord { +function projectEnvironmentStateFromSql(row: SqlProjectEnvironmentStateRow, db?: Database.Database): ProjectEnvironmentStateRecord { return { - userId: row.user_id, + userId: null, projectId: row.project_id, projectKind: row.project_kind, status: row.status, @@ -6002,6 +6141,7 @@ export class Repositories { readonly vectors: SqliteVecStore; constructor(readonly db: Database.Database) { + installDeviceScope(db); this.vectors = new SqliteVecStore(db); this.memories = new MemoryRepository(db, this.vectors); this.captureClaims = new MemoryCaptureClaimRepository(db); @@ -6102,8 +6242,8 @@ interface SqlL3WorldModelBatchTargetRow { updated_at: string; } -export function l3WorldModelScopeKey(userId: string, projectId?: string | null): string { - return `l3wm:${sha256Hex(canonicalJson({ userId, projectId: projectId ?? null }))}`; +export function l3WorldModelScopeKey(userId: string | null, projectId?: string | null): string { + return `l3wm:${sha256Hex(canonicalJson({ scope: "device", projectId: projectId ?? null }))}`; } export function l3WorldModelFieldScopeKey( @@ -6113,17 +6253,17 @@ export function l3WorldModelFieldScopeKey( return `${scopeKey}:${targetField}`; } -export function l3WorldModelMemoryKey(userId: string, projectId?: string | null): string { +export function l3WorldModelMemoryKey(userId: string | null, projectId?: string | null): string { return projectId - ? `world_model:project:${userId}:${projectId}` - : `world_model:general_rules:${userId}:no_project`; + ? `world_model:device:project:${projectId}` + : `world_model:device:general_rules`; } export function isL3WorldModelV2Memory(memory: MemoryRow): boolean { try { validateL3WorldModelMemory( memory, - memory.userId, + null, projectIdFromL3WorldModelMemory(memory) ); return true; @@ -6145,7 +6285,7 @@ export function fieldsFromL3WorldModelMemory(memory: MemoryRow): L3WorldModelFie function validateL3WorldModelMemory( memory: MemoryRow, - expectedUserId: string, + expectedUserId: string | null, expectedProjectId?: string | null ): void { const internal = memory.properties.internal_info; @@ -6168,10 +6308,11 @@ function validateL3WorldModelMemory( if (memory.memoryLayer !== "L3" || internal.memory_kind !== "world_model") { throw new Error(`invalid L3 World Model layer or kind: ${memory.id}`); } - if (memory.userId !== expectedUserId || projectIdFromL3WorldModelMemory(memory) !== projectId) { + if (projectIdFromL3WorldModelMemory(memory) !== projectId) { throw new Error(`invalid L3 World Model owner: ${memory.id}`); } - if (memory.memoryKey !== l3WorldModelMemoryKey(expectedUserId, projectId)) { + const legacyKey = projectId ? `world_model:project:${memory.userId}:${projectId}` : `world_model:general_rules:${memory.userId}:no_project`; + if (memory.memoryKey !== l3WorldModelMemoryKey(null, projectId) && memory.memoryKey !== legacyKey) { throw new Error(`invalid L3 World Model key: ${memory.id}`); } if (projectId && fields.generalRulesAndSafetyConstraints !== null) { @@ -6262,11 +6403,11 @@ export function splitL3TracesByRawTurn( return chunks; } -function l3WorldModelScopeFromSql(row: SqlL3WorldModelScopeRow): L3WorldModelScopeRecord { +function l3WorldModelScopeFromSql(row: SqlL3WorldModelScopeRow, db?: Database.Database): L3WorldModelScopeRecord { return { scopeKey: row.scope_key, - userId: row.user_id, - projectId: row.project_id ?? undefined, + userId: null, + projectId: (db ? canonicalProjectId(db, row.project_id) : row.project_id) ?? undefined, workspaceUri: row.workspace_uri ? row.workspace_uri as WorkspaceUri : undefined, memoryId: row.memory_id ?? undefined, nextScopeSeq: row.next_scope_seq, @@ -6287,13 +6428,13 @@ function l3WorldModelInputTraceFromSql(row: SqlL3WorldModelInputTraceRow): L3Wor function l3WorldModelEvidenceBatchFromSql( row: SqlL3WorldModelEvidenceBatchRow -): L3WorldModelEvidenceBatchRecord { +, db?: Database.Database): L3WorldModelEvidenceBatchRecord { return { id: row.id, scopeKey: row.scope_key, scopeSeq: row.scope_seq, - userId: row.user_id, - projectId: row.project_id ?? undefined, + userId: null, + projectId: (db ? canonicalProjectId(db, row.project_id) : row.project_id) ?? undefined, sessionId: row.session_id, trigger: row.trigger, startTraceSeq: row.start_trace_seq, @@ -6324,13 +6465,19 @@ function l3WorldModelBatchTargetFromSql( }; } -export function memoryFromSql(row: MemorySqlRow): MemoryRow { +export function memoryFromSql(row: MemorySqlRow, db?: Database.Database): MemoryRow { const info = parseJson>(row.info_json, {}); const properties = parseJson(row.properties_json, { internal_info: { memory_layer: row.memory_layer } }); + const storedProject = typeof info.project_id === "string" ? info.project_id : null; + const legacyKey = storedProject ? `world_model:project:${row.user_id}:${storedProject}` : `world_model:general_rules:${row.user_id}:no_project`; + const projectedKey = row.memory_layer === "L3" && properties.internal_info?.schema_version === 2 && row.memory_key === legacyKey + ? l3WorldModelMemoryKey(null, db ? canonicalProjectId(db, storedProject) : storedProject) : row.memory_key; + if (db && typeof info.project_id === "string") info.project_id = canonicalProjectId(db, info.project_id); + if (db && typeof properties.info?.project_id === "string") properties.info.project_id = canonicalProjectId(db, properties.info.project_id); const tags = uniq([ ...asStringArray(parseJson(row.tags_json, [])), ...asStringArray(info.tags), @@ -6344,15 +6491,15 @@ export function memoryFromSql(row: MemorySqlRow): MemoryRow { return { id: row.id, timeline: row.timeline, - userId: row.user_id, + userId: null, conversationId: row.conversation_id ?? undefined, sessionId: row.session_id ?? undefined, agentId: row.agent_id ?? undefined, - appId: row.app_id ?? undefined, + appId: (db ? canonicalProjectId(db, row.app_id) : row.app_id) ?? undefined, memoryType: row.memory_type, status: row.status, visibility: row.visibility, - memoryKey: row.memory_key ?? undefined, + memoryKey: projectedKey ?? undefined, memoryValue: row.memory_value, tags, info, @@ -6373,7 +6520,7 @@ function userMemoryFromSql(row: UserMemorySqlRow): UserMemoryRecord { return { id: row.id, sourceTurnId: row.source_turn_id, - userId: row.user_id, + userId: null, memoryTypes: asStringArray(parseJson(row.memory_types_json, [])) as UserMemoryType[], content: row.content, normalizedUserTextHash: row.normalized_user_text_hash, @@ -6393,13 +6540,13 @@ function userMemoryFromSql(row: UserMemorySqlRow): UserMemoryRecord { } function userMemoryPanelFilter(input: { - userId: string; + userId: string | null; status?: UserMemoryStatus; query?: string; sourceAgent?: string; }): { where: string; params: Array } { - const clauses = ["user_id = ?"]; - const params = [input.userId]; + const clauses = [`(status != 'active' OR (${ACTIVE_USER_MEMORY_VIEW_SQL}))`]; + const params: string[] = []; if (input.status) { clauses.push("status = ?"); params.push(input.status); @@ -6450,7 +6597,7 @@ export function memoryToSql(memory: MemoryRow): Record { return { id: memory.id, timeline: memory.timeline, - userId: memory.userId, + userId: null, conversationId: memory.conversationId ?? null, sessionId: memory.sessionId ?? null, agentId: memory.agentId ?? null, @@ -6900,7 +7047,7 @@ function buildMemoryWhere(filter: MemoryFilter): { where: string; params: SqlVal const clauses = ["deleted_at IS NULL"]; const params: SqlValue[] = []; - addValueClause("user_id", filter.userId); + // Legacy account provenance is not a memory filter. addValueClause("session_id", filter.sessionId); addValueClause("conversation_id", filter.conversationId); addAgentIdClause(filter.agentId, filter.excludedAgentIds); @@ -6980,14 +7127,13 @@ function buildMemoryWhere(filter: MemoryFilter): { where: string; params: SqlVal params.push(...values); } - function addWorkMemoryScopeClause(userId: string | undefined, projectId: string | null | undefined): void { - if (userId === undefined || projectId === undefined) return; - const normalizedProjectExpression = "NULLIF(TRIM(CAST(json_extract(info_json, '$.project_id') AS TEXT)), '')"; + function addWorkMemoryScopeClause(_userId: string | null | undefined, projectId: string | null | undefined): void { + if (projectId === undefined) return; + const normalizedProjectExpression = "memory_project_id(NULLIF(TRIM(CAST(json_extract(info_json, '$.project_id') AS TEXT)), ''))"; clauses.push(`( COALESCE(json_extract(properties_json, '$.internal_info.memory_kind'), 'trace') != 'work_memory' - OR (user_id = ? AND ${projectId === null ? `${normalizedProjectExpression} IS NULL` : `${normalizedProjectExpression} = ?`}) + OR (${projectId === null ? `${normalizedProjectExpression} IS NULL` : `${normalizedProjectExpression} = ?`}) )`); - params.push(userId); if (projectId !== null) params.push(projectId); } @@ -7016,13 +7162,9 @@ function buildMemoryWhere(filter: MemoryFilter): { where: string; params: SqlVal } } -function buildEpisodeWhere(userId?: string, query?: string, sourceAgent?: string): { where: string; params: SqlValue[] } { +function buildEpisodeWhere(userId?: string | null, query?: string, sourceAgent?: string): { where: string; params: SqlValue[] } { const clauses = ["1=1"]; const params: SqlValue[] = []; - if (userId) { - clauses.push("episodes.user_id = ?"); - params.push(userId); - } const normalizedSourceAgent = sourceAgent?.trim(); if (normalizedSourceAgent) { @@ -7205,20 +7347,22 @@ interface SqlSessionRow { updated_at: string; } -function sessionFromSql(row: SqlSessionRow): SessionRecord { +function sessionFromSql(row: SqlSessionRow, db?: Database.Database): SessionRecord { + const meta = parseJson>(row.meta_json, {}); + if (db && typeof meta.source_namespace_key === "string") meta.source_namespace_key = canonicalNamespaceKey(db, meta.source_namespace_key); return { id: row.id, - userId: row.user_id, + userId: null, source: row.source, profileId: row.profile_id, profileLabel: row.profile_label ?? undefined, - projectId: row.project_id ?? undefined, - workspaceId: row.workspace_id ?? undefined, + projectId: (db ? canonicalProjectId(db, row.project_id) : row.project_id) ?? undefined, + workspaceId: (db ? canonicalProjectId(db, row.workspace_id) : row.workspace_id) ?? undefined, workspacePath: row.workspace_path ?? undefined, hostSessionKey: row.host_session_key ?? undefined, conversationId: row.conversation_id ?? undefined, status: row.status, - meta: parseJson(row.meta_json, {}), + meta, openedAt: row.opened_at, lastSeenAt: row.last_seen_at ?? row.updated_at, closedAt: row.closed_at, @@ -7254,13 +7398,13 @@ interface SqlEpisodeRow { updated_at: string; } -function episodeFromSql(row: SqlEpisodeRow): EpisodeRecord { +function episodeFromSql(row: SqlEpisodeRow, db?: Database.Database): EpisodeRecord { const rawTurnIds = asStringArray(parseJson(row.raw_turn_ids_json, [])); return { id: row.id, sessionId: row.session_id, - userId: row.user_id, - projectId: row.project_id ?? undefined, + userId: null, + projectId: (db ? canonicalProjectId(db, row.project_id) : row.project_id) ?? undefined, conversationId: row.conversation_id ?? undefined, status: row.status, title: row.title ?? undefined, @@ -7301,12 +7445,12 @@ interface SqlSkillClusterRow { updated_at: string; } -function skillClusterFromSql(row: SqlSkillClusterRow): SkillClusterRecord { +function skillClusterFromSql(row: SqlSkillClusterRow, db?: Database.Database): SkillClusterRecord { const centroid = parseJson(row.centroid_json, null); return { id: row.id, - userId: row.user_id, - projectId: row.project_id ?? undefined, + userId: null, + projectId: (db ? canonicalProjectId(db, row.project_id) : row.project_id) ?? undefined, tools: asStringArray(parseJson(row.tools_json, [])), artifacts: asStringArray(parseJson(row.artifacts_json, [])), toolBigrams: asStringArray(parseJson(row.tool_bigrams_json, [])), @@ -7383,7 +7527,7 @@ function rawTurnFromSql(row: SqlRawTurnRow): RawTurnRecord { sessionId: row.session_id, episodeId: row.episode_id, turnId: row.turn_id, - userId: row.user_id, + userId: null, conversationId: row.conversation_id ?? undefined, userText: row.user_text ?? undefined, assistantText: row.assistant_text ?? undefined, @@ -7403,7 +7547,7 @@ function rawTurnFromSql(row: SqlRawTurnRow): RawTurnRecord { function feedbackFromSql(row: SqlFeedbackRow): FeedbackRecord { return { id: row.id, - userId: row.user_id, + userId: null, projectId: row.project_id ?? undefined, conversationId: row.conversation_id ?? undefined, sessionId: row.session_id ?? undefined, @@ -7451,7 +7595,7 @@ function recallEventFromSql(row: SqlRecallEventRow): RecallEventRecord { sessionId: row.session_id ?? undefined, episodeId: row.episode_id ?? undefined, turnId: row.turn_id ?? undefined, - userId: row.user_id, + userId: null, query: row.query, queryHash: row.query_hash ?? undefined, layers: asStringArray(parseJson(row.layers_json, [])) as MemoryLayer[], @@ -7608,7 +7752,7 @@ function jobFromSql(row: SqlJobRow): EvolutionJobRecord { jobType: row.job_type, status: row.status, dedupeKey: row.dedupe_key ?? undefined, - userId: row.user_id, + userId: null, sessionId: row.session_id ?? undefined, episodeId: row.episode_id ?? undefined, targetMemoryId: row.target_memory_id ?? undefined, @@ -7677,7 +7821,7 @@ function embeddingRetryFromSql(row: SqlEmbeddingRetryRow): EmbeddingRetryRecord function skillTrialFromSql(row: SqlSkillTrialRow): SkillTrialRecord { return { id: row.id, - userId: row.user_id, + userId: null, projectId: row.project_id ?? undefined, skillMemoryId: row.skill_memory_id, sessionId: row.session_id ?? undefined, @@ -7703,7 +7847,7 @@ function statusFromOutcome(outcome: SkillTrialRecord["outcome"]): SkillTrialReco function tracePolicyLinkFromSql(row: SqlTracePolicyLinkRow): TracePolicyLinkRecord { return { id: row.id, - userId: row.user_id, + userId: null, l1MemoryId: row.l1_memory_id, l2MemoryId: row.l2_memory_id, relation: row.relation, @@ -7718,7 +7862,7 @@ function decisionRepairFromSql(row: SqlDecisionRepairRow): DecisionRepairRecord sessionId: row.session_id ?? undefined, episodeId: row.episode_id ?? undefined, rawTurnId: row.raw_turn_id ?? undefined, - userId: row.user_id, + userId: null, projectId: row.project_id ?? undefined, contextHash: row.context_hash ?? undefined, issue: row.issue, @@ -7739,7 +7883,7 @@ function decisionRepairFromSql(row: SqlDecisionRepairRow): DecisionRepairRecord function candidatePoolFromSql(row: SqlCandidatePoolRow): CandidatePoolRecord { return { id: row.id, - userId: row.user_id, + userId: null, sessionId: row.session_id ?? undefined, sourceMemoryId: row.source_memory_id, candidateKey: row.candidate_key, @@ -7756,7 +7900,7 @@ function candidatePoolFromSql(row: SqlCandidatePoolRow): CandidatePoolRecord { function auditLogFromSql(row: SqlAuditLogRow): AuditLogRecord { return { id: row.id, - userId: row.user_id, + userId: null, sessionId: row.session_id ?? undefined, actor: parseJson(row.actor_json, {}), action: row.action, @@ -7870,7 +8014,7 @@ interface BundleIdentity { primaryKey: string; sourceId: string; columns: string[]; - values: Array; + values: Array; } function bundleIdentity( @@ -7878,6 +8022,8 @@ function bundleIdentity( row: Record ): BundleIdentity | undefined { const newTableIdentityColumns: Partial> = { + memory_project_aliases: ["legacy_id"], + memory_l3_overrides: ["project_key", "field"], memory_capture_claims: ["user_id", "source", "qa_hash"], source_turn_captures: ["user_id", "source", "profile_id", "namespace_key", "conversation_id", "turn_id"], l3_world_model_scopes: ["scope_key"], @@ -7891,18 +8037,18 @@ function bundleIdentity( const newColumns = newTableIdentityColumns[table]; if (newColumns) { const values = newColumns.map((column) => row[column]); - if (values.some((value) => typeof value !== "string" && typeof value !== "number")) { + if (values.some((value) => value !== null && typeof value !== "string" && typeof value !== "number")) { return undefined; } - const identity: Record = {}; + const identity: Record = {}; for (const [index, column] of newColumns.entries()) { - identity[column] = values[index] as string | number; + identity[column] = values[index] as string | number | null; } return { primaryKey: newColumns.join("+"), sourceId: canonicalJson(identity), columns: newColumns, - values: values as Array + values: values as Array }; } diff --git a/Memory/src/storage/schema.ts b/Memory/src/storage/schema.ts index 9755c4b92..699a0828e 100644 --- a/Memory/src/storage/schema.ts +++ b/Memory/src/storage/schema.ts @@ -1,8 +1,8 @@ import type Database from "better-sqlite3"; import { memoryCaptureQaHash, normalizeMemoryCaptureSource } from "../utils/memory-capture-claim.js"; -export const SCHEMA_VERSION = 9; -export const SCHEMA_MIGRATION_ID = "009_token_usage_outbox"; +export const SCHEMA_VERSION = 10; +export const SCHEMA_MIGRATION_ID = "010_device_memory_scope"; const API_LOG_SOURCE_AGENT_MIGRATION_FROM_VERSION = 2; const PROCESSING_TAGS = new Set([ "摘要排队中", @@ -25,7 +25,7 @@ const statements = [ `CREATE TABLE IF NOT EXISTS memories ( id TEXT PRIMARY KEY, timeline TEXT NOT NULL, - user_id TEXT NOT NULL, + user_id TEXT, conversation_id TEXT, session_id TEXT, agent_id TEXT, @@ -64,7 +64,7 @@ const statements = [ `CREATE TABLE IF NOT EXISTS l3_world_model_scopes ( scope_key TEXT PRIMARY KEY, - user_id TEXT NOT NULL, + user_id TEXT, project_id TEXT, workspace_uri TEXT, memory_id TEXT UNIQUE REFERENCES memories(id) ON DELETE SET NULL, @@ -82,7 +82,7 @@ const statements = [ `CREATE TABLE IF NOT EXISTS user_memories ( id TEXT PRIMARY KEY, source_turn_id TEXT NOT NULL, - user_id TEXT NOT NULL, + user_id TEXT, memory_types_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(memory_types_json)), content TEXT NOT NULL, normalized_user_text_hash TEXT NOT NULL, @@ -135,7 +135,7 @@ const statements = [ `CREATE TABLE IF NOT EXISTS sessions ( id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, + user_id TEXT, project_id TEXT, source TEXT NOT NULL, profile_id TEXT NOT NULL, @@ -173,7 +173,7 @@ const statements = [ `CREATE TABLE IF NOT EXISTS episodes ( id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, - user_id TEXT NOT NULL, + user_id TEXT, project_id TEXT, conversation_id TEXT, status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'processing', 'closed')), @@ -209,7 +209,7 @@ const statements = [ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, episode_id TEXT NOT NULL REFERENCES episodes(id) ON DELETE CASCADE, turn_id TEXT NOT NULL, - user_id TEXT NOT NULL, + user_id TEXT, conversation_id TEXT, user_text TEXT, assistant_text TEXT, @@ -245,7 +245,7 @@ const statements = [ `CREATE TABLE IF NOT EXISTS feedback ( id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, + user_id TEXT, project_id TEXT, conversation_id TEXT, session_id TEXT, @@ -277,7 +277,7 @@ const statements = [ id TEXT PRIMARY KEY, scope_key TEXT NOT NULL REFERENCES l3_world_model_scopes(scope_key) ON DELETE CASCADE, scope_seq INTEGER NOT NULL CHECK (scope_seq >= 1), - user_id TEXT NOT NULL, + user_id TEXT, project_id TEXT, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, trigger TEXT NOT NULL CHECK (trigger IN ( @@ -323,7 +323,7 @@ const statements = [ session_id TEXT, episode_id TEXT, raw_turn_id TEXT, - user_id TEXT NOT NULL, + user_id TEXT, project_id TEXT, context_hash TEXT, issue TEXT NOT NULL, @@ -346,7 +346,7 @@ const statements = [ `CREATE TABLE IF NOT EXISTS l2_candidate_pool ( id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, + user_id TEXT, session_id TEXT, source_memory_id TEXT NOT NULL, candidate_key TEXT NOT NULL, @@ -365,7 +365,7 @@ const statements = [ `CREATE TABLE IF NOT EXISTS trace_policy_links ( id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, + user_id TEXT, l1_memory_id TEXT NOT NULL, l2_memory_id TEXT NOT NULL, relation TEXT NOT NULL DEFAULT 'supports', @@ -380,7 +380,7 @@ const statements = [ `CREATE TABLE IF NOT EXISTS skill_trials ( id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, + user_id TEXT, project_id TEXT, skill_memory_id TEXT NOT NULL, session_id TEXT, @@ -410,7 +410,7 @@ const statements = [ `CREATE TABLE IF NOT EXISTS skill_clusters ( id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, + user_id TEXT, project_id TEXT, tools_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tools_json)), artifacts_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(artifacts_json)), @@ -446,7 +446,7 @@ const statements = [ session_id TEXT, episode_id TEXT, turn_id TEXT, - user_id TEXT NOT NULL, + user_id TEXT, query TEXT NOT NULL, query_hash TEXT, layers_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(layers_json)), @@ -488,7 +488,7 @@ const statements = [ kind TEXT, op TEXT, entity_id TEXT, - user_id TEXT NOT NULL, + user_id TEXT, change_type TEXT NOT NULL, version INTEGER, before_json TEXT, @@ -510,7 +510,7 @@ const statements = [ )`, `CREATE TABLE IF NOT EXISTS source_turn_captures ( - user_id TEXT NOT NULL, + user_id TEXT, source TEXT NOT NULL, profile_id TEXT NOT NULL, namespace_key TEXT NOT NULL, @@ -531,7 +531,7 @@ const statements = [ ON source_turn_captures (user_id, source, profile_id, namespace_key, conversation_id, completed_at DESC)`, `CREATE TABLE IF NOT EXISTS memory_capture_claims ( - user_id TEXT NOT NULL, + user_id TEXT, source TEXT NOT NULL, qa_hash TEXT NOT NULL, primary_memory_id TEXT NOT NULL, @@ -543,7 +543,7 @@ const statements = [ ON memory_capture_claims (primary_memory_id)`, `CREATE TABLE IF NOT EXISTS l3_world_model_project_environment_state ( - user_id TEXT NOT NULL, + user_id TEXT, project_id TEXT NOT NULL, project_kind TEXT NOT NULL DEFAULT 'unknown' CHECK (project_kind IN ('unknown', 'code', 'folder')), status TEXT NOT NULL DEFAULT 'uninitialized' CHECK (status IN ( @@ -565,7 +565,7 @@ const statements = [ status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'leased', 'succeeded', 'failed', 'dead_letter')), dedupe_key TEXT, - user_id TEXT NOT NULL, + user_id TEXT, session_id TEXT, episode_id TEXT, target_memory_id TEXT, @@ -642,7 +642,7 @@ const statements = [ session_id TEXT, episode_id TEXT, raw_turn_id TEXT, - user_id TEXT NOT NULL, + user_id TEXT, kind TEXT NOT NULL, uri TEXT, payload_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(payload_json)), @@ -659,7 +659,7 @@ const statements = [ `CREATE TABLE IF NOT EXISTS audit_logs ( id TEXT PRIMARY KEY, - user_id TEXT NOT NULL, + user_id TEXT, session_id TEXT, actor_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(actor_json)), action TEXT NOT NULL, @@ -687,7 +687,7 @@ export function migrate(db: Database.Database): void { const hasMemories = tableExists(db, "memories"); const version = currentSchemaVersion(db); - if (hasMemories && version !== SCHEMA_VERSION && version !== 2 && version !== 3 && version !== 4 && version !== 5 && version !== 6 && version !== 7 && version !== 8) { + if (hasMemories && version !== SCHEMA_VERSION && version !== 2 && version !== 3 && version !== 4 && version !== 5 && version !== 6 && version !== 7 && version !== 8 && version !== 9) { throw new Error( `Unsupported memory database schema version ${version}; the database was left unchanged` ); @@ -709,6 +709,7 @@ export function migrate(db: Database.Database): void { addColumnIfMissing(db, "evolution_jobs", "scope_key", "TEXT"); addColumnIfMissing(db, "evolution_jobs", "scope_seq", "INTEGER"); } + if (hasMemories && version < 10) relaxLegacyUserColumns(db); for (const statement of statements) { db.prepare(statement).run(); } @@ -725,6 +726,29 @@ export function migrate(db: Database.Database): void { WHERE dedupe_key IS NOT NULL AND status IN ('queued', 'leased', 'failed')` ).run(); + db.exec(` + CREATE TABLE IF NOT EXISTS memory_project_aliases ( + legacy_id TEXT PRIMARY KEY, project_id TEXT NOT NULL, workspace_uri TEXT NOT NULL, + workspace_host_id TEXT, created_at TEXT NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_memory_project_alias_target ON memory_project_aliases(project_id); + CREATE TABLE IF NOT EXISTS memory_l3_overrides ( + project_key TEXT NOT NULL, field TEXT NOT NULL, memory_id TEXT, deleted INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY(project_key, field) + ); + CREATE UNIQUE INDEX IF NOT EXISTS uq_device_l3_scope + ON l3_world_model_scopes(COALESCE(project_id, '')) WHERE user_id IS NULL; + CREATE UNIQUE INDEX IF NOT EXISTS uq_device_project_environment + ON l3_world_model_project_environment_state(project_id) WHERE user_id IS NULL; + CREATE UNIQUE INDEX IF NOT EXISTS uq_device_capture_claim ON memory_capture_claims(source, qa_hash) WHERE user_id IS NULL; + CREATE UNIQUE INDEX IF NOT EXISTS uq_device_source_turn ON source_turn_captures(source, profile_id, namespace_key, conversation_id, turn_id) WHERE user_id IS NULL; + CREATE INDEX IF NOT EXISTS idx_raw_turns_turn ON raw_turns(turn_id); + CREATE INDEX IF NOT EXISTS idx_memory_native_turn ON memories(memory_layer, + COALESCE(json_extract(info_json, '$.turn_id'), json_extract(properties_json, '$.internal_info.trace.turn_id'))); + CREATE INDEX IF NOT EXISTS idx_user_memories_device_text ON user_memories(normalized_user_text_hash, status, updated_at); + + `); + // This boundary belongs to the Memory database and is shared by Hook and scanners. db.prepare(`INSERT INTO runtime_kv (key, value_json, updated_at) VALUES ('source_turn_capture_activated_at', ?, ?) @@ -756,6 +780,33 @@ export function migrate(db: Database.Database): void { } } +/** SQLite cannot drop NOT NULL in place. Copy rows verbatim; never rewrite legacy identities or content. */ +function relaxLegacyUserColumns(db: Database.Database): void { + const sequences = db.prepare("SELECT name, seq FROM sqlite_sequence").all() as Array<{name:string;seq:number}>; + const tables = db.prepare("SELECT name, sql FROM sqlite_master WHERE type = 'table' AND sql IS NOT NULL") + .all() as Array<{ name: string; sql: string }>; + for (const { name, sql } of tables) { + if (!/\buser_id TEXT NOT NULL\b/i.test(sql)) continue; + const dependent = db.prepare("SELECT sql FROM sqlite_master WHERE tbl_name = ? AND type IN ('index', 'trigger') AND sql IS NOT NULL") + .all(name) as Array<{ sql: string }>; + const temporary = `${name}_device_upgrade`; + const definition = sql.replace(/^(CREATE TABLE(?: IF NOT EXISTS)?\s+)(?:"[^"]+"|`[^`]+`|\w+)/i, `$1"${temporary}"`) + .replace(/\buser_id TEXT NOT NULL\b/gi, "user_id TEXT"); + db.exec(definition); + db.exec(`INSERT INTO "${temporary}" SELECT * FROM "${name}"`); + db.exec(`DROP TABLE "${name}"`); + db.exec(`ALTER TABLE "${temporary}" RENAME TO "${name}"`); + for (const item of dependent) db.exec(item.sql); + } + for (const row of sequences) { + const exists = db.prepare("SELECT 1 FROM sqlite_sequence WHERE name = ?").get(row.name); + if (exists) db.prepare("UPDATE sqlite_sequence SET seq = MAX(seq, ?) WHERE name = ?").run(row.seq,row.name); + else db.prepare("INSERT INTO sqlite_sequence(name,seq) VALUES (?,?)").run(row.name,row.seq); + } + const violations = db.pragma("foreign_key_check") as unknown[]; + if (violations.length) throw new Error("Memory scope schema upgrade failed foreign-key validation"); +} + function backfillMemoryCaptureClaims(db: Database.Database): void { const rows = db.prepare( `SELECT raw_turns.user_id, @@ -856,7 +907,6 @@ function backfillLegacyAdapterHostSessionKeys(db: Database.Database, now: string FROM sessions AS existing WHERE existing.id != candidate.id AND existing.status = 'open' - AND existing.user_id = candidate.user_id AND existing.source = candidate.source AND existing.profile_id = candidate.profile_id AND existing.host_session_key = candidate.id diff --git a/Memory/src/types.ts b/Memory/src/types.ts index 7ef5d0a1a..a2e47555b 100644 --- a/Memory/src/types.ts +++ b/Memory/src/types.ts @@ -99,7 +99,7 @@ export interface RuntimeNamespace { workspaceId?: string; workspacePath?: string; sessionKey?: string; - userId?: string; + userId?: string | null; tenantId?: string; } @@ -137,7 +137,7 @@ export interface JobRef { export interface MemoryRow { id: string; timeline: IsoTime; - userId: string; + userId: string | null; conversationId?: string; sessionId?: string; agentId?: string; @@ -184,7 +184,7 @@ export interface MemoryStatsRow { } export interface MemoryFilter { - userId?: string; + userId?: string | null; sessionId?: string; conversationId?: string; agentId?: string; @@ -197,7 +197,7 @@ export interface MemoryFilter { tags?: string[]; ids?: string[]; memoryKind?: MemoryKind | MemoryKind[]; - workMemoryUserId?: string; + workMemoryUserId?: string | null; workMemoryProjectId?: string | null; } @@ -433,7 +433,7 @@ export type UserMemoryStatus = "active" | "archived" | "deleted"; export interface UserMemoryRecord { id: string; sourceTurnId: string; - userId: string; + userId: string | null; memoryTypes: UserMemoryType[]; content: string; normalizedUserTextHash: string; diff --git a/Memory/tests/cli-command-map.test.ts b/Memory/tests/cli-command-map.test.ts index 9f3222953..a023612be 100644 --- a/Memory/tests/cli-command-map.test.ts +++ b/Memory/tests/cli-command-map.test.ts @@ -436,7 +436,7 @@ describe("memmy CLI command map", () => { fetch: mockFetch(requests) }); - expect(requests[0]?.headers["x-memmy-user-id"]).toBe("user_from_memory"); + expect(requests[0]?.headers["x-memmy-user-id"]).toBeUndefined(); }); it("uses the flat memory userId even when obsolete profile fields remain", async () => { @@ -464,7 +464,7 @@ describe("memmy CLI command map", () => { fetch: mockFetch(requests) }); - expect(requests[0]?.headers["x-memmy-user-id"]).toBe("user_from_flat_memory"); + expect(requests[0]?.headers["x-memmy-user-id"]).toBeUndefined(); }); it("lets explicit user-id override memmyMemory.userId from config", async () => { diff --git a/Memory/tests/cli-setup.test.ts b/Memory/tests/cli-setup.test.ts index 05ac55649..c59eb202a 100644 --- a/Memory/tests/cli-setup.test.ts +++ b/Memory/tests/cli-setup.test.ts @@ -54,7 +54,6 @@ describe("memmy-memory CLI setup commands", () => { expect(saved).toMatchObject({ memmyMemory: { version: 1, - userId: "local-user", roleRouting: { summary: "follow", evolution: "follow" @@ -410,7 +409,6 @@ describe("memmy-memory CLI setup commands", () => { expect(saved.memmyMemory).toMatchObject({ futureMemoryField: "keep-memory", version: 1, - userId: "user_123", roleRouting: { summary: "follow", evolution: "follow" diff --git a/Memory/tests/codex-native-hook.test.ts b/Memory/tests/codex-native-hook.test.ts index c1c599045..058b321ae 100644 --- a/Memory/tests/codex-native-hook.test.ts +++ b/Memory/tests/codex-native-hook.test.ts @@ -11,7 +11,7 @@ import { loadMemmyWorkspaceBridgeRuntimeAsset } from "../src/agent-source/integr const directories: string[] = []; afterEach(() => { for (const dir of directories.splice(0)) rmSync(dir, { recursive: true, force: true }); }); const createdAt = "2026-09-09T10:00:00.000Z"; -const expectedNamespace = { source: "codex", profileId: "default", userId: "fixture-owner", sessionKey: "native-session" }; +const expectedNamespace = { source: "codex", profileId: "default", userId: "local-user", sessionKey: "native-session" }; function record(type: string, payload: unknown) { return JSON.stringify({ type, timestamp: createdAt, payload }); } function transcript(complete = true) { return [record("session_meta", { id: "native-session", cwd: "/project" }), record("event_msg", { type: "task_started", turn_id: "native-turn" }), diff --git a/Memory/tests/config.test.ts b/Memory/tests/config.test.ts index fa8e2ca30..09d38d844 100644 --- a/Memory/tests/config.test.ts +++ b/Memory/tests/config.test.ts @@ -196,7 +196,7 @@ describe("memmy memory config", () => { expect(config.storage.sqlitePath).toBe(join(homedir(), ".memmy", "memory-service", "memory.sqlite")); }); - it("reads user id from memmyMemory config and environment aliases", () => { + it("ignores memory user id from config and environment aliases", () => { const root = tempRoot(); const configPath = join(root, "config.yaml"); writeFileSync(configPath, YAML.stringify({ @@ -208,10 +208,10 @@ describe("memmy memory config", () => { } })); - expect(loadMemmyConfig(configPath).config.userId).toBe("user_from_memory"); + expect(loadMemmyConfig(configPath).config.userId).toBeUndefined(); setEnv("MEMMY_MEMORY_USER_ID", "user_from_env"); - expect(loadMemmyConfig(configPath).config.userId).toBe("user_from_env"); + expect(loadMemmyConfig(configPath).config.userId).toBeUndefined(); }); it("reads memory gates from memmyMemory algorithm config", () => { @@ -396,7 +396,7 @@ describe("memmy memory config", () => { const { config } = loadMemmyConfig(configPath); expect(config.roleRouting).toEqual({ summary: "fixed", evolution: "fixed" }); - expect(config.userId).toBe("user_account"); + expect(config.userId).toBeUndefined(); expect(config.summary).toMatchObject({ provider: "openai_compatible", sourceProvider: "memmy_account", diff --git a/Memory/tests/contract/source-turn-http.test.ts b/Memory/tests/contract/source-turn-http.test.ts index 34f070f86..291278853 100644 --- a/Memory/tests/contract/source-turn-http.test.ts +++ b/Memory/tests/contract/source-turn-http.test.ts @@ -32,7 +32,7 @@ describe("source turn HTTP contract", () => { expect(bodies.map((body) => body.status).sort()).toEqual(["existing", "stored"]); expect(bodies[0]!.result.rawTurnId).toBe(bodies[1]!.result.rawTurnId); expect(bodies[0]!.result.l1MemoryId).toBe(bodies[1]!.result.l1MemoryId); - expect(new Repositories(db.db).runtime.getSession(bodies[0]!.result.sessionId)).toMatchObject({ userId: "http-user", source: "codex", profileId: "default" }); + expect(new Repositories(db.db).runtime.getSession(bodies[0]!.result.sessionId)).toMatchObject({ userId: null, source: "codex", profileId: "default" }); const forbidden = await submit({ ...payload, sourceTurn: { ...payload.sourceTurn, profileId: "foreign-profile" } }); expect(forbidden.status).toBe(403); const foreignUser = await submit({ ...payload, namespace: { source: "codex", profileId: "default", userId: "foreign-user" } }); @@ -72,7 +72,7 @@ describe("source turn HTTP contract", () => { expect(first.result.sessionId).toBe(opened.sessionId); expect(second.result.l1MemoryId).toBe(first.result.l1MemoryId); const repos = new Repositories(db.db); - expect(repos.memories.get(first.result.l1MemoryId)).toMatchObject({ userId: "configured-owner", sessionId: opened.sessionId, + expect(repos.memories.get(first.result.l1MemoryId)).toMatchObject({ userId: null, sessionId: opened.sessionId, info: { project_id: repos.runtime.getSession(opened.sessionId)!.projectId } }); expect(db.db.prepare("SELECT COUNT(*) AS n FROM sessions").get()).toEqual({ n: 1 }); } finally { await new Promise(resolve => server.close(() => resolve())); } diff --git a/Memory/tests/fixtures/schema-v9.sql b/Memory/tests/fixtures/schema-v9.sql new file mode 100644 index 000000000..d6c1d3f48 --- /dev/null +++ b/Memory/tests/fixtures/schema-v9.sql @@ -0,0 +1,641 @@ +-- Schema v9 from upstream 5326b303, used only to test preservation on upgrade. +CREATE TABLE schema_migrations ( + id TEXT PRIMARY KEY, + version INTEGER NOT NULL, + applied_at TEXT NOT NULL, + checksum TEXT NOT NULL + ); +CREATE TABLE memories ( + id TEXT PRIMARY KEY, + timeline TEXT NOT NULL, + user_id TEXT NOT NULL, + conversation_id TEXT, + session_id TEXT, + agent_id TEXT, + app_id TEXT, + memory_type TEXT NOT NULL DEFAULT 'LongTermMemory', + status TEXT NOT NULL DEFAULT 'activated' + CHECK (status IN ('activated', 'resolving', 'archived', 'deleted')), + visibility TEXT NOT NULL DEFAULT 'private', + memory_key TEXT, + memory_value TEXT NOT NULL, + tags_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tags_json)), + info_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(info_json)), + properties_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(properties_json)), + memory_layer TEXT NOT NULL CHECK (memory_layer IN ('L1', 'L2', 'L3', 'Skill')), + content_hash TEXT, + version INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ); +CREATE INDEX idx_memories_layer_status_updated + ON memories (memory_layer, status, updated_at DESC); +CREATE INDEX idx_memories_layer_status_created + ON memories (memory_layer, status, created_at DESC); +CREATE INDEX idx_memories_conversation_updated + ON memories (conversation_id, updated_at DESC); +CREATE INDEX idx_memories_session_layer + ON memories (session_id, memory_layer, updated_at DESC); +CREATE INDEX idx_memories_agent_app + ON memories (agent_id, app_id, updated_at DESC); +CREATE INDEX idx_memories_content_hash_layer + ON memories (content_hash, memory_layer); +CREATE INDEX idx_memories_key_layer + ON memories (memory_key, memory_layer); +CREATE TABLE l3_world_model_scopes ( + scope_key TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + project_id TEXT, + workspace_uri TEXT, + memory_id TEXT UNIQUE REFERENCES memories(id) ON DELETE SET NULL, + next_scope_seq INTEGER NOT NULL DEFAULT 1 CHECK (next_scope_seq >= 1), + updated_at TEXT NOT NULL, + CHECK (workspace_uri IS NULL OR (project_id IS NOT NULL AND length(workspace_uri) > 0)) + ); +CREATE UNIQUE INDEX uq_l3_world_model_scopes_general + ON l3_world_model_scopes (user_id) + WHERE project_id IS NULL; +CREATE UNIQUE INDEX uq_l3_world_model_scopes_project + ON l3_world_model_scopes (user_id, project_id) + WHERE project_id IS NOT NULL; +CREATE TABLE user_memories ( + id TEXT PRIMARY KEY, + source_turn_id TEXT NOT NULL, + user_id TEXT NOT NULL, + memory_types_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(memory_types_json)), + content TEXT NOT NULL, + normalized_user_text_hash TEXT NOT NULL, + source_turn_refs_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(source_turn_refs_json)), + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived', 'deleted')), + replaces_memory_id TEXT, + replaced_by_memory_id TEXT, + archived_at TEXT, + archive_reason TEXT, + embedding_json TEXT CHECK (embedding_json IS NULL OR json_valid(embedding_json)), + embedding_model TEXT, + embedding_provider TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT + ); +CREATE INDEX idx_user_memories_user_status_updated + ON user_memories (user_id, status, updated_at DESC); +CREATE INDEX idx_user_memories_source_turn + ON user_memories (source_turn_id); +CREATE INDEX idx_user_memories_exact_text + ON user_memories (user_id, normalized_user_text_hash, status, updated_at DESC); +CREATE VIRTUAL TABLE user_memories_fts USING fts5 ( + id UNINDEXED, + content, + memory_types, + tokenize='unicode61' + ); +CREATE VIRTUAL TABLE memories_fts USING fts5 ( + id UNINDEXED, + identifier, + memory_value, + tags, + tokenize='unicode61' + ); +CREATE TABLE memory_vector_entries ( + id INTEGER PRIMARY KEY, + memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE, + vector_field TEXT NOT NULL CHECK (vector_field IN ('vec_summary', 'vec_action', 'vec')), + embedding_model TEXT, + embedding_provider TEXT, + embedding_dim INTEGER NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (memory_id, vector_field) + ); +CREATE INDEX idx_memory_vector_entries_field_updated + ON memory_vector_entries (vector_field, updated_at DESC, id DESC); +CREATE TABLE sessions ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + project_id TEXT, + source TEXT NOT NULL, + profile_id TEXT NOT NULL, + profile_label TEXT, + workspace_id TEXT, + workspace_path TEXT, + host_session_key TEXT, + conversation_id TEXT, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'processing', 'closed')), + meta_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(meta_json)), + opened_at TEXT NOT NULL, + last_seen_at TEXT NOT NULL, + closed_at TEXT, + updated_at TEXT NOT NULL + ); +CREATE INDEX idx_sessions_user_updated + ON sessions (user_id, updated_at DESC); +CREATE INDEX idx_sessions_host_key + ON sessions (host_session_key); +CREATE INDEX idx_sessions_host_scope + ON sessions (user_id, source, profile_id, host_session_key, status); +CREATE TABLE l3_world_model_session_cursors ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + last_scheduled_seq INTEGER NOT NULL DEFAULT 0 CHECK (last_scheduled_seq >= 0), + updated_at TEXT NOT NULL + ); +CREATE TABLE work_memory_session_cursors ( + session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + last_extracted_seq INTEGER NOT NULL DEFAULT 0 CHECK (last_extracted_seq >= 0), + updated_at TEXT NOT NULL + ); +CREATE TABLE episodes ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + user_id TEXT NOT NULL, + project_id TEXT, + conversation_id TEXT, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'processing', 'closed')), + title TEXT, + summary TEXT, + l1_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(l1_memory_ids_json)), + raw_turn_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(raw_turn_ids_json)), + feedback_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(feedback_ids_json)), + decision_repair_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(decision_repair_ids_json)), + l2_policy_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(l2_policy_ids_json)), + l3_world_model_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(l3_world_model_ids_json)), + skill_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(skill_memory_ids_json)), + turn_count INTEGER NOT NULL DEFAULT 0, + r_task REAL, + reward_detail_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(reward_detail_json)), + pipeline_run_id TEXT, + pipeline_status TEXT NOT NULL DEFAULT 'idle' CHECK (pipeline_status IN ('idle', 'running', 'succeeded', 'failed')), + pipeline_error TEXT, + meta_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(meta_json)), + opened_at TEXT NOT NULL, + closed_at TEXT, + updated_at TEXT NOT NULL + ); +CREATE INDEX idx_episodes_session_updated + ON episodes (session_id, updated_at DESC); +CREATE INDEX idx_episodes_project_updated + ON episodes (project_id, updated_at DESC); +CREATE INDEX idx_episodes_pipeline + ON episodes (pipeline_status, updated_at DESC); +CREATE TABLE raw_turns ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + episode_id TEXT NOT NULL REFERENCES episodes(id) ON DELETE CASCADE, + turn_id TEXT NOT NULL, + user_id TEXT NOT NULL, + conversation_id TEXT, + user_text TEXT, + assistant_text TEXT, + reasoning_summary TEXT, + tool_calls_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tool_calls_json)), + tool_results_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tool_results_json)), + source_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(source_memory_ids_json)), + usage_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(usage_json)), + message_payload_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(message_payload_json)), + status TEXT NOT NULL DEFAULT 'succeeded', + redacted_at TEXT, + deleted_at TEXT, + created_at TEXT NOT NULL, + UNIQUE (session_id, turn_id) + ); +CREATE INDEX idx_raw_turns_episode_created + ON raw_turns (episode_id, created_at ASC); +CREATE INDEX idx_raw_turns_user_turn + ON raw_turns (user_id, turn_id); +CREATE TABLE l3_world_model_input_traces ( + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + trace_seq INTEGER NOT NULL CHECK (trace_seq >= 1), + l1_memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE, + raw_turn_id TEXT NOT NULL REFERENCES raw_turns(id) ON DELETE CASCADE, + episode_id TEXT REFERENCES episodes(id) ON DELETE SET NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (session_id, trace_seq), + UNIQUE (session_id, l1_memory_id) + ); +CREATE INDEX idx_l3_world_model_input_traces_raw_turn + ON l3_world_model_input_traces (raw_turn_id, session_id, trace_seq); +CREATE TABLE feedback ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + project_id TEXT, + conversation_id TEXT, + session_id TEXT, + episode_id TEXT, + l1_memory_id TEXT, + raw_turn_id TEXT, + channel TEXT NOT NULL CHECK (channel IN ('explicit', 'implicit')), + polarity TEXT NOT NULL CHECK (polarity IN ('positive', 'negative', 'neutral')), + magnitude REAL NOT NULL DEFAULT 1, + rationale TEXT, + raw_payload_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(raw_payload_json)), + context_hash TEXT, + created_at TEXT NOT NULL + ); +CREATE INDEX idx_feedback_session_created + ON feedback (session_id, created_at DESC); +CREATE INDEX idx_feedback_memory + ON feedback (l1_memory_id, created_at DESC); +CREATE INDEX idx_feedback_user_created + ON feedback (user_id, created_at DESC); +CREATE INDEX idx_feedback_episode_created + ON feedback (episode_id, created_at DESC); +CREATE INDEX idx_feedback_raw_turn_created + ON feedback (raw_turn_id, created_at DESC); +CREATE INDEX idx_feedback_context + ON feedback (user_id, project_id, context_hash, created_at DESC); +CREATE TABLE l3_world_model_evidence_batches ( + id TEXT PRIMARY KEY, + scope_key TEXT NOT NULL REFERENCES l3_world_model_scopes(scope_key) ON DELETE CASCADE, + scope_seq INTEGER NOT NULL CHECK (scope_seq >= 1), + user_id TEXT NOT NULL, + project_id TEXT, + session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE, + trigger TEXT NOT NULL CHECK (trigger IN ( + 'new_task', 'token_compaction', 'token_compaction_attempt', 'session_close', 'episode_idle_close' + )), + start_trace_seq INTEGER NOT NULL CHECK (start_trace_seq >= 1), + end_trace_seq INTEGER NOT NULL CHECK (end_trace_seq >= start_trace_seq), + l1_memory_ids_json TEXT NOT NULL CHECK (json_valid(l1_memory_ids_json)), + raw_turn_ids_json TEXT NOT NULL CHECK (json_valid(raw_turn_ids_json)), + feedback_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(feedback_ids_json)), + payload_hash TEXT NOT NULL, + terminal_outcome TEXT CHECK (terminal_outcome IS NULL OR terminal_outcome IN ( + 'applied', 'partial_dead_letter', 'dead_letter' + )), + completed_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (scope_key, scope_seq) + ); +CREATE INDEX idx_l3_world_model_batches_session_trace + ON l3_world_model_evidence_batches (session_id, end_trace_seq); +CREATE TABLE l3_world_model_batch_targets ( + batch_id TEXT NOT NULL REFERENCES l3_world_model_evidence_batches(id) ON DELETE CASCADE, + target_field TEXT NOT NULL CHECK (target_field IN ( + 'general_rules_and_safety_constraints', 'project_contract', 'domain_knowledge' + )), + field_scope_key TEXT NOT NULL, + scope_seq INTEGER NOT NULL CHECK (scope_seq >= 1), + status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'applied', 'dead_letter')), + no_change INTEGER NOT NULL DEFAULT 0 CHECK (no_change IN (0, 1)), + applied_at TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (batch_id, target_field), + UNIQUE (field_scope_key, scope_seq), + CHECK (status = 'applied' OR no_change = 0) + ); +CREATE INDEX idx_l3_world_model_targets_field_status + ON l3_world_model_batch_targets (field_scope_key, status, scope_seq); +CREATE TABLE decision_repairs ( + id TEXT PRIMARY KEY, + session_id TEXT, + episode_id TEXT, + raw_turn_id TEXT, + user_id TEXT NOT NULL, + project_id TEXT, + context_hash TEXT, + issue TEXT NOT NULL, + suggestion TEXT NOT NULL, + preference TEXT, + anti_pattern TEXT, + high_value_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(high_value_memory_ids_json)), + low_value_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(low_value_memory_ids_json)), + attached_policy_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(attached_policy_memory_ids_json)), + feedback_id TEXT, + validated INTEGER NOT NULL DEFAULT 0 CHECK (validated IN (0, 1)), + source_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(source_json)), + meta_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(meta_json)), + created_at TEXT NOT NULL + ); +CREATE INDEX idx_decision_repairs_context + ON decision_repairs (user_id, project_id, context_hash); +CREATE INDEX idx_decision_repairs_episode + ON decision_repairs (episode_id, created_at DESC); +CREATE TABLE l2_candidate_pool ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + session_id TEXT, + source_memory_id TEXT NOT NULL, + candidate_key TEXT NOT NULL, + candidate_value TEXT NOT NULL, + score REAL NOT NULL DEFAULT 0, + status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'promoted', 'rejected')), + evidence_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(evidence_json)), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + expires_at TEXT + ); +CREATE INDEX idx_l2_candidate_status + ON l2_candidate_pool (user_id, status, updated_at DESC); +CREATE INDEX idx_l2_candidate_pending_expiry + ON l2_candidate_pool (user_id, candidate_key, status, expires_at); +CREATE TABLE trace_policy_links ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + l1_memory_id TEXT NOT NULL, + l2_memory_id TEXT NOT NULL, + relation TEXT NOT NULL DEFAULT 'supports', + strength REAL NOT NULL DEFAULT 1, + created_at TEXT NOT NULL, + UNIQUE (l1_memory_id, l2_memory_id, relation) + ); +CREATE INDEX idx_trace_policy_links_l1 + ON trace_policy_links (user_id, l1_memory_id, created_at DESC); +CREATE INDEX idx_trace_policy_links_l2 + ON trace_policy_links (user_id, l2_memory_id, created_at DESC); +CREATE TABLE skill_trials ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + project_id TEXT, + skill_memory_id TEXT NOT NULL, + session_id TEXT, + episode_id TEXT NOT NULL, + l1_memory_id TEXT, + raw_turn_id TEXT, + turn_id TEXT, + tool_call_id TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'pass', 'fail', 'unknown')), + outcome TEXT NOT NULL DEFAULT 'unknown' + CHECK (outcome IN ('unknown', 'success', 'failure', 'cancelled')), + feedback_id TEXT, + created_at TEXT NOT NULL, + resolved_at TEXT + ); +CREATE INDEX idx_skill_trials_skill_created + ON skill_trials (skill_memory_id, created_at DESC); +CREATE INDEX idx_skill_trials_user_status + ON skill_trials (user_id, status, created_at DESC); +CREATE INDEX idx_skill_trials_episode_status + ON skill_trials (episode_id, status, created_at DESC); +CREATE INDEX idx_skill_trials_l1_status + ON skill_trials (l1_memory_id, status, created_at DESC); +CREATE INDEX idx_skill_trials_raw_status + ON skill_trials (raw_turn_id, status, created_at DESC); +CREATE TABLE skill_clusters ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + project_id TEXT, + tools_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tools_json)), + artifacts_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(artifacts_json)), + tool_bigrams_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(tool_bigrams_json)), + centroid_json TEXT, + skill_memory_id TEXT, + meta_skill_md TEXT NOT NULL DEFAULT '', + processed_episode_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(processed_episode_ids_json)), + member_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); +CREATE INDEX idx_skill_clusters_scope + ON skill_clusters (user_id, project_id, updated_at DESC); +CREATE INDEX idx_skill_clusters_skill + ON skill_clusters (skill_memory_id); +CREATE TABLE skill_cluster_members ( + cluster_id TEXT NOT NULL, + episode_id TEXT NOT NULL, + outcome TEXT NOT NULL DEFAULT 'unknown' + CHECK (outcome IN ('success', 'failure', 'unknown')), + r_task REAL, + assigned_at TEXT NOT NULL, + PRIMARY KEY (cluster_id, episode_id) + ); +CREATE INDEX idx_skill_cluster_members_episode + ON skill_cluster_members (episode_id, assigned_at DESC); +CREATE TABLE recall_events ( + id TEXT PRIMARY KEY, + namespace_id TEXT, + session_id TEXT, + episode_id TEXT, + turn_id TEXT, + user_id TEXT NOT NULL, + query TEXT NOT NULL, + query_hash TEXT, + layers_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(layers_json)), + candidate_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(candidate_memory_ids_json)), + injected_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(injected_memory_ids_json)), + hit_memory_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(hit_memory_ids_json)), + dropped_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(dropped_json)), + outcome TEXT NOT NULL DEFAULT 'pending' CHECK (outcome IN ('pending', 'positive', 'negative', 'ignored')), + request_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(request_json)), + query_id TEXT, + user_memory_candidate_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(user_memory_candidate_ids_json)), + l1_candidate_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(l1_candidate_ids_json)), + merged_source_turn_ids_json TEXT NOT NULL DEFAULT '[]' CHECK (json_valid(merged_source_turn_ids_json)), + member_memory_ids_by_source_turn_id_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(member_memory_ids_by_source_turn_id_json)), + created_at TEXT NOT NULL + ); +CREATE INDEX idx_recall_events_session_created + ON recall_events (session_id, created_at DESC); +CREATE TABLE api_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tool_name TEXT NOT NULL CHECK (tool_name IN ('memory_add', 'memory_search', 'skill_generate', 'skill_evolve')), + source_agent TEXT, + input_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(input_json)), + output_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(output_json)), + duration_ms INTEGER NOT NULL DEFAULT 0, + success INTEGER NOT NULL DEFAULT 1 CHECK (success IN (0, 1)), + called_at TEXT NOT NULL + ); +CREATE INDEX idx_api_logs_tool_time + ON api_logs (tool_name, called_at DESC); +CREATE INDEX idx_api_logs_tool_source_time + ON api_logs (tool_name, source_agent, called_at DESC); +CREATE TABLE memory_change_log ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + memory_id TEXT NOT NULL, + namespace_id TEXT, + kind TEXT, + op TEXT, + entity_id TEXT, + user_id TEXT NOT NULL, + change_type TEXT NOT NULL, + version INTEGER, + before_json TEXT, + after_json TEXT, + source TEXT NOT NULL, + created_at TEXT NOT NULL + ); +CREATE INDEX idx_memory_change_log_namespace_seq + ON memory_change_log (namespace_id, seq); +CREATE INDEX idx_memory_change_log_created + ON memory_change_log (created_at DESC); +CREATE TABLE idempotency_keys ( + key TEXT PRIMARY KEY, + request_hash TEXT NOT NULL, + response_json TEXT NOT NULL CHECK (json_valid(response_json)), + created_at TEXT NOT NULL, + expires_at TEXT + ); +CREATE TABLE source_turn_captures ( + user_id TEXT NOT NULL, + source TEXT NOT NULL, + profile_id TEXT NOT NULL, + namespace_key TEXT NOT NULL, + conversation_id TEXT NOT NULL, + turn_id TEXT NOT NULL, + content_hash TEXT NOT NULL, + session_id TEXT, + episode_id TEXT, + raw_turn_id TEXT, + response_json TEXT NOT NULL CHECK (json_valid(response_json)), + started_at TEXT NOT NULL, + completed_at TEXT NOT NULL, + source_sequence INTEGER, + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, source, profile_id, namespace_key, conversation_id, turn_id) + ); +CREATE INDEX idx_source_turn_captures_conversation + ON source_turn_captures (user_id, source, profile_id, namespace_key, conversation_id, completed_at DESC); +CREATE INDEX idx_source_turn_captures_native_turn + ON source_turn_captures (source, profile_id, namespace_key, conversation_id, turn_id); +CREATE INDEX idx_source_turn_captures_native_conversation + ON source_turn_captures (source, profile_id, namespace_key, conversation_id, completed_at DESC); +CREATE INDEX idx_source_sessions_native_conversation + ON sessions (source, profile_id, conversation_id); +CREATE INDEX idx_source_sessions_native_host_key + ON sessions (source, profile_id, host_session_key); +CREATE TABLE memory_capture_claims ( + user_id TEXT NOT NULL, + source TEXT NOT NULL, + qa_hash TEXT NOT NULL, + primary_memory_id TEXT NOT NULL, + captured_by TEXT NOT NULL CHECK (captured_by IN ('turn_complete', 'agent_source_scan')), + created_at TEXT NOT NULL, + PRIMARY KEY (user_id, source, qa_hash) + ); +CREATE INDEX idx_memory_capture_claims_primary_memory + ON memory_capture_claims (primary_memory_id); +CREATE TABLE l3_world_model_project_environment_state ( + user_id TEXT NOT NULL, + project_id TEXT NOT NULL, + project_kind TEXT NOT NULL DEFAULT 'unknown' CHECK (project_kind IN ('unknown', 'code', 'folder')), + status TEXT NOT NULL DEFAULT 'uninitialized' CHECK (status IN ( + 'uninitialized', 'queued', 'scanning', 'summarizing', 'clean', 'failed' + )), + current_scan_id TEXT, + applied_scan_id TEXT, + fingerprint TEXT, + last_error TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (user_id, project_id) + ); +CREATE INDEX idx_l3_world_model_project_environment_status + ON l3_world_model_project_environment_state (status, updated_at); +CREATE TABLE evolution_jobs ( + id TEXT PRIMARY KEY, + job_type TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'queued' + CHECK (status IN ('queued', 'leased', 'succeeded', 'failed', 'dead_letter')), + dedupe_key TEXT, + user_id TEXT NOT NULL, + session_id TEXT, + episode_id TEXT, + target_memory_id TEXT, + scope_key TEXT, + scope_seq INTEGER, + payload_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(payload_json)), + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 3, + leased_until TEXT, + last_error TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ); +CREATE INDEX idx_evolution_jobs_status_created + ON evolution_jobs (status, created_at ASC); +CREATE INDEX idx_evolution_jobs_target + ON evolution_jobs (target_memory_id, job_type); +CREATE UNIQUE INDEX uq_evolution_jobs_l3_immutable_dedupe + ON evolution_jobs (dedupe_key) + WHERE dedupe_key IS NOT NULL + AND job_type IN ('l3_world_model_update', 'project_environment_profile'); +CREATE UNIQUE INDEX uq_evolution_jobs_scope_seq + ON evolution_jobs (scope_key, scope_seq) + WHERE scope_key IS NOT NULL; +CREATE TABLE embedding_retry_queue ( + id TEXT PRIMARY KEY, + target_kind TEXT NOT NULL CHECK (target_kind IN ('trace', 'policy', 'world_model', 'skill')), + target_id TEXT NOT NULL, + vector_field TEXT NOT NULL CHECK (vector_field IN ('vec_summary', 'vec_action', 'vec')), + source_text TEXT NOT NULL, + embed_role TEXT NOT NULL DEFAULT 'document' CHECK (embed_role IN ('document', 'query')), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'in_progress', 'failed', 'succeeded')), + attempts INTEGER NOT NULL DEFAULT 0, + max_attempts INTEGER NOT NULL DEFAULT 6, + next_attempt_at INTEGER NOT NULL, + claimed_by TEXT, + lease_until INTEGER, + last_error TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + UNIQUE (target_kind, target_id, vector_field) + ); +CREATE INDEX idx_embedding_retry_due + ON embedding_retry_queue (status, next_attempt_at); +CREATE INDEX idx_embedding_retry_target + ON embedding_retry_queue (target_kind, target_id); +CREATE TABLE memory_processing_state ( + memory_id TEXT PRIMARY KEY REFERENCES memories(id) ON DELETE CASCADE, + state TEXT NOT NULL CHECK (state IN ( + 'summary_pending', 'summarizing', 'embedding_pending', 'embedding', + 'ready', 'ready_text_only', 'failed' + )), + stage TEXT CHECK (stage IN ('summary', 'embedding')), + active_job_id TEXT REFERENCES evolution_jobs(id) ON DELETE SET NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + manual_retry_count INTEGER NOT NULL DEFAULT 0, + retry_action TEXT NOT NULL DEFAULT 'retry' + CHECK (retry_action IN ('retry', 'open_settings', 'none')), + error_code TEXT, + error_message TEXT, + failed_at TEXT, + updated_at TEXT NOT NULL + ); +CREATE INDEX idx_memory_processing_state_state_updated + ON memory_processing_state (state, updated_at ASC); +CREATE INDEX idx_memory_processing_state_active_job + ON memory_processing_state (active_job_id); +CREATE TABLE artifacts ( + id TEXT PRIMARY KEY, + session_id TEXT, + episode_id TEXT, + raw_turn_id TEXT, + user_id TEXT NOT NULL, + kind TEXT NOT NULL, + uri TEXT, + payload_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(payload_json)), + created_at TEXT NOT NULL + ); +CREATE INDEX idx_artifacts_session_created + ON artifacts (session_id, created_at DESC); +CREATE TABLE runtime_kv ( + key TEXT PRIMARY KEY, + value_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(value_json)), + updated_at TEXT NOT NULL + ); +CREATE TABLE audit_logs ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + session_id TEXT, + actor_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(actor_json)), + action TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_id TEXT NOT NULL, + before_json TEXT, + after_json TEXT, + meta_json TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(meta_json)), + created_at TEXT NOT NULL + ); +CREATE INDEX idx_audit_logs_user_created + ON audit_logs (user_id, created_at DESC); +CREATE TABLE token_usage_outbox ( + sequence INTEGER PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + payload_json TEXT NOT NULL CHECK (json_valid(payload_json)) + ); +CREATE UNIQUE INDEX uq_evolution_jobs_active_dedupe + ON evolution_jobs (dedupe_key) + WHERE dedupe_key IS NOT NULL AND status IN ('queued', 'leased', 'failed'); +INSERT INTO schema_migrations VALUES ('009_token_usage_outbox', 9, '2026-10-01', 'fixture'); diff --git a/Memory/tests/native-scanner-lifecycle.test.ts b/Memory/tests/native-scanner-lifecycle.test.ts index b5feb6d27..07fdeb426 100644 --- a/Memory/tests/native-scanner-lifecycle.test.ts +++ b/Memory/tests/native-scanner-lifecycle.test.ts @@ -47,8 +47,8 @@ describe("Codex scan and Hook share the actual Memory lifecycle", () => { expect(db.db.prepare("SELECT COUNT(*) AS n FROM memories WHERE memory_layer = 'L1'").get()).toEqual({ n: 1 }); const captured = hook().result!; const repos = new Repositories(db.db); - expect(repos.runtime.getSession(captured.sessionId)?.userId).toBe("scan-owner"); - expect(repos.memories.get(captured.l1MemoryId)?.userId).toBe("scan-owner"); + expect(repos.runtime.getSession(captured.sessionId)?.userId).toBe(null); + expect(repos.memories.get(captured.l1MemoryId)?.userId).toBe(null); expect(repos.runtime.getEpisode(captured.episodeId)?.l1MemoryIds).toEqual([captured.l1MemoryId]); expect(repos.runtime.getRawTurn(captured.rawTurnId)?.toolCalls[0]).toMatchObject({ id: "read", input: { path: "src/main.ts" }, output: expect.stringContaining("source contents") }); expect(db.db.prepare("SELECT COUNT(*) AS n FROM evolution_jobs WHERE job_type = 'import_summary'").get()).toEqual({ n: 0 }); diff --git a/Memory/tests/repository/device-memory-scope.test.ts b/Memory/tests/repository/device-memory-scope.test.ts new file mode 100644 index 000000000..e0a06e630 --- /dev/null +++ b/Memory/tests/repository/device-memory-scope.test.ts @@ -0,0 +1,209 @@ +import { L3WorldModelTraceFieldPipeline } from "../../src/service/evolution/l3-world-model-pipeline.js"; +import type { LlmClient } from "../../src/model/types.js"; +import { readFileSync, existsSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import Database from "better-sqlite3"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { MemoryDb } from "../../src/storage/db.js"; +import { Repositories } from "../../src/storage/repositories.js"; +import { canonicalJson, sha256Hex } from "../../src/contracts/index.js"; +import { canonicalProjectId, refreshProjectAliases } from "../../src/storage/device-scope.js"; +import { resolveWorkspaceIdentity } from "../../src/service/namespace/workspace-identity.js"; +import { stableHash } from "../../src/utils/id.js"; +import { createMemoryServiceFixture } from "../fixtures/memory-service-fixture.js"; +const fixture = createMemoryServiceFixture(); +afterEach(fixture.cleanup); +const at = "2026-10-01T00:00:00.000Z", uri = "file:///workspace/shared", host = "a".repeat(64); +const identity = resolveWorkspaceIdentity(null, {workspaceUri: uri, workspaceHostId: host}); +function legacyProject(user: string) { + const workspaceId = sha256Hex(`${user}\0local\0${host}\0${uri}`); + return {projectId: `ws_${workspaceId}`, workspaceId}; +} +function seedV9(path: string) { + const db = new Database(path); + db.exec(readFileSync(new URL("../fixtures/schema-v9.sql", import.meta.url), "utf8")); + for (const user of ["account-a", "account-b"]) { + const {projectId, workspaceId} = legacyProject(user); + const namespaceKey = stableHash({tenantId: null, projectId, workspaceId}); + db.prepare(`INSERT INTO sessions(id,user_id,project_id,source,profile_id,workspace_id,host_session_key,conversation_id,meta_json,opened_at,last_seen_at,updated_at) + VALUES (?, ?, ?, 'codex', 'default', ?, ?, 'native-conversation', ?, ?, ?, ?)`) + .run(`session-${user}`,user,projectId,workspaceId,`host-${user}`,JSON.stringify({workspace_uri:uri,workspace_host_id:host,l3_world_model_protocol_version:2,source_namespace_key:namespaceKey}),at,at,at); + for (const layer of ["L1","L3"] as const) { + const info = {project_id:projectId,...(layer === "L1" ? {turn_id:"native-turn"} : {})}; + const internal = layer === "L1" ? {memory_layer:layer,memory_kind:"trace",trace:{turn_id:"native-turn",step_index:0}} : { + memory_layer:layer,memory_kind:"world_model",schema_version:2,world_model:{ + general_rules_and_safety_constraints:null,project_environment_profile:null, + project_contract:user === "account-a" ? "Run tests" : null, + domain_knowledge:user === "account-b" ? "Parser accepts UTF-8" : null + }}; + db.prepare(`INSERT INTO memories(id,timeline,user_id,session_id,memory_key,memory_value,info_json,properties_json,memory_layer,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?)`).run(`${layer}-${user}`,at,user,`session-${user}`,`world_model:project:${user}:${projectId}`,"original", + JSON.stringify(info),JSON.stringify({internal_info:internal}),layer,at,at); + } + db.prepare(`INSERT INTO l3_world_model_scopes(scope_key,user_id,project_id,workspace_uri,memory_id,updated_at) VALUES (?,?,?,?,?,?)`) + .run(`l3wm:${sha256Hex(canonicalJson({userId:user,projectId}))}`,user,projectId,uri,`L3-${user}`,at); + db.prepare(`INSERT INTO user_memories(id,source_turn_id,user_id,memory_types_json,content,normalized_user_text_hash,created_at,updated_at) + VALUES (?, ?, ?, '["User Preference"]', 'Prefer Chinese', 'same-user-text', ?, ?)`) + .run(`user-memory-${user}`,`raw-${user}`,user,at,at); + const scopeKey = `l3wm:${sha256Hex(canonicalJson({userId:user,projectId}))}`; + const payload = {scopeKey,scopeSeq:1,userId:user,projectId,sessionId:`session-${user}`,trigger:"session_close",startTraceSeq:1,endTraceSeq:1,l1MemoryIds:[`L1-${user}`],rawTurnIds:[`raw-${user}`],feedbackIds:[]}; + db.prepare(`INSERT INTO episodes(id,session_id,user_id,project_id,opened_at,updated_at) VALUES (?,?,?,?,?,?)`).run(`episode-${user}`,`session-${user}`,user,projectId,at,at); + db.prepare(`INSERT INTO raw_turns(id,session_id,episode_id,turn_id,user_id,user_text,assistant_text,created_at) VALUES (?,?,?,?,?,?,?,?)`) + .run(`raw-${user}`,`session-${user}`,`episode-${user}`,"native-turn",user,"Please preserve the project rule.","The rule is already recorded.",at); + db.prepare(`INSERT INTO l3_world_model_input_traces(session_id,trace_seq,l1_memory_id,raw_turn_id,created_at) VALUES (?,1,?,?,?)`) + .run(`session-${user}`,`L1-${user}`,`raw-${user}`,at); + db.prepare(`INSERT INTO l3_world_model_evidence_batches(id,scope_key,scope_seq,user_id,project_id,session_id,trigger,start_trace_seq,end_trace_seq,l1_memory_ids_json,raw_turn_ids_json,feedback_ids_json,payload_hash,created_at,updated_at) + VALUES (?,?,1,?,?,?,'session_close',1,1,?,?,'[]',?,?,?)`) + .run(`batch-${user}`,scopeKey,user,projectId,`session-${user}`,JSON.stringify(payload.l1MemoryIds),JSON.stringify(payload.rawTurnIds),sha256Hex(canonicalJson(payload)),at,at); + db.prepare(`INSERT INTO l3_world_model_batch_targets(batch_id,target_field,field_scope_key,scope_seq,updated_at) VALUES (?,'project_contract',?,1,?)`) + .run(`batch-${user}`,`${scopeKey}:project_contract`,at); + db.prepare(`INSERT INTO evolution_jobs(id,job_type,user_id,session_id,scope_key,scope_seq,payload_json,created_at,updated_at) + VALUES (?,'l3_world_model_update',?,?,?,1,?,?,?)`) + .run(`job-${user}`,user,`session-${user}`,`${scopeKey}:project_contract`,JSON.stringify({batchId:`batch-${user}`,targetField:"project_contract"}),at,at); + db.prepare(`INSERT INTO source_turn_captures(user_id,source,profile_id,namespace_key,conversation_id,turn_id,content_hash,session_id,response_json,started_at,completed_at,created_at) + VALUES (?,'codex','default',?,'native-conversation','native-turn','hash',?,'{"status":"rejected","reason":"fixture"}',?,?,?)`) + .run(user,namespaceKey,`session-${user}`,at,at,at); + } + db.prepare(`INSERT INTO token_usage_outbox VALUES (1,'old-token-event',?)`).run(JSON.stringify({ownerAccountId:"account-a",totalTokens:1234})); + db.prepare(`INSERT INTO runtime_kv VALUES ('token-budget-fixture', ?, ?)`).run(JSON.stringify({accountId:"account-a",used:1234,limit:5678}),at); + db.prepare(`INSERT INTO runtime_kv VALUES ('source_turn_capture_activated_at', ?, ?)`).run(JSON.stringify(at),at); + db.prepare("INSERT INTO sqlite_sequence(name,seq) VALUES ('memory_change_log',9000)").run(); + return db; +} +describe("device memory / preserved schema-v9 history", () => { + it("upgrades NOT NULL tables verbatim, resolves aliases and never resurrects deleted legacy fields", () => { + const root = mkdtempSync(join(tmpdir(),"memmy-uid-upgrade-")), path = join(root,"memory.sqlite"); + try { + const old = seedV9(path); + const snapshots = ["memories","sessions","l3_world_model_scopes","source_turn_captures","user_memories","runtime_kv","token_usage_outbox","raw_turns","l3_world_model_evidence_batches","evolution_jobs"].map(table => [table,old.prepare(`SELECT * FROM ${table}`).all()] as const); + old.close(); + const db = new MemoryDb({path}); + expect(db.schemaVersion().version).toBe(10); + expect(existsSync(`${path}.pre-v10.bak`)).toBe(true); + for (const [table,rows] of snapshots) expect(db.db.prepare(`SELECT * FROM ${table}`).all()).toEqual(rows); + expect(db.db.pragma("foreign_key_check")).toEqual([]); + expect(db.db.pragma("table_info(memories)")).toEqual(expect.arrayContaining([expect.objectContaining({name:"user_id",notnull:0})])); + const repos = new Repositories(db.db), a = legacyProject("account-a"), b = legacyProject("account-b"); + expect(canonicalProjectId(db.db,a.projectId)).toBe(identity.projectId); + expect(canonicalProjectId(db.db,b.projectId)).toBe(identity.projectId); + expect(canonicalProjectId(db.db,"unknown-project")).toBe("unknown-project"); + const scope = {userId:"new-account",source:"codex",profileId:"default",namespaceKey:stableHash({tenantId:null,projectId:identity.projectId,workspaceId:identity.workspaceId}),conversationId:"native-conversation"}; + expect(repos.runtime.getSourceTurnCapture(scope,"native-turn")?.sessionId).toMatch(/^session-account-/); + expect(repos.memories.nativeDuplicates(repos.memories.get("L1-account-a")!)).toEqual({ids:["L1-account-a","L1-account-b"],deleted:false}); + repos.memories.softDelete("L1-account-a"); + expect(repos.memories.nativeDuplicates(repos.memories.get("L1-account-b")!).deleted).toBe(true); + expect(repos.userMemories.listActive("any-account").map(row=>row.id)).toEqual(["user-memory-account-a"]); + repos.userMemories.softDelete("user-memory-account-a"); + expect(repos.userMemories.listActive("account-b")).toEqual([]); + expect(db.db.prepare("SELECT COUNT(*) FROM user_memories").pluck().get()).toBe(2); + expect(db.db.prepare("SELECT seq FROM sqlite_sequence WHERE name='memory_change_log'").pluck().get()).toBe(9000); + const fields = repos.l3WorldModels.fields("new-account",identity.projectId); + expect(fields).toMatchObject({projectContract:"Run tests",domainKnowledge:"Parser accepts UTF-8"}); + expect(repos.l3WorldModels.fields("account-b",b.projectId)).toEqual(fields); + const saved = repos.l3WorldModels.upsertField({userId:"account-c",projectId:identity.projectId,targetField:"project_contract",value:null})!; + expect(repos.l3WorldModels.fields("account-a",a.projectId)).toMatchObject({projectContract:null,domainKnowledge:"Parser accepts UTF-8"}); + expect(db.db.prepare(`SELECT user_id FROM memories WHERE id=?`).get(saved.id)).toEqual({user_id:null}); + expect(db.db.prepare(`SELECT user_id FROM memories WHERE id='L3-account-a'`).get()).toEqual({user_id:"account-a"}); + repos.l3WorldModels.deleteScopeMemory(saved.id); + expect(repos.l3WorldModels.fields(null,identity.projectId)).toEqual({generalRulesAndSafetyConstraints:null,projectEnvironmentProfile:null,projectContract:null,domainKnowledge:null}); + db.close(); + const reopened = new MemoryDb({path}); + expect(new Repositories(reopened.db).l3WorldModels.fields("account-d",identity.projectId).projectContract).toBeNull(); + expect(reopened.db.prepare(`SELECT value_json FROM runtime_kv WHERE key='token-budget-fixture'`).pluck().get()).toBe(JSON.stringify({accountId:"account-a",used:1234,limit:5678})); + reopened.close(); + const readonly = new MemoryDb({path,readonly:true}); + expect(new Repositories(readonly.db).l3WorldModels.fields("account-e",a.projectId).projectContract).toBeNull(); + readonly.close(); + } finally { rmSync(root,{recursive:true,force:true}); } + }); + it("continues immutable legacy L3 batches once and serializes different account scopes", async () => { + const root = mkdtempSync(join(tmpdir(),"memmy-uid-batches-")), path = join(root,"memory.sqlite"); + try { + seedV9(path).close(); + const db = new MemoryDb({path}), repos = new Repositories(db.db); + const complete = vi.fn(async () => JSON.stringify({op:"noop",project_contract:"",reason:"already recorded"})); + const llm = {config:{},isConfigured:()=>true,complete,completeJson:vi.fn(),status:()=>({provider:"fixture",configured:true,remote:false})} as unknown as LlmClient; + const jobs = repos.runtime.leaseQueuedJobs(10,60,undefined,false,["l3_world_model_update"]); + expect(jobs.map(job=>job.id)).toEqual(["job-account-a"]); + expect(repos.runtime.leaseQueuedJobs(10,60,undefined,false,["l3_world_model_update"])).toEqual([]); + const pipeline = new L3WorldModelTraceFieldPipeline({repos,skillLlm:llm}); + await pipeline.updateField(jobs[0]!); + expect(complete).toHaveBeenCalledTimes(1); + await pipeline.updateField(jobs[0]!); + expect(complete).toHaveBeenCalledTimes(1); + expect(repos.l3WorldModels.getTarget("batch-account-a","project_contract")?.status).toBe("applied"); + db.db.prepare(`UPDATE evolution_jobs SET status='succeeded' WHERE id='job-account-a'`).run(); + expect(repos.runtime.leaseQueuedJobs(10,60,undefined,false,["l3_world_model_update"]).map(job=>job.id)).toEqual(["job-account-b"]); + expect(repos.l3WorldModels.getStoredBatch("batch-account-a")?.userId).toBe("account-a"); + expect(repos.l3WorldModels.getBatch("batch-account-a")?.userId).toBeNull(); + expect(repos.l3WorldModels.getBatch("batch-account-a")?.projectId).toBe(identity.projectId); + db.close(); + } finally {rmSync(root,{recursive:true,force:true});} + }); + it("shares writes and project identity across accounts, retaining separate host identities", () => { + const {db,service} = fixture.createTestService(); + const namespace = {source:"codex",profileId:"default",sessionKey:"shared-session",userId:"account-a"}; + const request = {l3WorldModelProtocolVersion:2 as const,l3WorldModelTransition:"resume_only" as const,workspaceUri:uri,workspaceHostId:host,namespace}; + const first = service.openSession(request), second = service.openSession({...request,namespace:{...namespace,userId:"account-b"}}); + expect(second.sessionId).toBe(first.sessionId); + expect(second.projectId).toBe(first.projectId); + expect(resolveWorkspaceIdentity("account-c",{workspaceUri:uri,workspaceHostId:host})).toEqual(identity); + expect(resolveWorkspaceIdentity(null,{workspaceUri:uri,workspaceHostId:"b".repeat(64)}).projectId).not.toBe(identity.projectId); + service.completeTurn("shared-turn",{sessionId:first.sessionId,namespace,query:"我最喜欢的水果是苹果",answer:"记住了。"}); + const a = service.panelItems({layer:"UserMemory",namespace}), b = service.panelItems({layer:"UserMemory",namespace:{...namespace,userId:"account-b"}}); + expect(a.items.length).toBeGreaterThan(0); + expect(b.items).toEqual(a.items); + for (const table of ["memories","user_memories","sessions","raw_turns","episodes","evolution_jobs"]) expect(db.db.prepare(`SELECT COUNT(*) FROM ${table} WHERE user_id IS NOT NULL`).pluck().get()).toBe(0); + service.deleteMemory(a.items[0]!.id,{namespace:{...namespace,userId:"account-b"}}); + expect(service.panelItems({layer:"UserMemory",namespace}).items).toEqual([]); + }); + it("retries new and historical idempotency entries across accounts without accepting changed content", () => { + const {db,service} = fixture.createTestService(); + const repos = new Repositories(db.db); + const request = {adapterId:"fixture",requestId:"open",timeZone:"+00:00",namespace:{source:"codex",profileId:"default",userId:"account-a"}}; + const opened = service.openSession(request); + expect(service.openSession({...request,namespace:{...request.namespace,userId:"account-b"}})) + .toMatchObject({sessionId:opened.sessionId,userId:null,duplicate:true}); + // Reproduce the exact pre-upgrade cache and session owner, without rewriting them on read. + db.db.prepare("UPDATE sessions SET user_id='account-a' WHERE id=?").run(opened.sessionId); + repos.runtime.saveIdempotency("session.open:fixture:open",stableHash({operation:"session.open",request}),{...opened,userId:"account-a"}); + expect(service.openSession({...request,namespace:{...request.namespace,userId:"account-b"}})).toMatchObject({userId:null,duplicate:true}); + expect(()=>service.openSession({...request,source:"different"})).toThrow(/different session.open request/); + const turn = {adapterId:"fixture",requestId:"complete",timeZone:"+00:00",sessionId:opened.sessionId,namespace:request.namespace,query:"Remember UTF-8",answer:"Confirmed"}; + const completed = service.completeTurn("turn",turn); + const switched = {...turn,namespace:{...turn.namespace,userId:"account-b"}}; + expect(service.completeTurn("turn",switched)).toMatchObject({duplicate:true,rawTurnId:completed.rawTurnId,jobs:[]}); + repos.runtime.saveIdempotency("turn.complete:fixture:complete",stableHash({turnId:"turn",request:turn}),completed); + expect(service.completeTurn("turn",switched)).toMatchObject({duplicate:true,rawTurnId:completed.rawTurnId}); + expect(()=>service.completeTurn("turn",{...switched,answer:"Different"})).toThrow(/different request body/); + }); + it("invalidates ambiguous imported aliases and keeps remote identity without session evidence", () => { + const {db,service} = fixture.createTestService(); + const opened = service.openSession({l3WorldModelProtocolVersion:2,l3WorldModelTransition:"resume_only",workspaceUri:uri,workspaceHostId:host,namespace:{source:"codex",profileId:"default",sessionKey:"alias-a"}}); + db.db.prepare("UPDATE sessions SET project_id='legacy',workspace_id='legacy-workspace' WHERE id=?").run(opened.sessionId); + refreshProjectAliases(db.db); + expect(canonicalProjectId(db.db,"legacy")).toBe(identity.projectId); + const other = service.openSession({l3WorldModelProtocolVersion:2,l3WorldModelTransition:"resume_only",workspaceUri:uri,workspaceHostId:"b".repeat(64),namespace:{source:"codex",profileId:"default",sessionKey:"alias-b"}}); + db.db.prepare("UPDATE sessions SET project_id='legacy',workspace_id='legacy-workspace' WHERE id=?").run(other.sessionId); + refreshProjectAliases(db.db); + expect(canonicalProjectId(db.db,"legacy")).toBe("legacy"); + expect(canonicalProjectId(db.db,"legacy-workspace")).toBe("legacy-workspace"); + const remoteUri = "git://github.com/example/project"; + db.db.prepare("INSERT INTO l3_world_model_scopes(scope_key,user_id,project_id,workspace_uri,updated_at) VALUES ('old-remote','old-owner','old-project',?,?)").run(remoteUri,at); + refreshProjectAliases(db.db); + expect(canonicalProjectId(db.db,"old-project")).toBe(resolveWorkspaceIdentity(null,{workspaceUri:remoteUri}).projectId); + }); + it("shares HTTP operation fingerprints across accounts and keeps content conflicts", async () => { + const {db,service} = fixture.createTestService(); + const request = {adapterId:"fixture",requestId:"generic",namespace:{source:"codex",profileId:"default",userId:"a"}}; + const fingerprint = {path:"/memory/add",request:{...request,content:"same content"}}; + const run = vi.fn(async()=>({id:"result"})); + await service.idempotent("memory.add",request,fingerprint,run); + const switched = {...request,namespace:{...request.namespace,userId:"b"}}; + expect(await service.idempotent("memory.add",switched,{...fingerprint,request:{...fingerprint.request,...switched}},run)).toMatchObject({id:"result",duplicate:true}); + expect(run).toHaveBeenCalledTimes(1); + await expect(service.idempotent("memory.add",switched,{...fingerprint,request:{...fingerprint.request,...switched,content:"different"}},run)).rejects.toThrow(/different request body/); + expect(db.db.prepare("SELECT COUNT(*) FROM idempotency_keys").pluck().get()).toBe(1); + }); +}); diff --git a/Memory/tests/repository/memory-retrieval-index.test.ts b/Memory/tests/repository/memory-retrieval-index.test.ts index 950254528..191db96c0 100644 --- a/Memory/tests/repository/memory-retrieval-index.test.ts +++ b/Memory/tests/repository/memory-retrieval-index.test.ts @@ -42,7 +42,7 @@ describe("memory retrieval indexes", () => { created: false, memory: { id: first.id, - userId: "global-key-user-a", + userId: null, memoryLayer: layer, memoryKey: "shared-memory-key", memoryValue: `${layer} second value` diff --git a/Memory/tests/service/evolution/l3-world-model.test.ts b/Memory/tests/service/evolution/l3-world-model.test.ts index 2b7246113..424becc4b 100644 --- a/Memory/tests/service/evolution/l3-world-model.test.ts +++ b/Memory/tests/service/evolution/l3-world-model.test.ts @@ -525,8 +525,8 @@ describe("L3 World Model trace field pipeline", () => { status: "succeeded" }); service.closeSession(opened.sessionId); - db.db.prepare(`UPDATE raw_turns SET user_id = 'another-user' WHERE id = ?`) - .run(completed.rawTurnId); + const other = service.openSession({namespace:{source:"codex",profileId:"default",sessionKey:"other-session"}}); + db.db.prepare(`UPDATE raw_turns SET session_id = ? WHERE id = ?`).run(other.sessionId,completed.rawTurnId); const repos = new Repositories(db.db); const job = repos.runtime.listJobs("queued", 100).find( (candidate) => candidate.jobType === "l3_world_model_update" diff --git a/Memory/tests/service/evolution/negative-experience.test.ts b/Memory/tests/service/evolution/negative-experience.test.ts index 9a8c89214..a69d66b27 100644 --- a/Memory/tests/service/evolution/negative-experience.test.ts +++ b/Memory/tests/service/evolution/negative-experience.test.ts @@ -177,8 +177,8 @@ describe("MemoryService / evolution / negative experience", () => { `SELECT COUNT(*) AS count FROM evolution_jobs WHERE job_type = 'negative_experience' - AND user_id = ?` - ).get(namespace.userId) as { count: number }; + AND user_id IS ?` + ).get(null) as { count: number }; expect(negativeJobCount.count).toBe(1); await service.runWorkerOnce(50); diff --git a/Memory/tests/service/evolution/orchestration.test.ts b/Memory/tests/service/evolution/orchestration.test.ts index 118547ab6..867ba38b4 100644 --- a/Memory/tests/service/evolution/orchestration.test.ts +++ b/Memory/tests/service/evolution/orchestration.test.ts @@ -107,7 +107,7 @@ describe("MemoryService / evolution / orchestration", () => { expect(promotedCandidates.count).toBeGreaterThanOrEqual(1); const l2Row = db.db.prepare( `SELECT properties_json FROM memories - WHERE user_id = 'user-2' AND memory_layer = 'L2' + WHERE user_id IS NULL AND memory_layer = 'L2' LIMIT 1` ).get() as { properties_json: string }; const l2Internal = (JSON.parse(l2Row.properties_json) as { @@ -149,7 +149,7 @@ describe("MemoryService / evolution / orchestration", () => { ).all() as Array<{ namespace_id: string | null; kind: string | null; op: string | null; entity_id: string | null }>; expect(workerMemoryChanges.length).toBeGreaterThan(0); for (const change of workerMemoryChanges) { - expect(change.namespace_id).toContain("user-2"); + expect(change.namespace_id).not.toContain("user-2"); expect(change.kind).toMatch(/^(trace|policy|world_model|skill)$/); expect(change.op).toMatch(/^(created|updated)$/); expect(change.entity_id).toBeTruthy(); @@ -471,7 +471,7 @@ describe("MemoryService / evolution / orchestration", () => { `SELECT id FROM memories WHERE memory_layer = 'L2' - AND user_id = 'user-2' + AND user_id IS NULL LIMIT 1` ).get() as { id: string } | undefined; expect(policyForRepeat).toBeTruthy(); @@ -697,7 +697,7 @@ describe("MemoryService / evolution / orchestration", () => { const worlds = db.db.prepare( `SELECT info_json, properties_json FROM memories - WHERE user_id = 'shared-downstream-user' + WHERE user_id IS NULL AND memory_layer = 'L3'` ).all() as Array<{ info_json: string; properties_json: string }>; expect(worlds).toHaveLength(0); @@ -705,7 +705,7 @@ describe("MemoryService / evolution / orchestration", () => { const skills = db.db.prepare( `SELECT info_json, properties_json FROM memories - WHERE user_id = 'shared-downstream-user' + WHERE user_id IS NULL AND memory_layer = 'Skill'` ).all() as Array<{ info_json: string; properties_json: string }>; expect(skills).toHaveLength(2); @@ -840,7 +840,7 @@ describe("MemoryService / evolution / orchestration", () => { const l2Count = db.db.prepare( `SELECT COUNT(*) AS count FROM memories - WHERE user_id = 'user-l3-skill-prompt' AND memory_layer = 'L2'` + WHERE user_id IS NULL AND memory_layer = 'L2'` ).get() as { count: number }; if (l2Count.count > 0) { policyCreated = true; @@ -852,7 +852,7 @@ describe("MemoryService / evolution / orchestration", () => { const policyRow = db.db.prepare( `SELECT id, properties_json FROM memories - WHERE user_id = 'user-l3-skill-prompt' AND memory_layer = 'L2' + WHERE user_id IS NULL AND memory_layer = 'L2' LIMIT 1` ).get() as { id: string; properties_json: string }; const policyProperties = JSON.parse(policyRow.properties_json) as { @@ -895,7 +895,7 @@ describe("MemoryService / evolution / orchestration", () => { const l1Rows = db.db.prepare( `SELECT * FROM memories - WHERE user_id = 'user-l3-skill-prompt' AND memory_layer = 'L1'` + WHERE user_id IS NULL AND memory_layer = 'L1'` ).all() as Array & { id: string; properties_json: string }>; const sourceL1Row = l1Rows.find((row) => { const properties = JSON.parse(row.properties_json) as { @@ -988,7 +988,7 @@ describe("MemoryService / evolution / orchestration", () => { const worldRow = db.db.prepare( `SELECT properties_json FROM memories - WHERE user_id = 'user-l3-skill-prompt' AND memory_layer = 'L3' + WHERE user_id IS NULL AND memory_layer = 'L3' LIMIT 1` ).get() as { properties_json: string } | undefined; if (worldRow) { @@ -1040,7 +1040,7 @@ describe("MemoryService / evolution / orchestration", () => { const skillRow = db.db.prepare( `SELECT memory_key, properties_json FROM memories - WHERE user_id = 'user-l3-skill-prompt' AND memory_layer = 'Skill' + WHERE user_id IS NULL AND memory_layer = 'Skill' LIMIT 1` ).get() as { memory_key: string; properties_json: string }; const skillProperties = JSON.parse(skillRow.properties_json) as { diff --git a/Memory/tests/service/evolution/policy-induction.test.ts b/Memory/tests/service/evolution/policy-induction.test.ts index 1c4332de1..8b6aad3e1 100644 --- a/Memory/tests/service/evolution/policy-induction.test.ts +++ b/Memory/tests/service/evolution/policy-induction.test.ts @@ -167,19 +167,19 @@ describe("MemoryService / evolution / policy induction", () => { expect(rows).toEqual([ { source_memory_id: "mem_candidate-user-a", - user_id: "candidate-user-a", + user_id: null, status: "promoted", policy_id: "policy-user-a" }, { source_memory_id: "mem_candidate-user-a_other-profile", - user_id: "candidate-user-a", + user_id: null, status: "pending", policy_id: null }, { source_memory_id: "mem_candidate-user-b", - user_id: "candidate-user-b", + user_id: null, status: "pending", policy_id: null } @@ -625,7 +625,7 @@ describe("MemoryService / evolution / policy induction", () => { const policies = db.db.prepare( `SELECT memory_key, info_json FROM memories - WHERE user_id = 'shared-key-user' + WHERE user_id IS NULL AND memory_layer = 'L2' ORDER BY memory_key` ).all() as Array<{ memory_key: string; info_json: string }>; @@ -949,7 +949,7 @@ describe("MemoryService / evolution / policy induction", () => { const row = db.db.prepare( `SELECT properties_json FROM memories - WHERE user_id = 'user-l2-support-subset' + WHERE user_id IS NULL AND memory_layer = 'L2' LIMIT 1` ).get() as { properties_json: string } | undefined; @@ -1051,18 +1051,18 @@ describe("MemoryService / evolution / policy induction", () => { const l2Count = db.db.prepare( `SELECT COUNT(*) AS count FROM memories - WHERE user_id = ? AND memory_layer = 'L2'` - ).get(userId) as { count: number }; + WHERE user_id IS ? AND memory_layer = 'L2'` + ).get(null) as { count: number }; expect(l2Count.count).toBe(0); const skippedRows = db.db.prepare( `SELECT after_json FROM memory_change_log - WHERE user_id = ? + WHERE user_id IS ? AND kind = 'policy' AND op = 'skipped' AND change_type = 'l2_induction_skipped' ORDER BY seq DESC` - ).all(userId) as Array<{ after_json: string }>; + ).all(null) as Array<{ after_json: string }>; const skippedReasons = skippedRows.map((row) => { const after = JSON.parse(row.after_json) as { reason?: string }; return after.reason; @@ -1072,10 +1072,10 @@ describe("MemoryService / evolution / policy induction", () => { const job = db.db.prepare( `SELECT status, attempts, last_error FROM evolution_jobs - WHERE user_id = ? AND job_type = 'l2_induction' + WHERE user_id IS ? AND job_type = 'l2_induction' ORDER BY created_at DESC LIMIT 1` - ).get(userId) as { status: string; attempts: number; last_error: string | null }; + ).get(null) as { status: string; attempts: number; last_error: string | null }; expect(job).toMatchObject({ status: "succeeded", attempts: 1, @@ -1084,7 +1084,7 @@ describe("MemoryService / evolution / policy induction", () => { const downstreamCount = db.db.prepare( `SELECT COUNT(*) AS count FROM evolution_jobs - WHERE user_id = ? + WHERE user_id IS ? AND ( job_type IN ('l3_abstraction', 'skill_crystallization') OR ( @@ -1092,7 +1092,7 @@ describe("MemoryService / evolution / policy induction", () => { AND json_extract(payload_json, '$.reason') = 'l2.upserted' ) )` - ).get(userId) as { count: number }; + ).get(null) as { count: number }; expect(downstreamCount.count).toBe(0); db.close(); @@ -1183,23 +1183,23 @@ describe("MemoryService / evolution / policy induction", () => { const l2Rows = db.db.prepare( `SELECT id FROM memories - WHERE user_id = ? AND memory_layer = 'L2'` - ).all(userId) as Array<{ id: string }>; + WHERE user_id IS ? AND memory_layer = 'L2'` + ).all(null) as Array<{ id: string }>; expect(l2Rows).toHaveLength(1); const skippedCount = db.db.prepare( `SELECT COUNT(*) AS count FROM memory_change_log - WHERE user_id = ? + WHERE user_id IS ? AND change_type = 'l2_induction_skipped'` - ).get(userId) as { count: number }; + ).get(null) as { count: number }; expect(skippedCount.count).toBe(0); const job = db.db.prepare( `SELECT status, attempts, last_error FROM evolution_jobs - WHERE user_id = ? AND job_type = 'l2_induction' + WHERE user_id IS ? AND job_type = 'l2_induction' ORDER BY created_at DESC LIMIT 1` - ).get(userId) as { status: string; attempts: number; last_error: string | null }; + ).get(null) as { status: string; attempts: number; last_error: string | null }; expect(job).toMatchObject({ status: "succeeded", attempts: 1, @@ -1208,7 +1208,7 @@ describe("MemoryService / evolution / policy induction", () => { const downstreamJobs = db.db.prepare( `SELECT job_type FROM evolution_jobs - WHERE user_id = ? + WHERE user_id IS ? AND ( job_type = 'l3_abstraction' OR ( @@ -1218,7 +1218,7 @@ describe("MemoryService / evolution / policy induction", () => { ) ) ORDER BY job_type` - ).all(userId, l2Rows[0]!.id) as Array<{ job_type: string }>; + ).all(null, l2Rows[0]!.id) as Array<{ job_type: string }>; expect(downstreamJobs.map((item) => item.job_type)).toEqual(["embedding"]); db.close(); diff --git a/Memory/tests/service/evolution/skill-lifecycle.test.ts b/Memory/tests/service/evolution/skill-lifecycle.test.ts index 5b92f2e98..2d0062b48 100644 --- a/Memory/tests/service/evolution/skill-lifecycle.test.ts +++ b/Memory/tests/service/evolution/skill-lifecycle.test.ts @@ -193,7 +193,7 @@ describe("MemoryService / evolution / skill lifecycle", () => { const skillCount = db.db.prepare( `SELECT COUNT(*) AS count FROM memories - WHERE user_id = 'user-skill-guard' + WHERE 1=1 AND memory_layer = 'Skill'` ).get() as { count: number }; expect(skillCount.count).toBe(0); @@ -247,7 +247,7 @@ describe("MemoryService / evolution / skill lifecycle", () => { const createdSkill = db.db.prepare( `SELECT id, updated_at FROM memories - WHERE user_id = 'user-fresh-skill-skip' + WHERE 1=1 AND memory_layer = 'Skill' LIMIT 1` ).get() as { id: string; updated_at: string } | undefined; @@ -391,9 +391,9 @@ describe("MemoryService / evolution / skill lifecycle", () => { const rows = db.db.prepare( `SELECT id, status, properties_json FROM memories - WHERE user_id = ? AND memory_layer = 'Skill' + WHERE 1=1 AND memory_layer = 'Skill' ORDER BY id` - ).all(userId) as Array<{ id: string; status: string; properties_json: string }>; + ).all() as Array<{ id: string; status: string; properties_json: string }>; expect(rows).toHaveLength(2); const active = rows.find((row) => row.status !== "archived"); const archived = rows.find((row) => row.status === "archived"); @@ -490,9 +490,9 @@ describe("MemoryService / evolution / skill lifecycle", () => { const rows = db.db.prepare( `SELECT id, status FROM memories - WHERE user_id = ? AND memory_layer = 'Skill' + WHERE 1=1 AND memory_layer = 'Skill' ORDER BY id` - ).all(userId) as Array<{ id: string; status: string }>; + ).all() as Array<{ id: string; status: string }>; expect(rows).toHaveLength(2); expect(rows.map((row) => row.id)).toEqual([ "skill-shared-policy-pytest", @@ -501,8 +501,8 @@ describe("MemoryService / evolution / skill lifecycle", () => { const merges = db.db.prepare( `SELECT entity_id, after_json FROM memory_change_log - WHERE user_id = ? AND change_type = 'skill_merged_into_canonical'` - ).all(userId) as Array<{ entity_id: string; after_json: string }>; + WHERE 1=1 AND change_type = 'skill_merged_into_canonical'` + ).all() as Array<{ entity_id: string; after_json: string }>; expect(merges).toEqual([]); expect(rows.map((row) => row.status)).toEqual(["resolving", "resolving"]); @@ -584,7 +584,7 @@ describe("MemoryService / evolution / skill lifecycle", () => { const createdSkill = db.db.prepare( `SELECT id, updated_at FROM memories - WHERE user_id = 'user-skill-cooldown' + WHERE 1=1 AND memory_layer = 'Skill' LIMIT 1` ).get() as { id: string; updated_at: string } | undefined; @@ -617,7 +617,7 @@ describe("MemoryService / evolution / skill lifecycle", () => { const skills = db.db.prepare( `SELECT id, updated_at FROM memories - WHERE user_id = 'user-skill-cooldown' + WHERE 1=1 AND memory_layer = 'Skill'` ).all() as Array<{ id: string; updated_at: string }>; expect(skills).toEqual([createdSkill]); @@ -760,7 +760,7 @@ describe("MemoryService / evolution / skill lifecycle", () => { const skillRows = db.db.prepare( `SELECT id, memory_key FROM memories - WHERE user_id = 'user-source-policy-skill-skip' + WHERE 1=1 AND memory_layer = 'Skill'` ).all() as Array<{ id: string; memory_key: string }>; expect(skillRows).toEqual([{ @@ -898,7 +898,7 @@ describe("MemoryService / evolution / skill lifecycle", () => { const skillRows = db.db.prepare( `SELECT id, status, updated_at, properties_json FROM memories - WHERE user_id = 'user-archived-skill-recreate' + WHERE 1=1 AND memory_layer = 'Skill' ORDER BY created_at ASC` ).all() as Array<{ id: string; status: string; updated_at: string; properties_json: string }>; @@ -917,7 +917,7 @@ describe("MemoryService / evolution / skill lifecycle", () => { const created = db.db.prepare( `SELECT memory_id, before_json, after_json FROM memory_change_log - WHERE user_id = 'user-archived-skill-recreate' + WHERE 1=1 AND kind = 'skill' AND change_type = 'create' ORDER BY seq DESC @@ -977,7 +977,7 @@ describe("MemoryService / evolution / skill lifecycle", () => { const skillRow = db.db.prepare( `SELECT id FROM memories - WHERE user_id = 'user-skill-reward-drift' + WHERE 1=1 AND memory_layer = 'Skill' LIMIT 1` ).get() as { id: string } | undefined; @@ -1188,9 +1188,9 @@ describe("MemoryService / evolution / skill lifecycle", () => { const skillRow = db.db.prepare( `SELECT memory_key, properties_json FROM memories - WHERE user_id = ? AND memory_layer = 'Skill' + WHERE 1=1 AND memory_layer = 'Skill' LIMIT 1` - ).get(userId) as { memory_key: string; properties_json: string }; + ).get() as { memory_key: string; properties_json: string }; const skillSourceProperties = JSON.parse(skillRow.properties_json) as { internal_info?: { source_policy_ids?: string[] }; }; @@ -1199,9 +1199,9 @@ describe("MemoryService / evolution / skill lifecycle", () => { const policyRow = db.db.prepare( `SELECT id, properties_json FROM memories - WHERE id = ? AND user_id = ? AND memory_layer = 'L2' + WHERE id = ? AND 1=1 AND memory_layer = 'L2' LIMIT 1` - ).get(sourcePolicyId!, userId) as { id: string; properties_json: string }; + ).get(sourcePolicyId!) as { id: string; properties_json: string }; const policyProperties = JSON.parse(policyRow.properties_json) as { internal_info?: { title?: string; @@ -1382,18 +1382,18 @@ describe("MemoryService / evolution / skill lifecycle", () => { const skillCount = db.db.prepare( `SELECT COUNT(*) AS count FROM memories - WHERE user_id = ? AND memory_layer = 'Skill'` - ).get(userId) as { count: number }; + WHERE 1=1 AND memory_layer = 'Skill'` + ).get() as { count: number }; expect(skillCount.count).toBe(0); const skippedRows = db.db.prepare( `SELECT after_json FROM memory_change_log - WHERE user_id = ? + WHERE 1=1 AND kind = 'skill' AND op = 'skipped' AND change_type = 'skill_crystallization_skipped' ORDER BY seq DESC` - ).all(userId) as Array<{ after_json: string }>; + ).all() as Array<{ after_json: string }>; const skippedReasons = skippedRows.map((row) => { const after = JSON.parse(row.after_json) as { reason?: string }; return after.reason; @@ -1526,18 +1526,18 @@ describe("MemoryService / evolution / skill lifecycle", () => { const skillCount = db.db.prepare( `SELECT COUNT(*) AS count FROM memories - WHERE user_id = ? AND memory_layer = 'Skill'` - ).get(userId) as { count: number }; + WHERE 1=1 AND memory_layer = 'Skill'` + ).get() as { count: number }; expect(skillCount.count).toBe(0); const skippedRows = db.db.prepare( `SELECT after_json FROM memory_change_log - WHERE user_id = ? + WHERE 1=1 AND kind = 'skill' AND op = 'skipped' AND change_type = 'skill_crystallization_skipped' ORDER BY seq DESC` - ).all(userId) as Array<{ after_json: string }>; + ).all() as Array<{ after_json: string }>; const skippedReasons = skippedRows.map((row) => { const after = JSON.parse(row.after_json) as { reason?: string }; return after.reason; diff --git a/Memory/tests/service/feedback/experience.test.ts b/Memory/tests/service/feedback/experience.test.ts index 7b1d68218..07232692c 100644 --- a/Memory/tests/service/feedback/experience.test.ts +++ b/Memory/tests/service/feedback/experience.test.ts @@ -111,7 +111,7 @@ describe("MemoryService / feedback / experience", () => { const created = db.db.prepare( `SELECT id, memory_value, properties_json FROM memories - WHERE user_id = 'user-feedback-experience' + WHERE user_id IS NULL AND memory_layer = 'L2'` ).all() as Array<{ id: string; memory_value: string; properties_json: string }>; expect(created).toHaveLength(1); @@ -149,7 +149,7 @@ describe("MemoryService / feedback / experience", () => { const beforeWorker = db.db.prepare( `SELECT id, properties_json FROM memories - WHERE user_id = 'user-feedback-experience' + WHERE user_id IS NULL AND memory_layer = 'L2'` ).all() as Array<{ id: string; properties_json: string }>; expect(beforeWorker).toHaveLength(1); @@ -167,7 +167,7 @@ describe("MemoryService / feedback / experience", () => { const policies = db.db.prepare( `SELECT id, properties_json FROM memories - WHERE user_id = 'user-feedback-experience' + WHERE user_id IS NULL AND memory_layer = 'L2'` ).all() as Array<{ id: string; properties_json: string }>; expect(policies).toHaveLength(2); @@ -282,7 +282,7 @@ describe("MemoryService / feedback / experience", () => { const row = db.db.prepare( `SELECT properties_json FROM memories - WHERE user_id = 'user-feedback-refiner' + WHERE user_id IS NULL AND memory_layer = 'L2' LIMIT 1` ).get() as { properties_json: string } | undefined; @@ -309,7 +309,7 @@ describe("MemoryService / feedback / experience", () => { const skillRow = db.db.prepare( `SELECT id, properties_json FROM memories - WHERE user_id = 'user-feedback-refiner' + WHERE user_id IS NULL AND memory_layer = 'Skill' LIMIT 1` ).get() as { id: string; properties_json: string } | undefined; diff --git a/Memory/tests/service/import/memory-capture-dedup.test.ts b/Memory/tests/service/import/memory-capture-dedup.test.ts index 850263a0e..4adf4a8dd 100644 --- a/Memory/tests/service/import/memory-capture-dedup.test.ts +++ b/Memory/tests/service/import/memory-capture-dedup.test.ts @@ -46,8 +46,8 @@ describe("MemoryService / import / QA capture dedup", () => { expect(l1Count(db.db)).toBe(beforeCount); expect(db.db.prepare( `SELECT captured_by FROM memory_capture_claims - WHERE user_id = ? AND source = 'codex'` - ).get(namespace.userId)).toEqual({ captured_by: "turn_complete" }); + WHERE user_id IS ? AND source = 'codex'` + ).get(null)).toEqual({ captured_by: "turn_complete" }); }); it("keeps the scan capture and still records the hook raw turn and episode link", () => { @@ -90,8 +90,8 @@ describe("MemoryService / import / QA capture dedup", () => { expect(JSON.parse(episode.raw_turn_ids_json)).toContain(completed.rawTurnId); expect(db.db.prepare( `SELECT captured_by FROM memory_capture_claims - WHERE user_id = ? AND source = 'codex'` - ).get(namespace.userId)).toEqual({ captured_by: "agent_source_scan" }); + WHERE user_id IS ? AND source = 'codex'` + ).get(null)).toEqual({ captured_by: "agent_source_scan" }); }); it("stores a second memory when the final assistant answer differs", () => { diff --git a/Memory/tests/service/memory-token-budget-review.test.ts b/Memory/tests/service/memory-token-budget-review.test.ts index 8f035b4a2..bb8d061d9 100644 --- a/Memory/tests/service/memory-token-budget-review.test.ts +++ b/Memory/tests/service/memory-token-budget-review.test.ts @@ -137,7 +137,7 @@ describe("memory token budget review regressions", () => { payload: { feedbackId: feedback.feedbackId, contextHash: "ctx-budget-revision", - namespace + namespace: { ...namespace, userId: null } } }); await service.runWorkerOnce(10); @@ -150,7 +150,7 @@ describe("memory token budget review regressions", () => { expect(repairs()).toHaveLength(1); expect(repairs()[0]).toMatchObject({ feedbackId: feedback.feedbackId, - userId: namespace.userId, + userId: null, projectId: namespace.projectId, preference: expect.stringContaining("Inspect migration output"), source: { synthesis: "llm" } @@ -158,7 +158,7 @@ describe("memory token budget review regressions", () => { expect(repos.runtime.listChanges(undefined, 50) .filter((change) => change.changeType === "decision_repair_created") .map((change) => change.namespaceId)).toEqual([ - "user-budget-revision:project-budget-revision:codex:budget-revision-profile" + "project-budget-revision:codex:budget-revision-profile" ]); await service.runWorkerOnce(10); expect(calls.filter((operation) => operation === "decision.repair.v1")).toHaveLength(1); @@ -524,7 +524,7 @@ describe("memory token budget review regressions", () => { const row = db.db.prepare( `SELECT id FROM memories - WHERE user_id = 'user-feedback-rule' + WHERE user_id IS NULL AND memory_layer = 'L2' LIMIT 1` ).get() as { id: string } | undefined; diff --git a/Memory/tests/service/read-model/l3-world-model-context.test.ts b/Memory/tests/service/read-model/l3-world-model-context.test.ts index e0f29f7e5..dbdf0bd36 100644 --- a/Memory/tests/service/read-model/l3-world-model-context.test.ts +++ b/Memory/tests/service/read-model/l3-world-model-context.test.ts @@ -89,8 +89,8 @@ describe("Session L3 World Model context read model", () => { db.db.prepare( `UPDATE l3_world_model_project_environment_state SET project_kind = 'code', status = 'clean', applied_scan_id = 'scan-1', updated_at = ? - WHERE user_id = ? AND project_id = ?` - ).run("2026-01-01T00:00:00.000Z", namespace.userId, projectId); + WHERE user_id IS ? AND project_id = ?` + ).run("2026-01-01T00:00:00.000Z", null, projectId); const context = service.l3WorldModelContext(opened.sessionId, envelope(scopedNamespace)); expect(context).toMatchObject({ @@ -102,8 +102,8 @@ describe("Session L3 World Model context read model", () => { const before = repos.memories.get(memory.id)!; db.db.prepare( `UPDATE l3_world_model_project_environment_state - SET applied_scan_id = 'scan-2' WHERE user_id = ? AND project_id = ?` - ).run(namespace.userId, projectId); + SET applied_scan_id = 'scan-2' WHERE user_id IS ? AND project_id = ?` + ).run(null, projectId); const projected = service.l3WorldModelContext(opened.sessionId, envelope(scopedNamespace)); expect(projected.projectEnvironmentProfile).toBeNull(); expect(projected.projectContract).toBe("提交前运行测试。"); diff --git a/Memory/tests/service/read-model/panel-read.test.ts b/Memory/tests/service/read-model/panel-read.test.ts index 6c4087b7a..5622f1a56 100644 --- a/Memory/tests/service/read-model/panel-read.test.ts +++ b/Memory/tests/service/read-model/panel-read.test.ts @@ -649,7 +649,7 @@ describe("MemoryService / read model / panel", () => { targetField: "project_environment_profile", value: "Project profile." })!; - db.db.prepare(`UPDATE memories SET user_id = 'different-owner' WHERE id = ?`).run(project.id); + db.db.prepare(`UPDATE memories SET info_json = json_set(info_json, '$.project_id', 'different-project') WHERE id = ?`).run(project.id); expect(service.panelItems({ layer: "L3" }).items.find((item) => item.id === project.id)) .not.toHaveProperty("worldModelScope"); diff --git a/Memory/tests/service/session/episode-relation.test.ts b/Memory/tests/service/session/episode-relation.test.ts index 0b27c7599..f0af8685c 100644 --- a/Memory/tests/service/session/episode-relation.test.ts +++ b/Memory/tests/service/session/episode-relation.test.ts @@ -406,8 +406,8 @@ describe("MemoryService / session / episode relation", () => { l1_memory_ids_json: JSON.stringify([first.l1MemoryId, completed.l1MemoryId]) }); expect(db.db.prepare( - `SELECT content, source_turn_id FROM user_memories WHERE user_id = ? AND status = 'active'` - ).all("user-end-topic-feedback")).toEqual([{ + `SELECT content, source_turn_id FROM user_memories WHERE user_id IS ? AND status = 'active'` + ).all(null)).toEqual([{ content: closingQuery, source_turn_id: completed.rawTurnId }]); @@ -827,7 +827,7 @@ describe("MemoryService / session / episode relation", () => { const feedbackCount = db.db.prepare( `SELECT COUNT(*) AS count FROM feedback - WHERE user_id = 'user-relation-follow-up'` + WHERE user_id IS NULL` ).get() as { count: number }; expect(feedbackCount.count).toBe(0); @@ -1227,7 +1227,7 @@ describe("MemoryService / session / episode relation", () => { }); expect(prepared).not.toHaveProperty("episodeId"); expect(db.db.prepare( - "SELECT COUNT(*) AS count FROM feedback WHERE user_id = 'user-relation-revision'" + "SELECT COUNT(*) AS count FROM feedback WHERE user_id IS NULL" ).get()).toEqual({ count: 0 }); const correction = service.completeTurn("turn-relation-revision-fix", { sessionId: session.sessionId, @@ -1236,13 +1236,13 @@ describe("MemoryService / session / episode relation", () => { }); expect(correction.episodeId).toBe(first.episodeId); expect(db.db.prepare( - "SELECT COUNT(*) AS count FROM feedback WHERE user_id = 'user-relation-revision'" + "SELECT COUNT(*) AS count FROM feedback WHERE user_id IS NULL" ).get()).toEqual({ count: 1 }); const feedback = db.db.prepare( `SELECT id, l1_memory_id, raw_turn_id, polarity, raw_payload_json FROM feedback - WHERE user_id = 'user-relation-revision'` + WHERE user_id IS NULL` ).get() as { id: string; l1_memory_id: string | null; @@ -1410,7 +1410,7 @@ describe("MemoryService / session / episode relation", () => { expect(prepared).not.toHaveProperty("episodeId"); expect(prepared).not.toHaveProperty("closedEpisodeIds"); expect(db.db.prepare( - "SELECT COUNT(*) AS count FROM feedback WHERE user_id = 'user-implicit-turn-feedback'" + "SELECT COUNT(*) AS count FROM feedback WHERE user_id IS NULL" ).get()).toEqual({ count: 0 }); expect(service.getMemory(first.episodeId)).toMatchObject({ kind: "episode", @@ -1426,7 +1426,7 @@ describe("MemoryService / session / episode relation", () => { const feedback = db.db.prepare( `SELECT id, channel, polarity, magnitude, l1_memory_id, raw_turn_id, raw_payload_json FROM feedback - WHERE user_id = 'user-implicit-turn-feedback'` + WHERE user_id IS NULL` ).get() as { id: string; channel: string; diff --git a/Memory/tests/service/session/session-lifecycle.test.ts b/Memory/tests/service/session/session-lifecycle.test.ts index df5d8ac18..f09c63f2b 100644 --- a/Memory/tests/service/session/session-lifecycle.test.ts +++ b/Memory/tests/service/session/session-lifecycle.test.ts @@ -252,7 +252,7 @@ describe("MemoryService / session / lifecycle", () => { injected_memory_ids_json: string; outcome: string; } | undefined; - expect(recallRow?.namespace_id).toContain("user-1"); + expect(recallRow?.namespace_id).not.toContain("user-1"); expect(recallRow?.query_hash).toBeTruthy(); expect(JSON.parse(recallRow!.candidate_memory_ids_json)).toEqual(recall.candidateMemoryIds); expect(JSON.parse(recallRow!.injected_memory_ids_json)).toEqual(recall.sourceMemoryIds); @@ -443,7 +443,7 @@ describe("MemoryService / session / lifecycle", () => { `SELECT user_id, project_id, workspace_uri FROM l3_world_model_scopes WHERE project_id = ?` ).get(opened.projectId)).toEqual({ - user_id: "v2-user", + user_id: null, project_id: opened.projectId, workspace_uri: base.workspaceUri }); @@ -513,8 +513,8 @@ describe("MemoryService / session / lifecycle", () => { db.db.prepare( `UPDATE l3_world_model_scopes SET workspace_uri = 'file:///workspace/conflict' - WHERE user_id = ? AND project_id = ?` - ).run(request.namespace.userId, opened.projectId); + WHERE user_id IS ? AND project_id = ?` + ).run(null, opened.projectId); expect(() => service.openSession({ ...request, sessionId: opened.sessionId, @@ -526,8 +526,8 @@ describe("MemoryService / session / lifecycle", () => { ).get(opened.sessionId)).toEqual(before); db.db.prepare( - `UPDATE l3_world_model_scopes SET workspace_uri = ? WHERE user_id = ? AND project_id = ?` - ).run(request.workspaceUri, request.namespace.userId, opened.projectId); + `UPDATE l3_world_model_scopes SET workspace_uri = ? WHERE user_id IS ? AND project_id = ?` + ).run(request.workspaceUri, null, opened.projectId); db.db.prepare( `UPDATE sessions SET meta_json = json_remove(meta_json, '$.workspace_uri') WHERE id = ?` ).run(opened.sessionId); @@ -624,7 +624,7 @@ describe("MemoryService / session / lifecycle", () => { session_id: session.sessionId, episode_id: complete.episodeId, raw_turn_id: complete.rawTurnId, - user_id: namespace.userId, + user_id: null, kind: "file", uri: "file:///tmp/memmy-report.txt" }); @@ -774,7 +774,7 @@ describe("L3 World Model trace boundaries", () => { requestId: "e4880aaa-e635-4c60-a58f-eec7c21677af", adapterId: "codex-memory", source: "codex", - namespace: { ...namespace, userId: "other-user" } + namespace: { ...namespace, profileId: "other-profile" } })).toThrow("l3_world_model_session_scope_conflict"); expect(() => service.l3WorldModelBoundary(opened.sessionId, { requestId: "9c7e85f5-5fb3-44b5-a91e-6c92d3ae5239", diff --git a/Memory/tests/service/session/source-turn-capture.test.ts b/Memory/tests/service/session/source-turn-capture.test.ts index 88f279f80..285eef02d 100644 --- a/Memory/tests/service/session/source-turn-capture.test.ts +++ b/Memory/tests/service/session/source-turn-capture.test.ts @@ -189,12 +189,12 @@ describe("native source turn submission", () => { expect(second).toMatchObject({ status: "existing", result: { sessionId: opened.sessionId, l1MemoryId: first.result!.l1MemoryId } }); expect(counts(db).sessions).toBe(1); expect(runtime.getSession(opened.sessionId)).toMatchObject({ - userId: "source-user", hostSessionKey: original.hostSessionKey, conversationId: input.sourceTurn.conversationId, + userId: null, hostSessionKey: original.hostSessionKey, conversationId: input.sourceTurn.conversationId, projectId: original.projectId, workspaceId: original.workspaceId, meta: original.meta }); - expect(runtime.getRawTurn(first.result!.rawTurnId)).toMatchObject({ userId: "source-user", conversationId: input.sourceTurn.conversationId }); + expect(runtime.getRawTurn(first.result!.rawTurnId)).toMatchObject({ userId: null, conversationId: input.sourceTurn.conversationId }); const memory = new Repositories(db.db).memories.get(first.result!.l1MemoryId)!; - expect(memory).toMatchObject({ userId: "source-user", conversationId: input.sourceTurn.conversationId, appId: original.workspaceId }); + expect(memory).toMatchObject({ userId: null, conversationId: input.sourceTurn.conversationId, appId: original.workspaceId }); expect(memory.info.project_id).toBe(original.projectId); service.closeSession(opened.sessionId, { namespace: input.namespace }); expect(runtime.getEpisode(first.result!.episodeId)?.status).toBe("closed"); @@ -280,7 +280,7 @@ describe("native source turn submission", () => { expect(new Repositories(db.db).memories.get(completed.result!.l1MemoryId)?.conversationId).toBe(input.sourceTurn.conversationId); }); - it.each(["user", "conversation"] as const)("refuses an observed RawTurn with a conflicting %s", async (conflict) => { + it.each(["user", "conversation"] as const)("handles observed RawTurn legacy %s identity", async (conflict) => { const { db, service } = createTestService(); const input = request(); const opened = service.openSession({ namespace: { ...input.namespace!, sessionKey: `codex-memory-${input.sourceTurn.conversationId}` } }); @@ -291,6 +291,11 @@ describe("native source turn submission", () => { const runtime = new Repositories(db.db).runtime; const original = runtime.getRawTurn(observed.rawTurnId!)!; const before = counts(db); + if (conflict === "user") { + expect(service.completeSourceTurn(request({ sessionId: opened.sessionId })).status).toBe("stored"); + expect(runtime.getRawTurn(observed.rawTurnId!)?.userId).toBeNull(); + return; + } expect(() => service.completeSourceTurn(request({ sessionId: opened.sessionId }))).toThrow("source_raw_turn_scope_conflict"); expect(counts(db)).toEqual(before); expect(runtime.getRawTurn(observed.rawTurnId!)).toEqual(original); @@ -315,7 +320,74 @@ describe("native source turn submission", () => { const first = service.completeSourceTurn(request()); const second = service.completeSourceTurn(request({ sourceTurn: { ...request().sourceTurn, turnId: "native-turn-2", sequence: 2, startedAt: "2099-01-01T10:02:00.000Z", completedAt: "2099-01-01T10:03:00.000Z" } })); const otherUser = service.completeSourceTurn(request({ namespace: { ...request().namespace!, userId: "other-user" } })); - expect(new Set([first.result?.l1MemoryId, second.result?.l1MemoryId, otherUser.result?.l1MemoryId]).size).toBe(3); + expect(otherUser).toMatchObject({ status: "existing", result: { l1MemoryId: first.result!.l1MemoryId, jobs: [] } }); + expect(second.result?.l1MemoryId).not.toBe(first.result?.l1MemoryId); + }); + + it.each(["codex", "claude_code", "cursor", "opencode"])( + "keeps one %s conversation across accounts and capture channels", + async (source) => { + for (const firstChannel of ["hook", "agent_source_scan"] as const) { + const { db, service } = createTestService(); + const input = request({ source, channel: firstChannel, + namespace: { source, profileId: "default", userId: "installed-owner" }, + sourceTurn: { ...request().sourceTurn, source } }); + const opened = service.openSession({ namespace: { ...input.namespace!, + sessionKey: `${source}-memory-${input.sourceTurn.conversationId}` } }); + const first = service.completeSourceTurn(input); + expect(first).toMatchObject({ status: "stored", result: { sessionId: opened.sessionId } }); + const before = counts(db); + const switched = { ...input, channel: firstChannel === "hook" ? "agent_source_scan" as const : "hook" as const, + namespace: { ...input.namespace!, userId: "current-owner" } }; + expect(service.completeSourceTurn(switched)).toMatchObject({ status: "existing", result: { + sessionId: first.result!.sessionId, episodeId: first.result!.episodeId, + rawTurnId: first.result!.rawTurnId, l1MemoryIds: first.result!.l1MemoryIds, jobs: [] + } }); + expect(counts(db)).toEqual(before); + const secondInput = { ...switched, sourceTurn: { ...input.sourceTurn, turnId: "native-turn-2", sequence: 2, + startedAt: "2099-01-01T10:02:00.000Z", completedAt: "2099-01-01T10:03:00.000Z" } }; + const second = service.completeSourceTurn(secondInput); + expect(second).toMatchObject({ status: "stored", result: { + sessionId: opened.sessionId, episodeId: first.result!.episodeId + } }); + expect(second.result!.l1MemoryId).not.toBe(first.result!.l1MemoryId); + expect(db.db.prepare("SELECT DISTINCT user_id FROM source_turn_captures").all()).toEqual([{ user_id: null }]); + expect(counts(db)).toMatchObject({ sessions: 1, episodes: 1, raw_turns: 2, source_turn_captures: 2 }); + await runWorkerRounds(service, 4); + const recall = await service.search({ query: input.query, layers: ["L1"], limit: 10 }); + expect(recall.hits.filter(hit => hit.id === first.result!.l1MemoryId)).toHaveLength(1); + const afterWorker = counts(db); + service.completeSourceTurn(switched); + service.completeSourceTurn(secondInput); + expect(counts(db)).toEqual(afterWorker); + } + } + ); + + it("lets the first scanner completion reuse a Hook Session from the installed account", () => { + const { service, db } = createTestService(); + const input = request(); + const opened = service.openSession({ namespace: { ...input.namespace!, + sessionKey: `codex-memory-${input.sourceTurn.conversationId}` } }); + const scanned = service.completeSourceTurn({ ...input, channel: "agent_source_scan", + namespace: { ...input.namespace!, userId: "new-account" } }); + expect(scanned).toMatchObject({ status: "stored", result: { sessionId: opened.sessionId } }); + expect(service.completeSourceTurn(input).status).toBe("existing"); + expect(counts(db)).toMatchObject({ sessions: 1, episodes: 1, raw_turns: 1, source_turn_captures: 1 }); + expect(new Repositories(db.db).memories.get(scanned.result!.l1MemoryId)?.userId).toBe(null); + }); + + it("retains cross-account conflicts and tombstones after the Episode closes", () => { + const { service, db } = createTestService(); + const first = service.completeSourceTurn(request()); + service.closeSession(first.result!.sessionId); + const switched = request({ namespace: { ...request().namespace!, userId: "switched-owner" }, channel: "agent_source_scan" }); + const before = counts(db); + expect(service.completeSourceTurn(switched)).toMatchObject({ status: "existing", result: { jobs: [] } }); + expect(service.completeSourceTurn({ ...switched, answer: "A different answer" })).toMatchObject({ status: "conflict", reason: "source_turn_content_conflict" }); + db.db.prepare("UPDATE memories SET status = 'deleted', deleted_at = ? WHERE id = ?").run("2099-01-02", first.result!.l1MemoryId); + expect(service.completeSourceTurn(switched)).toMatchObject({ status: "rejected", reason: "capture_deleted" }); + expect(counts(db)).toEqual(before); }); it("reports conflicting content and keeps deleted capture identities", () => { diff --git a/Memory/tests/service/user-memory/user-memory.test.ts b/Memory/tests/service/user-memory/user-memory.test.ts index 7125e0355..135eb1d6f 100644 --- a/Memory/tests/service/user-memory/user-memory.test.ts +++ b/Memory/tests/service/user-memory/user-memory.test.ts @@ -936,7 +936,7 @@ describe("User Memory", () => { db.close(); }); - it("lists User Memory in its own panel layer with user isolation and search", () => { + it("lists User Memory in its own panel layer across accounts with search", () => { const { db, service } = createTestService(); const session = open(service, "panel-user"); service.completeTurn("turn-panel-user-memory", { @@ -961,13 +961,13 @@ describe("User Memory", () => { expect(service.panelItems({ namespace: { source: "codex", profileId: "default", userId: "another-user" }, layer: "UserMemory" - }).items).toEqual([]); + }).items).toEqual(panel.items); expect(service.panelOverviewSummary({ namespace: { source: "codex", profileId: "default", userId: "panel-user" } }).counts.userMemories).toBe(1); expect(service.panelOverviewSummary({ namespace: { source: "codex", profileId: "default", userId: "another-user" } - }).counts.userMemories).toBe(0); + }).counts.userMemories).toBe(1); db.close(); }); diff --git a/Memory/tests/service/work-memory/work-memory-pipeline.test.ts b/Memory/tests/service/work-memory/work-memory-pipeline.test.ts index 30cb9d001..a3a859156 100644 --- a/Memory/tests/service/work-memory/work-memory-pipeline.test.ts +++ b/Memory/tests/service/work-memory/work-memory-pipeline.test.ts @@ -7,6 +7,7 @@ import { canonicalWorkMemoryText, WorkMemoryPipeline } from "../../../src/service/work-memory/work-memory-pipeline.js"; +import { refreshProjectAliases } from "../../../src/storage/device-scope.js"; import { stableHash } from "../../../src/utils/id.js"; import { createCapturingEmbedder, @@ -73,7 +74,7 @@ describe("Work Memory pipeline", () => { })); }); - it("第一阶段只抽取 requirement/reason,第二阶段 create 时生成 topic 并写入 L1 Work Memory", async () => { + it.each(["new", "new-on-legacy-session", "legacy-job"])("第一阶段抽取并写入 L1 Work Memory (%s)", async (mode) => { const { db, service } = createTestService(); const namespace = { source: "codex", @@ -104,7 +105,17 @@ describe("Work Memory pipeline", () => { throughL1MemoryId: completed.l1MemoryId }); const repos = new Repositories(db.db); - const job = findWorkMemoryJob(repos); + let job = findWorkMemoryJob(repos); + if (mode !== "new") { + db.db.prepare("UPDATE sessions SET user_id='legacy-owner', project_id='legacy-project' WHERE id=?").run(opened.sessionId); + refreshProjectAliases(db.db); + if (mode === "legacy-job") { + db.db.prepare("UPDATE evolution_jobs SET user_id='legacy-owner', scope_key=?, dedupe_key=? WHERE id=?").run( + stableHash(["work_memory", "legacy-owner", "legacy-project"]), + stableHash(["work_memory_extract", "legacy-owner", "codex", "legacy-project", job.payload.trajectoryHash]), job.id); + } + job = repos.runtime.getJob(job.id)!; + } const completeJson = vi.fn() .mockResolvedValueOnce({ work_memories: [{ requirement: "固定数据清洗流水线", reason: "保证训练结果可复现" }] }) .mockResolvedValueOnce({ decisions: [{ @@ -478,7 +489,7 @@ function workMemoryNamespace( ): { source: string; profileId: string; - userId: string; + userId: string | null; sessionKey: string; projectId?: string; } { @@ -551,7 +562,7 @@ function testLlm(completeJson: LlmClient["completeJson"]): LlmClient { }; } -function workMemoryRow(userId: string, projectId: string | null): MemoryRow { +function workMemoryRow(userId: string | null, projectId: string | null): MemoryRow { const at = "2026-01-01T00:00:00.000Z"; const topic = "SFT training pipeline"; const requirement = "固定 SFT 数据清洗流程"; diff --git a/Memory/tests/storage/token-usage-outbox.test.ts b/Memory/tests/storage/token-usage-outbox.test.ts index b42b110b2..fbc93e7f8 100644 --- a/Memory/tests/storage/token-usage-outbox.test.ts +++ b/Memory/tests/storage/token-usage-outbox.test.ts @@ -64,8 +64,8 @@ describe("token usage outbox", () => { const upgraded = new MemoryDb({ path: dbPath }); expect(upgraded.schemaVersion()).toMatchObject({ - version: 9, - lastMigrationId: "009_token_usage_outbox" + version: 10, + lastMigrationId: "010_device_memory_scope" }); expect(upgraded.db.prepare(`SELECT id FROM memories`).get()).toEqual({ id: "memory-keep" }); expect(upgraded.db.prepare( @@ -75,7 +75,7 @@ describe("token usage outbox", () => { upgraded.close(); const reopened = new MemoryDb({ path: dbPath }); - expect(reopened.schemaVersion().version).toBe(9); + expect(reopened.schemaVersion().version).toBe(10); expect(reopened.db.prepare(`SELECT COUNT(*) AS count FROM memories`).get()).toEqual({ count: 1 }); reopened.close(); }); From 731356d30b4304faa28b601fd93d65b0b74e9867 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Tue, 20 Oct 2026 11:13:15 +0800 Subject: [PATCH 2/7] fix: align agent memory namespace with nullable uid contract --- App/memmy-agent/src/memmy-memory/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/App/memmy-agent/src/memmy-memory/types.ts b/App/memmy-agent/src/memmy-memory/types.ts index d52044899..c8aff8604 100644 --- a/App/memmy-agent/src/memmy-memory/types.ts +++ b/App/memmy-agent/src/memmy-memory/types.ts @@ -30,7 +30,7 @@ export type MemmyMemoryRuntimeNamespace = { workspaceId?: string; workspacePath?: string; sessionKey?: string; - userId?: string; + userId?: string | null; tenantId?: string; }; From fc46a1208435b4f3f6196f79b352e976f67cd2db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Tue, 20 Oct 2026 11:16:14 +0800 Subject: [PATCH 3/7] fix: preserve historical L3 deletion across shared scopes --- .../evolution/l3-world-model-pipeline.ts | 2 ++ Memory/src/storage/repositories.ts | 20 +++++++++++++++++-- .../repository/device-memory-scope.test.ts | 18 +++++++++++++++++ 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/Memory/src/service/evolution/l3-world-model-pipeline.ts b/Memory/src/service/evolution/l3-world-model-pipeline.ts index 70f9b9459..6bb331b3b 100644 --- a/Memory/src/service/evolution/l3-world-model-pipeline.ts +++ b/Memory/src/service/evolution/l3-world-model-pipeline.ts @@ -130,6 +130,8 @@ export class L3WorldModelTraceFieldPipeline { private loadEvidence(batchId: string): TraceEvidence { const batch = this.deps.repos.l3WorldModels.getBatch(batchId); if (!batch) throw new L3WorldModelTerminalEvidenceError(`missing batch: ${batchId}`); + const deletedAt = this.deps.repos.l3WorldModels.latestDeletion(batch.projectId); + if (deletedAt && batch.createdAt <= deletedAt) return {rawTurns:[],eligibleL1MemoryIds:[]}; const session = this.deps.repos.runtime.getSession(batch.sessionId); if (!session || (session.projectId ?? null) !== (batch.projectId ?? null)) { throw new L3WorldModelTerminalEvidenceError("L3 World Model batch session scope mismatch"); diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index 6471e4e80..bb801459e 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -5336,6 +5336,15 @@ export class L3WorldModelRepository { .map(l3WorldModelInputTraceFromSql); } + latestDeletion(projectId?: string | null): string | undefined { + const row = this.db.prepare(`SELECT MAX(COALESCE(deleted_at, updated_at)) AS deleted_at + FROM memories WHERE memory_layer = 'L3' AND (deleted_at IS NOT NULL OR status = 'deleted') + AND json_extract(properties_json, '$.internal_info.schema_version') = 2 + AND memory_project_id(json_extract(info_json, '$.project_id')) IS ?`) + .get(canonicalProjectId(this.db, projectId)) as {deleted_at: string | null}; + return row.deleted_at ?? undefined; + } + getMemory(_userId: string | null, projectId?: string | null): MemoryRow | undefined { projectId = canonicalProjectId(this.db, projectId); const scopes = this.db.prepare(`SELECT * FROM l3_world_model_scopes @@ -5352,12 +5361,15 @@ export class L3WorldModelRepository { const overrides = this.db.prepare(`SELECT field, deleted FROM memory_l3_overrides WHERE project_key = ?`) .all(projectId ?? '') as Array<{field: keyof L3WorldModelFields; deleted: number}>; const fields = emptyL3WorldModelFields(); + const deletedAt = this.latestDeletion(projectId); let profileScanId: unknown; for (const property of Object.keys(fields) as Array) { const override = overrides.find(row => row.field === property); if (override?.deleted) continue; for (const candidate of candidates) { if (override && !candidate.canonical) continue; + if (deletedAt && (candidate.memory.updatedAt < deletedAt || + (!candidate.canonical && candidate.memory.updatedAt === deletedAt))) continue; const value = fieldsFromL3WorldModelMemory(candidate.memory)[property]; if (value !== null) { fields[property] = value; @@ -5372,7 +5384,7 @@ export class L3WorldModelRepository { ...base, memoryValue, contentHash: sha256Hex(memoryValue), info: {...base.info, project_environment_applied_scan_id: profileScanId}, status: memoryValue ? "activated" : "archived", - version: candidates.length > 1 ? Number.parseInt(sha256Hex(canonicalJson(fields)).slice(0, 12), 16) : base.version, + version: candidates.length > 1 || deletedAt ? Number.parseInt(sha256Hex(canonicalJson(fields)).slice(0, 12), 16) : base.version, properties: { ...base.properties, internal_info: { ...base.properties.internal_info, body: memoryValue, world_model: { @@ -5452,7 +5464,11 @@ export class L3WorldModelRepository { let noChange = false; let memory = this.getMemory(batch.userId, batch.projectId); - if (input.operation === "noop") { + const deletedAt = this.latestDeletion(batch.projectId); + if (deletedAt && batch.createdAt <= deletedAt) { + // An immutable pre-upgrade batch must not recreate a subsequently deleted scope. + noChange = true; + } else if (input.operation === "noop") { if (input.value !== "") throw new TypeError("noop L3 World Model output must be empty"); noChange = true; } else if (input.operation === "create") { diff --git a/Memory/tests/repository/device-memory-scope.test.ts b/Memory/tests/repository/device-memory-scope.test.ts index e0a06e630..f0c07851f 100644 --- a/Memory/tests/repository/device-memory-scope.test.ts +++ b/Memory/tests/repository/device-memory-scope.test.ts @@ -158,6 +158,24 @@ describe("device memory / preserved schema-v9 history", () => { service.deleteMemory(a.items[0]!.id,{namespace:{...namespace,userId:"account-b"}}); expect(service.panelItems({layer:"UserMemory",namespace}).items).toEqual([]); }); + it("respects pre-upgrade L3 deletion and does not replay older batches into another account's copy", async () => { + const root = mkdtempSync(join(tmpdir(),"memmy-uid-tombstone-")), path = join(root,"memory.sqlite"); + try { + const old = seedV9(path); + old.prepare("UPDATE memories SET deleted_at='2026-10-02T00:00:00.000Z',status='deleted',updated_at='2026-10-02T00:00:00.000Z' WHERE id='L3-account-a'").run(); + old.prepare("UPDATE l3_world_model_scopes SET memory_id=NULL WHERE user_id='account-a'").run(); + old.close(); + const db = new MemoryDb({path}), repos = new Repositories(db.db); + expect(repos.l3WorldModels.fields("account-c",identity.projectId).domainKnowledge).toBeNull(); + const complete = vi.fn(); + await new L3WorldModelTraceFieldPipeline({repos,skillLlm:{complete} as unknown as LlmClient}).updateField(repos.runtime.getJob("job-account-b")!); + expect(complete).not.toHaveBeenCalled(); + expect(repos.l3WorldModels.getTarget("batch-account-b","project_contract")).toMatchObject({status:"applied",noChange:true}); + repos.l3WorldModels.upsertField({userId:"account-c",projectId:identity.projectId,targetField:"domain_knowledge",value:"New evidence",at:"2026-10-03T00:00:00.000Z"}); + expect(repos.l3WorldModels.fields("account-d",identity.projectId).domainKnowledge).toBe("New evidence"); + db.close(); + } finally {rmSync(root,{recursive:true,force:true});} + }); it("retries new and historical idempotency entries across accounts without accepting changed content", () => { const {db,service} = fixture.createTestService(); const repos = new Repositories(db.db); From b991ec93e1c3fba7b9f847be72ea0c6e7d7ac882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Tue, 20 Oct 2026 11:21:40 +0800 Subject: [PATCH 4/7] fix: retain native capture aliases across clear and restart --- Memory/src/storage/device-scope.ts | 7 +++++- Memory/src/storage/repositories.ts | 5 +++-- Memory/src/storage/schema.ts | 3 +++ .../repository/device-memory-scope.test.ts | 22 +++++++++++++++++++ 4 files changed, 34 insertions(+), 3 deletions(-) diff --git a/Memory/src/storage/device-scope.ts b/Memory/src/storage/device-scope.ts index 278cde5fd..8a075afc1 100644 --- a/Memory/src/storage/device-scope.ts +++ b/Memory/src/storage/device-scope.ts @@ -36,7 +36,8 @@ export function registerProjectAlias(db: Database.Database, legacyId: string, ur } export function refreshProjectAliases(db: Database.Database): void { - namespaces.set(db, new Map()); + const storedNamespaces = db.prepare("SELECT legacy_key, namespace_key FROM memory_namespace_aliases").all() as Array<{legacy_key:string;namespace_key:string}>; + namespaces.set(db, new Map(storedNamespaces.map(row=>[row.legacy_key,row.namespace_key]))); aliases.set(db, new Map((db.prepare("SELECT legacy_id, project_id FROM memory_project_aliases").all() as Array<{legacy_id:string;project_id:string}>).map(row => [row.legacy_id,row.project_id]))); const rows = db.prepare(`SELECT project_id, workspace_id, meta_json FROM sessions WHERE project_id IS NOT NULL`).all() as Array<{ project_id: string; workspace_id: string | null; meta_json: string }>; // Resolve all evidence before writing any mapping; conflicting identities remain isolated. @@ -101,6 +102,10 @@ export function refreshProjectAliases(db: Database.Database): void { const tenantId = meta.tenant_id ?? meta.tenantId ?? null; const current = stableHash({tenantId, projectId, workspaceId: canonicalProjectId(db, row.workspace_id)}); const old = stableHash({tenantId, projectId: row.project_id, workspaceId: row.workspace_id}); + // These hashes cannot be reconstructed once clear removes their Sessions. + // Keep the alias with the native-capture tombstones, including across export/import. + if (!db.readonly) db.prepare(`INSERT INTO memory_namespace_aliases(legacy_key,namespace_key) VALUES (?,?) + ON CONFLICT(legacy_key) DO UPDATE SET namespace_key=excluded.namespace_key`).run(old,current); namespaces.get(db)?.set(old, current); if (meta.source_namespace_key === old) namespaces.get(db)?.set(meta.source_namespace_key, current); } diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index bb801459e..d0789175a 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -60,6 +60,7 @@ import { compactHistoryChangePayload, compactHistoryChangeRow } from "./history- type SqlValue = string | number | Buffer | null; const BUNDLE_TABLES = [ "memory_project_aliases", + "memory_namespace_aliases", "memory_l3_overrides", "memories", "memory_capture_claims", @@ -91,8 +92,7 @@ const BUNDLE_TABLES = [ "audit_logs" ] as const; const CLEAR_MEMORY_TABLES = [ - "memory_l3_overrides", - ...BUNDLE_TABLES.filter((table) => table !== "source_turn_captures" && table !== "memory_project_aliases"), + ...BUNDLE_TABLES.filter((table) => table !== "source_turn_captures" && table !== "memory_project_aliases" && table !== "memory_namespace_aliases"), "memories_fts", "user_memories_fts", "memory_vector_entries", @@ -8039,6 +8039,7 @@ function bundleIdentity( ): BundleIdentity | undefined { const newTableIdentityColumns: Partial> = { memory_project_aliases: ["legacy_id"], + memory_namespace_aliases: ["legacy_key"], memory_l3_overrides: ["project_key", "field"], memory_capture_claims: ["user_id", "source", "qa_hash"], source_turn_captures: ["user_id", "source", "profile_id", "namespace_key", "conversation_id", "turn_id"], diff --git a/Memory/src/storage/schema.ts b/Memory/src/storage/schema.ts index 699a0828e..75f24f114 100644 --- a/Memory/src/storage/schema.ts +++ b/Memory/src/storage/schema.ts @@ -732,6 +732,9 @@ export function migrate(db: Database.Database): void { workspace_host_id TEXT, created_at TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_memory_project_alias_target ON memory_project_aliases(project_id); + CREATE TABLE IF NOT EXISTS memory_namespace_aliases ( + legacy_key TEXT PRIMARY KEY, namespace_key TEXT NOT NULL + ); CREATE TABLE IF NOT EXISTS memory_l3_overrides ( project_key TEXT NOT NULL, field TEXT NOT NULL, memory_id TEXT, deleted INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(project_key, field) diff --git a/Memory/tests/repository/device-memory-scope.test.ts b/Memory/tests/repository/device-memory-scope.test.ts index f0c07851f..7999a5737 100644 --- a/Memory/tests/repository/device-memory-scope.test.ts +++ b/Memory/tests/repository/device-memory-scope.test.ts @@ -158,6 +158,28 @@ describe("device memory / preserved schema-v9 history", () => { service.deleteMemory(a.items[0]!.id,{namespace:{...namespace,userId:"account-b"}}); expect(service.panelItems({layer:"UserMemory",namespace}).items).toEqual([]); }); + it("keeps legacy native-capture identity after clear and restart", () => { + const root = mkdtempSync(join(tmpdir(),"memmy-uid-clear-")), path = join(root,"memory.sqlite"); + try { + seedV9(path).close(); + const db = new MemoryDb({path}), repos = new Repositories(db.db); + repos.clearAllMemoryData(); + expect(db.db.prepare("SELECT COUNT(*) FROM sessions").pluck().get()).toBe(0); + const bundle = repos.runtime.exportBundleTables(true); + expect(bundle.memory_namespace_aliases?.length ?? 0).toBeGreaterThan(0); + db.close(); + const reopened = new MemoryDb({path}); + const capture = new Repositories(reopened.db).runtime.getSourceTurnCapture({userId:"account-c",source:"codex",profileId:"default",namespaceKey:stableHash({tenantId:null,projectId:identity.projectId,workspaceId:identity.workspaceId}),conversationId:"native-conversation"},"native-turn"); + expect(capture).toBeDefined(); + expect(capture?.sessionId).toBeUndefined(); + reopened.close(); + const imported = new MemoryDb({path:join(root,"imported.sqlite")}); + const importedRepos = new Repositories(imported.db); + importedRepos.runtime.importBundleTables(bundle); + expect(importedRepos.runtime.getSourceTurnCapture({userId:"account-d",source:"codex",profileId:"default",namespaceKey:stableHash({tenantId:null,projectId:identity.projectId,workspaceId:identity.workspaceId}),conversationId:"native-conversation"},"native-turn")).toBeDefined(); + imported.close(); + } finally {rmSync(root,{recursive:true,force:true});} + }); it("respects pre-upgrade L3 deletion and does not replay older batches into another account's copy", async () => { const root = mkdtempSync(join(tmpdir(),"memmy-uid-tombstone-")), path = join(root,"memory.sqlite"); try { From a362cbfeefc4705ec112c15e192f6fd0648ec3ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Tue, 20 Oct 2026 14:39:57 +0800 Subject: [PATCH 5/7] fix: align memory UID decoupling with v1.2.6 --- Memory/src/service/retrieval/retrieval-service.ts | 3 ++- Memory/src/storage/repositories.ts | 2 +- Memory/tests/service/session/source-turn-capture.test.ts | 1 - 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Memory/src/service/retrieval/retrieval-service.ts b/Memory/src/service/retrieval/retrieval-service.ts index 201772a70..e10ab9d3c 100644 --- a/Memory/src/service/retrieval/retrieval-service.ts +++ b/Memory/src/service/retrieval/retrieval-service.ts @@ -1974,7 +1974,8 @@ export class RetrievalService { ? this.retrieveTimeFilteredTraceMemories({ timeFilter, tags: request.tags, - limit: retrievalLimit + limit: retrievalLimit, + preferredUserId: context.userId }) : await this.retrieveSearchMemories({ userId: context.userId, diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index d0789175a..6b55c5989 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -977,7 +977,7 @@ export class MemoryRepository { AND json_type(properties_json, '$.internal_info.external_evidence') = 'object' AND COALESCE(json_extract(properties_json, '$.internal_info.observation_memory.schema_version'), 0) < 1 ORDER BY created_at DESC, id DESC LIMIT ?`).all(Math.min(Math.max(limit, 0), 1000)) as MemorySqlRow[]; - return this.hydrateMany(rows.map(memoryFromSql)); + return this.hydrateMany(rows.map((row) => memoryFromSql(row, this.db))); } listUnindexedL1Imports(limit = 10000): MemoryRow[] { diff --git a/Memory/tests/service/session/source-turn-capture.test.ts b/Memory/tests/service/session/source-turn-capture.test.ts index 285eef02d..c647efb20 100644 --- a/Memory/tests/service/session/source-turn-capture.test.ts +++ b/Memory/tests/service/session/source-turn-capture.test.ts @@ -202,7 +202,6 @@ describe("native source turn submission", () => { }); it.each([ - { label: "another user with the same native conversation", namespace: { userId: "other-user" } }, { label: "another project", namespace: { projectId: "project-b" } }, { label: "another workspace", namespace: { workspaceId: "workspace-b" } }, { label: "an unproven tenant", namespace: { tenantId: "tenant-b" } }, From 945a2b8aad96a773060827a266edff2a05f05498 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Wed, 21 Oct 2026 08:33:49 +0800 Subject: [PATCH 6/7] fix: unify same-source L1 deduplication across recall paths --- .../cross-account-candidate-dedup.ts | 146 ------------------ .../service/retrieval/retrieval-service.ts | 42 ++++- .../service/retrieval/same-source-l1-dedup.ts | 89 +++++++++++ .../contract/external-evidence-http.test.ts | 4 +- .../external-evidence-source-http.test.ts | 2 +- .../evidence/history-skill-automatic.test.ts | 5 +- .../retrieval/same-source-l1-dedup.test.ts | 122 +++++++++++++++ 7 files changed, 250 insertions(+), 160 deletions(-) delete mode 100644 Memory/src/service/retrieval/cross-account-candidate-dedup.ts create mode 100644 Memory/src/service/retrieval/same-source-l1-dedup.ts create mode 100644 Memory/tests/service/retrieval/same-source-l1-dedup.test.ts diff --git a/Memory/src/service/retrieval/cross-account-candidate-dedup.ts b/Memory/src/service/retrieval/cross-account-candidate-dedup.ts deleted file mode 100644 index eda8fbf0e..000000000 --- a/Memory/src/service/retrieval/cross-account-candidate-dedup.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { canonicalNamespaceKey } from "../../storage/device-scope.js"; -import type { SeededChannelScores } from "../../algorithm/plugin-algorithms.js"; -import type { Repositories } from "../../storage/repositories.js"; -import { attachMemoryVector, memoryVector, memoryVectorEntries } from "../../storage/memory-vector-state.js"; -import type { MemoryRow } from "../../types.js"; -import { stableHash } from "../../utils/id.js"; -import { isRecord } from "../../utils/json.js"; - -export interface CrossAccountCandidateDedupResult { - memories: MemoryRow[]; - channelScoresByMemory: ReadonlyMap; - memoryIdByMemoryId: ReadonlyMap; -} - -/** - * Collapse copies of the same locally sourced memory before ranking. Account IDs - * are retained only on historical rows; the same native source turn may exist - * under more than one account before cross-account capture deduplication existed. - */ -export function deduplicateCrossAccountMemoryCandidates(input: { - memories: readonly MemoryRow[]; - repos: Repositories; - preferredUserId: string | null; - channelScoresByMemory?: ReadonlyMap; -}): CrossAccountCandidateDedupResult { - const groups = new Map(); - const nativeConversationBySessionId = new Map(); - input.memories.forEach((memory, index) => { - const groupState = input.repos.memories.nativeDuplicates(memory); - if (groupState.deleted) return; - const key = groupState.ids.length > 1 ? `native:${groupState.ids[0]}` : duplicateKey(memory, input.repos, nativeConversationBySessionId); - if (!key) { - groups.set(`unique:${index}:${memory.id}`, { firstIndex: index, memories: [memory] }); - return; - } - const group = groups.get(key); - if (group) group.memories.push(memory); - else groups.set(key, { firstIndex: index, memories: [memory] }); - }); - - const selected = [...groups.values()] - .sort((left, right) => left.firstIndex - right.firstIndex) - .map((group) => { - const representative = [...group.memories].sort((left, right) => - left.createdAt.localeCompare(right.createdAt) || - left.id.localeCompare(right.id) - )[0]!; - return { representative, members: group.memories }; - }); - - const channelScoresByMemory = new Map(); - const memoryIdByMemoryId = new Map(); - for (const group of selected) { - if (group.members.length === 1) { - const memory = group.representative; - memoryIdByMemoryId.set(memory.id, memory.id); - const scores = input.channelScoresByMemory?.get(memory.id); - if (scores) channelScoresByMemory.set(memory.id, scores); - continue; - } - - const merged: SeededChannelScores = {}; - for (const memory of group.members) { - memoryIdByMemoryId.set(memory.id, group.representative.id); - for (const vector of memoryVectorEntries(memory)) { - if (!memoryVector(group.representative, vector.vectorField)) { - attachMemoryVector(group.representative, vector); - } - } - for (const [channel, score] of Object.entries(input.channelScoresByMemory?.get(memory.id) ?? {})) { - const key = channel as keyof SeededChannelScores; - const current = merged[key]; - if (typeof score === "number" && (current === undefined || score > current)) { - merged[key] = score; - } - } - } - if (Object.keys(merged).length > 0) channelScoresByMemory.set(group.representative.id, merged); - } - - return { - memories: selected.map((group) => group.representative), - channelScoresByMemory, - memoryIdByMemoryId - }; -} - -function duplicateKey( - memory: MemoryRow, - repos: Repositories, - nativeConversationBySessionId: Map -): string | undefined { - if (memory.memoryLayer !== "L1" || memory.properties.internal_info.memory_kind === "work_memory") { - return undefined; - } - const nativeConversation = nativeConversationKey(memory, repos, nativeConversationBySessionId); - if (!nativeConversation) return undefined; - const internal = memory.properties.internal_info; - const trace = isRecord(internal.trace) - ? internal.trace - : isRecord(memory.info.trace) - ? memory.info.trace - : undefined; - const turnId = text(memory.info.turn_id) ?? text(trace?.turn_id); - if (!turnId) return undefined; - const stepIndex = typeof trace?.step_index === "number" && Number.isFinite(trace.step_index) - ? trace.step_index - : memory.contentHash ?? stableHash(memory.memoryValue); - return `native-turn:${stableHash({ nativeConversation, turnId, stepIndex })}`; -} - -function nativeConversationKey( - memory: MemoryRow, - repos: Repositories, - cache: Map -): string | undefined { - if (!memory.sessionId) return undefined; - if (cache.has(memory.sessionId)) return cache.get(memory.sessionId); - const session = repos.runtime.getSession(memory.sessionId); - if (!session) { - cache.set(memory.sessionId, undefined); - return undefined; - } - const conversationId = session.conversationId?.trim() || memory.conversationId?.trim(); - const source = session.source.trim(); - const profileId = session.profileId.trim(); - if (!conversationId || !source || !profileId) { - cache.set(memory.sessionId, undefined); - return undefined; - } - - const storedNamespaceKey = text(session.meta.source_namespace_key); - const tenantId = text(session.meta.tenant_id) ?? text(session.meta.tenantId) ?? null; - const namespaceKey = storedNamespaceKey ? canonicalNamespaceKey(repos.db, storedNamespaceKey) : stableHash({ - tenantId, - projectId: session.projectId ?? null, - workspaceId: session.workspaceId ?? null - }); - const key = stableHash({ source, profileId, namespaceKey, conversationId }); - cache.set(memory.sessionId, key); - return key; -} - -function text(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} diff --git a/Memory/src/service/retrieval/retrieval-service.ts b/Memory/src/service/retrieval/retrieval-service.ts index e10ab9d3c..ddd87a83f 100644 --- a/Memory/src/service/retrieval/retrieval-service.ts +++ b/Memory/src/service/retrieval/retrieval-service.ts @@ -1,3 +1,4 @@ +import { deduplicateSameSourceL1Candidates } from "./same-source-l1-dedup.js"; import type { RetrievalQueryExtract, RetrievalResult @@ -1974,8 +1975,7 @@ export class RetrievalService { ? this.retrieveTimeFilteredTraceMemories({ timeFilter, tags: request.tags, - limit: retrievalLimit, - preferredUserId: context.userId + limit: retrievalLimit }) : await this.retrieveSearchMemories({ userId: context.userId, @@ -2199,7 +2199,6 @@ export class RetrievalService { timeFilter: RetrievalTimeFilter; tags?: string[]; limit: number; - preferredUserId: string | null; }): { retrieval: RetrievalResult; memories: MemoryRow[] } { const filter: MemoryFilter = { memoryLayer: "L1", @@ -2209,10 +2208,14 @@ export class RetrievalService { ...(input.tags?.length ? { tags: input.tags } : {}) }; const candidateCount = this.deps.repos.memories.count(filter); - const candidates = this.deps.repos.memories + const rawCandidates = this.deps.repos.memories .list(filter, candidateCount) .filter((memory) => this.isMemoryReadyForRetrieval(memory)) .filter((memory) => kindFromMemory(memory) === "observed_activity" || Boolean(traceMetaFromMemory(memory)?.summary.trim())); + const { memories: candidates } = deduplicateSameSourceL1Candidates({ + memories: rawCandidates, + repos: this.deps.repos + }); const selected = [...candidates] .sort(compareTimeFilteredTraceRecency) .slice(0, Math.max(0, input.limit)) @@ -2343,7 +2346,11 @@ export class RetrievalService { currentAgentId: input.currentAgentId, config }); - const memories = candidatePool.memories; + const { memories, channelScoresByMemory } = deduplicateSameSourceL1Candidates({ + ...candidatePool, + repos: this.deps.repos, + excludeTraceRawTurnIds: input.excludeTraceRawTurnIds + }); if (memories.length === 0) { return { retrieval: emptyRetrievalResult(), memories }; } @@ -2359,7 +2366,7 @@ export class RetrievalService { mode: input.mode, excludeTraceRawTurnIds: input.excludeTraceRawTurnIds, targetSkillId: input.targetSkillId, - channelScoresByMemory: candidatePool.channelScoresByMemory, + channelScoresByMemory, config }) }; @@ -2386,9 +2393,28 @@ export class RetrievalService { query === input.query ? input.queryExtract : null ) )); + // Different rewrites may retrieve different copies of one native turn. + // Normalize their hit identities before RRF and its final limit as well. + const allMemories = uniqMemories(outputs.flatMap(output => output.memories)); + const hitsById = new Map(outputs.flatMap(output => output.retrieval.hits).map(hit => [hit.id, hit])); + const dedup = deduplicateSameSourceL1Candidates({ + memories: allMemories.filter(memory => hitsById.has(memory.id)), + repos: this.deps.repos + }); + const retrievals = outputs.map(output => { + const seen = new Set(); + const hits = output.retrieval.hits.flatMap(hit => { + const id = dedup.memoryIdByMemoryId.get(hit.id) ?? hit.id; + if (seen.has(id)) return []; + seen.add(id); + return [{ ...hitsById.get(id)!, score: hit.score }]; + }); + return { ...output.retrieval, hits }; + }); return { - retrieval: mergeRetrievalResults(outputs.map((output) => output.retrieval), input.limit, QUERY_REWRITE_RRF_CONSTANT, QUERY_REWRITE_PER_QUERY_MIN_KEEP), - memories: uniqMemories(outputs.flatMap((output) => output.memories)) + retrieval: mergeRetrievalResults(retrievals, input.limit, QUERY_REWRITE_RRF_CONSTANT, QUERY_REWRITE_PER_QUERY_MIN_KEEP), + memories: allMemories.filter(memory => !dedup.memoryIdByMemoryId.has(memory.id) || + dedup.memoryIdByMemoryId.get(memory.id) === memory.id) }; } diff --git a/Memory/src/service/retrieval/same-source-l1-dedup.ts b/Memory/src/service/retrieval/same-source-l1-dedup.ts new file mode 100644 index 000000000..08242abc6 --- /dev/null +++ b/Memory/src/service/retrieval/same-source-l1-dedup.ts @@ -0,0 +1,89 @@ +import { traceMetaFromMemory, type SeededChannelScores } from "../../algorithm/plugin-algorithms.js"; +import type { Repositories } from "../../storage/repositories.js"; +import { attachMemoryVector, memoryVector, memoryVectorEntries } from "../../storage/memory-vector-state.js"; +import type { MemoryRow } from "../../types.js"; + +export interface SameSourceL1CandidateDedupResult { + memories: MemoryRow[]; + channelScoresByMemory: ReadonlyMap; + memoryIdByMemoryId: ReadonlyMap; +} + +/** + * Collapse proven copies of the same native L1 source step before ranking. + * UID never participates in grouping or representative selection. Historical + * rows and their content are preserved; incomplete source identities stay separate. + */ +export function deduplicateSameSourceL1Candidates(input: { + memories: readonly MemoryRow[]; + repos: Repositories; + excludeTraceRawTurnIds?: ReadonlySet; + channelScoresByMemory?: ReadonlyMap; +}): SameSourceL1CandidateDedupResult { + const groups = new Map(); + input.memories.forEach((memory, index) => { + const groupState = input.repos.memories.nativeDuplicates(memory); + if (groupState.deleted) return; + // Use the repository's single source-identity rule, also used by L2 evidence. + // A recent turn must not re-enter context through another historical copy. + if (input.excludeTraceRawTurnIds?.size && groupState.ids.some(id => { + const member = id === memory.id ? memory : input.repos.memories.get(id); + const rawTurnId = member ? traceMetaFromMemory(member)?.rawTurnId : undefined; + return rawTurnId && input.excludeTraceRawTurnIds?.has(rawTurnId); + })) return; + const key = groupState.ids.length > 1 ? `native:${groupState.ids[0]}` : undefined; + if (!key) { + groups.set(`unique:${index}:${memory.id}`, { firstIndex: index, memories: [memory] }); + return; + } + const group = groups.get(key); + if (group) group.memories.push(memory); + else groups.set(key, { firstIndex: index, memories: [memory] }); + }); + + const selected = [...groups.values()] + .sort((left, right) => left.firstIndex - right.firstIndex) + .map((group) => { + const representative = [...group.memories].sort((left, right) => + left.createdAt.localeCompare(right.createdAt) || + left.id.localeCompare(right.id) + )[0]!; + return { representative, members: group.memories }; + }); + + const channelScoresByMemory = new Map(); + const memoryIdByMemoryId = new Map(); + for (const group of selected) { + if (group.members.length === 1) { + const memory = group.representative; + memoryIdByMemoryId.set(memory.id, memory.id); + const scores = input.channelScoresByMemory?.get(memory.id); + if (scores) channelScoresByMemory.set(memory.id, scores); + continue; + } + + const merged: SeededChannelScores = {}; + for (const memory of group.members) { + memoryIdByMemoryId.set(memory.id, group.representative.id); + for (const vector of memoryVectorEntries(memory)) { + if (!memoryVector(group.representative, vector.vectorField)) { + attachMemoryVector(group.representative, vector); + } + } + for (const [channel, score] of Object.entries(input.channelScoresByMemory?.get(memory.id) ?? {})) { + const key = channel as keyof SeededChannelScores; + const current = merged[key]; + if (typeof score === "number" && (current === undefined || score > current)) { + merged[key] = score; + } + } + } + if (Object.keys(merged).length > 0) channelScoresByMemory.set(group.representative.id, merged); + } + + return { + memories: selected.map((group) => group.representative), + channelScoresByMemory, + memoryIdByMemoryId + }; +} diff --git a/Memory/tests/contract/external-evidence-http.test.ts b/Memory/tests/contract/external-evidence-http.test.ts index 719f3bd46..14b2edcf4 100644 --- a/Memory/tests/contract/external-evidence-http.test.ts +++ b/Memory/tests/contract/external-evidence-http.test.ts @@ -80,10 +80,10 @@ describe("external passive evidence", () => { const anotherNamespace = { ...namespace, userId: "another-user" }; expect(service.externalEvidenceStatus({ source: evidence.source, sourceRecordIds: [], skillIds: [candidate.id!], namespace: anotherNamespace }).skills).toEqual([ - { id: candidate.id, status: "missing" } + { id: candidate.id, status: "activated" } ]); expect(() => service.approveExternalEvidenceSkill(candidate.id!, { namespace: anotherNamespace })) - .toThrow(/not found/u); + .toThrow(/no longer awaiting review/u); expect(() => service.approveExternalEvidenceSkill(candidate.id!, { namespace })).toThrow(/no longer awaiting review/u); expect(isMemoryReadyForRetrieval(repos.memories.get(candidate.id!)!)).toBe(true); service.deleteMemory(first.id, { namespace }); diff --git a/Memory/tests/contract/external-evidence-source-http.test.ts b/Memory/tests/contract/external-evidence-source-http.test.ts index 79bb45ca0..5ff9a3e10 100644 --- a/Memory/tests/contract/external-evidence-source-http.test.ts +++ b/Memory/tests/contract/external-evidence-source-http.test.ts @@ -267,7 +267,7 @@ describe("external evidence producer identity over HTTP", () => { expect(repos.memories.get(sourceB.id)?.status).toBe("activated"); const tombstone = service.syncExternalEvidence({ action: "delete", source: "computer_history", namespace: scopeA, sourceRecordId: "2026-09-27T11-00-00Z-10min-summary", revision: record.revision }); - expect(externalEvidenceNamespaceForMemory(repos.memories.getIncludingDeleted(tombstone.id)!)).toMatchObject(scopeA); + expect(externalEvidenceNamespaceForMemory(repos.memories.getIncludingDeleted(tombstone.id)!)).toMatchObject({ ...scopeA, userId: null }); }); it("keeps Skill review and status in the source tenant", async () => { diff --git a/Memory/tests/service/evidence/history-skill-automatic.test.ts b/Memory/tests/service/evidence/history-skill-automatic.test.ts index b4299f4e2..ded5ada0b 100644 --- a/Memory/tests/service/evidence/history-skill-automatic.test.ts +++ b/Memory/tests/service/evidence/history-skill-automatic.test.ts @@ -242,9 +242,8 @@ describe("automatic History Skill lifecycle", () => { const result = await service.suggestExternalEvidenceSkill(suggestion); const original = repos.memories.get(result.id!)!; const body = '---\nname: review_release_revised\ndescription: >\n Human revised release review\n with a verification step.\n---\n\n# Revised release review\n\n### When to use\nWhen the current release is ready for review\n\n### Procedure\n1. Review the draft.\n2. Verify the current result.'; - expect(() => service.updateExternalEvidenceSkill(original.id, { body, expectedVersion: original.version }, - { namespace: { ...namespace, userId: "other-user" } })).toThrow(/not found/u); - const saved = service.updateExternalEvidenceSkill(original.id, { body, expectedVersion: original.version }, { namespace }); + const saved = service.updateExternalEvidenceSkill(original.id, { body, expectedVersion: original.version }, + { namespace: { ...namespace, userId: "other-user" } }); expect(saved).toMatchObject({ status: "activated", version: original.version + 1 }); expect(() => service.updateExternalEvidenceSkill(original.id, { body: "Stale write", expectedVersion: original.version }, { namespace })) .toThrow(/changed since editing/u); diff --git a/Memory/tests/service/retrieval/same-source-l1-dedup.test.ts b/Memory/tests/service/retrieval/same-source-l1-dedup.test.ts new file mode 100644 index 000000000..80dc2e14a --- /dev/null +++ b/Memory/tests/service/retrieval/same-source-l1-dedup.test.ts @@ -0,0 +1,122 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { DEFAULT_MEMMY_CONFIG, type LlmClient, type MemoryRow } from "../../../src/index.js"; +import { Repositories } from "../../../src/storage/repositories.js"; +import { refreshProjectAliases } from "../../../src/storage/device-scope.js"; +import { sha256Hex } from "../../../src/contracts/index.js"; +import { stableHash } from "../../../src/utils/id.js"; +import { deduplicateSameSourceL1Candidates } from "../../../src/service/retrieval/same-source-l1-dedup.js"; +import { IndexedCandidatePool } from "../../../src/service/retrieval/indexed-candidate-pool.js"; +import { createMemoryServiceFixture } from "../../fixtures/memory-service-fixture.js"; + +const fixture = createMemoryServiceFixture(); +afterEach(() => { vi.restoreAllMocks(); fixture.cleanup(); }); +const at = "2026-10-01T00:00:00.000Z"; +const uri = "file:///workspace/shared", host = "a".repeat(64); +function setup(time = false, rewrite = false) { + const config = structuredClone(DEFAULT_MEMMY_CONFIG); + config.algorithm.enableQueryRewrite = rewrite; + Object.assign(config.algorithm.retrieval, { minRecallScore: 0, relativeThresholdFloor: 0, smartSeed: false, llmFilterEnabled: false }); + const llm: LlmClient = { + config: config.summary, isConfigured: () => time || rewrite, + complete: async () => "{}", + completeJson: async >(_messages: unknown, options: {operation?: string}) => + (options.operation?.includes("rewrite") ? { queries: ["shared evidence alpha", "shared evidence beta"] } : time + ? {queryVecText: "", keywords: [], timeFilter: {startAt: at, endAt: "2026-10-02T00:00:00.000Z"}} + : {queryVecText: "shared evidence", keywords: ["shared"]}) as unknown as T, + status: () => ({provider: "host", model: "fixture", configured: time || rewrite, remote: false}) + }; + const state = fixture.createTestService({ config, llm }); + const repos = new Repositories(state.db.db); + function seed(id: string, owner: string | null, options: { + turn?: string; step?: number | null; source?: string; profile?: string; project?: string; + conversation?: string; tenant?: string; missingSource?: boolean; layer?: "L1" | "L2"; work?: boolean; + } = {}) { + const opened = state.service.openSession({ namespace: {source: options.source ?? "codex", profileId: options.profile ?? "default", sessionKey: id, userId: owner ?? undefined} }); + const workspaceId = owner ? sha256Hex(`${owner}\0local\0${host}\0${uri}`) : "device-workspace"; + const projectId = options.project ?? (owner ? `ws_${workspaceId}` : "device-project"); + const meta = {workspace_uri: options.project ? undefined : uri, workspace_host_id: host, + tenant_id: options.tenant ?? null, + source_namespace_key: stableHash({tenantId: options.tenant ?? null, projectId, workspaceId})}; + state.db.db.prepare("UPDATE sessions SET user_id=?,project_id=?,workspace_id=?,conversation_id=?,meta_json=? WHERE id=?") + .run(owner, projectId, workspaceId, options.missingSource ? null : options.conversation ?? "native-chat", JSON.stringify(meta), opened.sessionId); + refreshProjectAliases(state.db.db); + const turn = options.turn ?? "native-turn"; + const row: MemoryRow = { + id, userId: null, sessionId: opened.sessionId, timeline: at, agentId: "codex", + memoryLayer: options.layer ?? "L1", memoryType: "LongTermMemory", status: "activated", visibility: "private", + memoryKey: `trace:${id}`, memoryValue: "shared evidence unchanged", tags: ["trace"], info: {turn_id: turn}, + properties: {internal_info: {memory_layer: options.layer ?? "L1", memory_kind: options.work ? "work_memory" : "trace", trace: { + key: `trace:${id}`, ts: Date.parse(at), turn_id: turn, raw_turn_id: `raw-${id}`, + ...(options.step === null ? {} : {step_index: options.step ?? 0}), sub_step_total: 1, + userText: "shared evidence", agentText: "shared evidence", summary: "shared evidence", + tool_calls: [], reflection: null, alpha: 0, tags: ["trace"], value: 1, priority: 1, + error_signatures: [], vec_summary: [1,0,0], vec_action: [1,0,0], embedding_model: "capturing-test-embedding" + }}}, contentHash: "same-content", version: 1, createdAt: at, updatedAt: at + }; + repos.memories.insert(row); + state.db.db.prepare("UPDATE memories SET user_id=? WHERE id=?").run(owner, id); + return repos.memories.get(id)!; + } + return {...state, repos, seed}; +} + +describe("same-source L1 deduplication without UID", () => { + it.each([["account-a", "account-b"], ["account-a", "account-a"], [null, null], ["account-a", null]])( + "folds same-source copies (%s / %s), preserving rows, content and channel scores", (a, b) => { + const {db, repos, seed} = setup(); + seed("copy-a", a); seed("copy-b", b); + const before = db.db.prepare("SELECT * FROM memories ORDER BY id").all(); + const memories = [repos.memories.get("copy-b")!, repos.memories.get("copy-a")!]; + const result = deduplicateSameSourceL1Candidates({memories, repos, + channelScoresByMemory: new Map([["copy-a", {fts: 0.2}], ["copy-b", {fts: 0.9, vec_summary: 0.8}]])}); + expect(result.memories.map(m => m.id)).toEqual(["copy-a"]); + expect(result.channelScoresByMemory.get("copy-a")).toMatchObject({fts: 0.9, vec_summary: 0.8}); + expect(db.db.prepare("SELECT * FROM memories ORDER BY id").all()).toEqual(before); + } + ); + + it.each([ + {turn: "other-turn"}, {step: 1}, {project: "other-project"}, {conversation: "other-chat"}, + {source: "cursor"}, {profile: "other-profile"}, {tenant: "other-tenant"}, + {missingSource: true}, {layer: "L2" as const}, {work: true} + ])("does not collapse equal content with a different or incomplete source: %j", options => { + const {repos, seed} = setup(); + seed("a", "account-a"); seed("b", "account-b", options); + expect(deduplicateSameSourceL1Candidates({repos, memories: repos.memories.list({}, 100)}).memories).toHaveLength(2); + }); + + it("does not let an unreturned deleted copy or recent raw turn reappear through a sibling", () => { + const {repos, seed} = setup(); + seed("a", "account-a"); const b = seed("b", "account-b"); + expect(deduplicateSameSourceL1Candidates({repos, memories: [b], excludeTraceRawTurnIds: new Set(["raw-a"])}).memories).toEqual([]); + repos.memories.softDelete("a"); + expect(deduplicateSameSourceL1Candidates({repos, memories: [b]}).memories).toEqual([]); + }); + + it.each([false, true])("search integration returns one historical copy for every account (time filter=%s)", async time => { + const {db, repos, service, seed} = setup(time); + seed("a", "account-a"); seed("b", "account-b"); seed("c", null, {turn: "different-turn"}); + const before = db.db.prepare("SELECT * FROM memories ORDER BY id").all(); + for (const userId of ["account-a", "account-b", "new-account"]) { + const result = await service.search({namespace: {source: "codex", profileId: "default", userId}, query: "shared evidence", layers: ["L1"], limit: 10}); + expect(result.hits.map(hit => hit.id).sort()).toEqual(["a", "c"]); + expect(result.sourceMemoryIds.sort()).toEqual(["a", "c"]); + } + expect(db.db.prepare("SELECT * FROM memories ORDER BY id").all()).toEqual(before); + repos.memories.softDelete("b"); + const result = await service.search({namespace: {source: "codex", profileId: "default", userId: "new-account"}, query: "shared evidence", layers: ["L1"], limit: 10}); + expect(result.hits.map(hit => hit.id)).toEqual(["c"]); + }); + + it("folds copies returned by separate query rewrites before final fusion", async () => { + const {repos, service, seed} = setup(false, true); + seed("a", "account-a"); seed("b", "account-b"); + const pool = vi.spyOn(IndexedCandidatePool.prototype, "indexedRetrievalCandidatePool"); + pool.mockResolvedValueOnce({memories: [repos.memories.get("a")!], channelScoresByMemory: new Map([["a", {vec_summary: 1}], ["b", {vec_summary: 1}]])}); + pool.mockResolvedValueOnce({memories: [repos.memories.get("b")!], channelScoresByMemory: new Map([["a", {vec_summary: 1}], ["b", {vec_summary: 1}]])}); + const result = await service.search({namespace: {source: "codex", profileId: "default", userId: "account-b"}, query: "shared evidence", layers: ["L1"], limit: 10}); + expect(pool).toHaveBeenCalledTimes(2); + expect(result.hits.map(hit => hit.id)).toEqual(["a"]); + expect(result.sourceMemoryIds).toEqual(["a"]); + }); +}); From 473a19b1a52d0058fddb84ec9801ac373f58e7a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=83=AD=E6=B5=A9=E6=B4=8B?= <3253580733@qq.com> Date: Mon, 26 Oct 2026 10:54:04 +0800 Subject: [PATCH 7/7] fix: restore uid-independent recall after v1.2.6 merge --- Memory/src/service/memory-service.ts | 2 +- .../cross-account-candidate-dedup.ts | 151 ------------------ .../retrieval/indexed-candidate-pool.ts | 7 +- .../service/retrieval/retrieval-service.ts | 5 +- .../service/session/session-turn-service.ts | 7 +- Memory/src/storage/repositories.ts | 6 +- .../service/evolution/span-big-turn.test.ts | 15 +- .../retrieval/same-source-l1-dedup.test.ts | 16 ++ 8 files changed, 37 insertions(+), 172 deletions(-) delete mode 100644 Memory/src/service/retrieval/cross-account-candidate-dedup.ts diff --git a/Memory/src/service/memory-service.ts b/Memory/src/service/memory-service.ts index 1798e3512..3a0f5d9ae 100644 --- a/Memory/src/service/memory-service.ts +++ b/Memory/src/service/memory-service.ts @@ -2665,7 +2665,7 @@ export class MemoryService { this.repos.transaction(() => { const spanIds = new Set(this.repos.memories.listTaskSpanIds( - episode.userId, episode.id, episode.l1MemoryIds + episode.id, episode.l1MemoryIds )); for (const spanId of spanIds) { this.deleteMemory(spanId, request); diff --git a/Memory/src/service/retrieval/cross-account-candidate-dedup.ts b/Memory/src/service/retrieval/cross-account-candidate-dedup.ts deleted file mode 100644 index aab41318a..000000000 --- a/Memory/src/service/retrieval/cross-account-candidate-dedup.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { SeededChannelScores } from "../../algorithm/plugin-algorithms.js"; -import type { Repositories } from "../../storage/repositories.js"; -import { attachMemoryVector, memoryVector, memoryVectorEntries } from "../../storage/memory-vector-state.js"; -import type { MemoryRow } from "../../types.js"; -import { stableHash } from "../../utils/id.js"; -import { isRecord } from "../../utils/json.js"; - -export interface CrossAccountCandidateDedupResult { - memories: MemoryRow[]; - channelScoresByMemory: ReadonlyMap; - memoryIdByMemoryId: ReadonlyMap; -} - -/** - * Collapse copies of the same locally sourced memory before ranking. Account IDs - * identify the write owner, but the same native source turn can have been written - * under more than one account before cross-account capture deduplication existed. - */ -export function deduplicateCrossAccountMemoryCandidates(input: { - memories: readonly MemoryRow[]; - repos: Repositories; - preferredUserId: string; - channelScoresByMemory?: ReadonlyMap; -}): CrossAccountCandidateDedupResult { - const groups = new Map(); - const nativeConversationBySessionId = new Map(); - input.memories.forEach((memory, index) => { - const key = duplicateKey(memory, input.repos, nativeConversationBySessionId); - if (!key) { - groups.set(`unique:${index}:${memory.id}`, { firstIndex: index, memories: [memory] }); - return; - } - const group = groups.get(key); - if (group) group.memories.push(memory); - else groups.set(key, { firstIndex: index, memories: [memory] }); - }); - - const selected = [...groups.values()] - .sort((left, right) => left.firstIndex - right.firstIndex) - .map((group) => { - const representative = [...group.memories].sort((left, right) => - Number(right.userId === input.preferredUserId) - Number(left.userId === input.preferredUserId) || - candidateScore(right, input.channelScoresByMemory) - candidateScore(left, input.channelScoresByMemory) || - left.createdAt.localeCompare(right.createdAt) || - left.id.localeCompare(right.id) - )[0]!; - return { representative, members: group.memories }; - }); - - const channelScoresByMemory = new Map(); - const memoryIdByMemoryId = new Map(); - for (const group of selected) { - if (group.members.length === 1) { - const memory = group.representative; - memoryIdByMemoryId.set(memory.id, memory.id); - const scores = input.channelScoresByMemory?.get(memory.id); - if (scores) channelScoresByMemory.set(memory.id, scores); - continue; - } - - const merged: SeededChannelScores = {}; - for (const memory of group.members) { - memoryIdByMemoryId.set(memory.id, group.representative.id); - for (const vector of memoryVectorEntries(memory)) { - if (!memoryVector(group.representative, vector.vectorField)) { - attachMemoryVector(group.representative, vector); - } - } - for (const [channel, score] of Object.entries(input.channelScoresByMemory?.get(memory.id) ?? {})) { - const key = channel as keyof SeededChannelScores; - const current = merged[key]; - if (typeof score === "number" && (current === undefined || score > current)) { - merged[key] = score; - } - } - } - if (Object.keys(merged).length > 0) channelScoresByMemory.set(group.representative.id, merged); - } - - return { - memories: selected.map((group) => group.representative), - channelScoresByMemory, - memoryIdByMemoryId - }; -} - -function duplicateKey( - memory: MemoryRow, - repos: Repositories, - nativeConversationBySessionId: Map -): string | undefined { - if (memory.memoryLayer !== "L1" || memory.properties.internal_info.memory_kind === "work_memory") { - return undefined; - } - const nativeConversation = nativeConversationKey(memory, repos, nativeConversationBySessionId); - if (!nativeConversation) return undefined; - const internal = memory.properties.internal_info; - const trace = isRecord(internal.trace) - ? internal.trace - : isRecord(memory.info.trace) - ? memory.info.trace - : undefined; - const turnId = text(memory.info.turn_id) ?? text(trace?.turn_id); - if (!turnId) return undefined; - const stepIndex = typeof trace?.step_index === "number" && Number.isFinite(trace.step_index) - ? trace.step_index - : memory.contentHash ?? stableHash(memory.memoryValue); - return `native-turn:${stableHash({ nativeConversation, turnId, stepIndex })}`; -} - -function nativeConversationKey( - memory: MemoryRow, - repos: Repositories, - cache: Map -): string | undefined { - if (!memory.sessionId) return undefined; - if (cache.has(memory.sessionId)) return cache.get(memory.sessionId); - const session = repos.runtime.getSession(memory.sessionId); - if (!session) { - cache.set(memory.sessionId, undefined); - return undefined; - } - const conversationId = session.conversationId?.trim() || memory.conversationId?.trim(); - const source = session.source.trim(); - const profileId = session.profileId.trim(); - if (!conversationId || !source || !profileId) { - cache.set(memory.sessionId, undefined); - return undefined; - } - - const storedNamespaceKey = text(session.meta.source_namespace_key); - const tenantId = text(session.meta.tenant_id) ?? text(session.meta.tenantId) ?? null; - const namespaceKey = storedNamespaceKey ?? stableHash({ - tenantId, - projectId: session.projectId ?? null, - workspaceId: session.workspaceId ?? null - }); - const key = stableHash({ source, profileId, namespaceKey, conversationId }); - cache.set(memory.sessionId, key); - return key; -} - -function candidateScore(memory: MemoryRow, scores?: ReadonlyMap): number { - return Math.max(0, ...Object.values(scores?.get(memory.id) ?? {}).filter((value): value is number => - typeof value === "number" && Number.isFinite(value) - )); -} - -function text(value: unknown): string | undefined { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} diff --git a/Memory/src/service/retrieval/indexed-candidate-pool.ts b/Memory/src/service/retrieval/indexed-candidate-pool.ts index a64b74873..a37d41c55 100644 --- a/Memory/src/service/retrieval/indexed-candidate-pool.ts +++ b/Memory/src/service/retrieval/indexed-candidate-pool.ts @@ -14,7 +14,6 @@ import type { MemoryLayer, MemoryRow } from "../../types.js"; -import { deduplicateCrossAccountMemoryCandidates } from "./cross-account-candidate-dedup.js"; export function dedupeStrings(values: readonly string[]): string[] { const out: string[] = []; @@ -152,12 +151,10 @@ export class IndexedCandidatePool { this.isMemoryReadyForRetrieval(memory) && this.isSkillVisibleToAgent(memory, input.currentAgentId) ); - return deduplicateCrossAccountMemoryCandidates({ + return { memories: readyMemories, - repos: this.deps.repos, - preferredUserId: input.userId, channelScoresByMemory - }); + }; } private isSkillVisibleToAgent(memory: MemoryRow, currentAgentId?: string): boolean { diff --git a/Memory/src/service/retrieval/retrieval-service.ts b/Memory/src/service/retrieval/retrieval-service.ts index da1571bf1..ddd87a83f 100644 --- a/Memory/src/service/retrieval/retrieval-service.ts +++ b/Memory/src/service/retrieval/retrieval-service.ts @@ -71,7 +71,6 @@ import { normalizeRetrievalExtractKeywords } from "../turn/turn-normalization.js"; import { IndexedCandidatePool } from "./indexed-candidate-pool.js"; -import { deduplicateCrossAccountMemoryCandidates } from "./cross-account-candidate-dedup.js"; import { filterL1TraceSpanRecallHits } from "./l1-trace-span-filter.js"; type InternalMemorySearchRequest = MemorySearchRequest & { @@ -1976,8 +1975,7 @@ export class RetrievalService { ? this.retrieveTimeFilteredTraceMemories({ timeFilter, tags: request.tags, - limit: retrievalLimit, - preferredUserId: context.userId + limit: retrievalLimit }) : await this.retrieveSearchMemories({ userId: context.userId, @@ -2201,7 +2199,6 @@ export class RetrievalService { timeFilter: RetrievalTimeFilter; tags?: string[]; limit: number; - preferredUserId: string; }): { retrieval: RetrievalResult; memories: MemoryRow[] } { const filter: MemoryFilter = { memoryLayer: "L1", diff --git a/Memory/src/service/session/session-turn-service.ts b/Memory/src/service/session/session-turn-service.ts index b6e056fb6..34f6d2b00 100644 --- a/Memory/src/service/session/session-turn-service.ts +++ b/Memory/src/service/session/session-turn-service.ts @@ -73,7 +73,7 @@ import type { SynthesizeDecisionRepairDraft } from "../feedback/feedback-experience.js"; import { recordApiLog } from "../model-audit/model-call-audit.js"; -import { deduplicateCrossAccountMemoryCandidates } from "../retrieval/cross-account-candidate-dedup.js"; +import { deduplicateSameSourceL1Candidates } from "../retrieval/same-source-l1-dedup.js"; import { namespaceForRawTurn, namespaceForSession, @@ -2977,10 +2977,9 @@ export class SessionTurnService { }, 500 ).filter((memory) => this.deps.isMemoryReadyForRetrieval(memory)); - const candidates = deduplicateCrossAccountMemoryCandidates({ + const candidates = deduplicateSameSourceL1Candidates({ memories: loadedCandidates, - repos: this.deps.repos, - preferredUserId: session.userId + repos: this.deps.repos }).memories; const retrieval = retrievePluginMemories({ query, diff --git a/Memory/src/storage/repositories.ts b/Memory/src/storage/repositories.ts index 27dd32fdd..d0d485cab 100644 --- a/Memory/src/storage/repositories.ts +++ b/Memory/src/storage/repositories.ts @@ -700,19 +700,19 @@ export class MemoryRepository { return row ? this.hydrate(memoryFromSql(row, this.db)) : undefined; } - listTaskSpanIds(userId: string, episodeId: string, sourceTraceIds: string[]): string[] { + listTaskSpanIds(episodeId: string, sourceTraceIds: string[]): string[] { // Parent traces may already be soft-deleted, and their span_ids only describe // the latest split. Use the spans' own ownership links for task deletion. const rows = this.db.prepare( `SELECT id FROM memories - WHERE user_id = ? AND memory_layer = 'L1' + WHERE memory_layer = 'L1' AND deleted_at IS NULL AND status != 'deleted' AND json_extract(properties_json, '$.internal_info.memory_kind') = 'span' AND json_extract(properties_json, '$.internal_info.span.episode_id') = ? AND json_extract(properties_json, '$.internal_info.span.source_trace_id') IN (SELECT value FROM json_each(?)) ORDER BY id` - ).all(userId, episodeId, JSON.stringify(sourceTraceIds)) as Array<{ id: string }>; + ).all(episodeId, JSON.stringify(sourceTraceIds)) as Array<{ id: string }>; return rows.map((row) => row.id); } diff --git a/Memory/tests/service/evolution/span-big-turn.test.ts b/Memory/tests/service/evolution/span-big-turn.test.ts index 270669764..3b1b39ea3 100644 --- a/Memory/tests/service/evolution/span-big-turn.test.ts +++ b/Memory/tests/service/evolution/span-big-turn.test.ts @@ -334,10 +334,10 @@ describe("MemoryService / evolution / span big turn", () => { }); it.each([ - { name: "after the parent trace was deleted", deleteParent: true, splitAgain: false }, - { name: "after a second successful split with different ranges", deleteParent: false, splitAgain: true }, - { name: "after both re-splitting and deleting the parent", deleteParent: true, splitAgain: true } - ])("deletes all task spans $name", async ({ deleteParent, splitAgain }) => { + { name: "after the parent trace was deleted", deleteParent: true, splitAgain: false, episodeUserId: null }, + { name: "after a second successful split with different ranges", deleteParent: false, splitAgain: true, episodeUserId: null }, + { name: "after both re-splitting and deleting a historical parent", deleteParent: true, splitAgain: true, episodeUserId: "legacy-task-owner" } + ])("deletes all task spans $name", async ({ deleteParent, splitAgain, episodeUserId }) => { const calls: Array<{ messages: LlmMessage[]; options: LlmCompletionOptions }> = []; const firstSplit = { spans: [ @@ -413,6 +413,13 @@ describe("MemoryService / evolution / span big turn", () => { } const targetIds = spanIds(task.episodeId); expect(targetIds).toHaveLength(splitAgain ? 5 : 3); + // An upgraded device can contain both retained account-owned spans and new + // spans without a UID. Task ownership comes from episode/source links. + db.db.prepare("UPDATE episodes SET user_id = ? WHERE id = ?").run(episodeUserId, task.episodeId); + targetIds.forEach((id, index) => { + db.db.prepare("UPDATE memories SET user_id = ? WHERE id = ?") + .run(index % 2 === 0 ? "legacy-span-owner" : null, id); + }); const control = createTask("cascade-control"); await reward(control); const controlIds = spanIds(control.episodeId); diff --git a/Memory/tests/service/retrieval/same-source-l1-dedup.test.ts b/Memory/tests/service/retrieval/same-source-l1-dedup.test.ts index 80dc2e14a..4fb41206c 100644 --- a/Memory/tests/service/retrieval/same-source-l1-dedup.test.ts +++ b/Memory/tests/service/retrieval/same-source-l1-dedup.test.ts @@ -108,6 +108,22 @@ describe("same-source L1 deduplication without UID", () => { expect(result.hits.map(hit => hit.id)).toEqual(["c"]); }); + it("uses the same historical source for repair suggestions across accounts", async () => { + const {db, repos, service, seed} = setup(); + seed("a", "account-a"); seed("b", "account-b"); + const before = db.db.prepare("SELECT * FROM memories ORDER BY id").all(); + for (const userId of ["account-a", "account-b", "new-account"]) { + const session = service.openSession({namespace: {source: "codex", profileId: "default", userId}}); + const result = await service.repairSuggestion({sessionId: session.sessionId, issue: "shared evidence"}); + expect(result.sourceMemoryIds).toEqual(["a"]); + } + expect(db.db.prepare("SELECT * FROM memories ORDER BY id").all()).toEqual(before); + repos.memories.softDelete("a"); + const session = service.openSession({namespace: {source: "codex", profileId: "default", userId: "account-b"}}); + const result = await service.repairSuggestion({sessionId: session.sessionId, issue: "shared evidence"}); + expect(result.sourceMemoryIds).toEqual([]); + }); + it("folds copies returned by separate query rewrites before final fusion", async () => { const {repos, service, seed} = setup(false, true); seed("a", "account-a"); seed("b", "account-b");