@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.
- package/README.md +9 -0
- package/dist/capture.d.ts +18 -1
- package/dist/capture.js +3 -2
- package/dist/classify-domains.d.ts +1 -1
- package/dist/classify-domains.js +5 -7
- package/dist/cli.js +10 -2
- package/dist/cluster.d.ts +5 -4
- package/dist/cluster.js +2 -3
- package/dist/consolidate.js +6 -5
- package/dist/db.js +23 -0
- package/dist/dedup.js +1 -1
- package/dist/distiller.js +19 -12
- package/dist/domain-classify.d.ts +1 -1
- package/dist/domain-classify.js +1 -5
- package/dist/eval/relevance-eval.d.ts +64 -0
- package/dist/eval/relevance-eval.js +1954 -0
- package/dist/eval/run-eval.js +0 -1
- package/dist/index.js +3 -3
- package/dist/init.d.ts +165 -0
- package/dist/init.js +283 -57
- package/dist/lessons-context.js +3 -2
- package/dist/mcp-server.js +72 -25
- package/dist/nightly.js +35 -3
- package/dist/nofit.d.ts +1 -1
- package/dist/nofit.js +1 -2
- package/dist/prompts.js +22 -13
- package/dist/recall-index.d.ts +56 -21
- package/dist/recall-index.js +51 -29
- package/dist/retrieval.d.ts +7 -7
- package/dist/retrieval.js +20 -24
- package/dist/schema-prototypes.d.ts +8 -13
- package/dist/schema-prototypes.js +13 -22
- package/dist/seed-lesson.d.ts +1 -1
- package/dist/seed-lesson.js +1 -2
- package/dist/storage.d.ts +9 -12
- package/dist/storage.js +19 -21
- package/dist/types.d.ts +47 -23
- package/domains.example.json +2 -3
- package/hermes-plugin/hicortex/README.md +3 -1
- package/hermes-plugin/hicortex/config.py +33 -2
- package/hermes-plugin/hicortex/plugin.yaml +1 -1
- package/hermes-plugin/hicortex/provider.py +5 -0
- package/package.json +2 -1
package/dist/eval/run-eval.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
490
|
-
//
|
|
491
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|