@crewhaus/memory-store 0.2.4 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -1,26 +1,110 @@
1
1
  import { CrewhausError } from "@crewhaus/errors";
2
2
  export declare const DEFAULT_ROOT_DIR = ".crewhaus/memories";
3
+ /** Schema version stamped on every new write. Absent = v1 (read lazily). */
4
+ export declare const MEMORY_SCHEMA_VERSION = 2;
5
+ /** Where a memory came from, and which tool runs prove it (toolUseIds). */
6
+ export type MemoryProvenance = {
7
+ readonly sessionId?: string;
8
+ /** toolUseIds from the source session's event log — proof the fact is
9
+ * grounded in tool results, not just assistant narration. */
10
+ readonly evidence?: readonly string[];
11
+ };
3
12
  export type MemoryEntry = {
4
13
  readonly id: string;
5
14
  readonly text: string;
6
15
  readonly tags: readonly string[];
7
16
  readonly createdAt: string;
17
+ /** 2 on new writes; absent = v1 entry (read untouched). */
18
+ readonly schemaVersion?: number;
19
+ /** Epoch ms after which the entry is expired (filtered from recall). */
20
+ readonly expiresAt?: number;
21
+ /** Entry id that supersedes this one (folded from tombstones at read). */
22
+ readonly supersededBy?: string;
23
+ readonly provenance?: MemoryProvenance;
24
+ };
25
+ /** Lifecycle status of an entry, materialized at read time. */
26
+ export type MemoryEntryStatus = "live" | "superseded" | "expired";
27
+ export type MemoryListItem = {
28
+ readonly entry: MemoryEntry;
29
+ readonly status: MemoryEntryStatus;
8
30
  };
9
31
  export type MemoryRecallResult = {
10
32
  readonly entry: MemoryEntry;
33
+ /** BM25 score (no embedder) or fused RRF score (hybrid recall). */
11
34
  readonly score: number;
12
35
  };
36
+ /**
37
+ * Minimal structural interface for the hybrid-recall embedder. The
38
+ * `@crewhaus/embedder` package's `Embedder` satisfies it (use
39
+ * `createEmbedder({ model: "mock/deterministic" })` for offline tests).
40
+ * Declared structurally here so memory-store keeps zero runtime deps.
41
+ */
42
+ export interface MemoryEmbedder {
43
+ embed(texts: ReadonlyArray<string>): Promise<number[][]>;
44
+ }
13
45
  export interface MemoryStoreOptions {
14
46
  readonly rootDir?: string;
15
47
  readonly specName: string;
16
48
  readonly now?: () => Date;
49
+ /**
50
+ * Optional embedder enabling hybrid BM25 + embedding recall (RRF).
51
+ * Absent → BM25-only ranking, byte-identical to the pre-v2 behavior.
52
+ */
53
+ readonly embedder?: MemoryEmbedder;
17
54
  }
55
+ export type RememberOptions = {
56
+ /** Time-to-live in ms; the entry expires at `now + ttlMs`. */
57
+ readonly ttlMs?: number;
58
+ readonly provenance?: MemoryProvenance;
59
+ };
60
+ export type ForgetOptions = {
61
+ /** Recorded on the tombstone line for later audit. */
62
+ readonly reason?: string;
63
+ };
64
+ export type SweepResult = {
65
+ /** Entries newly tombstoned as expired by this sweep. */
66
+ readonly swept: number;
67
+ /** Live entries remaining after the sweep. */
68
+ readonly live: number;
69
+ };
70
+ export type CompactResult = {
71
+ /** Lines kept (live entries + preserved unknown lines). */
72
+ readonly kept: number;
73
+ /** Lines dropped (dead entries, tombstones, unparseable lines). */
74
+ readonly dropped: number;
75
+ };
18
76
  export interface MemoryStore {
19
77
  /** Append a new memory. Returns the assigned entry. */
20
- remember(text: string, tags?: readonly string[]): Promise<MemoryEntry>;
21
- /** Top-k matches for the query, ranked by BM25-style score. */
78
+ remember(text: string, tags?: readonly string[], opts?: RememberOptions): Promise<MemoryEntry>;
79
+ /** Top-k matches for the query. Superseded/expired entries are filtered.
80
+ * BM25-ranked without an embedder; hybrid RRF-ranked with one. */
22
81
  recall(query: string, k?: number): Promise<readonly MemoryRecallResult[]>;
23
- /** Diagnostic: how many entries are stored. */
82
+ /**
83
+ * Explicit forgetting. `idOrQuery` that looks like an entry id
84
+ * (`mem_<16hex>`) tombstones exactly that entry (no text fallback — a
85
+ * missing id forgets nothing); anything else is a query and tombstones
86
+ * every live entry with a positive BM25 match (the same match set
87
+ * `recall(query, Infinity)` would return). The file stays append-only:
88
+ * a supersede tombstone line is appended per forgotten entry. Returns
89
+ * the entries that were forgotten.
90
+ */
91
+ forget(idOrQuery: string, opts?: ForgetOptions): Promise<readonly MemoryEntry[]>;
92
+ /**
93
+ * TTL sweep: appends an `expired` tombstone for every live entry whose
94
+ * `expiresAt` has passed. Deterministic and idempotent — re-running at
95
+ * the same time appends nothing new.
96
+ */
97
+ sweep(nowMs?: number): Promise<SweepResult>;
98
+ /**
99
+ * Growth-bounding rewrite (TODO #53 F7): drops tombstoned/expired
100
+ * entries and tombstone lines, preserving live entries (v1 lines
101
+ * untouched) and parseable-but-unknown lines verbatim. Atomic via
102
+ * tmp + rename.
103
+ */
104
+ compact(): Promise<CompactResult>;
105
+ /** Every entry in file order with its materialized lifecycle status. */
106
+ list(): Promise<readonly MemoryListItem[]>;
107
+ /** Diagnostic: how many LIVE entries are stored. */
24
108
  size(): Promise<number>;
25
109
  /** Diagnostic: where on disk this store writes. */
26
110
  path(): string;
@@ -29,6 +113,15 @@ export declare class MemoryStoreError extends CrewhausError {
29
113
  readonly name = "MemoryStoreError";
30
114
  constructor(message: string, cause?: unknown);
31
115
  }
116
+ /** An append-only tombstone line folding a lifecycle change over an entry. */
117
+ export type MemoryTombstone = {
118
+ readonly tombstone: "superseded" | "expired";
119
+ readonly target: string;
120
+ readonly at: string;
121
+ readonly schemaVersion: number;
122
+ readonly supersededBy?: string;
123
+ readonly reason?: string;
124
+ };
32
125
  /**
33
126
  * Construct a memory store for a given spec. The store is lazy — the
34
127
  * underlying file is created on the first `remember()` call.
@@ -59,6 +152,9 @@ export declare const DEFAULT_AUTO_RECALL_K = 5;
59
152
  export type CapturableTurn = {
60
153
  readonly input: string;
61
154
  readonly output: string;
155
+ /** toolUseIds whose tool_result succeeded during this turn — the proof
156
+ * substrate for provenance.evidence on captured facts. */
157
+ readonly toolUseIds?: readonly string[];
62
158
  };
63
159
  /**
64
160
  * Decide, from a memory config and a completed-turn count, whether an
@@ -84,8 +180,63 @@ export type SessionEvent = {
84
180
  * so the auto-capture codegen and CLI both consume one extractor without
85
181
  * importing the CLI's feedback module. Synthetic (runtime-injected) user
86
182
  * messages and tool-result echoes are not turns.
183
+ *
184
+ * v2: each turn also carries the toolUseIds of `tool_result` events that
185
+ * succeeded (`isError !== true`) between its user message and the next —
186
+ * the proof links auto-capture stamps into `provenance.evidence`.
87
187
  */
88
188
  export declare function turnsFromEvents(events: readonly SessionEvent[]): CapturableTurn[];
189
+ /**
190
+ * Per-child event-read cap for the sub-agent capture walk. A researcher
191
+ * fan-out can leave arbitrarily large child session JSONLs behind; the walk
192
+ * reads AT MOST this many events per child (readers get the cap so they can
193
+ * stop early; the walker slices defensively regardless). 2000 events is
194
+ * roughly a few hundred turns — far beyond what a single-turn sub-agent
195
+ * produces, small enough that a 50-child fan-out stays bounded.
196
+ */
197
+ export declare const MAX_CAPTURE_EVENTS_PER_CHILD = 2000;
198
+ /**
199
+ * Read one child session's parsed events. `maxEvents` is the per-child cap
200
+ * (see {@link MAX_CAPTURE_EVENTS_PER_CHILD}) — implementations SHOULD stop
201
+ * reading past it (e.g. slice the parsed JSONL lines); the walker slices
202
+ * defensively either way. Return `undefined` (or throw) for an unreadable /
203
+ * missing child log — the walker skips that child.
204
+ */
205
+ export type ChildSessionEventsReader = (childSessionId: string, maxEvents: number) => readonly SessionEvent[] | undefined | Promise<readonly SessionEvent[] | undefined>;
206
+ /** One child session's capturable turns, keyed for provenance/tagging. */
207
+ export type ChildCapturableTurns = {
208
+ /** The child's own sessionId — stamped into `provenance.sessionId`. */
209
+ readonly sessionId: string;
210
+ /** The sub-agent definition name — becomes the `subagent:<name>` tag. */
211
+ readonly name: string;
212
+ readonly turns: CapturableTurn[];
213
+ /** True when the reader supplied more events than the cap (input was cut). */
214
+ readonly truncated: boolean;
215
+ };
216
+ export type TurnsWithChildren = {
217
+ /** The parent session's turns — identical to `turnsFromEvents(events)`. */
218
+ readonly turns: CapturableTurn[];
219
+ readonly children: ChildCapturableTurns[];
220
+ };
221
+ /**
222
+ * `turnsFromEvents` extended with the sub-agent walk: follows the parent
223
+ * log's `sub_agent_start` brackets (payload `{name, childSessionId}` — the
224
+ * spawner appends one per spawn, before the matching `sub_agent_end`) and
225
+ * lazily reads each referenced child session's events through the injected
226
+ * reader, capped per child. Children are deduped by `childSessionId`;
227
+ * unreadable child logs are skipped. This is THE shared helper both memory
228
+ * wiring paths (target-cli codegen and the apps/cli interpreter) consume so
229
+ * researcher sub-agent findings stop being structurally invisible to
230
+ * auto-capture.
231
+ */
232
+ export declare function turnsFromEventsWithChildren(events: readonly SessionEvent[], readChildEvents: ChildSessionEventsReader, opts?: {
233
+ maxEventsPerChild?: number;
234
+ }): Promise<TurnsWithChildren>;
235
+ /** A durable fact plus the toolUseIds that ground it (may be empty). */
236
+ export type DurableFact = {
237
+ readonly text: string;
238
+ readonly evidence: readonly string[];
239
+ };
89
240
  /**
90
241
  * Extract durable, self-contained facts worth remembering from a session's
91
242
  * turns. Deterministic (no model call) so it runs offline and in tests: it
@@ -99,9 +250,46 @@ export declare function summarizeDurableFacts(turns: readonly CapturableTurn[],
99
250
  maxFacts?: number;
100
251
  maxLen?: number;
101
252
  }): string[];
253
+ /**
254
+ * `summarizeDurableFacts` carrying each fact's proof links: the toolUseIds
255
+ * of the source turn's successful tool results (design §2.4 proof-linked
256
+ * capture). Same extraction/dedupe rules; the string-returning wrapper above
257
+ * stays for pre-v2 callers.
258
+ */
259
+ export declare function summarizeDurableFactsWithEvidence(turns: readonly CapturableTurn[], opts?: {
260
+ maxFacts?: number;
261
+ maxLen?: number;
262
+ }): DurableFact[];
263
+ export type CaptureFactsOptions = {
264
+ /** Stamped into each written entry's `provenance.sessionId`. */
265
+ readonly sessionId?: string;
266
+ /** TTL applied to each written entry. */
267
+ readonly ttlMs?: number;
268
+ };
102
269
  /**
103
270
  * Idempotently persist facts into a store, skipping any whose text (case- and
104
271
  * whitespace-insensitively) already matches an existing entry. Returns the
105
272
  * entries actually written. Re-running the same auto-capture never duplicates.
273
+ *
274
+ * Facts may be plain strings or `DurableFact`s; the latter carry their proof
275
+ * toolUseIds into `provenance.evidence`. When `opts.sessionId` is given (the
276
+ * auto-capture path) it is stamped into `provenance.sessionId`.
277
+ */
278
+ export declare function captureFacts(store: MemoryStore, facts: ReadonlyArray<string | DurableFact>, tags?: readonly string[], opts?: CaptureFactsOptions): Promise<MemoryEntry[]>;
279
+ export type CaptureChildFactsOptions = {
280
+ /** TTL applied to each written entry. */
281
+ readonly ttlMs?: number;
282
+ /** Per-child fact cap forwarded to `summarizeDurableFactsWithEvidence`. */
283
+ readonly maxFactsPerChild?: number;
284
+ };
285
+ /**
286
+ * v0.3.0 §7.1 — persist the durable facts of walked child sessions (from
287
+ * {@link turnsFromEventsWithChildren}). Each child's facts land with
288
+ * `provenance.sessionId` set to the CHILD's sessionId (so proof/evidence
289
+ * resolution walks the right log) and a `subagent:<name>` tag appended to
290
+ * `baseTags` (so researcher findings are grep-able by sub-agent). Same
291
+ * idempotent dedupe as {@link captureFacts}. Returns everything written.
106
292
  */
107
- export declare function captureFacts(store: MemoryStore, facts: readonly string[], tags?: readonly string[]): Promise<MemoryEntry[]>;
293
+ export declare function captureChildFacts(store: MemoryStore, children: ReadonlyArray<ChildCapturableTurns>, baseTags?: readonly string[], opts?: CaptureChildFactsOptions): Promise<MemoryEntry[]>;
294
+ export declare function isMemoryEntry(value: unknown): value is MemoryEntry;
295
+ export declare function isMemoryTombstone(value: unknown): value is MemoryTombstone;
package/dist/index.js CHANGED
@@ -14,16 +14,57 @@
14
14
  * Why simple BM25 instead of embeddings: zero deps, deterministic for
15
15
  * tests, and the working set is small. When a user grows into a much
16
16
  * larger memory bank, swap the search backend behind the `MemoryStore`
17
- * interface — `recall()` is the only consumer-visible signature.
17
+ * interface — `recall()` is the only consumer-visible signature. As a
18
+ * middle step, `createMemoryStore({ embedder })` upgrades recall to a
19
+ * hybrid BM25 + embedding-similarity ranking (see "Hybrid recall").
18
20
  *
19
21
  * File path: `<rootDir>/<specName>.jsonl` where rootDir defaults to
20
22
  * `.crewhaus/memories/`. One file per spec keeps memories scoped.
23
+ *
24
+ * ## Schema v2 (0.3.0 memory release, design §3.4)
25
+ *
26
+ * New writes stamp `schemaVersion: 2` and may carry three additive
27
+ * fields: `expiresAt` (epoch ms — explicit forgetting via TTL),
28
+ * `supersededBy` (entry id — supersede, never delete), and
29
+ * `provenance {sessionId?, evidence?: toolUseId[]}` (where a fact came
30
+ * from and which tool runs prove it). Lines without `schemaVersion`
31
+ * are v1 entries and are read untouched — old readers skip unknown
32
+ * fields, new readers accept v1 lines as-is, so mixed files work in
33
+ * both directions.
34
+ *
35
+ * ## Explicit forgetting (append-only tombstones)
36
+ *
37
+ * The file stays append-only: `forget()` and `sweep()` never rewrite
38
+ * lines — they append tombstone lines (`{tombstone: "superseded" |
39
+ * "expired", target: <id>, at, …}`) that the reader folds over the
40
+ * entries. `recall()`/`size()` see only live entries. `compact()` is
41
+ * the growth-bounding primitive (closes TODO #53 F7): it rewrites the
42
+ * file atomically (tmp + rename) dropping tombstoned/expired entries
43
+ * and the tombstone lines themselves; parseable lines it does not
44
+ * recognise (future line kinds) are preserved verbatim.
45
+ *
46
+ * ## Hybrid recall (design §3.4)
47
+ *
48
+ * With no `embedder` option, recall is byte-identical to the v1
49
+ * BM25-only ranking (the regression-guarded default). With an
50
+ * `embedder` (structurally compatible with `@crewhaus/embedder`'s
51
+ * `Embedder`; use `mock/…` for offline tests), recall becomes a
52
+ * reciprocal-rank fusion (RRF, k=60) of the BM25 ranking and a
53
+ * cosine-similarity ranking over embeddings of `text + tags`.
54
+ * Tool-grounded facts — entries whose `provenance.evidence` is
55
+ * non-empty — receive a documented rank boost in the fused score: one
56
+ * extra reciprocal-rank vote at rank 1 (`1/(60+1)`), as if a third
57
+ * ranker had put every proof-backed fact first. This is the design's
58
+ * write-path-governance rule: facts proven by tool runs outrank
59
+ * pure-text claims of equal textual relevance.
21
60
  */
22
- import { existsSync, mkdirSync } from "node:fs";
61
+ import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
23
62
  import { appendFile, readFile } from "node:fs/promises";
24
63
  import { join } from "node:path";
25
64
  import { CrewhausError } from "@crewhaus/errors";
26
65
  export const DEFAULT_ROOT_DIR = ".crewhaus/memories";
66
+ /** Schema version stamped on every new write. Absent = v1 (read lazily). */
67
+ export const MEMORY_SCHEMA_VERSION = 2;
27
68
  export class MemoryStoreError extends CrewhausError {
28
69
  name = "MemoryStoreError";
29
70
  constructor(message, cause) {
@@ -32,6 +73,15 @@ export class MemoryStoreError extends CrewhausError {
32
73
  }
33
74
  const DEFAULT_K = 5;
34
75
  const ID_PREFIX = "mem_";
76
+ const ENTRY_ID_RE = /^mem_[0-9a-f]{16}$/;
77
+ /** Reciprocal-rank-fusion constant (the standard k=60). */
78
+ const RRF_K = 60;
79
+ /**
80
+ * Documented rank boost for tool-grounded facts in hybrid recall: one
81
+ * extra reciprocal-rank vote at rank 1. Applied ONLY on the fused
82
+ * (embedder-present) path so BM25-only ranking stays byte-identical.
83
+ */
84
+ const PROOF_BOOST = 1 / (RRF_K + 1);
35
85
  /**
36
86
  * Construct a memory store for a given spec. The store is lazy — the
37
87
  * underlying file is created on the first `remember()` call.
@@ -45,13 +95,18 @@ export function createMemoryStore(opts) {
45
95
  }
46
96
  const rootDir = opts.rootDir ?? DEFAULT_ROOT_DIR;
47
97
  const now = opts.now ?? (() => new Date());
98
+ const embedder = opts.embedder;
48
99
  const filePath = join(rootDir, `${opts.specName}.jsonl`);
100
+ // Embedding cache — entries are immutable once written, so id-keyed
101
+ // vectors never go stale; the cache only saves re-embedding across
102
+ // recall() calls within one store instance.
103
+ const embeddingCache = new Map();
49
104
  async function ensureRootDir() {
50
105
  if (!existsSync(rootDir)) {
51
106
  mkdirSync(rootDir, { recursive: true });
52
107
  }
53
108
  }
54
- async function loadAll() {
109
+ async function readLines() {
55
110
  if (!existsSync(filePath))
56
111
  return [];
57
112
  let raw;
@@ -61,14 +116,18 @@ export function createMemoryStore(opts) {
61
116
  catch {
62
117
  return [];
63
118
  }
119
+ return raw.split("\n").filter((l) => l.trim() !== "");
120
+ }
121
+ async function loadAll() {
64
122
  const entries = [];
65
- for (const line of raw.split("\n")) {
66
- if (!line.trim())
67
- continue;
123
+ const tombstones = [];
124
+ for (const line of await readLines()) {
68
125
  try {
69
126
  const parsed = JSON.parse(line);
70
127
  if (isMemoryEntry(parsed))
71
128
  entries.push(parsed);
129
+ else if (isMemoryTombstone(parsed))
130
+ tombstones.push(parsed);
72
131
  }
73
132
  catch {
74
133
  // Malformed line — skip. Append-only writes mean partial-write
@@ -76,7 +135,36 @@ export function createMemoryStore(opts) {
76
135
  // shouldn't block recall on the others.
77
136
  }
78
137
  }
79
- return entries;
138
+ return { entries, tombstones };
139
+ }
140
+ /** Fold tombstones + TTL over the raw entries into status-carrying items. */
141
+ function materialize(loaded, nowMs) {
142
+ const superseded = new Map();
143
+ const expired = new Set();
144
+ for (const t of loaded.tombstones) {
145
+ if (t.tombstone === "superseded")
146
+ superseded.set(t.target, t);
147
+ else
148
+ expired.add(t.target);
149
+ }
150
+ return loaded.entries.map((entry) => {
151
+ const sup = superseded.get(entry.id);
152
+ if (sup !== undefined || entry.supersededBy !== undefined) {
153
+ const supersededBy = entry.supersededBy ?? sup?.supersededBy;
154
+ return {
155
+ entry: supersededBy !== undefined ? { ...entry, supersededBy } : entry,
156
+ status: "superseded",
157
+ };
158
+ }
159
+ if (expired.has(entry.id) || (entry.expiresAt !== undefined && nowMs >= entry.expiresAt)) {
160
+ return { entry, status: "expired" };
161
+ }
162
+ return { entry, status: "live" };
163
+ });
164
+ }
165
+ async function loadLive() {
166
+ const items = materialize(await loadAll(), now().getTime());
167
+ return items.filter((i) => i.status === "live").map((i) => i.entry);
80
168
  }
81
169
  function mkId() {
82
170
  // 8-byte random hex; collision probability with < 10k entries per
@@ -88,21 +176,157 @@ export function createMemoryStore(opts) {
88
176
  }
89
177
  return `${ID_PREFIX}${hex}`;
90
178
  }
179
+ async function appendTombstones(targets, kind, reason) {
180
+ if (targets.length === 0)
181
+ return;
182
+ await ensureRootDir();
183
+ const at = now().toISOString();
184
+ const lines = targets
185
+ .map((e) => {
186
+ const t = {
187
+ tombstone: kind,
188
+ target: e.id,
189
+ at,
190
+ schemaVersion: MEMORY_SCHEMA_VERSION,
191
+ ...(reason !== undefined ? { reason } : {}),
192
+ };
193
+ return JSON.stringify(t);
194
+ })
195
+ .join("\n");
196
+ await appendFile(filePath, `${lines}\n`, { mode: 0o600 });
197
+ }
198
+ /**
199
+ * BM25-style scoring over the given entries — the exact pre-v2 math:
200
+ * - tf = term frequency in this document
201
+ * - idf = log((N - df + 0.5) / (df + 0.5))
202
+ * - score = sum over terms of idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (dl / avgdl)))
203
+ * Returns only positive-scoring entries, sorted descending (stable).
204
+ */
205
+ function bm25Rank(entries, query) {
206
+ const queryTerms = tokenize(query);
207
+ if (queryTerms.length === 0 || entries.length === 0)
208
+ return [];
209
+ const k1 = 1.5;
210
+ const b = 0.75;
211
+ const docs = entries.map((entry) => ({
212
+ entry,
213
+ terms: tokenize(`${entry.text} ${entry.tags.join(" ")}`),
214
+ }));
215
+ const N = docs.length;
216
+ const avgdl = docs.reduce((sum, d) => sum + d.terms.length, 0) / Math.max(1, N);
217
+ // Document frequency for each query term.
218
+ const df = new Map();
219
+ for (const t of new Set(queryTerms)) {
220
+ df.set(t, docs.filter((d) => d.terms.includes(t)).length);
221
+ }
222
+ const results = [];
223
+ for (const d of docs) {
224
+ let score = 0;
225
+ for (const t of queryTerms) {
226
+ const tf = d.terms.filter((x) => x === t).length;
227
+ if (tf === 0)
228
+ continue;
229
+ const dfi = df.get(t) ?? 0;
230
+ const idf = Math.log((N - dfi + 0.5) / (dfi + 0.5) + 1);
231
+ const dl = d.terms.length;
232
+ const norm = tf * (k1 + 1);
233
+ const denom = tf + k1 * (1 - b + (b * dl) / Math.max(1, avgdl));
234
+ score += idf * (norm / denom);
235
+ }
236
+ if (score > 0)
237
+ results.push({ entry: d.entry, score });
238
+ }
239
+ results.sort((a, b2) => b2.score - a.score);
240
+ return results;
241
+ }
242
+ /** Embed `text + tags` for the given entries, id-cached across calls. */
243
+ async function embeddingsFor(entries) {
244
+ if (embedder === undefined)
245
+ return new Map();
246
+ const missing = entries.filter((e) => !embeddingCache.has(e.id));
247
+ if (missing.length > 0) {
248
+ const vectors = await embedder.embed(missing.map((e) => `${e.text} ${e.tags.join(" ")}`));
249
+ missing.forEach((e, i) => {
250
+ const v = vectors[i];
251
+ if (v !== undefined)
252
+ embeddingCache.set(e.id, v);
253
+ });
254
+ }
255
+ const out = new Map();
256
+ for (const e of entries) {
257
+ const v = embeddingCache.get(e.id);
258
+ if (v !== undefined)
259
+ out.set(e.id, v);
260
+ }
261
+ return out;
262
+ }
263
+ /**
264
+ * Hybrid recall: reciprocal-rank fusion (k=60) of the BM25 ranking and
265
+ * a cosine-similarity ranking, plus the documented `PROOF_BOOST` for
266
+ * entries with non-empty `provenance.evidence`. Candidates are the
267
+ * union of both rankers' positive matches.
268
+ */
269
+ async function hybridRank(entries, query, emb) {
270
+ const bmRanked = bm25Rank(entries, query);
271
+ const [queryVec] = await emb.embed([query]);
272
+ const entryVecs = await embeddingsFor(entries);
273
+ const simRanked = [];
274
+ if (queryVec !== undefined) {
275
+ for (const entry of entries) {
276
+ const v = entryVecs.get(entry.id);
277
+ if (v === undefined)
278
+ continue;
279
+ const sim = cosineSimilarity(queryVec, v);
280
+ if (sim > 0)
281
+ simRanked.push({ entry, sim });
282
+ }
283
+ simRanked.sort((a, b) => b.sim - a.sim);
284
+ }
285
+ const fused = new Map();
286
+ const vote = (entry, rank) => {
287
+ const prev = fused.get(entry.id);
288
+ const inc = 1 / (RRF_K + rank);
289
+ if (prev === undefined)
290
+ fused.set(entry.id, { entry, score: inc });
291
+ else
292
+ prev.score += inc;
293
+ };
294
+ bmRanked.forEach((r, i) => vote(r.entry, i + 1));
295
+ simRanked.forEach((r, i) => vote(r.entry, i + 1));
296
+ for (const f of fused.values()) {
297
+ if (f.entry.provenance?.evidence !== undefined && f.entry.provenance.evidence.length > 0) {
298
+ f.score += PROOF_BOOST;
299
+ }
300
+ }
301
+ return [...fused.values()]
302
+ .sort((a, b) => b.score - a.score)
303
+ .map((f) => ({ entry: f.entry, score: f.score }));
304
+ }
91
305
  return {
92
- async remember(text, tags = []) {
306
+ async remember(text, tags = [], rememberOpts = {}) {
93
307
  if (typeof text !== "string" || text.length === 0) {
94
308
  throw new MemoryStoreError("remember(): text must be a non-empty string");
95
309
  }
310
+ if (rememberOpts.ttlMs !== undefined && rememberOpts.ttlMs <= 0) {
311
+ throw new MemoryStoreError("remember(): ttlMs must be a positive number of milliseconds");
312
+ }
313
+ const createdAt = now();
96
314
  const entry = {
97
315
  id: mkId(),
98
316
  text,
99
317
  tags: [...tags],
100
- createdAt: now().toISOString(),
318
+ createdAt: createdAt.toISOString(),
319
+ schemaVersion: MEMORY_SCHEMA_VERSION,
320
+ ...(rememberOpts.ttlMs !== undefined
321
+ ? { expiresAt: createdAt.getTime() + rememberOpts.ttlMs }
322
+ : {}),
323
+ ...(rememberOpts.provenance !== undefined ? { provenance: rememberOpts.provenance } : {}),
101
324
  };
102
325
  await ensureRootDir();
103
- // TODO(#53 F7): this append-only store grows unbounded add a size/age
104
- // cap or prune-on-write (e.g. keep the newest N or evict by recall score)
105
- // so long-lived harnesses don't accumulate an ever-growing memory file.
326
+ // #53 F7 (unbounded growth) is addressed by the explicit-forgetting
327
+ // primitives: `sweep()` tombstones expired entries, `forget()`
328
+ // supersedes stale ones, and `compact()` rewrites the file dropping
329
+ // dead lines (`crewhaus memory sweep --compact` runs both).
106
330
  await appendFile(filePath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
107
331
  return entry;
108
332
  },
@@ -110,58 +334,115 @@ export function createMemoryStore(opts) {
110
334
  if (typeof query !== "string" || query.length === 0) {
111
335
  throw new MemoryStoreError("recall(): query must be a non-empty string");
112
336
  }
113
- const all = await loadAll();
114
- if (all.length === 0)
337
+ const live = await loadLive();
338
+ if (live.length === 0)
115
339
  return [];
116
- const queryTerms = tokenize(query);
117
- if (queryTerms.length === 0)
340
+ if (tokenize(query).length === 0)
118
341
  return [];
119
- // BM25-style scoring:
120
- // - tf = term frequency in this document
121
- // - idf = log((N - df + 0.5) / (df + 0.5))
122
- // - score = sum over terms of idf * (tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (dl / avgdl)))
123
- const k1 = 1.5;
124
- const b = 0.75;
125
- const docs = all.map((entry) => ({
126
- entry,
127
- terms: tokenize(`${entry.text} ${entry.tags.join(" ")}`),
128
- }));
129
- const N = docs.length;
130
- const avgdl = docs.reduce((sum, d) => sum + d.terms.length, 0) / Math.max(1, N);
131
- // Document frequency for each query term.
132
- const df = new Map();
133
- for (const t of new Set(queryTerms)) {
134
- df.set(t, docs.filter((d) => d.terms.includes(t)).length);
342
+ const ranked = embedder === undefined ? bm25Rank(live, query) : await hybridRank(live, query, embedder);
343
+ return ranked.slice(0, Math.max(0, k));
344
+ },
345
+ async forget(idOrQuery, forgetOpts = {}) {
346
+ if (typeof idOrQuery !== "string" || idOrQuery.length === 0) {
347
+ throw new MemoryStoreError("forget(): idOrQuery must be a non-empty string");
348
+ }
349
+ const live = await loadLive();
350
+ let targets;
351
+ if (ENTRY_ID_RE.test(idOrQuery)) {
352
+ // Id-shaped input NEVER falls back to text matching — forgetting
353
+ // a missing id must forget nothing.
354
+ targets = live.filter((e) => e.id === idOrQuery);
355
+ }
356
+ else {
357
+ targets = bm25Rank(live, idOrQuery).map((r) => r.entry);
135
358
  }
136
- const results = [];
137
- for (const d of docs) {
138
- let score = 0;
139
- for (const t of queryTerms) {
140
- const tf = d.terms.filter((x) => x === t).length;
141
- if (tf === 0)
142
- continue;
143
- const dfi = df.get(t) ?? 0;
144
- const idf = Math.log((N - dfi + 0.5) / (dfi + 0.5) + 1);
145
- const dl = d.terms.length;
146
- const norm = tf * (k1 + 1);
147
- const denom = tf + k1 * (1 - b + (b * dl) / Math.max(1, avgdl));
148
- score += idf * (norm / denom);
359
+ await appendTombstones(targets, "superseded", forgetOpts.reason);
360
+ return targets;
361
+ },
362
+ async sweep(nowMs) {
363
+ const at = nowMs ?? now().getTime();
364
+ const loaded = await loadAll();
365
+ const items = materialize(loaded, at);
366
+ // Only past-expiry entries that carry NO tombstone yet get one, so
367
+ // re-running at the same time appends nothing (idempotent).
368
+ const tombstoned = new Set(loaded.tombstones.map((t) => t.target));
369
+ const fresh = items
370
+ .filter((i) => i.status === "expired" &&
371
+ i.entry.expiresAt !== undefined &&
372
+ at >= i.entry.expiresAt &&
373
+ !tombstoned.has(i.entry.id))
374
+ .map((i) => i.entry);
375
+ await appendTombstones(fresh, "expired");
376
+ const live = items.filter((i) => i.status === "live").length;
377
+ return { swept: fresh.length, live };
378
+ },
379
+ async compact() {
380
+ const lines = await readLines();
381
+ if (lines.length === 0)
382
+ return { kept: 0, dropped: 0 };
383
+ const loaded = await loadAll();
384
+ const items = materialize(loaded, now().getTime());
385
+ const liveIds = new Set(items.filter((i) => i.status === "live").map((i) => i.entry.id));
386
+ const kept = [];
387
+ let dropped = 0;
388
+ for (const line of lines) {
389
+ let parsed;
390
+ try {
391
+ parsed = JSON.parse(line);
392
+ }
393
+ catch {
394
+ dropped += 1; // unparseable partial-write junk
395
+ continue;
149
396
  }
150
- if (score > 0)
151
- results.push({ entry: d.entry, score });
397
+ if (isMemoryEntry(parsed)) {
398
+ if (liveIds.has(parsed.id))
399
+ kept.push(line);
400
+ else
401
+ dropped += 1;
402
+ continue;
403
+ }
404
+ if (isMemoryTombstone(parsed)) {
405
+ dropped += 1; // its target is gone — the tombstone has no referent
406
+ continue;
407
+ }
408
+ // Parseable but unknown line kind (a future writer's) — preserve
409
+ // verbatim so compact never destroys forward-compatible data.
410
+ kept.push(line);
152
411
  }
153
- results.sort((a, b) => b.score - a.score);
154
- return results.slice(0, Math.max(0, k));
412
+ const tmpPath = `${filePath}.tmp`;
413
+ writeFileSync(tmpPath, kept.length > 0 ? `${kept.join("\n")}\n` : "", { mode: 0o600 });
414
+ renameSync(tmpPath, filePath);
415
+ embeddingCache.clear();
416
+ return { kept: kept.length, dropped };
417
+ },
418
+ async list() {
419
+ return materialize(await loadAll(), now().getTime());
155
420
  },
156
421
  async size() {
157
- const all = await loadAll();
158
- return all.length;
422
+ const live = await loadLive();
423
+ return live.length;
159
424
  },
160
425
  path() {
161
426
  return filePath;
162
427
  },
163
428
  };
164
429
  }
430
+ function cosineSimilarity(a, b) {
431
+ const n = Math.min(a.length, b.length);
432
+ let dot = 0;
433
+ let na = 0;
434
+ let nb = 0;
435
+ for (let i = 0; i < n; i++) {
436
+ const x = a[i] ?? 0;
437
+ const y = b[i] ?? 0;
438
+ dot += x * y;
439
+ na += x * x;
440
+ nb += y * y;
441
+ }
442
+ if (na === 0 || nb === 0)
443
+ return 0;
444
+ return dot / (Math.sqrt(na) * Math.sqrt(nb));
445
+ }
165
446
  export const DEFAULT_AUTO_CAPTURE_THRESHOLD = 1;
166
447
  export const DEFAULT_AUTO_RECALL_K = 5;
167
448
  /**
@@ -188,6 +469,10 @@ export function deriveMemoryDecision(config, completedTurns) {
188
469
  * so the auto-capture codegen and CLI both consume one extractor without
189
470
  * importing the CLI's feedback module. Synthetic (runtime-injected) user
190
471
  * messages and tool-result echoes are not turns.
472
+ *
473
+ * v2: each turn also carries the toolUseIds of `tool_result` events that
474
+ * succeeded (`isError !== true`) between its user message and the next —
475
+ * the proof links auto-capture stamps into `provenance.evidence`.
191
476
  */
192
477
  export function turnsFromEvents(events) {
193
478
  const turns = [];
@@ -198,6 +483,7 @@ export function turnsFromEvents(events) {
198
483
  turns.push({
199
484
  input: current.input,
200
485
  output: current.texts.length > 0 ? current.texts[current.texts.length - 1] : "",
486
+ ...(current.toolUseIds.length > 0 ? { toolUseIds: current.toolUseIds } : {}),
201
487
  });
202
488
  };
203
489
  for (const ev of events) {
@@ -205,7 +491,7 @@ export function turnsFromEvents(events) {
205
491
  const text = userEventText(ev.payload);
206
492
  if (text !== undefined) {
207
493
  flush();
208
- current = { input: text, texts: [] };
494
+ current = { input: text, texts: [], toolUseIds: [] };
209
495
  }
210
496
  }
211
497
  else if (ev.kind === "assistant_message" && current !== undefined) {
@@ -213,6 +499,11 @@ export function turnsFromEvents(events) {
213
499
  if (t !== "")
214
500
  current.texts.push(t);
215
501
  }
502
+ else if (ev.kind === "tool_result" && current !== undefined) {
503
+ const id = toolResultUseId(ev.payload);
504
+ if (id !== undefined)
505
+ current.toolUseIds.push(id);
506
+ }
216
507
  }
217
508
  flush();
218
509
  return turns;
@@ -250,6 +541,73 @@ function assistantEventText(payload) {
250
541
  .map((b) => b.text)
251
542
  .join("\n");
252
543
  }
544
+ /** The toolUseId of a successful `tool_result` event payload, if any. */
545
+ function toolResultUseId(payload) {
546
+ if (payload === null || typeof payload !== "object")
547
+ return undefined;
548
+ const p = payload;
549
+ if (typeof p.toolUseId !== "string" || p.toolUseId === "")
550
+ return undefined;
551
+ // Errored tool runs are not evidence — the proof ladder rejects them.
552
+ if (p.isError === true)
553
+ return undefined;
554
+ return p.toolUseId;
555
+ }
556
+ // -------- v0.3.0 §7.1: capture walks sub-agent brackets into child logs --------
557
+ /**
558
+ * Per-child event-read cap for the sub-agent capture walk. A researcher
559
+ * fan-out can leave arbitrarily large child session JSONLs behind; the walk
560
+ * reads AT MOST this many events per child (readers get the cap so they can
561
+ * stop early; the walker slices defensively regardless). 2000 events is
562
+ * roughly a few hundred turns — far beyond what a single-turn sub-agent
563
+ * produces, small enough that a 50-child fan-out stays bounded.
564
+ */
565
+ export const MAX_CAPTURE_EVENTS_PER_CHILD = 2000;
566
+ /**
567
+ * `turnsFromEvents` extended with the sub-agent walk: follows the parent
568
+ * log's `sub_agent_start` brackets (payload `{name, childSessionId}` — the
569
+ * spawner appends one per spawn, before the matching `sub_agent_end`) and
570
+ * lazily reads each referenced child session's events through the injected
571
+ * reader, capped per child. Children are deduped by `childSessionId`;
572
+ * unreadable child logs are skipped. This is THE shared helper both memory
573
+ * wiring paths (target-cli codegen and the apps/cli interpreter) consume so
574
+ * researcher sub-agent findings stop being structurally invisible to
575
+ * auto-capture.
576
+ */
577
+ export async function turnsFromEventsWithChildren(events, readChildEvents, opts = {}) {
578
+ const cap = Math.max(1, opts.maxEventsPerChild ?? MAX_CAPTURE_EVENTS_PER_CHILD);
579
+ const children = [];
580
+ const seen = new Set();
581
+ for (const ev of events) {
582
+ if (ev.kind !== "sub_agent_start")
583
+ continue;
584
+ const p = ev.payload;
585
+ const childSessionId = typeof p?.childSessionId === "string" ? p.childSessionId : undefined;
586
+ if (childSessionId === undefined || childSessionId === "" || seen.has(childSessionId)) {
587
+ continue;
588
+ }
589
+ seen.add(childSessionId);
590
+ const name = typeof p?.name === "string" && p.name !== "" ? p.name : "unknown";
591
+ let childEvents;
592
+ try {
593
+ childEvents = await readChildEvents(childSessionId, cap);
594
+ }
595
+ catch {
596
+ childEvents = undefined;
597
+ }
598
+ if (childEvents === undefined)
599
+ continue;
600
+ const truncated = childEvents.length > cap;
601
+ const capped = truncated ? childEvents.slice(0, cap) : childEvents;
602
+ children.push({
603
+ sessionId: childSessionId,
604
+ name,
605
+ turns: turnsFromEvents(capped),
606
+ truncated,
607
+ });
608
+ }
609
+ return { turns: turnsFromEvents(events), children };
610
+ }
253
611
  /**
254
612
  * Extract durable, self-contained facts worth remembering from a session's
255
613
  * turns. Deterministic (no model call) so it runs offline and in tests: it
@@ -260,6 +618,15 @@ function assistantEventText(payload) {
260
618
  * injected.
261
619
  */
262
620
  export function summarizeDurableFacts(turns, opts = {}) {
621
+ return summarizeDurableFactsWithEvidence(turns, opts).map((f) => f.text);
622
+ }
623
+ /**
624
+ * `summarizeDurableFacts` carrying each fact's proof links: the toolUseIds
625
+ * of the source turn's successful tool results (design §2.4 proof-linked
626
+ * capture). Same extraction/dedupe rules; the string-returning wrapper above
627
+ * stays for pre-v2 callers.
628
+ */
629
+ export function summarizeDurableFactsWithEvidence(turns, opts = {}) {
263
630
  const maxFacts = opts.maxFacts ?? 8;
264
631
  const maxLen = opts.maxLen ?? 240;
265
632
  const seen = new Set();
@@ -282,7 +649,7 @@ export function summarizeDurableFacts(turns, opts = {}) {
282
649
  if (seen.has(key))
283
650
  continue;
284
651
  seen.add(key);
285
- facts.push(fact);
652
+ facts.push({ text: fact, evidence: [...(t.toolUseIds ?? [])] });
286
653
  if (facts.length >= maxFacts)
287
654
  break;
288
655
  }
@@ -292,8 +659,12 @@ export function summarizeDurableFacts(turns, opts = {}) {
292
659
  * Idempotently persist facts into a store, skipping any whose text (case- and
293
660
  * whitespace-insensitively) already matches an existing entry. Returns the
294
661
  * entries actually written. Re-running the same auto-capture never duplicates.
662
+ *
663
+ * Facts may be plain strings or `DurableFact`s; the latter carry their proof
664
+ * toolUseIds into `provenance.evidence`. When `opts.sessionId` is given (the
665
+ * auto-capture path) it is stamped into `provenance.sessionId`.
295
666
  */
296
- export async function captureFacts(store, facts, tags = ["auto-capture"]) {
667
+ export async function captureFacts(store, facts, tags = ["auto-capture"], opts = {}) {
297
668
  const written = [];
298
669
  if (facts.length === 0)
299
670
  return written;
@@ -301,30 +672,101 @@ export async function captureFacts(store, facts, tags = ["auto-capture"]) {
301
672
  // re-run is a no-op. recall() needs a query; we normalize existing text by
302
673
  // recalling each fact and checking for an exact normalized match.
303
674
  const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
675
+ const asFact = (f) => typeof f === "string" ? { text: f, evidence: [] } : f;
304
676
  const existing = new Set();
305
677
  for (const fact of facts) {
306
- for (const r of await store.recall(fact, 20))
678
+ for (const r of await store.recall(asFact(fact).text, 20))
307
679
  existing.add(norm(r.entry.text));
308
680
  }
309
681
  const writtenNorms = new Set();
310
- for (const fact of facts) {
311
- const n = norm(fact);
682
+ for (const raw of facts) {
683
+ const fact = asFact(raw);
684
+ const n = norm(fact.text);
312
685
  if (existing.has(n) || writtenNorms.has(n))
313
686
  continue;
314
687
  writtenNorms.add(n);
315
- written.push(await store.remember(fact, tags));
688
+ const provenance = opts.sessionId !== undefined || fact.evidence.length > 0
689
+ ? {
690
+ ...(opts.sessionId !== undefined ? { sessionId: opts.sessionId } : {}),
691
+ ...(fact.evidence.length > 0 ? { evidence: fact.evidence } : {}),
692
+ }
693
+ : undefined;
694
+ written.push(await store.remember(fact.text, tags, {
695
+ ...(provenance !== undefined ? { provenance } : {}),
696
+ ...(opts.ttlMs !== undefined ? { ttlMs: opts.ttlMs } : {}),
697
+ }));
316
698
  }
317
699
  return written;
318
700
  }
319
- function isMemoryEntry(value) {
701
+ /**
702
+ * v0.3.0 §7.1 — persist the durable facts of walked child sessions (from
703
+ * {@link turnsFromEventsWithChildren}). Each child's facts land with
704
+ * `provenance.sessionId` set to the CHILD's sessionId (so proof/evidence
705
+ * resolution walks the right log) and a `subagent:<name>` tag appended to
706
+ * `baseTags` (so researcher findings are grep-able by sub-agent). Same
707
+ * idempotent dedupe as {@link captureFacts}. Returns everything written.
708
+ */
709
+ export async function captureChildFacts(store, children, baseTags = ["auto-capture"], opts = {}) {
710
+ const written = [];
711
+ for (const child of children) {
712
+ const facts = summarizeDurableFactsWithEvidence(child.turns, opts.maxFactsPerChild !== undefined ? { maxFacts: opts.maxFactsPerChild } : {});
713
+ if (facts.length === 0)
714
+ continue;
715
+ written.push(...(await captureFacts(store, facts, [...baseTags, `subagent:${child.name}`], {
716
+ sessionId: child.sessionId,
717
+ ...(opts.ttlMs !== undefined ? { ttlMs: opts.ttlMs } : {}),
718
+ })));
719
+ }
720
+ return written;
721
+ }
722
+ export function isMemoryEntry(value) {
320
723
  if (typeof value !== "object" || value === null)
321
724
  return false;
322
725
  const v = value;
323
- return (typeof v["id"] === "string" &&
726
+ const baseOk = typeof v["id"] === "string" &&
324
727
  typeof v["text"] === "string" &&
325
728
  Array.isArray(v["tags"]) &&
326
729
  v["tags"].every((t) => typeof t === "string") &&
327
- typeof v["createdAt"] === "string");
730
+ typeof v["createdAt"] === "string";
731
+ if (!baseOk)
732
+ return false;
733
+ // Tombstone lines carry `tombstone` + `target` and never the entry base
734
+ // fields, so they can't reach here — but guard anyway.
735
+ if (v["tombstone"] !== undefined)
736
+ return false;
737
+ // v2 optional fields must be well-typed WHEN PRESENT; a line with a
738
+ // mangled optional field is treated as malformed (skipped), never
739
+ // half-read.
740
+ if (v["schemaVersion"] !== undefined && typeof v["schemaVersion"] !== "number")
741
+ return false;
742
+ if (v["expiresAt"] !== undefined && typeof v["expiresAt"] !== "number")
743
+ return false;
744
+ if (v["supersededBy"] !== undefined && typeof v["supersededBy"] !== "string")
745
+ return false;
746
+ if (v["provenance"] !== undefined && !isProvenance(v["provenance"]))
747
+ return false;
748
+ return true;
749
+ }
750
+ function isProvenance(value) {
751
+ if (typeof value !== "object" || value === null)
752
+ return false;
753
+ const v = value;
754
+ if (v["sessionId"] !== undefined && typeof v["sessionId"] !== "string")
755
+ return false;
756
+ if (v["evidence"] !== undefined &&
757
+ !(Array.isArray(v["evidence"]) &&
758
+ v["evidence"].every((e) => typeof e === "string"))) {
759
+ return false;
760
+ }
761
+ return true;
762
+ }
763
+ export function isMemoryTombstone(value) {
764
+ if (typeof value !== "object" || value === null)
765
+ return false;
766
+ const v = value;
767
+ return ((v["tombstone"] === "superseded" || v["tombstone"] === "expired") &&
768
+ typeof v["target"] === "string" &&
769
+ typeof v["at"] === "string");
328
770
  }
329
771
  function tokenize(text) {
330
772
  return text
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/memory-store",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "M4.2 — persistent cross-session memory store. File-backed JSONL with simple BM25-style text search. Per-spec scoped.",
6
6
  "main": "dist/index.js",
@@ -15,7 +15,10 @@
15
15
  "test": "bun test src"
16
16
  },
17
17
  "dependencies": {
18
- "@crewhaus/errors": "0.2.4"
18
+ "@crewhaus/errors": "0.3.1"
19
+ },
20
+ "devDependencies": {
21
+ "@crewhaus/embedder": "0.3.1"
19
22
  },
20
23
  "license": "Apache-2.0",
21
24
  "author": {