@gamaze/hicortex 0.19.2 → 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 CHANGED
@@ -70,6 +70,11 @@ The plugin connects to `http://127.0.0.1:8787` by default. For a remote server,
70
70
 
71
71
  Bare top-level keys (`"serverUrl": …` at the root of `openclaw.json`) still work — legacy compat — but the nested form above is canonical. An empty nested `config` object is ignored rather than shadowing top-level keys.
72
72
 
73
+ **Public-facing agents are hardened automatically.** On startup the plugin:
74
+
75
+ 1. Adds the 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."* — to the agent workspace bootstrap file (`BOOTSTRAP.md` in the workspace from `agents.defaults.workspace`, default `~/.openclaw/workspace`), creating the file if absent. This is the secondary layer under the hard `IDENTITY UNAVAILABLE` suspension banner the plugin injects whenever the identity fetch fails; it keeps guarding the agent even when the plugin itself cannot inject anything. The write is idempotent: the `.bak` backup is written once and never touched again. Scope: the shared default workspace only — per-agent workspace overrides are not covered yet. The workspace directory itself is never created (OpenClaw scaffolds it); a non-UTF-8 or relative-path bootstrap is left untouched with a warning. Disable the write with `"scaffoldDeadMan": false` in the plugin config.
76
+ 2. Warns once at startup while the gateway's plugin trust list is unpinned — with no `plugins.allow`, OpenClaw auto-loads any extension dropped into the plugins directory. Pin it by setting `"plugins": { "allow": ["hicortex"] }` in `~/.openclaw/openclaw.json` (list every plugin you trust). The plugin never edits the trust list itself; see [the install docs](https://hicortex.gamaze.com/docs/installation.html).
77
+
73
78
  ## Requirements
74
79
 
75
80
  - Node.js 20+
@@ -232,6 +237,8 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
232
237
  | `authToken` | Bearer token for endpoint auth. Generated on first `init` in server mode. Find the active token with `hicortex status` or in `~/.hicortex/config.json`. |
233
238
  | `corsAllowedOrigins` | Browser origins allowed to read cross-origin responses, e.g. `["https://ui.example.com"]`. **Empty by default** — the server sends no `Access-Control-Allow-Origin` and never `Allow-Credentials`, so no external web page can read its data. The bundled `/viz` and `/identity/ui` pages are same-origin and need no entry. |
234
239
  | `licenseKey` | Commercial license key (optional; for display in `hicortex status`) |
240
+ | `backupRetention` | How many of the newest backup artifacts (`hicortex-*.tar.gz`) the backup dir keeps after each successful write (default: 7; `0` keeps all). See [Backups](#backups) |
241
+ | _env_ `HICORTEX_DISTILL_BODY_LIMIT_MB` | Environment override for the `/distill` body limit — **wins over the `distillBodyLimitMb` config key in every mode** (that is the point: a deployment operator pins it so tenant-writable config cannot raise it). Unset = config/default applies. |
235
242
  | `domains` | Your memory domain list (`[{name, description}]`). Scaffolded by `init`; edit freely — see [Memory Domains & Tags](#memory-domains--tags) |
236
243
  | `weakPrimaryFloor` | Minimum similarity for a no-fit memory to keep a weak domain association (default: 0.45) |
237
244
  | `moduleIndexTokenBudget` | Max tokens for domain index in lessons context (default: 500) |
@@ -260,11 +267,12 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
260
267
  | `searchLimit` / `recentLimit` | Default result counts for search (8) and recent (12) |
261
268
  | `recentWindowDays` | Candidate window for recent recall (default: 180) |
262
269
  | `coldExposureSlots` | Top-k slots reservable for never-accessed memories so the long tail gets exposure (default: 2) |
263
- | `recallMaxItems` | Max lines in the pushed recall index (default: 6) |
264
- | `recallMinSimilarity` | Relevance floor for index entries (default: 0.55; text-search matches always pass) |
270
+ | `recallMaxItems` | Max lines in the pushed recall index (default: 5) |
271
+ | `noveltyFloorSlots` | Slots of `recallMaxItems` guaranteed to the top passing hit(s) of the pure-prompt (unblended) search — the novelty floor. Keeps a session whose earlier turns set a strong intent from burying a topic-switching prompt's best matches: the floor's picks render first, turn-based re-show suppression still applies, and the total never exceeds `recallMaxItems` (default: 2; set 0 to disable) |
272
+ | `recallMinSimilarity` | Relevance floor for index entries (default: 0.62; text-search matches always pass) |
265
273
  | `recallReshowTurns` | Turns before an already-shown memory may reappear in the same session (default: 30) |
266
274
  | `recallMinPromptChars` | Prompts shorter than this skip the recall index (default: 20) |
267
- | `recallTitleChars` | Chars of each memory's first line shown in an index entry (default: 150, range 40–400). Raised from 100 on 2026-08-02: with topic-first memory titles, 150 chars carries the subject *and* its claim, where 100 cut the claim mid-sentence. Costs roughly +74 tokens per 6-line block |
275
+ | `recallTitleChars` | Chars of each memory's first line shown in an index entry (default: 100, range 40–400). Reverted from 150 on 2026-08-03: a full-corpus relevance eval found 100 and 150 statistically identical while 100 saves ~13% of the block's tokens |
268
276
  | `sessionIntentWeight` | Blend weight of the session-intent rolling centroid in the recall search vector: `query = (1-w)·prompt + w·centroid` (default: 0.33; set 0 to disable — pure-prompt recall, the kill-switch). The first turn of a session searches with pure prompt and seeds the centroid; subsequent turns blend so recall follows the session's intent instead of being query-literal. The EMA rate (0.4) is a shipped constant, not configurable |
269
277
  | `dedupMergeThreshold` | Minimum cosine similarity for `hicortex dedup` to cluster memories as near-duplicates (default: 0.92) |
270
278
  | `supersessionMinSimilarity` | Minimum cosine similarity for a nightly supersession candidate pair (default: 0.80) |
@@ -316,6 +324,7 @@ Optional config (add to plugin entry in `~/.openclaw/openclaw.json`):
316
324
  | `authToken` | _(none)_ | Bearer token. Localhost bypasses auth; required for remote servers. Get the token from `hicortex status` on the server. |
317
325
  | `defaultProject` | _(none)_ | Project name sent on recall, search, recent, and ingest whenever the gateway supplies no project (Hermes `default_project` parity). |
318
326
  | `recallLimit` | `8` | Max memories per recall on the pre-0.14 `/search` fallback. The pushed recall index is sized by SERVER config (`recallMaxItems`) — the server accepts no client limit. |
327
+ | `scaffoldDeadMan` | `true` | Auto-scaffold the dead-man identity-guard line into the agent workspace bootstrap (`BOOTSTRAP.md`) at startup. Set `false` to disable the write and any file creation entirely. |
319
328
 
320
329
  If `serverUrl`/`authToken` are absent from the config, the `HICORTEX_URL` and `HICORTEX_AUTH_TOKEN` environment variables are used as fallbacks (config always wins).
321
330
 
@@ -354,10 +363,13 @@ The artifact contains the irreplaceable data only: `hicortex.db`, the whole `ide
354
363
  ```jsonc
355
364
  {
356
365
  "backupDir": "/mnt/backups/hicortex", // optional; default <home>/backups
357
- "backupCommand": "rclone copyto" // invoked: rclone copyto <path> remote:hicortex/
366
+ "backupCommand": "rclone copyto", // invoked: rclone copyto <path> remote:hicortex/
367
+ "backupRetention": 7 // keep the N newest artifacts (default 7; 0 = keep all)
358
368
  }
359
369
  ```
360
370
 
371
+ **Retention.** After every successful write, the backup dir is pruned to the `backupRetention` newest artifacts — without it each nightly adds a tar.gz forever. Only files matching the product's own `hicortex-*.tar.gz` pattern are ever removed; anything else you keep in the dir is untouched. That pattern is the pruner's — **do not name your own copies `hicortex-*.tar.gz`**: a manual copy kept in the backup dir under that shape counts against retention and will be deleted once it falls past the boundary. Name operator copies differently (e.g. `manual-2026-08-19.tar.gz`). Applies to the nightly stage and `hicortex backup` alike (not to `--stdout`, which writes nothing on disk).
372
+
361
373
  A failing, missing, or timed-out hook (5 min) reports failure and **never throws** — the artifact is already on disk; only the offsite copy didn't land. Both `hicortex backup` (non-zero exit) and the nightly stage surface the failure.
362
374
 
363
375
  **Stream to stdout** (pipe to any offsite transport, no on-disk artifact):
@@ -368,7 +380,7 @@ npx @gamaze/hicortex backup --stdout | rclone rcat remote:hicortex/$(date -I).ta
368
380
 
369
381
  `--stdout` is mutually exclusive with `--out`/`backupDir`.
370
382
 
371
- **Nightly stage.** Every full nightly run (not `--capture-only`) takes a backup automatically after consolidation and runs the hook if configured. Backup failure does NOT fail the nightly — capture + consolidation have already succeeded; the failure surfaces as `backupOk:false` in the dashboard snapshot and telemetry for alerting.
383
+ **Nightly stage.** Every full nightly run takes a backup automatically after consolidation and runs the hook if configured — and prunes per `backupRetention`. `--consolidate-only` runs back up too, but at most once a day: the stage is skipped only while the newest existing artifact is younger than ~20 hours (so a timer that fires several times a day still yields exactly one backup per day, bounded by retention). `--capture-only` never backs up (it is frequent and stateless). Backup failure does NOT fail the nightly — capture + consolidation have already succeeded; the failure surfaces as `backupOk:false` in the dashboard snapshot and telemetry for alerting.
372
384
 
373
385
  **Restore (manual):**
374
386
 
package/dist/backup.d.ts CHANGED
@@ -31,6 +31,13 @@
31
31
  * `memories.content` via the embedder — non-destructive either way.
32
32
  */
33
33
  import type Database from "better-sqlite3";
34
+ /**
35
+ * Default number of backup artifacts kept in the backup dir (#327). The
36
+ * nightly backup stage writes one `hicortex-<ISO>.tar.gz` per run with no
37
+ * pruning — unbounded growth (per hosted tenant too). Configurable via
38
+ * `backupRetention`; 0 keeps everything.
39
+ */
40
+ export declare const DEFAULT_BACKUP_RETENTION = 7;
34
41
  export interface CreateBackupOptions {
35
42
  /** Open live DB handle (caller manages lifetime). Backed up via db.backup(). */
36
43
  db: Database.Database;
@@ -46,12 +53,27 @@ export interface CreateBackupOptions {
46
53
  * set. Configurable via `config.backupDir`.
47
54
  */
48
55
  outDir?: string;
56
+ /**
57
+ * Directory for the transient DB snapshot (defaults to the OS tmpdir).
58
+ * Injectable so tests observe a PRIVATE dir — vitest runs files in parallel,
59
+ * and a sibling test's in-flight snapshot in the shared tmpdir reads as a
60
+ * "leak" to a before/after diff (final consolidation CR finding 1).
61
+ */
62
+ snapshotDir?: string;
49
63
  /**
50
64
  * Stream the tar.gz to `process.stdout` instead of writing a file (the
51
65
  * offsite pattern: `hicortex backup --stdout | rclone rcat ...`). Mutually
52
66
  * exclusive with `outFile`/`outDir`.
53
67
  */
54
68
  stdout?: boolean;
69
+ /**
70
+ * Retention (#327): how many of the newest `hicortex-*.tar.gz` artifacts to
71
+ * keep in the (dir-managed) output dir after a successful write. 0 keeps
72
+ * all. Defaults to DEFAULT_BACKUP_RETENTION; callers resolve the
73
+ * `backupRetention` config key. Only consulted when the artifact goes to
74
+ * `outDir`/the default dir — an explicit `outFile` is caller-owned.
75
+ */
76
+ retention?: number;
55
77
  }
56
78
  export interface CreateBackupResult {
57
79
  /** Absolute path of the written artifact; undefined when `stdout:true`. */
@@ -60,6 +82,11 @@ export interface CreateBackupResult {
60
82
  bytes: number;
61
83
  /** Number of files packaged (DB snapshot + identity tree + state files). */
62
84
  files: number;
85
+ /**
86
+ * Old artifacts removed by the retention pass (#327). Undefined when no
87
+ * retention pass ran (stdout / explicit outFile); 0 = nothing to prune.
88
+ */
89
+ pruned?: number;
63
90
  }
64
91
  export interface BackupHookResult {
65
92
  ok: boolean;
@@ -92,6 +119,29 @@ export declare function createBackup(opts: CreateBackupOptions): Promise<CreateB
92
119
  * here is deliberately dumb to avoid re-implementing a shell parser.
93
120
  */
94
121
  export declare function runBackupHook(artifactPath: string, command: string | undefined): Promise<BackupHookResult>;
122
+ /**
123
+ * mtime (ms) of the NEWEST `hicortex-*.tar.gz` in `dir`, or undefined when the
124
+ * dir has none / cannot be read. The nightly consolidate-only artifact-age
125
+ * gate (#327 CR) uses this to bound the hosted backup cadence to ~1/day
126
+ * without a new state file: the newest artifact on disk IS the
127
+ * "last backed up" marker. Same product-owned filename pattern the pruner
128
+ * matches, so the gate and the pruner can never disagree about what counts.
129
+ */
130
+ export declare function newestBackupArtifactMs(dir: string): number | undefined;
131
+ /**
132
+ * Prune a backup dir to the `retention` newest artifacts (#327). Matches ONLY
133
+ * files named `hicortex-*.tar.gz` (the nightly/CLI artifact pattern) — anything
134
+ * else in the dir (operator copies, notes) is never touched. Keeps the newest
135
+ * `retention` by mtime, with the ISO filename (time-ordered by construction)
136
+ * as a DESCENDING tie-break so equal mtimes resolve deterministically (the
137
+ * just-written artifact is the newest and always survives); deletes the rest,
138
+ * oldest first. `retention <= 0` keeps all.
139
+ *
140
+ * Best-effort by design: a per-file unlink failure logs and continues (a
141
+ * stale extra artifact is cheap; failing the nightly AFTER a good backup was
142
+ * written is not). Returns the number actually removed.
143
+ */
144
+ export declare function pruneBackupArtifacts(dir: string, retention: number): number;
95
145
  export interface BackupCliOptions {
96
146
  /** `--out <dir>` — output directory (the artifact is auto-named). Takes precedence over `config.backupDir`. Mutually exclusive with stdout. */
97
147
  outDir?: string;
package/dist/backup.js CHANGED
@@ -32,8 +32,11 @@
32
32
  * `memories.content` via the embedder — non-destructive either way.
33
33
  */
34
34
  Object.defineProperty(exports, "__esModule", { value: true });
35
+ exports.DEFAULT_BACKUP_RETENTION = void 0;
35
36
  exports.createBackup = createBackup;
36
37
  exports.runBackupHook = runBackupHook;
38
+ exports.newestBackupArtifactMs = newestBackupArtifactMs;
39
+ exports.pruneBackupArtifacts = pruneBackupArtifacts;
37
40
  exports.runBackupCli = runBackupCli;
38
41
  const node_child_process_1 = require("node:child_process");
39
42
  const node_crypto_1 = require("node:crypto");
@@ -45,6 +48,7 @@ const tar_stream_1 = require("tar-stream");
45
48
  const paths_js_1 = require("./paths.js");
46
49
  const db_js_1 = require("./db.js");
47
50
  const init_js_1 = require("./init.js");
51
+ const config_read_js_1 = require("./config-read.js");
48
52
  /**
49
53
  * Hook timeout — a stuck offsite upload (rclone/aws/B2 hung on a dead network)
50
54
  * must NOT hang the nightly. 5 min is generous for typical tarball uploads; the
@@ -52,6 +56,13 @@ const init_js_1 = require("./init.js");
52
56
  * block forever" discipline.
53
57
  */
54
58
  const HOOK_TIMEOUT_MS = 5 * 60 * 1000;
59
+ /**
60
+ * Default number of backup artifacts kept in the backup dir (#327). The
61
+ * nightly backup stage writes one `hicortex-<ISO>.tar.gz` per run with no
62
+ * pruning — unbounded growth (per hosted tenant too). Configurable via
63
+ * `backupRetention`; 0 keeps everything.
64
+ */
65
+ exports.DEFAULT_BACKUP_RETENTION = 7;
55
66
  /**
56
67
  * Create a backup artifact. Snapshots the live DB via the online-backup API,
57
68
  * packages it with the identity tree + state into a single `tar.gz`, and writes
@@ -69,7 +80,7 @@ async function createBackup(opts) {
69
80
  // 1. Snapshot the live DB via the WAL-safe online-backup API (proven at
70
81
  // dedup.ts:499). Runs concurrently with the live DB — no writer blocking,
71
82
  // no torn copy. The temp file is cleaned up in `finally` below.
72
- const snapshotPath = (0, node_path_1.join)((0, node_os_1.tmpdir)(), `hicortex-backup-${(0, node_crypto_1.randomBytes)(6).toString("hex")}.db`);
83
+ const snapshotPath = (0, node_path_1.join)(opts.snapshotDir ?? (0, node_os_1.tmpdir)(), `hicortex-backup-${(0, node_crypto_1.randomBytes)(6).toString("hex")}.db`);
73
84
  await opts.db.backup(snapshotPath);
74
85
  let totalBytes = 0;
75
86
  let fileCount = 0;
@@ -144,7 +155,14 @@ async function createBackup(opts) {
144
155
  }
145
156
  packStream.finalize();
146
157
  await sinkFinished;
147
- return { path: outFile, bytes: totalBytes, files: fileCount };
158
+ // Retention (#327) only after a SUCCESSFUL write (a failed one removed
159
+ // its own partial artifact above), and only for dir-managed targets: an
160
+ // explicit outFile is caller-owned, and --stdout writes nothing to prune.
161
+ let pruned;
162
+ if (!opts.stdout && opts.outFile === undefined) {
163
+ pruned = pruneBackupArtifacts((0, node_path_1.dirname)(outFile), opts.retention ?? exports.DEFAULT_BACKUP_RETENTION);
164
+ }
165
+ return { path: outFile, bytes: totalBytes, files: fileCount, pruned };
148
166
  }
149
167
  catch (err) {
150
168
  // Never leave a truncated/half-written artifact on disk masquerading as a
@@ -231,6 +249,101 @@ async function runBackupHook(artifactPath, command) {
231
249
  // ---------------------------------------------------------------------------
232
250
  // File-set helpers
233
251
  // ---------------------------------------------------------------------------
252
+ /**
253
+ * mtime (ms) of the NEWEST `hicortex-*.tar.gz` in `dir`, or undefined when the
254
+ * dir has none / cannot be read. The nightly consolidate-only artifact-age
255
+ * gate (#327 CR) uses this to bound the hosted backup cadence to ~1/day
256
+ * without a new state file: the newest artifact on disk IS the
257
+ * "last backed up" marker. Same product-owned filename pattern the pruner
258
+ * matches, so the gate and the pruner can never disagree about what counts.
259
+ */
260
+ function newestBackupArtifactMs(dir) {
261
+ let names;
262
+ try {
263
+ names = (0, node_fs_1.readdirSync)(dir);
264
+ }
265
+ catch {
266
+ // Missing/unreadable dir — no artifact, no gate (a fresh install backs up).
267
+ return undefined;
268
+ }
269
+ let newest;
270
+ for (const n of names) {
271
+ if (!/^hicortex-.*\.tar\.gz$/.test(n))
272
+ continue;
273
+ try {
274
+ const ms = (0, node_fs_1.statSync)((0, node_path_1.join)(dir, n)).mtimeMs;
275
+ if (newest === undefined || ms > newest)
276
+ newest = ms;
277
+ }
278
+ catch {
279
+ // Raced/unreadable — an unknown-age artifact cannot inform the gate.
280
+ }
281
+ }
282
+ return newest;
283
+ }
284
+ /**
285
+ * Prune a backup dir to the `retention` newest artifacts (#327). Matches ONLY
286
+ * files named `hicortex-*.tar.gz` (the nightly/CLI artifact pattern) — anything
287
+ * else in the dir (operator copies, notes) is never touched. Keeps the newest
288
+ * `retention` by mtime, with the ISO filename (time-ordered by construction)
289
+ * as a DESCENDING tie-break so equal mtimes resolve deterministically (the
290
+ * just-written artifact is the newest and always survives); deletes the rest,
291
+ * oldest first. `retention <= 0` keeps all.
292
+ *
293
+ * Best-effort by design: a per-file unlink failure logs and continues (a
294
+ * stale extra artifact is cheap; failing the nightly AFTER a good backup was
295
+ * written is not). Returns the number actually removed.
296
+ */
297
+ function pruneBackupArtifacts(dir, retention) {
298
+ if (!Number.isFinite(retention) || retention <= 0)
299
+ return 0;
300
+ let names;
301
+ try {
302
+ names = (0, node_fs_1.readdirSync)(dir);
303
+ }
304
+ catch {
305
+ // Missing/unreadable dir — nothing to prune (e.g. a fresh install).
306
+ return 0;
307
+ }
308
+ const artifacts = names
309
+ .filter((n) => /^hicortex-.*\.tar\.gz$/.test(n))
310
+ .map((n) => {
311
+ const abs = (0, node_path_1.join)(dir, n);
312
+ let mtimeMs = 0;
313
+ try {
314
+ mtimeMs = (0, node_fs_1.statSync)(abs).mtimeMs;
315
+ }
316
+ catch {
317
+ // Raced/unreadable — sort it oldest so the prune attempt happens
318
+ // anyway (force-unlink below may still succeed).
319
+ }
320
+ return { name: n, abs, mtimeMs };
321
+ })
322
+ // Newest first; the ISO filename (`hicortex-<ISO>.tar.gz`) is time-ordered
323
+ // by construction, so it is the tie-break in DESCENDING order — identical
324
+ // mtimes (same-second writes, or an operator restoring a copy) otherwise
325
+ // leave the retention boundary to readdir order, which is FS-arbitrary
326
+ // and can differ run to run (#327 CR).
327
+ .sort((a, b) => b.mtimeMs - a.mtimeMs ||
328
+ (a.name < b.name ? 1 : a.name > b.name ? -1 : 0));
329
+ const doomed = artifacts.slice(Math.floor(retention));
330
+ let pruned = 0;
331
+ for (const a of doomed) {
332
+ try {
333
+ (0, node_fs_1.rmSync)(a.abs, { force: true });
334
+ pruned++;
335
+ }
336
+ catch (err) {
337
+ console.error(`[hicortex] backup retention: could not prune ${a.abs}: ` +
338
+ `${err instanceof Error ? err.message : String(err)}`);
339
+ }
340
+ }
341
+ if (pruned > 0) {
342
+ console.log(`[hicortex] Backup retention: pruned ${pruned} old artifact(s), ` +
343
+ `keeping ${artifacts.length - pruned}`);
344
+ }
345
+ return pruned;
346
+ }
234
347
  /**
235
348
  * Recursively append every regular file under `absDir` to `entries`, with
236
349
  * in-tar paths rooted at `relRoot`. Symlinks are SKIPPED (security — a symlink
@@ -304,6 +417,8 @@ async function runBackupCli(opts) {
304
417
  const backupCommand = typeof config.backupCommand === "string" && config.backupCommand.trim()
305
418
  ? config.backupCommand
306
419
  : undefined;
420
+ // Same reader + default as the nightly stage (#327) — one knob everywhere.
421
+ const retention = (0, config_read_js_1.readNonNegativeConfig)(config, "backupRetention", exports.DEFAULT_BACKUP_RETENTION);
307
422
  const db = (0, db_js_1.initDb)((0, db_js_1.resolveDbPath)());
308
423
  try {
309
424
  const result = await createBackup({
@@ -315,6 +430,7 @@ async function runBackupCli(opts) {
315
430
  // exclusivity check would otherwise reject it).
316
431
  outDir: opts.stdout ? undefined : (opts.outDir ?? backupDir),
317
432
  stdout: opts.stdout,
433
+ retention,
318
434
  });
319
435
  if (result.path) {
320
436
  console.log(`[hicortex] Backup written: ${result.path} ` +
package/dist/capture.d.ts CHANGED
@@ -71,6 +71,12 @@ export interface PostResult {
71
71
  dropped?: string[];
72
72
  skipped?: boolean;
73
73
  error?: string;
74
+ /**
75
+ * Parsed `Retry-After` of a 429 response, in ms (#327). Absent when the
76
+ * response carried no parseable header. Honored (capped) by the in-run
77
+ * rate-limit retry; a token-budget 429 ignores it (terminal, not transient).
78
+ */
79
+ retryAfterMs?: number;
74
80
  /**
75
81
  * The segment's metered LLM usage from the 201 body (#287). Absent on a
76
82
  * pre-#287 daemon (or any non-201) — callers must treat absent as zero.
@@ -106,9 +112,20 @@ export interface CaptureResult {
106
112
  hadTransientFailure: boolean;
107
113
  /**
108
114
  * Set when the loop stopped early on a terminal server response: "limit"
109
- * (429 memory cap) or "auth" (401). The caller decides watermark handling.
115
+ * (token-budget 429, mcp-server.ts's `"token budget exceeded"` gate) or
116
+ * "auth" (401). A rate-limit 429 never sets this — it is transient (#327).
117
+ * The caller decides watermark handling.
110
118
  */
111
119
  stopped?: "limit" | "auth";
120
+ /**
121
+ * Run-global rate-429 latch (#327 CR): true when at least one session
122
+ * SURRENDERED to a rate-limit 429 (the transient kind — postWithRateRetry
123
+ * exhausted its in-run retries) and the remaining sessions were skipped
124
+ * without POSTing. Mirrors `stopped`'s role but is transient, not terminal:
125
+ * hadTransientFailure is also set, so the watermark holds and every skipped
126
+ * session retries next run with its cursor untouched.
127
+ */
128
+ rateLimited: boolean;
112
129
  /**
113
130
  * Sum of the successful segments' reported LLM usage (#287) — the run's
114
131
  * distill spend, for the dashboard snapshot's token totals. Zero-filled when
@@ -138,11 +155,19 @@ export declare function hardSplitText(text: string, maxChars?: number): string[]
138
155
  * hard-split pieces (A2).
139
156
  */
140
157
  export declare function packSegments(entries: unknown[], startCursor: number, entryCursors: number[], maxChars?: number): Segment[];
158
+ /** True when a 429 result is the tenant token-budget gate (terminal). */
159
+ export declare function isTokenBudget429(result: PostResult): boolean;
160
+ /** In-run retries for a rate-limit 429 before giving up as transient. */
161
+ export declare const RATE_LIMIT_RETRIES = 3;
141
162
  /**
142
163
  * Capture a list of session delta batches: pack, POST in order, advance
143
164
  * per-session cursors on success. Segments of one session POST in order; the
144
165
  * first hard failure stops THAT session (cursor holds at the last confirmed
145
- * boundary) while other sessions continue. A 429/401 stops the whole loop.
166
+ * boundary) while other sessions continue. A budget-429/401 stops the whole
167
+ * loop; a rate-429 is retried in-run before giving up as transient — and a
168
+ * SURRENDERED rate-429 latches the whole run: remaining sessions skip
169
+ * without POSTing (their cursors hold; next run retries) instead of each
170
+ * re-paying the Retry-After ladder (#327).
146
171
  */
147
172
  export declare function captureBatches(batches: TranscriptBatch[], opts: CaptureOptions): Promise<CaptureResult>;
148
173
  /**
package/dist/capture.js CHANGED
@@ -14,9 +14,10 @@
14
14
  * multi-night simulation can run as a pure unit test with no HTTP listener.
15
15
  */
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
- exports.MIN_SEGMENT_CHARS = exports.SEGMENT_MAX_CHARS = void 0;
17
+ exports.RATE_LIMIT_RETRIES = exports.MIN_SEGMENT_CHARS = exports.SEGMENT_MAX_CHARS = void 0;
18
18
  exports.hardSplitText = hardSplitText;
19
19
  exports.packSegments = packSegments;
20
+ exports.isTokenBudget429 = isTokenBudget429;
20
21
  exports.captureBatches = captureBatches;
21
22
  exports.acquireCaptureLock = acquireCaptureLock;
22
23
  const node_fs_1 = require("node:fs");
@@ -127,11 +128,80 @@ function packSegments(entries, startCursor, entryCursors, maxChars = exports.SEG
127
128
  flushGroup(entries.length);
128
129
  return segments;
129
130
  }
131
+ // ---------------------------------------------------------------------------
132
+ // 429 taxonomy (#327) — two producers share one status code
133
+ // ---------------------------------------------------------------------------
134
+ // - The tenant server's token-budget gate (mcp-server.ts:1205) answers
135
+ // 429 `{"error":"token budget exceeded"}` — TERMINAL for the billing
136
+ // period; retrying cannot help, so capture stops with stopped:"limit".
137
+ // - A rate limit (e.g. a hosted router's per-token limiter answering
138
+ // 429 `{"error":"rate limit exceeded"}` + Retry-After) is TRANSIENT —
139
+ // the window passes in seconds/minutes. Treating it as the terminal
140
+ // limit-stop left nightlies failing forever with the wrong diagnosis.
141
+ // The body's error string is the discriminator: only the budget gate says
142
+ // "token budget".
143
+ /** True when a 429 result is the tenant token-budget gate (terminal). */
144
+ function isTokenBudget429(result) {
145
+ return typeof result.error === "string" && /token budget/i.test(result.error);
146
+ }
147
+ /** In-run retries for a rate-limit 429 before giving up as transient. */
148
+ exports.RATE_LIMIT_RETRIES = 3;
149
+ /**
150
+ * Backoff schedule (ms) between rate-429 retries: 5s, 15s, 30s — short enough
151
+ * to ride out a per-token limiter window inside one nightly, long enough to
152
+ * let a busy shared backend drain. Overridable via
153
+ * HICORTEX_RATE_LIMIT_BACKOFF_MS (comma-separated ms list — tests only).
154
+ */
155
+ const RATE_LIMIT_BACKOFF_MS = [5_000, 15_000, 30_000];
156
+ /**
157
+ * Cap on an honored Retry-After. The header is advisory and can name a window
158
+ * far beyond one nightly (an hourly limiter saying 3600); waiting it out
159
+ * in-run would stall the run for hours with the capture lock held. Past the
160
+ * cap we give up as transient — the cursor holds and the next run retries.
161
+ */
162
+ const RATE_LIMIT_RETRY_AFTER_CAP_MS = 5 * 60 * 1000;
163
+ function rateLimitBackoffSchedule() {
164
+ const env = process.env.HICORTEX_RATE_LIMIT_BACKOFF_MS;
165
+ if (env && env.trim()) {
166
+ const parsed = env.split(",").map((s) => Number(s.trim()));
167
+ if (parsed.length > 0 && parsed.every((n) => Number.isFinite(n) && n >= 0)) {
168
+ return parsed;
169
+ }
170
+ }
171
+ return RATE_LIMIT_BACKOFF_MS;
172
+ }
173
+ /**
174
+ * POST one segment, retrying in-run while the answer is a TRANSIENT
175
+ * (non-budget) 429. Returns the final result — a budget-429 passes through
176
+ * untouched (the caller's terminal branch) and a rate-429 that survived every
177
+ * retry is returned as-is for the caller's transient branch. Network errors
178
+ * propagate to the caller's existing catch (message preserved).
179
+ */
180
+ async function postWithRateRetry(post, body) {
181
+ const schedule = rateLimitBackoffSchedule();
182
+ for (let attempt = 0;; attempt++) {
183
+ const result = await post(body);
184
+ if (result.status !== 429 || isTokenBudget429(result))
185
+ return result;
186
+ if (attempt >= exports.RATE_LIMIT_RETRIES || attempt >= schedule.length)
187
+ return result;
188
+ const waitMs = result.retryAfterMs != null && result.retryAfterMs > 0
189
+ ? Math.min(result.retryAfterMs, RATE_LIMIT_RETRY_AFTER_CAP_MS)
190
+ : schedule[attempt];
191
+ console.log(`[hicortex] Rate limited (${result.error ?? "429"}) — retrying segment in ` +
192
+ `${Math.round(waitMs / 1000)}s (retry ${attempt + 1}/${exports.RATE_LIMIT_RETRIES})`);
193
+ await sleep(waitMs);
194
+ }
195
+ }
130
196
  /**
131
197
  * Capture a list of session delta batches: pack, POST in order, advance
132
198
  * per-session cursors on success. Segments of one session POST in order; the
133
199
  * first hard failure stops THAT session (cursor holds at the last confirmed
134
- * boundary) while other sessions continue. A 429/401 stops the whole loop.
200
+ * boundary) while other sessions continue. A budget-429/401 stops the whole
201
+ * loop; a rate-429 is retried in-run before giving up as transient — and a
202
+ * SURRENDERED rate-429 latches the whole run: remaining sessions skip
203
+ * without POSTing (their cursors hold; next run retries) instead of each
204
+ * re-paying the Retry-After ladder (#327).
135
205
  */
136
206
  async function captureBatches(batches, opts) {
137
207
  const { post, cursorStore, dryRun = false, segmentMaxChars = exports.SEGMENT_MAX_CHARS, sourceAgentId, sourceDomain } = opts;
@@ -139,10 +209,16 @@ async function captureBatches(batches, opts) {
139
209
  let sessionsSent = 0;
140
210
  let hadTransientFailure = false;
141
211
  let stopped;
212
+ // Run-global rate-429 latch (#327 CR): once one session has paid the full
213
+ // Retry-After ladder and surrendered, the limiter window is exhausted for
214
+ // THIS run — letting each remaining session re-pay the ladder (worst case
215
+ // ~15 min each at the 5-min cap, with the capture lock held) can stretch a
216
+ // 20-session run to hours for zero accepted segments.
217
+ let rateLimited = false;
142
218
  // #287: distill tokens reported by successful 201s only — skips (200) and
143
219
  // failures metered nothing client-side.
144
220
  const distillUsage = { prompt: 0, completion: 0, total: 0 };
145
- for (const batch of batches) {
221
+ for (const [batchIdx, batch] of batches.entries()) {
146
222
  const short = batch.sessionId.slice(0, 8);
147
223
  const segments = packSegments(batch.entries, batch.startCursor, batch.entryCursors, segmentMaxChars);
148
224
  if (segments.length === 0)
@@ -198,7 +274,7 @@ async function captureBatches(batches, opts) {
198
274
  };
199
275
  let result;
200
276
  try {
201
- result = await post(body);
277
+ result = await postWithRateRetry(post, body);
202
278
  }
203
279
  catch (err) {
204
280
  console.error(`[hicortex] Capture failed: ${err instanceof Error ? err.message : String(err)} — will retry next run`);
@@ -228,11 +304,24 @@ async function captureBatches(batches, opts) {
228
304
  if (result.skipped)
229
305
  console.log(`[hicortex] Segment ${body.segment_id} already ingested`);
230
306
  }
231
- else if (result.status === 429) {
307
+ else if (result.status === 429 && isTokenBudget429(result)) {
232
308
  console.log(`[hicortex] Memory limit reached: ${result.error}. Stopping capture.`);
233
309
  stopped = "limit";
234
310
  break;
235
311
  }
312
+ else if (result.status === 429) {
313
+ // Rate-limit 429 that already burned its in-run retries (postWithRateRetry).
314
+ // Transient, NOT a limit stop (#327): hold the cursor, let the next run
315
+ // retry — a misdiagnosed "memory limit" here failed nightlies forever.
316
+ console.error(`[hicortex] Rate limited: ${result.error ?? "429"} — giving up this run, ` +
317
+ `will retry next run (cursor held)`);
318
+ hadTransientFailure = true;
319
+ // Latch the WHOLE run (see the declaration above): remaining sessions
320
+ // are skipped below, cursors untouched — they retry next run instead
321
+ // of each re-paying the full Retry-After ladder.
322
+ rateLimited = true;
323
+ break;
324
+ }
236
325
  else if (result.status === 401) {
237
326
  console.error(`[hicortex] Auth failed. Check authToken in ~/.hicortex/config.json`);
238
327
  stopped = "auth";
@@ -265,8 +354,16 @@ async function captureBatches(batches, opts) {
265
354
  }
266
355
  if (stopped)
267
356
  break;
357
+ if (rateLimited) {
358
+ const remaining = batches.length - batchIdx - 1;
359
+ if (remaining > 0) {
360
+ console.error(`[hicortex] Rate limited earlier this run — skipping ${remaining} remaining ` +
361
+ `session(s); they retry next run (cursors untouched)`);
362
+ }
363
+ break;
364
+ }
268
365
  }
269
- return { memoriesIngested, sessionsSent, hadTransientFailure, stopped, distillUsage };
366
+ return { memoriesIngested, sessionsSent, hadTransientFailure, stopped, rateLimited, distillUsage };
270
367
  }
271
368
  // ---------------------------------------------------------------------------
272
369
  // Single-flight guard (A5)
@@ -25,6 +25,29 @@ export declare function embed(text: string): Promise<Float32Array>;
25
25
  * Embed multiple texts. Returns an array of Float32Array embeddings.
26
26
  */
27
27
  export declare function embedBatch(texts: string[]): Promise<Float32Array[]>;
28
+ /**
29
+ * Fire-and-forget embedder warm-up (#329 item 2), called at the END of server
30
+ * boot. The ONNX pipeline lazy-loads inside the first embed() (~0.5-3s cold),
31
+ * so without this the FIRST /recall-index after every restart paid the model
32
+ * load inside its own latency budget — the 1s client hook budget blows and
33
+ * that turn silently loses recall.
34
+ *
35
+ * Contract (unit-pinned in tests/embedder-warm.test.ts):
36
+ * - fires exactly ONE embed call ("warmup"), NEVER awaited — returns
37
+ * synchronously so boot/listen is never blocked;
38
+ * - a failing warm-up is logged once (console.warn) and swallowed —
39
+ * warm-up is an optimization, never a boot dependency. The next real
40
+ * embed() retries the lazy load on its own terms.
41
+ *
42
+ * `embedFn` is injectable for tests; production passes the module's embed().
43
+ *
44
+ * MEMORY NOTE (accepted trade-off, #329 CR finding 3): warming at boot makes
45
+ * the model (~150-300MB resident) load in every server process from startup —
46
+ * including idle hosted tenant containers, which previously never loaded it.
47
+ * Accepted at current hosted sizing (2g per-tenant caps; active tenants load
48
+ * it on first use anyway). See the warm-site comment in mcp-server.ts.
49
+ */
50
+ export declare function warmEmbedder(embedFn?: (text: string) => Promise<Float32Array>): void;
28
51
  /**
29
52
  * Return the embedding dimension count.
30
53
  */
package/dist/embedder.js CHANGED
@@ -11,6 +11,7 @@ exports.EMBEDDING_DIMENSIONS = void 0;
11
11
  exports.resolveModelCacheDir = resolveModelCacheDir;
12
12
  exports.embed = embed;
13
13
  exports.embedBatch = embedBatch;
14
+ exports.warmEmbedder = warmEmbedder;
14
15
  exports.dimensions = dimensions;
15
16
  const node_fs_1 = require("node:fs");
16
17
  const node_path_1 = require("node:path");
@@ -104,6 +105,34 @@ async function embedBatch(texts) {
104
105
  }
105
106
  return results;
106
107
  }
108
+ /**
109
+ * Fire-and-forget embedder warm-up (#329 item 2), called at the END of server
110
+ * boot. The ONNX pipeline lazy-loads inside the first embed() (~0.5-3s cold),
111
+ * so without this the FIRST /recall-index after every restart paid the model
112
+ * load inside its own latency budget — the 1s client hook budget blows and
113
+ * that turn silently loses recall.
114
+ *
115
+ * Contract (unit-pinned in tests/embedder-warm.test.ts):
116
+ * - fires exactly ONE embed call ("warmup"), NEVER awaited — returns
117
+ * synchronously so boot/listen is never blocked;
118
+ * - a failing warm-up is logged once (console.warn) and swallowed —
119
+ * warm-up is an optimization, never a boot dependency. The next real
120
+ * embed() retries the lazy load on its own terms.
121
+ *
122
+ * `embedFn` is injectable for tests; production passes the module's embed().
123
+ *
124
+ * MEMORY NOTE (accepted trade-off, #329 CR finding 3): warming at boot makes
125
+ * the model (~150-300MB resident) load in every server process from startup —
126
+ * including idle hosted tenant containers, which previously never loaded it.
127
+ * Accepted at current hosted sizing (2g per-tenant caps; active tenants load
128
+ * it on first use anyway). See the warm-site comment in mcp-server.ts.
129
+ */
130
+ function warmEmbedder(embedFn = embed) {
131
+ embedFn("warmup").catch((err) => {
132
+ console.warn(`[hicortex] Embedder warm-up failed (first search will lazy-load instead): ` +
133
+ (err instanceof Error ? err.message : String(err)));
134
+ });
135
+ }
107
136
  /**
108
137
  * Return the embedding dimension count.
109
138
  */
package/dist/index.d.ts CHANGED
@@ -69,6 +69,16 @@ export declare function formatToolResults(results: MemorySearchResult[]): {
69
69
  * the plugin half-initialized.
70
70
  */
71
71
  export declare function resolveOcPluginConfig(raw: unknown): HicortexConfig;
72
+ /**
73
+ * Resolve the agent workspace directory from the RAW gateway config (#326):
74
+ * OpenClaw's `agents.defaults.workspace`. Pure — no module state, no fs, no
75
+ * mutation, never throws. Absent/non-string/empty → null (the caller falls
76
+ * back to the OC default workspace). Deliberately does NOT read the plugin's
77
+ * own config section: the workspace is a gateway-level fact, not a plugin
78
+ * knob, so it is resolved from ctx.config directly (the whole openclaw.json,
79
+ * same object resolveOcPluginConfig walks).
80
+ */
81
+ export declare function resolveOcWorkspaceDir(raw: unknown): string | null;
72
82
  declare const _default: {
73
83
  id: string;
74
84
  name: string;
@@ -76,3 +86,12 @@ declare const _default: {
76
86
  register(api: any): void;
77
87
  };
78
88
  export default _default;
89
+ /**
90
+ * Normalize a gateway-config workspace path for WRITING (#326 CR1): a bare
91
+ * `~` or leading `~/` expands against the real home dir; anything still
92
+ * RELATIVE afterwards is rejected (null). OpenClaw's semantics for relative
93
+ * workspace values are not verifiable from the plugin, and writing under
94
+ * process.cwd() would place the guard where OC never reads it — a silent
95
+ * no-op safety — so the caller skips with a warning instead. Pure; no fs.
96
+ */
97
+ export declare function normalizeWorkspacePath(ws: string): string | null;