@gamaze/hicortex 0.19.2 → 0.19.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.
package/README.md CHANGED
@@ -260,11 +260,12 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
260
260
  | `searchLimit` / `recentLimit` | Default result counts for search (8) and recent (12) |
261
261
  | `recentWindowDays` | Candidate window for recent recall (default: 180) |
262
262
  | `coldExposureSlots` | Top-k slots reservable for never-accessed memories so the long tail gets exposure (default: 2) |
263
- | `recallMaxItems` | Max lines in the pushed recall index (default: 6) |
264
- | `recallMinSimilarity` | Relevance floor for index entries (default: 0.55; text-search matches always pass) |
263
+ | `recallMaxItems` | Max lines in the pushed recall index (default: 5) |
264
+ | `noveltyFloorSlots` | Slots of `recallMaxItems` guaranteed to the top passing hit(s) of the pure-prompt (unblended) search — the novelty floor. Keeps a session whose earlier turns set a strong intent from burying a topic-switching prompt's best matches: the floor's picks render first, turn-based re-show suppression still applies, and the total never exceeds `recallMaxItems` (default: 2; set 0 to disable) |
265
+ | `recallMinSimilarity` | Relevance floor for index entries (default: 0.62; text-search matches always pass) |
265
266
  | `recallReshowTurns` | Turns before an already-shown memory may reappear in the same session (default: 30) |
266
267
  | `recallMinPromptChars` | Prompts shorter than this skip the recall index (default: 20) |
267
- | `recallTitleChars` | Chars of each memory's first line shown in an index entry (default: 150, range 40–400). Raised from 100 on 2026-08-02: with topic-first memory titles, 150 chars carries the subject *and* its claim, where 100 cut the claim mid-sentence. Costs roughly +74 tokens per 6-line block |
268
+ | `recallTitleChars` | Chars of each memory's first line shown in an index entry (default: 100, range 40–400). Reverted from 150 on 2026-08-03: a full-corpus relevance eval found 100 and 150 statistically identical while 100 saves ~13% of the block's tokens |
268
269
  | `sessionIntentWeight` | Blend weight of the session-intent rolling centroid in the recall search vector: `query = (1-w)·prompt + w·centroid` (default: 0.33; set 0 to disable — pure-prompt recall, the kill-switch). The first turn of a session searches with pure prompt and seeds the centroid; subsequent turns blend so recall follows the session's intent instead of being query-literal. The EMA rate (0.4) is a shipped constant, not configurable |
269
270
  | `dedupMergeThreshold` | Minimum cosine similarity for `hicortex dedup` to cluster memories as near-duplicates (default: 0.92) |
270
271
  | `supersessionMinSimilarity` | Minimum cosine similarity for a nightly supersession candidate pair (default: 0.80) |
@@ -643,7 +643,9 @@ async function startServer(options = {}) {
643
643
  const scoringCfg = retrieval.configureScoring(savedConfig);
644
644
  const sessionIntentCfg = retrieval.configureSessionIntent(savedConfig);
645
645
  console.log(`[hicortex] Recall: k=${recallCfg.searchLimit}/recent=${recallCfg.recentLimit}` +
646
- `/window=${recallCfg.recentWindowDays}d/cold=${recallCfg.coldExposureSlots} · ` +
646
+ `/window=${recallCfg.recentWindowDays}d/cold=${recallCfg.coldExposureSlots}` +
647
+ `/novelty=${(0, recall_index_js_1.resolveNoveltyFloorSlots)(savedConfig?.noveltyFloorSlots, savedConfig?.recallMaxItems)}` +
648
+ ` · ` +
647
649
  `score sim=${scoringCfg.similarity}/str=${scoringCfg.strength}/conn=${scoringCfg.connections}` +
648
650
  `/rec=${scoringCfg.recency}, fresh=${scoringCfg.freshnessBoostWeight}@${scoringCfg.freshnessBoostDays}d, ` +
649
651
  `superseded×${scoringCfg.supersededDemotion}` +
@@ -657,6 +659,9 @@ async function startServer(options = {}) {
657
659
  maxItems: savedConfig?.recallMaxItems,
658
660
  minPromptLength: savedConfig?.recallMinPromptChars,
659
661
  titleChars: savedConfig?.recallTitleChars,
662
+ // #324 novelty floor: slots of recallMaxItems guaranteed to the
663
+ // pure-prompt (unblended) search's top passing hit(s). 0 disables.
664
+ noveltyFloorSlots: savedConfig?.noveltyFloorSlots,
660
665
  };
661
666
  memoryInstructionsEnabled = savedConfig?.memoryInstructions !== false;
662
667
  if (resolvedAgents.dropped.length > 0) {
@@ -877,37 +882,23 @@ async function startServer(options = {}) {
877
882
  res.status(503).json({ error: "Server not initialized" });
878
883
  return;
879
884
  }
885
+ // Client-pushed project/privacy scoping (F1) rides through to retrieval,
886
+ // which handles the filtered over-fetch itself. The search closure itself
887
+ // lives in recall-index.ts (createRecallRetrieveFn): per-request prompt
888
+ // embed memo (ONE embed for the blended + pure-prompt searches), the
889
+ // session-intent centroid blend/EMA fold via retrieval.recallQueryVector
890
+ // (#192 session-intent keying, 0.15.3), and the #324 pure-prompt branch
891
+ // that searches unblended and touches no centroid state. Extracted so the
892
+ // exact behavior is unit-testable without HTTP (blendQueryVector
893
+ // precedent); this adapter stays thin.
880
894
  const r = await (0, recall_index_js_1.handleRecallIndex)({
881
895
  db,
882
896
  registry: recallRegistry,
883
- // Client-pushed project/privacy scoping (F1) rides through to
884
- // retrieval, which handles the filtered over-fetch itself.
885
- // #192 session-intent keying (0.15.3): embed the prompt ONCE here,
886
- // blend with the session's rolling centroid, and pass the blended
887
- // vector to retrieve() via queryEmbedding so retrieve() does NOT
888
- // re-embed. Turn 1 (no centroid yet) and weight=0 both reduce to a
889
- // pure-prompt search (the kill-switch). The centroid is updated AFTER
890
- // reading the prior one — so turn 1 searches with pure prompt, then
891
- // seeds the centroid for turn 2+ to blend against.
892
- retrieveFn: async (query, limit, filters, sessionId) => {
893
- const { weight, alpha } = retrieval.getSessionIntent();
894
- const promptEmb = await (0, embedder_js_1.embed)(query);
895
- // weight=0 (kill-switch): the centroid is neither read nor written.
896
- const centroid = weight > 0 ? recallRegistry.getCentroid(sessionId) : undefined;
897
- const queryVec = retrieval.blendQueryVector(promptEmb, centroid, weight);
898
- if (weight > 0)
899
- recallRegistry.updateCentroid(sessionId, promptEmb, alpha);
900
- return retrieval.retrieve(db, embedder_js_1.embed, query, {
901
- limit,
902
- noStrengthen: true,
903
- // #203: project + mission_domains are SOFT affinity (zero-boost
904
- // neutral), threaded into computeScore. 0.16.x: privacy is no
905
- // longer threaded (vestigial column, never filtered).
906
- project: filters?.project,
907
- missionDomains: filters?.mission_domains,
908
- queryEmbedding: queryVec,
909
- });
910
- },
897
+ retrieveFn: (0, recall_index_js_1.createRecallRetrieveFn)({
898
+ db,
899
+ registry: recallRegistry,
900
+ embedFn: embedder_js_1.embed,
901
+ }),
911
902
  options: recallIndexOptions,
912
903
  }, req.body);
913
904
  res.status(r.status).json(r.body);
@@ -18,6 +18,20 @@
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).
21
35
  */
22
36
  import type Database from "better-sqlite3";
23
37
  import type { MemorySearchResult } from "./types.js";
@@ -50,7 +64,26 @@ export interface RecallIndexOptions {
50
64
  * identical (0.6pts apart, N=40, full CI overlap); 100 saves ~13% tokens
51
65
  * per block. */
52
66
  titleChars?: number;
67
+ /** Slots of `maxItems` guaranteed to the pure-prompt (unblended) search's
68
+ * top passing hit(s) — the #324 novelty floor. Config `noveltyFloorSlots`,
69
+ * default 2 (mirrors coldExposureSlots sizing: small, a floor not a
70
+ * takeover). 0 disables the pure-prompt search entirely (the kill-switch).
71
+ * Clamped to [0, maxItems]. */
72
+ noveltyFloorSlots?: number;
53
73
  }
74
+ /** Default #324 novelty-floor slots (config `noveltyFloorSlots`). 2 mirrors
75
+ * coldExposureSlots sizing — enough to guarantee the pure-prompt top hit
76
+ * plus a runner-up, never a takeover of the index. The floor only SPENDS
77
+ * slots when a pure-prompt hit differs from the blended picks (topic
78
+ * switch); continuing-intent sessions pay nothing. Exported for the boot
79
+ * log's knob line (mcp-server resolves config-vs-default here, once). */
80
+ export declare const DEFAULT_NOVELTY_FLOOR_SLOTS = 2;
81
+ /** Resolve the EFFECTIVE novelty floor (raw ?? default, clamped to
82
+ * [0, maxItems]) — one definition shared by the handler and the boot knob
83
+ * line so the logged value is what handleRecallIndex actually uses.
84
+ * maxItems may be the handler's already-resolved number OR raw config
85
+ * (boot-log site) — raw is resolved with the handler's exact constants. */
86
+ export declare function resolveNoveltyFloorSlots(rawSlots: unknown, rawMaxItems: unknown): number;
54
87
  export interface RecallIndexResult {
55
88
  status: number;
56
89
  body: Record<string, unknown>;
@@ -97,15 +130,48 @@ export interface RecallFilters {
97
130
  * affinity in computeScore via max overlapping memory_tags.weight. */
98
131
  mission_domains?: string[];
99
132
  }
133
+ /** The search-closure contract handleRecallIndex consumes (see
134
+ * RecallIndexDeps.retrieveFn). Named so the production factory
135
+ * (createRecallRetrieveFn) and test doubles share one type. */
136
+ export type RecallRetrieveFn = (query: string, limit: number, filters: RecallFilters | undefined, sessionId: string, purePrompt?: boolean) => Promise<MemorySearchResult[]>;
100
137
  export interface RecallIndexDeps {
101
138
  db: Database.Database;
102
139
  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[]>;
140
+ /** Search closure. `sessionId` is forwarded so the closure resolves/updates
141
+ * the session-intent centroid and passes a blended query vector into
142
+ * retrieve() — see #192 session-intent keying (0.15.3).
143
+ *
144
+ * `purePrompt` (#324 novelty floor): request the PURE-prompt search — the
145
+ * closure must search with the prompt embedding UNBLENDED (no session
146
+ * centroid) and must NOT fold the prompt into the centroid a second time
147
+ * (the blended call owns this turn's EMA update). Older closures that
148
+ * ignore the flag degrade to blended-only recall — no novelty floor, but
149
+ * no breakage. */
150
+ retrieveFn: RecallRetrieveFn;
107
151
  options?: RecallIndexOptions;
108
152
  }
153
+ /**
154
+ * The PRODUCTION /recall-index retrieveFn (what mcp-server wires into
155
+ * handleRecallIndex), extracted from the route handler so the #324 path is
156
+ * testable without HTTP — same precedent as blendQueryVector/recallQueryVector
157
+ * ("extracted from the /recall-index closure so the exact decision is
158
+ * unit-testable").
159
+ *
160
+ * Per call:
161
+ * - embed the prompt ONCE per request — a single-entry promise memo keyed
162
+ * on the query text. The blended and pure-prompt searches of one request
163
+ * carry the same prompt, so they share one embed; the factory is built
164
+ * per request, so the memo never outlives it.
165
+ * - resolve the search vector via retrieval.recallQueryVector (blend + EMA
166
+ * fold, or the pure prompt with NO centroid state for #324);
167
+ * - retrieve() with noStrengthen (exposure is recorded by
168
+ * handleRecallIndex via touchMemoriesShown, never here).
169
+ */
170
+ export declare function createRecallRetrieveFn(deps: {
171
+ db: Database.Database;
172
+ registry: SessionRecallRegistry;
173
+ embedFn: (text: string) => Promise<Float32Array>;
174
+ }): RecallRetrieveFn;
109
175
  /** Normalize a request-supplied string-list param: array of strings or a CSV
110
176
  * string → string[] | undefined. Anything else (or an empty result) means
111
177
  * "absent" — never a partial guess. Used by `mission_domains` (#203) so it
@@ -19,6 +19,20 @@
19
19
  * per-session TURN-based dedup (SessionRecallRegistry), short-prompt skip,
20
20
  * and a hard item cap. On a prompt with no relevant memories the block is
21
21
  * null and the hook prints nothing.
22
+ *
23
+ * Novelty floor (#324): the session-intent blend (#192 session-intent keying)
24
+ * can dilute a topic-switching prompt below the relevance floor — the live
25
+ * failure was a technically-primed session asking about "my Sargo" and getting
26
+ * ZERO relevant memories while a fresh session with the identical prompt got
27
+ * the perfect top hit. So a second, PURE-prompt search (no centroid blend,
28
+ * SAME candidate window as the blended search) runs alongside the blended
29
+ * one, and its top hit(s) that pass the floor are GUARANTEED slots in the
30
+ * index (dedup by id against the blended picks, capped by
31
+ * `noveltyFloorSlots`; rendered first). When the pure top hits are already
32
+ * among the blended picks — the common continuing-intent case — the output is
33
+ * unchanged. Turn suppression still wins: a recently shown novelty pick is
34
+ * suppressed like any other (the guarantee is about candidate inclusion, not
35
+ * forcing re-shows).
22
36
  */
23
37
  var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
24
38
  if (k2 === undefined) k2 = k;
@@ -54,15 +68,19 @@ var __importStar = (this && this.__importStar) || (function () {
54
68
  };
55
69
  })();
56
70
  Object.defineProperty(exports, "__esModule", { value: true });
71
+ exports.DEFAULT_NOVELTY_FLOOR_SLOTS = void 0;
72
+ exports.resolveNoveltyFloorSlots = resolveNoveltyFloorSlots;
57
73
  exports.memoryTitle = memoryTitle;
58
74
  exports.formatIndexLine = formatIndexLine;
59
75
  exports.passesRelevanceGate = passesRelevanceGate;
76
+ exports.createRecallRetrieveFn = createRecallRetrieveFn;
60
77
  exports.parseStringListParam = parseStringListParam;
61
78
  exports.handleRecallIndex = handleRecallIndex;
62
79
  exports.handleMemoryGet = handleMemoryGet;
63
80
  exports.formatMemoryGetText = formatMemoryGetText;
64
81
  const storage = __importStar(require("./storage.js"));
65
82
  const type_labels_js_1 = require("./type-labels.js");
83
+ const retrieval_js_1 = require("./retrieval.js");
66
84
  /** Relevance-gate floor for vector-only candidates (config `recallMinSimilarity`).
67
85
  * 0.62 (was 0.55; raised 2026-08-03 on the fine-grain floor sweep — see the
68
86
  * minSimilarity doc above). */
@@ -74,6 +92,24 @@ const DEFAULT_MIN_PROMPT_LENGTH = 20;
74
92
  /** Default index-line title length. 100 (reverted from 150 on 2026-08-03:
75
93
  * eval #3 §5 showed 100 vs 150 statistically identical; 100 saves ~13% tokens). */
76
94
  const DEFAULT_TITLE_CHARS = 100;
95
+ /** Default #324 novelty-floor slots (config `noveltyFloorSlots`). 2 mirrors
96
+ * coldExposureSlots sizing — enough to guarantee the pure-prompt top hit
97
+ * plus a runner-up, never a takeover of the index. The floor only SPENDS
98
+ * slots when a pure-prompt hit differs from the blended picks (topic
99
+ * switch); continuing-intent sessions pay nothing. Exported for the boot
100
+ * log's knob line (mcp-server resolves config-vs-default here, once). */
101
+ exports.DEFAULT_NOVELTY_FLOOR_SLOTS = 2;
102
+ /** Resolve the EFFECTIVE novelty floor (raw ?? default, clamped to
103
+ * [0, maxItems]) — one definition shared by the handler and the boot knob
104
+ * line so the logged value is what handleRecallIndex actually uses.
105
+ * maxItems may be the handler's already-resolved number OR raw config
106
+ * (boot-log site) — raw is resolved with the handler's exact constants. */
107
+ function resolveNoveltyFloorSlots(rawSlots, rawMaxItems) {
108
+ const maxItems = typeof rawMaxItems === "number"
109
+ ? rawMaxItems
110
+ : clampInt(rawMaxItems, DEFAULT_MAX_ITEMS, 1, 20);
111
+ return clampInt(rawSlots, exports.DEFAULT_NOVELTY_FLOOR_SLOTS, 0, maxItems);
112
+ }
77
113
  /** Over-fetch multiplier: retrieve `maxItems × 3` candidates so gating + dedup
78
114
  * still leave a full menu. Kept at 3 after maxItems 6→5 and the higher floor —
79
115
  * permit-short is intended (returning fewer than maxItems when fewer clear the
@@ -139,6 +175,50 @@ function passesRelevanceGate(r, minSimilarity) {
139
175
  return true;
140
176
  return typeof r.similarity === "number" && r.similarity >= minSimilarity;
141
177
  }
178
+ /**
179
+ * The PRODUCTION /recall-index retrieveFn (what mcp-server wires into
180
+ * handleRecallIndex), extracted from the route handler so the #324 path is
181
+ * testable without HTTP — same precedent as blendQueryVector/recallQueryVector
182
+ * ("extracted from the /recall-index closure so the exact decision is
183
+ * unit-testable").
184
+ *
185
+ * Per call:
186
+ * - embed the prompt ONCE per request — a single-entry promise memo keyed
187
+ * on the query text. The blended and pure-prompt searches of one request
188
+ * carry the same prompt, so they share one embed; the factory is built
189
+ * per request, so the memo never outlives it.
190
+ * - resolve the search vector via retrieval.recallQueryVector (blend + EMA
191
+ * fold, or the pure prompt with NO centroid state for #324);
192
+ * - retrieve() with noStrengthen (exposure is recorded by
193
+ * handleRecallIndex via touchMemoriesShown, never here).
194
+ */
195
+ function createRecallRetrieveFn(deps) {
196
+ let embMemo = null;
197
+ const embedOnce = (query) => {
198
+ if (!embMemo || embMemo.query !== query) {
199
+ embMemo = { query, p: deps.embedFn(query) };
200
+ }
201
+ return embMemo.p;
202
+ };
203
+ return async (query, limit, filters, sessionId, purePrompt) => {
204
+ const { weight, alpha } = (0, retrieval_js_1.getSessionIntent)();
205
+ const promptEmb = await embedOnce(query);
206
+ const queryVec = (0, retrieval_js_1.recallQueryVector)(deps.registry, sessionId, promptEmb, {
207
+ weight,
208
+ alpha,
209
+ purePrompt,
210
+ });
211
+ return (0, retrieval_js_1.retrieve)(deps.db, deps.embedFn, query, {
212
+ limit,
213
+ noStrengthen: true,
214
+ // #203: project + mission_domains are SOFT affinity (zero-boost
215
+ // neutral), threaded into computeScore.
216
+ project: filters?.project,
217
+ missionDomains: filters?.mission_domains,
218
+ queryEmbedding: queryVec,
219
+ });
220
+ };
221
+ }
142
222
  /** Normalize a request-supplied string-list param: array of strings or a CSV
143
223
  * string → string[] | undefined. Anything else (or an empty result) means
144
224
  * "absent" — never a partial guess. Used by `mission_domains` (#203) so it
@@ -176,6 +256,9 @@ async function handleRecallIndex(deps, body) {
176
256
  const maxItems = clampInt(deps.options?.maxItems, DEFAULT_MAX_ITEMS, 1, 20);
177
257
  const titleChars = clampInt(deps.options?.titleChars, DEFAULT_TITLE_CHARS, 40, 400);
178
258
  const minSimilarity = clampNumber(deps.options?.minSimilarity, DEFAULT_MIN_SIMILARITY, 0, 1);
259
+ // #324: clamped to [0, maxItems] — the floor is a reservation inside the
260
+ // item cap, never an expansion of it.
261
+ const noveltySlots = resolveNoveltyFloorSlots(deps.options?.noveltyFloorSlots, maxItems);
179
262
  const turn = deps.registry.beginTurn(sessionId);
180
263
  // Optional client-side scoping (F1 + #203): project + mission_domains (soft
181
264
  // affinity) ride the body and are pushed into retrieval. project is cwd-
@@ -187,9 +270,29 @@ async function handleRecallIndex(deps, body) {
187
270
  project: typeof req.project === "string" && req.project ? req.project : undefined,
188
271
  mission_domains: parseStringListParam(req.mission_domains),
189
272
  };
273
+ // #324: when the floor is armed, TWO searches run per recall — the blended
274
+ // (session-intent) query that has always run, and a PURE-prompt query with
275
+ // no centroid blend. Issued together so the second adds no wall-clock
276
+ // latency beyond its own DB work (the prompt is embedded once — the closure
277
+ // memoizes). Same failure domain (same db + embedder): either failing fails
278
+ // the request explicitly; no silent blended-only degradation.
279
+ //
280
+ // The pure call fetches the SAME candidate window as the blended call
281
+ // (maxItems × 3). A narrower window would break the guarantee at the edge:
282
+ // sqlite-vec KNN candidates are fetched at limit × 3 and re-ranked by
283
+ // composite score, so a memory the fresh-session path ranks #1 by
284
+ // strength/recency but that sits at raw-vector rank 19+ would never enter
285
+ // a small window's candidate set — the #324 failure shape surviving at the
286
+ // edge. The guarantee is therefore: the top passing pure-prompt hit WITHIN
287
+ // the shared candidate window always survives into the index.
190
288
  let results;
289
+ let pureResults;
191
290
  try {
192
- results = await deps.retrieveFn(prompt, maxItems * CANDIDATE_MULTIPLIER, filters, sessionId);
291
+ const blended = deps.retrieveFn(prompt, maxItems * CANDIDATE_MULTIPLIER, filters, sessionId);
292
+ const pure = noveltySlots > 0
293
+ ? deps.retrieveFn(prompt, maxItems * CANDIDATE_MULTIPLIER, filters, sessionId, true)
294
+ : Promise.resolve([]);
295
+ [results, pureResults] = await Promise.all([blended, pure]);
193
296
  }
194
297
  catch (err) {
195
298
  return {
@@ -197,10 +300,39 @@ async function handleRecallIndex(deps, body) {
197
300
  body: { error: err instanceof Error ? err.message : String(err) },
198
301
  };
199
302
  }
200
- const picked = results
303
+ // Blended (session-intent) picks: relevance gate + turn-based suppression,
304
+ // top maxItems — exactly what the index would show with no novelty floor.
305
+ const blendedPicks = results
201
306
  .filter((r) => passesRelevanceGate(r, minSimilarity))
202
307
  .filter((r) => deps.registry.isShowable(sessionId, r.id))
203
308
  .slice(0, maxItems);
309
+ // #324 novelty floor: the best match(es) for the CURRENT PROMPT ALONE are
310
+ // guaranteed a place in the index. The blend exists to follow session
311
+ // intent, not to veto the prompt — so pure-prompt hits that pass the floor
312
+ // enter even when the blended query diluted them out of `results`
313
+ // entirely. Dedup is against the blended PICKS (the no-floor outcome): when
314
+ // the pure top hit is already shown by the blended path — the common
315
+ // continuing-intent case — the floor costs nothing and the output is
316
+ // unchanged. Suppression applies BEFORE the guarantee (suppression wins:
317
+ // the floor is about candidate inclusion, not forcing re-shows). FTS-sourced
318
+ // pure hits pass the gate unconditionally, same as the blended path.
319
+ const blendedIds = new Set(blendedPicks.map((r) => r.id));
320
+ const noveltyPicks = pureResults
321
+ .filter((r) => passesRelevanceGate(r, minSimilarity))
322
+ .filter((r) => deps.registry.isShowable(sessionId, r.id))
323
+ .filter((r) => !blendedIds.has(r.id))
324
+ .slice(0, noveltySlots);
325
+ // The floor takes precedence (#324 vs #192 cold slots): novelty picks hold
326
+ // their slots; blended picks keep the remainder, evicted from the TAIL
327
+ // (lowest rank first) so the session-intent head survives. Cold-exposure
328
+ // slots continue to apply inside each retrieve()'s own top-k. Total never
329
+ // exceeds maxItems. Render order: novelty picks FIRST — on a topic switch
330
+ // they are the most relevant lines to the CURRENT turn, and the head of the
331
+ // block carries the most weight for a reader scanning the menu.
332
+ const picked = [
333
+ ...noveltyPicks,
334
+ ...blendedPicks.slice(0, Math.max(0, maxItems - noveltyPicks.length)),
335
+ ];
204
336
  if (picked.length === 0) {
205
337
  return { status: 200, body: { block: null, shown: [], turn } };
206
338
  }
@@ -115,6 +115,32 @@ export declare function getSessionIntent(): {
115
115
  * closure-integration harness.
116
116
  */
117
117
  export declare function blendQueryVector(promptEmb: Float32Array, centroid: Float32Array | undefined, weight: number): Float32Array;
118
+ /**
119
+ * The /recall-index closure's PER-CALL search-vector decision (#199 + #324),
120
+ * extracted next to blendQueryVector (same precedent: the exact decision must
121
+ * be unit-testable without a closure-integration harness).
122
+ *
123
+ * - purePrompt (#324 novelty floor): return the prompt embedding UNBLENDED
124
+ * and touch NO centroid state — neither read nor the EMA fold. The folded
125
+ * turn is owned by the blended call; a second fold here would double-count
126
+ * the prompt and skew every later turn's blend (the single worst
127
+ * regression this extraction exists to lock out).
128
+ * - blended (default): read the prior centroid (weight>0 only), blend, then
129
+ * fold this turn's prompt ONCE (weight>0 only) — read-before-update so
130
+ * turn 1 searches pure and seeds the centroid for turn 2.
131
+ *
132
+ * `registry` is the structural surface needed (SessionRecallRegistry
133
+ * satisfies it) — keeps this module decoupled from the registry class.
134
+ */
135
+ export interface CentroidStore {
136
+ getCentroid(sessionId: string): Float32Array | undefined;
137
+ updateCentroid(sessionId: string, promptEmbedding: Float32Array, alpha: number): Float32Array;
138
+ }
139
+ export declare function recallQueryVector(registry: CentroidStore, sessionId: string, promptEmb: Float32Array, opts: {
140
+ weight: number;
141
+ alpha: number;
142
+ purePrompt?: boolean;
143
+ }): Float32Array;
118
144
  /**
119
145
  * Ids among `candidateIds` that have been superseded by a later memory — i.e.
120
146
  * they are the SOURCE of a `superseded_by` link (stageSupersession links
package/dist/retrieval.js CHANGED
@@ -61,6 +61,7 @@ exports.getScoringWeights = getScoringWeights;
61
61
  exports.configureSessionIntent = configureSessionIntent;
62
62
  exports.getSessionIntent = getSessionIntent;
63
63
  exports.blendQueryVector = blendQueryVector;
64
+ exports.recallQueryVector = recallQueryVector;
64
65
  exports.findSupersededIds = findSupersededIds;
65
66
  exports.l2ToCosine = l2ToCosine;
66
67
  exports.effectiveStrength = effectiveStrength;
@@ -250,6 +251,16 @@ function blendQueryVector(promptEmb, centroid, weight) {
250
251
  ? (0, schema_prototypes_js_1.l2Normalize)((0, schema_prototypes_js_1.weightedAdd)(promptEmb, 1 - weight, centroid, weight))
251
252
  : promptEmb;
252
253
  }
254
+ function recallQueryVector(registry, sessionId, promptEmb, opts) {
255
+ if (opts.purePrompt)
256
+ return promptEmb;
257
+ // weight=0 (kill-switch): the centroid is neither read nor written.
258
+ const centroid = opts.weight > 0 ? registry.getCentroid(sessionId) : undefined;
259
+ const queryVec = blendQueryVector(promptEmb, centroid, opts.weight);
260
+ if (opts.weight > 0)
261
+ registry.updateCentroid(sessionId, promptEmb, opts.alpha);
262
+ return queryVec;
263
+ }
253
264
  /**
254
265
  * Ids among `candidateIds` that have been superseded by a later memory — i.e.
255
266
  * they are the SOURCE of a `superseded_by` link (stageSupersession links
@@ -2,7 +2,7 @@
2
2
  "id": "hicortex",
3
3
  "name": "Hicortex — Long-term Memory That Learns",
4
4
  "description": "Your agents remember past decisions, avoid repeated mistakes, and get smarter every day. Nightly reflection generates actionable lessons that automatically update agent behavior.",
5
- "version": "0.19.2",
5
+ "version": "0.19.3",
6
6
  "kind": "lifecycle",
7
7
  "skills": ["./skills/hicortex-memory"],
8
8
  "configSchema": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.19.2",
3
+ "version": "0.19.3",
4
4
  "description": "Persistent agent identity for AI agents \u2014 a hand-edited identity layer, nightly-distilled experience, and lessons injected every session, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {