@gamaze/hicortex 0.16.1 → 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.
package/README.md CHANGED
@@ -164,6 +164,7 @@ npx @gamaze/hicortex context edit <name> # Edit a context section in $EDIT
164
164
  npx @gamaze/hicortex context show --agent <id> # Show a specific agent's resolved context (0.13)
165
165
  npx @gamaze/hicortex init --agent-name <name> # Opt in to a per-agent context id (default: unset — shared global context)
166
166
  npx @gamaze/hicortex init --agent-name "" # Clear it back to global context
167
+ npx @gamaze/hicortex init --repair-config # Recover from a malformed config.json (see below)
167
168
  npx @gamaze/hicortex telemetry # Show exactly what anonymous telemetry sends
168
169
  npx @gamaze/hicortex status # Show config, DB stats
169
170
  npx @gamaze/hicortex uninstall # Remove CC integration (keeps DB)
@@ -330,6 +331,13 @@ npm test
330
331
 
331
332
  ## Troubleshooting
332
333
 
334
+ **`init` fails with "Refusing to write ~/.hicortex/config.json":** the file exists but is not valid JSON — usually a hand-edit slip (a trailing comma, a truncated write). `init` refuses rather than overwriting it, because overwriting would lose `authToken`, `licenseKey`, `distillApiKey`, and your `domains` list. Two ways out:
335
+
336
+ 1. **Preferred — fix the JSON.** The error names the parse failure and its position. Correct it and re-run `init`. Nothing is lost.
337
+ 2. **`npx @gamaze/hicortex init --repair-config`.** Moves the broken file to `config.json.corrupt-<timestamp>` and rebuilds from scratch. Nothing is deleted, and it prints the top-level key names it found (names only — never secret values) so you know what to copy back. **This mints a new `authToken`**, so every thin client pointing at this server must be updated or its recall will silently 401 (recall is fail-soft — you will see no error, just no memories).
338
+
339
+ The nightly and the server behave differently on purpose: a malformed config makes them log a warning and run degraded rather than refuse to start, so a broken config never takes recall offline.
340
+
333
341
  **Tools not visible to agent (OC):** The plugin auto-adds tools to `tools.allow` on startup. Restart the gateway after install.
334
342
 
335
343
  **OC plugin: "Server unreachable":** The plugin requires a running Hicortex server. Run `npx @gamaze/hicortex init` on the same machine, or set `serverUrl` in the plugin config to point at a remote server.
package/dist/capture.d.ts CHANGED
@@ -52,11 +52,17 @@ export interface Segment {
52
52
  export interface DistillBody {
53
53
  text: string;
54
54
  source_agent: string;
55
+ /** Stable client UUID (config.json `agentId`). Attribution only. */
56
+ source_agent_id?: string | null;
57
+ /** Client-declared topic/domain of the capturing agent. Provenance only. */
58
+ source_domain?: string | null;
55
59
  project: string;
56
60
  session_id: string;
57
61
  segment_id: string;
58
62
  session_date: string;
59
- privacy: string;
63
+ /** 0.16.x: optional/vestigial. The distiller no longer sets it; a legacy
64
+ * client may. Honored if present, else the memory stores NULL. */
65
+ privacy?: string;
60
66
  }
61
67
  /** Normalized POST result the caller's transport returns. */
62
68
  export interface PostResult {
@@ -73,6 +79,17 @@ export interface CaptureOptions {
73
79
  dryRun?: boolean;
74
80
  /** Segment size cap; defaults to SEGMENT_MAX_CHARS. Lowered in tests. */
75
81
  segmentMaxChars?: number;
82
+ /**
83
+ * Per-client attribution UUID (config.json `agentId`). Sent on every
84
+ * segment as `source_agent_id`. Null when the client has no `agentId`
85
+ * (e.g. a pre-0.16.x config that has not re-run init).
86
+ */
87
+ sourceAgentId?: string | null;
88
+ /**
89
+ * Per-client declared topic/domain (config.json `sourceDomain`). Sent as
90
+ * `source_domain` provenance. Null when undeclared.
91
+ */
92
+ sourceDomain?: string | null;
76
93
  }
77
94
  export interface CaptureResult {
78
95
  memoriesIngested: number;
package/dist/capture.js CHANGED
@@ -134,7 +134,7 @@ function packSegments(entries, startCursor, entryCursors, maxChars = exports.SEG
134
134
  * boundary) while other sessions continue. A 429/401 stops the whole loop.
135
135
  */
136
136
  async function captureBatches(batches, opts) {
137
- const { post, cursorStore, dryRun = false, segmentMaxChars = exports.SEGMENT_MAX_CHARS } = opts;
137
+ const { post, cursorStore, dryRun = false, segmentMaxChars = exports.SEGMENT_MAX_CHARS, sourceAgentId, sourceDomain } = opts;
138
138
  let memoriesIngested = 0;
139
139
  let sessionsSent = 0;
140
140
  let hadTransientFailure = false;
@@ -186,11 +186,12 @@ async function captureBatches(batches, opts) {
186
186
  const body = {
187
187
  text: seg.text,
188
188
  source_agent: batch.sourceAgent ?? `claude-code/${batch.projectName}`,
189
+ source_agent_id: sourceAgentId ?? null,
190
+ source_domain: sourceDomain ?? null,
189
191
  project: batch.projectName,
190
192
  session_id: batch.sessionId,
191
193
  segment_id: `${genPrefix}${seg.segStart}-${seg.segEnd}${seg.idSuffix}`,
192
194
  session_date: batch.date,
193
- privacy: "WORK",
194
195
  };
195
196
  let result;
196
197
  try {
@@ -16,7 +16,7 @@
16
16
  * reflect tier), same as the nightly. The LLM emits ONLY the ordered tag
17
17
  * set; per-tag weights come from the domain prototypes (computed once at
18
18
  * run start) and the PRIMARY (memories.domain) is derived (argmax weight,
19
- * compartment override, LLM order breaking ties) inside
19
+ * LLM order breaking ties) inside
20
20
  * storage.setMemoryTags. After a completed (non-aborted) run the
21
21
  * prototypes, all weights, and all primaries are recomputed from the
22
22
  * final tag sets — same reconsolidation pass as the nightly.
@@ -17,7 +17,7 @@
17
17
  * reflect tier), same as the nightly. The LLM emits ONLY the ordered tag
18
18
  * set; per-tag weights come from the domain prototypes (computed once at
19
19
  * run start) and the PRIMARY (memories.domain) is derived (argmax weight,
20
- * compartment override, LLM order breaking ties) inside
20
+ * LLM order breaking ties) inside
21
21
  * storage.setMemoryTags. After a completed (non-aborted) run the
22
22
  * prototypes, all weights, and all primaries are recomputed from the
23
23
  * final tag sets — same reconsolidation pass as the nightly.
@@ -195,7 +195,6 @@ async function runClassifyDomains(options = {}) {
195
195
  // Prototypes once at run start — newly classified memories get their
196
196
  // weights from these; the post-run reconsolidation pass refreshes
197
197
  // everything from the final tag sets.
198
- const compartments = (0, schema_prototypes_js_1.compartmentSet)(domains);
199
198
  const { prototypes } = await (0, schema_prototypes_js_1.computeDomainPrototypes)(db, domains, getEmbedFn);
200
199
  // Scope filter: default = NULL / not-in-set / no tags yet; --all = everything.
201
200
  const placeholders = domains.map(() => "?").join(", ");
@@ -255,10 +254,9 @@ async function runClassifyDomains(options = {}) {
255
254
  continue;
256
255
  }
257
256
  // Derived primary (argmax weight from the run-start prototypes,
258
- // compartment override, LLM order breaking ties) — the same value
259
- // setMemoryTags will write below.
257
+ // LLM order breaking ties) — the same value setMemoryTags writes below.
260
258
  const weights = (0, schema_prototypes_js_1.computeTagWeights)(db, row.id, result.tags, prototypes);
261
- const derived = (0, schema_prototypes_js_1.derivePrimary)(result.tags.map((tag) => ({ tag, weight: weights[tag] ?? null })), compartments);
259
+ const derived = (0, schema_prototypes_js_1.derivePrimary)(result.tags.map((tag) => ({ tag, weight: weights[tag] ?? null })));
262
260
  if (derived === row.domain) {
263
261
  batchUnchanged++;
264
262
  }
@@ -273,10 +271,10 @@ async function runClassifyDomains(options = {}) {
273
271
  const tx = db.transaction(() => {
274
272
  for (const w of writes) {
275
273
  if (w.kind === "tags") {
276
- storage.setMemoryTags(db, w.id, w.tags, { weights: w.weights, compartments });
274
+ storage.setMemoryTags(db, w.id, w.tags, { weights: w.weights });
277
275
  }
278
276
  else if (w.resolution.kind === "weak_primary") {
279
- (0, nofit_js_1.applyWeakPrimary)(db, w.id, w.resolution.domain, w.resolution.weight, compartments);
277
+ (0, nofit_js_1.applyWeakPrimary)(db, w.id, w.resolution.domain, w.resolution.weight);
280
278
  }
281
279
  else {
282
280
  (0, nofit_js_1.applyNoAssociationDecay)(db, w.id);
package/dist/cli.js CHANGED
@@ -43,9 +43,14 @@ switch (command) {
43
43
  console.error("[hicortex] init: --agent-name requires a value, e.g. --agent-name lenovo");
44
44
  process.exit(1);
45
45
  }
46
+ const repairConfig = process.argv.includes("--repair-config");
46
47
  import("./init.js").then(({ runInit }) => {
47
- runInit({ serverUrl, agentName }).catch((err) => {
48
- console.error("[hicortex] Init failed:", err);
48
+ runInit({ serverUrl, agentName, repairConfig }).catch((err) => {
49
+ // Operator-fixable failures (a malformed config.json) carry a complete,
50
+ // actionable message — print that alone. A stack trace would bury it.
51
+ // Anything else is a real bug and gets the full error object.
52
+ const operatorFixable = err instanceof Error && /^Refusing to (read|write) /.test(err.message);
53
+ console.error("[hicortex] Init failed:", operatorFixable ? err.message : err);
49
54
  process.exit(1);
50
55
  });
51
56
  });
@@ -250,6 +255,9 @@ Commands:
250
255
  init --server <url> Set up as client (remote server)
251
256
  init --agent-name <name> Opt in to a per-agent context id (default: unset — shared global context)
252
257
  Pass --agent-name "" to clear it back to global
258
+ init --repair-config Recover from a malformed ~/.hicortex/config.json: move it to
259
+ config.json.corrupt-<timestamp> and rebuild. Nothing is deleted.
260
+ Mints a NEW authToken — every thin client must be updated.
253
261
  nightly Run nightly denoise + capture + consolidate
254
262
  relink Resumable link-discovery pass over the ENTIRE corpus (server mode)
255
263
  dedup Cluster + merge near-duplicate memories (server mode; dry run by default)
package/dist/cluster.d.ts CHANGED
@@ -25,18 +25,19 @@ export declare class UnionFind {
25
25
  export declare function clusterEdges(edges: Edge[], threshold: number): string[][];
26
26
  /** Excess = sum(cluster size − 1) — rows that would disappear if every cluster merged to one. */
27
27
  export declare function clusterExcess(clusters: string[][]): number;
28
- /** The three metadata fields a merge candidate cluster must agree on. */
28
+ /** Metadata fields a merge candidate cluster must agree on. `privacy` is on the row (the
29
+ * column still exists) but is NOT a merge-safety field — vestigial since 0.16.2. */
29
30
  export interface ClusterMetaRow {
30
31
  project: string | null;
31
- privacy: string;
32
+ privacy: string | null;
32
33
  source_agent: string;
33
34
  }
34
35
  export interface ClusterMetadataMismatch {
35
36
  projectMismatch: boolean;
36
- privacyMismatch: boolean;
37
37
  sourceAgentMismatch: boolean;
38
38
  }
39
- /** Do cluster members disagree on project/privacy/source_agent? (merge-safety input for #100). */
39
+ /** Do cluster members disagree on project / source_agent? (merge-safety input for #100).
40
+ * Privacy is intentionally NOT checked — it is vestigial since 0.16.2. */
40
41
  export declare function clusterMetadataMismatch(members: ClusterMetaRow[]): ClusterMetadataMismatch;
41
42
  /**
42
43
  * Build the max-cosine edge set via top-K KNN on `memory_vectors`, keeping
package/dist/cluster.js CHANGED
@@ -69,14 +69,13 @@ function clusterEdges(edges, threshold) {
69
69
  function clusterExcess(clusters) {
70
70
  return clusters.reduce((sum, c) => sum + (c.length - 1), 0);
71
71
  }
72
- /** Do cluster members disagree on project/privacy/source_agent? (merge-safety input for #100). */
72
+ /** Do cluster members disagree on project / source_agent? (merge-safety input for #100).
73
+ * Privacy is intentionally NOT checked — it is vestigial since 0.16.2. */
73
74
  function clusterMetadataMismatch(members) {
74
75
  const projects = new Set(members.map((m) => m.project ?? "\u0000null"));
75
- const privacies = new Set(members.map((m) => m.privacy));
76
76
  const agents = new Set(members.map((m) => m.source_agent));
77
77
  return {
78
78
  projectMismatch: projects.size > 1,
79
- privacyMismatch: privacies.size > 1,
80
79
  sourceAgentMismatch: agents.size > 1,
81
80
  };
82
81
  }
@@ -356,7 +356,6 @@ async function stageReflection(db, memories, llm, budget, embedFn, dryRun) {
356
356
  project,
357
357
  memoryType: "lesson",
358
358
  baseStrength: baseStrength[severity] ?? 0.8,
359
- privacy: "WORK",
360
359
  });
361
360
  generated++;
362
361
  }
@@ -436,7 +435,6 @@ async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, st
436
435
  return { curated: false, domains: domains.length, classified: 0, reason: `dry_run (${rows.length} would classify)` };
437
436
  }
438
437
  const getEmbedFn = async () => embedFn;
439
- const compartments = (0, schema_prototypes_js_1.compartmentSet)(domains);
440
438
  let classified = 0;
441
439
  let weakPrimary = 0;
442
440
  let noAssociationDecayed = 0;
@@ -465,7 +463,7 @@ async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, st
465
463
  // double-halves.
466
464
  const resolution = (0, nofit_js_1.resolveNoFit)(db, row.id, domains, startPrototypes, weakPrimaryFloor);
467
465
  if (resolution.kind === "weak_primary") {
468
- (0, nofit_js_1.applyWeakPrimary)(db, row.id, resolution.domain, resolution.weight, compartments);
466
+ (0, nofit_js_1.applyWeakPrimary)(db, row.id, resolution.domain, resolution.weight);
469
467
  weakPrimary++;
470
468
  }
471
469
  else {
@@ -475,7 +473,7 @@ async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, st
475
473
  continue;
476
474
  }
477
475
  const weights = (0, schema_prototypes_js_1.computeTagWeights)(db, row.id, result.tags, startPrototypes);
478
- storage.setMemoryTags(db, row.id, result.tags, { weights, compartments });
476
+ storage.setMemoryTags(db, row.id, result.tags, { weights });
479
477
  classified++;
480
478
  }
481
479
  }
package/dist/db.js CHANGED
@@ -441,6 +441,29 @@ const MIGRATIONS = [
441
441
  `);
442
442
  },
443
443
  },
444
+ {
445
+ version: 11,
446
+ name: "add_source_attribution",
447
+ up: (db) => {
448
+ // 0.16.x attribution + provenance. Two nullable columns on `memories`,
449
+ // both populated ONLY by capture (/distill) from client-declared values;
450
+ // nothing filters, scopes, or scores on either (attribution + echo).
451
+ //
452
+ // `source_agent_id`: the capturing client's stable UUID (config.json
453
+ // `agentId`, generated once by init). Survives agent/machine renames
454
+ // — unlike `source_agent`, a readable name. NULL on legacy rows.
455
+ // `source_domain`: the client-declared topic/domain of the capturing
456
+ // agent (config.json `domain`). Distinct from the content-classified
457
+ // `domain` column (which stays the LLM/prototype-derived primary).
458
+ // Guarded with hasColumn for idempotency across partially-migrated DBs.
459
+ if (!hasColumn(db, "memories", "source_agent_id")) {
460
+ db.exec("ALTER TABLE memories ADD COLUMN source_agent_id TEXT");
461
+ }
462
+ if (!hasColumn(db, "memories", "source_domain")) {
463
+ db.exec("ALTER TABLE memories ADD COLUMN source_domain TEXT");
464
+ }
465
+ },
466
+ },
444
467
  ];
445
468
  /**
446
469
  * Run all pending migrations against the database.
package/dist/dedup.js CHANGED
@@ -287,7 +287,7 @@ async function runDedup(options = {}) {
287
287
  if (members.length < 2)
288
288
  continue; // defensive — a member vanished between KNN and load
289
289
  const mismatch = (0, cluster_js_1.clusterMetadataMismatch)(members);
290
- if (mismatch.projectMismatch || mismatch.privacyMismatch || mismatch.sourceAgentMismatch) {
290
+ if (mismatch.projectMismatch || mismatch.sourceAgentMismatch) {
291
291
  mismatchSkipped.push({ size: members.length, memberIds: members.map((m) => m.id), mismatch });
292
292
  continue;
293
293
  }
package/dist/distiller.js CHANGED
@@ -419,8 +419,8 @@ function parseDistilledEntries(markdown) {
419
419
  const lines = markdown.split("\n");
420
420
  for (const line of lines) {
421
421
  const trimmed = line.trim();
422
- // Skip all markdown headers (session title, classification, section
423
- // headings). Sections are NOT prefixed onto entries: each bullet already
422
+ // Skip all markdown headers (session title, section headings). Sections
423
+ // are NOT prefixed onto entries: each bullet already
424
424
  // starts with its [SUBJECT] (topic-first, enforced by prompts.ts), and
425
425
  // prepending "[Section]" re-introduced the category-first prefix the
426
426
  // 2026-08-02 corpus rewrite removed. The section label is unused
@@ -18,7 +18,7 @@
18
18
  * (the order is used solely as an exact-weight tiebreak downstream).
19
19
  * The PRIMARY (memories.domain) is NO LONGER requested from the LLM — audits
20
20
  * proved LLM primaries a coin-flip on overlapping spheres. It is DERIVED
21
- * deterministically (argmax association weight + compartment override) in
21
+ * deterministically (argmax association weight, LLM tag order breaking ties) in
22
22
  * schema-prototypes.ts / storage.setMemoryTags.
23
23
  *
24
24
  * NO-FIT = EMPTY TAG SET (owner amendment 07.07): "Unsorted" is a non-tag —
@@ -19,7 +19,7 @@
19
19
  * (the order is used solely as an exact-weight tiebreak downstream).
20
20
  * The PRIMARY (memories.domain) is NO LONGER requested from the LLM — audits
21
21
  * proved LLM primaries a coin-flip on overlapping spheres. It is DERIVED
22
- * deterministically (argmax association weight + compartment override) in
22
+ * deterministically (argmax association weight, LLM tag order breaking ties) in
23
23
  * schema-prototypes.ts / storage.setMemoryTags.
24
24
  *
25
25
  * NO-FIT = EMPTY TAG SET (owner amendment 07.07): "Unsorted" is a non-tag —
@@ -84,10 +84,6 @@ function parseConfigDomains(config) {
84
84
  if (!name)
85
85
  continue;
86
86
  const def = { name, description };
87
- // Compartment policy passthrough (graded-schema spec): a domain flagged
88
- // `compartment: true` becomes the primary whenever tagged.
89
- if (d.compartment === true)
90
- def.compartment = true;
91
87
  out.push(def);
92
88
  }
93
89
  return out.length > 0 ? out : null;
@@ -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
@@ -470,7 +470,6 @@ exports.default = {
470
470
  source_agent: `openclaw/${context?.agentId ?? "manual"}`,
471
471
  project: args.project,
472
472
  memory_type: args.memory_type ?? "episode",
473
- privacy: "WORK",
474
473
  }, 15000);
475
474
  if (!result.ok) {
476
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.