@gamaze/hicortex 0.18.2 → 0.18.3

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.
@@ -3,7 +3,7 @@
3
3
  * Simplified from hicortex/distiller.py — messages come from agent_end hook,
4
4
  * not from filesystem scanning.
5
5
  */
6
- import type { LlmClient } from "./llm.js";
6
+ import type { LlmClient, LlmUsage } from "./llm.js";
7
7
  import { type RedactionConfig } from "./redact.js";
8
8
  /**
9
9
  * Estimate a safe chunk size in chars based on the LLM provider and model.
@@ -39,7 +39,9 @@ export declare function extractConversationText(messages: unknown[], redactionCo
39
39
  * discarded (full text). Callers use it to build a durable audit trail (#156);
40
40
  * omitting it leaves gate behaviour unchanged.
41
41
  */
42
- export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number, droppedOut?: string[]): Promise<DistilledEntry[]>;
42
+ export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number, droppedOut?: string[],
43
+ /** Called with each chunk's token usage (#5 budget metering). Optional. */
44
+ onUsage?: (usage: LlmUsage) => void): Promise<DistilledEntry[]>;
43
45
  /**
44
46
  * Reject ONLY structurally-empty distiller fragments before they become
45
47
  * memories (#156). The distiller occasionally emits leftovers that parse into
package/dist/distiller.js CHANGED
@@ -226,7 +226,9 @@ function extractConversationText(messages, redactionConfig) {
226
226
  * discarded (full text). Callers use it to build a durable audit trail (#156);
227
227
  * omitting it leaves gate behaviour unchanged.
228
228
  */
229
- async function distillSession(llm, conversation, projectName, date, chunkSizeChars, droppedOut) {
229
+ async function distillSession(llm, conversation, projectName, date, chunkSizeChars, droppedOut,
230
+ /** Called with each chunk's token usage (#5 budget metering). Optional. */
231
+ onUsage) {
230
232
  if (conversation.length < MIN_CONVERSATION_CHARS) {
231
233
  return [];
232
234
  }
@@ -239,7 +241,7 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
239
241
  const chunkSize = chunkSizeChars ?? MAX_TRANSCRIPT_CHARS;
240
242
  // If transcript fits in one chunk, distill directly (errors propagate)
241
243
  if (transcript.length <= chunkSize) {
242
- const { entries, dropped } = await distillChunk(llm, transcript, projectName, date);
244
+ const { entries, dropped } = await distillChunk(llm, transcript, projectName, date, onUsage);
243
245
  if (droppedOut)
244
246
  droppedOut.push(...dropped);
245
247
  return entries;
@@ -261,7 +263,7 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
261
263
  for (let i = 0; i < chunks.length; i++) {
262
264
  console.log(`[hicortex] Chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
263
265
  try {
264
- const { entries, dropped } = await distillChunk(llm, chunks[i], projectName, date);
266
+ const { entries, dropped } = await distillChunk(llm, chunks[i], projectName, date, onUsage);
265
267
  if (droppedOut)
266
268
  droppedOut.push(...dropped);
267
269
  for (const entry of entries) {
@@ -308,13 +310,19 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
308
310
  * `dropped` carries entries the substance gate rejected (full text) so the
309
311
  * caller can surface them in a durable audit trail (#156).
310
312
  */
311
- async function distillChunk(llm, transcript, projectName, date) {
313
+ async function distillChunk(llm, transcript, projectName, date, onUsage) {
312
314
  const prompt = (0, prompts_js_1.distillation)(projectName, date, transcript);
313
315
  // NOTE: Intentionally no try/catch here. Transient LLM errors (network
314
316
  // failures, 4xx/5xx, model-not-found, timeouts) propagate up to the caller
315
317
  // so the nightly pipeline can treat them as "retry later" instead of
316
318
  // "processed successfully with zero extractions".
317
- const { text: result } = await llm.completeDistill(prompt);
319
+ const { text: result, usage } = await llm.completeDistill(prompt);
320
+ // #5: report this chunk's token usage to the caller's budget meter. Optional
321
+ // (absent for callers that don't meter); a missing/undefined usage (claude-cli)
322
+ // is a no-op — consistent with the existing design that such tenants never
323
+ // trip a budget.
324
+ if (usage && onUsage)
325
+ onUsage(usage);
318
326
  if (!result)
319
327
  return { entries: [], dropped: [] };
320
328
  if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Hosted-mode boot assertions (#110 §1-§2, #271 — Phase 0B).
3
+ *
4
+ * Pure decision function — the side-effect (console.error + process.exit) is
5
+ * the caller's job (mcp-server.ts at boot), so the assertion logic is unit-
6
+ * testable in-process without spawning a child or intercepting process.exit.
7
+ *
8
+ * INERT unless hostedMode is true (self-hosted default). When true, the server
9
+ * must refuse to start under either condition:
10
+ * - HICORTEX_DB_PATH set (a tenant must not be redirectable to an attacker-
11
+ * chosen DB location — path-override attack);
12
+ * - the localhost auth-bypass marker file present (hosted is fail-closed —
13
+ * no bypass; a tenant dir provisioned from a restored tar could otherwise
14
+ * ship with the bypass active).
15
+ *
16
+ * Spec: specs/2026-07-27-hosted-service.md §1-§2 (Phase 0B, issue #271).
17
+ */
18
+ export interface HostedBootInput {
19
+ /** Resolved hostedMode flag from config (absent/false → self-hosted). */
20
+ hostedMode: boolean;
21
+ /** Whether HICORTEX_DB_PATH is currently set in the environment. */
22
+ dbPathEnvSet: boolean;
23
+ /** Whether the localhost-bypass marker file exists in the home dir. */
24
+ bypassMarkerPresent: boolean;
25
+ }
26
+ export type HostedBootDecision = {
27
+ ok: true;
28
+ hostedMode: boolean;
29
+ } | {
30
+ ok: false;
31
+ hostedMode: true;
32
+ reason: "db-path-override" | "bypass-marker";
33
+ message: string;
34
+ };
35
+ /**
36
+ * Decide whether the server may boot under hosted-mode constraints. Returns
37
+ * `{ok:true}` for self-hosted (always — assertions never fire) or hosted with
38
+ * a clean environment; returns `{ok:false, message}` when a hosted constraint
39
+ * is violated (caller logs + exits non-zero).
40
+ */
41
+ export declare function checkHostedBoot(input: HostedBootInput): HostedBootDecision;
42
+ /**
43
+ * Decide whether to emit the "Localhost auth bypass is disabled" boot warning
44
+ * (#271 — CR warning 4). Pure: the caller owns the console.warn side-effect,
45
+ * so this is unit-testable across the four input combinations without spawning
46
+ * a process or capturing stderr.
47
+ *
48
+ * Emits ONLY in self-hosted mode when the bypass marker is absent — the upgrade
49
+ * path (a user who upgraded without re-running init loses the bypass and sees
50
+ * 401s from localhost). Returns null in every other state:
51
+ * - self-hosted + marker present: bypass active, nothing to warn about;
52
+ * - hosted + marker absent: hosted is fail-closed by design, no bypass to warn;
53
+ * - hosted + marker present: checkHostedBoot already refused (unreachable here
54
+ * when called after a passed boot decision), and the failure message is the
55
+ * operator-facing one — a second warning would be noise.
56
+ *
57
+ * The marker is read from the canonical Hicortex home (HICORTEX_HOME), matching
58
+ * where `init` writes it — NOT from stateDir, which can drift when
59
+ * HICORTEX_DB_PATH relocates the DB (#271 CR warning 1).
60
+ */
61
+ export declare function shouldEmitBypassWarning(hostedMode: boolean, bypassMarkerPresent: boolean): string | null;
@@ -0,0 +1,72 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.checkHostedBoot = checkHostedBoot;
4
+ exports.shouldEmitBypassWarning = shouldEmitBypassWarning;
5
+ /**
6
+ * Decide whether the server may boot under hosted-mode constraints. Returns
7
+ * `{ok:true}` for self-hosted (always — assertions never fire) or hosted with
8
+ * a clean environment; returns `{ok:false, message}` when a hosted constraint
9
+ * is violated (caller logs + exits non-zero).
10
+ */
11
+ function checkHostedBoot(input) {
12
+ // KNOWN ESCAPE HATCH (CR M1, deferred to #110 Phase 0B item #2 — Docker):
13
+ // HICORTEX_HOME is the same class of env-var redirect as HICORTEX_DB_PATH
14
+ // (paths.ts honors it → a tenant who sets it points hostedMode/marker reads
15
+ // at an attacker-chosen dir with no config → hostedMode reads false → every
16
+ // assertion bypassed). It is NOT refused here because the per-tenant Docker
17
+ // template (#2) may legitimately use HICORTEX_HOME to give each tenant its
18
+ // own home dir. Resolution belongs with #2's tenant-home provisioning: either
19
+ // the orchestrator sanitizes HICORTEX_HOME (container sets it, tenant can't
20
+ // override), or this gate refuses it once the Docker design lands. Do NOT
21
+ // ship a hosted tenant before that decision is made.
22
+ const { hostedMode, dbPathEnvSet, bypassMarkerPresent } = input;
23
+ if (!hostedMode)
24
+ return { ok: true, hostedMode: false };
25
+ if (dbPathEnvSet) {
26
+ return {
27
+ ok: false,
28
+ hostedMode: true,
29
+ reason: "db-path-override",
30
+ message: `[hicortex] hostedMode is ON but HICORTEX_DB_PATH is set. ` +
31
+ `Hosted tenants must not allow DB-path overrides — refusing to start. ` +
32
+ `Unset HICORTEX_DB_PATH on hosted tenants.`,
33
+ };
34
+ }
35
+ if (bypassMarkerPresent) {
36
+ return {
37
+ ok: false,
38
+ hostedMode: true,
39
+ reason: "bypass-marker",
40
+ message: `[hicortex] hostedMode is ON but the localhost auth-bypass marker file ` +
41
+ `(.allow-localhost-bypass) is present. Hosted must be fail-closed — ` +
42
+ `refusing to start. Remove the marker file.`,
43
+ };
44
+ }
45
+ return { ok: true, hostedMode: true };
46
+ }
47
+ /**
48
+ * Decide whether to emit the "Localhost auth bypass is disabled" boot warning
49
+ * (#271 — CR warning 4). Pure: the caller owns the console.warn side-effect,
50
+ * so this is unit-testable across the four input combinations without spawning
51
+ * a process or capturing stderr.
52
+ *
53
+ * Emits ONLY in self-hosted mode when the bypass marker is absent — the upgrade
54
+ * path (a user who upgraded without re-running init loses the bypass and sees
55
+ * 401s from localhost). Returns null in every other state:
56
+ * - self-hosted + marker present: bypass active, nothing to warn about;
57
+ * - hosted + marker absent: hosted is fail-closed by design, no bypass to warn;
58
+ * - hosted + marker present: checkHostedBoot already refused (unreachable here
59
+ * when called after a passed boot decision), and the failure message is the
60
+ * operator-facing one — a second warning would be noise.
61
+ *
62
+ * The marker is read from the canonical Hicortex home (HICORTEX_HOME), matching
63
+ * where `init` writes it — NOT from stateDir, which can drift when
64
+ * HICORTEX_DB_PATH relocates the DB (#271 CR warning 1).
65
+ */
66
+ function shouldEmitBypassWarning(hostedMode, bypassMarkerPresent) {
67
+ if (!hostedMode && !bypassMarkerPresent) {
68
+ return ("[hicortex] Localhost auth bypass is disabled — run " +
69
+ "`npx @gamaze/hicortex init` to restore it.");
70
+ }
71
+ return null;
72
+ }
package/dist/init.d.ts CHANGED
@@ -289,6 +289,21 @@ export declare function getPackageSpec(configDir?: string): string;
289
289
  * later — the "looks configured but isn't" trap (#176). Never persist it.
290
290
  */
291
291
  export declare function isEphemeralNpxPath(binPath: string): boolean;
292
+ /**
293
+ * Build the PATH the launchd/systemd supervisors receive (#276). Order:
294
+ * 1. the binary's own dir — so a SIBLING node wins for nvm/volta/npm-global
295
+ * installs (the version the global was installed under);
296
+ * 2. the dir of the node the supervisor should run under — resolved via
297
+ * `which node` (the symlink path, stable across upgrades); see
298
+ * resolveNodeDir(). This is the generic rescue: for bun/pnpm/yarn globals
299
+ * the bin dir has NO node sibling, and on Apple Silicon node lives in
300
+ * /opt/homebrew/bin. Baking the resolved node dir in fixes every package
301
+ * manager without enumerating them;
302
+ * 3. the standard locations — including /opt/homebrew/bin (Apple Silicon
303
+ * homebrew) as a belt-and-suspenders fallback for the no-sibling case.
304
+ * Deduped (preserving first-seen order); empties dropped.
305
+ */
306
+ export declare function buildSupervisorPath(binaryArgs: string[]): string;
292
307
  /**
293
308
  * Install (or verify) the CC SessionStart hook that runs the canonical command
294
309
  * `hicortex learnings-identity` (aliased as the legacy `lessons-context`,
package/dist/init.js CHANGED
@@ -34,6 +34,7 @@ exports.writeClientConfig = writeClientConfig;
34
34
  exports.scaffoldDefaultDomains = scaffoldDefaultDomains;
35
35
  exports.getPackageSpec = getPackageSpec;
36
36
  exports.isEphemeralNpxPath = isEphemeralNpxPath;
37
+ exports.buildSupervisorPath = buildSupervisorPath;
37
38
  exports.installSessionStartHook = installSessionStartHook;
38
39
  exports.installRecallHooks = installRecallHooks;
39
40
  exports.runInit = runInit;
@@ -45,6 +46,7 @@ exports.formatOnCalendarLines = formatOnCalendarLines;
45
46
  exports.formatLaunchdIntervals = formatLaunchdIntervals;
46
47
  exports.formatSystemdTimerBody = formatSystemdTimerBody;
47
48
  const paths_js_1 = require("./paths.js");
49
+ const localhost_bypass_js_1 = require("./localhost-bypass.js");
48
50
  const telemetry_js_1 = require("./telemetry.js");
49
51
  const node_fs_1 = require("node:fs");
50
52
  const node_path_1 = require("node:path");
@@ -1134,6 +1136,10 @@ function getPackageSpec(configDir = HICORTEX_HOME) {
1134
1136
  function installDaemon() {
1135
1137
  const os = (0, node_os_1.platform)();
1136
1138
  const binaryArgs = resolveBinaryArgs();
1139
+ // #276: verify the supervisor can actually run (node resolvable on the
1140
+ // generated PATH) before writing the plist/unit — turns a silent DOA into a
1141
+ // loud install-time warning.
1142
+ verifySupervisorRuntime(binaryArgs);
1137
1143
  if (os === "darwin") {
1138
1144
  return installLaunchd(binaryArgs);
1139
1145
  }
@@ -1185,6 +1191,71 @@ function resolveBinaryArgs() {
1185
1191
  const packageSpec = getPackageSpec();
1186
1192
  return [npxPath, "-y", packageSpec];
1187
1193
  }
1194
+ /**
1195
+ * Build the PATH the launchd/systemd supervisors receive (#276). Order:
1196
+ * 1. the binary's own dir — so a SIBLING node wins for nvm/volta/npm-global
1197
+ * installs (the version the global was installed under);
1198
+ * 2. the dir of the node the supervisor should run under — resolved via
1199
+ * `which node` (the symlink path, stable across upgrades); see
1200
+ * resolveNodeDir(). This is the generic rescue: for bun/pnpm/yarn globals
1201
+ * the bin dir has NO node sibling, and on Apple Silicon node lives in
1202
+ * /opt/homebrew/bin. Baking the resolved node dir in fixes every package
1203
+ * manager without enumerating them;
1204
+ * 3. the standard locations — including /opt/homebrew/bin (Apple Silicon
1205
+ * homebrew) as a belt-and-suspenders fallback for the no-sibling case.
1206
+ * Deduped (preserving first-seen order); empties dropped.
1207
+ */
1208
+ function buildSupervisorPath(binaryArgs) {
1209
+ const binDir = (0, node_path_1.dirname)(binaryArgs[0]);
1210
+ const nodeDir = resolveNodeDir();
1211
+ return [binDir, nodeDir, "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"]
1212
+ .filter((d, i, a) => d && a.indexOf(d) === i)
1213
+ .join(":");
1214
+ }
1215
+ /**
1216
+ * Resolve the dir of the node the supervisor should use (#276). Prefers
1217
+ * `which node` — the SYMLINK path, stable across version upgrades (homebrew
1218
+ * rotates the Cellar target but keeps /opt/homebrew/bin/node) — over
1219
+ * process.execPath, which on macOS is the resolved realpath (the versioned
1220
+ * Cellar dir, e.g. /opt/homebrew/Cellar/node/X.Y.Z/bin) and STALES on a
1221
+ * `brew upgrade node`, re-introducing the silent-death the fix targets. Falls
1222
+ * back to process.execPath's dir only if `which node` is unavailable.
1223
+ */
1224
+ function resolveNodeDir() {
1225
+ try {
1226
+ const which = (0, node_child_process_1.execSync)("which node", { encoding: "utf-8", stdio: ["pipe", "pipe", "ignore"] }).trim();
1227
+ if (which)
1228
+ return (0, node_path_1.dirname)(which);
1229
+ }
1230
+ catch { /* node not on PATH — fall through to execPath */ }
1231
+ return (0, node_path_1.dirname)(process.execPath);
1232
+ }
1233
+ /** Dedup flag so the supervisor-runtime warning prints once per `init` run. */
1234
+ let supervisorRuntimeWarned = false;
1235
+ /**
1236
+ * Install-time smoke test (#276): spawn the resolved binary with the SAME PATH
1237
+ * the supervisor will use and confirm it can run (`--version`). Turns the
1238
+ * silent-dead-on-arrival case (node unresolvable under launchd's empty PATH →
1239
+ * the agent dies at the `#!/usr/bin/env node` shebang with exit 127, capture
1240
+ * stops silently, no signal in `status` because the shell PATH masks it) into a
1241
+ * LOUD install-time warning. Does NOT block install — the plist/unit is still
1242
+ * written so a PATH fix + reload recovers it without re-init.
1243
+ */
1244
+ function verifySupervisorRuntime(binaryArgs) {
1245
+ if (supervisorRuntimeWarned)
1246
+ return;
1247
+ const supervisorEnv = { ...process.env, PATH: buildSupervisorPath(binaryArgs) };
1248
+ try {
1249
+ (0, node_child_process_1.execSync)([...binaryArgs, "--version"].join(" "), { stdio: "pipe", env: supervisorEnv });
1250
+ }
1251
+ catch {
1252
+ supervisorRuntimeWarned = true;
1253
+ console.error(" ⚠ WARNING: the scheduled daemon/nightly could not run with the generated PATH — " +
1254
+ "`node` was not found, so the supervisor will fail silently at runtime (capture stops). " +
1255
+ "Reinstall via `npm install -g @gamaze/hicortex` (recommended) or ensure node is at a " +
1256
+ "standard location, then re-run `npx @gamaze/hicortex init`.");
1257
+ }
1258
+ }
1188
1259
  /**
1189
1260
  * Install (or verify) the CC SessionStart hook that runs the canonical command
1190
1261
  * `hicortex learnings-identity` (aliased as the legacy `lessons-context`,
@@ -1305,7 +1376,7 @@ function installLaunchd(binaryArgs) {
1305
1376
  // PATH must start with the binary's own directory so the sibling node
1306
1377
  // binary (correct version for nvm installs) is found first.
1307
1378
  // launchd has no PATH by default; without this, node itself won't be found.
1308
- const binDir = (0, node_path_1.dirname)(binaryArgs[0]);
1379
+ const supervisorPath = buildSupervisorPath(binaryArgs);
1309
1380
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
1310
1381
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1311
1382
  <plist version="1.0">
@@ -1327,7 +1398,7 @@ ${programArgs}
1327
1398
  <key>EnvironmentVariables</key>
1328
1399
  <dict>
1329
1400
  <key>PATH</key>
1330
- <string>${binDir}:/usr/local/bin:/usr/bin:/bin</string>
1401
+ <string>${supervisorPath}</string>
1331
1402
  </dict>
1332
1403
  </dict>
1333
1404
  </plist>`;
@@ -1354,7 +1425,7 @@ function installSystemd(binaryArgs) {
1354
1425
  const servicePath = (0, node_path_1.join)(unitDir, "hicortex.service");
1355
1426
  const execStart = [...binaryArgs, "server"].join(" ");
1356
1427
  // PATH must start with the binary's own directory (see installLaunchd for rationale).
1357
- const binDir = (0, node_path_1.dirname)(binaryArgs[0]);
1428
+ const supervisorPath = buildSupervisorPath(binaryArgs);
1358
1429
  const service = `[Unit]
1359
1430
  Description=Hicortex MCP server — long-term memory for AI agents
1360
1431
 
@@ -1365,7 +1436,7 @@ Restart=on-failure
1365
1436
  RestartSec=10
1366
1437
  StandardOutput=journal
1367
1438
  StandardError=journal
1368
- Environment=PATH=${binDir}:/usr/local/bin:/usr/bin:/bin
1439
+ Environment=PATH=${supervisorPath}
1369
1440
 
1370
1441
  [Install]
1371
1442
  WantedBy=default.target
@@ -1404,6 +1475,13 @@ async function runInit(options = {}) {
1404
1475
  // first — every writer downstream loads through loadConfigStrict.
1405
1476
  if (options.repairConfig) {
1406
1477
  quarantineMalformedConfig((0, node_path_1.join)(HICORTEX_HOME, "config.json"));
1478
+ // CR warning 2 (#271): repair-config is a plausible post-upgrade recovery
1479
+ // action, so it MUST (re)write the localhost-bypass marker itself — defensive
1480
+ // against a future early-return in this block. The full-init path writes it
1481
+ // again at line ~1615 (idempotent: same content, returns false the second
1482
+ // time). Never written in hosted mode (the boot assertion refuses to start
1483
+ // with the marker present).
1484
+ (0, localhost_bypass_js_1.writeLocalhostBypassMarker)(HICORTEX_HOME);
1407
1485
  }
1408
1486
  if (options.serverUrl) {
1409
1487
  await runClientInit(options.serverUrl, options.agentName);
@@ -1500,6 +1578,16 @@ async function runInit(options = {}) {
1500
1578
  // Classification activates automatically once an LLM is configured; until
1501
1579
  // then domains sit inert (strict-skip path).
1502
1580
  scaffoldDefaultDomains(configPath);
1581
+ // Write the localhost auth-bypass marker (#110 §2, #271 — Phase 0B). The
1582
+ // bypass is marker-gated from 0.18: self-hosted init writes the marker so
1583
+ // existing installs keep the bypass after upgrade + re-init; a hosted tenant
1584
+ // dir is fail-closed by default. Idempotent (overwrites an existing marker,
1585
+ // refreshing the note). Never written in hosted mode (the boot assertion
1586
+ // would refuse to start with the marker present).
1587
+ const markerCreated = (0, localhost_bypass_js_1.writeLocalhostBypassMarker)(HICORTEX_HOME);
1588
+ if (markerCreated) {
1589
+ console.log(" ✓ Localhost auth-bypass marker written");
1590
+ }
1503
1591
  // Per-agent identity id (#179): server mode writes it ONLY when the operator
1504
1592
  // passes --agent-name. Without the flag no agentName is written and the
1505
1593
  // co-located CC shares the global identity (global by default). Explicit flag
@@ -1944,9 +2032,12 @@ function formatSystemdTimerBody(isInterval, intervalSec, hours, jitterSec) {
1944
2032
  */
1945
2033
  function writeScheduleUnit(opts) {
1946
2034
  const binaryArgs = resolveBinaryArgs();
2035
+ // #276: verify the scheduled nightly/capture can run before writing its unit.
2036
+ verifySupervisorRuntime(binaryArgs);
1947
2037
  const os = (0, node_os_1.platform)();
1948
- // PATH must start with the binary's own directory (see installLaunchd for rationale).
1949
- const binDir = (0, node_path_1.dirname)(binaryArgs[0]);
2038
+ // PATH the supervisor receives — includes the dir of the node running init
2039
+ // (process.execPath) so bun/pnpm/yarn globals resolve node under launchd (#276).
2040
+ const supervisorPath = buildSupervisorPath(binaryArgs);
1950
2041
  // One canonical nightly log path across platforms — status output, docs,
1951
2042
  // and support instructions all reference this single location.
1952
2043
  const logPath = (0, node_path_1.join)(HICORTEX_HOME, "nightly.log");
@@ -1999,7 +2090,7 @@ ${scheduleBlock}
1999
2090
  <key>EnvironmentVariables</key>
2000
2091
  <dict>
2001
2092
  <key>PATH</key>
2002
- <string>${binDir}:/usr/local/bin:/usr/bin:/bin</string>
2093
+ <string>${supervisorPath}</string>
2003
2094
  </dict>
2004
2095
  </dict>
2005
2096
  </plist>`;
@@ -2033,7 +2124,7 @@ Type=oneshot
2033
2124
  ExecStart=${execStart}
2034
2125
  ${opts.timeoutMin ? `TimeoutStartSec=${opts.timeoutMin}min\n` : ""}StandardOutput=append:${logPath}
2035
2126
  StandardError=append:${logPath}
2036
- Environment=PATH=${binDir}:/usr/local/bin:/usr/bin:/bin
2127
+ Environment=PATH=${supervisorPath}
2037
2128
  Environment=HOME=${(0, node_os_1.homedir)()}
2038
2129
  WorkingDirectory=${(0, node_os_1.homedir)()}`;
2039
2130
  // Timer body: OnUnitActiveSec (interval, watchdog) or one OnCalendar line
@@ -0,0 +1,27 @@
1
+ /** Marker filename inside the Hicortex home dir. */
2
+ export declare const LOCALHOST_BYPASS_MARKER = ".allow-localhost-bypass";
3
+ /** Marker contents — a one-line note. Its mere PRESENCE is the signal. */
4
+ export declare const LOCALHOST_BYPASS_MARKER_CONTENT: string;
5
+ /**
6
+ * Resolve the marker file path for a given home dir. Defaults to the canonical
7
+ * Hicortex home (honors HICORTEX_HOME), so callers in tests can point the env
8
+ * override at a temp dir.
9
+ */
10
+ export declare function localhostBypassMarkerPath(home?: string): string;
11
+ /**
12
+ * Does the localhost auth-bypass marker exist? Pure filesystem check — no
13
+ * logging, no side-effects. Used by both createAuthMiddleware (gates the
14
+ * bypass per-request via a boot-time capture in mcp-server.ts) and the
15
+ * hosted-mode boot assertion.
16
+ */
17
+ export declare function localhostBypassEnabled(home?: string): boolean;
18
+ /**
19
+ * Write the localhost auth-bypass marker file (self-hosted init only — never
20
+ * in hosted mode). Idempotent: overwrites an existing marker so a re-init
21
+ * refreshes the explanatory note. Ensures the parent dir exists. Does NOT
22
+ * touch auth or any other config — just the one marker file.
23
+ *
24
+ * Returns true when a NEW marker was created (for init's "✓" reporting), false
25
+ * when one already existed (refreshed in place).
26
+ */
27
+ export declare function writeLocalhostBypassMarker(home?: string): boolean;
@@ -0,0 +1,71 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.LOCALHOST_BYPASS_MARKER_CONTENT = exports.LOCALHOST_BYPASS_MARKER = void 0;
4
+ exports.localhostBypassMarkerPath = localhostBypassMarkerPath;
5
+ exports.localhostBypassEnabled = localhostBypassEnabled;
6
+ exports.writeLocalhostBypassMarker = writeLocalhostBypassMarker;
7
+ /**
8
+ * Localhost auth-bypass marker file (#110 §2, #271 — Phase 0B).
9
+ *
10
+ * The localhost auth bypass in createAuthMiddleware (viz.ts) is marker-GATED
11
+ * from 0.18: it applies ONLY when this marker file exists in the Hicortex
12
+ * home dir. Self-hosted `init` writes the marker, so existing installs keep
13
+ * working after upgrade + re-init; a hosted tenant dir provisioned by any
14
+ * means (script, hand, restored tar) is fail-closed by default — no marker,
15
+ * no bypass, every connection (localhost included) needs the bearer token.
16
+ *
17
+ * Rationale (spec 2026-07-27 §2): with the bypass unconditional, a future
18
+ * `trust proxy` enablement would make `req.ip` header-spoofable and the
19
+ * bypass remotely triggerable. Inverting the default to "off unless marked"
20
+ * makes the bypass opt-in via a filesystem side-effect of self-hosted init,
21
+ * so a tenant home built from a bare config + DB restore cannot accidentally
22
+ * ship with the bypass active. The hosted-mode boot assertion (mcp-server.ts)
23
+ * refuses to start if BOTH hostedMode=true AND the marker is present, so even
24
+ * a stray marker cannot open a hosted tenant.
25
+ *
26
+ * Marker file name: `.allow-localhost-bypass` (dot-prefixed; not a secret —
27
+ * its mere presence is the signal; no contents needed).
28
+ */
29
+ const node_fs_1 = require("node:fs");
30
+ const node_path_1 = require("node:path");
31
+ const paths_js_1 = require("./paths.js");
32
+ /** Marker filename inside the Hicortex home dir. */
33
+ exports.LOCALHOST_BYPASS_MARKER = ".allow-localhost-bypass";
34
+ /** Marker contents — a one-line note. Its mere PRESENCE is the signal. */
35
+ exports.LOCALHOST_BYPASS_MARKER_CONTENT = "# Written by `hicortex init` (self-hosted). Opt-in to the localhost auth\n" +
36
+ "# bypass. DELETE this file to require the bearer token on localhost too\n" +
37
+ "# (fail-closed). Hosted-mode (hostedMode:true) refuses to start with this\n" +
38
+ "# marker present — see specs/2026-07-27-hosted-service.md §2.\n";
39
+ /**
40
+ * Resolve the marker file path for a given home dir. Defaults to the canonical
41
+ * Hicortex home (honors HICORTEX_HOME), so callers in tests can point the env
42
+ * override at a temp dir.
43
+ */
44
+ function localhostBypassMarkerPath(home = (0, paths_js_1.hicortexHome)()) {
45
+ return (0, node_path_1.join)(home, exports.LOCALHOST_BYPASS_MARKER);
46
+ }
47
+ /**
48
+ * Does the localhost auth-bypass marker exist? Pure filesystem check — no
49
+ * logging, no side-effects. Used by both createAuthMiddleware (gates the
50
+ * bypass per-request via a boot-time capture in mcp-server.ts) and the
51
+ * hosted-mode boot assertion.
52
+ */
53
+ function localhostBypassEnabled(home = (0, paths_js_1.hicortexHome)()) {
54
+ return (0, node_fs_1.existsSync)(localhostBypassMarkerPath(home));
55
+ }
56
+ /**
57
+ * Write the localhost auth-bypass marker file (self-hosted init only — never
58
+ * in hosted mode). Idempotent: overwrites an existing marker so a re-init
59
+ * refreshes the explanatory note. Ensures the parent dir exists. Does NOT
60
+ * touch auth or any other config — just the one marker file.
61
+ *
62
+ * Returns true when a NEW marker was created (for init's "✓" reporting), false
63
+ * when one already existed (refreshed in place).
64
+ */
65
+ function writeLocalhostBypassMarker(home = (0, paths_js_1.hicortexHome)()) {
66
+ const markerPath = localhostBypassMarkerPath(home);
67
+ const existed = (0, node_fs_1.existsSync)(markerPath);
68
+ (0, node_fs_1.mkdirSync)((0, node_path_1.dirname)(markerPath), { recursive: true });
69
+ (0, node_fs_1.writeFileSync)(markerPath, exports.LOCALHOST_BYPASS_MARKER_CONTENT, { mode: 0o644 });
70
+ return !existed;
71
+ }
@@ -59,6 +59,10 @@ const db_js_1 = require("./db.js");
59
59
  const llm_js_1 = require("./llm.js");
60
60
  const features_js_1 = require("./features.js");
61
61
  const config_read_js_1 = require("./config-read.js");
62
+ const localhost_bypass_js_1 = require("./localhost-bypass.js");
63
+ const hosted_boot_js_1 = require("./hosted-boot.js");
64
+ const token_budget_js_1 = require("./token-budget.js");
65
+ const paths_js_1 = require("./paths.js");
62
66
  const state_js_1 = require("./state.js");
63
67
  const embedder_js_1 = require("./embedder.js");
64
68
  const storage = __importStar(require("./storage.js"));
@@ -406,6 +410,59 @@ function createMcpServer() {
406
410
  async function startServer(options = {}) {
407
411
  const port = options.port ?? 8787;
408
412
  const host = options.host ?? "0.0.0.0";
413
+ // ---------------------------------------------------------------------------
414
+ // Hosted-mode boot gate (#110 §1-§2, #271 — Phase 0B).
415
+ //
416
+ // MUST run BEFORE resolveDbPath/initDb: in hosted mode with HICORTEX_DB_PATH
417
+ // set, the server must refuse the attacker-chosen DB location WITHOUT first
418
+ // touching it. The hosted signals (hostedMode from config, bypassMarkerPresent
419
+ // from the marker file) do NOT depend on the DB, so reading them now is safe.
420
+ // CR warning 3: this block was previously after initDb, letting a hostile
421
+ // HICORTEX_DB_PATH create/touch a file at the chosen path before the gate.
422
+ //
423
+ // CR warning 1: the marker is a HOME-level file (like config.json, written by
424
+ // init to HICORTEX_HOME). Read it from hicortexHome() — NOT stateDir, which
425
+ // is dirname(dbPath) and drifts when HICORTEX_DB_PATH relocates the DB. The
426
+ // config key hostedMode likewise lives at <hicortexHome>/config.json.
427
+ //
428
+ // Decision logic lives in hosted-boot.ts (pure, unit-tested); the side-effect
429
+ // (console.error + process.exit) is local to boot. The marker state is
430
+ // captured once here and reused below to gate the localhost bypass in
431
+ // createAuthMiddleware (no per-request stat). CR warning 4: the upgrade-path
432
+ // warning is decided by the pure shouldEmitBypassWarning helper (behavior-
433
+ // tested), not an inline branch.
434
+ const bootConfig = readConfigFile((0, paths_js_1.hicortexHome)());
435
+ const hostedMode = (0, config_read_js_1.readStrictBoolean)(bootConfig ?? {}, "hostedMode") === true;
436
+ let bypassMarkerPresent = (0, localhost_bypass_js_1.localhostBypassEnabled)();
437
+ const bootDecision = (0, hosted_boot_js_1.checkHostedBoot)({
438
+ hostedMode,
439
+ dbPathEnvSet: !!process.env.HICORTEX_DB_PATH,
440
+ bypassMarkerPresent,
441
+ });
442
+ if (!bootDecision.ok) {
443
+ console.error(bootDecision.message);
444
+ process.exit(1);
445
+ }
446
+ // Upgrade migration (CR S1): self-hosted server-mode CC MCP registration
447
+ // carries NO bearer token (init.ts:192 — only client-mode adds the header),
448
+ // so it relies entirely on the localhost bypass. An existing install that
449
+ // upgrades without re-running init has no marker → the bypass silently
450
+ // disappears → every server-mode CC MCP call 401s. Auto-write the marker on
451
+ // first post-upgrade boot in self-hosted mode to preserve the prior
452
+ // unconditional-bypass behaviour. Hosted mode is untouched: checkHostedBoot
453
+ // refuses to start with a marker present, so this block — gated on
454
+ // !hostedMode — never runs for a hosted tenant. bypassMarkerPresent is
455
+ // reassigned so createAuthMiddleware below gates the bypass for THIS boot
456
+ // too (the file write and the in-memory flag stay in sync).
457
+ if (!hostedMode && !bypassMarkerPresent) {
458
+ (0, localhost_bypass_js_1.writeLocalhostBypassMarker)((0, paths_js_1.hicortexHome)());
459
+ bypassMarkerPresent = true;
460
+ console.log("[hicortex] Localhost auth-bypass marker written (upgrade migration).");
461
+ }
462
+ const bypassWarning = (0, hosted_boot_js_1.shouldEmitBypassWarning)(hostedMode, bypassMarkerPresent);
463
+ if (bypassWarning) {
464
+ console.warn(bypassWarning);
465
+ }
409
466
  // Initialize core
410
467
  const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
411
468
  console.log(`[hicortex] Initializing database at ${dbPath}`);
@@ -431,6 +488,11 @@ async function startServer(options = {}) {
431
488
  const { agentId } = (0, init_js_1.ensureAndPersistAgentId)((0, node_path_1.join)(stateDir, "config.json"));
432
489
  savedConfig.agentId = agentId;
433
490
  }
491
+ // #5: token-budget enforcement. Mode-agnostic — gates on cap > 0. Self-hosted
492
+ // uses config llmTokensPerMonth (default 0 = off); hosted uses HICORTEX_TOKEN_CAP
493
+ // env (provider-set, tenant-immutable) which takes precedence. Initialised here
494
+ // (after stateDir + savedConfig are known) so the warn-dedup can seed from state.
495
+ (0, token_budget_js_1.initTokenBudget)(stateDir, savedConfig?.llmTokensPerMonth);
434
496
  if (savedConfig?.llmBackend === "claude-cli") {
435
497
  const claudePath = (0, llm_js_1.findClaudeBinary)();
436
498
  if (claudePath) {
@@ -615,7 +677,7 @@ async function startServer(options = {}) {
615
677
  // /dashboard has its own shell-exemption pattern. Gives the console one entry
616
678
  // point: http://<host>:8787/ → /dashboard.
617
679
  app.get("/", (_req, res) => res.redirect("/dashboard"));
618
- app.use((0, viz_js_1.createAuthMiddleware)(authToken, authTokenPrevious));
680
+ app.use((0, viz_js_1.createAuthMiddleware)(authToken, authTokenPrevious, bypassMarkerPresent));
619
681
  // SSE transport management — each connection gets its own McpServer instance
620
682
  const transports = new Map();
621
683
  // Health endpoint — PUBLIC minimal probe. Unauthenticated (the auth
@@ -1027,12 +1089,27 @@ async function startServer(options = {}) {
1027
1089
  const sourcePrefix = session_id
1028
1090
  ? `${session_id}${segment_id ? `#${segment_id}` : ""}`
1029
1091
  : undefined;
1092
+ // #5: declared outside the try so the finally can record tokens spent even
1093
+ // when distillSession throws partway through (the LLM calls already happened).
1094
+ let distillUsage = { prompt: 0, completion: 0, total: 0 };
1030
1095
  try {
1096
+ // #5: token-budget gate — refuse (429) BEFORE the LLM call if the tenant is
1097
+ // already at/over the monthly cap. Placed after the dedup short-circuits so
1098
+ // a skipped duplicate neither trips the gate nor consumes budget. The client
1099
+ // capture loop holds its cursor on 429 (dup-over-loss, capture.ts:303).
1100
+ if ((0, token_budget_js_1.isTokenBudgetExceeded)(stateDir)) {
1101
+ res.status(429).json({ error: "token budget exceeded", retry: "next billing period" });
1102
+ return;
1103
+ }
1031
1104
  // Collect gate-dropped entries so they can ride back in the response and
1032
1105
  // land in the caller's file-persisted nightly log (#156 audit trail); the
1033
1106
  // server-side per-entry console.log in distillChunk stays as well.
1034
1107
  const dropped = [];
1035
- const entries = await (0, distiller_js_1.distillSession)(llm, conversationText, project ?? "unknown", date, chunkSize, dropped);
1108
+ const entries = await (0, distiller_js_1.distillSession)(llm, conversationText, project ?? "unknown", date, chunkSize, dropped, (u) => {
1109
+ distillUsage.prompt += u.prompt_tokens ?? 0;
1110
+ distillUsage.completion += u.completion_tokens ?? 0;
1111
+ distillUsage.total += u.total_tokens ?? 0;
1112
+ });
1036
1113
  // Phase 1 — embed every chunk up front (async). If ANY embed fails we
1037
1114
  // never reach the insert, so nothing is stored.
1038
1115
  const createdAt = new Date(date).toISOString();
@@ -1090,6 +1167,14 @@ async function startServer(options = {}) {
1090
1167
  res.status(500).json({ error: "Distillation failed" });
1091
1168
  console.error(`[hicortex] /distill: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1092
1169
  }
1170
+ finally {
1171
+ // #5: record tokens spent against the monthly budget — in finally so a
1172
+ // mid-distil throw (some chunks' LLM calls already happened) still counts.
1173
+ // No-op when cap=0 (enforcement off) or distillUsage.total=0 (gate refused
1174
+ // / no chunk reached an LLM call).
1175
+ if (distillUsage.total > 0)
1176
+ (0, token_budget_js_1.recordDistillUsage)(stateDir, distillUsage);
1177
+ }
1093
1178
  });
1094
1179
  // -------------------------------------------------------------------------
1095
1180
  // REST /update — update a memory (and re-embed when content changes).
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Resolve the effective cap: env (HICORTEX_TOKEN_CAP) takes precedence over the
3
+ * config key. A positive, finite env wins; otherwise the config value (0/absent
4
+ * = unlimited). Pure — exported for tests.
5
+ */
6
+ export declare function resolveTokenCap(configCap: unknown): number;
7
+ /**
8
+ * Initialise at server boot (after stateDir is known). Resolves + caches the cap
9
+ * and seeds the 80%-warn dedup so a restart mid-period doesn't re-warn.
10
+ */
11
+ export declare function initTokenBudget(stateDir: string, configCap: unknown): void;
12
+ /** The resolved monthly cap (0 = unlimited / enforcement off). */
13
+ export declare function getTokenCap(): number;
14
+ /**
15
+ * Pre-call check for /distill: refuse (429) when the tenant is already at/over
16
+ * the monthly cap. Reuses `shouldThrottleTokens(cap, period, 0)` — lastRunTokens
17
+ * is 0 because we cannot predict a call's cost before making it, so this refuses
18
+ * only when already over (a tenant exactly at the cap is refused on the next
19
+ * call). Reads state.json fresh so the nightly process's writes are reflected.
20
+ */
21
+ export declare function isTokenBudgetExceeded(stateDir: string): boolean;
22
+ /**
23
+ * After a successful distill, add the consumed tokens to the monthly counter and
24
+ * emit the 80% warning once per period. Synchronous read-modify-write via
25
+ * `updateState` (serializes concurrent in-process /distill; picks up the nightly
26
+ * process's writes via the fresh read). Accumulates the full breakdown
27
+ * (prompt/completion/total) so the dashboard's prompt+completion stays
28
+ * consistent with total (distill + consolidation).
29
+ */
30
+ export declare function recordDistillUsage(stateDir: string, usage: {
31
+ prompt: number;
32
+ completion: number;
33
+ total: number;
34
+ }): void;
@@ -0,0 +1,131 @@
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
+ if (cap <= 0 || usage.total <= 0)
96
+ return;
97
+ let newTotal = 0;
98
+ let periodStart = "";
99
+ (0, state_js_1.updateState)((s) => {
100
+ const prev = s.llmTokensThisPeriod;
101
+ // Monthly reset (year+month) — matches shouldThrottleTokens's staleness check.
102
+ const stale = !prev?.periodStart ||
103
+ new Date(prev.periodStart).getUTCFullYear() !== new Date().getUTCFullYear() ||
104
+ new Date(prev.periodStart).getUTCMonth() !== new Date().getUTCMonth();
105
+ if (stale) {
106
+ s.llmTokensThisPeriod = {
107
+ prompt: usage.prompt,
108
+ completion: usage.completion,
109
+ total: usage.total,
110
+ periodStart: new Date().toISOString(),
111
+ };
112
+ }
113
+ else {
114
+ const base = prev;
115
+ s.llmTokensThisPeriod = {
116
+ prompt: (base.prompt ?? 0) + usage.prompt,
117
+ completion: (base.completion ?? 0) + usage.completion,
118
+ total: (base.total ?? 0) + usage.total,
119
+ periodStart: base.periodStart,
120
+ };
121
+ }
122
+ newTotal = s.llmTokensThisPeriod.total;
123
+ periodStart = s.llmTokensThisPeriod.periodStart;
124
+ }, stateDir);
125
+ // 80% warning — dedup per period (once per month per threshold crossing).
126
+ if (periodStart && warnedPeriod !== periodStart && newTotal >= cap * 0.8) {
127
+ warnedPeriod = periodStart;
128
+ const pct = Math.round((newTotal / cap) * 100);
129
+ console.warn(`[hicortex] Token usage at ${pct}% of monthly cap (${newTotal.toLocaleString()}/${cap.toLocaleString()}).`);
130
+ }
131
+ }
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).
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,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.18.2",
3
+ "version": "0.18.3",
4
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.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {