@crewhaus/memory-store 0.1.8 → 0.2.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 +71 -0
- package/dist/index.js +157 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -34,3 +34,74 @@ export declare class MemoryStoreError extends CrewhausError {
|
|
|
34
34
|
* underlying file is created on the first `remember()` call.
|
|
35
35
|
*/
|
|
36
36
|
export declare function createMemoryStore(opts: MemoryStoreOptions): MemoryStore;
|
|
37
|
+
/**
|
|
38
|
+
* Memory configuration lowered from the spec `memory:` block. All fields
|
|
39
|
+
* optional so the block's mere presence wires the Remember/Recall tools; the
|
|
40
|
+
* auto-* switches are opt-in on top of that.
|
|
41
|
+
*/
|
|
42
|
+
export type MemoryConfig = {
|
|
43
|
+
/** Register Remember/Recall + honour the auto-* switches. Presence of the
|
|
44
|
+
* block implies enabled; an explicit `false` keeps everything off. */
|
|
45
|
+
readonly enabled?: boolean;
|
|
46
|
+
/** At teardown, summarize the session's durable outcomes into the store. */
|
|
47
|
+
readonly autoCapture?: boolean;
|
|
48
|
+
/** Minimum completed user-text turns before an auto-capture fires (default 1). */
|
|
49
|
+
readonly autoCaptureThreshold?: number;
|
|
50
|
+
/** At session start, recall the top-K memories and inject them into the prompt. */
|
|
51
|
+
readonly autoRecall?: boolean;
|
|
52
|
+
/** How many memories autoRecall injects. Default 5. */
|
|
53
|
+
readonly recallK?: number;
|
|
54
|
+
};
|
|
55
|
+
export declare const DEFAULT_AUTO_CAPTURE_THRESHOLD = 1;
|
|
56
|
+
export declare const DEFAULT_AUTO_RECALL_K = 5;
|
|
57
|
+
/** A minimal transcript turn — the durable-fact extractor's input. Matches the
|
|
58
|
+
* shape `deriveTurns` in the CLI produces (input/output text per turn). */
|
|
59
|
+
export type CapturableTurn = {
|
|
60
|
+
readonly input: string;
|
|
61
|
+
readonly output: string;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Decide, from a memory config and a completed-turn count, whether an
|
|
65
|
+
* auto-capture should run and how many memories auto-recall should inject.
|
|
66
|
+
* Pure so the wiring in runtime-core is unit-testable without a filesystem.
|
|
67
|
+
* A config that is absent, or explicitly `enabled: false`, disables both.
|
|
68
|
+
*/
|
|
69
|
+
export declare function deriveMemoryDecision(config: MemoryConfig | undefined, completedTurns: number): {
|
|
70
|
+
capture: boolean;
|
|
71
|
+
recall: boolean;
|
|
72
|
+
recallK: number;
|
|
73
|
+
captureThreshold: number;
|
|
74
|
+
};
|
|
75
|
+
/** A parsed session event-log line (`{ kind, payload }`). */
|
|
76
|
+
export type SessionEvent = {
|
|
77
|
+
readonly kind?: string;
|
|
78
|
+
readonly payload?: unknown;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* Reconstruct `CapturableTurn`s from a raw session event log. A dependency-
|
|
82
|
+
* free mirror of the CLI's `deriveTurns` restricted to what the fact
|
|
83
|
+
* extractor needs (each user-text turn's final assistant answer). Kept here
|
|
84
|
+
* so the auto-capture codegen and CLI both consume one extractor without
|
|
85
|
+
* importing the CLI's feedback module. Synthetic (runtime-injected) user
|
|
86
|
+
* messages and tool-result echoes are not turns.
|
|
87
|
+
*/
|
|
88
|
+
export declare function turnsFromEvents(events: readonly SessionEvent[]): CapturableTurn[];
|
|
89
|
+
/**
|
|
90
|
+
* Extract durable, self-contained facts worth remembering from a session's
|
|
91
|
+
* turns. Deterministic (no model call) so it runs offline and in tests: it
|
|
92
|
+
* keeps each turn's final answer as one candidate fact, trimmed to a single
|
|
93
|
+
* sentence-ish line, dropping empty/echo/error turns and near-duplicates.
|
|
94
|
+
* Callers who have a model available can override with a summary; this is the
|
|
95
|
+
* always-available fallback the auto-capture path uses when no summarizer is
|
|
96
|
+
* injected.
|
|
97
|
+
*/
|
|
98
|
+
export declare function summarizeDurableFacts(turns: readonly CapturableTurn[], opts?: {
|
|
99
|
+
maxFacts?: number;
|
|
100
|
+
maxLen?: number;
|
|
101
|
+
}): string[];
|
|
102
|
+
/**
|
|
103
|
+
* Idempotently persist facts into a store, skipping any whose text (case- and
|
|
104
|
+
* whitespace-insensitively) already matches an existing entry. Returns the
|
|
105
|
+
* entries actually written. Re-running the same auto-capture never duplicates.
|
|
106
|
+
*/
|
|
107
|
+
export declare function captureFacts(store: MemoryStore, facts: readonly string[], tags?: readonly string[]): Promise<MemoryEntry[]>;
|
package/dist/index.js
CHANGED
|
@@ -100,6 +100,9 @@ export function createMemoryStore(opts) {
|
|
|
100
100
|
createdAt: now().toISOString(),
|
|
101
101
|
};
|
|
102
102
|
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.
|
|
103
106
|
await appendFile(filePath, `${JSON.stringify(entry)}\n`, { mode: 0o600 });
|
|
104
107
|
return entry;
|
|
105
108
|
},
|
|
@@ -159,6 +162,160 @@ export function createMemoryStore(opts) {
|
|
|
159
162
|
},
|
|
160
163
|
};
|
|
161
164
|
}
|
|
165
|
+
export const DEFAULT_AUTO_CAPTURE_THRESHOLD = 1;
|
|
166
|
+
export const DEFAULT_AUTO_RECALL_K = 5;
|
|
167
|
+
/**
|
|
168
|
+
* Decide, from a memory config and a completed-turn count, whether an
|
|
169
|
+
* auto-capture should run and how many memories auto-recall should inject.
|
|
170
|
+
* Pure so the wiring in runtime-core is unit-testable without a filesystem.
|
|
171
|
+
* A config that is absent, or explicitly `enabled: false`, disables both.
|
|
172
|
+
*/
|
|
173
|
+
export function deriveMemoryDecision(config, completedTurns) {
|
|
174
|
+
const enabled = config !== undefined && config.enabled !== false;
|
|
175
|
+
const captureThreshold = Math.max(1, config?.autoCaptureThreshold ?? DEFAULT_AUTO_CAPTURE_THRESHOLD);
|
|
176
|
+
const recallK = Math.max(1, config?.recallK ?? DEFAULT_AUTO_RECALL_K);
|
|
177
|
+
return {
|
|
178
|
+
capture: enabled && config?.autoCapture === true && completedTurns >= captureThreshold,
|
|
179
|
+
recall: enabled && config?.autoRecall === true,
|
|
180
|
+
recallK,
|
|
181
|
+
captureThreshold,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Reconstruct `CapturableTurn`s from a raw session event log. A dependency-
|
|
186
|
+
* free mirror of the CLI's `deriveTurns` restricted to what the fact
|
|
187
|
+
* extractor needs (each user-text turn's final assistant answer). Kept here
|
|
188
|
+
* so the auto-capture codegen and CLI both consume one extractor without
|
|
189
|
+
* importing the CLI's feedback module. Synthetic (runtime-injected) user
|
|
190
|
+
* messages and tool-result echoes are not turns.
|
|
191
|
+
*/
|
|
192
|
+
export function turnsFromEvents(events) {
|
|
193
|
+
const turns = [];
|
|
194
|
+
let current;
|
|
195
|
+
const flush = () => {
|
|
196
|
+
if (current === undefined)
|
|
197
|
+
return;
|
|
198
|
+
turns.push({
|
|
199
|
+
input: current.input,
|
|
200
|
+
output: current.texts.length > 0 ? current.texts[current.texts.length - 1] : "",
|
|
201
|
+
});
|
|
202
|
+
};
|
|
203
|
+
for (const ev of events) {
|
|
204
|
+
if (ev.kind === "user_message") {
|
|
205
|
+
const text = userEventText(ev.payload);
|
|
206
|
+
if (text !== undefined) {
|
|
207
|
+
flush();
|
|
208
|
+
current = { input: text, texts: [] };
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (ev.kind === "assistant_message" && current !== undefined) {
|
|
212
|
+
const t = assistantEventText(ev.payload);
|
|
213
|
+
if (t !== "")
|
|
214
|
+
current.texts.push(t);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
flush();
|
|
218
|
+
return turns;
|
|
219
|
+
}
|
|
220
|
+
function eventContent(payload) {
|
|
221
|
+
const content = payload?.content;
|
|
222
|
+
if (typeof content === "string")
|
|
223
|
+
return { blocks: [], text: content };
|
|
224
|
+
if (Array.isArray(content))
|
|
225
|
+
return { blocks: content };
|
|
226
|
+
return { blocks: [] };
|
|
227
|
+
}
|
|
228
|
+
function userEventText(payload) {
|
|
229
|
+
if (payload !== null &&
|
|
230
|
+
typeof payload === "object" &&
|
|
231
|
+
payload.synthetic === true) {
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
const { blocks, text } = eventContent(payload);
|
|
235
|
+
if (text !== undefined)
|
|
236
|
+
return text;
|
|
237
|
+
if (blocks.some((b) => b.type === "tool_result"))
|
|
238
|
+
return undefined;
|
|
239
|
+
const texts = blocks
|
|
240
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
241
|
+
.map((b) => b.text);
|
|
242
|
+
return texts.length > 0 ? texts.join("\n") : undefined;
|
|
243
|
+
}
|
|
244
|
+
function assistantEventText(payload) {
|
|
245
|
+
const { blocks, text } = eventContent(payload);
|
|
246
|
+
if (text !== undefined)
|
|
247
|
+
return text;
|
|
248
|
+
return blocks
|
|
249
|
+
.filter((b) => b.type === "text" && typeof b.text === "string")
|
|
250
|
+
.map((b) => b.text)
|
|
251
|
+
.join("\n");
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Extract durable, self-contained facts worth remembering from a session's
|
|
255
|
+
* turns. Deterministic (no model call) so it runs offline and in tests: it
|
|
256
|
+
* keeps each turn's final answer as one candidate fact, trimmed to a single
|
|
257
|
+
* sentence-ish line, dropping empty/echo/error turns and near-duplicates.
|
|
258
|
+
* Callers who have a model available can override with a summary; this is the
|
|
259
|
+
* always-available fallback the auto-capture path uses when no summarizer is
|
|
260
|
+
* injected.
|
|
261
|
+
*/
|
|
262
|
+
export function summarizeDurableFacts(turns, opts = {}) {
|
|
263
|
+
const maxFacts = opts.maxFacts ?? 8;
|
|
264
|
+
const maxLen = opts.maxLen ?? 240;
|
|
265
|
+
const seen = new Set();
|
|
266
|
+
const facts = [];
|
|
267
|
+
for (const t of turns) {
|
|
268
|
+
const answer = (t.output ?? "").trim();
|
|
269
|
+
if (answer === "")
|
|
270
|
+
continue;
|
|
271
|
+
// First non-empty line, collapsed whitespace — a durable one-liner.
|
|
272
|
+
const firstLine = answer
|
|
273
|
+
.split("\n")
|
|
274
|
+
.map((l) => l.trim())
|
|
275
|
+
.find((l) => l.length > 0);
|
|
276
|
+
if (firstLine === undefined)
|
|
277
|
+
continue;
|
|
278
|
+
let fact = firstLine.replace(/\s+/g, " ");
|
|
279
|
+
if (fact.length > maxLen)
|
|
280
|
+
fact = `${fact.slice(0, maxLen - 1).trimEnd()}…`;
|
|
281
|
+
const key = fact.toLowerCase();
|
|
282
|
+
if (seen.has(key))
|
|
283
|
+
continue;
|
|
284
|
+
seen.add(key);
|
|
285
|
+
facts.push(fact);
|
|
286
|
+
if (facts.length >= maxFacts)
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
return facts;
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Idempotently persist facts into a store, skipping any whose text (case- and
|
|
293
|
+
* whitespace-insensitively) already matches an existing entry. Returns the
|
|
294
|
+
* entries actually written. Re-running the same auto-capture never duplicates.
|
|
295
|
+
*/
|
|
296
|
+
export async function captureFacts(store, facts, tags = ["auto-capture"]) {
|
|
297
|
+
const written = [];
|
|
298
|
+
if (facts.length === 0)
|
|
299
|
+
return written;
|
|
300
|
+
// Pull existing entries once (via a broad recall over the fact tokens) so a
|
|
301
|
+
// re-run is a no-op. recall() needs a query; we normalize existing text by
|
|
302
|
+
// recalling each fact and checking for an exact normalized match.
|
|
303
|
+
const norm = (s) => s.toLowerCase().replace(/\s+/g, " ").trim();
|
|
304
|
+
const existing = new Set();
|
|
305
|
+
for (const fact of facts) {
|
|
306
|
+
for (const r of await store.recall(fact, 20))
|
|
307
|
+
existing.add(norm(r.entry.text));
|
|
308
|
+
}
|
|
309
|
+
const writtenNorms = new Set();
|
|
310
|
+
for (const fact of facts) {
|
|
311
|
+
const n = norm(fact);
|
|
312
|
+
if (existing.has(n) || writtenNorms.has(n))
|
|
313
|
+
continue;
|
|
314
|
+
writtenNorms.add(n);
|
|
315
|
+
written.push(await store.remember(fact, tags));
|
|
316
|
+
}
|
|
317
|
+
return written;
|
|
318
|
+
}
|
|
162
319
|
function isMemoryEntry(value) {
|
|
163
320
|
if (typeof value !== "object" || value === null)
|
|
164
321
|
return false;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/memory-store",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.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,7 @@
|
|
|
15
15
|
"test": "bun test src"
|
|
16
16
|
},
|
|
17
17
|
"dependencies": {
|
|
18
|
-
"@crewhaus/errors": "0.1
|
|
18
|
+
"@crewhaus/errors": "0.2.1"
|
|
19
19
|
},
|
|
20
20
|
"license": "Apache-2.0",
|
|
21
21
|
"author": {
|