@gamaze/hicortex 0.16.0 → 0.16.2

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.
Files changed (43) hide show
  1. package/README.md +9 -0
  2. package/dist/capture.d.ts +18 -1
  3. package/dist/capture.js +3 -2
  4. package/dist/classify-domains.d.ts +1 -1
  5. package/dist/classify-domains.js +5 -7
  6. package/dist/cli.js +10 -2
  7. package/dist/cluster.d.ts +5 -4
  8. package/dist/cluster.js +2 -3
  9. package/dist/consolidate.js +6 -5
  10. package/dist/db.js +23 -0
  11. package/dist/dedup.js +1 -1
  12. package/dist/distiller.js +19 -12
  13. package/dist/domain-classify.d.ts +1 -1
  14. package/dist/domain-classify.js +1 -5
  15. package/dist/eval/relevance-eval.d.ts +64 -0
  16. package/dist/eval/relevance-eval.js +1954 -0
  17. package/dist/eval/run-eval.js +0 -1
  18. package/dist/index.js +3 -3
  19. package/dist/init.d.ts +165 -0
  20. package/dist/init.js +283 -57
  21. package/dist/lessons-context.js +3 -2
  22. package/dist/mcp-server.js +72 -25
  23. package/dist/nightly.js +35 -3
  24. package/dist/nofit.d.ts +1 -1
  25. package/dist/nofit.js +1 -2
  26. package/dist/prompts.js +22 -13
  27. package/dist/recall-index.d.ts +56 -21
  28. package/dist/recall-index.js +51 -29
  29. package/dist/retrieval.d.ts +7 -7
  30. package/dist/retrieval.js +20 -24
  31. package/dist/schema-prototypes.d.ts +8 -13
  32. package/dist/schema-prototypes.js +13 -22
  33. package/dist/seed-lesson.d.ts +1 -1
  34. package/dist/seed-lesson.js +1 -2
  35. package/dist/storage.d.ts +9 -12
  36. package/dist/storage.js +19 -21
  37. package/dist/types.d.ts +47 -23
  38. package/domains.example.json +2 -3
  39. package/hermes-plugin/hicortex/README.md +3 -1
  40. package/hermes-plugin/hicortex/config.py +33 -2
  41. package/hermes-plugin/hicortex/plugin.yaml +1 -1
  42. package/hermes-plugin/hicortex/provider.py +5 -0
  43. package/package.json +2 -1
@@ -65,7 +65,6 @@ function renderDups(d) {
65
65
  const mismatch = cluster.metadataMismatch;
66
66
  const mismatchFlags = [
67
67
  mismatch.projectMismatch ? "project" : null,
68
- mismatch.privacyMismatch ? "privacy" : null,
69
68
  mismatch.sourceAgentMismatch ? "source_agent" : null,
70
69
  ].filter(Boolean);
71
70
  lines.push(`**Cluster ${i + 1}** — size ${cluster.size}, attribution: ${cluster.attribution.recoveryReingest} recovery-pair(s) / ${cluster.attribution.organic} organic-pair(s)` +
package/dist/index.js CHANGED
@@ -163,10 +163,11 @@ async function buildLessonsBlock(project) {
163
163
  if (selected.length === 0)
164
164
  return null;
165
165
  const formatted = selected.map((l) => {
166
- const titleMatch = l.content.match(/## Lesson: (.+)/);
167
166
  const typeMatch = l.content.match(/\*\*Type:\*\* (\w+)/);
168
167
  const severityMatch = l.content.match(/\*\*Severity:\*\* (\w+)/);
169
- const title = titleMatch ? titleMatch[1] : l.content.slice(0, 150);
168
+ // First line, with any legacy `## Lesson:` prefix stripped — new lessons
169
+ // are stored topic-first without the prefix (memory_type carries the type).
170
+ const title = l.content.replace(/^##\s*Lesson:\s*/i, "").split("\n")[0].slice(0, 150);
170
171
  const meta = [severityMatch?.[1], typeMatch?.[1]].filter(Boolean).join(", ");
171
172
  return `- ${title}${meta ? ` (${meta})` : ""}`;
172
173
  });
@@ -469,7 +470,6 @@ exports.default = {
469
470
  source_agent: `openclaw/${context?.agentId ?? "manual"}`,
470
471
  project: args.project,
471
472
  memory_type: args.memory_type ?? "episode",
472
- privacy: "WORK",
473
473
  }, 15000);
474
474
  if (!result.ok) {
475
475
  return { error: `Ingest failed: ${result.data?.error ?? `HTTP ${result.status}`}` };
package/dist/init.d.ts CHANGED
@@ -37,6 +37,84 @@ export declare function parseEnvFile(content: string): Record<string, string>;
37
37
  * a `models.score` would then silently shadow (nested > flat).
38
38
  */
39
39
  export declare function isLlmConfigured(config: Record<string, unknown>): boolean;
40
+ /**
41
+ * Detect or ask for LLM config and persist to ~/.hicortex/config.json.
42
+ * The daemon can't inherit shell env vars, so we persist here.
43
+ * LLM choice is always user-controlled: candidates are detected and presented
44
+ * as a numbered list; the user picks one. Nothing is auto-applied.
45
+ * If the user cancels, the server runs recall-only (no LLM).
46
+ */
47
+ export declare function persistLlmConfig(configPath?: string): Promise<void>;
48
+ /**
49
+ * Strict config loader — the SINGLE source of truth for "read config.json or
50
+ * fail loudly". Every config writer in init (persistLlmConfig, persistAuthToken,
51
+ * ensureAndPersistAgentId, scaffoldDefaultDomains) loads through this, and the
52
+ * runtime readers (nightly, server boot) route through it too (catching the
53
+ * throw to fail-soft with a visible WARN).
54
+ *
55
+ * The 0.16.x BLOCKER this closes: the bare `try { JSON.parse(readFileSync) }
56
+ * catch { /* new file *\/ }` pattern, on a config.json that EXISTS but won't
57
+ * parse (a hand-edit syntax slip, truncation, corruption), silently seeded `{}`
58
+ * and the writer then OVERWROTE the file — `persistAuthToken` minted a fresh
59
+ * token (fleet-wide 401), `scaffoldDefaultDomains` re-seeded the generic
60
+ * vocabulary over the owner list, etc. `authToken` / `licenseKey` /
61
+ * `distillApiKey` / `domains` / `weakPrimaryFloor` / `contextClients` all gone.
62
+ * The early-return guards (existing-key checks) did NOT save them: those only
63
+ * fire on a VALID parse that reads the key, not on a corrupted file.
64
+ *
65
+ * Contract:
66
+ * - ENOENT (genuinely no file) → `{ config: {}, hadFile: false }` (a new
67
+ * install; the caller decides whether to persist).
68
+ * - Any OTHER read failure on an existing file (EACCES, etc.) → THROW.
69
+ * - A parse failure (bad JSON) on an existing file → THROW.
70
+ * - A non-object JSON value (null / array / true / 5 / "x") → THROW. Such a
71
+ * value is not a valid config and must not be silently replaced with {}.
72
+ *
73
+ * Refusing is the right DEFAULT, but it dead-ends the operator: `init` is
74
+ * exactly what you would run to repair a broken install, and it now won't run.
75
+ * `init --repair-config` is the explicit escape hatch — see
76
+ * quarantineMalformedConfig below. Never quarantine implicitly: that path mints
77
+ * a fresh authToken (fleet-wide 401), so it must be a deliberate choice.
78
+ *
79
+ * Exported so nightly.ts / mcp-server.ts readers can route through it.
80
+ */
81
+ export declare function loadConfigStrict(configPath: string): {
82
+ config: Record<string, unknown>;
83
+ hadFile: boolean;
84
+ };
85
+ /**
86
+ * `init --repair-config` escape hatch: move a malformed config.json aside so
87
+ * init can rebuild, instead of dead-ending on loadConfigStrict's throw.
88
+ *
89
+ * Why this exists: refusing to overwrite a corrupt config is right (it closed
90
+ * the 0.16.x wipe BLOCKER), but it leaves the operator stuck — `init` is the
91
+ * natural repair action and it now refuses to run. Deleting the file by hand
92
+ * works but silently loses `licenseKey` / `authToken` / `distillApiKey`.
93
+ *
94
+ * Why it is OPT-IN and never automatic: rebuilding mints a fresh `authToken`,
95
+ * which 401s every thin client on the fleet until they are re-pointed. That is
96
+ * a deliberate operator decision, not a fallback.
97
+ *
98
+ * Behaviour:
99
+ * - Config absent or valid → no-op (`{ quarantined: false }`).
100
+ * - Malformed → rename to `config.json.corrupt-<ISO>` (colons stripped for
101
+ * Windows), and report the TOP-LEVEL KEY NAMES recovered from the raw text
102
+ * so the operator knows what to restore.
103
+ *
104
+ * SECURITY: key NAMES only, never values. `authToken`, `licenseKey`,
105
+ * `distillApiKey` and `reflectApiKey` are secrets — printing them would leak
106
+ * into terminal scrollback, CI logs, and screen shares. The operator reads the
107
+ * values out of the backup file themselves.
108
+ *
109
+ * Exported for testability.
110
+ */
111
+ export declare function quarantineMalformedConfig(configPath: string): {
112
+ quarantined: false;
113
+ } | {
114
+ quarantined: true;
115
+ backupPath: string;
116
+ keys: string[];
117
+ };
40
118
  /**
41
119
  * Generate a random auth token in the format hctx-<32 hex chars>.
42
120
  * Exported for testability.
@@ -52,6 +130,56 @@ export declare function persistAuthToken(configPath: string): {
52
130
  token: string;
53
131
  generated: boolean;
54
132
  };
133
+ /**
134
+ * Ensure a stable per-install `agentId` UUID is set on a config object.
135
+ *
136
+ * The id is the client's attribution identity (stored on each captured
137
+ * memory as `source_agent_id`) — it survives agent/machine renames, unlike
138
+ * the readable `source_agent` name. Generated once, never rotated (idempotent:
139
+ * an existing valid `agentId` is always kept). Pure: mutates `config` in place
140
+ * and does NO file IO — BOTH server and client init call this on their
141
+ * in-memory config object before saving (the server path loads/saves
142
+ * config.json around it; the client builds in-memory and saves once).
143
+ * Exported for testability.
144
+ */
145
+ export declare function ensureAgentId(config: Record<string, unknown>): {
146
+ agentId: string;
147
+ generated: boolean;
148
+ };
149
+ /**
150
+ * Load → ensure → persist wrapper for the `agentId` provenance field. This is
151
+ * the runtime activation path: `ensureAgentId` was historically called ONLY
152
+ * inside `init`, so pre-0.16.2 installs that already ran init never get an
153
+ * `agentId` written → nightly + server boot capture sent `source_agent_id:
154
+ * null` forever (the feature was inert for the entire existing fleet). Both
155
+ * nightly and server boot call THIS on startup so the field self-heals on the
156
+ * first run after upgrade — one read, one conditional write, idempotent.
157
+ *
158
+ * Built on the pure `ensureAgentId` (which init's client path and the unit
159
+ * test still call directly); this wrapper adds the disk IO.
160
+ *
161
+ * Hardening (0.16.x CR BLOCKER): the naive "try { read } catch { seed {} }"
162
+ * + unconditional save WIPES config.json when the file exists but is
163
+ * unparseable (a hand-edit syntax slip) — the catch swallows the parse error,
164
+ * {} is seeded, and the save overwrites the file with just {"agentId": ...},
165
+ * destroying authToken / licenseKey / domains / weakPrimaryFloor, then
166
+ * cascades into scaffoldDefaultDomains re-seeding the generic vocabulary.
167
+ * This wrapper refuses that path:
168
+ * - ENOENT (file genuinely absent) → seed {} is correct (new install).
169
+ * - Any OTHER read/parse failure on a file that EXISTS (corruption,
170
+ * truncation, bad JSON) → THROW. Swallowing would overwrite the file; the
171
+ * operator must fix the JSON instead of silently losing it.
172
+ * Save happens ONLY when a new id was generated AND the file already existed
173
+ * — a missing config.json means init was never run (a separate problem), so we
174
+ * do not create a stub file just to hold an agentId. The returned id is still
175
+ * usable in-memory for the run either way.
176
+ *
177
+ * Exported for use by init's server path, nightly.ts, and mcp-server.ts boot.
178
+ */
179
+ export declare function ensureAndPersistAgentId(configPath: string): {
180
+ agentId: string;
181
+ generated: boolean;
182
+ };
55
183
  /**
56
184
  * Decide the per-agent context id to persist at init (#179; CC default = global,
57
185
  * owner decision 20.07.2026). `agentName` is an explicit opt-in only — there is
@@ -75,6 +203,42 @@ export declare function decideAgentName(existing: unknown, flag: string | undefi
75
203
  clear?: boolean;
76
204
  error?: string;
77
205
  };
206
+ /** Read config.json, set agentName, write it back. Used by the server path.
207
+ * Routes through loadConfigStrict — a malformed existing config throws rather
208
+ * than being wiped to a `{agentName: …}` stub (0.16.x BLOCKER; same pattern as
209
+ * the other four writers). Exported for wipe-protection coverage. */
210
+ export declare function writeAgentNameConfig(configPath: string, value: string): void;
211
+ /**
212
+ * Client-init config write: strict-load → apply the client overrides → save.
213
+ * This is the testable seam for the client path's config build (the rest of
214
+ * runClientInit is interactive / mutates ~/.claude / installs the daemon, so it
215
+ * is not unit-testable; this helper is).
216
+ *
217
+ * Strict load closes the 0.16.x BLOCKER for the client path: the old bare
218
+ * `try { parse } catch { warn }` seeded `{}` on a malformed existing config,
219
+ * then proceeded to set mode/serverUrl/authToken/agentId and `saveConfig` —
220
+ * OVERWRITING the file and losing the client's existing `authToken` (→ 401 on
221
+ * the next /search) and `licenseKey`, the same class as the server-side wipe.
222
+ * Now a malformed existing config THROWS (the user is interactive at `init`;
223
+ * they can fix the JSON and re-run, same as the server path). ENOENT → `{}`
224
+ * → a genuinely new client config is built fresh and saved.
225
+ *
226
+ * `agentNameDecision` is resolved by the caller via decideAgentName (which owns
227
+ * the process.exit on an invalid --agent-name flag). A no-flag run passes a
228
+ * {write:false} decision → this helper does not touch `agentName`, preserving
229
+ * whatever the loaded config already carries. Exported for wipe-protection
230
+ * coverage.
231
+ */
232
+ export declare function writeClientConfig(configPath: string, overrides: {
233
+ serverUrl: string;
234
+ authToken?: string;
235
+ }, agentNameDecision?: {
236
+ write: boolean;
237
+ value: string | null;
238
+ clear?: boolean;
239
+ }): {
240
+ config: Record<string, unknown>;
241
+ };
78
242
  /**
79
243
  * Generic default memory domains scaffolded by server-mode init (issue #150).
80
244
  * Deliberately broad, high-level spheres — an editable STARTING POINT, not a
@@ -129,6 +293,7 @@ export declare function installRecallHooks(settingsPath?: string): void;
129
293
  export declare function runInit(options?: {
130
294
  serverUrl?: string;
131
295
  agentName?: string;
296
+ repairConfig?: boolean;
132
297
  }): Promise<void>;
133
298
  /**
134
299
  * Resolve the nightly hour (0–23, local time) for the generated schedule.
package/dist/init.js CHANGED
@@ -21,9 +21,16 @@ exports.GENERIC_DEFAULT_DOMAINS = void 0;
21
21
  exports.parseMcpListStatus = parseMcpListStatus;
22
22
  exports.parseEnvFile = parseEnvFile;
23
23
  exports.isLlmConfigured = isLlmConfigured;
24
+ exports.persistLlmConfig = persistLlmConfig;
25
+ exports.loadConfigStrict = loadConfigStrict;
26
+ exports.quarantineMalformedConfig = quarantineMalformedConfig;
24
27
  exports.generateAuthToken = generateAuthToken;
25
28
  exports.persistAuthToken = persistAuthToken;
29
+ exports.ensureAgentId = ensureAgentId;
30
+ exports.ensureAndPersistAgentId = ensureAndPersistAgentId;
26
31
  exports.decideAgentName = decideAgentName;
32
+ exports.writeAgentNameConfig = writeAgentNameConfig;
33
+ exports.writeClientConfig = writeClientConfig;
27
34
  exports.scaffoldDefaultDomains = scaffoldDefaultDomains;
28
35
  exports.isEphemeralNpxPath = isEphemeralNpxPath;
29
36
  exports.installSessionStartHook = installSessionStartHook;
@@ -485,14 +492,10 @@ function isLlmConfigured(config) {
485
492
  * as a numbered list; the user picks one. Nothing is auto-applied.
486
493
  * If the user cancels, the server runs recall-only (no LLM).
487
494
  */
488
- async function persistLlmConfig() {
489
- const configPath = (0, node_path_1.join)(HICORTEX_HOME, "config.json");
490
- // Read existing config (may have licenseKey)
491
- let config = {};
492
- try {
493
- config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
494
- }
495
- catch { /* new file */ }
495
+ async function persistLlmConfig(configPath = (0, node_path_1.join)(HICORTEX_HOME, "config.json")) {
496
+ // Strict load: a malformed existing config throws here — NEVER wiped to a
497
+ // stub (the 0.16.x BLOCKER). ENOENT seeds {} (fresh install).
498
+ const { config } = loadConfigStrict(configPath);
496
499
  // Don't overwrite if LLM config already persisted (incl. a nested-only config).
497
500
  if (isLlmConfigured(config)) {
498
501
  console.log(` ✓ LLM config already configured`);
@@ -709,6 +712,128 @@ function saveConfig(configPath, config) {
709
712
  (0, node_fs_1.mkdirSync)(HICORTEX_HOME, { recursive: true });
710
713
  (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
711
714
  }
715
+ /**
716
+ * Strict config loader — the SINGLE source of truth for "read config.json or
717
+ * fail loudly". Every config writer in init (persistLlmConfig, persistAuthToken,
718
+ * ensureAndPersistAgentId, scaffoldDefaultDomains) loads through this, and the
719
+ * runtime readers (nightly, server boot) route through it too (catching the
720
+ * throw to fail-soft with a visible WARN).
721
+ *
722
+ * The 0.16.x BLOCKER this closes: the bare `try { JSON.parse(readFileSync) }
723
+ * catch { /* new file *\/ }` pattern, on a config.json that EXISTS but won't
724
+ * parse (a hand-edit syntax slip, truncation, corruption), silently seeded `{}`
725
+ * and the writer then OVERWROTE the file — `persistAuthToken` minted a fresh
726
+ * token (fleet-wide 401), `scaffoldDefaultDomains` re-seeded the generic
727
+ * vocabulary over the owner list, etc. `authToken` / `licenseKey` /
728
+ * `distillApiKey` / `domains` / `weakPrimaryFloor` / `contextClients` all gone.
729
+ * The early-return guards (existing-key checks) did NOT save them: those only
730
+ * fire on a VALID parse that reads the key, not on a corrupted file.
731
+ *
732
+ * Contract:
733
+ * - ENOENT (genuinely no file) → `{ config: {}, hadFile: false }` (a new
734
+ * install; the caller decides whether to persist).
735
+ * - Any OTHER read failure on an existing file (EACCES, etc.) → THROW.
736
+ * - A parse failure (bad JSON) on an existing file → THROW.
737
+ * - A non-object JSON value (null / array / true / 5 / "x") → THROW. Such a
738
+ * value is not a valid config and must not be silently replaced with {}.
739
+ *
740
+ * Refusing is the right DEFAULT, but it dead-ends the operator: `init` is
741
+ * exactly what you would run to repair a broken install, and it now won't run.
742
+ * `init --repair-config` is the explicit escape hatch — see
743
+ * quarantineMalformedConfig below. Never quarantine implicitly: that path mints
744
+ * a fresh authToken (fleet-wide 401), so it must be a deliberate choice.
745
+ *
746
+ * Exported so nightly.ts / mcp-server.ts readers can route through it.
747
+ */
748
+ function loadConfigStrict(configPath) {
749
+ let raw;
750
+ try {
751
+ raw = (0, node_fs_1.readFileSync)(configPath, "utf-8");
752
+ }
753
+ catch (e) {
754
+ // Only a genuinely-absent file (ENOENT) may safely seed {}.
755
+ if (e.code === "ENOENT") {
756
+ return { config: {}, hadFile: false };
757
+ }
758
+ // EACCES / EIO / … — the file is there but unreadable. Do not swallow.
759
+ throw new Error(`Refusing to read ${configPath}: the file exists but is not readable ` +
760
+ `(fix the permissions and re-run). Cause: ${e instanceof Error ? e.message : String(e)}`);
761
+ }
762
+ let parsed;
763
+ try {
764
+ parsed = JSON.parse(raw);
765
+ }
766
+ catch (e) {
767
+ throw new Error(`Refusing to write ${configPath}: the file exists but could not be parsed ` +
768
+ `(swallowing this would overwrite it with a stub and lose authToken / licenseKey / ` +
769
+ `domains). Fix the JSON and re-run, or run \`hicortex init --repair-config\` to move ` +
770
+ `the broken file aside and rebuild. ` +
771
+ `Cause: ${e instanceof Error ? e.message : String(e)}`);
772
+ }
773
+ // Non-object JSON (null / array / boolean / number / string) is not a valid
774
+ // config object and must never be silently replaced with {}.
775
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
776
+ const kind = parsed === null ? "null" : Array.isArray(parsed) ? "an array" : typeof parsed;
777
+ throw new Error(`Refusing to write ${configPath}: the file parses to ${kind}, not a JSON object ` +
778
+ `(swallowing this would overwrite it with a stub). Fix the JSON and re-run, or run ` +
779
+ `\`hicortex init --repair-config\` to move the broken file aside and rebuild.`);
780
+ }
781
+ return { config: parsed, hadFile: true };
782
+ }
783
+ /**
784
+ * `init --repair-config` escape hatch: move a malformed config.json aside so
785
+ * init can rebuild, instead of dead-ending on loadConfigStrict's throw.
786
+ *
787
+ * Why this exists: refusing to overwrite a corrupt config is right (it closed
788
+ * the 0.16.x wipe BLOCKER), but it leaves the operator stuck — `init` is the
789
+ * natural repair action and it now refuses to run. Deleting the file by hand
790
+ * works but silently loses `licenseKey` / `authToken` / `distillApiKey`.
791
+ *
792
+ * Why it is OPT-IN and never automatic: rebuilding mints a fresh `authToken`,
793
+ * which 401s every thin client on the fleet until they are re-pointed. That is
794
+ * a deliberate operator decision, not a fallback.
795
+ *
796
+ * Behaviour:
797
+ * - Config absent or valid → no-op (`{ quarantined: false }`).
798
+ * - Malformed → rename to `config.json.corrupt-<ISO>` (colons stripped for
799
+ * Windows), and report the TOP-LEVEL KEY NAMES recovered from the raw text
800
+ * so the operator knows what to restore.
801
+ *
802
+ * SECURITY: key NAMES only, never values. `authToken`, `licenseKey`,
803
+ * `distillApiKey` and `reflectApiKey` are secrets — printing them would leak
804
+ * into terminal scrollback, CI logs, and screen shares. The operator reads the
805
+ * values out of the backup file themselves.
806
+ *
807
+ * Exported for testability.
808
+ */
809
+ function quarantineMalformedConfig(configPath) {
810
+ try {
811
+ loadConfigStrict(configPath);
812
+ return { quarantined: false }; // absent (ENOENT) or valid — nothing to do.
813
+ }
814
+ catch { /* malformed — fall through and quarantine */ }
815
+ // Best-effort key-name recovery from the RAW text. The file does not parse,
816
+ // so this is a regex over top-level-looking `"key":` occurrences — advisory
817
+ // only (it may over- or under-report on deeply nested or truncated files).
818
+ let keys = [];
819
+ try {
820
+ const raw = (0, node_fs_1.readFileSync)(configPath, "utf-8");
821
+ keys = [...new Set([...raw.matchAll(/"([A-Za-z_][A-Za-z0-9_]*)"\s*:/g)].map((m) => m[1]))];
822
+ }
823
+ catch { /* unreadable — report no keys rather than fail the repair */ }
824
+ const backupPath = `${configPath}.corrupt-${new Date().toISOString().replace(/:/g, "-")}`;
825
+ (0, node_fs_1.renameSync)(configPath, backupPath);
826
+ console.log(` ⚠ ${configPath} was malformed — moved to ${backupPath}`);
827
+ console.log(` init will rebuild a fresh config. NOTHING was deleted.`);
828
+ if (keys.length > 0) {
829
+ console.log(` Keys found in the old file: ${keys.join(", ")}`);
830
+ }
831
+ console.log(` ACTION REQUIRED: copy any of licenseKey / distillApiKey / reflectApiKey /`);
832
+ console.log(` domains / weakPrimaryFloor back from the backup by hand.`);
833
+ console.log(` A NEW authToken will be generated — every thin client pointing at this`);
834
+ console.log(` server must be updated, or their recall will 401 (silently, fail-soft).`);
835
+ return { quarantined: true, backupPath, keys };
836
+ }
712
837
  /**
713
838
  * Generate a random auth token in the format hctx-<32 hex chars>.
714
839
  * Exported for testability.
@@ -723,11 +848,10 @@ function generateAuthToken() {
723
848
  * Exported for testability.
724
849
  */
725
850
  function persistAuthToken(configPath) {
726
- let config = {};
727
- try {
728
- config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
729
- }
730
- catch { /* new file */ }
851
+ // Strict load: a malformed existing config throws here — NEVER mint a fresh
852
+ // token over a wiped stub (the 0.16.x BLOCKER: this writer was the worst — a
853
+ // fleet-wide 401 on a hand-edit slip). ENOENT seeds {} (fresh install).
854
+ const { config } = loadConfigStrict(configPath);
731
855
  if (config.authToken && typeof config.authToken === "string") {
732
856
  return { token: config.authToken, generated: false };
733
857
  }
@@ -737,6 +861,66 @@ function persistAuthToken(configPath) {
737
861
  (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
738
862
  return { token, generated: true };
739
863
  }
864
+ /**
865
+ * Ensure a stable per-install `agentId` UUID is set on a config object.
866
+ *
867
+ * The id is the client's attribution identity (stored on each captured
868
+ * memory as `source_agent_id`) — it survives agent/machine renames, unlike
869
+ * the readable `source_agent` name. Generated once, never rotated (idempotent:
870
+ * an existing valid `agentId` is always kept). Pure: mutates `config` in place
871
+ * and does NO file IO — BOTH server and client init call this on their
872
+ * in-memory config object before saving (the server path loads/saves
873
+ * config.json around it; the client builds in-memory and saves once).
874
+ * Exported for testability.
875
+ */
876
+ function ensureAgentId(config) {
877
+ if (typeof config.agentId === "string" && config.agentId) {
878
+ return { agentId: config.agentId, generated: false };
879
+ }
880
+ const agentId = (0, node_crypto_1.randomUUID)();
881
+ config.agentId = agentId;
882
+ return { agentId, generated: true };
883
+ }
884
+ /**
885
+ * Load → ensure → persist wrapper for the `agentId` provenance field. This is
886
+ * the runtime activation path: `ensureAgentId` was historically called ONLY
887
+ * inside `init`, so pre-0.16.2 installs that already ran init never get an
888
+ * `agentId` written → nightly + server boot capture sent `source_agent_id:
889
+ * null` forever (the feature was inert for the entire existing fleet). Both
890
+ * nightly and server boot call THIS on startup so the field self-heals on the
891
+ * first run after upgrade — one read, one conditional write, idempotent.
892
+ *
893
+ * Built on the pure `ensureAgentId` (which init's client path and the unit
894
+ * test still call directly); this wrapper adds the disk IO.
895
+ *
896
+ * Hardening (0.16.x CR BLOCKER): the naive "try { read } catch { seed {} }"
897
+ * + unconditional save WIPES config.json when the file exists but is
898
+ * unparseable (a hand-edit syntax slip) — the catch swallows the parse error,
899
+ * {} is seeded, and the save overwrites the file with just {"agentId": ...},
900
+ * destroying authToken / licenseKey / domains / weakPrimaryFloor, then
901
+ * cascades into scaffoldDefaultDomains re-seeding the generic vocabulary.
902
+ * This wrapper refuses that path:
903
+ * - ENOENT (file genuinely absent) → seed {} is correct (new install).
904
+ * - Any OTHER read/parse failure on a file that EXISTS (corruption,
905
+ * truncation, bad JSON) → THROW. Swallowing would overwrite the file; the
906
+ * operator must fix the JSON instead of silently losing it.
907
+ * Save happens ONLY when a new id was generated AND the file already existed
908
+ * — a missing config.json means init was never run (a separate problem), so we
909
+ * do not create a stub file just to hold an agentId. The returned id is still
910
+ * usable in-memory for the run either way.
911
+ *
912
+ * Exported for use by init's server path, nightly.ts, and mcp-server.ts boot.
913
+ */
914
+ function ensureAndPersistAgentId(configPath) {
915
+ // One source of truth: loadConfigStrict throws on a malformed existing file
916
+ // (never wipe) and returns hadFile=false on ENOENT (do not create a stub).
917
+ const { config, hadFile } = loadConfigStrict(configPath);
918
+ const result = ensureAgentId(config);
919
+ if (result.generated && hadFile) {
920
+ saveConfig(configPath, config);
921
+ }
922
+ return result;
923
+ }
740
924
  /**
741
925
  * Decide the per-agent context id to persist at init (#179; CC default = global,
742
926
  * owner decision 20.07.2026). `agentName` is an explicit opt-in only — there is
@@ -773,32 +957,70 @@ function decideAgentName(existing, flag) {
773
957
  return { write: false, value: existing };
774
958
  return { write: false, value: null };
775
959
  }
776
- /** Read config.json, set agentName, write it back. Used by the server path. */
960
+ /** Read config.json, set agentName, write it back. Used by the server path.
961
+ * Routes through loadConfigStrict — a malformed existing config throws rather
962
+ * than being wiped to a `{agentName: …}` stub (0.16.x BLOCKER; same pattern as
963
+ * the other four writers). Exported for wipe-protection coverage. */
777
964
  function writeAgentNameConfig(configPath, value) {
778
- let config = {};
779
- try {
780
- config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
781
- }
782
- catch { /* new / unreadable → start fresh */ }
965
+ const { config } = loadConfigStrict(configPath);
783
966
  config.agentName = value;
784
967
  (0, node_fs_1.mkdirSync)(HICORTEX_HOME, { recursive: true });
785
968
  (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
786
969
  }
787
- /** Read config.json, delete any `agentName` key, write it back (server path). */
970
+ /** Read config.json, delete any `agentName` key, write it back (server path).
971
+ * Routes through loadConfigStrict — a malformed existing config throws (never
972
+ * silently no-op). ENOENT is still a silent no-op (hadFile=false → empty
973
+ * config has no `agentName` key → return without writing). */
788
974
  function clearAgentNameConfig(configPath) {
789
- let config = {};
790
- try {
791
- config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
792
- }
793
- catch {
794
- return; /* new / unreadable → nothing to clear */
795
- }
975
+ const { config } = loadConfigStrict(configPath);
796
976
  if (!("agentName" in config))
797
977
  return;
798
978
  delete config.agentName;
799
979
  (0, node_fs_1.mkdirSync)(HICORTEX_HOME, { recursive: true });
800
980
  (0, node_fs_1.writeFileSync)(configPath, JSON.stringify(config, null, 2));
801
981
  }
982
+ /**
983
+ * Client-init config write: strict-load → apply the client overrides → save.
984
+ * This is the testable seam for the client path's config build (the rest of
985
+ * runClientInit is interactive / mutates ~/.claude / installs the daemon, so it
986
+ * is not unit-testable; this helper is).
987
+ *
988
+ * Strict load closes the 0.16.x BLOCKER for the client path: the old bare
989
+ * `try { parse } catch { warn }` seeded `{}` on a malformed existing config,
990
+ * then proceeded to set mode/serverUrl/authToken/agentId and `saveConfig` —
991
+ * OVERWRITING the file and losing the client's existing `authToken` (→ 401 on
992
+ * the next /search) and `licenseKey`, the same class as the server-side wipe.
993
+ * Now a malformed existing config THROWS (the user is interactive at `init`;
994
+ * they can fix the JSON and re-run, same as the server path). ENOENT → `{}`
995
+ * → a genuinely new client config is built fresh and saved.
996
+ *
997
+ * `agentNameDecision` is resolved by the caller via decideAgentName (which owns
998
+ * the process.exit on an invalid --agent-name flag). A no-flag run passes a
999
+ * {write:false} decision → this helper does not touch `agentName`, preserving
1000
+ * whatever the loaded config already carries. Exported for wipe-protection
1001
+ * coverage.
1002
+ */
1003
+ function writeClientConfig(configPath, overrides, agentNameDecision) {
1004
+ const { config } = loadConfigStrict(configPath);
1005
+ config.mode = "client";
1006
+ config.serverUrl = overrides.serverUrl;
1007
+ if (overrides.authToken)
1008
+ config.authToken = overrides.authToken;
1009
+ if (agentNameDecision) {
1010
+ if (agentNameDecision.clear) {
1011
+ delete config.agentName;
1012
+ }
1013
+ else if (agentNameDecision.write && agentNameDecision.value) {
1014
+ config.agentName = agentNameDecision.value;
1015
+ }
1016
+ // write:false (no --agent-name flag) → leave the existing agentName as-is.
1017
+ }
1018
+ // Stable per-install agent id (attribution). Generated once, kept across
1019
+ // re-runs (never rotated). An existing id from a prior init is preserved.
1020
+ ensureAgentId(config);
1021
+ saveConfig(configPath, config);
1022
+ return { config };
1023
+ }
802
1024
  /**
803
1025
  * Generic default memory domains scaffolded by server-mode init (issue #150).
804
1026
  * Deliberately broad, high-level spheres — an editable STARTING POINT, not a
@@ -827,11 +1049,10 @@ exports.GENERIC_DEFAULT_DOMAINS = [
827
1049
  * Exported for testability.
828
1050
  */
829
1051
  function scaffoldDefaultDomains(configPath) {
830
- let config = {};
831
- try {
832
- config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
833
- }
834
- catch { /* new file */ }
1052
+ // Strict load: a malformed existing config throws here — NEVER re-seed the
1053
+ // generic defaults over the owner vocabulary (the 0.16.x BLOCKER). ENOENT
1054
+ // seeds {} (fresh install → scaffold is the point).
1055
+ const { config } = loadConfigStrict(configPath);
835
1056
  if ("domains" in config) {
836
1057
  console.log(" ✓ Memory domains already configured — leaving your list as-is");
837
1058
  return { scaffolded: false };
@@ -1114,6 +1335,12 @@ async function ask(question) {
1114
1335
  // Main
1115
1336
  // ---------------------------------------------------------------------------
1116
1337
  async function runInit(options = {}) {
1338
+ // --repair-config: quarantine a malformed config.json BEFORE any writer runs,
1339
+ // so the strict loaders see ENOENT and rebuild instead of throwing. Must come
1340
+ // first — every writer downstream loads through loadConfigStrict.
1341
+ if (options.repairConfig) {
1342
+ quarantineMalformedConfig((0, node_path_1.join)(HICORTEX_HOME, "config.json"));
1343
+ }
1117
1344
  if (options.serverUrl) {
1118
1345
  await runClientInit(options.serverUrl, options.agentName);
1119
1346
  return;
@@ -1190,6 +1417,19 @@ async function runInit(options = {}) {
1190
1417
  else {
1191
1418
  console.log(` ✓ Auth token already configured`);
1192
1419
  }
1420
+ // Stable per-install agent id (attribution on captured memories; survives
1421
+ // renames). Generated once, never rotated — same non-clobber philosophy as
1422
+ // the auth token. Goes through ensureAndPersistAgentId (hardened wrapper:
1423
+ // throws on a malformed existing config instead of swallowing + wiping, and
1424
+ // saves ONLY when a new id was generated). persistAuthToken above already
1425
+ // ensured config.json exists by this point. The client path shares the SAME
1426
+ // loadConfigStrict discipline via writeClientConfig — it throws on a malformed
1427
+ // existing config too (ENOENT builds a fresh client config), so no path in
1428
+ // init silently wipes config.json anymore.
1429
+ const agentIdResult = ensureAndPersistAgentId(configPath);
1430
+ if (agentIdResult.generated) {
1431
+ console.log(` ✓ Agent id: ${agentIdResult.agentId}`);
1432
+ }
1193
1433
  // Scaffold the generic default memory domains (server mode only — domains
1194
1434
  // live in the server's config; a client's memories are classified by the
1195
1435
  // server). Non-clobber: an existing `domains` key is never touched.
@@ -1355,39 +1595,25 @@ async function runClientInit(serverUrl, agentName) {
1355
1595
  // Step 3: Save client config
1356
1596
  (0, node_fs_1.mkdirSync)(HICORTEX_HOME, { recursive: true });
1357
1597
  const configPath = (0, node_path_1.join)(HICORTEX_HOME, "config.json");
1358
- let config = {};
1359
- if ((0, node_fs_1.existsSync)(configPath)) {
1360
- try {
1361
- config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
1362
- }
1363
- catch {
1364
- console.log(` ⚠ ${configPath} exists but is not valid JSON — starting with empty config (licenseKey and LLM settings may need to be re-entered).`);
1365
- }
1366
- }
1367
- config.mode = "client";
1368
- config.serverUrl = serverUrl;
1369
- if (authToken)
1370
- config.authToken = authToken;
1371
- // Per-agent context id (#179): explicit opt-in only. Written ONLY when
1372
- // --agent-name is passed; otherwise no agentName is set and the client shares
1373
- // the global context (global by default). Re-init keeps an existing value
1374
- // unless --agent-name is explicit; `--agent-name ""` clears it back to global.
1375
- const nameDecision = decideAgentName(config.agentName, agentName);
1598
+ // Per-agent context id (#179): explicit opt-in only. resolve the flag via
1599
+ // decideAgentName (it owns the process.exit on an invalid --agent-name). A
1600
+ // no-flag run yields a {write:false} decision → writeClientConfig leaves any
1601
+ // existing agentName untouched (preserving what the loaded config carries).
1602
+ const nameDecision = decideAgentName(undefined, agentName);
1376
1603
  if (nameDecision.error) {
1377
1604
  console.error(` ✗ ${nameDecision.error}`);
1378
1605
  process.exit(1);
1379
1606
  }
1380
1607
  if (nameDecision.clear) {
1381
- delete config.agentName;
1382
1608
  console.log(" ✓ Agent name cleared — global context");
1383
1609
  }
1384
- else if (nameDecision.write && nameDecision.value) {
1385
- config.agentName = nameDecision.value;
1386
- if (agentName && nameDecision.value !== agentName) {
1387
- console.log(` ℹ Agent name sanitized to '${nameDecision.value}'`);
1388
- }
1610
+ else if (nameDecision.write && nameDecision.value && agentName && nameDecision.value !== agentName) {
1611
+ console.log(` ℹ Agent name sanitized to '${nameDecision.value}'`);
1389
1612
  }
1390
- saveConfig(configPath, config);
1613
+ // writeClientConfig: strict-load → apply overrides → save. Throws on a
1614
+ // malformed existing config (0.16.x BLOCKER — never wipe the client's
1615
+ // authToken/licenseKey). ENOENT → fresh client config.
1616
+ const { config } = writeClientConfig(configPath, { serverUrl, authToken }, nameDecision);
1391
1617
  console.log(` ✓ Client config saved to ${configPath}`);
1392
1618
  if (typeof config.agentName === "string") {
1393
1619
  console.log(` ✓ Agent name: ${config.agentName}`);