@gamaze/hicortex 0.18.1 → 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.
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
@@ -117,7 +117,7 @@ async function fetchLessonsBlock(cfg) {
117
117
  parts.push("BEFORE making decisions, search memory: `hicortex_search` for prior decisions on the same topic.");
118
118
  parts.push("Use `hicortex_recent` at session start for recent project state.");
119
119
  if (lessonLines.length > 0) {
120
- parts.push("", "### Lessons (updated nightly)");
120
+ parts.push("", "### Learnings (updated nightly)");
121
121
  parts.push(...lessonLines);
122
122
  }
123
123
  const { index } = data;
@@ -125,16 +125,16 @@ async function fetchLessonsBlock(cfg) {
125
125
  parts.push("", "### Memory Index");
126
126
  for (const domain of moduleIndex.domains) {
127
127
  const kwStr = domain.keywords.length > 0 ? `: ${domain.keywords.join(", ")}` : "";
128
- parts.push(`${domain.name} (${domain.memoryCount} memories, ${domain.lessonCount} lessons)${kwStr}`);
128
+ parts.push(`${domain.name} (${domain.memoryCount} memories, ${domain.lessonCount} Learnings)${kwStr}`);
129
129
  if (domain.projects.length > 0)
130
130
  parts.push(` ${domain.projects.join(" | ")}`);
131
131
  }
132
- parts.push(`${index.total} memories, ${index.lessonCount} lessons, ${index.sourceCount} agents. Search with \`hicortex_search\`.`);
132
+ parts.push(`${index.total} memories, ${index.lessonCount} Learnings, ${index.sourceCount} agents. Search with \`hicortex_search\`.`);
133
133
  }
134
134
  else if (index.projects.length > 0) {
135
135
  parts.push("", "### Memory Index");
136
136
  parts.push(index.projects.map(p => `${p.name}: ${p.count}`).join(" | "));
137
- parts.push(`${index.total} memories, ${index.lessonCount} lessons, ${index.sourceCount} agents. Search with \`hicortex_search\`.`);
137
+ parts.push(`${index.total} memories, ${index.lessonCount} Learnings, ${index.sourceCount} agents. Search with \`hicortex_search\`.`);
138
138
  }
139
139
  return parts.join("\n");
140
140
  }
@@ -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"));
@@ -183,10 +187,10 @@ function createMcpServer() {
183
187
  }
184
188
  });
185
189
  // -- hicortex_ingest --
186
- server.tool("hicortex_ingest", "Store a new memory in long-term storage. Use for important facts, decisions, or lessons.", {
190
+ server.tool("hicortex_ingest", "Store a new memory in long-term storage. Use for Knowledge, Decisions, or Learnings.", {
187
191
  content: zod_1.z.string().describe("Memory content to store"),
188
192
  project: zod_1.z.string().optional().describe("Project this memory belongs to"),
189
- memory_type: zod_1.z.enum(["episode", "lesson", "fact", "decision"]).optional().describe("Type of memory (default: episode)"),
193
+ memory_type: zod_1.z.enum(["knowledge", "experience", "decisions", "learnings", "fact", "episode", "decision", "lesson"]).optional().describe("Type of memory (default: Experience). Accepted: Knowledge/Experience/Decisions/Learnings (legacy raw enum also accepted, normalized to the canonical term)."),
190
194
  }, async ({ content, project, memory_type }) => {
191
195
  if (!db)
192
196
  return { content: [{ type: "text", text: "Hicortex not initialized" }], isError: true };
@@ -195,7 +199,8 @@ function createMcpServer() {
195
199
  const id = storage.insertMemory(db, content, embedding, {
196
200
  sourceAgent: "claude-code/manual",
197
201
  project,
198
- memoryType: memory_type ?? "episode",
202
+ // Normalize legacy raw enum to the canonical term the DB stores.
203
+ memoryType: memory_type ? (0, type_labels_js_1.normalizeMemoryType)(memory_type) : "experience",
199
204
  });
200
205
  return { content: [{ type: "text", text: `Memory stored (id: ${id.slice(0, 8)})` }] };
201
206
  }
@@ -208,7 +213,7 @@ function createMcpServer() {
208
213
  id: zod_1.z.string().describe("Memory ID (from search results, first 8 chars or full UUID)"),
209
214
  content: zod_1.z.string().optional().describe("New content text"),
210
215
  project: zod_1.z.string().optional().describe("New project name"),
211
- memory_type: zod_1.z.enum(["episode", "lesson", "fact", "decision"]).optional().describe("New memory type"),
216
+ memory_type: zod_1.z.enum(["knowledge", "experience", "decisions", "learnings", "fact", "episode", "decision", "lesson"]).optional().describe("New memory type. Accepted: Knowledge/Experience/Decisions/Learnings (legacy raw enum also accepted, normalized to the canonical term)."),
212
217
  }, async ({ id, content, project, memory_type }) => {
213
218
  if (!db)
214
219
  return { content: [{ type: "text", text: "Hicortex not initialized" }], isError: true };
@@ -222,8 +227,9 @@ function createMcpServer() {
222
227
  fields.content = content;
223
228
  if (project !== undefined)
224
229
  fields.project = project;
230
+ // Normalize legacy raw enum to canonical human terms before DB write.
225
231
  if (memory_type !== undefined)
226
- fields.memory_type = memory_type;
232
+ fields.memory_type = (0, type_labels_js_1.normalizeMemoryType)(memory_type);
227
233
  if (Object.keys(fields).length === 0) {
228
234
  return { content: [{ type: "text", text: "No fields to update" }], isError: true };
229
235
  }
@@ -319,7 +325,7 @@ function createMcpServer() {
319
325
  const moduleIndex = state.moduleIndex;
320
326
  if (moduleIndex && moduleIndex.domains.length > 0) {
321
327
  const text = moduleIndex.domains.map((d) => {
322
- const head = `**${d.name}** (${d.memoryCount} memories, ${d.lessonCount} lessons)`;
328
+ const head = `**${d.name}** (${d.memoryCount} memories, ${d.lessonCount} Learnings)`;
323
329
  // Content-based domains carry a description and no projects; legacy
324
330
  // project-grouping domains carry a project list + keywords.
325
331
  if (d.description && d.projects.length === 0) {
@@ -404,6 +410,59 @@ function createMcpServer() {
404
410
  async function startServer(options = {}) {
405
411
  const port = options.port ?? 8787;
406
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
+ }
407
466
  // Initialize core
408
467
  const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
409
468
  console.log(`[hicortex] Initializing database at ${dbPath}`);
@@ -429,6 +488,11 @@ async function startServer(options = {}) {
429
488
  const { agentId } = (0, init_js_1.ensureAndPersistAgentId)((0, node_path_1.join)(stateDir, "config.json"));
430
489
  savedConfig.agentId = agentId;
431
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);
432
496
  if (savedConfig?.llmBackend === "claude-cli") {
433
497
  const claudePath = (0, llm_js_1.findClaudeBinary)();
434
498
  if (claudePath) {
@@ -613,7 +677,7 @@ async function startServer(options = {}) {
613
677
  // /dashboard has its own shell-exemption pattern. Gives the console one entry
614
678
  // point: http://<host>:8787/ → /dashboard.
615
679
  app.get("/", (_req, res) => res.redirect("/dashboard"));
616
- app.use((0, viz_js_1.createAuthMiddleware)(authToken, authTokenPrevious));
680
+ app.use((0, viz_js_1.createAuthMiddleware)(authToken, authTokenPrevious, bypassMarkerPresent));
617
681
  // SSE transport management — each connection gets its own McpServer instance
618
682
  const transports = new Map();
619
683
  // Health endpoint — PUBLIC minimal probe. Unauthenticated (the auth
@@ -692,11 +756,15 @@ async function startServer(options = {}) {
692
756
  res.status(400).json({ error: "Missing or invalid 'content' field" });
693
757
  return;
694
758
  }
695
- const validTypes = ["episode", "lesson", "fact", "decision"];
759
+ const validTypes = type_labels_js_1.ACCEPTED_MEMORY_TYPES;
696
760
  if (memory_type && !validTypes.includes(memory_type)) {
697
761
  res.status(400).json({ error: `Invalid memory_type: ${memory_type}` });
698
762
  return;
699
763
  }
764
+ // Normalize legacy raw enum (fact/episode/decision/lesson) to the
765
+ // canonical term the DB stores (knowledge/experience/decisions/learnings).
766
+ // Canonical values pass through unchanged.
767
+ const normalizedType = memory_type ? (0, type_labels_js_1.normalizeMemoryType)(memory_type) : memory_type;
700
768
  // Dedup by source_session (idempotent — skip if already ingested)
701
769
  if (source_session) {
702
770
  const existing = db.prepare("SELECT COUNT(*) as cnt FROM memories WHERE source_session = ?").get(source_session);
@@ -714,7 +782,7 @@ async function startServer(options = {}) {
714
782
  sourceDomain: typeof source_domain === "string" ? source_domain : null,
715
783
  sourceSession: source_session ?? undefined,
716
784
  project: project ?? undefined,
717
- memoryType: memory_type ?? "episode",
785
+ memoryType: normalizedType ?? "experience",
718
786
  // 0.16.x: privacy defaults to null (vestigial column). A legacy client
719
787
  // that sends an explicit value is honored; absent → null.
720
788
  privacy: typeof privacy === "string" ? privacy : null,
@@ -1021,12 +1089,27 @@ async function startServer(options = {}) {
1021
1089
  const sourcePrefix = session_id
1022
1090
  ? `${session_id}${segment_id ? `#${segment_id}` : ""}`
1023
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 };
1024
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
+ }
1025
1104
  // Collect gate-dropped entries so they can ride back in the response and
1026
1105
  // land in the caller's file-persisted nightly log (#156 audit trail); the
1027
1106
  // server-side per-entry console.log in distillChunk stays as well.
1028
1107
  const dropped = [];
1029
- 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
+ });
1030
1113
  // Phase 1 — embed every chunk up front (async). If ANY embed fails we
1031
1114
  // never reach the insert, so nothing is stored.
1032
1115
  const createdAt = new Date(date).toISOString();
@@ -1061,9 +1144,9 @@ async function startServer(options = {}) {
1061
1144
  sourceSession: sourcePrefix ? `${sourcePrefix}#${i}` : undefined,
1062
1145
  project: project ?? undefined,
1063
1146
  // #216: the distiller now classifies each entry as
1064
- // episode/fact/decision via the [E]/[F]/[D] tag parsed in
1147
+ // experience/knowledge/decisions via the [E]/[K]/[D] tag parsed in
1065
1148
  // distiller.ts. Pre-#216 distiller output (no tag) defaults to
1066
- // episode in the parser, so this is backward compatible.
1149
+ // experience in the parser, so this is backward compatible.
1067
1150
  memoryType,
1068
1151
  // 0.16.x: privacy defaults to null (vestigial column). A legacy
1069
1152
  // client that sends an explicit value is honored; absent → null.
@@ -1084,6 +1167,14 @@ async function startServer(options = {}) {
1084
1167
  res.status(500).json({ error: "Distillation failed" });
1085
1168
  console.error(`[hicortex] /distill: ${err instanceof Error ? (err.stack ?? err.message) : String(err)}`);
1086
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
+ }
1087
1178
  });
1088
1179
  // -------------------------------------------------------------------------
1089
1180
  // REST /update — update a memory (and re-embed when content changes).
@@ -1111,19 +1202,24 @@ async function startServer(options = {}) {
1111
1202
  fields.content = content;
1112
1203
  if (project !== undefined)
1113
1204
  fields.project = project;
1114
- if (memory_type !== undefined)
1115
- fields.memory_type = memory_type;
1116
1205
  if (privacy !== undefined)
1117
1206
  fields.privacy = privacy;
1118
- if (Object.keys(fields).length === 0) {
1119
- res.status(400).json({ error: "No fields to update" });
1120
- return;
1121
- }
1122
- const validTypes = ["episode", "lesson", "fact", "decision"];
1207
+ // Validate + normalize memory_type BEFORE adding to `fields` so the
1208
+ // empty-fields check below correctly counts a memory_type-only update.
1209
+ const validTypes = type_labels_js_1.ACCEPTED_MEMORY_TYPES;
1123
1210
  if (memory_type !== undefined && !validTypes.includes(memory_type)) {
1124
1211
  res.status(400).json({ error: `Invalid memory_type: ${memory_type}` });
1125
1212
  return;
1126
1213
  }
1214
+ // Normalize legacy raw enum (fact/episode/decision/lesson) to the
1215
+ // canonical term the DB stores (knowledge/experience/decisions/learnings).
1216
+ // Canonical values pass through unchanged.
1217
+ if (memory_type !== undefined)
1218
+ fields.memory_type = (0, type_labels_js_1.normalizeMemoryType)(memory_type);
1219
+ if (Object.keys(fields).length === 0) {
1220
+ res.status(400).json({ error: "No fields to update" });
1221
+ return;
1222
+ }
1127
1223
  try {
1128
1224
  storage.updateMemory(db, fullId, fields);
1129
1225
  // Re-embed when content changes
package/dist/prompts.js CHANGED
@@ -48,7 +48,7 @@ function reflection(memoriesBlock, recentLessons) {
48
48
  Like human learning: we grow fastest when we reinforce what works AND correct what doesn't. A system that only learns from mistakes becomes overly cautious. A system that only learns from successes never improves. The combination multiplies.
49
49
 
50
50
  GENERALITY BAR (read carefully — the most important rule):
51
- Every lesson MUST be a generalizable operating principle that transfers across contexts, agents, and projects. It is NOT: an incident report, a changelog entry, a one-event fact, a tool-specific recipe, or a note about a named entity. If a memory is only interesting as "what happened today", it is an EPISODE — do not emit a lesson for it. Abstract away specific tool names, hostnames, and incident details from the lesson text; state the transferable rule.
51
+ Every lesson MUST be a generalizable operating principle that transfers across contexts, agents, and projects. It is NOT: an incident report, a changelog entry, a one-event fact, a tool-specific recipe, or a note about a named entity. If a memory is only interesting as "what happened today", it is an EXPERIENCE — do not emit a lesson for it. Abstract away specific tool names, hostnames, and incident details from the lesson text; state the transferable rule.
52
52
 
53
53
  Quality over quantity. 1-3 lessons is typical. An empty array [] is the CORRECT response when memories show routine competent work without noteworthy patterns, surprises, or friction. Do not manufacture lessons from nothing.
54
54
 
@@ -121,8 +121,8 @@ EXTRACT into this markdown format:
121
121
  ### Decisions Made
122
122
  - [D] [SUBJECT]: [decision] — [reasoning] (${date})
123
123
 
124
- ### Facts Learned
125
- - [F] [SUBJECT]: [fact] — [context/source] (${date})
124
+ ### Knowledge Learned
125
+ - [K] [SUBJECT]: [knowledge] — [context/source] (${date})
126
126
 
127
127
  ### Problems & Solutions
128
128
  - [E] [SUBJECT]: [problem] → [solution that worked] (${date})
@@ -131,7 +131,7 @@ EXTRACT into this markdown format:
131
131
  - [D] [SUBJECT]: [what changed], [from → to] (${date})
132
132
 
133
133
  ### Key Entities & Relationships
134
- - [F] [entity A] → [relationship] → [entity B] (${date})
134
+ - [K] [entity A] → [relationship] → [entity B] (${date})
135
135
 
136
136
  ### Corrections & Rejections
137
137
  - [E] [SUBJECT]: [what AI proposed] → [why rejected/corrected] → [what user wanted instead] (${date})
@@ -139,18 +139,18 @@ EXTRACT into this markdown format:
139
139
  user corrections of AI assumptions, quality complaints like "too verbose")
140
140
 
141
141
  TYPE TAG (critical — prefix EVERY bullet with exactly one letter + space):
142
- - [E] EPISODE — a specific event, interaction, or narrative: "tried X, failed
142
+ - [E] EXPERIENCE — a specific event, interaction, or narrative: "tried X, failed
143
143
  because Y", a correction, a debugging session, a one-time occurrence. The
144
144
  DEFAULT when in doubt.
145
- - [F] FACT — a durable truth that will hold across sessions: "the API is at
145
+ - [K] KNOWLEDGE — a durable truth that will hold across sessions: "the API is at
146
146
  :8787", "uv is used for packages", "config lives in ~/.hicortex/". Not tied
147
147
  to a single moment.
148
- - [D] DECISION — a choice made that future work builds on, and that a later
148
+ - [D] DECISIONS — a choice made that future work builds on, and that a later
149
149
  decision can SUPERSEDE: "switched from gemma4 to qwen3.5", "adopted the
150
- graded-schema tag model". Not a fact (it can change) and not an episode
150
+ graded-schema tag model". Not knowledge (it can change) and not experience
151
151
  (it persists and constrains).
152
- - NEVER use [L] (lesson). Lessons are extracted by a SEPARATE reflection stage,
153
- not here. If the model emits [L], it is wrong — re-tag as episode/fact/decision.
152
+ - NEVER use [L] (learnings). Learnings are extracted by a SEPARATE reflection stage,
153
+ not here. If the model emits [L], it is wrong — re-tag as experience/knowledge/decisions.
154
154
  The type tag goes BEFORE the subject, never as a section/category bracket.
155
155
 
156
156
  TOPIC-FIRST RULE (critical — read carefully):
@@ -159,8 +159,8 @@ concrete thing it is about — the system, file, component, decision area, or
159
159
  entity. The subject is what a future reader would search for.
160
160
  - Write: "[E] Electrical load calculation: don't bundle unknown loads into one figure — user rejected the estimate"
161
161
  - NOT: "[E] User rejected AI's bundling of unknown loads"
162
- - Write: "[F] Nightly capture (Hermes): cron sessions are excluded — source='cron' is skipped before distillation"
163
- - NOT: "[F] Discovered that cron sessions are filtered out"
162
+ - Write: "[K] Nightly capture (Hermes): cron sessions are excluded — source='cron' is skipped before distillation"
163
+ - NOT: "[K] Discovered that cron sessions are filtered out"
164
164
  Reason: each item's first words (after the type tag) become the memory's one-line
165
165
  index entry AND dominate its search embedding. An item that opens with a category
166
166
  label, a sentiment ("Strong Negative"), or "User rejected…" is unfindable — it