@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.
- package/README.md +8 -4
- package/assets/identity.html +59 -8
- package/dist/identity-store.d.ts +65 -1
- package/dist/identity-store.js +132 -9
- package/dist/index.d.ts +22 -7
- package/dist/index.js +510 -86
- package/dist/learnings-identity.d.ts +19 -10
- package/dist/learnings-identity.js +30 -19
- package/dist/mcp-server.js +29 -42
- package/dist/prompts.js +21 -1
- package/dist/recall-index.d.ts +70 -4
- package/dist/recall-index.js +134 -2
- package/dist/retrieval.d.ts +26 -0
- package/dist/retrieval.js +11 -0
- package/dist/types.d.ts +14 -0
- package/hermes-plugin/hicortex/provider.py +21 -5
- package/openclaw.plugin.json +15 -7
- package/package.json +1 -1
|
@@ -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
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
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 (
|
|
77
|
-
* then alphabetical) and rendered under
|
|
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. `
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
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
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
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 =
|
|
162
|
-
const rest = names.filter((n) => n
|
|
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 (
|
|
170
|
-
* then alphabetical) and rendered under
|
|
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
|
-
|
|
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. `
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
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
|
-
|
|
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] } : {})
|
package/dist/mcp-server.js
CHANGED
|
@@ -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 →
|
|
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
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
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
|
-
|
|
979
|
-
//
|
|
980
|
-
//
|
|
981
|
-
//
|
|
982
|
-
|
|
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
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
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
|
package/dist/recall-index.d.ts
CHANGED
|
@@ -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
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
|
|
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
|
package/dist/recall-index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/retrieval.d.ts
CHANGED
|
@@ -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
|