@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.
@@ -13,10 +13,11 @@
13
13
  import express from "express";
14
14
  import type { MemorySearchResult } from "./types.js";
15
15
  /**
16
- * Resolve the request body-size limit in MB (#7). Pure — exported for tests.
17
- * Precedence: an explicit config value > hosted-mode default (5) > self-hosted
18
- * default (25, the historical fixed value no regression). A finite positive
19
- * config value wins; invalid/absent falls through.
16
+ * Resolve the request body-size limit in MB (#7, #328 item 2b). Pure —
17
+ * exported for tests. Precedence: HICORTEX_DISTILL_BODY_LIMIT_MB env >
18
+ * explicit config value > hosted-mode default (5) > self-hosted default (25,
19
+ * the historical fixed value no regression). A finite positive value wins
20
+ * at each step; invalid/absent falls through.
20
21
  */
21
22
  export declare function resolveBodyLimitMb(configVal: unknown, hostedMode: boolean): number;
22
23
  /**
@@ -29,6 +30,26 @@ export declare function resolveBodyLimitMb(configVal: unknown, hostedMode: boole
29
30
  * on top of that ordering. Exported so tests exercise the real handler.
30
31
  */
31
32
  export declare function makeBodyLimitErrorHandler(limitMb: number): express.ErrorRequestHandler;
33
+ /**
34
+ * #328 item 4 (package-server half, CR-corrected ORDERING): a Content-Length
35
+ * pre-check that MUST be registered BEFORE express.json. Registered after the
36
+ * parser (the first #328 pass had it inside createAuthMiddleware, which sits
37
+ * after the parser) it is inert as a bounding measure — body-parser buffers
38
+ * the body up to its own limit BEFORE auth runs and refuses oversize itself,
39
+ * so the check only ever saw bodies the parser had already accepted and
40
+ * buffered. Registered FIRST it refuses a DECLARED-oversize body before a
41
+ * single byte is read and before any route/auth work, on every path. The
42
+ * twin check inside createAuthMiddleware (viz.ts) is kept as a belt — but the
43
+ * GATE here is the one that actually bounds pre-auth buffering.
44
+ *
45
+ * RESIDUAL RISK (deliberate, documented): chunked transfer-encoding sends no
46
+ * Content-Length, so this gate cannot see it — those requests still buffer up
47
+ * to the parser limit inside express.json (bounded per request, no
48
+ * concurrency cap here). Full pre-auth bounding lives in the hosted router's
49
+ * webhook path (stripe.ts); the tenant data plane trusts its bearer
50
+ * (self-hosted threat model) or sits behind the provider's edge (hosted).
51
+ */
52
+ export declare function makeContentLengthGate(limitBytes: number): express.RequestHandler;
32
53
  export declare function startServer(options?: {
33
54
  port?: number;
34
55
  host?: string;
@@ -50,6 +50,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
50
50
  Object.defineProperty(exports, "__esModule", { value: true });
51
51
  exports.resolveBodyLimitMb = resolveBodyLimitMb;
52
52
  exports.makeBodyLimitErrorHandler = makeBodyLimitErrorHandler;
53
+ exports.makeContentLengthGate = makeContentLengthGate;
53
54
  exports.startServer = startServer;
54
55
  exports.formatResults = formatResults;
55
56
  const express_1 = __importDefault(require("express"));
@@ -410,12 +411,26 @@ function createMcpServer() {
410
411
  // HTTP server with SSE transport
411
412
  // ---------------------------------------------------------------------------
412
413
  /**
413
- * Resolve the request body-size limit in MB (#7). Pureexported for tests.
414
- * Precedence: an explicit config value > hosted-mode default (5) > self-hosted
415
- * default (25, the historical fixed value no regression). A finite positive
416
- * config value wins; invalid/absent falls through.
414
+ * Env override for the body limit (#328 item 2b hosted tenant-immutable
415
+ * pin, same ENV-WINS pattern as HICORTEX_TOKEN_CAP in token-budget.ts). The
416
+ * hosted tenant's /data is tenant-writable, so a `distillBodyLimitMb` in the
417
+ * tenant's own config.json could raise the limit the provider intended; an
418
+ * env baked into the container (`-e`, provisioner-written .env) cannot be
419
+ * mutated by the tenant process. Self-hosted installs may also use it as an
420
+ * operator knob — precedence below puts it above config in every mode.
421
+ */
422
+ const DISTILL_BODY_LIMIT_MB_ENV = "HICORTEX_DISTILL_BODY_LIMIT_MB";
423
+ /**
424
+ * Resolve the request body-size limit in MB (#7, #328 item 2b). Pure —
425
+ * exported for tests. Precedence: HICORTEX_DISTILL_BODY_LIMIT_MB env >
426
+ * explicit config value > hosted-mode default (5) > self-hosted default (25,
427
+ * the historical fixed value → no regression). A finite positive value wins
428
+ * at each step; invalid/absent falls through.
417
429
  */
418
430
  function resolveBodyLimitMb(configVal, hostedMode) {
431
+ const envCap = Number(process.env[DISTILL_BODY_LIMIT_MB_ENV]);
432
+ if (Number.isFinite(envCap) && envCap > 0)
433
+ return envCap;
419
434
  const cfg = Number(configVal);
420
435
  if (Number.isFinite(cfg) && cfg > 0)
421
436
  return cfg;
@@ -441,6 +456,36 @@ function makeBodyLimitErrorHandler(limitMb) {
441
456
  next(err);
442
457
  };
443
458
  }
459
+ /**
460
+ * #328 item 4 (package-server half, CR-corrected ORDERING): a Content-Length
461
+ * pre-check that MUST be registered BEFORE express.json. Registered after the
462
+ * parser (the first #328 pass had it inside createAuthMiddleware, which sits
463
+ * after the parser) it is inert as a bounding measure — body-parser buffers
464
+ * the body up to its own limit BEFORE auth runs and refuses oversize itself,
465
+ * so the check only ever saw bodies the parser had already accepted and
466
+ * buffered. Registered FIRST it refuses a DECLARED-oversize body before a
467
+ * single byte is read and before any route/auth work, on every path. The
468
+ * twin check inside createAuthMiddleware (viz.ts) is kept as a belt — but the
469
+ * GATE here is the one that actually bounds pre-auth buffering.
470
+ *
471
+ * RESIDUAL RISK (deliberate, documented): chunked transfer-encoding sends no
472
+ * Content-Length, so this gate cannot see it — those requests still buffer up
473
+ * to the parser limit inside express.json (bounded per request, no
474
+ * concurrency cap here). Full pre-auth bounding lives in the hosted router's
475
+ * webhook path (stripe.ts); the tenant data plane trusts its bearer
476
+ * (self-hosted threat model) or sits behind the provider's edge (hosted).
477
+ */
478
+ function makeContentLengthGate(limitBytes) {
479
+ return (req, res, next) => {
480
+ const declared = req.headers["content-length"];
481
+ const declaredNum = typeof declared === "string" ? Number(declared) : NaN;
482
+ if (Number.isFinite(declaredNum) && declaredNum > limitBytes) {
483
+ res.status(413).json({ error: "request body too large" });
484
+ return;
485
+ }
486
+ next();
487
+ };
488
+ }
444
489
  async function startServer(options = {}) {
445
490
  const port = options.port ?? 8787;
446
491
  const host = options.host ?? "0.0.0.0";
@@ -527,12 +572,20 @@ async function startServer(options = {}) {
527
572
  // env (provider-set, tenant-immutable) which takes precedence. Initialised here
528
573
  // (after stateDir + savedConfig are known) so the warn-dedup can seed from state.
529
574
  (0, token_budget_js_1.initTokenBudget)(stateDir, savedConfig?.llmTokensPerMonth);
530
- // #7: request body-size limit. Config key wins; else 5 MB hosted / 25 MB
531
- // self-hosted (the prior fixed value no regression). Guards the OOM vector
532
- // (the body is fully parsed into memory before the distiller truncates to 80K
533
- // chars). Legitimate capture segments are ≤60K chars (~200KB), so this never
575
+ // #7: request body-size limit. Env (HICORTEX_DISTILL_BODY_LIMIT_MB) wins;
576
+ // else the config key; else 5 MB hosted / 25 MB self-hosted (the prior
577
+ // fixed value no regression). Guards the OOM vector (the body is fully
578
+ // parsed into memory before the distiller truncates to 80K chars).
579
+ // Legitimate capture segments are ≤60K chars (~200KB), so this never
534
580
  // constrains real flow — it's an abuse/backstop. Oversized → 413.
581
+ // #328 item 2b: in HOSTED mode the env is the provider's tenant-immutable
582
+ // pin — the tenant-writable /data/config.json must not be able to raise it.
535
583
  const bodyLimitMb = resolveBodyLimitMb(savedConfig?.distillBodyLimitMb, hostedMode);
584
+ // Label the source truthfully (token-budget.ts pattern): only claim env
585
+ // when the env value was actually used (a malformed env falls through).
586
+ if (Number(process.env.HICORTEX_DISTILL_BODY_LIMIT_MB) === bodyLimitMb) {
587
+ console.log(`[hicortex] Body limit: ${bodyLimitMb} MB (HICORTEX_DISTILL_BODY_LIMIT_MB env — overrides config)`);
588
+ }
536
589
  if (savedConfig?.llmBackend === "claude-cli") {
537
590
  const claudePath = (0, llm_js_1.findClaudeBinary)();
538
591
  if (claudePath) {
@@ -680,6 +733,10 @@ async function startServer(options = {}) {
680
733
  }
681
734
  // Express app
682
735
  const app = (0, express_1.default)();
736
+ // #328 item 4: the Content-Length pre-check MUST precede express.json (the
737
+ // parser buffers unauthenticated bodies up to its own limit; refusing the
738
+ // DECLARED oversize first bounds that). See makeContentLengthGate.
739
+ app.use(makeContentLengthGate(bodyLimitMb * 1024 * 1024));
683
740
  // Raise the body limit — whole-session denoised transcripts exceed the 100 kB default.
684
741
  app.use(express_1.default.json({ limit: `${bodyLimitMb}mb` }));
685
742
  // #7: JSON 413 on body-limit exceed (see makeBodyLimitErrorHandler). Server-side
@@ -726,7 +783,7 @@ async function startServer(options = {}) {
726
783
  // /dashboard has its own shell-exemption pattern. Gives the console one entry
727
784
  // point: http://<host>:8787/ → /dashboard.
728
785
  app.get("/", (_req, res) => res.redirect("/dashboard"));
729
- app.use((0, viz_js_1.createAuthMiddleware)(authToken, authTokenPrevious, bypassMarkerPresent));
786
+ app.use((0, viz_js_1.createAuthMiddleware)(authToken, authTokenPrevious, bypassMarkerPresent, bodyLimitMb * 1024 * 1024));
730
787
  // SSE transport management — each connection gets its own McpServer instance
731
788
  const transports = new Map();
732
789
  // Health endpoint — PUBLIC minimal probe. Unauthenticated (the auth
@@ -1141,7 +1198,14 @@ async function startServer(options = {}) {
1141
1198
  distillUsage.prompt += u.prompt_tokens ?? 0;
1142
1199
  distillUsage.completion += u.completion_tokens ?? 0;
1143
1200
  distillUsage.total += u.total_tokens ?? 0;
1144
- });
1201
+ },
1202
+ // #339: identify this POST in the NO_EXTRACT over-firing warning —
1203
+ // segment_id (incremental capture), else session_id (legacy), else none.
1204
+ typeof segment_id === "string" && segment_id
1205
+ ? segment_id
1206
+ : typeof session_id === "string" && session_id
1207
+ ? session_id
1208
+ : undefined);
1145
1209
  // Phase 1 — embed every chunk up front (async). If ANY embed fails we
1146
1210
  // never reach the insert, so nothing is stored.
1147
1211
  const createdAt = new Date(date).toISOString();
@@ -1566,6 +1630,21 @@ async function startServer(options = {}) {
1566
1630
  };
1567
1631
  process.on("SIGINT", shutdown);
1568
1632
  process.on("SIGTERM", shutdown);
1633
+ // #329 item 2: warm the embedder NOW, in the background. The ONNX pipeline
1634
+ // lazy-loads inside the first embed() (~0.5-3s cold) — without this, the
1635
+ // first /recall-index after every restart paid that load inside its own
1636
+ // latency budget and the 1s client hook failed soft (silent recall loss).
1637
+ // Fire-and-forget AFTER listen: never blocks boot, never fatal (a failed
1638
+ // warm-up just logs once; the next real embed lazy-loads as before).
1639
+ //
1640
+ // HOSTED MEMORY IMPLICATION (accepted, #329 CR finding 3): this makes the
1641
+ // embedding model (~150-300MB resident) load in EVERY tenant container from
1642
+ // boot, idle tenants included — previously an idle tenant never loaded it.
1643
+ // Accepted for the current single-tenant-VPS sizing: containers run under
1644
+ // 2g caps, and any ACTIVE tenant loaded the model on first use anyway. If
1645
+ // tenant density grows, revisit (e.g. warm on first authenticated request
1646
+ // instead of boot). Capacity math: hosted/README.md (TENANT_MEMORY_LIMIT).
1647
+ (0, embedder_js_1.warmEmbedder)(embedder_js_1.embed);
1569
1648
  }
1570
1649
  // ---------------------------------------------------------------------------
1571
1650
  // Helpers
package/dist/nightly.d.ts CHANGED
@@ -10,6 +10,31 @@
10
10
  * Every machine (server + clients) uses the same capture path: denoise locally,
11
11
  * POST to /distill. No local LLM required for capture; distillation is server-side.
12
12
  */
13
+ /**
14
+ * Discovery watermark. Normally the last-nightly timestamp; with
15
+ * `--recapture-window <days>` (#189 Tier-2 recovery) the window may only
16
+ * WIDEN — since = min(lastNightly, now−N days). Taking the earlier of the two
17
+ * means a machine that was offline longer than N days still re-discovers every
18
+ * session it missed; using now−N unconditionally would NARROW the window and
19
+ * skip (then, via writeLastRun, permanently lose) the 8-to-N-day-old sessions
20
+ * (#189 review, fix 3). Per-session cursors keep the wide re-scan cheap: an
21
+ * already-captured session yields an empty delta.
22
+ *
23
+ * Clock-jump clamp (#327): a FUTURE-dated lastNightly (client clock error —
24
+ * NTP not yet synced at write time) would, once the clock corrects, sit ahead
25
+ * of every session mtime and permanently skip quiet sessions (their mtimes
26
+ * never re-cross a future watermark). Clamped to `now` with a warn; the warn
27
+ * fires once per affected run (this function runs once per nightly).
28
+ * `now` is injectable for tests.
29
+ */
30
+ export declare function computeSince(stateDir: string, recaptureWindowDays?: number, now?: Date): Date;
31
+ /**
32
+ * Parse a `Retry-After` header into ms (#327). Handles both RFC forms —
33
+ * delay-seconds (`"30"`) and HTTP-date — and returns undefined for anything
34
+ * unparseable (the caller then falls back to its own backoff schedule).
35
+ * Exported for unit tests (pure on the header value).
36
+ */
37
+ export declare function parseRetryAfterMs(resp: Response): number | undefined;
13
38
  export declare function runNightly(options?: {
14
39
  dryRun?: boolean;
15
40
  captureOnly?: boolean;
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(Date.now() - recaptureWindowDays * 24 * 60 * 60 * 1000);
133
- return windowStart < lastRun ? windowStart : lastRun;
161
+ const windowStart = new Date(now.getTime() - recaptureWindowDays * 24 * 60 * 60 * 1000);
162
+ return windowStart < effective ? windowStart : effective;
134
163
  }
135
- return lastRun;
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
- return { status: resp.status, error: data.error ?? "unknown error" };
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
- const ageH = (Date.now() - new Date(last).getTime()) / 3_600_000;
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
- // ONLY on a full nightly (capture-only is frequent + stateless; dry-run
672
- // writes nothing). Backup failure must NOT fail the nightly — capture +
673
- // consolidation have already succeeded; the snapshot is on disk and the
674
- // failure surfaces as `backupOk:false` in the dashboard snapshot + telemetry
675
- // for alerting (the operator's hook owns active alerting; no in-product
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
- if (!dryRun && !captureOnly) {
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 backupDir = typeof savedConfig?.backupDir === "string" && savedConfig.backupDir.trim()
683
- ? savedConfig.backupDir
684
- : undefined;
685
- const bRes = await (0, backup_js_1.createBackup)({ db, home: stateDir, outDir: backupDir });
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); undefined on capture-only /
775
- // dry-run. backupOk flips to false on snapshot OR hook failure so the
776
- // dashboard digest can flag a night the offsite copy didn't complete.
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 (no backup ran).
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,24 @@ 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 (REVERTED 2026-08-24): transcript BEFORE the static instruction
23
+ * block — the pre-0.19.4 order. The #329 item-6 reorder (static-first, for
24
+ * provider prefix caching) was REVERTED after a deterministic A/B on real
25
+ * segments: with instructions first, the model over-fires NO_EXTRACT on
26
+ * summary-led and long mixed sessions (a real coding segment: 15 memories →
27
+ * 0; a real Hermes session: rich → 0; isolation proved the LAYOUT caused it,
28
+ * not the item-5 sentence, which is KEPT). Silent shape-dependent segment
29
+ * loss beats any caching win. Re-attempting instructions-first requires a
30
+ * gate fix that passes the A/B matrix harness first.
31
+ *
32
+ * #339 gate hardening (same day): the NO_EXTRACT rule now carries an explicit
33
+ * whole-transcript guard + counter-example (a summary-led session that
34
+ * contains later decisions MUST be extracted) — the over-firing mechanism was
35
+ * the model pattern-matching a bookkeeping-heavy OPENING to the ephemera gate
36
+ * and abandoning the whole segment. Companion visibility net (warning on
37
+ * large empty results) lives in distiller.ts; the release-gate harness is
38
+ * scripts/distill-ab-check/.
21
39
  */
22
40
  export declare function distillation(projectName: string, date: string, transcript: string): string;
23
41
  /**
package/dist/prompts.js CHANGED
@@ -106,6 +106,24 @@ 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 (REVERTED 2026-08-24): transcript BEFORE the static instruction
111
+ * block — the pre-0.19.4 order. The #329 item-6 reorder (static-first, for
112
+ * provider prefix caching) was REVERTED after a deterministic A/B on real
113
+ * segments: with instructions first, the model over-fires NO_EXTRACT on
114
+ * summary-led and long mixed sessions (a real coding segment: 15 memories →
115
+ * 0; a real Hermes session: rich → 0; isolation proved the LAYOUT caused it,
116
+ * not the item-5 sentence, which is KEPT). Silent shape-dependent segment
117
+ * loss beats any caching win. Re-attempting instructions-first requires a
118
+ * gate fix that passes the A/B matrix harness first.
119
+ *
120
+ * #339 gate hardening (same day): the NO_EXTRACT rule now carries an explicit
121
+ * whole-transcript guard + counter-example (a summary-led session that
122
+ * contains later decisions MUST be extracted) — the over-firing mechanism was
123
+ * the model pattern-matching a bookkeeping-heavy OPENING to the ephemera gate
124
+ * and abandoning the whole segment. Companion visibility net (warning on
125
+ * large empty results) lives in distiller.ts; the release-gate harness is
126
+ * scripts/distill-ab-check/.
109
127
  */
110
128
  function distillation(projectName, date, transcript) {
111
129
  return `You are a memory extraction agent. Analyze this AI session transcript and extract
@@ -195,9 +213,29 @@ do not score it lower and write it anyway: OMIT it. A closed category of never-r
195
213
  The durable part of the same event may still qualify — the CHOICE a change
196
214
  embodies ("standardize on model X", user-confirmed) is [D], a configuration
197
215
  that holds going forward is [K]; the version bump, merge, or count itself never is.
216
+ Override for the [D] "actually carried out" test: even if carried out by the
217
+ user, a version bump, merge, or count is NEVER [D] — only the durable
218
+ user-confirmed standardization it embodies qualifies.
198
219
  If EVERY item in the transcript is never-record ephemera, output ONLY:
199
220
  "NO_EXTRACT" — zero memories is the correct result for a pure-status segment.
200
221
 
222
+ NO_EXTRACT guard (a verdict on the WHOLE transcript, never on its opening):
223
+ "NO_EXTRACT" requires that NO durable decision, knowledge, or correction
224
+ appears ANYWHERE in the transcript — including after long bookkeeping
225
+ stretches. The opening is not evidence about the rest: real sessions often
226
+ OPEN with bookkeeping (a compaction summary, a task notification, a status
227
+ recap) and CONTAIN extractable material later. Read to the END of the
228
+ transcript before deciding; NO_EXTRACT on a long, mixed session is almost
229
+ always a mistake — when in doubt, extract the durable items.
230
+ Counter-example (MUST be extracted, never NO_EXTRACT): a session opens with
231
+ "Session summary: continuing the API migration; prior PR merged, tests
232
+ green" but later the user confirms "standardize on the queue-based worker —
233
+ make it the documented default" and corrects the assistant: "no, don't gate
234
+ retries behind a flag — remove the flag entirely". That session yields at
235
+ least a [D] standardization and an [E] correction; the summary opening
236
+ changes nothing. Emitting NO_EXTRACT there would lose the only record of
237
+ both.
238
+
201
239
  RULES:
202
240
  - Extract MAX 20 items total (quality over quantity)
203
241
  - Use EXACT names/versions/paths/numbers as they appear in the transcript —
@@ -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