@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 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.8",
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.8"
18
+ "@crewhaus/errors": "0.2.1"
19
19
  },
20
20
  "license": "Apache-2.0",
21
21
  "author": {