@gamaze/hicortex 0.19.3 → 0.19.5
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 +13 -2
- package/dist/backup.d.ts +50 -0
- package/dist/backup.js +118 -2
- package/dist/capture.d.ts +27 -2
- package/dist/capture.js +103 -6
- package/dist/distiller.d.ts +46 -1
- package/dist/distiller.js +92 -9
- package/dist/embedder.d.ts +23 -0
- package/dist/embedder.js +29 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +286 -5
- package/dist/mcp-server.d.ts +25 -4
- package/dist/mcp-server.js +89 -10
- package/dist/nightly.d.ts +25 -0
- package/dist/nightly.js +125 -20
- package/dist/prompts.d.ts +18 -0
- package/dist/prompts.js +38 -0
- package/dist/recall-index.d.ts +35 -0
- package/dist/recall-index.js +103 -20
- package/dist/retrieval.d.ts +11 -0
- package/dist/retrieval.js +6 -2
- package/dist/storage.d.ts +30 -0
- package/dist/storage.js +46 -1
- package/dist/type-classify.js +3 -1
- package/dist/types.d.ts +17 -0
- package/dist/uninstall.d.ts +33 -0
- package/dist/uninstall.js +78 -26
- package/dist/viz.d.ts +1 -1
- package/dist/viz.js +28 -1
- package/openclaw.plugin.json +10 -1
- package/package.json +1 -1
package/dist/recall-index.js
CHANGED
|
@@ -33,6 +33,19 @@
|
|
|
33
33
|
* unchanged. Turn suppression still wins: a recently shown novelty pick is
|
|
34
34
|
* suppressed like any other (the guarantee is about candidate inclusion, not
|
|
35
35
|
* forcing re-shows).
|
|
36
|
+
*
|
|
37
|
+
* #329 item 3 — the pure search is SKIPPED when it would be byte-identical
|
|
38
|
+
* to the blended one: turn 1 (no centroid yet — nothing to blend) or
|
|
39
|
+
* sessionIntentWeight 0 (blend disabled). The blended result IS the pure
|
|
40
|
+
* result there, so the floor is trivially satisfied by the blended picks and
|
|
41
|
+
* the second search (embeds aside, its whole DB + FTS half) is pure waste.
|
|
42
|
+
*
|
|
43
|
+
* #329 item 4 — novelty backfill: when the blended picks are empty/short,
|
|
44
|
+
* unclaimed maxItems slots are filled from the remaining filtered
|
|
45
|
+
* pure-prompt tail (gate + suppression already applied). Without it the
|
|
46
|
+
* topic-switch turn — the one the floor exists for — got the MOST truncated
|
|
47
|
+
* menu: novelty slots + a diluted remainder, while further pure candidates
|
|
48
|
+
* that had already passed every gate sat unused.
|
|
36
49
|
*/
|
|
37
50
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
38
51
|
if (k2 === undefined) k2 = k;
|
|
@@ -68,7 +81,7 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
68
81
|
};
|
|
69
82
|
})();
|
|
70
83
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
71
|
-
exports.DEFAULT_NOVELTY_FLOOR_SLOTS = void 0;
|
|
84
|
+
exports.MAX_SESSION_ID_CHARS = exports.DEFAULT_NOVELTY_FLOOR_SLOTS = void 0;
|
|
72
85
|
exports.resolveNoveltyFloorSlots = resolveNoveltyFloorSlots;
|
|
73
86
|
exports.memoryTitle = memoryTitle;
|
|
74
87
|
exports.formatIndexLine = formatIndexLine;
|
|
@@ -116,6 +129,16 @@ function resolveNoveltyFloorSlots(rawSlots, rawMaxItems) {
|
|
|
116
129
|
* gate is correct, not a defect); raise only if blocks are persistently
|
|
117
130
|
* under-filled in production. */
|
|
118
131
|
const CANDIDATE_MULTIPLIER = 3;
|
|
132
|
+
/**
|
|
133
|
+
* Hard cap on `session_id` length (#328 item 2a). The id is retained as a Map
|
|
134
|
+
* key by SessionRecallRegistry for the process lifetime (maxSessions=500 LRU
|
|
135
|
+
* + a per-session shown-set + intent centroid), so an unbounded id is an OOM
|
|
136
|
+
* vector: ~4.9MB ids × 500 sessions ≈ 2.4GB of retained keys from an
|
|
137
|
+
* authenticated-but-hostile tenant. Real session ids (CC UUIDs, plugin
|
|
138
|
+
* session keys) are ≤64 chars — 128 is generous headroom. Longer → 400 with
|
|
139
|
+
* a clear error; the client treats it like any bad request.
|
|
140
|
+
*/
|
|
141
|
+
exports.MAX_SESSION_ID_CHARS = 128;
|
|
119
142
|
/** First content line, de-markdowned and truncated — the index line title. */
|
|
120
143
|
function memoryTitle(content, maxLen = DEFAULT_TITLE_CHARS) {
|
|
121
144
|
const firstLine = content
|
|
@@ -191,6 +214,15 @@ function passesRelevanceGate(r, minSimilarity) {
|
|
|
191
214
|
* fold, or the pure prompt with NO centroid state for #324);
|
|
192
215
|
* - retrieve() with noStrengthen (exposure is recorded by
|
|
193
216
|
* handleRecallIndex via touchMemoriesShown, never here).
|
|
217
|
+
*
|
|
218
|
+
* #329 CR finding 1b: the FTS candidate list is ALSO computed once per
|
|
219
|
+
* request (ftsOnce, keyed on query + candidate window) and threaded into both
|
|
220
|
+
* retrieve() calls via the ftsCandidates provider — the blended and pure
|
|
221
|
+
* searches of one request carry identical query text and window, so their FTS
|
|
222
|
+
* halves were byte-identical SQL executed twice. `ftsFn` is the DI seam for
|
|
223
|
+
* tests (production: storage.searchFts); a throwing FTS computation memoizes
|
|
224
|
+
* to an empty shared list — the same vector-only degradation retrieve()'s
|
|
225
|
+
* catch always produced, never an error.
|
|
194
226
|
*/
|
|
195
227
|
function createRecallRetrieveFn(deps) {
|
|
196
228
|
let embMemo = null;
|
|
@@ -200,6 +232,21 @@ function createRecallRetrieveFn(deps) {
|
|
|
200
232
|
}
|
|
201
233
|
return embMemo.p;
|
|
202
234
|
};
|
|
235
|
+
const ftsResolve = deps.ftsFn ?? storage.searchFts;
|
|
236
|
+
let ftsMemo = null;
|
|
237
|
+
const ftsOnce = (query, limit) => {
|
|
238
|
+
if (!ftsMemo || ftsMemo.query !== query || ftsMemo.limit !== limit) {
|
|
239
|
+
try {
|
|
240
|
+
ftsMemo = { query, limit, rows: ftsResolve(deps.db, query, limit) };
|
|
241
|
+
}
|
|
242
|
+
catch {
|
|
243
|
+
// Same degradation retrieve()'s own catch always produced — the FTS
|
|
244
|
+
// list is dropped and the search proceeds vector-only.
|
|
245
|
+
ftsMemo = { query, limit, rows: [] };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return ftsMemo.rows;
|
|
249
|
+
};
|
|
203
250
|
return async (query, limit, filters, sessionId, purePrompt) => {
|
|
204
251
|
const { weight, alpha } = (0, retrieval_js_1.getSessionIntent)();
|
|
205
252
|
const promptEmb = await embedOnce(query);
|
|
@@ -216,6 +263,10 @@ function createRecallRetrieveFn(deps) {
|
|
|
216
263
|
project: filters?.project,
|
|
217
264
|
missionDomains: filters?.mission_domains,
|
|
218
265
|
queryEmbedding: queryVec,
|
|
266
|
+
// #329: shared per-request FTS list. The recall path never passes
|
|
267
|
+
// sourceAgent, so the memo is keyed on (query, fetchLimit) only —
|
|
268
|
+
// exactly the two things retrieve() would pass to searchFts.
|
|
269
|
+
ftsCandidates: (fetchLimit) => ftsOnce(query, fetchLimit),
|
|
219
270
|
});
|
|
220
271
|
};
|
|
221
272
|
}
|
|
@@ -242,6 +293,16 @@ async function handleRecallIndex(deps, body) {
|
|
|
242
293
|
if (!sessionId) {
|
|
243
294
|
return { status: 400, body: { error: "Missing 'session_id'" } };
|
|
244
295
|
}
|
|
296
|
+
// Length cap (#328 item 2a) — BEFORE the reset branch so an oversized id
|
|
297
|
+
// never reaches ANY registry call (reset() itself only deletes, but the
|
|
298
|
+
// next non-reset call with the same id would beginTurn it into a retained
|
|
299
|
+
// Map key). Clear error so a misbehaving client can self-diagnose.
|
|
300
|
+
if (sessionId.length > exports.MAX_SESSION_ID_CHARS) {
|
|
301
|
+
return {
|
|
302
|
+
status: 400,
|
|
303
|
+
body: { error: `'session_id' too long (max ${exports.MAX_SESSION_ID_CHARS} chars, got ${sessionId.length})` },
|
|
304
|
+
};
|
|
305
|
+
}
|
|
245
306
|
// Reset: SessionStart (startup/resume/clear/compact) — fresh context, so the
|
|
246
307
|
// shown-set is stale by definition.
|
|
247
308
|
if (req.reset === true) {
|
|
@@ -270,26 +331,30 @@ async function handleRecallIndex(deps, body) {
|
|
|
270
331
|
project: typeof req.project === "string" && req.project ? req.project : undefined,
|
|
271
332
|
mission_domains: parseStringListParam(req.mission_domains),
|
|
272
333
|
};
|
|
273
|
-
// #324: when the floor is armed
|
|
274
|
-
//
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
// the request explicitly;
|
|
334
|
+
// #324 + #329 item 3: when the floor is armed AND would differ from the
|
|
335
|
+
// blended search, TWO searches run per recall — the blended (session-intent)
|
|
336
|
+
// query that has always run, and a PURE-prompt query with no centroid blend.
|
|
337
|
+
// Issued together so the second adds no wall-clock latency beyond its own DB
|
|
338
|
+
// work (the prompt is embedded once — the closure memoizes). Same failure
|
|
339
|
+
// domain (same db + embedder): either failing fails the request explicitly;
|
|
340
|
+
// no silent blended-only degradation.
|
|
279
341
|
//
|
|
280
|
-
// The
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
//
|
|
342
|
+
// The SKIP (#329 item 3): on turn 1 the registry has no centroid yet (the
|
|
343
|
+
// blended call reads-before-fold — recallQueryVector), and at
|
|
344
|
+
// sessionIntentWeight 0 the centroid is never read at all. In both cases
|
|
345
|
+
// the blended query vector IS the pure prompt vector, so the second search
|
|
346
|
+
// would return byte-identical candidates — skip it (the floor is trivially
|
|
347
|
+
// satisfied: every pure hit is by construction among the blended picks).
|
|
348
|
+
// The decision is made BEFORE any retrieveFn call, i.e. on the centroid
|
|
349
|
+
// state of the PREVIOUS turns — exactly the turn-1/turn-2 distinction.
|
|
350
|
+
const runPureSearch = noveltySlots > 0 &&
|
|
351
|
+
(0, retrieval_js_1.getSessionIntent)().weight > 0 &&
|
|
352
|
+
deps.registry.getCentroid(sessionId) !== undefined;
|
|
288
353
|
let results;
|
|
289
354
|
let pureResults;
|
|
290
355
|
try {
|
|
291
356
|
const blended = deps.retrieveFn(prompt, maxItems * CANDIDATE_MULTIPLIER, filters, sessionId);
|
|
292
|
-
const pure =
|
|
357
|
+
const pure = runPureSearch
|
|
293
358
|
? deps.retrieveFn(prompt, maxItems * CANDIDATE_MULTIPLIER, filters, sessionId, true)
|
|
294
359
|
: Promise.resolve([]);
|
|
295
360
|
[results, pureResults] = await Promise.all([blended, pure]);
|
|
@@ -316,10 +381,14 @@ async function handleRecallIndex(deps, body) {
|
|
|
316
381
|
// unchanged. Suppression applies BEFORE the guarantee (suppression wins:
|
|
317
382
|
// the floor is about candidate inclusion, not forcing re-shows). FTS-sourced
|
|
318
383
|
// pure hits pass the gate unconditionally, same as the blended path.
|
|
319
|
-
|
|
320
|
-
|
|
384
|
+
//
|
|
385
|
+
// The gate + suppression are applied ONCE to the pure list: the head feeds
|
|
386
|
+
// the novelty floor, the tail feeds the #329 backfill below.
|
|
387
|
+
const pureFiltered = pureResults
|
|
321
388
|
.filter((r) => passesRelevanceGate(r, minSimilarity))
|
|
322
|
-
.filter((r) => deps.registry.isShowable(sessionId, r.id))
|
|
389
|
+
.filter((r) => deps.registry.isShowable(sessionId, r.id));
|
|
390
|
+
const blendedIds = new Set(blendedPicks.map((r) => r.id));
|
|
391
|
+
const noveltyPicks = pureFiltered
|
|
323
392
|
.filter((r) => !blendedIds.has(r.id))
|
|
324
393
|
.slice(0, noveltySlots);
|
|
325
394
|
// The floor takes precedence (#324 vs #192 cold slots): novelty picks hold
|
|
@@ -329,10 +398,24 @@ async function handleRecallIndex(deps, body) {
|
|
|
329
398
|
// exceeds maxItems. Render order: novelty picks FIRST — on a topic switch
|
|
330
399
|
// they are the most relevant lines to the CURRENT turn, and the head of the
|
|
331
400
|
// block carries the most weight for a reader scanning the menu.
|
|
332
|
-
|
|
401
|
+
let picked = [
|
|
333
402
|
...noveltyPicks,
|
|
334
403
|
...blendedPicks.slice(0, Math.max(0, maxItems - noveltyPicks.length)),
|
|
335
404
|
];
|
|
405
|
+
// #329 item 4 — backfill: a topic-switch turn dilutes the blended picks, so
|
|
406
|
+
// picked can land below maxItems even though FURTHER pure candidates have
|
|
407
|
+
// already passed the gate + suppression + dedup (they sit in the pure tail
|
|
408
|
+
// beyond the first noveltyFloorSlots). Fill the unclaimed slots from that
|
|
409
|
+
// tail — without it, the turn the floor exists for got the most truncated
|
|
410
|
+
// menu. Continuing-intent sessions are untouched: blended picks full →
|
|
411
|
+
// nothing to backfill (zero-delta output preserved).
|
|
412
|
+
if (picked.length < maxItems) {
|
|
413
|
+
const pickedIds = new Set(picked.map((r) => r.id));
|
|
414
|
+
const backfill = pureFiltered
|
|
415
|
+
.filter((r) => !pickedIds.has(r.id))
|
|
416
|
+
.slice(0, maxItems - picked.length);
|
|
417
|
+
picked = [...picked, ...backfill];
|
|
418
|
+
}
|
|
336
419
|
if (picked.length === 0) {
|
|
337
420
|
return { status: 200, body: { block: null, shown: [], turn } };
|
|
338
421
|
}
|
package/dist/retrieval.d.ts
CHANGED
|
@@ -231,6 +231,17 @@ export declare function retrieve(db: Database.Database, embedFn: EmbedFn, query:
|
|
|
231
231
|
* and get pure-prompt behavior (the query string is embedded here). The
|
|
232
232
|
* FTS path still uses the raw `query` text regardless. */
|
|
233
233
|
queryEmbedding?: Float32Array;
|
|
234
|
+
/** #329 CR finding 1b: caller-provided FTS candidate resolution, called
|
|
235
|
+
* INSTEAD of running storage.searchFts here. The /recall-index closure
|
|
236
|
+
* passes a per-request memoized provider so the blended and pure
|
|
237
|
+
* searches of ONE request — same query text, same candidate window —
|
|
238
|
+
* execute the FTS half exactly once and share the list. The provider
|
|
239
|
+
* receives the fetchLimit/sourceAgent THIS call would have used, so the
|
|
240
|
+
* shared list is always computed with the right window. Callers that
|
|
241
|
+
* omit it get the previous behavior (retrieve runs searchFts itself). */
|
|
242
|
+
ftsCandidates?: (fetchLimit: number, sourceAgent?: string) => Array<Memory & {
|
|
243
|
+
rank: number;
|
|
244
|
+
}>;
|
|
234
245
|
}): Promise<MemorySearchResult[]>;
|
|
235
246
|
/**
|
|
236
247
|
* Get recent context, optionally filtered by project.
|
package/dist/retrieval.js
CHANGED
|
@@ -556,8 +556,12 @@ async function retrieve(db, embedFn, query, options) {
|
|
|
556
556
|
try {
|
|
557
557
|
// sourceAgent is pushed into the FTS SQL (hard filter). project is NOT (it
|
|
558
558
|
// is a soft affinity boost in computeScore as of #203). privacy is NOT
|
|
559
|
-
// (0.16.x: vestigial column, never filtered).
|
|
560
|
-
|
|
559
|
+
// (0.16.x: vestigial column, never filtered). With a caller-provided
|
|
560
|
+
// provider (#329 request-level memo) the same list is shared across the
|
|
561
|
+
// retrieves of one recall request instead of re-executed.
|
|
562
|
+
ftsCandidates = options?.ftsCandidates
|
|
563
|
+
? options.ftsCandidates(fetchLimit, sourceAgent)
|
|
564
|
+
: storage.searchFts(db, query, fetchLimit, sourceAgent);
|
|
561
565
|
}
|
|
562
566
|
catch {
|
|
563
567
|
// FTS5 search can fail on special characters; fall back to vector-only
|
package/dist/storage.d.ts
CHANGED
|
@@ -137,6 +137,36 @@ export interface Bm25Weights {
|
|
|
137
137
|
export declare function configureBm25Fts(config?: Record<string, unknown> | null): Bm25Weights;
|
|
138
138
|
/** Current resolved weights (tests + status output). */
|
|
139
139
|
export declare function getBm25Weights(): Bm25Weights;
|
|
140
|
+
/**
|
|
141
|
+
* Cap on tokens fed to an FTS5 MATCH expression (#329 CR finding 1a).
|
|
142
|
+
* Quoting made pasted term lists LEGAL queries — and an all-common-tokens AND
|
|
143
|
+
* is expensive: measured at 100K rows, a 50-token AND runs ~518ms and a
|
|
144
|
+
* 200-token one 6.3s, and the /recall-index hot path would pay it twice per
|
|
145
|
+
* prompt. Beyond ~24 tokens the implicit AND is semantic noise anyway (a
|
|
146
|
+
* memory matching 24+ ANDed prompt tokens is either the exact text or
|
|
147
|
+
* nothing), so the FIRST 24 tokens are used. 24 is a shipped bound, not a
|
|
148
|
+
* config knob — change it deliberately, with a perf measurement.
|
|
149
|
+
*/
|
|
150
|
+
export declare const FTS_MATCH_MAX_TOKENS = 24;
|
|
151
|
+
/**
|
|
152
|
+
* FTS5 MATCH-safety quoting (#329 item 1). The raw prompt is NOT valid FTS5
|
|
153
|
+
* query syntax: ordinary prompt punctuation (?, -, (, :, URLs, apostrophes, a
|
|
154
|
+
* leading AND/OR) crashes the FTS5 parser, and retrieval.retrieve's catch then
|
|
155
|
+
* silently drops the ENTIRE FTS candidate list — the perf sweep measured 8/12
|
|
156
|
+
* realistic prompts affected, and it is why relevance eval #3 saw 0 FTS rows
|
|
157
|
+
* in 2,208 candidates. Fix: tokenize on whitespace, strip embedded double
|
|
158
|
+
* quotes (a raw `"` would terminate our own quoting), and wrap each token in
|
|
159
|
+
* double quotes — a quoted token is a phrase of LITERAL strings, immune to
|
|
160
|
+
* FTS5 query syntax (`"what" "is" "the" "deployment" "status"`). Punctuation
|
|
161
|
+
* INSIDE a token is kept: the tokenizer strips it identically on both sides,
|
|
162
|
+
* so `"status?"` still matches content containing "status". Joined with spaces
|
|
163
|
+
* (implicit AND — the same semantics clean prompts always had; a PROSE prompt
|
|
164
|
+
* whose content holds only most of the tokens matches nothing, which is why
|
|
165
|
+
* FTS fires on short keyword prompts, not prose recall). Capped at the first
|
|
166
|
+
* FTS_MATCH_MAX_TOKENS tokens. A query that quotes away to nothing yields ""
|
|
167
|
+
* and the caller skips the SQL entirely.
|
|
168
|
+
*/
|
|
169
|
+
export declare function buildFtsMatchExpression(query: string): string;
|
|
140
170
|
/**
|
|
141
171
|
* Full-text search using FTS5 fielded BM25 (BM25F) ranking.
|
|
142
172
|
* Returns memories with a rank field (lower is better — see sign note below).
|
package/dist/storage.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* Ported from hicortex/storage.py. All functions are synchronous (better-sqlite3).
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
exports.FTS_MATCH_MAX_TOKENS = void 0;
|
|
7
8
|
exports.embedToBlob = embedToBlob;
|
|
8
9
|
exports.insertMemory = insertMemory;
|
|
9
10
|
exports.resolveMemoryId = resolveMemoryId;
|
|
@@ -20,6 +21,7 @@ exports.getStoredEmbedding = getStoredEmbedding;
|
|
|
20
21
|
exports.vectorSearch = vectorSearch;
|
|
21
22
|
exports.configureBm25Fts = configureBm25Fts;
|
|
22
23
|
exports.getBm25Weights = getBm25Weights;
|
|
24
|
+
exports.buildFtsMatchExpression = buildFtsMatchExpression;
|
|
23
25
|
exports.searchFts = searchFts;
|
|
24
26
|
exports.addLink = addLink;
|
|
25
27
|
exports.getLinks = getLinks;
|
|
@@ -345,6 +347,44 @@ function configureBm25Fts(config) {
|
|
|
345
347
|
function getBm25Weights() {
|
|
346
348
|
return { ...bm25Weights };
|
|
347
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Cap on tokens fed to an FTS5 MATCH expression (#329 CR finding 1a).
|
|
352
|
+
* Quoting made pasted term lists LEGAL queries — and an all-common-tokens AND
|
|
353
|
+
* is expensive: measured at 100K rows, a 50-token AND runs ~518ms and a
|
|
354
|
+
* 200-token one 6.3s, and the /recall-index hot path would pay it twice per
|
|
355
|
+
* prompt. Beyond ~24 tokens the implicit AND is semantic noise anyway (a
|
|
356
|
+
* memory matching 24+ ANDed prompt tokens is either the exact text or
|
|
357
|
+
* nothing), so the FIRST 24 tokens are used. 24 is a shipped bound, not a
|
|
358
|
+
* config knob — change it deliberately, with a perf measurement.
|
|
359
|
+
*/
|
|
360
|
+
exports.FTS_MATCH_MAX_TOKENS = 24;
|
|
361
|
+
/**
|
|
362
|
+
* FTS5 MATCH-safety quoting (#329 item 1). The raw prompt is NOT valid FTS5
|
|
363
|
+
* query syntax: ordinary prompt punctuation (?, -, (, :, URLs, apostrophes, a
|
|
364
|
+
* leading AND/OR) crashes the FTS5 parser, and retrieval.retrieve's catch then
|
|
365
|
+
* silently drops the ENTIRE FTS candidate list — the perf sweep measured 8/12
|
|
366
|
+
* realistic prompts affected, and it is why relevance eval #3 saw 0 FTS rows
|
|
367
|
+
* in 2,208 candidates. Fix: tokenize on whitespace, strip embedded double
|
|
368
|
+
* quotes (a raw `"` would terminate our own quoting), and wrap each token in
|
|
369
|
+
* double quotes — a quoted token is a phrase of LITERAL strings, immune to
|
|
370
|
+
* FTS5 query syntax (`"what" "is" "the" "deployment" "status"`). Punctuation
|
|
371
|
+
* INSIDE a token is kept: the tokenizer strips it identically on both sides,
|
|
372
|
+
* so `"status?"` still matches content containing "status". Joined with spaces
|
|
373
|
+
* (implicit AND — the same semantics clean prompts always had; a PROSE prompt
|
|
374
|
+
* whose content holds only most of the tokens matches nothing, which is why
|
|
375
|
+
* FTS fires on short keyword prompts, not prose recall). Capped at the first
|
|
376
|
+
* FTS_MATCH_MAX_TOKENS tokens. A query that quotes away to nothing yields ""
|
|
377
|
+
* and the caller skips the SQL entirely.
|
|
378
|
+
*/
|
|
379
|
+
function buildFtsMatchExpression(query) {
|
|
380
|
+
return query
|
|
381
|
+
.split(/\s+/)
|
|
382
|
+
.map((token) => token.replace(/"/g, ""))
|
|
383
|
+
.filter((token) => token.length > 0)
|
|
384
|
+
.slice(0, exports.FTS_MATCH_MAX_TOKENS)
|
|
385
|
+
.map((token) => `"${token}"`)
|
|
386
|
+
.join(" ");
|
|
387
|
+
}
|
|
348
388
|
/**
|
|
349
389
|
* Full-text search using FTS5 fielded BM25 (BM25F) ranking.
|
|
350
390
|
* Returns memories with a rank field (lower is better — see sign note below).
|
|
@@ -366,8 +406,13 @@ function getBm25Weights() {
|
|
|
366
406
|
* caching is unaffected and the config path is the only editor.
|
|
367
407
|
*/
|
|
368
408
|
function searchFts(db, query, limit = 10, sourceAgent) {
|
|
409
|
+
// #329: quote the query into literal phrases — a raw prompt crashes the
|
|
410
|
+
// FTS5 parser on punctuation and the caller's catch drops the whole list.
|
|
411
|
+
const matchExpr = buildFtsMatchExpression(query);
|
|
412
|
+
if (!matchExpr)
|
|
413
|
+
return [];
|
|
369
414
|
const conditions = ["memories_fts MATCH ?"];
|
|
370
|
-
const params = [
|
|
415
|
+
const params = [matchExpr];
|
|
371
416
|
if (sourceAgent) {
|
|
372
417
|
conditions.push("m.source_agent = ?");
|
|
373
418
|
params.push(sourceAgent);
|
package/dist/type-classify.js
CHANGED
|
@@ -84,7 +84,9 @@ function buildTypeClassifyPrompt(content) {
|
|
|
84
84
|
`"adopted the graded-schema tag model"). Not knowledge (it can change) and ` +
|
|
85
85
|
`not an experience (it persists). A bare AI recommendation or proposal is ` +
|
|
86
86
|
`NEVER a decision — "AI proposed X → user declined/held" is experience ` +
|
|
87
|
-
`(#290)
|
|
87
|
+
`(#290). Even if carried out by the user, a version bump, merge, or count ` +
|
|
88
|
+
`is never a decision — only the durable user-confirmed standardization it ` +
|
|
89
|
+
`embodies is (#329).\n\n` +
|
|
88
90
|
`IMPORTANCE (0.0–1.0):\n` +
|
|
89
91
|
`- 0.8–1.0: load-bearing — a core piece of knowledge or a decision the ` +
|
|
90
92
|
`agent must know.\n` +
|
package/dist/types.d.ts
CHANGED
|
@@ -413,6 +413,15 @@ export interface HicortexConfig {
|
|
|
413
413
|
* accepts no client limit.
|
|
414
414
|
*/
|
|
415
415
|
recallLimit?: number;
|
|
416
|
+
/**
|
|
417
|
+
* OC plugin (#326): auto-scaffold the dead-man guard line into the agent
|
|
418
|
+
* workspace bootstrap file (BOOTSTRAP.md) at service start — the #313
|
|
419
|
+
* SECONDARY layer under the injected IDENTITY UNAVAILABLE banner (which is
|
|
420
|
+
* the primary, plugin-side mechanism). Idempotent: a bootstrap already
|
|
421
|
+
* carrying the line is never rewritten. Default true; `false` disables both
|
|
422
|
+
* the write and any file creation entirely.
|
|
423
|
+
*/
|
|
424
|
+
scaffoldDeadMan?: boolean;
|
|
416
425
|
/**
|
|
417
426
|
* Soft cap on the memory corpus (default 10000). When the corpus exceeds this,
|
|
418
427
|
* the nightly's capacity-eviction stage (#245) removes the lowest-
|
|
@@ -465,6 +474,14 @@ export interface HicortexConfig {
|
|
|
465
474
|
* Operator-owned: point at a mounted backup volume, a tmpfs, etc.
|
|
466
475
|
*/
|
|
467
476
|
backupDir?: string;
|
|
477
|
+
/**
|
|
478
|
+
* Backup retention (#327): how many of the newest `hicortex-*.tar.gz`
|
|
479
|
+
* artifacts the backup dir keeps after each successful write. Default 7;
|
|
480
|
+
* 0 keeps everything. Without it every full nightly (and `hicortex backup`)
|
|
481
|
+
* adds an artifact forever — unbounded growth, per hosted tenant too. Only
|
|
482
|
+
* artifacts matching the product's own name pattern are ever pruned.
|
|
483
|
+
*/
|
|
484
|
+
backupRetention?: number;
|
|
468
485
|
/**
|
|
469
486
|
* Post-backup offsite hook (#6). When set, `hicortex backup` and the nightly
|
|
470
487
|
* backup stage invoke this command with the artifact path appended as the LAST
|
package/dist/uninstall.d.ts
CHANGED
|
@@ -14,4 +14,37 @@
|
|
|
14
14
|
export declare const SESSION_START_HOOK_COMMAND_RE: RegExp;
|
|
15
15
|
/** True when a CC hook `command` string runs the Hicortex SessionStart hook. */
|
|
16
16
|
export declare function isHicortexSessionStartHook(command: string): boolean;
|
|
17
|
+
/**
|
|
18
|
+
* Matches a CC hook `command` that runs the Hicortex recall hook — the
|
|
19
|
+
* `recall-hook` subcommand (#192). Same word-boundary discipline as the
|
|
20
|
+
* learnings matcher: an unrelated command that merely CONTAINS the substring
|
|
21
|
+
* ("my-recall-hook", "recall-hooks-old") is never swept up. Used for BOTH
|
|
22
|
+
* event arrays the installer writes (UserPromptSubmit + SessionStart).
|
|
23
|
+
*/
|
|
24
|
+
export declare const RECALL_HOOK_COMMAND_RE: RegExp;
|
|
25
|
+
/** True when a CC hook `command` string runs the Hicortex recall hook. */
|
|
26
|
+
export declare function isHicortexRecallHook(command: string): boolean;
|
|
27
|
+
/** One hook group removed from settings.json (for per-group logging). */
|
|
28
|
+
export interface RemovedHookGroup {
|
|
29
|
+
/** CC event array the entries were removed from ("SessionStart", "UserPromptSubmit"). */
|
|
30
|
+
event: string;
|
|
31
|
+
/** Which Hicortex hook set: "learnings" (learnings-identity/lessons-context) or "recall". */
|
|
32
|
+
kind: "learnings" | "recall";
|
|
33
|
+
/** Number of matcher entries removed. */
|
|
34
|
+
count: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Remove every Hicortex hook entry from a PARSED ~/.claude/settings.json
|
|
38
|
+
* (#327): the SessionStart learnings hook (canonical + legacy alias) AND the
|
|
39
|
+
* recall-hook pair (UserPromptSubmit + SessionStart, installed together by
|
|
40
|
+
* installRecallHooks — leaving either behind is a silent npx spawn per prompt
|
|
41
|
+
* forever). Mutates `settings` in place; returns what was removed (empty when
|
|
42
|
+
* nothing matched — a clean no-op). Exact-match discipline throughout: only
|
|
43
|
+
* entries whose `hooks[].command` matches a Hicortex subcommand are removed;
|
|
44
|
+
* foreign hooks (and prefix-colliding names) stay untouched.
|
|
45
|
+
*
|
|
46
|
+
* Pure on the parsed object so the uninstall behavior is unit-testable
|
|
47
|
+
* without spinning up CC; runUninstall owns the file I/O.
|
|
48
|
+
*/
|
|
49
|
+
export declare function removeHicortexCcHooks(settings: Record<string, unknown>): RemovedHookGroup[];
|
|
17
50
|
export declare function runUninstall(): Promise<void>;
|
package/dist/uninstall.js
CHANGED
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* Preserves the database (user data).
|
|
5
5
|
*/
|
|
6
6
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
-
exports.SESSION_START_HOOK_COMMAND_RE = void 0;
|
|
7
|
+
exports.RECALL_HOOK_COMMAND_RE = exports.SESSION_START_HOOK_COMMAND_RE = void 0;
|
|
8
8
|
exports.isHicortexSessionStartHook = isHicortexSessionStartHook;
|
|
9
|
+
exports.isHicortexRecallHook = isHicortexRecallHook;
|
|
10
|
+
exports.removeHicortexCcHooks = removeHicortexCcHooks;
|
|
9
11
|
exports.runUninstall = runUninstall;
|
|
10
12
|
const paths_js_1 = require("./paths.js");
|
|
11
13
|
const node_fs_1 = require("node:fs");
|
|
@@ -33,6 +35,69 @@ exports.SESSION_START_HOOK_COMMAND_RE = /(^|\s)(?:learnings-identity|lessons-con
|
|
|
33
35
|
function isHicortexSessionStartHook(command) {
|
|
34
36
|
return typeof command === "string" && exports.SESSION_START_HOOK_COMMAND_RE.test(command);
|
|
35
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Matches a CC hook `command` that runs the Hicortex recall hook — the
|
|
40
|
+
* `recall-hook` subcommand (#192). Same word-boundary discipline as the
|
|
41
|
+
* learnings matcher: an unrelated command that merely CONTAINS the substring
|
|
42
|
+
* ("my-recall-hook", "recall-hooks-old") is never swept up. Used for BOTH
|
|
43
|
+
* event arrays the installer writes (UserPromptSubmit + SessionStart).
|
|
44
|
+
*/
|
|
45
|
+
exports.RECALL_HOOK_COMMAND_RE = /(^|\s)recall-hook(\s|$)/;
|
|
46
|
+
/** True when a CC hook `command` string runs the Hicortex recall hook. */
|
|
47
|
+
function isHicortexRecallHook(command) {
|
|
48
|
+
return typeof command === "string" && exports.RECALL_HOOK_COMMAND_RE.test(command);
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Remove every Hicortex hook entry from a PARSED ~/.claude/settings.json
|
|
52
|
+
* (#327): the SessionStart learnings hook (canonical + legacy alias) AND the
|
|
53
|
+
* recall-hook pair (UserPromptSubmit + SessionStart, installed together by
|
|
54
|
+
* installRecallHooks — leaving either behind is a silent npx spawn per prompt
|
|
55
|
+
* forever). Mutates `settings` in place; returns what was removed (empty when
|
|
56
|
+
* nothing matched — a clean no-op). Exact-match discipline throughout: only
|
|
57
|
+
* entries whose `hooks[].command` matches a Hicortex subcommand are removed;
|
|
58
|
+
* foreign hooks (and prefix-colliding names) stay untouched.
|
|
59
|
+
*
|
|
60
|
+
* Pure on the parsed object so the uninstall behavior is unit-testable
|
|
61
|
+
* without spinning up CC; runUninstall owns the file I/O.
|
|
62
|
+
*/
|
|
63
|
+
function removeHicortexCcHooks(settings) {
|
|
64
|
+
const hooks = settings.hooks;
|
|
65
|
+
if (!hooks || typeof hooks !== "object")
|
|
66
|
+
return [];
|
|
67
|
+
const groups = [
|
|
68
|
+
{ event: "SessionStart", kind: "learnings", match: isHicortexSessionStartHook },
|
|
69
|
+
{ event: "SessionStart", kind: "recall", match: isHicortexRecallHook },
|
|
70
|
+
{ event: "UserPromptSubmit", kind: "recall", match: isHicortexRecallHook },
|
|
71
|
+
];
|
|
72
|
+
const removed = [];
|
|
73
|
+
for (const g of groups) {
|
|
74
|
+
const arr = hooks[g.event];
|
|
75
|
+
if (!Array.isArray(arr))
|
|
76
|
+
continue;
|
|
77
|
+
const filtered = arr.filter((entry) => {
|
|
78
|
+
if (typeof entry !== "object" || entry === null)
|
|
79
|
+
return true;
|
|
80
|
+
const e = entry;
|
|
81
|
+
if (Array.isArray(e.hooks)) {
|
|
82
|
+
return !e.hooks.some((h) => typeof h === "object" &&
|
|
83
|
+
h !== null &&
|
|
84
|
+
typeof h.command === "string" &&
|
|
85
|
+
g.match(h.command));
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
});
|
|
89
|
+
if (filtered.length < arr.length) {
|
|
90
|
+
// Drop the event key entirely when the filter emptied it — no
|
|
91
|
+
// `"UserPromptSubmit": []` husk left in the settings file.
|
|
92
|
+
if (filtered.length > 0)
|
|
93
|
+
hooks[g.event] = filtered;
|
|
94
|
+
else
|
|
95
|
+
delete hooks[g.event];
|
|
96
|
+
removed.push({ event: g.event, kind: g.kind, count: arr.length - filtered.length });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return removed;
|
|
100
|
+
}
|
|
36
101
|
async function ask(question) {
|
|
37
102
|
const rl = (0, node_readline_1.createInterface)({ input: process.stdin, output: process.stdout });
|
|
38
103
|
return new Promise((resolve) => {
|
|
@@ -146,34 +211,21 @@ async function runUninstall() {
|
|
|
146
211
|
}
|
|
147
212
|
if (removedCmds > 0)
|
|
148
213
|
console.log(` ✓ Removed ${removedCmds} legacy CC command${removedCmds > 1 ? "s" : ""} (/learn, /hicortex-activate)`);
|
|
149
|
-
// 4. Remove
|
|
150
|
-
//
|
|
151
|
-
//
|
|
214
|
+
// 4. Remove ALL Hicortex CC hooks (#327): the SessionStart learnings hook
|
|
215
|
+
// (canonical `learnings-identity` OR the legacy `lessons-context` alias,
|
|
216
|
+
// #264 backcompat) AND BOTH `recall-hook` entries (UserPromptSubmit +
|
|
217
|
+
// SessionStart — installed as a pair by installRecallHooks; leaving
|
|
218
|
+
// either behind keeps a silent npx spawn per prompt forever).
|
|
219
|
+
// Fail-soft when absent; word-boundary matchers never touch foreign hooks.
|
|
152
220
|
try {
|
|
153
221
|
const raw = (0, node_fs_1.readFileSync)(CC_SETTINGS, "utf-8");
|
|
154
222
|
const settings = JSON.parse(raw);
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
return true;
|
|
162
|
-
const e = entry;
|
|
163
|
-
if (Array.isArray(e.hooks)) {
|
|
164
|
-
return !e.hooks.some((h) => {
|
|
165
|
-
if (typeof h !== "object" || h === null)
|
|
166
|
-
return false;
|
|
167
|
-
const hook = h;
|
|
168
|
-
return typeof hook.command === "string" && isHicortexSessionStartHook(hook.command);
|
|
169
|
-
});
|
|
170
|
-
}
|
|
171
|
-
return true;
|
|
172
|
-
});
|
|
173
|
-
if (filtered.length < before) {
|
|
174
|
-
hooks.SessionStart = filtered;
|
|
175
|
-
(0, node_fs_1.writeFileSync)(CC_SETTINGS, JSON.stringify(settings, null, 2));
|
|
176
|
-
console.log(" ✓ Removed SessionStart learnings-identity hook");
|
|
223
|
+
const removed = removeHicortexCcHooks(settings);
|
|
224
|
+
if (removed.length > 0) {
|
|
225
|
+
(0, node_fs_1.writeFileSync)(CC_SETTINGS, JSON.stringify(settings, null, 2));
|
|
226
|
+
for (const r of removed) {
|
|
227
|
+
const name = r.kind === "learnings" ? "learnings-identity" : "recall-hook";
|
|
228
|
+
console.log(` ✓ Removed ${r.event} ${name} hook${r.count > 1 ? "s" : ""}`);
|
|
177
229
|
}
|
|
178
230
|
}
|
|
179
231
|
}
|
package/dist/viz.d.ts
CHANGED
|
@@ -54,7 +54,7 @@ export declare const VIZ_VENDOR_FILES: ReadonlySet<string>;
|
|
|
54
54
|
* this — a hosted tenant dir is fail-closed by default. mcp-server.ts captures
|
|
55
55
|
* the marker state once at boot and passes it in (no per-request stat).
|
|
56
56
|
*/
|
|
57
|
-
export declare function createAuthMiddleware(authToken: string | undefined, authTokenPrevious?: string, allowLocalhostBypass?: boolean): express.RequestHandler;
|
|
57
|
+
export declare function createAuthMiddleware(authToken: string | undefined, authTokenPrevious?: string, allowLocalhostBypass?: boolean, bodyLimitBytes?: number): express.RequestHandler;
|
|
58
58
|
/**
|
|
59
59
|
* Resolve the on-disk path of the viz page. Throws (fail explicitly) when the
|
|
60
60
|
* asset is missing — a broken install should surface, not degrade silently.
|
package/dist/viz.js
CHANGED
|
@@ -101,10 +101,37 @@ function safeBearerMatch(headerValue, expectedToken) {
|
|
|
101
101
|
* this — a hosted tenant dir is fail-closed by default. mcp-server.ts captures
|
|
102
102
|
* the marker state once at boot and passes it in (no per-request stat).
|
|
103
103
|
*/
|
|
104
|
-
function createAuthMiddleware(authToken, authTokenPrevious, allowLocalhostBypass) {
|
|
104
|
+
function createAuthMiddleware(authToken, authTokenPrevious, allowLocalhostBypass, bodyLimitBytes) {
|
|
105
105
|
const previous = authTokenPrevious && authTokenPrevious.length > 0 ? authTokenPrevious : undefined;
|
|
106
106
|
const bypassEnabled = allowLocalhostBypass === true;
|
|
107
|
+
const contentLengthCap = Number.isFinite(bodyLimitBytes) && bodyLimitBytes > 0
|
|
108
|
+
? bodyLimitBytes
|
|
109
|
+
: null;
|
|
107
110
|
return (req, res, next) => {
|
|
111
|
+
// #328 item 4 (package-server half) — BELT. The PRIMARY gate is
|
|
112
|
+
// makeContentLengthGate (mcp-server.ts), registered BEFORE express.json:
|
|
113
|
+
// this middleware sits AFTER the parser, so by the time it runs the body
|
|
114
|
+
// has already been buffered (up to the parser's limit) — its Content-Length
|
|
115
|
+
// check can only catch what a caller wires WITHOUT the front gate. Kept
|
|
116
|
+
// for standalone/reuse callers of createAuthMiddleware and as
|
|
117
|
+
// defense-in-depth; 413 mirrors express.json's own oversize status.
|
|
118
|
+
//
|
|
119
|
+
// RESIDUAL RISK (unchanged by either check): chunked transfer-encoding
|
|
120
|
+
// sends no Content-Length, so neither gate sees it — those requests still
|
|
121
|
+
// buffer up to the parser limit inside express.json before the 413
|
|
122
|
+
// (bounded per request, no pre-auth rejection), and there is no
|
|
123
|
+
// concurrency cap here. Full pre-auth bounding lives in the hosted
|
|
124
|
+
// router's webhook path (stripe.ts, #328 item 4) — the tenant data plane
|
|
125
|
+
// trusts its bearer (self-hosted threat model) or sits behind the
|
|
126
|
+
// provider's edge (hosted).
|
|
127
|
+
if (contentLengthCap !== null) {
|
|
128
|
+
const declared = req.headers["content-length"];
|
|
129
|
+
const declaredNum = typeof declared === "string" ? Number(declared) : NaN;
|
|
130
|
+
if (Number.isFinite(declaredNum) && declaredNum > contentLengthCap) {
|
|
131
|
+
res.status(413).json({ error: "request body too large" });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
108
135
|
if (req.path === "/health")
|
|
109
136
|
return next();
|
|
110
137
|
// The /viz page SHELL is public like /health — it contains no data and no
|
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.5",
|
|
6
6
|
"kind": "lifecycle",
|
|
7
7
|
"skills": ["./skills/hicortex-memory"],
|
|
8
8
|
"configSchema": {
|
|
@@ -25,6 +25,11 @@
|
|
|
25
25
|
"type": "number",
|
|
26
26
|
"default": 8,
|
|
27
27
|
"description": "Max memories per recall on the pre-0.14 /search fallback (default 8). The pushed recall index is sized by SERVER config (recallMaxItems) — the server accepts no client limit."
|
|
28
|
+
},
|
|
29
|
+
"scaffoldDeadMan": {
|
|
30
|
+
"type": "boolean",
|
|
31
|
+
"default": true,
|
|
32
|
+
"description": "Auto-scaffold the dead-man identity-guard line into the agent workspace bootstrap (BOOTSTRAP.md) at startup — the secondary defense under the identity-unavailable banner the plugin injects when the identity fetch fails. Idempotent: the .bak backup is written once and never touched again. Set false to keep the plugin from writing any bootstrap file."
|
|
28
33
|
}
|
|
29
34
|
},
|
|
30
35
|
"required": []
|
|
@@ -46,6 +51,10 @@
|
|
|
46
51
|
"recallLimit": {
|
|
47
52
|
"label": "Recall Limit",
|
|
48
53
|
"placeholder": "8"
|
|
54
|
+
},
|
|
55
|
+
"scaffoldDeadMan": {
|
|
56
|
+
"label": "Scaffold Dead-Man Guard",
|
|
57
|
+
"placeholder": "true"
|
|
49
58
|
}
|
|
50
59
|
}
|
|
51
60
|
}
|