@gamaze/hicortex 0.19.2 → 0.19.4

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/nightly.js CHANGED
@@ -45,6 +45,8 @@ var __importStar = (this && this.__importStar) || (function () {
45
45
  };
46
46
  })();
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
+ exports.computeSince = computeSince;
49
+ exports.parseRetryAfterMs = parseRetryAfterMs;
48
50
  exports.runNightly = runNightly;
49
51
  const paths_js_1 = require("./paths.js");
50
52
  const node_fs_1 = require("node:fs");
@@ -77,6 +79,17 @@ const telemetry_js_1 = require("./telemetry.js");
77
79
  const init_js_1 = require("./init.js");
78
80
  const backup_js_1 = require("./backup.js");
79
81
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
82
+ /**
83
+ * Consolidate-only backup gate (#327 CR blocker). Hosted tenants run ONLY
84
+ * `nightly --consolidate-only` several times a day (hicortex-consolidate@.timer)
85
+ * and the provisioner has no backup job of its own, so those runs DO run the
86
+ * backup stage — but at most once per day, keyed on the newest existing
87
+ * artifact's age (the artifact IS the marker; no extra state file). 20h is
88
+ * slightly under a day so a timer firing at a drifting clock hour still gets
89
+ * exactly one backup per day; with `backupRetention` (default 7) the artifact
90
+ * count stays bounded.
91
+ */
92
+ const CONSOLIDATE_ONLY_BACKUP_MIN_AGE_MS = 20 * 60 * 60 * 1000;
80
93
  function readNightlyConfig(stateDir) {
81
94
  const configPath = (0, node_path_1.join)(stateDir, "config.json");
82
95
  let loaded;
@@ -125,14 +138,30 @@ function readLastRun(stateDir = HICORTEX_HOME) {
125
138
  * skip (then, via writeLastRun, permanently lose) the 8-to-N-day-old sessions
126
139
  * (#189 review, fix 3). Per-session cursors keep the wide re-scan cheap: an
127
140
  * already-captured session yields an empty delta.
141
+ *
142
+ * Clock-jump clamp (#327): a FUTURE-dated lastNightly (client clock error —
143
+ * NTP not yet synced at write time) would, once the clock corrects, sit ahead
144
+ * of every session mtime and permanently skip quiet sessions (their mtimes
145
+ * never re-cross a future watermark). Clamped to `now` with a warn; the warn
146
+ * fires once per affected run (this function runs once per nightly).
147
+ * `now` is injectable for tests.
128
148
  */
129
- function computeSince(stateDir, recaptureWindowDays) {
149
+ function computeSince(stateDir, recaptureWindowDays, now = new Date()) {
130
150
  const lastRun = readLastRun(stateDir);
151
+ let effective = lastRun;
152
+ if (lastRun.getTime() > now.getTime()) {
153
+ console.warn(`[hicortex] state lastNightly (${lastRun.toISOString()}) is ahead of the clock ` +
154
+ `(${now.toISOString()}) — clamping discovery to now. A future watermark permanently ` +
155
+ `skips quiet sessions once the clock corrects; check the machine's clock/NTP. ` +
156
+ `Run \`hicortex nightly --recapture-window <days>\` to recover sessions missed ` +
157
+ `while the clock was wrong.`);
158
+ effective = now;
159
+ }
131
160
  if (recaptureWindowDays && recaptureWindowDays > 0) {
132
- const windowStart = new Date(Date.now() - recaptureWindowDays * 24 * 60 * 60 * 1000);
133
- return windowStart < lastRun ? windowStart : lastRun;
161
+ const windowStart = new Date(now.getTime() - recaptureWindowDays * 24 * 60 * 60 * 1000);
162
+ return windowStart < effective ? windowStart : effective;
134
163
  }
135
- return lastRun;
164
+ return effective;
136
165
  }
137
166
  /** POST /distill transport for server mode — localhost. Sends authToken so
138
167
  * self-capture works regardless of the localhost-bypass marker (#271 root-cause fix). */
@@ -185,7 +214,32 @@ async function normalizePostResult(resp) {
185
214
  return { status: 200, skipped: Boolean(data.skipped) };
186
215
  }
187
216
  const data = (await resp.json().catch(() => ({})));
188
- return { status: resp.status, error: data.error ?? "unknown error" };
217
+ const retryAfterMs = parseRetryAfterMs(resp);
218
+ return {
219
+ status: resp.status,
220
+ error: data.error ?? "unknown error",
221
+ // #327: surface Retry-After so a rate-limit 429 backs off as the server
222
+ // asked (absent on the tenant's terminal budget-429, which ignores it).
223
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
224
+ };
225
+ }
226
+ /**
227
+ * Parse a `Retry-After` header into ms (#327). Handles both RFC forms —
228
+ * delay-seconds (`"30"`) and HTTP-date — and returns undefined for anything
229
+ * unparseable (the caller then falls back to its own backoff schedule).
230
+ * Exported for unit tests (pure on the header value).
231
+ */
232
+ function parseRetryAfterMs(resp) {
233
+ const v = resp.headers.get("retry-after");
234
+ if (!v)
235
+ return undefined;
236
+ const secs = Number(v);
237
+ if (Number.isFinite(secs) && secs >= 0)
238
+ return secs * 1000;
239
+ const at = new Date(v).getTime();
240
+ if (Number.isFinite(at))
241
+ return Math.max(0, at - Date.now());
242
+ return undefined;
189
243
  }
190
244
  /** Strict {prompt, completion, total} parser for /distill's usage field (#287);
191
245
  * undefined on anything malformed — the caller then treats it as unmetered. */
@@ -296,7 +350,15 @@ async function runNightly(options = {}) {
296
350
  const cooldownH = (0, config_read_js_1.readNonNegativeConfig)(savedConfig ?? {}, "captureCooldownHours", 6);
297
351
  const last = (0, state_js_1.loadState)(stateDir).lastNightly;
298
352
  if (last) {
299
- const ageH = (Date.now() - new Date(last).getTime()) / 3_600_000;
353
+ // #327 clamp: a FUTURE-dated stamp (clock error at write time) reads as
354
+ // a NEGATIVE age raw, and `negative < cooldownH` is true even at the
355
+ // cooldown-0 opt-in ("capture every poll") — the watchdog would stay
356
+ // silent until real time passed the future stamp, exactly when catch-up
357
+ // ticks are most needed. Clamped, the worst honest reading is "captured
358
+ // just now", which the normal cooldown handles (and discovery applies
359
+ // the same clamp in computeSince).
360
+ const lastMs = Math.min(new Date(last).getTime(), Date.now());
361
+ const ageH = (Date.now() - lastMs) / 3_600_000;
300
362
  if (ageH < cooldownH) {
301
363
  console.log(`[hicortex] watchdog: last capture ${ageH.toFixed(1)}h ago (< ${cooldownH}h cooldown) — skipping`);
302
364
  return;
@@ -668,21 +730,52 @@ async function runNightly(options = {}) {
668
730
  // Backup stage (#6, Phase 0B) — a transactionally-consistent snapshot of
669
731
  // the irreplaceable data (DB + identity + state), packaged as one tar.gz
670
732
  // the operator ships offsite via the optional `backupCommand` hook. Runs
671
- // ONLY on a full nightly (capture-only is frequent + stateless; dry-run
672
- // writes nothing). Backup failure must NOT fail the nightly — capture +
673
- // consolidation have already succeeded; the snapshot is on disk and the
674
- // failure surfaces as `backupOk:false` in the dashboard snapshot + telemetry
675
- // for alerting (the operator's hook owns active alerting; no in-product
733
+ // on every full nightly and on consolidate-only runs (capture-only is
734
+ // frequent + stateless; dry-run writes nothing). Consolidate-only MUST
735
+ // back up (#327 CR blocker): hosted tenants run ONLY --consolidate-only
736
+ // several times a day (hicortex-consolidate@.timer) and the provisioner
737
+ // has no backup job of its own skipping the stage left them with NO
738
+ // recurring backup. Their cadence is bounded by the artifact-age gate
739
+ // below (~1/day) instead, so `backupRetention` keeps the dir bounded.
740
+ // Backup failure must NOT fail the nightly — capture + consolidation
741
+ // have already succeeded; the snapshot is on disk and the failure
742
+ // surfaces as `backupOk:false` in the dashboard snapshot + telemetry for
743
+ // alerting (the operator's hook owns active alerting; no in-product
676
744
  // channel yet — Phase 3).
677
745
  let backupPath;
678
746
  let backupBytes;
679
747
  let backupOk;
680
- if (!dryRun && !captureOnly) {
748
+ // Artifact-age gate for consolidate-only runs: skip only while the newest
749
+ // existing artifact is younger than ~20h. 20h (not 24) so a timer firing
750
+ // at a drifting clock hour still gets exactly one backup per day, and
751
+ // robust to an hour of clock skew either way. Keyed on the artifact, not
752
+ // a state timestamp — no new state file to drift out of sync with the
753
+ // disk it describes.
754
+ const effectiveBackupDir = typeof savedConfig?.backupDir === "string" && savedConfig.backupDir.trim()
755
+ ? savedConfig.backupDir
756
+ : (0, node_path_1.join)(stateDir, "backups");
757
+ let skipBackup = false;
758
+ let newestArtifactMs;
759
+ if (consolidateOnly) {
760
+ newestArtifactMs = (0, backup_js_1.newestBackupArtifactMs)(effectiveBackupDir);
761
+ skipBackup =
762
+ newestArtifactMs !== undefined &&
763
+ Date.now() - newestArtifactMs < CONSOLIDATE_ONLY_BACKUP_MIN_AGE_MS;
764
+ }
765
+ if (!dryRun && !captureOnly && !skipBackup) {
681
766
  try {
682
- const backupDir = typeof savedConfig?.backupDir === "string" && savedConfig.backupDir.trim()
683
- ? savedConfig.backupDir
684
- : undefined;
685
- const bRes = await (0, backup_js_1.createBackup)({ db, home: stateDir, outDir: backupDir });
767
+ const bRes = await (0, backup_js_1.createBackup)({
768
+ db,
769
+ home: stateDir,
770
+ // undefined falls back to <home>/backups inside createBackup the
771
+ // same resolution effectiveBackupDir above uses for the age gate.
772
+ outDir: typeof savedConfig?.backupDir === "string" && savedConfig.backupDir.trim()
773
+ ? savedConfig.backupDir
774
+ : undefined,
775
+ // Same reader + default as the CLI (#327): keep the N newest
776
+ // artifacts, 0 = keep all.
777
+ retention: (0, config_read_js_1.readNonNegativeConfig)(savedConfig ?? {}, "backupRetention", backup_js_1.DEFAULT_BACKUP_RETENTION),
778
+ });
686
779
  backupPath = bRes.path;
687
780
  backupBytes = bRes.bytes;
688
781
  backupOk = true;
@@ -712,6 +805,15 @@ async function runNightly(options = {}) {
712
805
  console.error(`[hicortex] Backup FAILED: ${err instanceof Error ? err.message : String(err)}`);
713
806
  }
714
807
  }
808
+ else if (consolidateOnly && !dryRun) {
809
+ // #327: explicit log line so a hosted consolidation timer's log doesn't
810
+ // read as a silently-missing backup stage — and names WHY (age gate), so
811
+ // an operator reading "skipped" can tell a healthy cadence gate from a
812
+ // dead one.
813
+ const ageH = newestArtifactMs !== undefined ? (Date.now() - newestArtifactMs) / 3_600_000 : -1;
814
+ console.log(`[hicortex] Backup stage skipped — consolidate-only run: newest backup is ` +
815
+ `${ageH.toFixed(1)}h old (< ${Math.round(CONSOLIDATE_ONLY_BACKUP_MIN_AGE_MS / 3_600_000)}h gate).`);
816
+ }
715
817
  // Dashboard snapshot (#224) — full nightly only. The snapshot reflects
716
818
  // corpus state regardless of whether consolidation/LLM ran, so it is
717
819
  // ALWAYS written here (the use case is history; an LLM-less install still
@@ -771,9 +873,11 @@ async function runNightly(options = {}) {
771
873
  budgetExhausted,
772
874
  budgetDeferredByStage,
773
875
  // #6 backup stage — hoisted from the block above. Present whenever
774
- // the backup stage ran (full nightly); undefined on capture-only /
775
- // dry-run. backupOk flips to false on snapshot OR hook failure so the
776
- // dashboard digest can flag a night the offsite copy didn't complete.
876
+ // the backup stage ran (full nightly, and consolidate-only runs past
877
+ // the artifact-age gate); undefined on capture-only / dry-run / a
878
+ // gated-skip consolidate-only run. backupOk flips to false on
879
+ // snapshot OR hook failure so the dashboard digest can flag a night
880
+ // the offsite copy didn't complete.
777
881
  backupPath,
778
882
  backupBytes,
779
883
  backupOk,
@@ -826,7 +930,8 @@ async function runNightly(options = {}) {
826
930
  // #6 backup stage outcome — forwarded only when the backup stage ran
827
931
  // (full nightly). `ok` is false on snapshot OR hook failure; the fleet
828
932
  // aggregate surfaces a sustained drop in backup_ok as a data-loss risk.
829
- // Absent on capture-only / dry-run / client runs (no backup ran).
933
+ // Absent on capture-only / consolidate-only / dry-run / client runs
934
+ // (no backup ran).
830
935
  ...(backupOk !== undefined
831
936
  ? { backup: { ok: backupOk === true, bytes: backupBytes ?? 0 } }
832
937
  : {}),
package/dist/prompts.d.ts CHANGED
@@ -18,6 +18,21 @@ export declare function importanceScoring(memoriesBlock: string): string;
18
18
  export declare function reflection(memoriesBlock: string, recentLessons?: string): string;
19
19
  /**
20
20
  * Distillation prompt. Extracts knowledge from a session transcript.
21
+ *
22
+ * LAYOUT (#329 item 6): the ~6.3KB of static instructions come FIRST and the
23
+ * transcript LAST, so every distill call shares a byte-identical instruction
24
+ * prefix and provider-side prompt prefix caching can actually hit (per-session
25
+ * calls with the same project/date share everything up to the transcript; a
26
+ * multi-segment session — the common capture shape — re-uses the cached prefix
27
+ * for every segment after the first). The static block is the transcript-first
28
+ * block MOVED plus ONE deliberate addition in the same change: the #329 item-5
29
+ * [D]-override sentence in the NEVER-RECORD section ("a version bump, merge,
30
+ * or count is NEVER [D]"). Anyone diffing distill behavior across this change
31
+ * must baseline against BOTH the reorder and that wording addition. NOTE the
32
+ * prefix is only
33
+ * fully shared while project/date agree: "# Session Memory: ${date} -
34
+ * ${projectName}" and the (${date}) format examples interpolate inside the
35
+ * static block by design (the model needs the real date in its output format).
21
36
  */
22
37
  export declare function distillation(projectName: string, date: string, transcript: string): string;
23
38
  /**
package/dist/prompts.js CHANGED
@@ -106,14 +106,26 @@ Respond with a JSON array. Empty array [] is a valid response.`;
106
106
  }
107
107
  /**
108
108
  * Distillation prompt. Extracts knowledge from a session transcript.
109
+ *
110
+ * LAYOUT (#329 item 6): the ~6.3KB of static instructions come FIRST and the
111
+ * transcript LAST, so every distill call shares a byte-identical instruction
112
+ * prefix and provider-side prompt prefix caching can actually hit (per-session
113
+ * calls with the same project/date share everything up to the transcript; a
114
+ * multi-segment session — the common capture shape — re-uses the cached prefix
115
+ * for every segment after the first). The static block is the transcript-first
116
+ * block MOVED plus ONE deliberate addition in the same change: the #329 item-5
117
+ * [D]-override sentence in the NEVER-RECORD section ("a version bump, merge,
118
+ * or count is NEVER [D]"). Anyone diffing distill behavior across this change
119
+ * must baseline against BOTH the reorder and that wording addition. NOTE the
120
+ * prefix is only
121
+ * fully shared while project/date agree: "# Session Memory: ${date} -
122
+ * ${projectName}" and the (${date}) format examples interpolate inside the
123
+ * static block by design (the model needs the real date in its output format).
109
124
  */
110
125
  function distillation(projectName, date, transcript) {
111
126
  return `You are a memory extraction agent. Analyze this AI session transcript and extract
112
127
  knowledge worth remembering long-term.
113
128
 
114
- SESSION TRANSCRIPT (project: ${projectName}, date: ${date}):
115
- ${transcript}
116
-
117
129
  EXTRACT into this markdown format:
118
130
 
119
131
  # Session Memory: ${date} - ${projectName}
@@ -195,6 +207,9 @@ do not score it lower and write it anyway: OMIT it. A closed category of never-r
195
207
  The durable part of the same event may still qualify — the CHOICE a change
196
208
  embodies ("standardize on model X", user-confirmed) is [D], a configuration
197
209
  that holds going forward is [K]; the version bump, merge, or count itself never is.
210
+ Override for the [D] "actually carried out" test: even if carried out by the
211
+ user, a version bump, merge, or count is NEVER [D] — only the durable
212
+ user-confirmed standardization it embodies qualifies.
198
213
  If EVERY item in the transcript is never-record ephemera, output ONLY:
199
214
  "NO_EXTRACT" — zero memories is the correct result for a pure-status segment.
200
215
 
@@ -216,6 +231,9 @@ RULES:
216
231
  "[Strong Negative] User rejected per-agent billing"). The subject always comes first.
217
232
  - Omit any section that has zero items (don't include empty sections)
218
233
  - If nothing worth extracting, output ONLY: "NO_EXTRACT"
234
+
235
+ SESSION TRANSCRIPT (project: ${projectName}, date: ${date}):
236
+ ${transcript}
219
237
  `;
220
238
  }
221
239
  /**
@@ -18,9 +18,37 @@
18
18
  * per-session TURN-based dedup (SessionRecallRegistry), short-prompt skip,
19
19
  * and a hard item cap. On a prompt with no relevant memories the block is
20
20
  * null and the hook prints nothing.
21
+ *
22
+ * Novelty floor (#324): the session-intent blend (#192 session-intent keying)
23
+ * can dilute a topic-switching prompt below the relevance floor — the live
24
+ * failure was a technically-primed session asking about "my Sargo" and getting
25
+ * ZERO relevant memories while a fresh session with the identical prompt got
26
+ * the perfect top hit. So a second, PURE-prompt search (no centroid blend,
27
+ * SAME candidate window as the blended search) runs alongside the blended
28
+ * one, and its top hit(s) that pass the floor are GUARANTEED slots in the
29
+ * index (dedup by id against the blended picks, capped by
30
+ * `noveltyFloorSlots`; rendered first). When the pure top hits are already
31
+ * among the blended picks — the common continuing-intent case — the output is
32
+ * unchanged. Turn suppression still wins: a recently shown novelty pick is
33
+ * suppressed like any other (the guarantee is about candidate inclusion, not
34
+ * forcing re-shows).
35
+ *
36
+ * #329 item 3 — the pure search is SKIPPED when it would be byte-identical
37
+ * to the blended one: turn 1 (no centroid yet — nothing to blend) or
38
+ * sessionIntentWeight 0 (blend disabled). The blended result IS the pure
39
+ * result there, so the floor is trivially satisfied by the blended picks and
40
+ * the second search (embeds aside, its whole DB + FTS half) is pure waste.
41
+ *
42
+ * #329 item 4 — novelty backfill: when the blended picks are empty/short,
43
+ * unclaimed maxItems slots are filled from the remaining filtered
44
+ * pure-prompt tail (gate + suppression already applied). Without it the
45
+ * topic-switch turn — the one the floor exists for — got the MOST truncated
46
+ * menu: novelty slots + a diluted remainder, while further pure candidates
47
+ * that had already passed every gate sat unused.
21
48
  */
22
49
  import type Database from "better-sqlite3";
23
50
  import type { MemorySearchResult } from "./types.js";
51
+ import * as storage from "./storage.js";
24
52
  import { SessionRecallRegistry } from "./recall-registry.js";
25
53
  export interface RecallIndexOptions {
26
54
  /** Minimum measured cosine for vector-only candidates (config
@@ -50,11 +78,40 @@ export interface RecallIndexOptions {
50
78
  * identical (0.6pts apart, N=40, full CI overlap); 100 saves ~13% tokens
51
79
  * per block. */
52
80
  titleChars?: number;
81
+ /** Slots of `maxItems` guaranteed to the pure-prompt (unblended) search's
82
+ * top passing hit(s) — the #324 novelty floor. Config `noveltyFloorSlots`,
83
+ * default 2 (mirrors coldExposureSlots sizing: small, a floor not a
84
+ * takeover). 0 disables the pure-prompt search entirely (the kill-switch).
85
+ * Clamped to [0, maxItems]. */
86
+ noveltyFloorSlots?: number;
53
87
  }
88
+ /** Default #324 novelty-floor slots (config `noveltyFloorSlots`). 2 mirrors
89
+ * coldExposureSlots sizing — enough to guarantee the pure-prompt top hit
90
+ * plus a runner-up, never a takeover of the index. The floor only SPENDS
91
+ * slots when a pure-prompt hit differs from the blended picks (topic
92
+ * switch); continuing-intent sessions pay nothing. Exported for the boot
93
+ * log's knob line (mcp-server resolves config-vs-default here, once). */
94
+ export declare const DEFAULT_NOVELTY_FLOOR_SLOTS = 2;
95
+ /** Resolve the EFFECTIVE novelty floor (raw ?? default, clamped to
96
+ * [0, maxItems]) — one definition shared by the handler and the boot knob
97
+ * line so the logged value is what handleRecallIndex actually uses.
98
+ * maxItems may be the handler's already-resolved number OR raw config
99
+ * (boot-log site) — raw is resolved with the handler's exact constants. */
100
+ export declare function resolveNoveltyFloorSlots(rawSlots: unknown, rawMaxItems: unknown): number;
54
101
  export interface RecallIndexResult {
55
102
  status: number;
56
103
  body: Record<string, unknown>;
57
104
  }
105
+ /**
106
+ * Hard cap on `session_id` length (#328 item 2a). The id is retained as a Map
107
+ * key by SessionRecallRegistry for the process lifetime (maxSessions=500 LRU
108
+ * + a per-session shown-set + intent centroid), so an unbounded id is an OOM
109
+ * vector: ~4.9MB ids × 500 sessions ≈ 2.4GB of retained keys from an
110
+ * authenticated-but-hostile tenant. Real session ids (CC UUIDs, plugin
111
+ * session keys) are ≤64 chars — 128 is generous headroom. Longer → 400 with
112
+ * a clear error; the client treats it like any bad request.
113
+ */
114
+ export declare const MAX_SESSION_ID_CHARS = 128;
58
115
  /** First content line, de-markdowned and truncated — the index line title. */
59
116
  export declare function memoryTitle(content: string, maxLen?: number): string;
60
117
  /**
@@ -97,15 +154,59 @@ export interface RecallFilters {
97
154
  * affinity in computeScore via max overlapping memory_tags.weight. */
98
155
  mission_domains?: string[];
99
156
  }
157
+ /** The search-closure contract handleRecallIndex consumes (see
158
+ * RecallIndexDeps.retrieveFn). Named so the production factory
159
+ * (createRecallRetrieveFn) and test doubles share one type. */
160
+ export type RecallRetrieveFn = (query: string, limit: number, filters: RecallFilters | undefined, sessionId: string, purePrompt?: boolean) => Promise<MemorySearchResult[]>;
100
161
  export interface RecallIndexDeps {
101
162
  db: Database.Database;
102
163
  registry: SessionRecallRegistry;
103
- /** Search closure. `sessionId` is forwarded so the closure (in mcp-server)
104
- * can resolve/update the session-intent centroid and pass a blended query
105
- * vector into retrieve() — see #192 session-intent keying (0.15.3). */
106
- retrieveFn: (query: string, limit: number, filters: RecallFilters | undefined, sessionId: string) => Promise<MemorySearchResult[]>;
164
+ /** Search closure. `sessionId` is forwarded so the closure resolves/updates
165
+ * the session-intent centroid and passes a blended query vector into
166
+ * retrieve() — see #192 session-intent keying (0.15.3).
167
+ *
168
+ * `purePrompt` (#324 novelty floor): request the PURE-prompt search — the
169
+ * closure must search with the prompt embedding UNBLENDED (no session
170
+ * centroid) and must NOT fold the prompt into the centroid a second time
171
+ * (the blended call owns this turn's EMA update). Older closures that
172
+ * ignore the flag degrade to blended-only recall — no novelty floor, but
173
+ * no breakage. */
174
+ retrieveFn: RecallRetrieveFn;
107
175
  options?: RecallIndexOptions;
108
176
  }
177
+ /**
178
+ * The PRODUCTION /recall-index retrieveFn (what mcp-server wires into
179
+ * handleRecallIndex), extracted from the route handler so the #324 path is
180
+ * testable without HTTP — same precedent as blendQueryVector/recallQueryVector
181
+ * ("extracted from the /recall-index closure so the exact decision is
182
+ * unit-testable").
183
+ *
184
+ * Per call:
185
+ * - embed the prompt ONCE per request — a single-entry promise memo keyed
186
+ * on the query text. The blended and pure-prompt searches of one request
187
+ * carry the same prompt, so they share one embed; the factory is built
188
+ * per request, so the memo never outlives it.
189
+ * - resolve the search vector via retrieval.recallQueryVector (blend + EMA
190
+ * fold, or the pure prompt with NO centroid state for #324);
191
+ * - retrieve() with noStrengthen (exposure is recorded by
192
+ * handleRecallIndex via touchMemoriesShown, never here).
193
+ *
194
+ * #329 CR finding 1b: the FTS candidate list is ALSO computed once per
195
+ * request (ftsOnce, keyed on query + candidate window) and threaded into both
196
+ * retrieve() calls via the ftsCandidates provider — the blended and pure
197
+ * searches of one request carry identical query text and window, so their FTS
198
+ * halves were byte-identical SQL executed twice. `ftsFn` is the DI seam for
199
+ * tests (production: storage.searchFts); a throwing FTS computation memoizes
200
+ * to an empty shared list — the same vector-only degradation retrieve()'s
201
+ * catch always produced, never an error.
202
+ */
203
+ export declare function createRecallRetrieveFn(deps: {
204
+ db: Database.Database;
205
+ registry: SessionRecallRegistry;
206
+ embedFn: (text: string) => Promise<Float32Array>;
207
+ /** FTS resolution override (tests). Defaults to storage.searchFts. */
208
+ ftsFn?: typeof storage.searchFts;
209
+ }): RecallRetrieveFn;
109
210
  /** Normalize a request-supplied string-list param: array of strings or a CSV
110
211
  * string → string[] | undefined. Anything else (or an empty result) means
111
212
  * "absent" — never a partial guess. Used by `mission_domains` (#203) so it