@gamaze/hicortex 0.18.2 → 0.19.0

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.
@@ -0,0 +1,139 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveTokenCap = resolveTokenCap;
4
+ exports.initTokenBudget = initTokenBudget;
5
+ exports.getTokenCap = getTokenCap;
6
+ exports.isTokenBudgetExceeded = isTokenBudgetExceeded;
7
+ exports.recordDistillUsage = recordDistillUsage;
8
+ /**
9
+ * Per-tenant monthly token-budget enforcement (#110 Phase 0B item #5).
10
+ *
11
+ * Limits LLM token consumption over /distill (the cost-generating path the
12
+ * nightly consolidation throttle did NOT cover). Mode-agnostic — gates on
13
+ * `cap > 0`, never on `hostedMode`:
14
+ * - Self-hosted: the cap is the operator's own `llmTokensPerMonth` config
15
+ * (default 0 = unlimited → never throttles). Protects the operator's wallet
16
+ * from a runaway nightly on an expensive model.
17
+ * - Hosted: the cap is provider-set via the `HICORTEX_TOKEN_CAP` env, which
18
+ * takes PRECEDENCE over config. The tenant process cannot mutate boot-time
19
+ * env, so a hosted tenant cannot raise its own cap (the config.json
20
+ * self-edit loophole is closed). Protects the provider's wallet.
21
+ *
22
+ * Reuses the existing machinery: `shouldThrottleTokens` (consolidate.ts) for the
23
+ * decision (incl. monthly reset), and `llmTokensThisPeriod` + `updateState`
24
+ * (state.ts) for the counter + atomic persistence.
25
+ *
26
+ * Concurrency: state.json is read fresh for each check and written via
27
+ * `updateState` (a synchronous read-modify-write; Node's single thread
28
+ * serializes concurrent /distill calls within the server process, so no
29
+ * in-process tally is needed). The nightly consolidation is a SEPARATE process
30
+ * that also writes state.json; a rare cross-process write collision can lose a
31
+ * small increment — negligible on a multi-million-token monthly budget. A
32
+ * DB-backed counter (WAL transactions serialize across processes) is the future
33
+ * hardening if it ever matters.
34
+ */
35
+ const state_js_1 = require("./state.js");
36
+ const consolidate_js_1 = require("./consolidate.js");
37
+ /** Env override (hosted: provider-set, tenant-immutable at runtime). */
38
+ const TOKEN_CAP_ENV = "HICORTEX_TOKEN_CAP";
39
+ let cap = 0;
40
+ /** periodStart we last emitted the 80% warning at, to dedup within a period. */
41
+ let warnedPeriod = null;
42
+ /**
43
+ * Resolve the effective cap: env (HICORTEX_TOKEN_CAP) takes precedence over the
44
+ * config key. A positive, finite env wins; otherwise the config value (0/absent
45
+ * = unlimited). Pure — exported for tests.
46
+ */
47
+ function resolveTokenCap(configCap) {
48
+ const envCap = Number(process.env[TOKEN_CAP_ENV]);
49
+ if (Number.isFinite(envCap) && envCap > 0)
50
+ return envCap;
51
+ const cfg = Number(configCap);
52
+ return Number.isFinite(cfg) && cfg > 0 ? cfg : 0;
53
+ }
54
+ /**
55
+ * Initialise at server boot (after stateDir is known). Resolves + caches the cap
56
+ * and seeds the 80%-warn dedup so a restart mid-period doesn't re-warn.
57
+ */
58
+ function initTokenBudget(stateDir, configCap) {
59
+ cap = resolveTokenCap(configCap);
60
+ if (cap > 0) {
61
+ const p = (0, state_js_1.loadState)(stateDir).llmTokensThisPeriod;
62
+ warnedPeriod = p && p.total >= cap * 0.8 ? (p.periodStart ?? null) : null;
63
+ // Label the source truthfully: only claim env if the env value was actually
64
+ // used (a malformed env falls back to config, so the label must not lie).
65
+ const fromEnv = Number(process.env[TOKEN_CAP_ENV]) === cap;
66
+ console.log(`[hicortex] Token budget: ${cap.toLocaleString()}/month${fromEnv ? " (HICORTEX_TOKEN_CAP)" : ""}`);
67
+ }
68
+ }
69
+ /** The resolved monthly cap (0 = unlimited / enforcement off). */
70
+ function getTokenCap() {
71
+ return cap;
72
+ }
73
+ /**
74
+ * Pre-call check for /distill: refuse (429) when the tenant is already at/over
75
+ * the monthly cap. Reuses `shouldThrottleTokens(cap, period, 0)` — lastRunTokens
76
+ * is 0 because we cannot predict a call's cost before making it, so this refuses
77
+ * only when already over (a tenant exactly at the cap is refused on the next
78
+ * call). Reads state.json fresh so the nightly process's writes are reflected.
79
+ */
80
+ function isTokenBudgetExceeded(stateDir) {
81
+ if (cap <= 0)
82
+ return false;
83
+ const period = (0, state_js_1.loadState)(stateDir).llmTokensThisPeriod;
84
+ return (0, consolidate_js_1.shouldThrottleTokens)(cap, period, 0).throttle;
85
+ }
86
+ /**
87
+ * After a successful distill, add the consumed tokens to the monthly counter and
88
+ * emit the 80% warning once per period. Synchronous read-modify-write via
89
+ * `updateState` (serializes concurrent in-process /distill; picks up the nightly
90
+ * process's writes via the fresh read). Accumulates the full breakdown
91
+ * (prompt/completion/total) so the dashboard's prompt+completion stays
92
+ * consistent with total (distill + consolidation).
93
+ */
94
+ function recordDistillUsage(stateDir, usage) {
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)
103
+ return;
104
+ let newTotal = 0;
105
+ let periodStart = "";
106
+ (0, state_js_1.updateState)((s) => {
107
+ const prev = s.llmTokensThisPeriod;
108
+ // Monthly reset (year+month) — matches shouldThrottleTokens's staleness check.
109
+ const stale = !prev?.periodStart ||
110
+ new Date(prev.periodStart).getUTCFullYear() !== new Date().getUTCFullYear() ||
111
+ new Date(prev.periodStart).getUTCMonth() !== new Date().getUTCMonth();
112
+ if (stale) {
113
+ s.llmTokensThisPeriod = {
114
+ prompt: usage.prompt,
115
+ completion: usage.completion,
116
+ total: usage.total,
117
+ periodStart: new Date().toISOString(),
118
+ };
119
+ }
120
+ else {
121
+ const base = prev;
122
+ s.llmTokensThisPeriod = {
123
+ prompt: (base.prompt ?? 0) + usage.prompt,
124
+ completion: (base.completion ?? 0) + usage.completion,
125
+ total: (base.total ?? 0) + usage.total,
126
+ periodStart: base.periodStart,
127
+ };
128
+ }
129
+ newTotal = s.llmTokensThisPeriod.total;
130
+ periodStart = s.llmTokensThisPeriod.periodStart;
131
+ }, stateDir);
132
+ // 80% warning — dedup per period (once per month per threshold crossing).
133
+ // Cap-gated by design: no cap → no percentage to warn about.
134
+ if (cap > 0 && periodStart && warnedPeriod !== periodStart && newTotal >= cap * 0.8) {
135
+ warnedPeriod = periodStart;
136
+ const pct = Math.round((newTotal / cap) * 100);
137
+ console.warn(`[hicortex] Token usage at ${pct}% of monthly cap (${newTotal.toLocaleString()}/${cap.toLocaleString()}).`);
138
+ }
139
+ }
@@ -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
@@ -412,6 +412,15 @@ export interface HicortexConfig {
412
412
  * so these were not surfacing in the top-k anyway).
413
413
  */
414
414
  memorySoftCap?: number;
415
+ /**
416
+ * Hosted-service mode (issue #110, #271 — spec 2026-07-27 §1-§2). When true,
417
+ * the server enforces hosted-tenant constraints at boot: it refuses to start
418
+ * if `HICORTEX_DB_PATH` is set (path-override attacks) or if the localhost
419
+ * auth-bypass marker file is present (hosted must be fail-closed — no bypass).
420
+ * Absent/false (the self-hosted default) → the assertions never fire and
421
+ * behaviour is unchanged. Read at server boot via readStrictBoolean.
422
+ */
423
+ hostedMode?: boolean;
415
424
  /**
416
425
  * Monthly fair-use ceiling on consolidation LLM token consumption (#246).
417
426
  * Default `0` = unlimited (the self-hosted default — no cap, never throttled).
@@ -425,6 +434,46 @@ export interface HicortexConfig {
425
434
  * set it. Period resets monthly (state.json `llmTokensThisPeriod.periodStart`).
426
435
  */
427
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;
428
477
  }
429
478
  /** A config-owned life-sphere domain (see HicortexConfig.domains). */
430
479
  export interface DomainDef {
package/dist/viz.d.ts CHANGED
@@ -45,8 +45,16 @@ export declare const VIZ_VENDOR_FILES: ReadonlySet<string>;
45
45
  * always evaluated (no short-circuit), so a caller cannot learn WHICH token
46
46
  * matched from the response timing. Absent/empty `authTokenPrevious` behaves
47
47
  * exactly as the single-token middleware always has.
48
+ *
49
+ * `allowLocalhostBypass` (0.18, #110 §2/#271): when false (or omitted), the
50
+ * localhost bypass is DISABLED — localhost connections need the bearer token
51
+ * like any other (fail-closed). When true, localhost loopback (127.0.0.1,
52
+ * ::1, ::ffff:127.0.0.1) bypasses auth as before. The marker file
53
+ * `~/.hicortex/.allow-localhost-bypass` (written by self-hosted init) gates
54
+ * this — a hosted tenant dir is fail-closed by default. mcp-server.ts captures
55
+ * the marker state once at boot and passes it in (no per-request stat).
48
56
  */
49
- export declare function createAuthMiddleware(authToken: string | undefined, authTokenPrevious?: string): express.RequestHandler;
57
+ export declare function createAuthMiddleware(authToken: string | undefined, authTokenPrevious?: string, allowLocalhostBypass?: boolean): express.RequestHandler;
50
58
  /**
51
59
  * Resolve the on-disk path of the viz page. Throws (fail explicitly) when the
52
60
  * asset is missing — a broken install should surface, not degrade silently.
package/dist/viz.js CHANGED
@@ -92,9 +92,18 @@ function safeBearerMatch(headerValue, expectedToken) {
92
92
  * always evaluated (no short-circuit), so a caller cannot learn WHICH token
93
93
  * matched from the response timing. Absent/empty `authTokenPrevious` behaves
94
94
  * exactly as the single-token middleware always has.
95
+ *
96
+ * `allowLocalhostBypass` (0.18, #110 §2/#271): when false (or omitted), the
97
+ * localhost bypass is DISABLED — localhost connections need the bearer token
98
+ * like any other (fail-closed). When true, localhost loopback (127.0.0.1,
99
+ * ::1, ::ffff:127.0.0.1) bypasses auth as before. The marker file
100
+ * `~/.hicortex/.allow-localhost-bypass` (written by self-hosted init) gates
101
+ * this — a hosted tenant dir is fail-closed by default. mcp-server.ts captures
102
+ * the marker state once at boot and passes it in (no per-request stat).
95
103
  */
96
- function createAuthMiddleware(authToken, authTokenPrevious) {
104
+ function createAuthMiddleware(authToken, authTokenPrevious, allowLocalhostBypass) {
97
105
  const previous = authTokenPrevious && authTokenPrevious.length > 0 ? authTokenPrevious : undefined;
106
+ const bypassEnabled = allowLocalhostBypass === true;
98
107
  return (req, res, next) => {
99
108
  if (req.path === "/health")
100
109
  return next();
@@ -142,7 +151,7 @@ function createAuthMiddleware(authToken, authTokenPrevious) {
142
151
  return next();
143
152
  }
144
153
  const ip = req.ip ?? req.socket.remoteAddress ?? "";
145
- if (ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1")
154
+ if (bypassEnabled && (ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1"))
146
155
  return next();
147
156
  // Constant-time bearer check (#254). When authTokenPrevious is set, BOTH
148
157
  // tokens are compared every request (no short-circuit) so timing cannot
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.18.2",
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.0",
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
+ }