@gamaze/hicortex 0.18.3 → 0.19.1

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/nightly.js CHANGED
@@ -75,6 +75,7 @@ const capture_js_1 = require("./capture.js");
75
75
  const dashboard_js_1 = require("./dashboard.js");
76
76
  const telemetry_js_1 = require("./telemetry.js");
77
77
  const init_js_1 = require("./init.js");
78
+ const backup_js_1 = require("./backup.js");
78
79
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
79
80
  function readNightlyConfig(stateDir) {
80
81
  const configPath = (0, node_path_1.join)(stateDir, "config.json");
@@ -168,7 +169,16 @@ function makeRemotePost(serverUrl, authToken) {
168
169
  async function normalizePostResult(resp) {
169
170
  if (resp.status === 201) {
170
171
  const data = (await resp.json().catch(() => ({})));
171
- return { status: 201, distilled: data.distilled ?? 0, dropped: data.dropped ?? [] };
172
+ // #287: the daemon reports the segment's metered usage. Shape-validated
173
+ // so a partial payload can't NaN the run's totals; a pre-#287 daemon
174
+ // simply omits it → capture sums zero (snapshot stays consolidation-only).
175
+ const usage = parseUsage(data.usage);
176
+ return {
177
+ status: 201,
178
+ distilled: data.distilled ?? 0,
179
+ dropped: data.dropped ?? [],
180
+ ...(usage ? { usage } : {}),
181
+ };
172
182
  }
173
183
  if (resp.status === 200) {
174
184
  const data = (await resp.json().catch(() => ({})));
@@ -177,6 +187,18 @@ async function normalizePostResult(resp) {
177
187
  const data = (await resp.json().catch(() => ({})));
178
188
  return { status: resp.status, error: data.error ?? "unknown error" };
179
189
  }
190
+ /** Strict {prompt, completion, total} parser for /distill's usage field (#287);
191
+ * undefined on anything malformed — the caller then treats it as unmetered. */
192
+ function parseUsage(u) {
193
+ if (typeof u !== "object" || u === null)
194
+ return undefined;
195
+ const { prompt, completion, total } = u;
196
+ const num = (n) => typeof n === "number" && Number.isFinite(n) && n >= 0 ? n : null;
197
+ const p = num(prompt), c = num(completion), t = num(total);
198
+ if (p === null || c === null || t === null)
199
+ return undefined;
200
+ return { prompt: p, completion: c, total: t };
201
+ }
180
202
  function writeLastRun(stateDir = HICORTEX_HOME) {
181
203
  (0, state_js_1.updateState)((s) => {
182
204
  s.lastNightly = new Date().toISOString();
@@ -347,6 +369,13 @@ async function runNightly(options = {}) {
347
369
  let batches = [];
348
370
  let memoriesIngested = 0;
349
371
  let hadTransientFailure = false;
372
+ // #287: distill tokens metered by the daemon across this run's segment
373
+ // POSTs (summed from the /distill responses by the capture loop).
374
+ // Forwarded to the dashboard snapshot so new_this_run.tokens is the run's
375
+ // TRUE total (distill + consolidation) and the distill share lands in
376
+ // tokens_by_stage. Zero when the daemon predates the usage field — the
377
+ // snapshot writer gates on total > 0, so old servers keep today's shape.
378
+ let distillUsage;
350
379
  // consolidateOnly (hosted service): skip capture entirely. The hosted
351
380
  // consolidation timer uses this so per-tenant nightly runs don't ingest the
352
381
  // operator's local sessions into the tenant's DB.
@@ -423,6 +452,7 @@ async function runNightly(options = {}) {
423
452
  sourceDomain: savedConfig?.sourceDomain,
424
453
  });
425
454
  memoriesIngested = result.memoriesIngested;
455
+ distillUsage = result.distillUsage;
426
456
  // A 429/401 stop must hold the watermark too (fix 1): the loop abandoned
427
457
  // the remaining sessions, and mtime discovery would never re-find them.
428
458
  hadTransientFailure = result.hadTransientFailure || result.stopped !== undefined;
@@ -430,7 +460,12 @@ async function runNightly(options = {}) {
430
460
  finally {
431
461
  releaseLock();
432
462
  }
433
- console.log(`[hicortex] Capture complete: ${memoriesIngested} new memories`);
463
+ console.log(`[hicortex] Capture complete: ${memoriesIngested} new memories` +
464
+ // #287: distill tokens the daemon metered for those segments (absent
465
+ // when the daemon predates the usage field or nothing distilled).
466
+ (distillUsage && distillUsage.total > 0
467
+ ? ` · ${distillUsage.total.toLocaleString()} distill tokens`
468
+ : ""));
434
469
  // Prune aged-out cursors (90d) — only on a clean run so a transient
435
470
  // failure doesn't drop a still-needed cursor.
436
471
  if (!dryRun && !hadTransientFailure) {
@@ -630,6 +665,53 @@ async function runNightly(options = {}) {
630
665
  }
631
666
  }
632
667
  console.log(`[hicortex] Nightly pipeline complete.`);
668
+ // Backup stage (#6, Phase 0B) — a transactionally-consistent snapshot of
669
+ // the irreplaceable data (DB + identity + state), packaged as one tar.gz
670
+ // 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
676
+ // channel yet — Phase 3).
677
+ let backupPath;
678
+ let backupBytes;
679
+ let backupOk;
680
+ if (!dryRun && !captureOnly) {
681
+ 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 });
686
+ backupPath = bRes.path;
687
+ backupBytes = bRes.bytes;
688
+ backupOk = true;
689
+ console.log(`[hicortex] Backup: ${bRes.files} files, ${bRes.bytes.toLocaleString()} bytes -> ${bRes.path}`);
690
+ const cmd = typeof savedConfig?.backupCommand === "string" && savedConfig.backupCommand.trim()
691
+ ? savedConfig.backupCommand
692
+ : undefined;
693
+ if (cmd && backupPath) {
694
+ const hook = await (0, backup_js_1.runBackupHook)(backupPath, cmd);
695
+ if (!hook.ok) {
696
+ // The artifact is on disk; only the offsite copy failed. Keep
697
+ // backupPath/backupBytes (the snapshot records what was produced)
698
+ // but flip backupOk so the aggregate can alert.
699
+ backupOk = false;
700
+ console.error(`[hicortex] Backup hook failed (exit ${hook.exitCode ?? "n/a"}). ` +
701
+ `Artifact is on disk; offsite copy did NOT complete.`);
702
+ }
703
+ else {
704
+ console.log(`[hicortex] Backup hook ok (exit 0).`);
705
+ }
706
+ }
707
+ }
708
+ catch (err) {
709
+ // The whole backup stage failed (snapshot, tar, or write). Do NOT
710
+ // propagate — capture/consolidation already succeeded. Surface + continue.
711
+ backupOk = false;
712
+ console.error(`[hicortex] Backup FAILED: ${err instanceof Error ? err.message : String(err)}`);
713
+ }
714
+ }
633
715
  // Dashboard snapshot (#224) — full nightly only. The snapshot reflects
634
716
  // corpus state regardless of whether consolidation/LLM ran, so it is
635
717
  // ALWAYS written here (the use case is history; an LLM-less install still
@@ -673,6 +755,12 @@ async function runNightly(options = {}) {
673
755
  // when consolidation didn't run or made no metered calls).
674
756
  tokensThisRun,
675
757
  tokensByStage,
758
+ // #287: the capture phase's distill tokens (from the /distill
759
+ // responses). The writer merges them into `tokens` +
760
+ // `tokens_by_stage.distill` so the customer-facing total is the
761
+ // run's TRUE spend; zero (old daemon / nothing distilled) is a no-op
762
+ // and the row keeps its consolidation-only shape.
763
+ distillUsage,
676
764
  // #255 CR: always-on budget usage — undefined when consolidation
677
765
  // didn't run (capture-only / no_llm / throttled). Forwarded whenever
678
766
  // consolidation ran so the digest renders a continuous used/max bar.
@@ -682,6 +770,13 @@ async function runNightly(options = {}) {
682
770
  // (capture-only / no_llm / throttled) or didn't exhaust.
683
771
  budgetExhausted,
684
772
  budgetDeferredByStage,
773
+ // #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.
777
+ backupPath,
778
+ backupBytes,
779
+ backupOk,
685
780
  }, memorySoftCapResolved);
686
781
  }
687
782
  catch (snapErr) {
@@ -728,6 +823,13 @@ async function runNightly(options = {}) {
728
823
  // exhausted (false is omitted to keep the ping minimal; the aggregate
729
824
  // treats absent as "not exhausted / not measurable").
730
825
  ...(budgetExhausted ? { budget_exhausted: true } : {}),
826
+ // #6 backup stage outcome — forwarded only when the backup stage ran
827
+ // (full nightly). `ok` is false on snapshot OR hook failure; the fleet
828
+ // 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).
830
+ ...(backupOk !== undefined
831
+ ? { backup: { ok: backupOk === true, bytes: backupBytes ?? 0 } }
832
+ : {}),
731
833
  sessions: batches.length,
732
834
  ok: !hadTransientFailure,
733
835
  shown: adoption.shown,
package/dist/prompts.js CHANGED
@@ -120,6 +120,7 @@ EXTRACT into this markdown format:
120
120
 
121
121
  ### Decisions Made
122
122
  - [D] [SUBJECT]: [decision] — [reasoning] (${date})
123
+ (ONLY decisions the user explicitly made or confirmed — never an AI proposal)
123
124
 
124
125
  ### Knowledge Learned
125
126
  - [K] [SUBJECT]: [knowledge] — [context/source] (${date})
@@ -145,10 +146,19 @@ TYPE TAG (critical — prefix EVERY bullet with exactly one letter + space):
145
146
  - [K] KNOWLEDGE — a durable truth that will hold across sessions: "the API is at
146
147
  :8787", "uv is used for packages", "config lives in ~/.hicortex/". Not tied
147
148
  to a single moment.
148
- - [D] DECISIONS — a choice made that future work builds on, and that a later
149
- decision can SUPERSEDE: "switched from gemma4 to qwen3.5", "adopted the
150
- graded-schema tag model". Not knowledge (it can change) and not experience
151
- (it persists and constrains).
149
+ - [D] DECISIONS — a choice the USER explicitly made or confirmed in the
150
+ transcript: they said "do X / agreed / ok / go ahead", approved the plan, or
151
+ the transcript shows the change actually being carried out. It must be a
152
+ choice future work builds on and that a later decision can SUPERSEDE:
153
+ "switched from gemma4 to qwen3.5", "adopted the graded-schema tag model".
154
+ Not knowledge (it can change) and not experience (it persists and constrains).
155
+ - An AI recommendation or proposal is NEVER a decision, however detailed or
156
+ well-reasoned — even if the user seemed receptive. If the user declined,
157
+ deferred ("hold", "later", "wait for X"), or did not answer: record it as
158
+ [E] EXPERIENCE with proposal framing ("AI proposed X → user declined/held
159
+ because Y"), or under Corrections & Rejections.
160
+ - SELF-CHECK: if an item's text says "AI recommended/proposed" or the user
161
+ "has not (yet) confirmed" it, that item is NOT [D] — re-tag it [E].
152
162
  - NEVER use [L] (learnings). Learnings are extracted by a SEPARATE reflection stage,
153
163
  not here. If the model emits [L], it is wrong — re-tag as experience/knowledge/decisions.
154
164
  The type tag goes BEFORE the subject, never as a section/category bracket.
@@ -161,6 +171,8 @@ entity. The subject is what a future reader would search for.
161
171
  - NOT: "[E] User rejected AI's bundling of unknown loads"
162
172
  - Write: "[K] Nightly capture (Hermes): cron sessions are excluded — source='cron' is skipped before distillation"
163
173
  - NOT: "[K] Discovered that cron sessions are filtered out"
174
+ - Write: "[E] Qwen3.8-27B swap: AI proposed switching → user held on Qwen3.6-35B-A3B until an MoE variant ships"
175
+ - NOT: "[D] Qwen3.8-27B: switch from Qwen3.6-27B — drop-in upgrade"
164
176
  Reason: each item's first words (after the type tag) become the memory's one-line
165
177
  index entry AND dominate its search embedding. An item that opens with a category
166
178
  label, a sentiment ("Strong Negative"), or "User rejected…" is unfindable — it
@@ -169,6 +181,9 @@ the subject; put reaction, intensity and reasoning AFTER it.
169
181
 
170
182
  RULES:
171
183
  - Extract MAX 20 items total (quality over quantity)
184
+ - Use EXACT names/versions/paths/numbers as they appear in the transcript —
185
+ never substitute what seems current or more standard (writing "Qwen3.6-27B"
186
+ when the transcript says "Qwen3.6-35B-A3B" is a fabrication)
172
187
  - Each must be useful if recalled in a future session
173
188
  - Skip: routine code edits, standard tool usage, trivial fixes
174
189
  - Include: architectural decisions, debugging breakthroughs, user preferences,
@@ -116,6 +116,20 @@ export interface TelemetryPayload {
116
116
  * the dashboard snapshot, not on the wire (mirrors `tokens_this_run`).
117
117
  */
118
118
  budget_exhausted?: boolean;
119
+ /**
120
+ * Backup stage outcome (#6, Phase 0B). Present on every full nightly (absent
121
+ * on capture-only / dry-run / client runs — no backup stage runs there).
122
+ * `ok` is false when EITHER the snapshot write OR the operator's
123
+ * `backupCommand` hook failed — the aggregate data-loss-risk signal (a
124
+ * sustained drop in `backup.ok` means offsite copies are silently not
125
+ * landing). `bytes` is the compressed artifact size (0 when the snapshot
126
+ * itself failed before producing a file). The operator's hook owns active
127
+ * alerting (email/Discord); this field is the passive fleet-health signal.
128
+ */
129
+ backup?: {
130
+ ok: boolean;
131
+ bytes: number;
132
+ };
119
133
  }
120
134
  /**
121
135
  * Check if telemetry is enabled. Disabled by:
@@ -92,7 +92,14 @@ function isTokenBudgetExceeded(stateDir) {
92
92
  * consistent with total (distill + consolidation).
93
93
  */
94
94
  function recordDistillUsage(stateDir, usage) {
95
- if (cap <= 0 || usage.total <= 0)
95
+ // Record ALWAYS; the cap only governs ENFORCEMENT (isTokenBudgetExceeded)
96
+ // and the 80% warning below. Without this, an uncapped install (self-hosted
97
+ // default) never accrues distill tokens into the period meter, so the
98
+ // dashboard's monthly headline read consolidation-only while the per-run
99
+ // chart next to it showed true (distill-inclusive) totals — numbers on the
100
+ // same card telling different stories (#287 CR). Always-recording makes the
101
+ // meter the honest "what did this install spend" number everywhere.
102
+ if (usage.total <= 0)
96
103
  return;
97
104
  let newTotal = 0;
98
105
  let periodStart = "";
@@ -123,7 +130,8 @@ function recordDistillUsage(stateDir, usage) {
123
130
  periodStart = s.llmTokensThisPeriod.periodStart;
124
131
  }, stateDir);
125
132
  // 80% warning — dedup per period (once per month per threshold crossing).
126
- if (periodStart && warnedPeriod !== periodStart && newTotal >= cap * 0.8) {
133
+ // Cap-gated by design: no cap no percentage to warn about.
134
+ if (cap > 0 && periodStart && warnedPeriod !== periodStart && newTotal >= cap * 0.8) {
127
135
  warnedPeriod = periodStart;
128
136
  const pct = Math.round((newTotal / cap) * 100);
129
137
  console.warn(`[hicortex] Token usage at ${pct}% of monthly cap (${newTotal.toLocaleString()}/${cap.toLocaleString()}).`);
@@ -78,10 +78,13 @@ function buildTypeClassifyPrompt(content) {
78
78
  `occurrence ("tried X, failed because Y", a correction, a debugging session).\n` +
79
79
  `- knowledge: a durable truth that holds across sessions, not tied to a ` +
80
80
  `single moment ("the API is at :8787", "uv is used for packages").\n` +
81
- `- decisions: a choice made that future work builds on and a later ` +
82
- `decision can supersede ("switched from gemma4 to qwen3.5", "adopted the ` +
83
- `graded-schema tag model"). Not knowledge (it can change) and not an ` +
84
- `experience (it persists).\n\n` +
81
+ `- decisions: a choice the user explicitly made or confirmed (or one the ` +
82
+ `memory records as actually carried out/applied) that future work builds ` +
83
+ `on and a later decision can supersede ("switched from gemma4 to qwen3.5", ` +
84
+ `"adopted the graded-schema tag model"). Not knowledge (it can change) and ` +
85
+ `not an experience (it persists). A bare AI recommendation or proposal is ` +
86
+ `NEVER a decision — "AI proposed X → user declined/held" is experience ` +
87
+ `(#290).\n\n` +
85
88
  `IMPORTANCE (0.0–1.0):\n` +
86
89
  `- 0.8–1.0: load-bearing — a core piece of knowledge or a decision the ` +
87
90
  `agent must know.\n` +
package/dist/types.d.ts CHANGED
@@ -434,6 +434,46 @@ export interface HicortexConfig {
434
434
  * set it. Period resets monthly (state.json `llmTokensThisPeriod.periodStart`).
435
435
  */
436
436
  llmTokensPerMonth?: number;
437
+ /**
438
+ * Max request body size in MB accepted by the REST/MCP server (#7). The body
439
+ * is fully JSON-parsed into memory before the distiller truncates content to
440
+ * 80K chars, so an unbounded body is an OOM vector — a tenant (hosted) or a
441
+ * misbehaving client could POST a huge payload to exhaust RAM. Default: 25 MB
442
+ * self-hosted (unchanged), 5 MB hosted (the spec §6.3 figure — ~25× the 200KB
443
+ * segment max, so legitimate capture never approaches it; it's a pure
444
+ * abuse/OOM backstop). Oversized bodies get HTTP 413.
445
+ */
446
+ distillBodyLimitMb?: number;
447
+ /**
448
+ * Output directory for backup artifacts (#6). Default `<HICORTEX_HOME>/backups`.
449
+ * Both the CLI (`hicortex backup`) and the nightly backup stage resolve this
450
+ * before calling createBackup; an unset value falls back to the home dir.
451
+ * Operator-owned: point at a mounted backup volume, a tmpfs, etc.
452
+ */
453
+ backupDir?: string;
454
+ /**
455
+ * Post-backup offsite hook (#6). When set, `hicortex backup` and the nightly
456
+ * backup stage invoke this command with the artifact path appended as the LAST
457
+ * arg (e.g. `"rclone copyto"` → `rclone copyto <path> remote:bucket/`). Cloud
458
+ * credentials + active alerting (email/Discord) stay in the operator's wrapper
459
+ * script, out of the product. The hook is split on whitespace (no shell); a
460
+ * command with quoted args containing spaces should be a wrapper script. A
461
+ * failing/missing/timed-out hook reports `{ok:false}` and never throws —
462
+ * capture/consolidation have already succeeded, so a backup-hook failure must
463
+ * not fail the nightly. 5 min timeout.
464
+ */
465
+ backupCommand?: string;
466
+ /**
467
+ * Account identity shown in the dashboard header (hosted). When ALL three
468
+ * are absent the header renders no account element (self-hosted default —
469
+ * nothing changes). Strings only; read via readStringConfig, null when
470
+ * absent/not a string. Set per-tenant by provision-tenant.sh.
471
+ */
472
+ displayName?: string;
473
+ /** Organization name — rendered alongside displayName as "Name · Org". */
474
+ orgName?: string;
475
+ /** Plan/tier label rendered as a small badge (e.g. "Cloud · Early bird"). */
476
+ planLabel?: string;
437
477
  }
438
478
  /** A config-owned life-sphere domain (see HicortexConfig.domains). */
439
479
  export interface DomainDef {
@@ -2,7 +2,7 @@
2
2
  "id": "hicortex",
3
3
  "name": "Hicortex — Long-term Memory That Learns",
4
4
  "description": "Your agents remember past decisions, avoid repeated mistakes, and get smarter every day. Nightly reflection generates actionable lessons that automatically update agent behavior.",
5
- "version": "0.10.0",
5
+ "version": "0.19.1",
6
6
  "kind": "lifecycle",
7
7
  "skills": ["./skills/hicortex-memory"],
8
8
  "configSchema": {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.18.3",
4
- "description": "Persistent agent identity for AI agents a hand-edited identity layer, nightly-distilled experience, and lessons injected every session, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
3
+ "version": "0.19.1",
4
+ "description": "Persistent agent identity for AI agents \u2014 a hand-edited identity layer, nightly-distilled experience, and lessons injected every session, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
7
7
  "hicortex": "dist/cli.js"
@@ -47,6 +47,7 @@
47
47
  "@types/better-sqlite3": "^7.6.0",
48
48
  "@types/express": "^5.0.6",
49
49
  "@types/node": "^22.0.0",
50
+ "@types/tar-stream": "^3.1.4",
50
51
  "typescript": "^5.4.0",
51
52
  "vitest": "^3.0.0"
52
53
  },
@@ -69,6 +70,7 @@
69
70
  "@modelcontextprotocol/sdk": "^1.28.0",
70
71
  "better-sqlite3": "^12.11.1",
71
72
  "express": "^4.21.0",
72
- "sqlite-vec": "^0.1.7"
73
+ "sqlite-vec": "^0.1.7",
74
+ "tar-stream": "^2.2.0"
73
75
  }
74
- }
76
+ }