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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ ST-->>PL: pack text
PL->>OC: inject codemem context
```

**Retrieval** combines two strategies: keyword search via SQLite FTS5 with BM25 scoring and semantic similarity via sqlite-vec embeddings. In the pack-building path, results from both are merged, deduplicated, and re-ranked using recency and memory-kind boosts.
**Retrieval** combines two strategies: keyword search via SQLite FTS5 with BM25 scoring and semantic similarity via sqlite-vec embeddings. In the pack-building path, results from both are merged, exactly deduplicated, and re-ranked using recency and memory-kind boosts. Near-related memories stay fully rendered by default; use compact rendering or `CODEMEM_PACK_COMPRESSION=ids` only when you intentionally want ID-based expansion via `memory_get_observations`.

**Injection** happens automatically. The plugin builds a query from the current session context (first prompt, latest prompt, project, recently modified files), calls `build_memory_pack`, and appends the result to the system prompt via `experimental.chat.system.transform`.

Expand Down Expand Up @@ -142,6 +142,8 @@ For architecture details, see [docs/architecture.md](docs/architecture.md).

Run `codemem --help` for the full list. Legacy top-level aliases (`export-memories`, `import-memories`, `show`, `forget`, `remember`) still work but are hidden from help.

Pack rendering defaults to self-contained context. For token-constrained experiments, `codemem pack <context> --compact` renders an index plus top details. Near-related compression is controlled by `--compression-mode off|compact|ids` (or `CODEMEM_PACK_COMPRESSION`); MCP `memory_pack` exposes the same setting as `compression_mode`. Use `ids` only when the agent can follow up with `memory_get_observations`.

## MCP tools

To give the LLM direct access to memory tools (search, timeline, pack, remember, forget):
Expand Down
21 changes: 18 additions & 3 deletions packages/cli/src/commands/pack-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,11 @@ export function addPackRequestOptions(command: Command): Command {
.option("--project <project>", "project identifier (defaults to git repo root)")
.option("--all-projects", "search across all projects")
.option("--compact", "render a scannable index with full detail only for top N items")
.option("--compact-detail <n>", "items to show in full detail in compact mode (default 3)");
.option("--compact-detail <n>", "items to show in full detail in compact mode (default 3)")
.option(
"--compression-mode <mode>",
"near-related compression mode: off, compact, or ids (default: CODEMEM_PACK_COMPRESSION or compact)",
);
}

export function buildPackRequestOptions(
Expand All @@ -49,6 +53,7 @@ export function buildPackRequestOptions(
allProjects?: boolean;
compact?: boolean;
compactDetail?: string;
compressionMode?: string;
},
ctx: {
cwd?: string;
Expand Down Expand Up @@ -76,19 +81,29 @@ export function buildPackRequestOptions(
}

let renderOptions: PackRenderOptions | undefined;
if (opts.compact || opts.compactDetail != null) {
renderOptions = { compact: true };
if (opts.compact || opts.compactDetail != null || opts.compressionMode != null) {
renderOptions = {};
if (opts.compact || opts.compactDetail != null) renderOptions.compact = true;
if (opts.compactDetail != null) {
renderOptions.compactDetailCount = parseNonNegativeInt(
opts.compactDetail,
"compact detail count",
);
}
if (opts.compressionMode != null) {
renderOptions.compressionMode = parseCompressionMode(opts.compressionMode);
}
}

return { limit, budget, filters, renderOptions };
}

function parseCompressionMode(value: string): "off" | "compact" | "ids" {
const normalized = value.trim().toLowerCase();
if (normalized === "off" || normalized === "compact" || normalized === "ids") return normalized;
throw new PackUsageError("compression mode must be one of: off, compact, ids");
}

function parsePositiveInt(value: string, label: string): number {
if (!/^\d+$/.test(value.trim())) {
throw new PackUsageError(`${label} must be a positive integer`);
Expand Down
43 changes: 43 additions & 0 deletions packages/cli/src/commands/pack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,49 @@ describe("pack command", () => {
expect(JSON.parse(String(logSpy.mock.calls.at(-1)?.[0]))).toMatchObject({ items: [] });
});

it("passes explicit compression mode through main pack command", async () => {
buildMemoryPackAsync.mockResolvedValue({
items: [],
metrics: {
total_items: 0,
pack_tokens: 0,
fallback_used: false,
sources: { fts: 0, semantic: 0, fuzzy: 0 },
},
pack_text: "",
});

await parsePackCommand(["continue viewer health work", "--compression-mode", "ids"]);

expect(buildMemoryPackAsync).toHaveBeenCalledWith(
"continue viewer health work",
10,
undefined,
expect.any(Object),
{ compressionMode: "ids" },
);
});

it("rejects invalid compression mode", async () => {
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});

await parsePackCommand([
"continue viewer health work",
"--json",
"--compression-mode",
"banana",
]);

expect(JSON.parse(String(logSpy.mock.calls.at(-1)?.[0]))).toEqual({
error: "usage_error",
message: "compression mode must be one of: off, compact, ids",
});
expect(process.exitCode).toBe(2);
expect(errorSpy).not.toHaveBeenCalled();
expect(buildMemoryPackAsync).not.toHaveBeenCalled();
});

it("emits structured json errors for pack failures", async () => {
buildMemoryPackAsync.mockRejectedValue(new Error("pack blew up"));
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
Expand Down
125 changes: 116 additions & 9 deletions packages/core/src/pack.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 { afterEach, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { connect } from "./db.js";
import { buildMemoryPack, buildMemoryPackTrace, estimateTokens } from "./pack.js";
import { MemoryStore } from "./store.js";
Expand Down Expand Up @@ -1577,7 +1577,7 @@ describe("buildMemoryPack compact mode", () => {
expect(pack.pack_text).toContain("## Index");
expect(pack.pack_text).toContain("## Detail");
expect(pack.pack_text).toContain("memory_get");
expect(pack.pack_text).toContain("memory_search");
expect(pack.pack_text).toContain("memory_get_observations");
});

it("shows index lines for all items", () => {
Expand Down Expand Up @@ -1761,7 +1761,26 @@ describe("buildMemoryPack compact mode", () => {
0.7,
);

const pack = buildMemoryPack(store, "thread local memory store pooling mcp server", 10, 5);
const unbudgeted = buildMemoryPack(
store,
"thread local memory store pooling mcp server",
10,
null,
undefined,
undefined,
{ compressionMode: "ids" },
);
expect(unbudgeted.items.some((item) => (item.compressed_ids?.length ?? 0) > 0)).toBe(true);

const pack = buildMemoryPack(
store,
"thread local memory store pooling mcp server",
10,
5,
undefined,
undefined,
{ compressionMode: "ids" },
);

expect(pack.items).toHaveLength(0);
expect(pack.item_ids).toHaveLength(0);
Expand Down Expand Up @@ -1792,7 +1811,17 @@ describe("buildMemoryPack compact mode", () => {
0.9,
);

const pack = buildMemoryPack(store, "remember what happened previously", 10);
const pack = buildMemoryPack(
store,
"remember what happened previously",
10,
null,
undefined,
undefined,
{
compressionMode: "ids",
},
);

expect(pack.item_ids).toEqual(expect.arrayContaining([summaryId, idA, idB]));
expect(pack.items.find((item) => item.id === idB)?.compressed_ids).toEqual([idA]);
Expand All @@ -1819,11 +1848,12 @@ describe("buildMemoryPack cluster compression", () => {
});

afterEach(() => {
vi.unstubAllEnvs();
store.close();
rmSync(tmpDir, { recursive: true, force: true });
});

it("compresses related items into one representative in default mode", () => {
it("keeps related items self-contained in default mode", () => {
const idA = store.remember(
sessionId,
"discovery",
Expand All @@ -1841,7 +1871,42 @@ describe("buildMemoryPack cluster compression", () => {

const pack = buildMemoryPack(store, "sync pass orchestrator typescript", 10);

expect(pack.pack_text).toContain("(+1 related)");
expect(pack.pack_text).not.toContain("(+1 related)");
expect(pack.pack_text).toContain("Sync pass orchestrator moved from Python to TypeScript");
expect(pack.pack_text).toContain("Sync pass orchestrator ported to TypeScript");
expect(pack.item_ids).toEqual(expect.arrayContaining([idA, idB]));
expect(pack.items.map((item) => item.id)).toEqual(expect.arrayContaining([idA, idB]));
});

it("compresses related items into one representative when ids mode is requested", () => {
const idA = store.remember(
sessionId,
"discovery",
"Sync pass orchestrator ported to TypeScript",
"Moved the sync pass orchestrator from Python to TypeScript.",
0.6,
);
const idB = store.remember(
sessionId,
"feature",
"Sync pass orchestrator moved from Python to TypeScript",
"The orchestrator now coordinates sync in TypeScript.",
0.9,
);

const pack = buildMemoryPack(
store,
"sync pass orchestrator typescript",
10,
null,
undefined,
undefined,
{
compressionMode: "ids",
},
);

expect(pack.pack_text).toContain(`(+1 related: [${idA}])`);
expect(pack.pack_text).toContain("Sync pass orchestrator moved from Python to TypeScript");
expect(pack.pack_text).not.toContain("Sync pass orchestrator ported to TypeScript -");
expect(pack.item_ids).toEqual(expect.arrayContaining([idA, idB]));
Expand All @@ -1853,6 +1918,29 @@ describe("buildMemoryPack cluster compression", () => {
});
});

it("uses CODEMEM_PACK_COMPRESSION to select ids compression mode", () => {
const idA = store.remember(
sessionId,
"change",
"Replication retention plan updated for bounded history",
"Updated the retention plan.",
0.4,
);
store.remember(
sessionId,
"decision",
"Replication retention plan revised for bounded history",
"Revised retention plan and documented bounds.",
0.95,
);

vi.stubEnv("CODEMEM_PACK_COMPRESSION", "ids");
const pack = buildMemoryPack(store, "replication retention bounded history", 10);

expect(pack.pack_text).toContain(`(+1 related: [${idA}])`);
expect(pack.items[0]?.compressed_ids).toEqual([idA]);
});

it("chooses highest-confidence representative on clustered items", () => {
const idLow = store.remember(
sessionId,
Expand All @@ -1869,7 +1957,17 @@ describe("buildMemoryPack cluster compression", () => {
0.95,
);

const pack = buildMemoryPack(store, "replication retention bounded history", 10);
const pack = buildMemoryPack(
store,
"replication retention bounded history",
10,
null,
undefined,
undefined,
{
compressionMode: "ids",
},
);
expect(pack.items[0]?.id).toBe(idHigh);
expect(pack.items[0]?.compressed_ids).toEqual([idLow]);
});
Expand Down Expand Up @@ -1925,7 +2023,8 @@ describe("buildMemoryPack cluster compression", () => {
},
);

expect(pack.pack_text).toContain("(+1 related)");
expect(pack.pack_text).toContain(`(+1 related: [${idA}])`);
expect(pack.pack_text).toContain("memory_get_observations");
expect(pack.item_ids).toEqual(expect.arrayContaining([idA, idB]));
expect(pack.items).toHaveLength(1);
});
Expand Down Expand Up @@ -1990,7 +2089,15 @@ describe("buildMemoryPack cluster compression", () => {
0.9,
);

const trace = buildMemoryPackTrace(store, "peer deletion cursor leak sync worker", 10);
const trace = buildMemoryPackTrace(
store,
"peer deletion cursor leak sync worker",
10,
null,
undefined,
undefined,
{ compressionMode: "ids" },
);

expect(trace.assembly.compressed_clusters).toHaveLength(1);
expect(trace.assembly.compressed_clusters[0]).toMatchObject({
Expand Down
Loading