@noir-ai/memory 1.0.0-beta.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.
@@ -0,0 +1,803 @@
1
+ import { ProjectId } from '@noir-ai/core';
2
+ export { ProjectId } from '@noir-ai/core';
3
+ import { Store } from '@noir-ai/store';
4
+ import { EmbedFn } from '@noir-ai/context';
5
+ export { EmbedFn, EmbedderConfig } from '@noir-ai/context';
6
+
7
+ /**
8
+ * Known observation types (dev-flavored, DS-3). `lesson` is reserved for
9
+ * consolidation output (task t5); the others are user-supplied on save. This
10
+ * list is intentionally NOT a closed set: {@link MemoryType} also accepts any
11
+ * unknown string so forward-compatible / user-defined types round-trip through
12
+ * the store without a migration.
13
+ */
14
+ declare const MEMORY_TYPES: readonly ["pattern", "preference", "architecture", "bug", "workflow", "fact", "decision", "lesson"];
15
+ /**
16
+ * Open enum: one of {@link MEMORY_TYPES} OR any string. The `(string & {})`
17
+ * intersection preserves literal autocompletion for the known values while
18
+ * permitting arbitrary user-defined types (DS-3: unknown values accepted +
19
+ * stored). `lesson` is reserved for consolidation output.
20
+ */
21
+ type MemoryType = (typeof MEMORY_TYPES)[number] | (string & {});
22
+ /**
23
+ * Provenance of a capture. `'explicit'` = a deliberate `memory_save` (the v1
24
+ * default, DS-4). `'auto:<hook>'` = an opt-in Claude Code hooks template
25
+ * capture (e.g. `'auto:stop'`, `'auto:posttooluse'`); the template ships as
26
+ * files the user installs deliberately — never auto-wired by `noir init`/`sync`.
27
+ */
28
+ type MemorySource = 'explicit' | `auto:${string}`;
29
+ /**
30
+ * Default observation salience (spec §4.1). Applied by `MemoryEngine.save` when
31
+ * {@link SaveInput.importance} is omitted, and by consolidation when appending a
32
+ * derived `type:'lesson'` row. Shared via types.ts so save + consolidation agree
33
+ * on the baseline without a cross-module value import.
34
+ */
35
+ declare const DEFAULT_IMPORTANCE = 0.5;
36
+ /**
37
+ * The canonical memory row. Realized ON TOP of the store (DS-2): the full row
38
+ * lives in KV `memory:obs:<id>` (the authoritative source of truth — the line
39
+ * between it and the indexes is R4's mitigation), with
40
+ * `indexDoc({source:'memory'})` (FTS5) + `upsertVec({source:'memory'})`
41
+ * (sqlite-vec) as search indexes. `content` is NEVER truncated (DS-9) — the
42
+ * FTS snippet is only a preview window hydrated-around on recall.
43
+ */
44
+ interface Observation {
45
+ /** Unique id (crypto.randomUUID()). Key into KV `memory:obs:<id>`. */
46
+ id: string;
47
+ /** Open enum (DS-3). `lesson` is reserved for consolidation output. */
48
+ type: MemoryType;
49
+ /** Full text — never truncated (DS-9). Indexed into FTS5 + embedded into vec0. */
50
+ content: string;
51
+ /** Canonical project id (NEVER a filesystem path — blueprint D6). */
52
+ project: ProjectId;
53
+ /** Host session id if known, else null. */
54
+ sessionId: string | null;
55
+ /** Created-at epoch millis. */
56
+ ts: number;
57
+ /** Bumped on recall hit (best-effort, single writer). */
58
+ lastAccessTs: number;
59
+ /** Salience 0..1 (default 0.5, applied at save time). */
60
+ importance: number;
61
+ /** User tags (no auto-LLM tagging in v1 — explicit only). */
62
+ concepts: string[];
63
+ /** Repo-relative paths mentioned. */
64
+ files: string[];
65
+ /** Capture provenance (DS-4). */
66
+ source: MemorySource;
67
+ /**
68
+ * Source observation ids, set ONLY on derived `type:'lesson'` rows produced
69
+ * by consolidation (task t5). Absent on user-saved observations. Originals
70
+ * are never mutated or deleted (append-only — reversible + auditable, DS-6).
71
+ */
72
+ provenance?: string[];
73
+ }
74
+ /**
75
+ * Input to {@link MemoryEngine.save} / the `memory_save` MCP tool. Only
76
+ * `content` is required; the engine applies defaults (`type`, `importance`,
77
+ * `source:'explicit'`, `ts`, `id`) at save time. No field here triggers a
78
+ * network or LLM call — capture is always local + free (DS-10).
79
+ */
80
+ interface SaveInput {
81
+ /** Full text to remember. Required. */
82
+ content: string;
83
+ /** Open enum (DS-3); a default is applied at save time when omitted. */
84
+ type?: MemoryType;
85
+ /** User tags. */
86
+ concepts?: string[];
87
+ /** Repo-relative paths mentioned. */
88
+ files?: string[];
89
+ /** Salience 0..1 (defaults to 0.5 at save time). */
90
+ importance?: number;
91
+ /** Host session id (recorded when known). */
92
+ sessionId?: string;
93
+ }
94
+ /**
95
+ * Options for {@link MemoryEngine.recall} (the hybrid path). Mirrors the S6
96
+ * retriever's source-scoped search plus memory-side filters.
97
+ */
98
+ interface RecallOptions {
99
+ /** Max results (default 10). */
100
+ limit?: number;
101
+ /** Filter to a single {@link MemoryType}. */
102
+ type?: MemoryType;
103
+ /** Filter to a single host session. */
104
+ sessionId?: string;
105
+ }
106
+ /** Options for {@link MemoryEngine.search} (the BM25-only instant path). */
107
+ interface SearchOptions {
108
+ /** Max results (default 10). */
109
+ limit?: number;
110
+ }
111
+ /**
112
+ * A ranked recall/search hit. `content` is the FULL observation text hydrated
113
+ * from the authoritative KV row — never the truncated FTS snippet (DS-9).
114
+ * `score` is the RRF-fused rank score (recall, k=60) or the BM25 score
115
+ * (search); it is rank-based, not a normalized similarity.
116
+ */
117
+ interface MemoryHit {
118
+ id: string;
119
+ type: MemoryType;
120
+ /** Full text — never truncated (DS-9). */
121
+ content: string;
122
+ /** RRF-fused rank score (recall) or BM25 score (search). */
123
+ score: number;
124
+ concepts: string[];
125
+ files: string[];
126
+ /** Created-at epoch millis (from the observation). */
127
+ ts: number;
128
+ importance: number;
129
+ source: MemorySource;
130
+ }
131
+ /**
132
+ * Per-session rollup, listed by {@link MemoryEngine.sessions}. Stored in KV
133
+ * `memory:sessions` as a per-project list (solo user v1 — A3).
134
+ */
135
+ interface SessionInfo {
136
+ /** Host session id. */
137
+ id: string;
138
+ /** Canonical project id (NEVER a filesystem path — D6). */
139
+ project: ProjectId;
140
+ /** Number of observations in this session. */
141
+ count: number;
142
+ /** Epoch millis of the most recent observation in this session. */
143
+ lastTs: number;
144
+ }
145
+ /**
146
+ * Consolidation config block. Provider-EXPLICIT (blueprint D5/D6, DS-6): the
147
+ * provider is NEVER inferred from env-var presence — no explicit `provider`
148
+ * ⇒ {@link MemoryEngine.consolidate} refuses + logs
149
+ * (`{ok:false, reason:'no-provider'}`) and writes a `memory:consolidation:miss`
150
+ * KV audit entry. NEVER a silent paid call.
151
+ */
152
+ interface ConsolidationConfig {
153
+ /** Master switch (default false). When false, `consolidate` is unregistered. */
154
+ enabled?: boolean;
155
+ /** Provider key, e.g. 'anthropic' | 'openai' | 'ollama'. Required to run. */
156
+ provider?: string;
157
+ /** Provider-specific model id. */
158
+ model?: string;
159
+ /** Restrict candidates to these types (default: every non-`lesson` type). */
160
+ types?: string[];
161
+ }
162
+ /**
163
+ * Runtime memory config consumed by the engine (the subset of the core
164
+ * `memory:` block that affects engine behavior). The full user-facing schema
165
+ * (capture / hooksTemplate / recall / consolidation) lives in
166
+ * `NoirConfigSchema.memory` (@noir-ai/core, task t6); this is its runtime
167
+ * projection. Defaults to `{}` ⇒ consolidation disabled (offline, free).
168
+ */
169
+ interface MemoryConfig {
170
+ consolidation?: ConsolidationConfig;
171
+ }
172
+ /**
173
+ * Result of {@link MemoryEngine.forget}: the KV row removed + best-effort
174
+ * doc/vec purge (deleteDoc + deleteVec — A2's acceptable v1 behavior).
175
+ */
176
+ interface ForgetResult {
177
+ /** Number of observations actually removed. */
178
+ deleted: number;
179
+ /** Ids passed to forget (echoed for the MCP envelope). */
180
+ ids: string[];
181
+ }
182
+ /** Options for {@link MemoryEngine.consolidate}. */
183
+ interface ConsolidateOptions {
184
+ /** Restrict candidates to these types (overrides config). */
185
+ types?: string[];
186
+ /** Cap on candidate observations. */
187
+ limit?: number;
188
+ }
189
+ /**
190
+ * Result of {@link MemoryEngine.consolidate} (DS-6). Either a success appending
191
+ * one or more derived `type:'lesson'` rows (originals never mutated), or a
192
+ * documented refusal. A refusal is NEVER a crash and NEVER a silent paid call:
193
+ * `logged:true` records the miss so the user can see why nothing happened.
194
+ */
195
+ type ConsolidationResult = {
196
+ ok: true;
197
+ lessons: Observation[];
198
+ from: string[];
199
+ } | {
200
+ ok: false;
201
+ /** Why consolidation did not run. */
202
+ reason: 'no-provider' | 'model-unavailable' | 'no-candidates';
203
+ /** True once the miss has been written to the KV audit log. */
204
+ logged: boolean;
205
+ };
206
+ /**
207
+ * Snapshot returned by {@link MemoryEngine.status} (mirrors `ContextStatus` /
208
+ * `StoreStatus`). `observations` is a live read off the single writer handle.
209
+ */
210
+ interface MemoryStatus {
211
+ ok: boolean;
212
+ /** Canonical project id (never a filesystem path — D6). */
213
+ projectId: string;
214
+ /** Number of observations (rows indexed with `source:'memory'`). */
215
+ observations: number;
216
+ /** True when the store handle is read-only (saves/forgets will refuse). */
217
+ degraded: boolean;
218
+ }
219
+ /**
220
+ * The memory engine — the `ctx.memory` service (the contract the daemon seam
221
+ * in task t4 types against, and tests mock). Constructed once per serve
222
+ * lifecycle from the daemon's store handle + the shared S6 `EmbedFn` (mirrors
223
+ * `ContextEngine` / `WorkflowEngine`). The daemon owns the single embedder and
224
+ * passes the SAME `EmbedFn` to both the context and memory engines — no
225
+ * embedder duplication (plan §Architecture).
226
+ *
227
+ * `consolidate` is OPTIONAL: registered only when consolidation is enabled AND
228
+ * a provider is configured (task t5, OQ-5). Its absence is the static signal
229
+ * that no LLM surface is wired (DS-6/D5).
230
+ *
231
+ * Single-writer discipline: the engine — like the context indexer — is the
232
+ * ONLY thing that writes `source:'memory'` rows through the injected handle; it
233
+ * never opens a second store connection (blueprint D6: in-process only, no
234
+ * sidecar).
235
+ */
236
+ interface MemoryEngine {
237
+ /** Persist an observation (FTS5 + vec0 + KV `memory:obs:<id>`); returns the row. */
238
+ save(input: SaveInput): Promise<Observation>;
239
+ /**
240
+ * Hybrid recall: BM25 ∪ kNN fused by RRF (k=60), scoped to `source:'memory'`,
241
+ * + cheap regex entity-boost, hydrated from KV (DS-5/DS-9). Degrades to
242
+ * BM25-only when the embedder is unavailable.
243
+ */
244
+ recall(query: string, opts?: RecallOptions): Promise<MemoryHit[]>;
245
+ /** Instant BM25-only lookup scoped to `source:'memory'` (no embed cost). */
246
+ search(query: string, opts?: SearchOptions): Promise<MemoryHit[]>;
247
+ /** Per-session rollups from KV `memory:sessions`. */
248
+ sessions(): SessionInfo[];
249
+ /** Remove observations: KV row + best-effort doc/vec purge. */
250
+ forget(ids: string[]): ForgetResult;
251
+ /**
252
+ * Explicit consolidation job (DS-6). Provider-gated: refuses + logs if no
253
+ * provider is configured — NEVER a silent paid call. Appends derived
254
+ * `type:'lesson'` rows; originals are never mutated.
255
+ */
256
+ consolidate?(opts?: ConsolidateOptions): Promise<ConsolidationResult>;
257
+ /** State snapshot (mirrors `ContextStatus` / `StoreStatus`). */
258
+ status(): MemoryStatus;
259
+ }
260
+
261
+ /**
262
+ * Known host hook events Noir can capture (the four Claude Code hooks the v1
263
+ * template targets, DS-4 / spec §5). The list is intentionally NOT closed:
264
+ * {@link CaptureEventType} also accepts any unknown string so a future host's
265
+ * hook names round-trip through the mapper without a code change (mirrors the
266
+ * open-enum taxonomy in types.ts).
267
+ */
268
+ declare const CAPTURE_HOOKS: readonly ["PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"];
269
+ /** One of {@link CAPTURE_HOOKS} OR any host-defined hook name (open enum). */
270
+ type CaptureEventType = (typeof CAPTURE_HOOKS)[number] | (string & {});
271
+ /**
272
+ * Hook events captured by DEFAULT (DS-4). Persist session-end summaries + the
273
+ * prompts a user submits; SKIP the noisy per-tool events (`PreToolUse` /
274
+ * `PostToolUse` fire on every tool call and would flood memory + leak tool
275
+ * inputs the user did not ask to remember). A user opts INTO tool-event capture
276
+ * by passing a custom {@link CapturePolicy.hooks}.
277
+ */
278
+ declare const DEFAULT_CAPTURE_HOOKS: readonly CaptureEventType[];
279
+ /**
280
+ * The default capture policy: capture {@link DEFAULT_CAPTURE_HOOKS} only. Used
281
+ * when {@link toSaveInput} is called with no `policy` argument. Host-neutral +
282
+ * conservative — the user owns the policy.
283
+ */
284
+ declare const DEFAULT_CAPTURE_POLICY: CapturePolicy;
285
+ /**
286
+ * The structured payload a host hook forwards. All fields optional — a hook
287
+ * populates whichever it has (a `Stop` hook carries a `summary`; a
288
+ * `UserPromptSubmit` hook carries the `prompt`; a tool hook carries `toolName`
289
+ * + `toolInput`). Unknown extra fields are tolerated (the mapper reads only the
290
+ * fields below), so a host can forward its raw stdin without shape-massaging.
291
+ */
292
+ interface CapturePayload {
293
+ /** Tool name for tool hooks (e.g. `'Bash'`, `'Edit'`, `'Write'`). */
294
+ toolName?: string;
295
+ /** The tool's input object (e.g. `{ command: 'npm test' }`, `{ file_path }`). */
296
+ toolInput?: unknown;
297
+ /** The submitted prompt text (`UserPromptSubmit`). */
298
+ prompt?: string;
299
+ /** A session-end summary (`Stop`) — free text the host derived or was given. */
300
+ summary?: string;
301
+ }
302
+ /**
303
+ * A host-neutral capture event. Built by a host hook (or a `noir memory capture`
304
+ * CLI) from whatever the host emitted on stdin, then handed to
305
+ * {@link toSaveInput}. The shape is deliberately minimal + portable so it is not
306
+ * tied to Claude Code's stdin schema (a future host adapter translates its own
307
+ * payload into this).
308
+ *
309
+ * `project` is the CANONICAL project id (NEVER a filesystem path — blueprint
310
+ * D6); the capture command resolves it from `cwd` before constructing the event.
311
+ */
312
+ interface CaptureEvent {
313
+ /** Hook event name (open enum — {@link CaptureEventType}). */
314
+ event_type: CaptureEventType;
315
+ /** Epoch millis of the event (host-supplied or `Date.now()` at capture). */
316
+ ts: number;
317
+ /** Host session id if known, else null (recorded on the observation). */
318
+ sessionId: string | null;
319
+ /** Canonical project identifier (NEVER a filesystem path — D6). */
320
+ project: ProjectId;
321
+ /** The event-specific fields (see {@link CapturePayload}). */
322
+ payload: CapturePayload;
323
+ }
324
+ /**
325
+ * An opinionated capture policy. Controls WHICH hook events are persisted when
326
+ * {@link toSaveInput} is called. Defaults to {@link DEFAULT_CAPTURE_POLICY}
327
+ * (session summaries + submitted prompts only). The user owns this — it is the
328
+ * one knob that keeps auto-capture from flooding memory (R3).
329
+ */
330
+ interface CapturePolicy {
331
+ /**
332
+ * Hook events to capture. An event whose `event_type` is NOT in this list is
333
+ * skipped (`toSaveInput` returns `null`). Defaults to
334
+ * {@link DEFAULT_CAPTURE_HOOKS}.
335
+ */
336
+ hooks?: ReadonlyArray<CaptureEventType>;
337
+ }
338
+ /**
339
+ * Project a host-neutral {@link CaptureEvent} into a {@link SaveInput} ready for
340
+ * `MemoryEngine.save` / the `memory_save` MCP tool, OR return `null` when the
341
+ * policy says to skip this event (a noisy hook the user did not opt into, or an
342
+ * event with no usable content).
343
+ *
344
+ * The mapper is PURE: it reads no environment, holds no secrets, performs NO I/O
345
+ * and NO LLM call (capture is always local + free, DS-10). It applies the
346
+ * opinionated defaults from spec §5:
347
+ * • only the policy's `hooks` are captured (default: Stop + UserPromptSubmit);
348
+ * • content is built from the event's payload fields (summary / prompt / a
349
+ * compact tool-call description);
350
+ * • `type` is inferred lightly (a decision-shaped prompt → `'decision'`; a
351
+ * tool hook → `'workflow'`; otherwise `'fact'`) — a heuristic the user can
352
+ * override by editing the saved row;
353
+ * • `files` is pulled best-effort from a tool input's `file_path`;
354
+ * • `sessionId` is forwarded when present.
355
+ *
356
+ * Returning `null` is the documented "skip" signal — the caller (a capture
357
+ * command) MUST NOT treat it as an error; it is the policy working as intended.
358
+ *
359
+ * @param event The host-neutral capture event.
360
+ * @param policy Capture policy (defaults to {@link DEFAULT_CAPTURE_POLICY}).
361
+ * @returns The {@link SaveInput}, or `null` to skip.
362
+ */
363
+ declare function toSaveInput(event: CaptureEvent, policy?: CapturePolicy): SaveInput | null;
364
+ /**
365
+ * Build the observation `content` for an event from its payload, or `null` when
366
+ * the payload carries nothing persistable. Per hook:
367
+ * • `Stop` → `payload.summary` (a session-end summary); null when absent.
368
+ * • `UserPromptSubmit` → `payload.prompt`; null when absent.
369
+ * • tool hooks (`PreToolUse` / `PostToolUse`, or any `toolName`-bearing
370
+ * event) → a compact {@link describeToolCall} line.
371
+ * Pure.
372
+ */
373
+ declare function buildContent(event: CaptureEvent): string | null;
374
+ /**
375
+ * Render a compact one-line description of a tool call for memory content, e.g.
376
+ * `"Ran Bash: npm test"` or `"Edited src/foo.ts"`. Returns `null` when the
377
+ * payload carries neither a toolName nor a toolInput. Pure + best-effort — it
378
+ * never throws on an unexpected toolInput shape (it renders what it can).
379
+ */
380
+ declare function describeToolCall(payload: CapturePayload): string | null;
381
+ /**
382
+ * Light type inference for a captured event (a heuristic — the user can override
383
+ * by editing the saved row; no LLM is involved, DS-10). Decision-shaped prompts
384
+ * → `'decision'`; tool hooks → `'workflow'`; otherwise `'fact'` (the engine's
385
+ * default type). Pure.
386
+ */
387
+ declare function inferType(event: CaptureEvent): MemoryType;
388
+ /**
389
+ * Extract repo-relative file paths from a tool input best-effort: the
390
+ * `file_path` field (Edit / Write / Read) when present. Returns `[]` when none.
391
+ * Pure; never throws on a non-object toolInput.
392
+ */
393
+ declare function extractFiles(payload: CapturePayload): string[];
394
+ /**
395
+ * The memory `source` provenance for a captured event: `'auto:<hook>'` (e.g.
396
+ * `'auto:stop'`, `'auto:posttooluse'`), lowercased so the bucket name is a
397
+ * stable identifier. Exported for a dedicated `noir memory capture` CLI (S9)
398
+ * that persists an event OUTSIDE the `memory_save` envelope and wants to tag
399
+ * provenance — the MCP `memory_save` path itself records `source:'explicit'`
400
+ * (SaveInput carries no source field by design).
401
+ */
402
+ declare function captureSource(eventType: CaptureEventType): MemorySource;
403
+
404
+ /**
405
+ * User-facing memory config shape — mirrors `NoirConfig['memory']` (the zod block
406
+ * @noir-ai/core ships, slice S7 / task t6). Declared LOCALLY with every field
407
+ * optional so this module type-checks WITHOUT a forward dependency on a core
408
+ * type (core never imports memory — no cycle; @noir-ai/core is not even
409
+ * consulted here), AND so a config with no `memory:` block (or a partial one)
410
+ * maps cleanly to a fully-disabled runtime config. The fully-resolved zod output
411
+ * is structurally assignable to this permissive shape, so the mapper accepts a
412
+ * `NoirConfig['memory']` directly.
413
+ */
414
+ interface MemoryUserConfig {
415
+ /** Consolidation gate (provider-explicit — never silent paid, DS-6). */
416
+ consolidation?: {
417
+ /** Master switch (default false). When false, `consolidate` refuses + logs. */
418
+ enabled?: boolean;
419
+ /** Provider key, e.g. 'anthropic' | 'openai' | 'ollama'. Required to run. */
420
+ provider?: string;
421
+ /** Provider-specific model id. */
422
+ model?: string;
423
+ /** Restrict candidates to these types (default: every non-`lesson` type). */
424
+ types?: string[];
425
+ };
426
+ }
427
+ /**
428
+ * Resolve a user-facing {@link MemoryUserConfig} into the runtime
429
+ * {@link MemoryConfig} the memory engine (and `runConsolidation`) consume.
430
+ *
431
+ * - `undefined` / missing block ⇒ `{ consolidation: { enabled: false } }`
432
+ * (consolidation disabled — the safe default; capture/store/retrieve stay
433
+ * local + free. `runConsolidation` then refuses with `'no-provider'` + logs,
434
+ * making NO paid call, blueprint D6).
435
+ * - A block whose `consolidation.enabled` is absent or `false` ⇒ the same
436
+ * disabled default, regardless of any `provider`/`model` written alongside it
437
+ * (the master switch is the first gate `runConsolidation` checks).
438
+ * - A block with `consolidation.enabled === true` + a `provider` ⇒ the provider
439
+ * + optional `model`/`types` pass straight through; `runConsolidation` then
440
+ * proceeds to its model-availability + candidate gates.
441
+ *
442
+ * `consolidation` is ALWAYS populated (with `enabled` defaulted) so consumers
443
+ * read `config.consolidation.enabled` without a separate undefined check —
444
+ * mirrors how `resolveModelConfig` normalizes `tiers`/`providers` to
445
+ * always-present objects.
446
+ *
447
+ * This mapper is a PURE projection — it copies fields through unchanged, NEVER
448
+ * infers a provider from env-var presence (DS-6), reads NO environment, holds NO
449
+ * secrets, and never throws. Whether a configured provider is actually USABLE is
450
+ * decided at call time inside `complete()` (S8, which re-reads env idempotently),
451
+ * NOT here.
452
+ */
453
+ declare function resolveMemoryConfig(raw?: MemoryUserConfig): MemoryConfig;
454
+
455
+ /**
456
+ * Per-call request shape passed to {@link MemoryModel.complete}. A structural
457
+ * subset of S8's `CompleteRequest` (provider-EXPLICIT; no `tools`/`stream` —
458
+ * single-shot only, blueprint D5). Defined locally so the memory package has no
459
+ * value-level dependency on `@noir-ai/model`; the daemon seam (t6) binds
460
+ * `complete(req, cfg)` into this shape, and tests inject a fake.
461
+ */
462
+ interface MemoryCompleteRequest {
463
+ system?: string;
464
+ prompt: string;
465
+ /** Provider block name — explicit, NEVER env-inferred (D5/DS-6). */
466
+ provider: string;
467
+ /** Model id for this call. */
468
+ model: string;
469
+ maxTokens?: number;
470
+ /** Bounded task tier (DS-9). Selects a per-tier output cap; never a provider. */
471
+ tier?: 'draft' | 'title' | 'summarize' | 'consolidate';
472
+ }
473
+ /**
474
+ * The subset of S8's `CompleteResult` that consolidation consumes. `null` is
475
+ * first-class degradation (no provider resolvable at call time — the always-
476
+ * available offline path, D5); `{ok:false}` is an attempted-call failure.
477
+ */
478
+ type MemoryCompleteResult = {
479
+ ok: true;
480
+ text: string;
481
+ } | {
482
+ ok: false;
483
+ reason: string;
484
+ } | null;
485
+ /**
486
+ * The optional S8 model injection. Its absence is the runtime signal that the
487
+ * bounded model layer is not wired (consolidation then refuses
488
+ * `'model-unavailable'` — the documented S7 stub, OQ-3/OQ-8). When present,
489
+ * `complete` is the SOLE LLM entry point and is reached ONLY after the provider
490
+ * gate passes (DS-6: never a silent paid call).
491
+ */
492
+ interface MemoryModel {
493
+ complete(req: MemoryCompleteRequest): Promise<MemoryCompleteResult>;
494
+ }
495
+ /** Construction options for {@link MemoryEngineImpl} / {@link createMemoryEngine}. */
496
+ interface MemoryEngineOptions {
497
+ /** The daemon's store handle — the ONLY storage surface used (single writer). */
498
+ store: Store;
499
+ /**
500
+ * Project root. Stored for API symmetry with {@link ContextEngine} and future
501
+ * path normalization; v1 stores {@link Observation.files} repo-relative as-is.
502
+ */
503
+ root: string;
504
+ /** Canonical project identifier (NEVER a filesystem path — blueprint D6). */
505
+ projectId: ProjectId;
506
+ /**
507
+ * The shared S6 embedder (the daemon resolves it once and passes the SAME
508
+ * `EmbedFn` to context + memory). A throw on `embed()` ⇒ the vec index is
509
+ * skipped for that observation (F8-style degradation — the row is still
510
+ * BM25-searchable via FTS5 + the authoritative KV row).
511
+ */
512
+ embed: EmbedFn;
513
+ /** Optional S8 model injection for consolidation (absent ⇒ consolidation refuses). */
514
+ model?: MemoryModel;
515
+ /** Runtime memory config (the consolidation gate). Defaults to `{}` (offline). */
516
+ config?: MemoryConfig;
517
+ /** True when `store` was opened read-only (the daemon-down fallback). */
518
+ storeDegraded?: boolean;
519
+ }
520
+ /**
521
+ * Noir's cross-session memory engine — the `ctx.memory` service. Constructed
522
+ * once per serve lifecycle (mirror `ContextEngine` / `WorkflowEngine`) from the
523
+ * daemon's store handle + the shared S6 `EmbedFn` + an optional S8 model.
524
+ * Implements the {@link MemoryEngine} contract; {@link get} is an extra public
525
+ * method (beyond the interface) for recall hydration + tests.
526
+ *
527
+ * `consolidate` is ALWAYS present on the class and self-gates on the configured
528
+ * provider; the daemon decides whether to register the `memory_consolidate` MCP
529
+ * tool from `config.memory.consolidation.enabled` (OQ-5). Its refusal paths are
530
+ * the documented stub behavior (spec §7) — never a crash, never a silent call.
531
+ */
532
+ declare class MemoryEngineImpl implements MemoryEngine {
533
+ /** The daemon's single-writer store handle (possibly read-only). */
534
+ readonly store: Store;
535
+ /** Project root (stored for symmetry / future path normalization). */
536
+ readonly root: string;
537
+ /** Canonical project identifier (NEVER a filesystem path — D6). */
538
+ readonly projectId: ProjectId;
539
+ /**
540
+ * Persistent degradation flag (read-only store). Mutating ops throw a clear
541
+ * error upfront instead of letting the first write fail mid-run; reads
542
+ * (recall/search/sessions/status) keep working.
543
+ */
544
+ readonly degraded: boolean;
545
+ private readonly embed;
546
+ private readonly model;
547
+ private readonly config;
548
+ private chain;
549
+ constructor(opts: MemoryEngineOptions);
550
+ /** @inheritDoc MemoryEngine.save */
551
+ save(input: SaveInput): Promise<Observation>;
552
+ private saveInternal;
553
+ /**
554
+ * Write one observation to ALL three indexes in a single synchronous block:
555
+ * FTS5 (`indexDoc`, content searchable) + sqlite-vec (`upsertVec`, best-effort
556
+ * — skipped if the embedder or vec0 is unavailable, F8-style) + the
557
+ * authoritative KV row (`memory:obs:<id>`) + the id index + the sessions
558
+ * rollup. The `docs.meta` payload is the denormalized search projection
559
+ * (Observation minus content); the KV row is the source of truth (R4).
560
+ */
561
+ private indexObservation;
562
+ /**
563
+ * Hydrate the full {@link Observation} for `id` from the authoritative KV row.
564
+ * Returns `null` for an unknown / forgotten id. This is the only correct way
565
+ * to read an observation's complete `content` (DS-9).
566
+ */
567
+ get(id: string): Observation | null;
568
+ /** @inheritDoc MemoryEngine.recall */
569
+ recall(query: string, opts?: RecallOptions): Promise<MemoryHit[]>;
570
+ /** @inheritDoc MemoryEngine.search */
571
+ search(query: string, opts?: SearchOptions): Promise<MemoryHit[]>;
572
+ /**
573
+ * Hydrate a ranked id list into {@link MemoryHit}s from the authoritative KV
574
+ * row, applying the optional `type` / `sessionId` filters. Each hit carries
575
+ * the FULL `content` (DS-9). A ranked id with no KV row (a stale vec-only hit
576
+ * whose row was forgotten) is dropped — never emitted with partial data.
577
+ */
578
+ private hydrateHits;
579
+ /** @inheritDoc MemoryEngine.sessions */
580
+ sessions(): SessionInfo[];
581
+ /** @inheritDoc MemoryEngine.forget */
582
+ forget(ids: string[]): ForgetResult;
583
+ /** @inheritDoc MemoryEngine.consolidate */
584
+ consolidate(opts?: ConsolidateOptions): Promise<ConsolidationResult>;
585
+ private consolidateInternal;
586
+ /** @inheritDoc MemoryEngine.status */
587
+ status(): MemoryStatus;
588
+ /**
589
+ * Best-effort embedding: returns the vec, or `null` if the embedder is
590
+ * unavailable (`kind:'none'`, native load failure, provider error). A `null`
591
+ * vec skips `upsertVec` so the save still succeeds — the row is BM25-searchable
592
+ * + hydrated from KV (F8-style degradation).
593
+ */
594
+ private embedBestEffort;
595
+ /** Throw a clear, typed error when the store handle is read-only. */
596
+ private assertNotDegraded;
597
+ /**
598
+ * Single-flight serialization of async mutating ops (mirrors the context
599
+ * indexer's `serialized`). Forces `work` to run strictly after the previous
600
+ * queued op completes. A failed op does NOT poison the queue — the chain
601
+ * advances regardless, and the caller observes the real outcome via `result`.
602
+ */
603
+ private serialized;
604
+ }
605
+ /**
606
+ * Build a {@link MemoryEngineImpl} bound to a single store handle + the shared
607
+ * S6 embedder + an optional S8 model. Constructed once per serve lifecycle
608
+ * alongside the context + workflow engines. The daemon passes the SAME `embed`
609
+ * it resolved for S6 (no embedder duplication) and resolves the model via S8's
610
+ * `resolveModelConfig` only when `config.memory.consolidation.enabled` is set
611
+ * (provider-explicit — never a silent paid call, D6).
612
+ */
613
+ declare function createMemoryEngine(opts: MemoryEngineOptions): MemoryEngineImpl;
614
+
615
+ /** Default cap on consolidation candidates (spec §7.4 — deterministic selection). */
616
+ declare const DEFAULT_CONSOLIDATE_LIMIT = 50;
617
+ /**
618
+ * Single-shot consolidation instruction (D5: no tools, no loop). The lesson is
619
+ * free-text PROSE (not JSON): a lesson is a synthesized insight, and free-text
620
+ * avoids a JSON-parse/repair round on what is naturally unstructured output.
621
+ */
622
+ declare const CONSOLIDATION_SYSTEM_PROMPT: string;
623
+ /**
624
+ * Capabilities `runConsolidation` needs from the engine. The engine owns the
625
+ * store handle + the embedder; `indexDerived` is the callback that writes a
626
+ * derived observation to every index (embed → FTS5 + vec + KV + id index),
627
+ * reusing the engine's shared `indexObservation` path so a lesson lands exactly
628
+ * like a user-saved observation. Consolidation itself never touches the
629
+ * embedder or the write path directly — it builds the lesson row + hands it off.
630
+ */
631
+ interface ConsolidationDeps {
632
+ /** The daemon's single-writer store handle (possibly read-only — D6). */
633
+ store: Store;
634
+ /** Optional S8 model injection; absent ⇒ `'model-unavailable'` refusal. */
635
+ model: MemoryModel | undefined;
636
+ /** Runtime memory config (the provider-explicit consolidation gate). */
637
+ config: MemoryConfig;
638
+ /** Canonical project identifier (NEVER a filesystem path — D6). */
639
+ projectId: ProjectId;
640
+ /**
641
+ * Write a derived lesson observation to all indexes. The engine implements
642
+ * this as `embedBestEffort(content)` then the shared `indexObservation(obs,
643
+ * vec)` — so the lesson is searchable (BM25 + vec) + hydrated from KV just
644
+ * like a user save.
645
+ */
646
+ indexDerived: (observation: Observation) => Promise<void>;
647
+ }
648
+ /**
649
+ * Run one explicit consolidation pass.
650
+ *
651
+ * Gate order (each refusal logs to `memory:consolidation:miss` and returns
652
+ * `logged:true`; NONE makes a paid call before its gate passes):
653
+ * 1. `no-provider` — consolidation not enabled OR no explicit `provider`
654
+ * configured. The provider is NEVER inferred from env-var presence (D5/D6).
655
+ * 2. `model-unavailable` — enabled + provider set, but the S8 model injection
656
+ * is absent OR no explicit `model` id is configured (the documented S7 stub,
657
+ * OQ-3/OQ-8). Also the refusal when `complete()` returns `null` (provider
658
+ * not resolvable at call time — e.g. key missing) OR `{ok:false}` (an
659
+ * attempted call failed): both are wrapped as `'model-unavailable'`.
660
+ * 3. `no-candidates` — nothing matches the (optional) type filter / lookback,
661
+ * OR the model returned empty text.
662
+ *
663
+ * On success: appends ONE derived `type:'lesson'` observation with
664
+ * `provenance:[candidate ids]` via `indexDerived`; originals are NEVER mutated
665
+ * (append-only — reversible + auditable, DS-6).
666
+ */
667
+ declare function runConsolidation(deps: ConsolidationDeps, opts?: ConsolidateOptions): Promise<ConsolidationResult>;
668
+ /**
669
+ * Gather consolidation candidates: observations with `type != 'lesson'`,
670
+ * newest-first, optionally restricted to `types`, capped at `limit`. Pure read
671
+ * off the id index + KV — no LLM, no clustering (spec §7.4). Exported so the
672
+ * deterministic selection is testable without a model.
673
+ */
674
+ declare function gatherCandidates(store: Store, types: ReadonlyArray<string> | undefined, limit: number): Observation[];
675
+ /**
676
+ * Serialize candidates into the consolidation prompt body (deterministic order,
677
+ * 1-indexed, with type + ts for the model's context). Pure.
678
+ */
679
+ declare function serializeCandidates(candidates: ReadonlyArray<Observation>): string;
680
+ /**
681
+ * De-duplicate + collect concept tags across the candidate set (becomes the
682
+ * derived lesson's `concepts`). Order is first-seen; pure.
683
+ */
684
+ declare function dedupeConcepts(candidates: ReadonlyArray<Observation>): string[];
685
+
686
+ /** The injected store handle + the shared S6 embedder (read-only pipeline). */
687
+ interface RecallDeps {
688
+ /** The daemon's single-writer store handle (may be read-only — reads keep working). */
689
+ store: Store;
690
+ /** Query embedder. A throw on `embed(query)` ⇒ BM25-only degradation. */
691
+ embed: EmbedFn;
692
+ }
693
+ /** Internal outcome of {@link recallMemory} (the engine projects this to `MemoryHit[]`). */
694
+ interface RecallMemoryResult {
695
+ /** Ranked, hydrated hits (FULL content — DS-9), truncated to `limit`. */
696
+ hits: MemoryHit[];
697
+ /** True when the kNN OR BM25 leg failed this call (honest per-query signal). */
698
+ degraded: boolean;
699
+ /** `'hybrid'` when the kNN leg ran, `'bm25-only'` when it was skipped. */
700
+ mode: 'hybrid' | 'bm25-only';
701
+ }
702
+ /**
703
+ * Cheap regex extraction of identifiers + file/path tokens from a query (NO LLM
704
+ * — DS-5). Two kinds of entity are collected, de-duplicated:
705
+ *
706
+ * 1. **Qualified tokens** — whitespace-delimited tokens that contain a `/`,
707
+ * `.`, or `:` (e.g. `packages/memory/src/recall.ts`, `memory:obs`,
708
+ * `MemoryEngine.save`). Kept whole + lowercased so they match an
709
+ * observation's `files` / `concepts` exactly when it mentions the same path
710
+ * or qualified name.
711
+ * 2. **Identifier subwords** — every token is also split at camelCase /
712
+ * PascalCase / snake_case / kebab-case boundaries into lowercase subwords
713
+ * (mirrors the index-side `explodeIdentifiers` convention from
714
+ * `@noir-ai/context`), so a query for `ContextEngine` yields `context` +
715
+ * `engine` and a query for `recall_memory` yields `recall` + `memory`.
716
+ *
717
+ * Tokens shorter than {@link MIN_ENTITY_LEN} and {@link STOPWORDS} are dropped
718
+ * to keep matching precise. Pure + deterministic.
719
+ */
720
+ declare function extractEntities(query: string): string[];
721
+ /**
722
+ * Run hybrid recall (BM25 ∪ kNN → RRF → entity-boost → KV hydration) scoped to
723
+ * `source:'memory'`. Read-only against the injected store; no second connection,
724
+ * no network, no LLM (blueprint D6).
725
+ *
726
+ * The returned hits carry the FULL `content` hydrated from the authoritative KV
727
+ * row (DS-9 — never the truncated FTS snippet). `degraded`/`mode` describe the
728
+ * actual outcome of THIS call: `mode:'bm25-only'` + `degraded:true` when the
729
+ * embedder was unavailable and recall fell back to BM25.
730
+ */
731
+ declare function recallMemory(deps: RecallDeps, query: string, opts?: RecallOptions): Promise<RecallMemoryResult>;
732
+
733
+ /** Per-observation authoritative-row key prefix; value is the full {@link Observation}. */
734
+ declare const OBS_PREFIX = "memory:obs:";
735
+ /** KV key holding the per-project {@link SessionInfo} rollup list. */
736
+ declare const SESSIONS_KEY = "memory:sessions";
737
+ /** KV key holding the sorted list of all observation ids (status + candidates). */
738
+ declare const INDEX_KEY = "memory:index";
739
+ /** KV key holding the consolidation refusal audit log (DS-6: refuse + LOG). */
740
+ declare const CONSOLIDATION_MISS_KEY = "memory:consolidation:miss";
741
+ /** Build a `memory:obs:<id>` KV key. */
742
+ declare function obsKey(id: string): string;
743
+ /**
744
+ * Hydrate the FULL {@link Observation} for `id` from the authoritative KV row.
745
+ * Returns `null` when the id was never saved (or has been forgotten — the
746
+ * tombstone reads back as `null`). This is the ONLY correct way to read an
747
+ * observation's complete `content` (DS-9: never the truncated FTS snippet).
748
+ */
749
+ declare function getObservation(store: Store, id: string): Observation | null;
750
+ /** Write the authoritative full row (the source of truth — DS-2/R4). */
751
+ declare function setObservation(store: Store, obs: Observation): void;
752
+ /**
753
+ * Clear the authoritative KV row for `id` (tombstone — mirrors the context
754
+ * indexer's `persist(..., tombstones)` which writes `null`). The id MUST also
755
+ * be dropped from {@link INDEX_KEY} and the session rollup by the caller so the
756
+ * index stays consistent (see engine.ts `forget`).
757
+ */
758
+ declare function clearObservation(store: Store, id: string): void;
759
+ /** All observation ids currently tracked (insertion order). Empty array if none. */
760
+ declare function getObservationIds(store: Store): string[];
761
+ /** Overwrite the full id list (used by `save`/`forget` after an atomic RMW). */
762
+ declare function setObservationIds(store: Store, ids: string[]): void;
763
+ /** The per-project {@link SessionInfo} rollup list (empty array if none). */
764
+ declare function getSessions(store: Store): SessionInfo[];
765
+ /** Overwrite the sessions rollup (used by the RMW helpers below). */
766
+ declare function setSessions(store: Store, sessions: SessionInfo[]): void;
767
+ /**
768
+ * Increment the rollup for `sessionId` (inserting a new entry when first seen),
769
+ * bumping `lastTs` to the max. RMW — call within the engine's serialized/sync KV
770
+ * block so two concurrent saves cannot lose an increment.
771
+ */
772
+ declare function bumpSession(store: Store, sessionId: string, project: SessionInfo['project'], ts: number): void;
773
+ /**
774
+ * Decrement the rollup for `sessionId`, dropping the entry when it reaches zero
775
+ * so a forgotten observation cleans up its own empty session. RMW — call within
776
+ * the engine's serialized/sync KV block. No-op for an unknown session.
777
+ */
778
+ declare function decrementSession(store: Store, sessionId: string): void;
779
+ /**
780
+ * One recorded consolidation refusal. `reason` is the documented
781
+ * {@link ConsolidationResult} refusal cause; `provider` is recorded when a
782
+ * provider WAS configured but the run still refused (e.g. the S8 layer was
783
+ * unavailable), so the user can see exactly why nothing happened.
784
+ */
785
+ interface ConsolidationMiss {
786
+ /** Epoch millis of the refusal. */
787
+ ts: number;
788
+ /** Documented refusal reason (`'no-provider' | 'model-unavailable' | 'no-candidates'`). */
789
+ reason: string;
790
+ /** Provider key, when one was configured (absent for the no-provider case). */
791
+ provider?: string;
792
+ }
793
+ /** The full refusal audit log (newest appended last). Empty array if none. */
794
+ declare function getConsolidationMisses(store: Store): ConsolidationMiss[];
795
+ /**
796
+ * Append a refusal record to the audit log. RMW — called ONLY from the engine's
797
+ * serialized `consolidate`, so the append cannot race itself. DS-6: a refusal is
798
+ * never silent — the miss is recorded so the user can see why no lesson was
799
+ * written (and that NO paid call was made).
800
+ */
801
+ declare function appendConsolidationMiss(store: Store, miss: ConsolidationMiss): void;
802
+
803
+ export { CAPTURE_HOOKS, CONSOLIDATION_MISS_KEY, CONSOLIDATION_SYSTEM_PROMPT, type CaptureEvent, type CaptureEventType, type CapturePayload, type CapturePolicy, type ConsolidateOptions, type ConsolidationConfig, type ConsolidationDeps, type ConsolidationMiss, type ConsolidationResult, DEFAULT_CAPTURE_HOOKS, DEFAULT_CAPTURE_POLICY, DEFAULT_CONSOLIDATE_LIMIT, DEFAULT_IMPORTANCE, type ForgetResult, INDEX_KEY, MEMORY_TYPES, type MemoryCompleteRequest, type MemoryCompleteResult, type MemoryConfig, type MemoryEngine, MemoryEngineImpl, type MemoryEngineOptions, type MemoryHit, type MemoryModel, type MemorySource, type MemoryStatus, type MemoryType, type MemoryUserConfig, OBS_PREFIX, type Observation, type RecallDeps, type RecallMemoryResult, type RecallOptions, SESSIONS_KEY, type SaveInput, type SearchOptions, type SessionInfo, appendConsolidationMiss, buildContent, bumpSession, captureSource, clearObservation, createMemoryEngine, decrementSession, dedupeConcepts, describeToolCall, extractEntities, extractFiles, gatherCandidates, getConsolidationMisses, getObservation, getObservationIds, getSessions, inferType, obsKey, recallMemory, resolveMemoryConfig, runConsolidation, serializeCandidates, setObservation, setObservationIds, setSessions, toSaveInput };