From 2841b5ffb1348f0c0f4e25cd6a77f8b3f71cf813 Mon Sep 17 00:00:00 2001 From: Jojii Date: Sat, 22 Aug 2026 22:24:05 +0200 Subject: [PATCH] Include user-scope shards in all-projects tool queries --- src/services/client.ts | 19 ++-- src/services/memory-scope.ts | 16 ++- tests/client-scope-traversal.test.ts | 152 +++++++++++++++++++++++++++ tests/memory-scope-helper.test.ts | 16 ++- 4 files changed, 188 insertions(+), 15 deletions(-) create mode 100644 tests/client-scope-traversal.test.ts diff --git a/src/services/client.ts b/src/services/client.ts index bb589d03..70dea54a 100644 --- a/src/services/client.ts +++ b/src/services/client.ts @@ -4,7 +4,11 @@ import { tursoVectorSearch } from "./turso/vector-search.js"; import { tursoConnectionManager } from "./turso/connection-manager.js"; import { ensureTursoReady } from "./turso/ready.js"; import { formatTagsForEmbedding } from "./turso/vector-utils.js"; -import { extractScopeFromContainerTag, resolveMemoryScope } from "./memory-scope.js"; +import { + extractScopeFromContainerTag, + resolveMemoryScope, + type MemoryScopeRef, +} from "./memory-scope.js"; import { CONFIG } from "../config.js"; import { log } from "./logger.js"; import type { MemoryType } from "../types/index.js"; @@ -40,10 +44,7 @@ function safeJSONParse(jsonString: any): any { } } -function resolveScopeValue( - scope: MemoryScope, - containerTag: string -): { scope: "user" | "project"; hash: string } { +function resolveScopeValue(scope: MemoryScope, containerTag: string): MemoryScopeRef[] { return resolveMemoryScope(scope, containerTag); } @@ -115,7 +116,9 @@ export class LocalMemoryClient { const queryVector = await embeddingService.embedWithTimeout(query, { task: "query" }); const resolved = resolveScopeValue(scope, containerTag); - const shards = await tursoShardManager.getAllShards(resolved.scope, resolved.hash); + const shards = ( + await Promise.all(resolved.map((ref) => tursoShardManager.getAllShards(ref.scope, ref.hash))) + ).flat(); if (shards.length === 0) { return { success: true as const, results: [], total: 0, timing: 0 }; @@ -265,7 +268,9 @@ export class LocalMemoryClient { await this.initialize(); const resolved = resolveScopeValue(scope, containerTag); - const shards = await tursoShardManager.getAllShards(resolved.scope, resolved.hash); + const shards = ( + await Promise.all(resolved.map((ref) => tursoShardManager.getAllShards(ref.scope, ref.hash))) + ).flat(); if (shards.length === 0) { return { diff --git a/src/services/memory-scope.ts b/src/services/memory-scope.ts index 1a0e98ac..304a3f03 100644 --- a/src/services/memory-scope.ts +++ b/src/services/memory-scope.ts @@ -47,12 +47,22 @@ export function tryExtractScopeFromContainerTag( } } +export interface MemoryScopeRef { + scope: "user" | "project"; + hash: string; +} + export function resolveMemoryScope( scope: "project" | "all-projects", containerTag: string -): { scope: "user" | "project"; hash: string } { +): MemoryScopeRef[] { + // "all-projects" must span both canonical scopes: user-scope memories live in + // user shards and would be silently excluded if only project shards were walked. if (scope === "all-projects") { - return { scope: "project", hash: "" }; + return [ + { scope: "user", hash: "" }, + { scope: "project", hash: "" }, + ]; } - return extractScopeFromContainerTag(containerTag); + return [extractScopeFromContainerTag(containerTag)]; } diff --git a/tests/client-scope-traversal.test.ts b/tests/client-scope-traversal.test.ts new file mode 100644 index 00000000..4a36d4e6 --- /dev/null +++ b/tests/client-scope-traversal.test.ts @@ -0,0 +1,152 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +const tempDirs: string[] = []; + +const clientUrl = new URL("../src/services/client.js", import.meta.url).href; +const embeddingUrl = new URL("../src/services/embedding.js", import.meta.url).href; +const shardManagerUrl = new URL("../src/services/turso/shard-manager.js", import.meta.url).href; +const vectorSearchUrl = new URL("../src/services/turso/vector-search.js", import.meta.url).href; +const connectionManagerUrl = new URL( + "../src/services/turso/connection-manager.js", + import.meta.url +).href; +const vectorUtilsUrl = new URL("../src/services/turso/vector-utils.js", import.meta.url).href; +const readyUrl = new URL("../src/services/turso/ready.js", import.meta.url).href; +const configUrl = new URL("../src/config.js", import.meta.url).href; +const loggerUrl = new URL("../src/services/logger.js", import.meta.url).href; + +const PROJECT_TAG = "opencode_project_abcdef1234567890"; + +function runScenario(scriptBody: string) { + const dir = mkdtempSync(join(tmpdir(), "opencode-mem-client-scope-")); + tempDirs.push(dir); + const scriptPath = join(dir, "scenario.mjs"); + const script = ` +import { mock } from "bun:test"; + +const getAllShardsCalls = []; + +mock.module(${JSON.stringify(embeddingUrl)}, () => ({ + embeddingService: { + isWarmedUp: true, + warmup: async () => {}, + embedWithTimeout: async () => new Float32Array([1, 2, 3]), + }, +})); + +mock.module(${JSON.stringify(shardManagerUrl)}, () => ({ + tursoShardManager: { + async getAllShards(scope, hash) { + getAllShardsCalls.push({ scope, hash }); + return [{ id: 1, scope, scopeHash: hash, shardIndex: 0, dbPath: "/tmp/shard.db" }]; + }, + }, +})); + +mock.module(${JSON.stringify(vectorSearchUrl)}, () => ({ + tursoVectorSearch: { + listMemories: async () => [], + searchAcrossShards: async () => ({ results: [], warnings: [] }), + }, +})); + +mock.module(${JSON.stringify(connectionManagerUrl)}, () => ({ + tursoConnectionManager: { + getConnection: async () => ({}), + closeAll: async () => {}, + }, +})); + +mock.module(${JSON.stringify(vectorUtilsUrl)}, () => ({ + formatTagsForEmbedding: (tags) => tags.join(" "), +})); + +mock.module(${JSON.stringify(readyUrl)}, () => ({ + ensureTursoReady: async () => {}, +})); + +mock.module(${JSON.stringify(configUrl)}, () => ({ + CONFIG: { maxMemories: 20, similarityThreshold: 0.5 }, +})); + +mock.module(${JSON.stringify(loggerUrl)}, () => ({ + log: () => {}, +})); + +const { memoryClient } = await import(${JSON.stringify(clientUrl)}); +${scriptBody} +`; + writeFileSync(scriptPath, script, "utf-8"); + const result = Bun.spawnSync({ + cmd: [process.execPath, scriptPath], + stdout: "pipe", + stderr: "pipe", + }); + const stdout = Buffer.from(result.stdout).toString("utf8").trim(); + const stderr = Buffer.from(result.stderr).toString("utf8").trim(); + const jsonLine = stdout + .split("\n") + .reverse() + .find((line) => line.trim().startsWith("{")); + + return { + exitCode: result.exitCode, + stdout, + stderr, + parsed: jsonLine ? JSON.parse(jsonLine) : null, + }; +} + +afterEach(() => { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("memory client all-projects shard traversal", () => { + it("listMemories with all-projects walks both user and project shards", () => { + const result = runScenario(` +let n = getAllShardsCalls.length; +await memoryClient.listMemories(${JSON.stringify(PROJECT_TAG)}, 10, "all-projects"); +console.log(JSON.stringify({ calls: getAllShardsCalls.slice(n) })); +`); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.parsed?.calls).toEqual([ + { scope: "user", hash: "" }, + { scope: "project", hash: "" }, + ]); + }); + + it("searchMemories with all-projects walks both user and project shards", () => { + const result = runScenario(` +let n = getAllShardsCalls.length; +await memoryClient.searchMemories("query", ${JSON.stringify(PROJECT_TAG)}, "all-projects"); +console.log(JSON.stringify({ calls: getAllShardsCalls.slice(n) })); +`); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.parsed?.calls).toEqual([ + { scope: "user", hash: "" }, + { scope: "project", hash: "" }, + ]); + }); + + it("listMemories with project scope walks only the current project shard", () => { + const result = runScenario(` +let n = getAllShardsCalls.length; +await memoryClient.listMemories(${JSON.stringify(PROJECT_TAG)}, 10, "project"); +console.log(JSON.stringify({ calls: getAllShardsCalls.slice(n) })); +`); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + expect(result.parsed?.calls).toEqual([{ scope: "project", hash: "abcdef1234567890" }]); + }); +}); diff --git a/tests/memory-scope-helper.test.ts b/tests/memory-scope-helper.test.ts index 8285d8cf..f04403c6 100644 --- a/tests/memory-scope-helper.test.ts +++ b/tests/memory-scope-helper.test.ts @@ -25,11 +25,17 @@ describe("memory scope helper", () => { }); }); - it("resolves all-projects scope to empty project hash", () => { - expect(resolveMemoryScope("all-projects", `opencode_project_${PROJECT_HASH}`)).toEqual({ - scope: "project", - hash: "", - }); + it("resolves all-projects scope to both user and project scopes", () => { + expect(resolveMemoryScope("all-projects", `opencode_project_${PROJECT_HASH}`)).toEqual([ + { scope: "user", hash: "" }, + { scope: "project", hash: "" }, + ]); + }); + + it("resolves project scope to the container tag scope", () => { + expect(resolveMemoryScope("project", `opencode_project_${PROJECT_HASH}`)).toEqual([ + { scope: "project", hash: PROJECT_HASH }, + ]); }); it("validates scope hash format", () => {