@gamaze/hicortex 0.19.1 → 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.
@@ -63,18 +63,25 @@ export declare function resolveConfig(): ResolvedConfig | null;
63
63
  */
64
64
  export declare function titleCaseSection(name: string): string;
65
65
  /**
66
- * Stable section ordering: `user` first, then `rules` (the seeded primary
67
- * sections, spec §8), then every other section alphabetically. Server-side
68
- * enumeration order (readdirSync) is FS-dependent, so we sort here for a
69
- * deterministic injection block. Exported for reuse by the OC plugin.
66
+ * Stable section ordering for the rendered `## Identity` block: the #313
67
+ * precedence contract `agent_identity` (the agent's own self + role
68
+ * conduct) first, then `user` (the principal), then `rules` (fleet-wide house
69
+ * rules), then every other section alphabetically. The list is imported from
70
+ * identity-store (SECTION_PRECEDENCE) so the server's SERVED order and every
71
+ * client's RENDERED order are the same definition — they cannot drift.
72
+ * Server-side enumeration order (readdirSync) is FS-dependent, so we sort
73
+ * here for a deterministic injection block. Exported for reuse by the OC
74
+ * plugin; the Python Hermes plugin mirrors this helper — keep them in sync.
70
75
  */
71
76
  export declare function orderSectionNames(names: string[]): string[];
72
77
  /**
73
78
  * Render the `## Identity` block from a resolved section map, or null when there
74
79
  * is nothing to inject (no sections, or every section blank after trimming).
75
80
  * Pure — no gating, no I/O. Shared verbatim by the CC hook and the OC plugin so
76
- * both harnesses emit an identical block. Sections are ordered (user, rules,
77
- * then alphabetical) and rendered under title-cased `###` headings.
81
+ * both harnesses emit an identical block. Sections are ordered (agent_identity,
82
+ * user, rules, then alphabetical) and rendered under `###` headings labeled by
83
+ * the #313 scope map (SECTION_LABELS: "Agent identity" / "User" / "Global
84
+ * rules"); unknown section names fall back to title-case.
78
85
  */
79
86
  export declare function renderIdentityBlock(sections: Record<string, string>): string | null;
80
87
  /**
@@ -116,10 +123,12 @@ export interface IdentityToolResult {
116
123
  * `agent` param forwarded so per-agent installs resolve the right scope
117
124
  * (WARNING-2: previously the tool always passed `{}` → global, so an agent
118
125
  * with an override saw the wrong identity).
119
- * 2. `injectMemorySection` — the synthetic product-owned `memory` section
120
- * (WARNING-1: the REST route + SessionStart hook inject it; the tool did
121
- * not, contradicting its "same data" docs).
122
- * 3. optional `name` filter, then `renderIdentityBlock` for the `### <Title>`
126
+ * 2. `serveIdentityBody` — the ONE composition helper (REST uses it too):
127
+ * injects the synthetic product-owned `memory` section (WARNING-1: the
128
+ * REST route + SessionStart hook inject it; the tool did not,
129
+ * contradicting its "same data" docs) and applies the SECTION_PRECEDENCE
130
+ * wire order.
131
+ * 3. optional `name` filter, then `renderIdentityBlock` for the `### <Label>`
123
132
  * markdown the hook injects.
124
133
  *
125
134
  * Pure: no I/O of its own (the only I/O is `handleIdentityGet` reading the
@@ -39,7 +39,6 @@ exports.fetchLessonsIdentity = fetchLessonsIdentity;
39
39
  const node_fs_1 = require("node:fs");
40
40
  const node_path_1 = require("node:path");
41
41
  const identity_store_js_1 = require("./identity-store.js");
42
- const memory_instructions_js_1 = require("./memory-instructions.js");
43
42
  const features_js_1 = require("./features.js");
44
43
  const extensions_js_1 = require("./extensions.js");
45
44
  const state_js_1 = require("./state.js");
@@ -152,22 +151,29 @@ function titleCaseSection(name) {
152
151
  .join(" ");
153
152
  }
154
153
  /**
155
- * Stable section ordering: `user` first, then `rules` (the seeded primary
156
- * sections, spec §8), then every other section alphabetically. Server-side
157
- * enumeration order (readdirSync) is FS-dependent, so we sort here for a
158
- * deterministic injection block. Exported for reuse by the OC plugin.
154
+ * Stable section ordering for the rendered `## Identity` block: the #313
155
+ * precedence contract `agent_identity` (the agent's own self + role
156
+ * conduct) first, then `user` (the principal), then `rules` (fleet-wide house
157
+ * rules), then every other section alphabetically. The list is imported from
158
+ * identity-store (SECTION_PRECEDENCE) so the server's SERVED order and every
159
+ * client's RENDERED order are the same definition — they cannot drift.
160
+ * Server-side enumeration order (readdirSync) is FS-dependent, so we sort
161
+ * here for a deterministic injection block. Exported for reuse by the OC
162
+ * plugin; the Python Hermes plugin mirrors this helper — keep them in sync.
159
163
  */
160
164
  function orderSectionNames(names) {
161
- const primaries = ["user", "rules"].filter((p) => names.includes(p));
162
- const rest = names.filter((n) => n !== "user" && n !== "rules").sort();
165
+ const primaries = identity_store_js_1.SECTION_PRECEDENCE.filter((p) => names.includes(p));
166
+ const rest = names.filter((n) => !identity_store_js_1.SECTION_PRECEDENCE.includes(n)).sort();
163
167
  return [...primaries, ...rest];
164
168
  }
165
169
  /**
166
170
  * Render the `## Identity` block from a resolved section map, or null when there
167
171
  * is nothing to inject (no sections, or every section blank after trimming).
168
172
  * Pure — no gating, no I/O. Shared verbatim by the CC hook and the OC plugin so
169
- * both harnesses emit an identical block. Sections are ordered (user, rules,
170
- * then alphabetical) and rendered under title-cased `###` headings.
173
+ * both harnesses emit an identical block. Sections are ordered (agent_identity,
174
+ * user, rules, then alphabetical) and rendered under `###` headings labeled by
175
+ * the #313 scope map (SECTION_LABELS: "Agent identity" / "User" / "Global
176
+ * rules"); unknown section names fall back to title-case.
171
177
  */
172
178
  function renderIdentityBlock(sections) {
173
179
  if (!sections || typeof sections !== "object" || Array.isArray(sections))
@@ -178,7 +184,11 @@ function renderIdentityBlock(sections) {
178
184
  const body = sections[name];
179
185
  if (typeof body !== "string" || body.trim() === "")
180
186
  continue;
181
- bodyParts.push(`### ${titleCaseSection(name)}`, "", body.trim());
187
+ // Own-property lookup (CR-A): "constructor" is allowlist-VALID, and a
188
+ // plain SECTION_LABELS[name] would resolve through the Object.prototype
189
+ // chain and render the inherited function's source as the heading.
190
+ const label = Object.hasOwn(identity_store_js_1.SECTION_LABELS, name) ? identity_store_js_1.SECTION_LABELS[name] : titleCaseSection(name);
191
+ bodyParts.push(`### ${label}`, "", body.trim());
182
192
  }
183
193
  if (bodyParts.length === 0)
184
194
  return null;
@@ -195,10 +205,12 @@ function renderIdentityBlock(sections) {
195
205
  * `agent` param forwarded so per-agent installs resolve the right scope
196
206
  * (WARNING-2: previously the tool always passed `{}` → global, so an agent
197
207
  * with an override saw the wrong identity).
198
- * 2. `injectMemorySection` — the synthetic product-owned `memory` section
199
- * (WARNING-1: the REST route + SessionStart hook inject it; the tool did
200
- * not, contradicting its "same data" docs).
201
- * 3. optional `name` filter, then `renderIdentityBlock` for the `### <Title>`
208
+ * 2. `serveIdentityBody` — the ONE composition helper (REST uses it too):
209
+ * injects the synthetic product-owned `memory` section (WARNING-1: the
210
+ * REST route + SessionStart hook inject it; the tool did not,
211
+ * contradicting its "same data" docs) and applies the SECTION_PRECEDENCE
212
+ * wire order.
213
+ * 3. optional `name` filter, then `renderIdentityBlock` for the `### <Label>`
202
214
  * markdown the hook injects.
203
215
  *
204
216
  * Pure: no I/O of its own (the only I/O is `handleIdentityGet` reading the
@@ -209,7 +221,10 @@ function buildIdentityToolResult(identityDir, identityClients, identityAgents, o
209
221
  // WARNING-2: forward `agent` so per-agent installs resolve the right scope.
210
222
  // An invalid id makes handleIdentityGet return a 400 → surfaced as isError.
211
223
  const query = opts.agent ? { agent: opts.agent } : {};
212
- const r = (0, identity_store_js_1.handleIdentityGet)(identityDir, identityClients, query, identityAgents);
224
+ // WARNING-1: inject the synthetic `memory` section exactly like REST + the
225
+ // SessionStart hook — via the ONE composition helper (serveIdentityBody,
226
+ // #313 CR3) so the tool's served order is the routes' served order.
227
+ const r = (0, identity_store_js_1.serveIdentityBody)((0, identity_store_js_1.handleIdentityGet)(identityDir, identityClients, query, identityAgents), opts.memoryInstructionsEnabled);
213
228
  if (r.status !== 200) {
214
229
  const errBody = r.body;
215
230
  return {
@@ -217,10 +232,6 @@ function buildIdentityToolResult(identityDir, identityClients, identityAgents, o
217
232
  isError: true,
218
233
  };
219
234
  }
220
- // WARNING-1: inject the synthetic `memory` section exactly like REST + the
221
- // SessionStart hook. `injectMemorySection` is a no-op when disabled or when
222
- // agent mode === "off".
223
- (0, memory_instructions_js_1.injectMemorySection)(r.body, opts.memoryInstructionsEnabled);
224
235
  const sections = r.body.sections ?? {};
225
236
  const filtered = opts.name
226
237
  ? (sections[opts.name] !== undefined ? { [opts.name]: sections[opts.name] } : {})
@@ -309,7 +309,7 @@ function createMcpServer() {
309
309
  const identityDir = (0, node_path_1.join)(stateDir, "identity");
310
310
  // Single pipeline shared with REST /identity + the SessionStart hook
311
311
  // (#264 CRITICAL + WARNING-1 + WARNING-2). The pure function owns
312
- // handleIdentityGet → injectMemorySection → renderIdentityBlock.
312
+ // handleIdentityGet → serveIdentityBody → renderIdentityBlock.
313
313
  const result = (0, learnings_identity_js_1.buildIdentityToolResult)(identityDir, identityClients, identityAgents, {
314
314
  name,
315
315
  agent,
@@ -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);
@@ -975,14 +966,11 @@ async function startServer(options = {}) {
975
966
  const identityDir = (0, node_path_1.join)(stateDir, "identity");
976
967
  app.get("/identity", (req, res) => {
977
968
  try {
978
- const r = (0, identity_store_js_1.handleIdentityGet)(identityDir, identityClients, req.query, identityAgents);
979
- // #192: product-owned memory instructions ride as a synthetic read-only
980
- // `memory` section (config memoryInstructions !== false; agent mode
981
- // "off" respected inside the helper). Every harness renders it via the
982
- // shared section renderer zero client changes.
983
- if (r.status === 200) {
984
- (0, memory_instructions_js_1.injectMemorySection)(r.body, memoryInstructionsEnabled);
985
- }
969
+ // serveIdentityBody (#192 + #313): the ONE composition — synthetic
970
+ // product-owned `memory` section, then the SECTION_PRECEDENCE wire
971
+ // order. Shared with GET /context and the MCP hicortex_identity tool so
972
+ // every surface serves byte-identical ordering.
973
+ const r = (0, identity_store_js_1.serveIdentityBody)((0, identity_store_js_1.handleIdentityGet)(identityDir, identityClients, req.query, identityAgents), memoryInstructionsEnabled);
986
974
  res.status(r.status).json(r.body);
987
975
  }
988
976
  catch (err) {
@@ -1013,10 +1001,9 @@ async function startServer(options = {}) {
1013
1001
  // difference in behaviour, only the URL.
1014
1002
  app.get("/context", (req, res) => {
1015
1003
  try {
1016
- const r = (0, identity_store_js_1.handleIdentityGet)(identityDir, identityClients, req.query, identityAgents);
1017
- if (r.status === 200) {
1018
- (0, memory_instructions_js_1.injectMemorySection)(r.body, memoryInstructionsEnabled);
1019
- }
1004
+ // Same serveIdentityBody composition as GET /identity — the alias
1005
+ // serves byte-equal behaviour by construction.
1006
+ const r = (0, identity_store_js_1.serveIdentityBody)((0, identity_store_js_1.handleIdentityGet)(identityDir, identityClients, req.query, identityAgents), memoryInstructionsEnabled);
1020
1007
  res.status(r.status).json(r.body);
1021
1008
  }
1022
1009
  catch (err) {
package/dist/prompts.js CHANGED
@@ -130,6 +130,8 @@ EXTRACT into this markdown format:
130
130
 
131
131
  ### Project State Changes
132
132
  - [D] [SUBJECT]: [what changed], [from → to] (${date})
133
+ (Durable shifts only — the model/tool/approach the project now uses. A
134
+ status flip or counter change is NEVER-RECORD ephemera, not a state change)
133
135
 
134
136
  ### Key Entities & Relationships
135
137
  - [K] [entity A] → [relationship] → [entity B] (${date})
@@ -179,6 +181,23 @@ label, a sentiment ("Strong Negative"), or "User rejected…" is unfindable —
179
181
  matches every emotionally-similar prompt and no topically-relevant one. Front-load
180
182
  the subject; put reaction, intensity and reasoning AFTER it.
181
183
 
184
+ NEVER-RECORD — ephemera gate (critical):
185
+ Every candidate must pass one test: will it still be TRUE and still MATTER in 3 months?
186
+ Content whose entire value is a state that expires is NEVER recorded —
187
+ do not score it lower and write it anyway: OMIT it. A closed category of never-record ephemera:
188
+ - issue/PR/epic/merge status: "PR #173 merged", "Epic 2 CLOSED — all children merged"
189
+ - version/deploy/test-count statistics: "v0.16.0 deployed to rc dist-tag",
190
+ "Test suite grew 616→749", "Main branch updated to <sha>"
191
+ - session bookkeeping/outcomes: "Session closure: nothing remains to do",
192
+ todo deferrals ("parked until next week"), sync/replay states
193
+ - transient readings/snapshots: "disk usage snapshot: 84% at 14:20",
194
+ "uptime counter passed 40 days"
195
+ The durable part of the same event may still qualify — the CHOICE a change
196
+ embodies ("standardize on model X", user-confirmed) is [D], a configuration
197
+ that holds going forward is [K]; the version bump, merge, or count itself never is.
198
+ If EVERY item in the transcript is never-record ephemera, output ONLY:
199
+ "NO_EXTRACT" — zero memories is the correct result for a pure-status segment.
200
+
182
201
  RULES:
183
202
  - Extract MAX 20 items total (quality over quantity)
184
203
  - Use EXACT names/versions/paths/numbers as they appear in the transcript —
@@ -187,7 +206,8 @@ RULES:
187
206
  - Each must be useful if recalled in a future session
188
207
  - Skip: routine code edits, standard tool usage, trivial fixes
189
208
  - Include: architectural decisions, debugging breakthroughs, user preferences,
190
- tool configurations, API discoveries, project milestones
209
+ tool configurations, API discoveries, durable project milestones (a status
210
+ flip — shipped/merged/closed — is NEVER-RECORD ephemera, not a milestone)
191
211
  - PRIORITIZE Corrections & Rejections — these are high-value signals for learning
192
212
  what the user does NOT want. Even a single "no" or style correction is worth extracting.
193
213
  - Strong language or profanity from the user is a high-intensity signal — it indicates
@@ -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