@gamaze/hicortex 0.22.1 → 0.22.3

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.
@@ -251,27 +251,34 @@ function createRecallRetrieveFn(deps) {
251
251
  }
252
252
  return ftsMemo.rows;
253
253
  };
254
- return async (query, limit, filters, sessionId, purePrompt) => {
255
- const { weight, alpha } = (0, retrieval_js_1.getSessionIntent)();
256
- const promptEmb = await embedOnce(query);
257
- const queryVec = (0, retrieval_js_1.recallQueryVector)(deps.registry, sessionId, promptEmb, {
258
- weight,
259
- alpha,
260
- purePrompt,
261
- });
262
- return (0, retrieval_js_1.retrieve)(deps.db, deps.embedFn, query, {
263
- limit,
264
- noStrengthen: true,
265
- // #203: project + mission_domains are SOFT affinity (zero-boost
266
- // neutral), threaded into computeScore.
267
- project: filters?.project,
268
- missionDomains: filters?.mission_domains,
269
- queryEmbedding: queryVec,
270
- // #329: shared per-request FTS list. The recall path never passes
271
- // sourceAgent, so the memo is keyed on (query, fetchLimit) only —
272
- // exactly the two things retrieve() would pass to searchFts.
273
- ftsCandidates: (fetchLimit) => ftsOnce(query, fetchLimit),
274
- });
254
+ return {
255
+ retrieveFn: async (query, limit, filters, sessionId, purePrompt) => {
256
+ const { weight, alpha } = (0, retrieval_js_1.getSessionIntent)();
257
+ const promptEmb = await embedOnce(query);
258
+ const queryVec = (0, retrieval_js_1.recallQueryVector)(deps.registry, sessionId, promptEmb, {
259
+ weight,
260
+ alpha,
261
+ purePrompt,
262
+ });
263
+ return (0, retrieval_js_1.retrieve)(deps.db, deps.embedFn, query, {
264
+ limit,
265
+ noStrengthen: true,
266
+ // #203: project + mission_domains are SOFT affinity (zero-boost
267
+ // neutral), threaded into computeScore.
268
+ project: filters?.project,
269
+ missionDomains: filters?.mission_domains,
270
+ queryEmbedding: queryVec,
271
+ // #329: shared per-request FTS list. The recall path never passes
272
+ // sourceAgent, so the memo is keyed on (query, fetchLimit) only —
273
+ // exactly the two things retrieve() would pass to searchFts.
274
+ ftsCandidates: (fetchLimit) => ftsOnce(query, fetchLimit),
275
+ });
276
+ },
277
+ // #476: the memoized prompt embed, EXPOSED so the precision recorder
278
+ // computes per-line cosines with ZERO additional embeds (the perf law —
279
+ // the memo semantics are unchanged: one embed per distinct query string
280
+ // per factory instance).
281
+ embedPrompt: embedOnce,
275
282
  };
276
283
  }
277
284
  /** Normalize a request-supplied string-list param: array of strings or a CSV
@@ -421,12 +428,48 @@ async function handleRecallIndex(deps, body) {
421
428
  picked = [...picked, ...backfill];
422
429
  }
423
430
  if (picked.length === 0) {
431
+ // #476: a silent turn (searches ran, no line passed the gates) still
432
+ // records its PUSH row — the silence rate is computable later and the
433
+ // judge's population stays complete. Zero event rows, no exposure touch.
434
+ // Fail-soft like every recording site.
435
+ if (deps.precision) {
436
+ try {
437
+ deps.precision.recorder(deps.db, { sessionId, prompt, ids: [] });
438
+ }
439
+ catch (err) {
440
+ console.warn(`[hicortex] recall-precision push recording failed (fail-soft): ` +
441
+ `${err instanceof Error ? err.message : String(err)}`);
442
+ }
443
+ }
424
444
  return { status: 200, body: { block: null, shown: [], turn } };
425
445
  }
426
446
  const ids = picked.map((r) => r.id);
427
447
  deps.registry.markShown(sessionId, ids);
428
- // Exposure signal: shown_count + last_accessed refresh, NOT access_count.
429
- storage.touchMemoriesShown(deps.db, ids, new Date().toISOString());
448
+ const nowIso = new Date().toISOString();
449
+ // #476 Memory Precision: record the push + per-line events IN THE SAME
450
+ // TRANSACTION as the exposure write (the recorder owns the unit — it calls
451
+ // touchMemoriesShown inside its own transaction). FAIL-SOFT: on any error
452
+ // (or when the seams are absent — library callers, test doubles) the plain
453
+ // exposure write runs instead, so shown_count NEVER depends on telemetry.
454
+ let exposureWritten = false;
455
+ if (deps.precision) {
456
+ try {
457
+ const [promptEmb, basis] = await Promise.all([
458
+ deps.precision.promptEmbed(prompt),
459
+ deps.precision.basis(deps.db),
460
+ ]);
461
+ deps.precision.recorder(deps.db, { ts: nowIso, sessionId, prompt, ids, promptEmbedding: promptEmb }, basis);
462
+ exposureWritten = true;
463
+ }
464
+ catch (err) {
465
+ console.warn(`[hicortex] recall-precision recording failed (fail-soft): ` +
466
+ `${err instanceof Error ? err.message : String(err)}`);
467
+ }
468
+ }
469
+ if (!exposureWritten) {
470
+ // Exposure signal: shown_count + last_accessed refresh, NOT access_count.
471
+ storage.touchMemoriesShown(deps.db, ids, nowIso);
472
+ }
430
473
  const lines = picked.map((r) => formatIndexLine(r, titleChars));
431
474
  const block = [
432
475
  "## Memory recall (auto)",
@@ -456,7 +499,7 @@ async function handleRecallIndex(deps, body) {
456
499
  * never filtered. Callers may still send a `privacy` field (backward compat)
457
500
  * but it is ignored.
458
501
  */
459
- function handleMemoryGet(db, query) {
502
+ function handleMemoryGet(db, query, deps) {
460
503
  const id = typeof query.id === "string" ? query.id : "";
461
504
  if (!id)
462
505
  return { status: 400, body: { error: "Missing 'id'" } };
@@ -471,6 +514,18 @@ function handleMemoryGet(db, query) {
471
514
  if (!mem)
472
515
  return notFound;
473
516
  storage.strengthenMemory(db, fullId, new Date().toISOString());
517
+ // #476: one kind='fetch' precision event per successful fetch (the use
518
+ // signal for Level 2 / divergence). Fail-soft — the fetch result is never
519
+ // affected by a recording failure.
520
+ if (deps?.recordFetch) {
521
+ try {
522
+ deps.recordFetch(db, fullId, new Date().toISOString());
523
+ }
524
+ catch (err) {
525
+ console.warn(`[hicortex] recall-precision fetch recording failed (fail-soft): ` +
526
+ `${err instanceof Error ? err.message : String(err)}`);
527
+ }
528
+ }
474
529
  // `citation` is server-rendered so every plugin surfaces the same built-in
475
530
  // provenance norm (owner directive 27.07) — see #193.
476
531
  const date = (mem.created_at ?? "").slice(0, 10);
@@ -498,8 +553,10 @@ function handleMemoryGet(db, query) {
498
553
  * built inline, while the REST path used handleMemoryGet — same contract, two
499
554
  * implementations, one updated).
500
555
  */
501
- function formatMemoryGetText(db, query) {
502
- const r = handleMemoryGet(db, query);
556
+ function formatMemoryGetText(db, query, deps) {
557
+ // deps (the #476 fetch recorder) forwards so the MCP path records exactly
558
+ // like the REST path — one funnel, one event per fetch.
559
+ const r = handleMemoryGet(db, query, deps);
503
560
  if (r.status !== 200) {
504
561
  return { status: r.status, text: String(r.body.error ?? `No memory with id ${query.id ?? ""}`) };
505
562
  }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Memory Precision (#476) — the event store behind the console's Memory
3
+ * Precision card, and the two-level context model made visible:
4
+ *
5
+ * - Level 1 (pushed index): what every prompt receives unasked. One
6
+ * `recall_pushes` row per NON-skipped /recall-index call + one
7
+ * `recall_events` kind='shown' row per pushed line, carrying the RAW
8
+ * cosines (similarity to the prompt, redundancy vs standing context) —
9
+ * stored threshold-free, verdicts computed at render so recalibration
10
+ * never rewrites history.
11
+ * - Level 2 (recall depth): what the agent fetches when needed. One
12
+ * `recall_events` kind='fetch' row per handleMemoryGet — the ONE funnel
13
+ * the REST GET /memory and MCP hicortex_get paths share.
14
+ *
15
+ * Layering (the recall-index.ts / capture-health.ts convention): all write +
16
+ * aggregation logic lives here as pure functions over the db handle so tests
17
+ * exercise them without HTTP; mcp-server.ts only wires the record calls into
18
+ * the /recall-index and fetch funnels, and dashboard.ts spreads the window
19
+ * aggregation into /dashboard/data.
20
+ *
21
+ * FAIL-SOFT LAW: recording is telemetry. Any error here is caught by the
22
+ * CALLER (handleRecallIndex / handleMemoryGet) and swallowed — the recall
23
+ * response (status 200 + block) is never affected, and the exposure signal
24
+ * (touchMemoriesShown) never depends on it (the handler falls back to the
25
+ * plain exposure write when recording is absent or fails).
26
+ *
27
+ * The perf law (bounded hot-path cost): the recorder computes cosines with
28
+ * the request's ALREADY-MEMOIZED prompt embedding (createRecallRetrieveFn's
29
+ * embedPrompt — zero extra embeds) against STORED memory vectors, and writes
30
+ * one transaction of ≤ maxItems+1 INSERTs — microseconds inside an endpoint
31
+ * that already writes and spends ~100-300 ms embedding and searching.
32
+ */
33
+ import type Database from "better-sqlite3";
34
+ /** One non-skipped /recall-index call to record (recorder seam contract —
35
+ * recall-index.ts types its precision deps against this). */
36
+ export interface RecallPushEntry {
37
+ /** ISO timestamp of the push; defaults to now. */
38
+ ts?: string;
39
+ /** The request's session_id (nullable — the wire field is optional). */
40
+ sessionId: string | null;
41
+ /** The prompt text (only the ≤256-char excerpt is persisted). */
42
+ prompt: string;
43
+ /** The shown memory ids, in index order. EMPTY = silent turn (the push row
44
+ * is still recorded — the silence rate is computable and the judge's
45
+ * population complete — but no event rows, no exposure touch). */
46
+ ids: string[];
47
+ /** The PURE-prompt embedding — REQUIRED when ids.length > 0 (the request's
48
+ * memoized embed; unused on a silent turn, so no embed is ever spent on
49
+ * recording). */
50
+ promptEmbedding?: Float32Array;
51
+ }
52
+ /**
53
+ * Record one /recall-index push: the push row + (when lines were shown) the
54
+ * per-line kind='shown' event rows, in ONE transaction WITH the existing
55
+ * exposure write (storage.touchMemoriesShown — the spec's "same transaction";
56
+ * better-sqlite3 nests the inner transaction as a savepoint, so a recording
57
+ * failure rolls back the whole unit and the CALLER's fallback re-runs the
58
+ * exposure touch alone — shown_count never depends on telemetry).
59
+ *
60
+ * similarity = cosine(memory embedding, PURE prompt embedding), computed
61
+ * UNIFORMLY via cosineBetweenVectors against the stored vector — FTS-sourced
62
+ * picks (which bypass the cosine floor and carry similarity:null in
63
+ * MemorySearchResult) get a measured value here too. redundancy = MAX cosine
64
+ * against the standing-context basis vectors (NULL when the basis is empty —
65
+ * embed failure degrades to "unmeasured", never a guessed 0). Both stored
66
+ * raw; no thresholds touch this row.
67
+ */
68
+ export declare function recordRecallPush(db: Database.Database, entry: RecallPushEntry, basis?: Float32Array[]): void;
69
+ /** Fetch-event recorder contract (handleMemoryGet's optional #476 dep —
70
+ * mcp-server wires recordRecallFetch; tests can force a failure). */
71
+ export type RecallFetchRecorder = (db: Database.Database, memoryId: string, ts?: string) => void;
72
+ /**
73
+ * Record one explicit fetch (handleMemoryGet — the funnel both REST GET
74
+ * /memory and MCP hicortex_get route through, so the row is written exactly
75
+ * once per fetch). similarity/redundancy are NULL on fetch rows: Level 2 is
76
+ * the use signal, not a relevance measure. push_id NULL — a fetch stands
77
+ * alone. Fail-soft at the CALLER (handleMemoryGet wraps this).
78
+ */
79
+ export declare function recordRecallFetch(db: Database.Database, memoryId: string, ts?: string): void;
80
+ /**
81
+ * Nightly retention prune (zero-LLM): drop both event tables' rows outside
82
+ * the rolling window whose length IS the retention constant (the
83
+ * CAPTURE_HEALTH_WINDOW_DAYS single-constant law — the card can never claim a
84
+ * window the store no longer has rows for). Inclusive cutoff: the last N
85
+ * calendar days with today counted stay (day >= today−(N−1)), the complement
86
+ * is deleted — retained rows == window rows.
87
+ */
88
+ export declare function pruneRecallPrecision(db: Database.Database): void;
89
+ /**
90
+ * True when the identity scope is served to EVERY known client — the only
91
+ * case where identity sections belong in the corpus-level standing-context
92
+ * basis. /recall-index carries no client type, so a SCOPED identity config
93
+ * (any subset) excludes identity rather than guessing which sections the
94
+ * calling session sees. `clients` is the boot-resolved
95
+ * resolveIdentityClientsConfig output; the absent-config default ["cc"] is
96
+ * NOT all → identity excluded.
97
+ */
98
+ export declare function identityServedToAllClients(clients: string[]): boolean;
99
+ export interface StandingContextBasisConfig {
100
+ /** The daemon's boot-resolved identityClients (read lazily — assigned at
101
+ * boot, after the routes that consume the basis are registered). */
102
+ clients: () => string[];
103
+ /** The daemon's identity dir (sections read only when all-clients). */
104
+ identityDir: () => string;
105
+ /** Embedding seam — tests inject a deterministic embedder. Default embedBatch. */
106
+ embedFn?: (texts: string[]) => Promise<Float32Array[]>;
107
+ /** Clock seam (tests). Default Date.now. */
108
+ now?: () => number;
109
+ /** Cache TTL seam (tests). Default 24h. */
110
+ ttlMs?: number;
111
+ }
112
+ /** The basis provider: db → basis vectors (may be empty). Callable, plus a
113
+ * test-only cache reset (the cache is deliberately per-provider so suites
114
+ * with different configs never share it). */
115
+ export interface StandingContextBasis {
116
+ (db: Database.Database): Promise<Float32Array[]>;
117
+ resetForTests(): void;
118
+ }
119
+ /**
120
+ * The standing-context basis: the top lessons in /learnings order
121
+ * (storage.getLessons(db, 30) — exactly what the /learnings handler serves
122
+ * and every client's session-start hook injects) PLUS identity sections ONLY
123
+ * when identityServedToAllClients (see above). Embedded with the LOCAL
124
+ * embedder — no LLM anywhere in this feature. Cached for 24h (single-flight:
125
+ * concurrent callers share the embedding pass); an embed failure degrades to
126
+ * an EMPTY cached basis (redundancy NULL — "unmeasured", never guessed) and
127
+ * is retried after the TTL.
128
+ */
129
+ export declare function createStandingContextBasis(config: StandingContextBasisConfig): StandingContextBasis;
130
+ /** The Level-1 half: pushed-index precision proxies over the window's stored
131
+ * events. Thresholds are ECHOED (the page never hardcodes an edge);
132
+ * mean/share are computed at read time from raw values. */
133
+ export interface MemoryPrecisionLevel1 {
134
+ /** Non-skipped /recall-index calls in the window (silent turns included). */
135
+ pushes: number;
136
+ /** Pushed index lines (kind='shown' event rows) in the window. */
137
+ lines: number;
138
+ /** Mean cosine(line, its prompt) over lines with a measured similarity;
139
+ * null when nothing was measured. */
140
+ mean_similarity: number | null;
141
+ /** Share of measured lines at/above the redundancy threshold; null when
142
+ * nothing was measured (empty/unmeasured — undefined, not zero). */
143
+ redundant_share: number | null;
144
+ thresholds: {
145
+ /** The redundancy render edge (calibration MEMORY_PRECISION_REDUNDANT_ABOVE). */
146
+ redundant_above: number;
147
+ /** The divergence bar (calibration MEMORY_PRECISION_DIVERGENCE_MIN_SHOWN). */
148
+ divergence_min_shown: number;
149
+ };
150
+ }
151
+ /** The Level-2 half: recall depth (fetches per showing) over the window,
152
+ * derived from corpus-wide cumulative counter DELTAS — live adoption minus
153
+ * the newest pre-window snapshot. No second source of truth: the events
154
+ * above are per-line; this is the corpus aggregate the nightly already
155
+ * snapshots. Null = no honest value (no pre-window baseline), never 0. */
156
+ export interface MemoryPrecisionLevel2 {
157
+ shown: number | null;
158
+ used: number | null;
159
+ uses_per_showing: number | null;
160
+ }
161
+ /** The whole /dashboard/data memory_precision block (one card's payload,
162
+ * budget ≤ 2 KB — the top-3 divergence lines are the only content). */
163
+ export interface MemoryPrecision {
164
+ /** The EFFECTIVE window (min(range, retention)); the page renders
165
+ * "last N days" from this echo, never a hardcoded literal. */
166
+ window_days: number;
167
+ level1: MemoryPrecisionLevel1;
168
+ level2: MemoryPrecisionLevel2;
169
+ divergence: {
170
+ /** Live memories with ≥ thresholds.divergence_min_shown window showings
171
+ * and ZERO window fetches — the index keeps pushing, nothing reads. */
172
+ count: number;
173
+ /** Top-3 by window showings DESC, lines rendered through the SHARED
174
+ * formatIndexLine (≤ RECALL_TITLE_CHARS — the card cannot drift from
175
+ * what agents see). Hover-only on the card face (no memory lists on
176
+ * the main page — owner ruling 2026-09-12). */
177
+ top: Array<{
178
+ id: string;
179
+ line: string;
180
+ shown: number;
181
+ }>;
182
+ };
183
+ }
184
+ /** Read-time seams (the #408 experiment pattern — the eval/tests sweep
185
+ * values through them; production never passes anything). */
186
+ export interface MemoryPrecisionReadOptions {
187
+ /** Fixed clock (tests); default now. */
188
+ now?: Date;
189
+ /** Redundancy render threshold override (tests — changing it must move
190
+ * only the share, never a stored row). */
191
+ redundantAbove?: number;
192
+ }
193
+ /**
194
+ * The window aggregation behind /dashboard/data's memory_precision block
195
+ * (the readCaptureHealthWindow pattern). Effective window = min(range,
196
+ * MEMORY_PRECISION_WINDOW_DAYS) for every range incl. 180d/all (the #452
197
+ * selector's wide options clamp to what the retention honestly holds).
198
+ *
199
+ * Level 1 groups the stored event rows over the window (day >= today−(N−1),
200
+ * the inclusive capture-health convention); Level 2 is the corpus-wide
201
+ * adoption delta — live sums minus the NEWEST non-null `adoption` in
202
+ * dashboard_snapshots at/before now−N days (cumulative counters telescope:
203
+ * a missing nightly widens the effective window rather than corrupting it;
204
+ * two snapshots on one day are harmless — the newest wins; no pre-window
205
+ * baseline → null, undefined rather than zero). Divergence joins the
206
+ * window's per-memory shown/fetch counts against LIVE (non-absorbed)
207
+ * memories and renders the top-3 through the shared index-line path.
208
+ */
209
+ export declare function readMemoryPrecision(db: Database.Database, range: string, liveAdoption: {
210
+ shown_sum: number;
211
+ used_sum: number;
212
+ }, opts?: MemoryPrecisionReadOptions): MemoryPrecision;