@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 +4 -3
- package/dist/mcp-server.js +20 -29
- 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/openclaw.plugin.json +1 -1
- package/package.json +1 -1
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:
|
|
264
|
-
| `
|
|
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:
|
|
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) |
|
package/dist/mcp-server.js
CHANGED
|
@@ -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);
|
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
|
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
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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": {
|