@gamaze/hicortex 0.19.3 → 0.19.4
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/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 +81 -9
- package/dist/nightly.d.ts +25 -0
- package/dist/nightly.js +125 -20
- package/dist/prompts.d.ts +15 -0
- package/dist/prompts.js +21 -3
- 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/nightly.js
CHANGED
|
@@ -45,6 +45,8 @@ var __importStar = (this && this.__importStar) || (function () {
|
|
|
45
45
|
};
|
|
46
46
|
})();
|
|
47
47
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.computeSince = computeSince;
|
|
49
|
+
exports.parseRetryAfterMs = parseRetryAfterMs;
|
|
48
50
|
exports.runNightly = runNightly;
|
|
49
51
|
const paths_js_1 = require("./paths.js");
|
|
50
52
|
const node_fs_1 = require("node:fs");
|
|
@@ -77,6 +79,17 @@ const telemetry_js_1 = require("./telemetry.js");
|
|
|
77
79
|
const init_js_1 = require("./init.js");
|
|
78
80
|
const backup_js_1 = require("./backup.js");
|
|
79
81
|
const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
|
|
82
|
+
/**
|
|
83
|
+
* Consolidate-only backup gate (#327 CR blocker). Hosted tenants run ONLY
|
|
84
|
+
* `nightly --consolidate-only` several times a day (hicortex-consolidate@.timer)
|
|
85
|
+
* and the provisioner has no backup job of its own, so those runs DO run the
|
|
86
|
+
* backup stage — but at most once per day, keyed on the newest existing
|
|
87
|
+
* artifact's age (the artifact IS the marker; no extra state file). 20h is
|
|
88
|
+
* slightly under a day so a timer firing at a drifting clock hour still gets
|
|
89
|
+
* exactly one backup per day; with `backupRetention` (default 7) the artifact
|
|
90
|
+
* count stays bounded.
|
|
91
|
+
*/
|
|
92
|
+
const CONSOLIDATE_ONLY_BACKUP_MIN_AGE_MS = 20 * 60 * 60 * 1000;
|
|
80
93
|
function readNightlyConfig(stateDir) {
|
|
81
94
|
const configPath = (0, node_path_1.join)(stateDir, "config.json");
|
|
82
95
|
let loaded;
|
|
@@ -125,14 +138,30 @@ function readLastRun(stateDir = HICORTEX_HOME) {
|
|
|
125
138
|
* skip (then, via writeLastRun, permanently lose) the 8-to-N-day-old sessions
|
|
126
139
|
* (#189 review, fix 3). Per-session cursors keep the wide re-scan cheap: an
|
|
127
140
|
* already-captured session yields an empty delta.
|
|
141
|
+
*
|
|
142
|
+
* Clock-jump clamp (#327): a FUTURE-dated lastNightly (client clock error —
|
|
143
|
+
* NTP not yet synced at write time) would, once the clock corrects, sit ahead
|
|
144
|
+
* of every session mtime and permanently skip quiet sessions (their mtimes
|
|
145
|
+
* never re-cross a future watermark). Clamped to `now` with a warn; the warn
|
|
146
|
+
* fires once per affected run (this function runs once per nightly).
|
|
147
|
+
* `now` is injectable for tests.
|
|
128
148
|
*/
|
|
129
|
-
function computeSince(stateDir, recaptureWindowDays) {
|
|
149
|
+
function computeSince(stateDir, recaptureWindowDays, now = new Date()) {
|
|
130
150
|
const lastRun = readLastRun(stateDir);
|
|
151
|
+
let effective = lastRun;
|
|
152
|
+
if (lastRun.getTime() > now.getTime()) {
|
|
153
|
+
console.warn(`[hicortex] state lastNightly (${lastRun.toISOString()}) is ahead of the clock ` +
|
|
154
|
+
`(${now.toISOString()}) — clamping discovery to now. A future watermark permanently ` +
|
|
155
|
+
`skips quiet sessions once the clock corrects; check the machine's clock/NTP. ` +
|
|
156
|
+
`Run \`hicortex nightly --recapture-window <days>\` to recover sessions missed ` +
|
|
157
|
+
`while the clock was wrong.`);
|
|
158
|
+
effective = now;
|
|
159
|
+
}
|
|
131
160
|
if (recaptureWindowDays && recaptureWindowDays > 0) {
|
|
132
|
-
const windowStart = new Date(
|
|
133
|
-
return windowStart <
|
|
161
|
+
const windowStart = new Date(now.getTime() - recaptureWindowDays * 24 * 60 * 60 * 1000);
|
|
162
|
+
return windowStart < effective ? windowStart : effective;
|
|
134
163
|
}
|
|
135
|
-
return
|
|
164
|
+
return effective;
|
|
136
165
|
}
|
|
137
166
|
/** POST /distill transport for server mode — localhost. Sends authToken so
|
|
138
167
|
* self-capture works regardless of the localhost-bypass marker (#271 root-cause fix). */
|
|
@@ -185,7 +214,32 @@ async function normalizePostResult(resp) {
|
|
|
185
214
|
return { status: 200, skipped: Boolean(data.skipped) };
|
|
186
215
|
}
|
|
187
216
|
const data = (await resp.json().catch(() => ({})));
|
|
188
|
-
|
|
217
|
+
const retryAfterMs = parseRetryAfterMs(resp);
|
|
218
|
+
return {
|
|
219
|
+
status: resp.status,
|
|
220
|
+
error: data.error ?? "unknown error",
|
|
221
|
+
// #327: surface Retry-After so a rate-limit 429 backs off as the server
|
|
222
|
+
// asked (absent on the tenant's terminal budget-429, which ignores it).
|
|
223
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Parse a `Retry-After` header into ms (#327). Handles both RFC forms —
|
|
228
|
+
* delay-seconds (`"30"`) and HTTP-date — and returns undefined for anything
|
|
229
|
+
* unparseable (the caller then falls back to its own backoff schedule).
|
|
230
|
+
* Exported for unit tests (pure on the header value).
|
|
231
|
+
*/
|
|
232
|
+
function parseRetryAfterMs(resp) {
|
|
233
|
+
const v = resp.headers.get("retry-after");
|
|
234
|
+
if (!v)
|
|
235
|
+
return undefined;
|
|
236
|
+
const secs = Number(v);
|
|
237
|
+
if (Number.isFinite(secs) && secs >= 0)
|
|
238
|
+
return secs * 1000;
|
|
239
|
+
const at = new Date(v).getTime();
|
|
240
|
+
if (Number.isFinite(at))
|
|
241
|
+
return Math.max(0, at - Date.now());
|
|
242
|
+
return undefined;
|
|
189
243
|
}
|
|
190
244
|
/** Strict {prompt, completion, total} parser for /distill's usage field (#287);
|
|
191
245
|
* undefined on anything malformed — the caller then treats it as unmetered. */
|
|
@@ -296,7 +350,15 @@ async function runNightly(options = {}) {
|
|
|
296
350
|
const cooldownH = (0, config_read_js_1.readNonNegativeConfig)(savedConfig ?? {}, "captureCooldownHours", 6);
|
|
297
351
|
const last = (0, state_js_1.loadState)(stateDir).lastNightly;
|
|
298
352
|
if (last) {
|
|
299
|
-
|
|
353
|
+
// #327 clamp: a FUTURE-dated stamp (clock error at write time) reads as
|
|
354
|
+
// a NEGATIVE age raw, and `negative < cooldownH` is true even at the
|
|
355
|
+
// cooldown-0 opt-in ("capture every poll") — the watchdog would stay
|
|
356
|
+
// silent until real time passed the future stamp, exactly when catch-up
|
|
357
|
+
// ticks are most needed. Clamped, the worst honest reading is "captured
|
|
358
|
+
// just now", which the normal cooldown handles (and discovery applies
|
|
359
|
+
// the same clamp in computeSince).
|
|
360
|
+
const lastMs = Math.min(new Date(last).getTime(), Date.now());
|
|
361
|
+
const ageH = (Date.now() - lastMs) / 3_600_000;
|
|
300
362
|
if (ageH < cooldownH) {
|
|
301
363
|
console.log(`[hicortex] watchdog: last capture ${ageH.toFixed(1)}h ago (< ${cooldownH}h cooldown) — skipping`);
|
|
302
364
|
return;
|
|
@@ -668,21 +730,52 @@ async function runNightly(options = {}) {
|
|
|
668
730
|
// Backup stage (#6, Phase 0B) — a transactionally-consistent snapshot of
|
|
669
731
|
// the irreplaceable data (DB + identity + state), packaged as one tar.gz
|
|
670
732
|
// the operator ships offsite via the optional `backupCommand` hook. Runs
|
|
671
|
-
//
|
|
672
|
-
// writes nothing).
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
733
|
+
// on every full nightly and on consolidate-only runs (capture-only is
|
|
734
|
+
// frequent + stateless; dry-run writes nothing). Consolidate-only MUST
|
|
735
|
+
// back up (#327 CR blocker): hosted tenants run ONLY --consolidate-only
|
|
736
|
+
// several times a day (hicortex-consolidate@.timer) and the provisioner
|
|
737
|
+
// has no backup job of its own — skipping the stage left them with NO
|
|
738
|
+
// recurring backup. Their cadence is bounded by the artifact-age gate
|
|
739
|
+
// below (~1/day) instead, so `backupRetention` keeps the dir bounded.
|
|
740
|
+
// Backup failure must NOT fail the nightly — capture + consolidation
|
|
741
|
+
// have already succeeded; the snapshot is on disk and the failure
|
|
742
|
+
// surfaces as `backupOk:false` in the dashboard snapshot + telemetry for
|
|
743
|
+
// alerting (the operator's hook owns active alerting; no in-product
|
|
676
744
|
// channel yet — Phase 3).
|
|
677
745
|
let backupPath;
|
|
678
746
|
let backupBytes;
|
|
679
747
|
let backupOk;
|
|
680
|
-
|
|
748
|
+
// Artifact-age gate for consolidate-only runs: skip only while the newest
|
|
749
|
+
// existing artifact is younger than ~20h. 20h (not 24) so a timer firing
|
|
750
|
+
// at a drifting clock hour still gets exactly one backup per day, and
|
|
751
|
+
// robust to an hour of clock skew either way. Keyed on the artifact, not
|
|
752
|
+
// a state timestamp — no new state file to drift out of sync with the
|
|
753
|
+
// disk it describes.
|
|
754
|
+
const effectiveBackupDir = typeof savedConfig?.backupDir === "string" && savedConfig.backupDir.trim()
|
|
755
|
+
? savedConfig.backupDir
|
|
756
|
+
: (0, node_path_1.join)(stateDir, "backups");
|
|
757
|
+
let skipBackup = false;
|
|
758
|
+
let newestArtifactMs;
|
|
759
|
+
if (consolidateOnly) {
|
|
760
|
+
newestArtifactMs = (0, backup_js_1.newestBackupArtifactMs)(effectiveBackupDir);
|
|
761
|
+
skipBackup =
|
|
762
|
+
newestArtifactMs !== undefined &&
|
|
763
|
+
Date.now() - newestArtifactMs < CONSOLIDATE_ONLY_BACKUP_MIN_AGE_MS;
|
|
764
|
+
}
|
|
765
|
+
if (!dryRun && !captureOnly && !skipBackup) {
|
|
681
766
|
try {
|
|
682
|
-
const
|
|
683
|
-
|
|
684
|
-
:
|
|
685
|
-
|
|
767
|
+
const bRes = await (0, backup_js_1.createBackup)({
|
|
768
|
+
db,
|
|
769
|
+
home: stateDir,
|
|
770
|
+
// undefined falls back to <home>/backups inside createBackup — the
|
|
771
|
+
// same resolution effectiveBackupDir above uses for the age gate.
|
|
772
|
+
outDir: typeof savedConfig?.backupDir === "string" && savedConfig.backupDir.trim()
|
|
773
|
+
? savedConfig.backupDir
|
|
774
|
+
: undefined,
|
|
775
|
+
// Same reader + default as the CLI (#327): keep the N newest
|
|
776
|
+
// artifacts, 0 = keep all.
|
|
777
|
+
retention: (0, config_read_js_1.readNonNegativeConfig)(savedConfig ?? {}, "backupRetention", backup_js_1.DEFAULT_BACKUP_RETENTION),
|
|
778
|
+
});
|
|
686
779
|
backupPath = bRes.path;
|
|
687
780
|
backupBytes = bRes.bytes;
|
|
688
781
|
backupOk = true;
|
|
@@ -712,6 +805,15 @@ async function runNightly(options = {}) {
|
|
|
712
805
|
console.error(`[hicortex] Backup FAILED: ${err instanceof Error ? err.message : String(err)}`);
|
|
713
806
|
}
|
|
714
807
|
}
|
|
808
|
+
else if (consolidateOnly && !dryRun) {
|
|
809
|
+
// #327: explicit log line so a hosted consolidation timer's log doesn't
|
|
810
|
+
// read as a silently-missing backup stage — and names WHY (age gate), so
|
|
811
|
+
// an operator reading "skipped" can tell a healthy cadence gate from a
|
|
812
|
+
// dead one.
|
|
813
|
+
const ageH = newestArtifactMs !== undefined ? (Date.now() - newestArtifactMs) / 3_600_000 : -1;
|
|
814
|
+
console.log(`[hicortex] Backup stage skipped — consolidate-only run: newest backup is ` +
|
|
815
|
+
`${ageH.toFixed(1)}h old (< ${Math.round(CONSOLIDATE_ONLY_BACKUP_MIN_AGE_MS / 3_600_000)}h gate).`);
|
|
816
|
+
}
|
|
715
817
|
// Dashboard snapshot (#224) — full nightly only. The snapshot reflects
|
|
716
818
|
// corpus state regardless of whether consolidation/LLM ran, so it is
|
|
717
819
|
// ALWAYS written here (the use case is history; an LLM-less install still
|
|
@@ -771,9 +873,11 @@ async function runNightly(options = {}) {
|
|
|
771
873
|
budgetExhausted,
|
|
772
874
|
budgetDeferredByStage,
|
|
773
875
|
// #6 backup stage — hoisted from the block above. Present whenever
|
|
774
|
-
// the backup stage ran (full nightly
|
|
775
|
-
//
|
|
776
|
-
//
|
|
876
|
+
// the backup stage ran (full nightly, and consolidate-only runs past
|
|
877
|
+
// the artifact-age gate); undefined on capture-only / dry-run / a
|
|
878
|
+
// gated-skip consolidate-only run. backupOk flips to false on
|
|
879
|
+
// snapshot OR hook failure so the dashboard digest can flag a night
|
|
880
|
+
// the offsite copy didn't complete.
|
|
777
881
|
backupPath,
|
|
778
882
|
backupBytes,
|
|
779
883
|
backupOk,
|
|
@@ -826,7 +930,8 @@ async function runNightly(options = {}) {
|
|
|
826
930
|
// #6 backup stage outcome — forwarded only when the backup stage ran
|
|
827
931
|
// (full nightly). `ok` is false on snapshot OR hook failure; the fleet
|
|
828
932
|
// aggregate surfaces a sustained drop in backup_ok as a data-loss risk.
|
|
829
|
-
// Absent on capture-only / dry-run / client runs
|
|
933
|
+
// Absent on capture-only / consolidate-only / dry-run / client runs
|
|
934
|
+
// (no backup ran).
|
|
830
935
|
...(backupOk !== undefined
|
|
831
936
|
? { backup: { ok: backupOk === true, bytes: backupBytes ?? 0 } }
|
|
832
937
|
: {}),
|
package/dist/prompts.d.ts
CHANGED
|
@@ -18,6 +18,21 @@ export declare function importanceScoring(memoriesBlock: string): string;
|
|
|
18
18
|
export declare function reflection(memoriesBlock: string, recentLessons?: string): string;
|
|
19
19
|
/**
|
|
20
20
|
* Distillation prompt. Extracts knowledge from a session transcript.
|
|
21
|
+
*
|
|
22
|
+
* LAYOUT (#329 item 6): the ~6.3KB of static instructions come FIRST and the
|
|
23
|
+
* transcript LAST, so every distill call shares a byte-identical instruction
|
|
24
|
+
* prefix and provider-side prompt prefix caching can actually hit (per-session
|
|
25
|
+
* calls with the same project/date share everything up to the transcript; a
|
|
26
|
+
* multi-segment session — the common capture shape — re-uses the cached prefix
|
|
27
|
+
* for every segment after the first). The static block is the transcript-first
|
|
28
|
+
* block MOVED plus ONE deliberate addition in the same change: the #329 item-5
|
|
29
|
+
* [D]-override sentence in the NEVER-RECORD section ("a version bump, merge,
|
|
30
|
+
* or count is NEVER [D]"). Anyone diffing distill behavior across this change
|
|
31
|
+
* must baseline against BOTH the reorder and that wording addition. NOTE the
|
|
32
|
+
* prefix is only
|
|
33
|
+
* fully shared while project/date agree: "# Session Memory: ${date} -
|
|
34
|
+
* ${projectName}" and the (${date}) format examples interpolate inside the
|
|
35
|
+
* static block by design (the model needs the real date in its output format).
|
|
21
36
|
*/
|
|
22
37
|
export declare function distillation(projectName: string, date: string, transcript: string): string;
|
|
23
38
|
/**
|
package/dist/prompts.js
CHANGED
|
@@ -106,14 +106,26 @@ Respond with a JSON array. Empty array [] is a valid response.`;
|
|
|
106
106
|
}
|
|
107
107
|
/**
|
|
108
108
|
* Distillation prompt. Extracts knowledge from a session transcript.
|
|
109
|
+
*
|
|
110
|
+
* LAYOUT (#329 item 6): the ~6.3KB of static instructions come FIRST and the
|
|
111
|
+
* transcript LAST, so every distill call shares a byte-identical instruction
|
|
112
|
+
* prefix and provider-side prompt prefix caching can actually hit (per-session
|
|
113
|
+
* calls with the same project/date share everything up to the transcript; a
|
|
114
|
+
* multi-segment session — the common capture shape — re-uses the cached prefix
|
|
115
|
+
* for every segment after the first). The static block is the transcript-first
|
|
116
|
+
* block MOVED plus ONE deliberate addition in the same change: the #329 item-5
|
|
117
|
+
* [D]-override sentence in the NEVER-RECORD section ("a version bump, merge,
|
|
118
|
+
* or count is NEVER [D]"). Anyone diffing distill behavior across this change
|
|
119
|
+
* must baseline against BOTH the reorder and that wording addition. NOTE the
|
|
120
|
+
* prefix is only
|
|
121
|
+
* fully shared while project/date agree: "# Session Memory: ${date} -
|
|
122
|
+
* ${projectName}" and the (${date}) format examples interpolate inside the
|
|
123
|
+
* static block by design (the model needs the real date in its output format).
|
|
109
124
|
*/
|
|
110
125
|
function distillation(projectName, date, transcript) {
|
|
111
126
|
return `You are a memory extraction agent. Analyze this AI session transcript and extract
|
|
112
127
|
knowledge worth remembering long-term.
|
|
113
128
|
|
|
114
|
-
SESSION TRANSCRIPT (project: ${projectName}, date: ${date}):
|
|
115
|
-
${transcript}
|
|
116
|
-
|
|
117
129
|
EXTRACT into this markdown format:
|
|
118
130
|
|
|
119
131
|
# Session Memory: ${date} - ${projectName}
|
|
@@ -195,6 +207,9 @@ do not score it lower and write it anyway: OMIT it. A closed category of never-r
|
|
|
195
207
|
The durable part of the same event may still qualify — the CHOICE a change
|
|
196
208
|
embodies ("standardize on model X", user-confirmed) is [D], a configuration
|
|
197
209
|
that holds going forward is [K]; the version bump, merge, or count itself never is.
|
|
210
|
+
Override for the [D] "actually carried out" test: even if carried out by the
|
|
211
|
+
user, a version bump, merge, or count is NEVER [D] — only the durable
|
|
212
|
+
user-confirmed standardization it embodies qualifies.
|
|
198
213
|
If EVERY item in the transcript is never-record ephemera, output ONLY:
|
|
199
214
|
"NO_EXTRACT" — zero memories is the correct result for a pure-status segment.
|
|
200
215
|
|
|
@@ -216,6 +231,9 @@ RULES:
|
|
|
216
231
|
"[Strong Negative] User rejected per-agent billing"). The subject always comes first.
|
|
217
232
|
- Omit any section that has zero items (don't include empty sections)
|
|
218
233
|
- If nothing worth extracting, output ONLY: "NO_EXTRACT"
|
|
234
|
+
|
|
235
|
+
SESSION TRANSCRIPT (project: ${projectName}, date: ${date}):
|
|
236
|
+
${transcript}
|
|
219
237
|
`;
|
|
220
238
|
}
|
|
221
239
|
/**
|
package/dist/recall-index.d.ts
CHANGED
|
@@ -32,9 +32,23 @@
|
|
|
32
32
|
* unchanged. Turn suppression still wins: a recently shown novelty pick is
|
|
33
33
|
* suppressed like any other (the guarantee is about candidate inclusion, not
|
|
34
34
|
* forcing re-shows).
|
|
35
|
+
*
|
|
36
|
+
* #329 item 3 — the pure search is SKIPPED when it would be byte-identical
|
|
37
|
+
* to the blended one: turn 1 (no centroid yet — nothing to blend) or
|
|
38
|
+
* sessionIntentWeight 0 (blend disabled). The blended result IS the pure
|
|
39
|
+
* result there, so the floor is trivially satisfied by the blended picks and
|
|
40
|
+
* the second search (embeds aside, its whole DB + FTS half) is pure waste.
|
|
41
|
+
*
|
|
42
|
+
* #329 item 4 — novelty backfill: when the blended picks are empty/short,
|
|
43
|
+
* unclaimed maxItems slots are filled from the remaining filtered
|
|
44
|
+
* pure-prompt tail (gate + suppression already applied). Without it the
|
|
45
|
+
* topic-switch turn — the one the floor exists for — got the MOST truncated
|
|
46
|
+
* menu: novelty slots + a diluted remainder, while further pure candidates
|
|
47
|
+
* that had already passed every gate sat unused.
|
|
35
48
|
*/
|
|
36
49
|
import type Database from "better-sqlite3";
|
|
37
50
|
import type { MemorySearchResult } from "./types.js";
|
|
51
|
+
import * as storage from "./storage.js";
|
|
38
52
|
import { SessionRecallRegistry } from "./recall-registry.js";
|
|
39
53
|
export interface RecallIndexOptions {
|
|
40
54
|
/** Minimum measured cosine for vector-only candidates (config
|
|
@@ -88,6 +102,16 @@ export interface RecallIndexResult {
|
|
|
88
102
|
status: number;
|
|
89
103
|
body: Record<string, unknown>;
|
|
90
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* Hard cap on `session_id` length (#328 item 2a). The id is retained as a Map
|
|
107
|
+
* key by SessionRecallRegistry for the process lifetime (maxSessions=500 LRU
|
|
108
|
+
* + a per-session shown-set + intent centroid), so an unbounded id is an OOM
|
|
109
|
+
* vector: ~4.9MB ids × 500 sessions ≈ 2.4GB of retained keys from an
|
|
110
|
+
* authenticated-but-hostile tenant. Real session ids (CC UUIDs, plugin
|
|
111
|
+
* session keys) are ≤64 chars — 128 is generous headroom. Longer → 400 with
|
|
112
|
+
* a clear error; the client treats it like any bad request.
|
|
113
|
+
*/
|
|
114
|
+
export declare const MAX_SESSION_ID_CHARS = 128;
|
|
91
115
|
/** First content line, de-markdowned and truncated — the index line title. */
|
|
92
116
|
export declare function memoryTitle(content: string, maxLen?: number): string;
|
|
93
117
|
/**
|
|
@@ -166,11 +190,22 @@ export interface RecallIndexDeps {
|
|
|
166
190
|
* fold, or the pure prompt with NO centroid state for #324);
|
|
167
191
|
* - retrieve() with noStrengthen (exposure is recorded by
|
|
168
192
|
* handleRecallIndex via touchMemoriesShown, never here).
|
|
193
|
+
*
|
|
194
|
+
* #329 CR finding 1b: the FTS candidate list is ALSO computed once per
|
|
195
|
+
* request (ftsOnce, keyed on query + candidate window) and threaded into both
|
|
196
|
+
* retrieve() calls via the ftsCandidates provider — the blended and pure
|
|
197
|
+
* searches of one request carry identical query text and window, so their FTS
|
|
198
|
+
* halves were byte-identical SQL executed twice. `ftsFn` is the DI seam for
|
|
199
|
+
* tests (production: storage.searchFts); a throwing FTS computation memoizes
|
|
200
|
+
* to an empty shared list — the same vector-only degradation retrieve()'s
|
|
201
|
+
* catch always produced, never an error.
|
|
169
202
|
*/
|
|
170
203
|
export declare function createRecallRetrieveFn(deps: {
|
|
171
204
|
db: Database.Database;
|
|
172
205
|
registry: SessionRecallRegistry;
|
|
173
206
|
embedFn: (text: string) => Promise<Float32Array>;
|
|
207
|
+
/** FTS resolution override (tests). Defaults to storage.searchFts. */
|
|
208
|
+
ftsFn?: typeof storage.searchFts;
|
|
174
209
|
}): RecallRetrieveFn;
|
|
175
210
|
/** Normalize a request-supplied string-list param: array of strings or a CSV
|
|
176
211
|
* string → string[] | undefined. Anything else (or an empty result) means
|
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).
|