@crewhaus/memory-store 0.2.3 → 0.3.0
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 +192 -4
- package/dist/index.js +503 -61
- package/package.json +5 -2
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
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
66
|
-
|
|
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:
|
|
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
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
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
|
|
114
|
-
if (
|
|
337
|
+
const live = await loadLive();
|
|
338
|
+
if (live.length === 0)
|
|
115
339
|
return [];
|
|
116
|
-
|
|
117
|
-
if (queryTerms.length === 0)
|
|
340
|
+
if (tokenize(query).length === 0)
|
|
118
341
|
return [];
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 (
|
|
151
|
-
|
|
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
|
-
|
|
154
|
-
|
|
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
|
|
158
|
-
return
|
|
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
|
|
311
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "0.3.0",
|
|
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.
|
|
18
|
+
"@crewhaus/errors": "0.3.0"
|
|
19
|
+
},
|
|
20
|
+
"devDependencies": {
|
|
21
|
+
"@crewhaus/embedder": "0.3.0"
|
|
19
22
|
},
|
|
20
23
|
"license": "Apache-2.0",
|
|
21
24
|
"author": {
|