Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 131 additions & 1 deletion packages/cli/src/commands/memory.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,49 @@
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,
rememberMemoryCommand,
showMemoryCommand,
} from "./memory.js";

vi.mock("../../../core/src/embeddings.js", async () => {
const actual = await vi.importActual<typeof import("../../../core/src/embeddings.js")>(
"../../../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([
Expand Down Expand Up @@ -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 });
}
});
});
41 changes: 40 additions & 1 deletion packages/cli/src/commands/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }));
Expand All @@ -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<void> {
const store = new MemoryStore(resolveDbPath(resolveDbOpt(opts)));
let sessionId: number | null = null;
Expand All @@ -131,8 +164,14 @@ async function rememberMemoryAction(opts: RememberMemoryOptions): Promise<void>
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 {
Expand Down
51 changes: 50 additions & 1 deletion packages/cli/src/commands/stats.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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();
}
});
});
12 changes: 12 additions & 0 deletions packages/core/src/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
50 changes: 35 additions & 15 deletions packages/core/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1010,35 +1010,55 @@ export class MemoryStore {
// biome-ignore lint/suspicious/noExplicitAny: Drizzle table union type is unwieldy
const countRows = (tbl: any) =>
this.d.select({ c: sql<number>`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<number>`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;
}
}
const vectorCoverage = activeMemories > 0 ? Math.min(1, vectorCount / activeMemories) : 0;

const tagsFilled =
this.d
.select({ c: sql<number>`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;
Expand Down
Loading