diff --git a/packages/cli/src/commands/memory.test.ts b/packages/cli/src/commands/memory.test.ts index b4098c48..c28e69b5 100644 --- a/packages/cli/src/commands/memory.test.ts +++ b/packages/cli/src/commands/memory.test.ts @@ -1,4 +1,9 @@ -import { describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { initDatabase, MemoryStore } from "@codemem/core"; +import { describe, expect, it, vi } from "vitest"; +import * as embeddings from "../../../core/src/embeddings.js"; import { forgetMemoryCommand, memoryCommand, @@ -6,6 +11,39 @@ import { showMemoryCommand, } from "./memory.js"; +vi.mock("../../../core/src/embeddings.js", async () => { + const actual = await vi.importActual( + "../../../core/src/embeddings.js", + ); + return { + ...actual, + embedTexts: vi.fn(), + getEmbeddingClient: vi.fn(), + resolveEmbeddingModel: vi.fn(() => "test-model"), + }; +}); + +function insertCoordinatorScope(store: MemoryStore, scopeId: string): void { + const now = "2026-01-01T00:00:00Z"; + store.db + .prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, membership_epoch, status, created_at, updated_at + ) VALUES (?, ?, 'team', 'coordinator', 1, 'active', ?, ?)`, + ) + .run(scopeId, scopeId, now, now); +} + +function insertHiddenOwnedMemory(store: MemoryStore): number { + insertCoordinatorScope(store, "unauthorized-team"); + const sessionId = store.startSession({ cwd: process.cwd(), project: "secret-project" }); + const memoryId = store.remember(sessionId, "discovery", "Hidden owned memory", "Hidden body"); + store.db + .prepare("UPDATE memory_items SET scope_id = ? WHERE id = ?") + .run("unauthorized-team", memoryId); + return memoryId; +} + describe("memory command aliases", () => { it("keeps memory subcommands available under the memory group", () => { expect(memoryCommand.commands.map((command) => command.name())).toEqual([ @@ -145,3 +183,95 @@ describe("memory command aliases", () => { expect(longs).toContain("--json"); }); }); + +describe("memory command scope safety", () => { + it("stores vectors for manually remembered memories", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "codemem-memory-command-vector-")); + const dbPath = join(tmpDir, "test.sqlite"); + initDatabase(dbPath); + vi.mocked(embeddings.getEmbeddingClient).mockResolvedValue({ + model: "test-model", + dimensions: 384, + embed: vi.fn(), + }); + vi.mocked(embeddings.embedTexts).mockResolvedValue([new Float32Array(384)]); + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const originalExitCode = process.exitCode; + process.exitCode = undefined; + try { + await rememberMemoryCommand.parseAsync( + [ + "--kind", + "discovery", + "--title", + "Manual vector memory", + "--body", + "Manual vector body", + "--db-path", + dbPath, + "--json", + ], + { from: "user" }, + ); + + const output = logSpy.mock.calls.at(-1)?.[0]; + const parsed = JSON.parse(String(output)) as { id: number }; + expect(parsed.id).toBeGreaterThan(0); + expect(process.exitCode).toBeUndefined(); + + const verifyStore = new MemoryStore(dbPath); + try { + const row = verifyStore.db + .prepare("SELECT COUNT(*) AS n FROM memory_vectors WHERE memory_id = ?") + .get(parsed.id) as { n: number }; + expect(row.n).toBe(1); + } finally { + verifyStore.close(); + } + } finally { + process.exitCode = originalExitCode; + logSpy.mockRestore(); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("does not forget memories outside visible sharing domains", async () => { + const tmpDir = mkdtempSync(join(tmpdir(), "codemem-memory-command-scope-")); + const dbPath = join(tmpDir, "test.sqlite"); + initDatabase(dbPath); + const store = new MemoryStore(dbPath); + const memoryId = insertHiddenOwnedMemory(store); + await store.flushPendingVectorWrites(); + store.close(); + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + const originalExitCode = process.exitCode; + process.exitCode = undefined; + try { + await forgetMemoryCommand.parseAsync([String(memoryId), "--db-path", dbPath, "--json"], { + from: "user", + }); + + const output = logSpy.mock.calls.at(-1)?.[0]; + expect(JSON.parse(String(output))).toMatchObject({ + error: "not_found", + message: `Memory ${memoryId} not found`, + }); + expect(process.exitCode).toBe(1); + + const verifyStore = new MemoryStore(dbPath); + try { + const row = verifyStore.db + .prepare("SELECT active FROM memory_items WHERE id = ?") + .get(memoryId) as { active: number }; + expect(row.active).toBe(1); + } finally { + verifyStore.close(); + } + } finally { + process.exitCode = originalExitCode; + logSpy.mockRestore(); + rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/commands/memory.ts b/packages/cli/src/commands/memory.ts index 6b9e2c67..6518581c 100644 --- a/packages/cli/src/commands/memory.ts +++ b/packages/cli/src/commands/memory.ts @@ -99,6 +99,15 @@ function forgetMemoryAction(idStr: string, opts: DbOpts & JsonOpts): void { } const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts))); try { + if (!store.get(memoryId)) { + if (opts.json) { + emitJsonError("not_found", `Memory ${memoryId} not found`); + } else { + p.log.error(`Memory ${memoryId} not found`); + process.exitCode = 1; + } + return; + } store.forget(memoryId); if (opts.json) { console.log(JSON.stringify({ id: memoryId, status: "forgotten" })); @@ -118,6 +127,30 @@ interface RememberMemoryOptions extends DbOpts, JsonOpts { project?: string; } +function rollbackManualMemory(store: MemoryStore, sessionId: number, memoryId: number): void { + store.db.transaction(() => { + const row = store.db + .prepare("SELECT import_key FROM memory_items WHERE id = ?") + .get(memoryId) as { import_key: string | null } | undefined; + store.db.prepare("DELETE FROM memory_vectors WHERE memory_id = ?").run(memoryId); + store.db.prepare("DELETE FROM memory_file_refs WHERE memory_id = ?").run(memoryId); + store.db.prepare("DELETE FROM memory_concept_refs WHERE memory_id = ?").run(memoryId); + store.db + .prepare( + "DELETE FROM replication_ops WHERE entity_type = 'memory_item' AND (entity_id = ? OR entity_id = ?)", + ) + .run(row?.import_key ?? "", String(memoryId)); + store.db.prepare("DELETE FROM memory_items WHERE id = ?").run(memoryId); + store.db + .prepare( + `DELETE FROM sessions + WHERE id = ? + AND NOT EXISTS (SELECT 1 FROM memory_items WHERE session_id = ?)`, + ) + .run(sessionId, sessionId); + })(); +} + async function rememberMemoryAction(opts: RememberMemoryOptions): Promise { const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts))); let sessionId: number | null = null; @@ -131,8 +164,14 @@ async function rememberMemoryAction(opts: RememberMemoryOptions): Promise metadata: { manual: true }, }); const memId = store.remember(sessionId, opts.kind, opts.title, opts.body, 0.5, opts.tags); - await store.flushPendingVectorWrites(); + if (!store.get(memId)) { + await store.flushPendingVectorWrites(); + rollbackManualMemory(store, sessionId, memId); + sessionId = null; + throw new Error("unauthorized_scope"); + } store.endSession(sessionId, { manual: true }); + await store.flushPendingVectorWrites(); if (opts.json) { console.log(JSON.stringify({ id: memId })); } else { diff --git a/packages/cli/src/commands/stats.test.ts b/packages/cli/src/commands/stats.test.ts index b7806b95..87b2bee1 100644 --- a/packages/cli/src/commands/stats.test.ts +++ b/packages/cli/src/commands/stats.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { connect, getSchemaVersion, SCHEMA_VERSION } from "@codemem/core"; +import { connect, getSchemaVersion, MemoryStore, SCHEMA_VERSION } from "@codemem/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { statsCommand } from "./stats.js"; @@ -43,4 +43,53 @@ describe("stats command", () => { logSpy.mockRestore(); } }); + + it("reports memory counts through the local scope visibility gate", async () => { + const dbPath = join(tmpDir, "scoped.sqlite"); + const store = new MemoryStore(dbPath); + try { + const now = "2026-01-01T00:00:00Z"; + for (const scopeId of ["authorized-team", "unauthorized-team"]) { + store.db + .prepare( + `INSERT INTO replication_scopes( + scope_id, label, kind, authority_type, membership_epoch, status, created_at, updated_at + ) VALUES (?, ?, 'team', 'coordinator', 1, 'active', ?, ?)`, + ) + .run(scopeId, scopeId, now, now); + } + store.db + .prepare( + `INSERT INTO scope_memberships(scope_id, device_id, role, status, membership_epoch, updated_at) + VALUES ('authorized-team', ?, 'member', 'active', 1, ?)`, + ) + .run(store.deviceId, now); + + const sessionId = store.startSession({ cwd: process.cwd(), project: "scope-test" }); + const visibleId = store.remember(sessionId, "discovery", "Visible stats", "Visible body"); + const hiddenId = store.remember(sessionId, "discovery", "Hidden stats", "Hidden body"); + store.db + .prepare("UPDATE memory_items SET scope_id = ? WHERE id = ?") + .run("authorized-team", visibleId); + store.db + .prepare("UPDATE memory_items SET scope_id = ? WHERE id = ?") + .run("unauthorized-team", hiddenId); + await store.flushPendingVectorWrites(); + } finally { + store.close(); + } + + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}); + try { + await statsCommand.parseAsync(["--db-path", dbPath, "--json"], { from: "user" }); + + const output = logSpy.mock.calls.at(-1)?.[0]; + expect(typeof output).toBe("string"); + const result = JSON.parse(String(output)); + expect(result.database.memory_items).toBe(1); + expect(result.database.active_memory_items).toBe(1); + } finally { + logSpy.mockRestore(); + } + }); }); diff --git a/packages/core/src/store.test.ts b/packages/core/src/store.test.ts index e0675788..d51f737f 100644 --- a/packages/core/src/store.test.ts +++ b/packages/core/src/store.test.ts @@ -1396,6 +1396,18 @@ describe("MemoryStore", () => { expect(result.database.active_memory_items).toBe(1); }); + it("excludes memories outside locally authorized scopes from memory stats", () => { + grantScopeToLocalDevice("authorized-team"); + insertCoordinatorScope("unauthorized-team"); + insertScopedMemory("authorized-team", "Authorized stats memory"); + insertScopedMemory("unauthorized-team", "Unauthorized stats memory"); + + const result = store.stats(); + expect(result.database.sessions).toBe(1); + expect(result.database.memory_items).toBe(1); + expect(result.database.active_memory_items).toBe(1); + }); + it("handles memory_vectors count failures without crashing", () => { const sessionId = insertTestSession(store.db); store.remember(sessionId, "discovery", "Vector test", "Body"); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index eac7afd1..48b0a69e 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -1010,22 +1010,44 @@ export class MemoryStore { // biome-ignore lint/suspicious/noExplicitAny: Drizzle table union type is unwieldy const countRows = (tbl: any) => this.d.select({ c: sql`COUNT(*)` }).from(tbl).get()?.c ?? 0; + const visibleFilter = buildFilterClausesWithContext(null, this.scopeVisibleFilterContext()); + const countVisibleMemoryRows = (extraClauses: string[] = []): number => { + const clauses = [...extraClauses, ...visibleFilter.clauses]; + const row = this.db + .prepare(`SELECT COUNT(*) AS c FROM memory_items WHERE ${clauses.join(" AND ")}`) + .get(...visibleFilter.params) as { c: number | null } | undefined; + return row?.c ?? 0; + }; + const countVisibleMemorySessions = (): number => { + const clauses = ["memory_items.active = 1", ...visibleFilter.clauses]; + const row = this.db + .prepare( + `SELECT COUNT(DISTINCT memory_items.session_id) AS c + FROM memory_items + WHERE ${clauses.join(" AND ")}`, + ) + .get(...visibleFilter.params) as { c: number | null } | undefined; + return row?.c ?? 0; + }; - const totalMemories = countRows(schema.memoryItems); - const activeMemories = - this.d - .select({ c: sql`COUNT(*)` }) - .from(schema.memoryItems) - .where(eq(schema.memoryItems.active, 1)) - .get()?.c ?? 0; - const sessions = countRows(schema.sessions); + const totalMemories = countVisibleMemoryRows(); + const activeMemories = countVisibleMemoryRows(["memory_items.active = 1"]); + const sessions = countVisibleMemorySessions(); const artifacts = countRows(schema.artifacts); const rawEvents = countRows(schema.rawEvents); let vectorCount = 0; if (!isEmbeddingDisabled() && tableExists(this.db, "memory_vectors")) { try { - const row = this.d.get<{ c: number | null }>(sql`SELECT COUNT(*) AS c FROM memory_vectors`); + const clauses = ["memory_items.active = 1", ...visibleFilter.clauses]; + const row = this.db + .prepare( + `SELECT COUNT(*) AS c + FROM memory_vectors + JOIN memory_items ON memory_items.id = memory_vectors.memory_id + WHERE ${clauses.join(" AND ")}`, + ) + .get(...visibleFilter.params) as { c: number | null } | undefined; vectorCount = row?.c ?? 0; } catch { vectorCount = 0; @@ -1033,12 +1055,10 @@ export class MemoryStore { } const vectorCoverage = activeMemories > 0 ? Math.min(1, vectorCount / activeMemories) : 0; - const tagsFilled = - this.d - .select({ c: sql`COUNT(*)` }) - .from(schema.memoryItems) - .where(and(eq(schema.memoryItems.active, 1), sql`TRIM(tags_text) != ''`)) - .get()?.c ?? 0; + const tagsFilled = countVisibleMemoryRows([ + "memory_items.active = 1", + "TRIM(memory_items.tags_text) != ''", + ]); const tagsCoverage = activeMemories > 0 ? Math.min(1, tagsFilled / activeMemories) : 0; let sizeBytes = 0; diff --git a/packages/viewer-server/src/index.test.ts b/packages/viewer-server/src/index.test.ts index 8611838e..5ac5d077 100644 --- a/packages/viewer-server/src/index.test.ts +++ b/packages/viewer-server/src/index.test.ts @@ -62,6 +62,7 @@ function insertTestMemory( originDeviceId?: string | null; createdAt?: string; active?: boolean; + scopeId?: string | null; }, ): number { const now = options.createdAt ?? new Date().toISOString(); @@ -71,8 +72,9 @@ function insertTestMemory( session_id, kind, title, subtitle, body_text, confidence, tags_text, active, created_at, updated_at, metadata_json, actor_id, actor_display_name, visibility, workspace_id, workspace_kind, origin_device_id, origin_source, trust_state, - facts, narrative, concepts, files_read, files_modified, prompt_number, rev, import_key - ) VALUES (?, ?, ?, NULL, ?, 0.5, '', ?, ?, ?, ?, ?, ?, 'shared', 'shared:default', 'shared', ?, ?, 'trusted', NULL, NULL, NULL, NULL, NULL, NULL, 1, ?)`, + facts, narrative, concepts, files_read, files_modified, prompt_number, rev, import_key, + scope_id + ) VALUES (?, ?, ?, NULL, ?, 0.5, '', ?, ?, ?, ?, ?, ?, 'shared', 'shared:default', 'shared', ?, ?, 'trusted', NULL, NULL, NULL, NULL, NULL, NULL, 1, ?, ?)`, ) .run( options.sessionId, @@ -90,6 +92,7 @@ function insertTestMemory( options.originDeviceId === undefined ? "test-device-001" : options.originDeviceId, String(options.metadata?.source ?? "test"), `${options.kind}-${options.title}-${now}`, + options.scopeId ?? null, ); return Number(result.lastInsertRowid); } @@ -278,6 +281,38 @@ describe("viewer-server", () => { } }); + it("counts only visible memory scopes in memory stats", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + grantSyncScopeToDevices(store, "authorized-team", [store.deviceId]); + grantSyncScopeToDevices(store, "unauthorized-team", []); + const sessionId = insertTestSession(store.db); + insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "Visible stats memory", + scopeId: "authorized-team", + }); + insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "Hidden stats memory", + scopeId: "unauthorized-team", + }); + + const res = await app.request("/api/stats"); + expect(res.status).toBe(200); + const body = (await res.json()) as { database: Record }; + expect(body.database.memory_items).toBe(1); + expect(body.database.active_memory_items).toBe(1); + } finally { + cleanup(); + } + }); + it("keeps active maintenance jobs in stable started order", async () => { const { app, getStore, cleanup } = createTestApp(); try { @@ -337,6 +372,11 @@ describe("viewer-server", () => { if (!store) throw new Error("store not initialized"); const sessionId = insertTestSession(store.db); store.db.prepare("UPDATE sessions SET project = ? WHERE id = ?").run("codemem", sessionId); + insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "Usage-visible memory", + }); store.db .prepare( `INSERT INTO usage_events(session_id, event, tokens_read, tokens_written, tokens_saved, created_at, metadata_json) @@ -366,6 +406,79 @@ describe("viewer-server", () => { cleanup(); } }); + + it("removes hidden memory ids from recent pack metadata", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + grantSyncScopeToDevices(store, "authorized-team", [store.deviceId]); + grantSyncScopeToDevices(store, "unauthorized-team", []); + const sessionId = insertTestSession(store.db); + const visibleId = insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "Visible pack item", + scopeId: "authorized-team", + }); + const hiddenId = insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "Hidden pack item", + scopeId: "unauthorized-team", + }); + store.db + .prepare( + `INSERT INTO usage_events(session_id, event, tokens_read, tokens_written, tokens_saved, created_at, metadata_json) + VALUES (?, 'pack', 123, 0, 456, ?, ?)`, + ) + .run( + sessionId, + "2026-03-26T23:30:00Z", + JSON.stringify({ + pack_item_ids: [visibleId, hiddenId], + added_ids: [visibleId, hiddenId], + removed_ids: [hiddenId], + retained_ids: [String(visibleId), String(hiddenId)], + }), + ); + const hiddenSessionId = insertTestSession(store.db); + const hiddenOnlyId = insertTestMemory(store, { + sessionId: hiddenSessionId, + kind: "discovery", + title: "Hidden only pack item", + scopeId: "unauthorized-team", + }); + store.db + .prepare( + `INSERT INTO usage_events(session_id, event, tokens_read, tokens_written, tokens_saved, created_at, metadata_json) + VALUES (?, 'pack', 999, 0, 999, ?, ?)`, + ) + .run( + hiddenSessionId, + "2026-03-27T23:30:00Z", + JSON.stringify({ pack_item_ids: [hiddenOnlyId], project: "secret-project" }), + ); + + const res = await app.request("/api/usage"); + expect(res.status).toBe(200); + const body = (await res.json()) as { + recent_packs: Array<{ metadata_json: unknown }>; + totals: { count: number; tokens_read: number; tokens_saved: number }; + }; + expect(body.recent_packs).toHaveLength(1); + expect(body.totals).toMatchObject({ count: 1, tokens_read: 123, tokens_saved: 456 }); + expect(body.recent_packs[0]?.metadata_json).toMatchObject({ + pack_item_ids: [visibleId], + added_ids: [visibleId], + removed_ids: [], + retained_ids: [visibleId], + }); + } finally { + cleanup(); + } + }); }); describe("GET /api/sessions", () => { @@ -376,7 +489,12 @@ describe("viewer-server", () => { const _warmup = await app.request("/api/stats"); const store = getStore(); if (store) { - insertTestSession(store.db); + const sessionId = insertTestSession(store.db); + insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "Visible session memory", + }); } const res = await app.request("/api/sessions"); expect(res.status).toBe(200); @@ -402,9 +520,115 @@ describe("viewer-server", () => { cleanup(); } }); + + it("only lists projects backed by visible memory scopes", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + grantSyncScopeToDevices(store, "authorized-team", [store.deviceId]); + grantSyncScopeToDevices(store, "unauthorized-team", []); + + const visibleSessionId = insertTestSession(store.db); + store.db + .prepare("UPDATE sessions SET project = ? WHERE id = ?") + .run("visible-project", visibleSessionId); + insertTestMemory(store, { + sessionId: visibleSessionId, + kind: "discovery", + title: "Visible scoped memory", + scopeId: "authorized-team", + }); + + const hiddenSessionId = insertTestSession(store.db); + store.db + .prepare("UPDATE sessions SET project = ? WHERE id = ?") + .run("secret-project", hiddenSessionId); + insertTestMemory(store, { + sessionId: hiddenSessionId, + kind: "discovery", + title: "Hidden scoped memory", + scopeId: "unauthorized-team", + }); + + const projectsRes = await app.request("/api/projects"); + expect(projectsRes.status).toBe(200); + const projectsBody = (await projectsRes.json()) as { projects: string[] }; + expect(projectsBody.projects).toEqual(["visible-project"]); + + const sessionsRes = await app.request("/api/sessions"); + expect(sessionsRes.status).toBe(200); + const sessionsBody = (await sessionsRes.json()) as { + items: Array<{ id: number; project: string }>; + }; + expect(sessionsBody.items.map((item) => item.id)).toEqual([visibleSessionId]); + } finally { + cleanup(); + } + }); }); describe("memory feed routes", () => { + it("applies sharing-domain visibility to memory list endpoints", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + grantSyncScopeToDevices(store, "authorized-team", [store.deviceId]); + grantSyncScopeToDevices(store, "unauthorized-team", []); + const sessionId = insertTestSession(store.db); + + insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "Visible observation", + scopeId: "authorized-team", + }); + insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "Hidden observation", + scopeId: "unauthorized-team", + }); + insertTestMemory(store, { + sessionId, + kind: "session_summary", + title: "Visible summary", + scopeId: "authorized-team", + }); + insertTestMemory(store, { + sessionId, + kind: "session_summary", + title: "Hidden summary", + scopeId: "unauthorized-team", + }); + + const observationsRes = await app.request("/api/observations"); + expect(observationsRes.status).toBe(200); + const observations = (await observationsRes.json()) as { + items: Array<{ title: string }>; + }; + expect(observations.items.map((item) => item.title)).toEqual(["Visible observation"]); + + const summariesRes = await app.request("/api/summaries"); + expect(summariesRes.status).toBe(200); + const summaries = (await summariesRes.json()) as { items: Array<{ title: string }> }; + expect(summaries.items.map((item) => item.title)).toEqual(["Visible summary"]); + + const memoryRes = await app.request("/api/memory?limit=10"); + expect(memoryRes.status).toBe(200); + const memory = (await memoryRes.json()) as { items: Array<{ title: string }> }; + expect(memory.items.map((item) => item.title).sort()).toEqual([ + "Visible observation", + "Visible summary", + ]); + } finally { + cleanup(); + } + }); + it("applies mine/theirs scope filters to observations", async () => { const { app, getStore, cleanup } = createTestApp(); try { @@ -527,6 +751,54 @@ describe("viewer-server", () => { } }); + it("does not mutate memories outside visible sharing domains", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + grantSyncScopeToDevices(store, "unauthorized-team", []); + const sessionId = insertTestSession(store.db); + store.db + .prepare("UPDATE sessions SET project = ? WHERE id = ?") + .run("secret-project", sessionId); + const memoryId = insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "Hidden local-owned memory", + scopeId: "unauthorized-team", + }); + + for (const [path, body] of [ + ["/api/memories/project", { memory_id: memoryId, project: "new-project" }], + ["/api/memories/visibility", { memory_id: memoryId, visibility: "private" }], + ["/api/memories/forget", { memory_id: memoryId }], + ] as const) { + const res = await app.request(path, { + method: "POST", + headers: { + "Content-Type": "application/json", + Origin: "http://127.0.0.1:38888", + }, + body: JSON.stringify(body), + }); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "memory not found" }); + } + + const row = store.db + .prepare( + `SELECT memory_items.active, memory_items.visibility, sessions.project + FROM memory_items JOIN sessions ON sessions.id = memory_items.session_id + WHERE memory_items.id = ?`, + ) + .get(memoryId) as { active: number; visibility: string; project: string }; + expect(row).toMatchObject({ active: 1, visibility: "shared", project: "secret-project" }); + } finally { + cleanup(); + } + }); + it("forgets an owned memory via the viewer API", async () => { const { app, getStore, cleanup } = createTestApp(); try { @@ -849,6 +1121,97 @@ describe("viewer-server", () => { } }); + it("excludes hidden sharing domains from session memory counts", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + grantSyncScopeToDevices(store, "authorized-team", [store.deviceId]); + grantSyncScopeToDevices(store, "unauthorized-team", []); + const sessionId = insertTestSession(store.db); + insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "Visible count memory", + scopeId: "authorized-team", + }); + insertTestMemory(store, { + sessionId, + kind: "discovery", + title: "Hidden count memory", + scopeId: "unauthorized-team", + }); + + const res = await app.request("/api/session"); + expect(res.status).toBe(200); + const body = (await res.json()) as { memories: number; observations: number }; + expect(body.memories).toBe(1); + expect(body.observations).toBe(1); + } finally { + cleanup(); + } + }); + + it("gates prompt and artifact aggregate counts by visible memory sessions", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + grantSyncScopeToDevices(store, "authorized-team", [store.deviceId]); + grantSyncScopeToDevices(store, "unauthorized-team", []); + + const seedProjectSession = (project: string, scopeId: string) => { + const sessionId = insertTestSession(store.db); + store.db.prepare("UPDATE sessions SET project = ? WHERE id = ?").run(project, sessionId); + insertTestMemory(store, { + sessionId, + kind: "discovery", + title: `${project} memory`, + scopeId, + }); + store.db + .prepare( + `INSERT INTO user_prompts(session_id, project, prompt_text, created_at, created_at_epoch, metadata_json) + VALUES (?, ?, 'prompt', ?, 0, '{}')`, + ) + .run(sessionId, project, "2026-01-01T00:00:00Z"); + store.db + .prepare( + `INSERT INTO artifacts(session_id, kind, path, content_text, content_hash, created_at, metadata_json) + VALUES (?, 'note', ?, 'artifact', 'hash', ?, '{}')`, + ) + .run(sessionId, `${project}.txt`, "2026-01-01T00:00:00Z"); + }; + + seedProjectSession("visible-project", "authorized-team"); + seedProjectSession("secret-project", "unauthorized-team"); + + const hiddenRes = await app.request("/api/session?project=secret-project"); + expect(hiddenRes.status).toBe(200); + expect(await hiddenRes.json()).toMatchObject({ + artifacts: 0, + memories: 0, + observations: 0, + prompts: 0, + total: 0, + }); + + const visibleRes = await app.request("/api/session?project=visible-project"); + expect(visibleRes.status).toBe(200); + expect(await visibleRes.json()).toMatchObject({ + artifacts: 1, + memories: 1, + observations: 1, + prompts: 1, + total: 3, + }); + } finally { + cleanup(); + } + }); + it("tolerates malformed metadata when classifying summaries", async () => { const { app, getStore, cleanup } = createTestApp(); try { @@ -880,6 +1243,82 @@ describe("viewer-server", () => { }); }); + describe("GET /api/artifacts", () => { + it("requires a visible memory in the session before returning local artifacts", async () => { + const { app, getStore, cleanup } = createTestApp(); + try { + await app.request("/api/stats"); + const store = getStore(); + if (!store) throw new Error("store not initialized"); + grantSyncScopeToDevices(store, "authorized-team", [store.deviceId]); + grantSyncScopeToDevices(store, "unauthorized-team", []); + + const visibleSessionId = insertTestSession(store.db); + insertTestMemory(store, { + sessionId: visibleSessionId, + kind: "discovery", + title: "Visible artifact session memory", + scopeId: "authorized-team", + }); + store.db + .prepare( + `INSERT INTO artifacts(session_id, kind, path, content_text, content_hash, created_at, metadata_json) + VALUES (?, 'note', 'visible.txt', 'visible artifact', 'visible-hash', ?, '{}')`, + ) + .run(visibleSessionId, "2026-01-01T00:00:00Z"); + + const hiddenSessionId = insertTestSession(store.db); + insertTestMemory(store, { + sessionId: hiddenSessionId, + kind: "discovery", + title: "Hidden artifact session memory", + scopeId: "unauthorized-team", + }); + store.db + .prepare( + `INSERT INTO artifacts(session_id, kind, path, content_text, content_hash, created_at, metadata_json) + VALUES (?, 'note', 'hidden.txt', 'hidden artifact', 'hidden-hash', ?, '{}')`, + ) + .run(hiddenSessionId, "2026-01-01T00:00:00Z"); + + const mixedSessionId = insertTestSession(store.db); + insertTestMemory(store, { + sessionId: mixedSessionId, + kind: "discovery", + title: "Mixed visible artifact memory", + scopeId: "authorized-team", + }); + insertTestMemory(store, { + sessionId: mixedSessionId, + kind: "discovery", + title: "Mixed hidden artifact memory", + scopeId: "unauthorized-team", + }); + store.db + .prepare( + `INSERT INTO artifacts(session_id, kind, path, content_text, content_hash, created_at, metadata_json) + VALUES (?, 'note', 'mixed.txt', 'mixed artifact', 'mixed-hash', ?, '{}')`, + ) + .run(mixedSessionId, "2026-01-01T00:00:00Z"); + + const visibleRes = await app.request(`/api/artifacts?session_id=${visibleSessionId}`); + expect(visibleRes.status).toBe(200); + const visibleBody = (await visibleRes.json()) as { items: Array<{ path: string }> }; + expect(visibleBody.items.map((item) => item.path)).toEqual(["visible.txt"]); + + const hiddenRes = await app.request(`/api/artifacts?session_id=${hiddenSessionId}`); + expect(hiddenRes.status).toBe(404); + expect(await hiddenRes.json()).toEqual({ error: "session not found" }); + + const mixedRes = await app.request(`/api/artifacts?session_id=${mixedSessionId}`); + expect(mixedRes.status).toBe(404); + expect(await mixedRes.json()).toEqual({ error: "session not found" }); + } finally { + cleanup(); + } + }); + }); + describe("GET /api/pack", () => { it("uses async pack builder path", async () => { const { app, getStore, cleanup } = createTestApp(); diff --git a/packages/viewer-server/src/routes/memory.ts b/packages/viewer-server/src/routes/memory.ts index b01eb43f..696eba8f 100644 --- a/packages/viewer-server/src/routes/memory.ts +++ b/packages/viewer-server/src/routes/memory.ts @@ -2,7 +2,7 @@ * Memory routes — observations, summaries, sessions, projects, pack, artifacts. */ -import type { MemoryStore } from "@codemem/core"; +import type { MemoryFilters, MemoryStore } from "@codemem/core"; import { buildFilterClausesWithContext, canonicalMemoryKind, @@ -11,7 +11,7 @@ import { parseStrictInteger, schema, } from "@codemem/core"; -import { desc, eq, inArray, isNotNull } from "drizzle-orm"; +import { eq, inArray } from "drizzle-orm"; import { drizzle } from "drizzle-orm/better-sqlite3"; import { Hono } from "hono"; import { queryInt } from "../helpers.js"; @@ -138,6 +138,84 @@ function normalizeScope(raw: string | undefined): "mine" | "theirs" | undefined return undefined; } +function buildViewerMemoryFilters(store: MemoryStore, filters?: MemoryFilters | null) { + return buildFilterClausesWithContext(filters, { + actorId: store.actorId, + deviceId: store.deviceId, + enforceScopeVisibility: true, + }); +} + +function countVisibleMemoryRows(store: MemoryStore, filters?: MemoryFilters | null): number { + const filterResult = buildViewerMemoryFilters(store, filters); + const clauses = ["memory_items.active = 1", ...filterResult.clauses]; + const from = filterResult.joinSessions + ? "memory_items JOIN sessions ON sessions.id = memory_items.session_id" + : "memory_items"; + const row = store.db + .prepare(`SELECT COUNT(*) AS total FROM ${from} WHERE ${clauses.join(" AND ")}`) + .get(...filterResult.params) as Record | undefined; + return Number(row?.total ?? 0); +} + +function sessionAllowsArtifactAccess(store: MemoryStore, sessionId: number): boolean { + const visibleCount = countVisibleMemoryRows(store, { session_id: sessionId }); + if (visibleCount === 0) return false; + const row = store.db + .prepare( + `SELECT COUNT(*) AS total FROM memory_items + WHERE session_id = ? AND active = 1`, + ) + .get(sessionId) as Record | undefined; + return visibleCount === Number(row?.total ?? 0); +} + +function countVisiblePromptRows(store: MemoryStore, project?: string | null): number { + const filterResult = buildViewerMemoryFilters(store, null); + const clauses = [ + "user_prompts.session_id IS NOT NULL", + `EXISTS ( + SELECT 1 FROM memory_items + WHERE memory_items.session_id = user_prompts.session_id + AND memory_items.active = 1 + AND ${filterResult.clauses.join(" AND ")} + )`, + ]; + const params: unknown[] = [...filterResult.params]; + if (project) { + clauses.unshift("user_prompts.project = ?"); + params.unshift(project); + } + const row = store.db + .prepare(`SELECT COUNT(*) AS total FROM user_prompts WHERE ${clauses.join(" AND ")}`) + .get(...params) as Record | undefined; + return Number(row?.total ?? 0); +} + +function countVisibleArtifactRows(store: MemoryStore, project?: string | null): number { + const filterResult = buildViewerMemoryFilters(store, null); + const clauses = [ + `EXISTS ( + SELECT 1 FROM memory_items + WHERE memory_items.session_id = artifacts.session_id + AND memory_items.active = 1 + AND ${filterResult.clauses.join(" AND ")} + )`, + ]; + const params: unknown[] = [...filterResult.params]; + const from = project + ? "artifacts JOIN sessions ON sessions.id = artifacts.session_id" + : "artifacts"; + if (project) { + clauses.unshift("sessions.project = ?"); + params.unshift(project); + } + const row = store.db + .prepare(`SELECT COUNT(*) AS total FROM ${from} WHERE ${clauses.join(" AND ")}`) + .get(...params) as Record | undefined; + return Number(row?.total ?? 0); +} + function queryMemoryPage( store: MemoryStore, options: { @@ -147,14 +225,11 @@ function queryMemoryPage( scope?: "mine" | "theirs"; }, ): Record[] { - const filters: Record = {}; + const filters: MemoryFilters = {}; if (options.project) filters.project = options.project; if (options.scope) filters.ownership_scope = options.scope; - const filterResult = buildFilterClausesWithContext(filters, { - actorId: store.actorId, - deviceId: store.deviceId, - }); + const filterResult = buildViewerMemoryFilters(store, filters); const clauses = ["memory_items.active = 1", ...filterResult.clauses]; const where = clauses.join(" AND "); const from = filterResult.joinSessions @@ -219,16 +294,23 @@ export function memoryRoutes(getStore: StoreFactory) { const store = getStore(); { const limit = queryInt(c.req.query("limit"), 20); - const d = drizzle(store.db, { schema }); - const rows = d - .select() - .from(schema.sessions) - .orderBy(desc(schema.sessions.started_at)) - .limit(limit) - .all(); + const filterResult = buildViewerMemoryFilters(store, null); + const clauses = [ + "memory_items.session_id = sessions.id", + "memory_items.active = 1", + ...filterResult.clauses, + ]; + const rows = store.db + .prepare( + `SELECT sessions.* FROM sessions + WHERE EXISTS (SELECT 1 FROM memory_items WHERE ${clauses.join(" AND ")}) + ORDER BY sessions.started_at DESC + LIMIT ?`, + ) + .all(...filterResult.params, limit) as Record[]; const items = rows.map((row) => ({ ...row, - metadata_json: fromJson(row.metadata_json), + metadata_json: fromJson((row.metadata_json as string | null | undefined) ?? null), })); return c.json({ items }); } @@ -238,12 +320,20 @@ export function memoryRoutes(getStore: StoreFactory) { app.get("/api/projects", (c) => { const store = getStore(); { - const d = drizzle(store.db, { schema }); - const rows = d - .selectDistinct({ project: schema.sessions.project }) - .from(schema.sessions) - .where(isNotNull(schema.sessions.project)) - .all(); + const filterResult = buildViewerMemoryFilters(store, null); + const clauses = [ + "memory_items.session_id = sessions.id", + "memory_items.active = 1", + "sessions.project IS NOT NULL", + ...filterResult.clauses, + ]; + const rows = store.db + .prepare( + `SELECT DISTINCT sessions.project AS project FROM sessions + JOIN memory_items ON memory_items.session_id = sessions.id + WHERE ${clauses.join(" AND ")}`, + ) + .all(...filterResult.params) as Record[]; const projects = [ ...new Set( rows @@ -329,11 +419,6 @@ export function memoryRoutes(getStore: StoreFactory) { const store = getStore(); { const project = c.req.query("project") || null; - const count = (sql: string, ...params: unknown[]): number => { - const row = store.db.prepare(sql).get(...params) as Record | undefined; - return Number(row?.total ?? 0); - }; - let prompts: number; let artifacts: number; let memories: number; @@ -355,24 +440,14 @@ export function memoryRoutes(getStore: StoreFactory) { return total; }; if (project) { - prompts = count("SELECT COUNT(*) AS total FROM user_prompts WHERE project = ?", project); - artifacts = count( - `SELECT COUNT(*) AS total FROM artifacts - JOIN sessions ON sessions.id = artifacts.session_id - WHERE sessions.project = ?`, - project, - ); - memories = count( - `SELECT COUNT(*) AS total FROM memory_items - JOIN sessions ON sessions.id = memory_items.session_id - WHERE sessions.project = ?`, - project, - ); + prompts = countVisiblePromptRows(store, project); + artifacts = countVisibleArtifactRows(store, project); + memories = countVisibleMemoryRows(store, { project }); observations = countObservations(project); } else { - prompts = count("SELECT COUNT(*) AS total FROM user_prompts"); - artifacts = count("SELECT COUNT(*) AS total FROM artifacts"); - memories = count("SELECT COUNT(*) AS total FROM memory_items"); + prompts = countVisiblePromptRows(store); + artifacts = countVisibleArtifactRows(store); + memories = countVisibleMemoryRows(store); observations = countObservations(); } const total = prompts + artifacts + memories; @@ -467,7 +542,7 @@ export function memoryRoutes(getStore: StoreFactory) { const limit = queryInt(c.req.query("limit"), 20); const kind = c.req.query("kind") || undefined; const project = c.req.query("project") || undefined; - const filters: Record = {}; + const filters: MemoryFilters = {}; if (kind) filters.kind = kind; if (project) filters.project = project; const items = store.recent(limit, filters); @@ -489,6 +564,9 @@ export function memoryRoutes(getStore: StoreFactory) { if (sessionId == null) { return c.json({ error: "session_id must be int" }, 400); } + if (!sessionAllowsArtifactAccess(store, sessionId)) { + return c.json({ error: "session not found" }, 404); + } const d = drizzle(store.db, { schema }); const rows = d .select() @@ -514,6 +592,9 @@ export function memoryRoutes(getStore: StoreFactory) { if (memoryId == null || memoryId <= 0) { return c.json({ error: "memory_id must be int" }, 400); } + if (!store.get(memoryId)) { + return c.json({ error: "memory not found" }, 404); + } const visibility = String(body.visibility ?? "").trim(); if (visibility !== "private" && visibility !== "shared") { return c.json({ error: "visibility must be private or shared" }, 400); @@ -548,6 +629,9 @@ export function memoryRoutes(getStore: StoreFactory) { if (!project) { return c.json({ error: "project must be a non-empty string" }, 400); } + if (!store.get(memoryId)) { + return c.json({ error: "memory not found" }, 404); + } try { const result = store.moveMemoryProject(memoryId, project); return c.json(result); @@ -574,6 +658,9 @@ export function memoryRoutes(getStore: StoreFactory) { if (memoryId == null || memoryId <= 0) { return c.json({ error: "memory_id must be int" }, 400); } + if (!store.get(memoryId)) { + return c.json({ error: "memory not found" }, 404); + } const row = drizzle(store.db, { schema }) .select() diff --git a/packages/viewer-server/src/routes/stats.ts b/packages/viewer-server/src/routes/stats.ts index bca5b346..1f5e68c3 100644 --- a/packages/viewer-server/src/routes/stats.ts +++ b/packages/viewer-server/src/routes/stats.ts @@ -4,7 +4,12 @@ * Ports Python's viewer_routes/stats.py. */ -import { listMaintenanceJobs, type MemoryStore, VERSION } from "@codemem/core"; +import { + buildFilterClausesWithContext, + listMaintenanceJobs, + type MemoryStore, + VERSION, +} from "@codemem/core"; import { Hono } from "hono"; function maintenanceJobSortKey(job: { @@ -26,6 +31,123 @@ function sortActiveMaintenanceJobs< }); } +const MEMORY_ID_METADATA_KEYS = ["pack_item_ids", "added_ids", "removed_ids", "retained_ids"]; + +type UsageRow = { + id: number; + session_id: number | null; + event: string; + tokens_read: number; + tokens_written: number; + tokens_saved: number; + created_at: string; + metadata_json: Record | null; +}; + +function toUsageRow( + row: Record, + metadata: Record | null, +): UsageRow { + return { + id: Number(row.id ?? 0), + session_id: row.session_id == null ? null : Number(row.session_id), + event: String(row.event ?? "pack"), + tokens_read: Number(row.tokens_read ?? 0), + tokens_written: Number(row.tokens_written ?? 0), + tokens_saved: Number(row.tokens_saved ?? 0), + created_at: String(row.created_at ?? ""), + metadata_json: metadata, + }; +} + +function visibleMemoryIds(store: MemoryStore, value: unknown): number[] { + if (!Array.isArray(value)) return []; + return value + .map((item) => (typeof item === "number" ? item : Number(item))) + .filter((item) => Number.isInteger(item) && store.get(item) != null); +} + +function sessionHasVisibleMemory(store: MemoryStore, sessionId: number): boolean { + const filterResult = buildFilterClausesWithContext( + { session_id: sessionId }, + { + actorId: store.actorId, + deviceId: store.deviceId, + enforceScopeVisibility: true, + }, + ); + const clauses = ["memory_items.active = 1", ...filterResult.clauses]; + const row = store.db + .prepare(`SELECT 1 AS found FROM memory_items WHERE ${clauses.join(" AND ")} LIMIT 1`) + .get(...filterResult.params) as Record | undefined; + return row != null; +} + +function usageRowVisible(store: MemoryStore, row: UsageRow): boolean { + const packItemIds = row.metadata_json?.pack_item_ids; + if (Array.isArray(packItemIds)) { + if (packItemIds.length === 0) { + return row.session_id != null && sessionHasVisibleMemory(store, row.session_id); + } + return visibleMemoryIds(store, packItemIds).length > 0; + } + return row.session_id != null && sessionHasVisibleMemory(store, row.session_id); +} + +function sanitizePackUsageMetadata( + store: MemoryStore, + metadata: Record | null, +): Record | null { + if (!metadata) return null; + const sanitized = { ...metadata }; + for (const key of MEMORY_ID_METADATA_KEYS) { + if (Array.isArray(sanitized[key])) { + sanitized[key] = visibleMemoryIds(store, sanitized[key]); + } + } + return sanitized; +} + +function summarizeUsageEvents(rows: UsageRow[]): Record[] { + const byEvent = new Map< + string, + { + event: string; + total_tokens_read: number; + total_tokens_written: number; + total_tokens_saved: number; + count: number; + } + >(); + for (const row of rows) { + const summary = byEvent.get(row.event) ?? { + event: row.event, + total_tokens_read: 0, + total_tokens_written: 0, + total_tokens_saved: 0, + count: 0, + }; + summary.total_tokens_read += row.tokens_read; + summary.total_tokens_written += row.tokens_written; + summary.total_tokens_saved += row.tokens_saved; + summary.count += 1; + byEvent.set(row.event, summary); + } + return [...byEvent.values()].sort((a, b) => a.event.localeCompare(b.event)); +} + +function totalUsageEvents(rows: UsageRow[]): Record { + return rows.reduce( + (acc, row) => ({ + tokens_read: Number(acc.tokens_read) + row.tokens_read, + tokens_written: Number(acc.tokens_written) + row.tokens_written, + tokens_saved: Number(acc.tokens_saved) + row.tokens_saved, + count: Number(acc.count) + 1, + }), + { tokens_read: 0, tokens_written: 0, tokens_saved: 0, count: 0 } as Record, + ); +} + /** * Create stats routes. The store factory is called per-request to get a * fresh connection (matching the Python viewer pattern). @@ -90,84 +212,44 @@ export function statsRoutes(getStore: () => MemoryStore) { const store = getStore(); { const projectFilter = c.req.query("project") || null; - const recentPacksQuery = projectFilter - ? store.db.prepare( - `SELECT usage_events.id, usage_events.session_id, usage_events.event, - usage_events.tokens_read, usage_events.tokens_written, usage_events.tokens_saved, - usage_events.created_at, usage_events.metadata_json - FROM usage_events - JOIN sessions ON sessions.id = usage_events.session_id - WHERE usage_events.event = 'pack' AND sessions.project = ? - ORDER BY usage_events.created_at DESC - LIMIT 10`, - ) - : store.db.prepare( - `SELECT id, session_id, event, tokens_read, tokens_written, tokens_saved, created_at, metadata_json - FROM usage_events - WHERE event = 'pack' - ORDER BY created_at DESC - LIMIT 10`, - ); - const eventsGlobal = store.db - .prepare( - `SELECT event, - SUM(tokens_read) AS total_tokens_read, - SUM(tokens_written) AS total_tokens_written, - SUM(tokens_saved) AS total_tokens_saved, - COUNT(*) AS count - FROM usage_events GROUP BY event ORDER BY event`, - ) - .all() as Record[]; - const totalsGlobal = store.db - .prepare( - `SELECT COALESCE(SUM(tokens_read),0) AS tokens_read, - COALESCE(SUM(tokens_written),0) AS tokens_written, - COALESCE(SUM(tokens_saved),0) AS tokens_saved, - COUNT(*) AS count - FROM usage_events`, - ) - .get() as Record; - let eventsFiltered: Record[] | null = null; - let totalsFiltered: Record | null = null; - if (projectFilter) { - eventsFiltered = store.db - .prepare( - `SELECT event, - SUM(tokens_read) AS total_tokens_read, - SUM(tokens_written) AS total_tokens_written, - SUM(tokens_saved) AS total_tokens_saved, - COUNT(*) AS count - FROM usage_events - JOIN sessions ON sessions.id = usage_events.session_id - WHERE sessions.project = ? - GROUP BY event ORDER BY event`, - ) - .all(projectFilter) as Record[]; - totalsFiltered = store.db - .prepare( - `SELECT COALESCE(SUM(tokens_read),0) AS tokens_read, - COALESCE(SUM(tokens_written),0) AS tokens_written, - COALESCE(SUM(tokens_saved),0) AS tokens_saved, - COUNT(*) AS count - FROM usage_events - JOIN sessions ON sessions.id = usage_events.session_id - WHERE sessions.project = ?`, - ) - .get(projectFilter) as Record; - } - const recentPacksRaw = ( - projectFilter ? recentPacksQuery.all(projectFilter) : recentPacksQuery.all() - ) as Record[]; - const recentPacks = recentPacksRaw.map((row) => ({ - id: Number(row.id ?? 0), - session_id: row.session_id == null ? null : Number(row.session_id), - event: String(row.event ?? "pack"), - tokens_read: Number(row.tokens_read ?? 0), - tokens_written: Number(row.tokens_written ?? 0), - tokens_saved: Number(row.tokens_saved ?? 0), - created_at: String(row.created_at ?? ""), - metadata_json: parseMetadataJson(row.metadata_json), - })); + const loadUsageRows = (project?: string | null): UsageRow[] => { + const rows = project + ? (store.db + .prepare( + `SELECT usage_events.id, usage_events.session_id, usage_events.event, + usage_events.tokens_read, usage_events.tokens_written, usage_events.tokens_saved, + usage_events.created_at, usage_events.metadata_json + FROM usage_events + JOIN sessions ON sessions.id = usage_events.session_id + WHERE sessions.project = ? + ORDER BY usage_events.created_at DESC`, + ) + .all(project) as Record[]) + : (store.db + .prepare( + `SELECT id, session_id, event, tokens_read, tokens_written, tokens_saved, + created_at, metadata_json + FROM usage_events + ORDER BY created_at DESC`, + ) + .all() as Record[]); + return rows + .map((row) => toUsageRow(row, parseMetadataJson(row.metadata_json))) + .filter((row) => usageRowVisible(store, row)); + }; + const globalRows = loadUsageRows(); + const filteredRows = projectFilter ? loadUsageRows(projectFilter) : null; + const eventsGlobal = summarizeUsageEvents(globalRows); + const totalsGlobal = totalUsageEvents(globalRows); + const eventsFiltered = filteredRows ? summarizeUsageEvents(filteredRows) : null; + const totalsFiltered = filteredRows ? totalUsageEvents(filteredRows) : null; + const recentPacks = (filteredRows ?? globalRows) + .filter((row) => row.event === "pack") + .slice(0, 10) + .map((row) => ({ + ...row, + metadata_json: sanitizePackUsageMetadata(store, row.metadata_json), + })); return c.json({ project: projectFilter,