diff --git a/CLAUDE.md b/CLAUDE.md index f4c4ad6..191cec8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -48,6 +48,8 @@ Two harness details are load-bearing rather than incidental: **The indexing hook and a doctor check are coupled through a file.** `sync-memories.mts` writes `.claude/.kb-index/memory-loop.log` when a run fails or leaves documents unembedded, and deletes it on success; the "Memory indexing completed" check reports the file's existence. Move or rename it on one side and the check passes forever without testing anything. +**Memory retrievals are recorded in `.claude/memories/.usage.tsv`, and the file is a contract with the shared-memories pack.** A `get`, `multi_get` or `Read` of a memory sets its row to today's UTC date. The file has a `# since:` header and one line per memory: `\t`. It lives inside `memories/` so it travels with the memories however a team shares them. Shared-memories allowlists the path in its naming guardrail and merges it with git's `union` driver. Union keeps both sides' lines, so the format must stay correct under any mix of lines: readers take the latest date per file, the earliest `since`, and drop orphan, malformed and future-dated rows. Retrievals only ever **append** a line, and only when the memory's latest row is not already today, because a read-merge-rewrite lost a third of the rows when parallel `get` calls ran together. Compaction to the normalized form runs on `UserPromptSubmit`, and only once the file holds more than `max(USAGE_COMPACT_FLOOR, 2 × distinct memories)` lines, so a compaction commit is rare. Before renaming, it carries over rows appended while it ran. The hook is the only writer, and the audit must never edit the file. Reads in a session where memory-audit ran, or in a turn where continuous-learning ran, are not counted, since those reads come from reviewing or saving memories, not from using one. The data has no consumer yet: no audit rule acts on it until enough has been collected to choose one. + **The index is reached by `--index`, never by a project-local `.qmd/`.** Two reasons, and the second is the dangerous one. A user may keep their own `.qmd/` at the project root for their own code, which this pack must not touch. And a project-local `.qmd/index.yml` falls under qmd's trust gate, which covers a non-default `models.embed` — for a non-interactive caller the gate does not prompt or fail, it *skips*, silently substituting a much weaker default model. Named indexes are never gated. `QMD_CONFIG_DIR` and `INDEX_PATH` are what move a named index back under the project directory. **Reranking and query expansion are disabled by pointing their model slots at the embedding model.** The MCP `query` tool hard-defaults `rerank: true` with no server-side way to turn it off, and a *missing* model is downloaded mid-query with no progress output. An embedding model has no ranking head, so qmd fails to build a ranking context, warns, and falls back to RRF — measured at MRR 0.792 against 0.800 for an explicit `rerank: false`, and it buys back zero R@5 versus a real reranker. Those numbers come from a 20-query fixture over this project's own memories, kept outside the repo — nothing here reproduces them, so treat them as recorded measurements rather than something CI re-checks. The fallback is not a *strict* no-op: on a later fixture, a document RRF ranked 9th was dropped entirely when `rerank: true` was passed. Top ranks were unaffected, which is why this is a safe structural disable, but do not describe it as "reranking is off and nothing changes". diff --git a/README.md b/README.md index e9666b8..67ba6a2 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,12 @@ memory/ **[shared-memories](https://github.com/mcs-cli/shared-memories)** extends The Memory Loop by syncing `.claude/memories/` across teammates through a dedicated Git repository. Install both for team-shared memory: `mcs-cli/memory` captures and retrieves knowledge, while `shared-memories` distributes it. +The loop records the day each memory was last retrieved in `.claude/memories/.usage.tsv`, which travels with the memories. `shared-memories` sets up its merge rule for you. If your team commits `.claude/memories/` in the project repo instead, add this line to `.gitattributes` so concurrent updates merge without conflicts: + +``` +.claude/memories/.usage.tsv merge=union +``` + ## You might also like | Pack | Description | diff --git a/hooks/kb-gate.mts b/hooks/kb-gate.mts index 88aadba..6f7a6c5 100755 --- a/hooks/kb-gate.mts +++ b/hooks/kb-gate.mts @@ -16,6 +16,12 @@ const MEMORY_WRITE_TOOLS = ["Write", "Edit", "NotebookEdit"]; const MAX_MEMORY_WRITE_DENIALS_PER_TURN = 3; const MEMORY_DELETE = /(?:^|[^\w-])(?:rm|unlink|mv)(?=\s)/; const SLASH_SKILL = new RegExp(`(?:^|[\\s>])/(?:[\\w-]+:)?(${Object.keys(MEMORY_SKILL_SCOPES).join("|")})\\b`, "g"); +const RETRIEVAL_TOOLS = ["mcp__memory-loop__get", "mcp__memory-loop__multi_get", "Read"]; +// qmd heads every returned document with this line, which is the only place a docid or glob resolves to a file. +const RETRIEVED_RESOURCE = /\[Resource from memory-loop at qmd:\/\/memories\/([^\]\s]+\.md)\]/g; +const DAY = /^\d{4}-\d{2}-\d{2}$/; +const USAGE_COMPACT_FLOOR = 50; +const TODAY = new Date().toISOString().slice(0, 10); function memorySkill(name: unknown): string | undefined { const skill = String(name ?? "").replace(/^\//, "").split(":").at(-1) ?? ""; @@ -26,9 +32,75 @@ function realPath(path: string): string { return attempt(() => realpathSync(path)) ?? join(attempt(() => realpathSync(dirname(path))) ?? dirname(path), basename(path)); } +function mentionsMemories(path: string): boolean { + const lexical = resolve(path); + return lexical.includes("memories") || realPath(lexical).includes("memories"); +} + +function readIfExists(file: string): string | undefined { + return existsSync(file) ? attempt(() => read(file)) : ""; +} + +function rows(file: string): string[][] | undefined { + return readIfExists(file)?.split("\n").filter(Boolean).map(line => line.split("\t")); +} + +function isMemory(memories: string, name: string): boolean { + return name.endsWith(".md") && attempt(() => statSync(`${memories}/${name}`).isFile()) === true; +} + +function parseUsage(content: string): { since: string; dates: Map; lines: number } { + const dates = new Map(); + let since = ""; + const lines = content.split("\n").filter(Boolean); + for (const [first, date, ...rest] of lines.map(line => line.split("\t"))) { + const header = /^# since: (\S+)$/.exec(first!)?.[1]; + if (header && DAY.test(header) && header <= TODAY) { + if (!since || header < since) since = header; + continue; + } + // A future date would pin a memory as fresh forever, so a skewed clock's row is dropped, not trusted. + if (!date || rest.length > 0 || !DAY.test(date) || date > TODAY) continue; + if (date > (dates.get(first!) ?? "")) dates.set(first!, date); + } + return { since, dates, lines: lines.length }; +} + +function recordRetrievals(usage: string, retrieved: string[]): string[] { + const content = readIfExists(usage) ?? ""; + const { dates } = parseUsage(content); + const fresh = retrieved.filter(name => dates.get(name) !== TODAY); + if (fresh.length === 0) return fresh; + const header = content ? (content.endsWith("\n") ? "" : "\n") : `# since: ${TODAY}\n`; + // One append per hook, never a rewrite, so concurrent hooks cannot erase each other's rows. + appendFileSync(usage, `${header}${fresh.map(name => `${name}\t${TODAY}\n`).join("")}`); + return fresh; +} + +function compactUsage(usage: string, memories: string, state: string): void { + const content = readIfExists(usage); + if (!content || content.split("\n").length <= USAGE_COMPACT_FLOOR) return; + const { since, dates, lines } = parseUsage(content); + if (lines <= Math.max(USAGE_COMPACT_FLOOR, 2 * dates.size)) return; + const kept = [...dates.keys()].filter(name => isMemory(memories, name)).sort(); + const compacted = `# since: ${since || TODAY}\n${kept.map(name => `${name}\t${dates.get(name)}\n`).join("")}`; + // Staged outside memories/ so the shared-memories autopush never stages a half-written temp file. + const temporary = `${state}/usage.${process.pid}.tmp`; + try { + // Rows appended while this ran are carried over; a file rewritten under us is left for the next turn. + const latest = read(usage); + if (!latest.startsWith(content)) return; + writeFileSync(temporary, compacted + latest.slice(content.length)); + renameSync(temporary, usage); + } finally { + attempt(() => rmSync(temporary, { force: true })); + } +} + function gate(): void { if (MODE === "off") return; - const payload = JSON.parse(read(0)); + const stdin = read(0); + const payload = JSON.parse(stdin); const event = payload.hook_event_name || ""; const session = String(payload.session_id ?? "unknown").replace(/[^A-Za-z0-9._-]/g, "_"); const agentId = payload.agent_id || ""; @@ -38,15 +110,17 @@ function gate(): void { if (!["UserPromptSubmit", "PostToolUse", "PreToolUse", "SubagentStart"].includes(event)) return; const isSkillCall = event === "PostToolUse" && payload.tool_name === "Skill"; if (isSkillCall && !memorySkill(payload.tool_input?.skill)) return; + const isRetrieval = event === "PostToolUse" && RETRIEVAL_TOOLS.includes(payload.tool_name); const isMemoryWrite = event === "PreToolUse" && MEMORY_WRITE_TOOLS.includes(payload.tool_name); const target = payload.tool_input?.file_path || payload.tool_input?.notebook_path || ""; - if (isMemoryWrite && !`${resolve(target)}\n${realPath(resolve(target))}`.includes("memories")) return; + if (target && !mentionsMemories(target)) return; const command = String(payload.tool_input?.command ?? ""); const isMemoryDelete = event === "PreToolUse" && payload.tool_name === "Bash" && command.includes("memories") && MEMORY_DELETE.test(command); if (event === "PreToolUse" && payload.tool_name === "Bash" && !isMemoryDelete) return; const root = projectRoot(); const memories = `${root}/.claude/memories`; + const usage = `${memories}/.usage.tsv`; const state = `${root}/.claude/.kb-gate`; const log = `${root}/.claude/.kb-gate.log`; const turnFile = `${state}/${session}.turn`; @@ -64,11 +138,6 @@ function gate(): void { process.stdout.write(`${JSON.stringify({ hookSpecificOutput: { hookEventName: event, ...fields } })}\n`); } - function rows(file: string): string[][] | undefined { - const content = existsSync(file) ? attempt(() => read(file)) : ""; - return content?.split("\n").filter(Boolean).map(line => line.split("\t")); - } - function denials(phase: string): string[][] | undefined { return rows(denialsFile)?.filter(fields => Number(fields[0]) === turn && fields[1] === phase); } @@ -91,6 +160,18 @@ function gate(): void { } } + function activeMemorySkill(includeTurnScoped: boolean): string | undefined { + return rows(skillsFile)?.find(([at, name]) => MEMORY_SKILL_SCOPES[name!] === "session" || (includeTurnScoped && MEMORY_SKILL_SCOPES[name!] === "turn" && Number(at) === turn))?.[1]; + } + + function memoryName(path: string): string | undefined { + const lexical = resolve(root, path); + if (lexical.startsWith(`${memories}/`)) return lexical.slice(memories.length + 1); + const real = realPath(lexical); + const memoriesReal = attempt(() => realpathSync(memories)) ?? memories; + return real.startsWith(`${memoriesReal}/`) ? real.slice(memoriesReal.length + 1) : undefined; + } + if (event === "UserPromptSubmit") { mkdirSync(state, { recursive: true }); if (turn === 0) attempt(() => sweep(state)); @@ -102,6 +183,7 @@ function gate(): void { renameSync(`${state}/log.tmp`, log); }); attempt(() => rmSync(`${state}/log.tmp`, { force: true })); + attempt(() => compactUsage(usage, memories, state)); attempt(() => writeFileSync(denialsFile, "")); attempt(() => writeFileSync(turnFile, `${turn + 1}\n`)); logEvent({ decision: "turn_start", turn: turn + 1 }); @@ -119,6 +201,23 @@ function gate(): void { return; } + if (isRetrieval) { + if (!isDirectory(memories)) return; + const names = target + ? [memoryName(target)] + : [...stdin.matchAll(RETRIEVED_RESOURCE)].map(match => match[1]); + const retrieved = [...new Set(names)].filter((name): name is string => !!name && isMemory(memories, name)); + if (retrieved.length === 0) return; + const skill = activeMemorySkill(true); + if (skill) { + logEvent({ tool: payload.tool_name, decision: "skip", skip_reason: "memory_skill", skill }); + return; + } + const fresh = recordRetrievals(usage, retrieved); + if (fresh.length > 0) logEvent({ tool: payload.tool_name, decision: "retrieved", files: fresh }); + return; + } + if (event === "PostToolUse") { const input = payload.tool_input; const query = (input?.query || (input?.searches ?? []).map((search: { query?: string }) => search.query ?? "").join(" ")).replace(/\n+$/, ""); @@ -157,11 +256,8 @@ mcp__memory-loop__query. const phase = isMemoryWrite ? "memory_write" : "memory_delete"; let subject = command.slice(0, 200); if (isMemoryWrite) { - const lexical = resolve(root, target); - subject = realPath(lexical); - const memoriesReal = attempt(() => realpathSync(memories)) ?? memories; - const inside = (path: string, directory: string) => path.startsWith(`${directory}/`); - if (!inside(lexical, memories) && !inside(subject, memoriesReal)) return; + subject = realPath(resolve(root, target)); + if (memoryName(target) === undefined) return; } function logMemory(decision: string, extra = {}): void { logEvent({ phase, tool: payload.tool_name, target: subject, agent_type: agentType, decision, ...extra }); @@ -171,7 +267,7 @@ mcp__memory-loop__query. output({ permissionDecision: "deny", permissionDecisionReason: "Sub-agents cannot change .claude/memories/. Report the proposed memory change back instead; the main thread applies it through the continuous-learning or memory-audit skill, which run the search, pre-write and approval checks." }); return; } - const skill = rows(skillsFile)?.find(([at, name]) => MEMORY_SKILL_SCOPES[name!] === "session" || (isMemoryWrite && MEMORY_SKILL_SCOPES[name!] === "turn" && Number(at) === turn))?.[1]; + const skill = activeMemorySkill(isMemoryWrite); if (skill) { logMemory("allow", { skill }); return; diff --git a/techpack.yaml b/techpack.yaml index 14eff03..6530de3 100644 --- a/techpack.yaml +++ b/techpack.yaml @@ -319,6 +319,18 @@ components: source: hooks/kb-gate.mts destination: kb-gate.mts + - id: hook-kb-gate-retrieval + displayName: KB gate — record memory retrievals + description: Records the day each memory was last retrieved in .claude/memories/.usage.tsv, so a team can see which memories are still read + hookInterpreter: node --experimental-strip-types --disable-warning=ExperimentalWarning + hookEvent: PostToolUse + hookMatcher: "mcp__memory-loop__get|mcp__memory-loop__multi_get|Read" + # Every Read starts this hook, and it never needs to block, so it stays off the turn's critical path. + hookAsync: true + hook: + source: hooks/kb-gate.mts + destination: kb-gate.mts + - id: hook-kb-gate-memory-write displayName: KB gate — check memory writes description: Blocks writes, deletes and moves in .claude/memories/ unless a memory skill is active diff --git a/tests/contracts.test.mts b/tests/contracts.test.mts index ad7c964..f76ab48 100644 --- a/tests/contracts.test.mts +++ b/tests/contracts.test.mts @@ -23,7 +23,7 @@ for (const type of ["commonjs", "module"]) { const f = fixture(t); writeFileSync(`${f.project}/package.json`, JSON.stringify({ type })); const hooks = manifest.split(/^ - id: /m).filter(component => /\n hookEvent:/.test(component)); - assert.equal(hooks.length, 9); + assert.equal(hooks.length, 10); assert.match(manifest, /hook:\n source: hooks\/shared.mts\n destination: shared.mts/); const installed = `${f.project}/.claude/hooks/memory`; mkdirSync(installed, { recursive: true }); @@ -42,8 +42,9 @@ for (const type of ["commonjs", "module"]) { assert.match(manifest, /hookMatcher: "Agent\|Task"/); assert.match(manifest, /hookMatcher: "mcp__memory-loop__query"/); assert.match(manifest, /hookMatcher: "Skill"/); + assert.match(manifest, /hookMatcher: "mcp__memory-loop__get\|mcp__memory-loop__multi_get\|Read"/); assert.match(manifest, /hookMatcher: "Write\|Edit\|NotebookEdit\|Bash"/); - assert.equal((manifest.match(/hookAsync: true/g) ?? []).length, 2); + assert.equal((manifest.match(/hookAsync: true/g) ?? []).length, 3); assert.equal((manifest.match(/hookTimeout: 120/g) ?? []).length, 2); }); } diff --git a/tests/helpers.mts b/tests/helpers.mts index 6d0595c..59f1062 100644 --- a/tests/helpers.mts +++ b/tests/helpers.mts @@ -2,7 +2,7 @@ import assert from "node:assert/strict"; import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { resolve } from "node:path"; -import { spawnSync } from "node:child_process"; +import { spawn as spawnProcess, spawnSync } from "node:child_process"; import type { TestContext } from "node:test"; export const root = resolve(import.meta.dirname, ".."); @@ -27,7 +27,20 @@ export function fixture(t: TestContext) { assert.equal(result.stderr, ""); return result.stdout; }; - return { work, project, env, run }; + const runAsync = (script: string, payload: unknown) => new Promise<{ status: number | null; stdout: string; stderr: string }>(done => { + const child = spawnProcess(process.execPath, [...flags, script], { cwd: project, env }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", chunk => (stdout += chunk)); + child.stderr.on("data", chunk => (stderr += chunk)); + child.on("close", status => done({ status, stdout, stderr })); + child.stdin.end(JSON.stringify(payload)); + }).then(result => { + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ""); + return result.stdout; + }); + return { work, project, env, run, runAsync }; } export function gateFixture(t: TestContext, mode = "enforce") { @@ -36,15 +49,23 @@ export function gateFixture(t: TestContext, mode = "enforce") { writeFileSync(script, readFileSync(script, "utf8").replace("__KB_GATE_MODE__", mode)); const state = `${f.project}/.claude/.kb-gate`; const log = `${f.project}/.claude/.kb-gate.log`; - const event = (hook_event_name: string, extra = {}) => f.run(script, { hook_event_name, session_id: "session", ...extra }); + const payload = (hook_event_name: string, extra = {}) => ({ hook_event_name, session_id: "session", ...extra }); + const event = (hook_event_name: string, extra = {}) => f.run(script, payload(hook_event_name, extra)); const turn = (prompt = "do the thing") => event("UserPromptSubmit", { prompt }); const search = (input: unknown = { query: "some topic" }) => event("PostToolUse", { tool_input: input }); const spawn = (prompt = "find the thing", subagent_type = "Explore", agent_id = "") => event("PreToolUse", { agent_id, tool_input: { subagent_type, prompt } }); const skill = (name = "continuous-learning") => event("PostToolUse", { tool_name: "Skill", tool_input: { skill: name } }); const write = ({ file_path = `${f.project}/.claude/memories/learning_x.md`, agent_id = "", tool_name = "Write" } = {}) => event("PreToolUse", { tool_name, agent_id, tool_input: { file_path, content: "x" } }); const bash = (command: string, agent_id = "") => event("PreToolUse", { tool_name: "Bash", agent_id, tool_input: { command } }); + const retrieval = (names: string[], tool_name = "mcp__memory-loop__get") => ({ tool_name, tool_input: { file: "#abc123" }, tool_response: names.map(name => ({ type: "text", text: `[Resource from memory-loop at qmd://memories/${name}] \n\n1: # Title` })) }); + const retrieve = (names: string[], tool_name?: string) => event("PostToolUse", retrieval(names, tool_name)); + const retrieveConcurrently = (names: string[]) => f.runAsync(script, payload("PostToolUse", retrieval(names))); + const readMemory = (file_path: string) => event("PostToolUse", { tool_name: "Read", tool_input: { file_path }, tool_response: {} }); + const memories = (...names: string[]) => names.forEach(name => writeFileSync(`${f.project}/.claude/memories/${name}`, "# x\n")); + const usagePath = `${f.project}/.claude/memories/.usage.tsv`; + const usage = () => readFileSync(usagePath, "utf8"); const lastLog = () => JSON.parse(readFileSync(log, "utf8").trim().split("\n").at(-1)!); - return { ...f, script, state, log, event, turn, search, spawn, skill, write, bash, lastLog }; + return { ...f, script, state, log, event, turn, search, spawn, skill, write, bash, retrieve, retrieveConcurrently, readMemory, memories, usagePath, usage, lastLog }; } export function denied(output: string): { permissionDecisionReason: string } { diff --git a/tests/kb-gate.test.mts b/tests/kb-gate.test.mts index 581f04e..9b5b7ba 100644 --- a/tests/kb-gate.test.mts +++ b/tests/kb-gate.test.mts @@ -240,11 +240,14 @@ for (const mode of ["warn", "observe", "__KB_GATE_MODE__"]) { }); } -test("off skips memory writes too", t => { +test("off skips memory writes and records no usage", t => { const f = gateFixture(t, "off"); + f.memories("learning_a.md"); f.turn(); assert.equal(f.write(), ""); + f.retrieve(["learning_a.md"]); assert.equal(existsSync(f.state), false); + assert.equal(existsSync(f.usagePath), false); }); test("deleting or moving memories through Bash needs memory-audit", t => { @@ -279,3 +282,86 @@ test("sub-agents cannot delete memories even under memory-audit", t => { denied(f.bash("rm .claude/memories/learning_x.md", "agent-3")); assert.equal(f.lastLog().skip_reason, "subagent"); }); + +const today = () => new Date().toISOString().slice(0, 10); + +test("get, multi_get and Read record the day each existing memory was retrieved", t => { + const f = gateFixture(t); + f.memories("learning_a.md", "decision_b.md", "learning_c.md"); + f.turn(); + f.retrieve(["learning_a.md"]); + f.retrieve(["decision_b.md", "learning_missing.md"], "mcp__memory-loop__multi_get"); + f.readMemory(`${f.project}/.claude/memories/learning_c.md`); + const recorded = `# since: ${today()}\nlearning_a.md\t${today()}\ndecision_b.md\t${today()}\nlearning_c.md\t${today()}\n`; + assert.equal(f.usage(), recorded); + assert.deepEqual(f.lastLog().files, ["learning_c.md"]); + f.retrieve(["learning_a.md"]); + f.readMemory(f.usagePath); + f.readMemory(`${f.project}/src/learning_a.md`); + assert.equal(f.usage(), recorded); +}); + +test("reads through a symlinked memories dir count under either path", t => { + const f = gateFixture(t); + rmSync(`${f.project}/.claude/memories`, { recursive: true }); + mkdirSync(`${f.work}/repo/memories`, { recursive: true }); + symlinkSync(`${f.work}/repo/memories`, `${f.project}/.claude/memories`); + f.memories("learning_a.md", "learning_b.md"); + f.turn(); + f.readMemory(`${f.work}/repo/memories/learning_a.md`); + f.readMemory(`${f.project}/.claude/memories/learning_b.md`); + assert.equal(readFileSync(`${f.work}/repo/memories/.usage.tsv`, "utf8"), `# since: ${today()}\nlearning_a.md\t${today()}\nlearning_b.md\t${today()}\n`); +}); + +test("reads during an audit session or a capture turn are not usage", t => { + const f = gateFixture(t); + f.memories("learning_a.md"); + f.turn(); + f.skill(); + f.retrieve(["learning_a.md"]); + assert.equal(existsSync(f.usagePath), false); + assert.equal(f.lastLog().skip_reason, "memory_skill"); + f.turn(); + f.retrieve(["learning_a.md"]); + assert.equal(f.usage(), `# since: ${today()}\nlearning_a.md\t${today()}\n`); + rmSync(f.usagePath); + f.turn("/memory-audit"); + f.turn("approved"); + f.retrieve(["learning_a.md"]); + assert.equal(existsSync(f.usagePath), false); +}); + +test("a retrieval appends to a union-merged file without rewriting it", t => { + const f = gateFixture(t); + f.memories("learning_a.md", "learning_c.md"); + const merged = "# since: 2026-01-10\nlearning_a.md\t2026-02-01\n# since: 2025-12-01\nlearning_a.md\t2026-03-05\ngarbage line\nlearning_c.md\t2026-01-01"; + writeFileSync(f.usagePath, merged); + f.turn(); + f.retrieve(["learning_c.md"]); + assert.equal(f.usage(), `${merged}\nlearning_c.md\t${today()}\n`); +}); + +test("prompt submit compacts a usage file past its threshold and leaves a small one alone", t => { + const f = gateFixture(t); + f.memories("learning_a.md", "learning_b.md", "learning_c.md"); + const small = "# since: 2026-01-10\nlearning_a.md\t2026-02-01\nlearning_a.md\t2026-03-05\n"; + writeFileSync(f.usagePath, small); + f.turn(); + assert.equal(f.usage(), small); + const history = Array.from({ length: 60 }, (_, i) => `learning_a.md\t2026-02-${String((i % 28) + 1).padStart(2, "0")}\n`).join(""); + writeFileSync(f.usagePath, `# since: 2026-01-10\n${history}# since: 2025-12-01\nlearning_gone.md\t2026-03-01\nlearning_b.md\t2999-01-01\ngarbage line\nlearning_c.md\t2026-01-01\n`); + f.turn(); + assert.equal(f.usage(), "# since: 2025-12-01\nlearning_a.md\t2026-02-28\nlearning_c.md\t2026-01-01\n"); + assert.equal(readdirSync(f.state).some(name => name.endsWith(".tmp")), false); +}); + +test("concurrent retrievals record every memory", async t => { + const f = gateFixture(t); + const names = Array.from({ length: 12 }, (_, i) => `learning_${String(i).padStart(2, "0")}.md`); + f.memories(...names); + f.turn(); + await Promise.all(names.map(name => f.retrieveConcurrently([name]))); + const lines = f.usage().split("\n").filter(Boolean); + assert.ok(lines.filter(line => line.startsWith("#")).every(line => line === `# since: ${today()}`)); + assert.deepEqual(lines.filter(line => !line.startsWith("#")).sort(), names.map(name => `${name}\t${today()}`)); +});