@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/dist/index.js CHANGED
@@ -42,6 +42,8 @@
42
42
  Object.defineProperty(exports, "__esModule", { value: true });
43
43
  exports.formatToolResults = formatToolResults;
44
44
  exports.resolveOcPluginConfig = resolveOcPluginConfig;
45
+ exports.resolveOcWorkspaceDir = resolveOcWorkspaceDir;
46
+ exports.normalizeWorkspacePath = normalizeWorkspacePath;
45
47
  const paths_js_1 = require("./paths.js");
46
48
  const features_js_1 = require("./features.js");
47
49
  const extensions_js_1 = require("./extensions.js");
@@ -128,6 +130,14 @@ const warnedRecallStatuses = new Set();
128
130
  /** Warn-once flag for /search fallback failures (#316 CR finding 3): fires on
129
131
  * the first failure of a streak, re-armed by any successful fallback fetch. */
130
132
  let warnedLegacyFallbackFailure = false;
133
+ /** Warn-once-per-PROCESS flag (#326): unpinned gateway plugins.allow. NOT
134
+ * reset at start() — a gateway restart inside one process must not re-warn
135
+ * (a new process starts with clean flags anyway). */
136
+ let warnedUnpinnedPlugins = false;
137
+ /** Warn-once-per-PROCESS bookkeeping (#326) for dead-man scaffold SKIPS and
138
+ * failures — one warning per distinct cause (relative path, missing dir,
139
+ * non-UTF-8 file, fs error), never reset at start(). */
140
+ const warnedScaffoldSkips = new Set();
131
141
  /** Plugin logger captured at service start (ctx.logger or console). */
132
142
  let pluginLog = console.log;
133
143
  /** Sessions whose server-side recall dedup was already reset this process
@@ -164,6 +174,22 @@ const sessionsReset = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
164
174
  */
165
175
  const identityInjected = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
166
176
  const lessonsInjected = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
177
+ /**
178
+ * #327 banner lifecycle — notices memoized SEPARATELY from identityInjected.
179
+ * appendSystemContext persists on the session (premise above), so a FAILED
180
+ * identity fetch that re-injects the IDENTITY UNAVAILABLE banner every turn
181
+ * accumulates one copy per turn of an outage (dozens over an hour) — and the
182
+ * suspension wording then never retracts. The FETCH still retries every turn
183
+ * (identityInjected is only set on success); the NOTICE (banner or 404
184
+ * version-skew note) is appended once per outage, and the first success that
185
+ * delivers identity content prepends a one-line retraction (see
186
+ * IDENTITY_RESTORED_RETRACTION). Two trackers so the kinds settle
187
+ * independently — a 404 note must not suppress a later genuine-outage banner.
188
+ * Evicted on compaction/reset with the other memos: a rebuilt window may have
189
+ * dropped the notice, so one re-inject after the rebuild is wanted, not lost.
190
+ */
191
+ const identityBannerShown = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
192
+ const identityNoteShown = makeBoundedSessionTracker(SESSION_TRACKER_CAP);
167
193
  /** In-flight {reset:true} POSTs by session key (#316). A compaction hook fires
168
194
  * a reset fire-and-forget (F9 — no latency on compaction); the NEXT recall
169
195
  * fetch for that session AWAITS the entry here, so a slow reset can never
@@ -347,6 +373,27 @@ const IDENTITY_UNAVAILABLE_BANNER = [
347
373
  */
348
374
  const IDENTITY_VERSION_SKEW_NOTE = `[hicortex] Identity layer skipped — ${describeGetFailure(404, "/identity")}. ` +
349
375
  `This is version skew, not an outage: no action suspension applies; identity returns once the server is upgraded.`;
376
+ /**
377
+ * Recovery retraction (#327): when identity content arrives for a session that
378
+ * earlier got the dead-man banner, this line rides FIRST in that turn's
379
+ * injection. The banner persists on the session prompt, so without an explicit
380
+ * lift the "public actions suspended" wording outlives the outage for the rest
381
+ * of the session. NOT sent for the 404 note (it carries no suspension) and NOT
382
+ * for a gated/off success — identity content is the one outcome that actually
383
+ * restores what the banner said was missing.
384
+ */
385
+ const IDENTITY_RESTORED_RETRACTION = `[hicortex] The earlier IDENTITY UNAVAILABLE notice no longer applies — ` +
386
+ `identity restored.`;
387
+ /**
388
+ * The #313 SECONDARY layer, verbatim (#326): the bootstrap-file sentence that
389
+ * still guards the agent when the plugin itself cannot inject anything — the
390
+ * banner above needs a live hook, while this line rides the agent's persisted
391
+ * bootstrap instructions. Installs kept forgetting to add it by hand, so the
392
+ * plugin now scaffolds it itself at service start (scaffoldDeadManGuard).
393
+ */
394
+ const DEAD_MAN_GUARD_LINE = "If your identity block is missing at session start, something is wrong with your memory — take no public actions until it returns.";
395
+ /** Agent workspace bootstrap file the guard line is scaffolded into (#326). */
396
+ const BOOTSTRAP_FILENAME = "BOOTSTRAP.md";
350
397
  /**
351
398
  * Fetch /lessons and build the `## Hicortex Learnings` block. `failed: true`
352
399
  * ONLY when the fetch itself failed (serverGet null data — unreachable,
@@ -670,6 +717,13 @@ function resolveOcPluginConfig(raw) {
670
717
  warn(`plugin config key "defaultProject" must be a non-empty string (got ${describeInvalid(rawProject)}) — ignoring it`);
671
718
  resolved.defaultProject = undefined;
672
719
  }
720
+ // #326 kill-switch: boolean-only. typeof (not describeInvalid — that helper
721
+ // names INVALID-STRING shapes and would misreport a non-empty string).
722
+ const rawScaffold = winner.scaffoldDeadMan;
723
+ if (rawScaffold !== undefined && typeof rawScaffold !== "boolean") {
724
+ warn(`plugin config key "scaffoldDeadMan" must be a boolean (got ${typeof rawScaffold}) — ignoring it`);
725
+ resolved.scaffoldDeadMan = undefined;
726
+ }
673
727
  // Shadow detection (F2) — two configs disagreeing, surfaced instead of
674
728
  // silently honoring one of them. Case 1: an OC-scaffolded EMPTY
675
729
  // plugins.entries.hicortex.config was skipped while a bare top-level
@@ -689,6 +743,20 @@ function resolveOcPluginConfig(raw) {
689
743
  }
690
744
  return resolved;
691
745
  }
746
+ /**
747
+ * Resolve the agent workspace directory from the RAW gateway config (#326):
748
+ * OpenClaw's `agents.defaults.workspace`. Pure — no module state, no fs, no
749
+ * mutation, never throws. Absent/non-string/empty → null (the caller falls
750
+ * back to the OC default workspace). Deliberately does NOT read the plugin's
751
+ * own config section: the workspace is a gateway-level fact, not a plugin
752
+ * knob, so it is resolved from ctx.config directly (the whole openclaw.json,
753
+ * same object resolveOcPluginConfig walks).
754
+ */
755
+ function resolveOcWorkspaceDir(raw) {
756
+ const agents = isRecord(isRecord(raw)?.agents);
757
+ const workspace = isRecord(agents?.defaults)?.workspace;
758
+ return isNonEmptyString(workspace) ? workspace : null;
759
+ }
692
760
  // ---------------------------------------------------------------------------
693
761
  // Plugin export
694
762
  // ---------------------------------------------------------------------------
@@ -740,6 +808,8 @@ exports.default = {
740
808
  sessionsReset.clear();
741
809
  identityInjected.clear();
742
810
  lessonsInjected.clear();
811
+ identityBannerShown.clear();
812
+ identityNoteShown.clear();
743
813
  pendingResets.clear();
744
814
  pluginLog = log;
745
815
  log(`[hicortex] Thin-client mode — server: ${serverUrl}`);
@@ -792,6 +862,17 @@ exports.default = {
792
862
  `Run \`npx @gamaze/hicortex init\` to start the server. ` +
793
863
  `Capture and tool calls will fail until the server is available.`);
794
864
  }
865
+ // #326 self-hardening — install hygiene, both fail-soft by design:
866
+ // keep the dead-man guard line (#313 secondary layer) present in the
867
+ // agent workspace bootstrap, and surface an unpinned gateway trust
868
+ // list. The kill-switch (config scaffoldDeadMan, default on) disables
869
+ // the scaffold entirely; the trust warning always runs.
870
+ scaffoldDeadManGuard({
871
+ workspaceDir: resolveOcWorkspaceDir(ctx.config) ?? fallbackOcWorkspace(),
872
+ enabled: config.scaffoldDeadMan !== false,
873
+ log,
874
+ });
875
+ warnIfPluginsUnpinned(ctx.config, log);
795
876
  ensureToolsAllowed(log);
796
877
  },
797
878
  async stop() {
@@ -869,11 +950,45 @@ exports.default = {
869
950
  // note, NOT the banner (CR2: a pinned plugin on an old server must
870
951
  // not self-suspend every turn). Lessons/recall keep their own
871
952
  // independent fail-soft.
872
- const identityBlock = identity !== null && identity.block !== null
873
- ? identity.block
874
- : identity !== null && identity.failed
875
- ? (identity.status === 404 ? IDENTITY_VERSION_SKEW_NOTE : IDENTITY_UNAVAILABLE_BANNER)
876
- : null;
953
+ //
954
+ // #327 lifecycle: the notice is appended ONCE per outage (appends
955
+ // persist on the session — every-turn copies accumulate), and the
956
+ // first success carrying identity content prepends the retraction so
957
+ // the suspension wording cannot linger. Without a sessionId there is
958
+ // no per-session key — inject per turn (pre-#316 shape), matching
959
+ // the standing-block behavior above.
960
+ let identityBlock;
961
+ if (identity !== null && identity.block !== null) {
962
+ identityBlock = identity.block;
963
+ if (sKey !== undefined && identityBannerShown.has(sKey)) {
964
+ identityBlock = `${IDENTITY_RESTORED_RETRACTION}\n\n${identityBlock}`;
965
+ }
966
+ if (sKey !== undefined) {
967
+ identityBannerShown.evict(sKey);
968
+ identityNoteShown.evict(sKey);
969
+ }
970
+ }
971
+ else if (identity !== null && identity.failed) {
972
+ const isSkew = identity.status === 404;
973
+ const shown = isSkew ? identityNoteShown : identityBannerShown;
974
+ if (sKey === undefined || !shown.has(sKey)) {
975
+ if (sKey !== undefined)
976
+ shown.add(sKey);
977
+ identityBlock = isSkew ? IDENTITY_VERSION_SKEW_NOTE : IDENTITY_UNAVAILABLE_BANNER;
978
+ }
979
+ else {
980
+ // Already appended earlier in the outage — it persists on the
981
+ // session; re-sending would duplicate it (see tracker docs).
982
+ identityBlock = null;
983
+ }
984
+ }
985
+ else {
986
+ // Gated-null success (old server / oc ∉ clients / mode off): a
987
+ // settled outcome with no content. A prior banner stays standing —
988
+ // identity is still effectively missing to the agent, and the
989
+ // bootstrap dead-man guard line keeps advising caution.
990
+ identityBlock = null;
991
+ }
877
992
  const lessonsBlock = lessons !== null ? lessons.block : null;
878
993
  const blocks = [identityBlock, lessonsBlock, recallBlock].filter((b) => b !== null && b !== "");
879
994
  if (blocks.length === 0)
@@ -911,6 +1026,10 @@ exports.default = {
911
1026
  void resetRecallDedup(sid, key);
912
1027
  identityInjected.evict(key);
913
1028
  lessonsInjected.evict(key);
1029
+ // #327: a rebuilt window may have dropped the notice too — evict so one
1030
+ // re-inject (banner while still failing) can happen after the rebuild.
1031
+ identityBannerShown.evict(key);
1032
+ identityNoteShown.evict(key);
914
1033
  };
915
1034
  api.on("after_compaction", recallResetHook);
916
1035
  api.on("before_reset", recallResetHook);
@@ -1212,6 +1331,168 @@ const HICORTEX_TOOLS = [
1212
1331
  "hicortex_update",
1213
1332
  "hicortex_delete",
1214
1333
  ];
1334
+ /**
1335
+ * The OpenClaw home this plugin resolves against (#326). `HICORTEX_OC_HOME`
1336
+ * redirects it for tests, mirroring how HICORTEX_HOME redirects the hicortex
1337
+ * home (paths.ts) — one resolution shared by the workspace fallback and the
1338
+ * unpinned-trust warning so they can never disagree.
1339
+ */
1340
+ function ocHomeDir() {
1341
+ return process.env.HICORTEX_OC_HOME ?? (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw");
1342
+ }
1343
+ /**
1344
+ * Normalize a gateway-config workspace path for WRITING (#326 CR1): a bare
1345
+ * `~` or leading `~/` expands against the real home dir; anything still
1346
+ * RELATIVE afterwards is rejected (null). OpenClaw's semantics for relative
1347
+ * workspace values are not verifiable from the plugin, and writing under
1348
+ * process.cwd() would place the guard where OC never reads it — a silent
1349
+ * no-op safety — so the caller skips with a warning instead. Pure; no fs.
1350
+ */
1351
+ function normalizeWorkspacePath(ws) {
1352
+ let p = ws;
1353
+ if (p === "~")
1354
+ p = (0, node_os_1.homedir)();
1355
+ else if (p.startsWith("~/"))
1356
+ p = (0, node_path_1.join)((0, node_os_1.homedir)(), p.slice(2));
1357
+ return (0, node_path_1.isAbsolute)(p) ? p : null;
1358
+ }
1359
+ /**
1360
+ * Fallback workspace when the gateway config names none (#326): OpenClaw's
1361
+ * default `<ocHome>/workspace`. Returned ONLY when a real OC install is
1362
+ * present (`<ocHome>/openclaw.json` exists — the same touchpoint
1363
+ * ensureToolsAllowed reads): outside a gateway (tests, CI, a bare import) the
1364
+ * plugin must not conjure `~/.openclaw` into existence just to drop a
1365
+ * bootstrap file.
1366
+ */
1367
+ function fallbackOcWorkspace() {
1368
+ const ocHome = ocHomeDir();
1369
+ return (0, node_fs_1.existsSync)((0, node_path_1.join)(ocHome, "openclaw.json")) ? (0, node_path_1.join)(ocHome, "workspace") : null;
1370
+ }
1371
+ /** Per-cause warn-once for scaffold skips/failures (#326). */
1372
+ function warnScaffoldSkipOnce(cause, log, reason) {
1373
+ if (warnedScaffoldSkips.has(cause))
1374
+ return;
1375
+ warnedScaffoldSkips.add(cause);
1376
+ log(`[hicortex] WARNING: ${reason} — the dead-man guard line was not scaffolded.`);
1377
+ }
1378
+ /**
1379
+ * Scaffold the dead-man guard line into the agent workspace bootstrap (#326 —
1380
+ * the #313 SECONDARY layer; the primary layer is the injected
1381
+ * IDENTITY UNAVAILABLE banner, which needs a live plugin hook). The sentence
1382
+ * used to be a manual install step every public-agent setup could forget, so
1383
+ * the plugin maintains it itself at service start:
1384
+ *
1385
+ * - bootstrap absent → created containing ONLY the guard line
1386
+ * - present without the line → one-time .bak of the operator's original
1387
+ * BYTES, then the line appended exactly once
1388
+ * - present with the line → untouched (idempotent: no write, no backup)
1389
+ *
1390
+ * Deliberately conservative (CR):
1391
+ * - the workspace DIRECTORY is never created — OC may scaffold workspaces
1392
+ * from templates, and a pre-created dir could interfere; absent dir →
1393
+ * warn once + skip (a gateway restart after the first agent run retries)
1394
+ * - a non-absolute workspace path (after ~ expansion) → warn once + skip
1395
+ * (never write somewhere speculative like process.cwd())
1396
+ * - a bootstrap that is not valid UTF-8 → warn once + skip; decoding would
1397
+ * be lossy and rewriting the file would mangle the operator's bytes
1398
+ *
1399
+ * Fail-soft by construction: any filesystem failure (unreadable path,
1400
+ * permissions) warns ONCE per cause and never breaks plugin start. The
1401
+ * kill-switch (config `scaffoldDeadMan: false`, default on) returns before a
1402
+ * single fs call — no write, no file creation.
1403
+ */
1404
+ function scaffoldDeadManGuard(opts) {
1405
+ if (!opts.enabled)
1406
+ return;
1407
+ const rawWorkspace = opts.workspaceDir;
1408
+ if (!rawWorkspace)
1409
+ return; // no workspace resolvable — not an OC install / no workspace key
1410
+ const log = opts.log;
1411
+ const workspaceDir = normalizeWorkspacePath(rawWorkspace);
1412
+ if (!workspaceDir) {
1413
+ warnScaffoldSkipOnce("relative-workspace", log, `workspace path "${rawWorkspace}" in the gateway config is relative — OpenClaw's ` +
1414
+ "resolution for it is unknown, so the plugin will not write speculatively");
1415
+ return;
1416
+ }
1417
+ const bootstrapPath = (0, node_path_1.join)(workspaceDir, BOOTSTRAP_FILENAME);
1418
+ try {
1419
+ // CR3: never create the workspace dir itself (template interference).
1420
+ if (!(0, node_fs_1.existsSync)(workspaceDir)) {
1421
+ warnScaffoldSkipOnce("missing-workspace-dir", log, `workspace directory ${workspaceDir} does not exist yet (OpenClaw creates it; ` +
1422
+ "restart the gateway after the first agent run to retry)");
1423
+ return;
1424
+ }
1425
+ // Read BYTES (CR2): the .bak must hold the operator's original exactly,
1426
+ // and an append must splice onto the original bytes, not a lossy decode.
1427
+ let original = null;
1428
+ try {
1429
+ original = (0, node_fs_1.readFileSync)(bootstrapPath);
1430
+ }
1431
+ catch (err) {
1432
+ // Only "does not exist" means "create it" — anything else (EACCES,
1433
+ // EISDIR, …) is a genuine failure and must reach the warn below, not
1434
+ // be mistaken for an absent file and overwritten.
1435
+ if (err.code !== "ENOENT")
1436
+ throw err;
1437
+ }
1438
+ if (original !== null) {
1439
+ const decoded = original.toString("utf-8");
1440
+ // Invalid UTF-8 (round-trip compare): appending would rewrite the file
1441
+ // with mangled bytes — leave it untouched and say so once.
1442
+ if (!Buffer.from(decoded, "utf-8").equals(original)) {
1443
+ warnScaffoldSkipOnce("invalid-utf8", log, `${bootstrapPath} is not valid UTF-8 — leaving the file untouched`);
1444
+ return;
1445
+ }
1446
+ if (decoded.includes(DEAD_MAN_GUARD_LINE))
1447
+ return;
1448
+ }
1449
+ if (original === null) {
1450
+ (0, node_fs_1.writeFileSync)(bootstrapPath, `${DEAD_MAN_GUARD_LINE}\n`);
1451
+ }
1452
+ else {
1453
+ // One-time backup of the operator's original BYTES — never churned.
1454
+ const bakPath = `${bootstrapPath}.bak`;
1455
+ if (!(0, node_fs_1.existsSync)(bakPath))
1456
+ (0, node_fs_1.writeFileSync)(bakPath, original);
1457
+ // Separator at the BYTE level (0x0A), so a no-trailing-newline file is
1458
+ // spliced correctly without decoding.
1459
+ const needsSep = original.length > 0 && original[original.length - 1] !== 0x0a;
1460
+ (0, node_fs_1.writeFileSync)(bootstrapPath, Buffer.concat([
1461
+ original,
1462
+ needsSep ? Buffer.from("\n", "utf-8") : Buffer.alloc(0),
1463
+ Buffer.from(`${DEAD_MAN_GUARD_LINE}\n`, "utf-8"),
1464
+ ]));
1465
+ }
1466
+ log(`[hicortex] Added the dead-man identity guard line to ${bootstrapPath}`);
1467
+ }
1468
+ catch (err) {
1469
+ const msg = err instanceof Error ? err.message : String(err);
1470
+ warnScaffoldSkipOnce("fs-error", log, `could not scaffold the dead-man guard line into ${bootstrapPath}: ${msg}`);
1471
+ }
1472
+ }
1473
+ /**
1474
+ * Warn once per process when the gateway's plugin trust list is unpinned
1475
+ * (#326): while `plugins.allow` is absent or empty, OpenClaw auto-loads ANY
1476
+ * extension dropped into the plugins directory. A plugin must not pin trust
1477
+ * itself — a self-pinned list defeats the point of the list — so this only
1478
+ * WARNS with the fix. It never writes plugins.allow (or any gateway config;
1479
+ * ensureToolsAllowed's tools.allow edit is the one intentional config write).
1480
+ * Any non-empty array counts as pinned → silent.
1481
+ */
1482
+ function warnIfPluginsUnpinned(raw, log) {
1483
+ if (warnedUnpinnedPlugins)
1484
+ return;
1485
+ const allow = isRecord(isRecord(raw)?.plugins)?.allow;
1486
+ if (Array.isArray(allow) && allow.length > 0)
1487
+ return;
1488
+ warnedUnpinnedPlugins = true;
1489
+ log("[hicortex] WARNING: the OpenClaw plugin trust list (plugins.allow) is not " +
1490
+ "pinned — any extension dropped into the plugins directory loads " +
1491
+ 'automatically. Fix: set "plugins": { "allow": ["hicortex"] } in ' +
1492
+ `${(0, node_path_1.join)(ocHomeDir(), "openclaw.json")} (list every plugin you trust). See ` +
1493
+ "https://hicortex.gamaze.com/docs/installation.html — hicortex never " +
1494
+ "edits the trust list itself.");
1495
+ }
1215
1496
  /**
1216
1497
  * Ensure hicortex tools are in tools.allow so they're visible to agents
1217
1498
  * regardless of the tools.profile setting.
@@ -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
@@ -1566,6 +1623,21 @@ async function startServer(options = {}) {
1566
1623
  };
1567
1624
  process.on("SIGINT", shutdown);
1568
1625
  process.on("SIGTERM", shutdown);
1626
+ // #329 item 2: warm the embedder NOW, in the background. The ONNX pipeline
1627
+ // lazy-loads inside the first embed() (~0.5-3s cold) — without this, the
1628
+ // first /recall-index after every restart paid that load inside its own
1629
+ // latency budget and the 1s client hook failed soft (silent recall loss).
1630
+ // Fire-and-forget AFTER listen: never blocks boot, never fatal (a failed
1631
+ // warm-up just logs once; the next real embed lazy-loads as before).
1632
+ //
1633
+ // HOSTED MEMORY IMPLICATION (accepted, #329 CR finding 3): this makes the
1634
+ // embedding model (~150-300MB resident) load in EVERY tenant container from
1635
+ // boot, idle tenants included — previously an idle tenant never loaded it.
1636
+ // Accepted for the current single-tenant-VPS sizing: containers run under
1637
+ // 2g caps, and any ACTIVE tenant loaded the model on first use anyway. If
1638
+ // tenant density grows, revisit (e.g. warm on first authenticated request
1639
+ // instead of boot). Capacity math: hosted/README.md (TENANT_MEMORY_LIMIT).
1640
+ (0, embedder_js_1.warmEmbedder)(embedder_js_1.embed);
1569
1641
  }
1570
1642
  // ---------------------------------------------------------------------------
1571
1643
  // 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;