@gamaze/hicortex 0.16.1 → 0.16.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +11 -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 +11 -3
  7. package/dist/cluster.d.ts +5 -4
  8. package/dist/cluster.js +2 -3
  9. package/dist/consolidate.js +3 -5
  10. package/dist/db.js +23 -0
  11. package/dist/dedup.js +1 -1
  12. package/dist/distiller.js +2 -2
  13. package/dist/domain-classify.d.ts +1 -1
  14. package/dist/domain-classify.js +1 -5
  15. package/dist/eval/run-eval.js +0 -1
  16. package/dist/index.js +0 -1
  17. package/dist/init.d.ts +165 -0
  18. package/dist/init.js +283 -57
  19. package/dist/mcp-server.js +71 -25
  20. package/dist/nightly.js +121 -12
  21. package/dist/nofit.d.ts +1 -1
  22. package/dist/nofit.js +1 -2
  23. package/dist/pi-transcript-reader.d.ts +1 -1
  24. package/dist/pi-transcript-reader.js +1 -1
  25. package/dist/prompts.js +0 -7
  26. package/dist/recall-index.d.ts +15 -19
  27. package/dist/recall-index.js +13 -23
  28. package/dist/redact.d.ts +2 -2
  29. package/dist/redact.js +2 -2
  30. package/dist/retrieval.d.ts +7 -7
  31. package/dist/retrieval.js +20 -24
  32. package/dist/schema-prototypes.d.ts +8 -13
  33. package/dist/schema-prototypes.js +13 -22
  34. package/dist/seed-lesson.js +0 -1
  35. package/dist/storage.d.ts +9 -12
  36. package/dist/storage.js +19 -21
  37. package/dist/telemetry.d.ts +13 -2
  38. package/dist/telemetry.js +5 -1
  39. package/dist/types.d.ts +70 -24
  40. package/domains.example.json +2 -3
  41. package/hermes-plugin/hicortex/README.md +3 -1
  42. package/hermes-plugin/hicortex/config.py +34 -3
  43. package/hermes-plugin/hicortex/plugin.yaml +1 -1
  44. package/hermes-plugin/hicortex/provider.py +7 -1
  45. package/package.json +1 -1
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}`);
@@ -70,6 +70,7 @@ const recall_index_js_1 = require("./recall-index.js");
70
70
  const seed_lesson_js_1 = require("./seed-lesson.js");
71
71
  const distiller_js_1 = require("./distiller.js");
72
72
  const dedup_js_1 = require("./dedup.js");
73
+ const init_js_1 = require("./init.js");
73
74
  // ---------------------------------------------------------------------------
74
75
  // Server state
75
76
  // ---------------------------------------------------------------------------
@@ -78,6 +79,21 @@ let llm = null;
78
79
  // llmConfig is module-level so the /distill handler can call resolveDistillFallback
79
80
  // without having to read config on every request. null when no LLM is configured.
80
81
  let llmConfig = null;
82
+ // One-time-per-process deprecation warning for the `?privacy=` query param
83
+ // (0.16.x: the column is vestigial, never filtered). Old clients/plugins still
84
+ // send it; we accept it (backward compat) but warn ONCE so an operator relying
85
+ // on privacy filtering discovers from the logs that it is now a no-op.
86
+ let privacyDeprecationWarned = false;
87
+ function warnDeprecatedPrivacyParamIfPresent(query, route) {
88
+ if (privacyDeprecationWarned)
89
+ return;
90
+ if (query.privacy === undefined || query.privacy === null || query.privacy === "")
91
+ return;
92
+ privacyDeprecationWarned = true;
93
+ console.warn(`[hicortex] client sent ?privacy= on /${route}, which is ignored since 0.16.2 — ` +
94
+ `privacy is no longer filtered server-side (the column is vestigial). ` +
95
+ `Use a separate Hicortex server for isolation. (This warning fires once per process.)`);
96
+ }
81
97
  // distillFallbackMode controls whether a failed remote distill endpoint causes an
82
98
  // immediate abort ("strict", default) or a fallback to the base model ("local").
83
99
  let distillFallbackMode = "strict";
@@ -174,7 +190,6 @@ function createMcpServer() {
174
190
  sourceAgent: "claude-code/manual",
175
191
  project,
176
192
  memoryType: memory_type ?? "episode",
177
- privacy: "WORK",
178
193
  });
179
194
  return { content: [{ type: "text", text: `Memory stored (id: ${id.slice(0, 8)})` }] };
180
195
  }
@@ -360,6 +375,17 @@ async function startServer(options = {}) {
360
375
  // goes through resolveExplicitLlmConfig which requires a user-chosen provider.
361
376
  // If nothing is configured: start recall-only with an unmissable warning.
362
377
  const savedConfig = (0, llm_js_1.applyModelsBlock)(readConfigFile(stateDir));
378
+ // 0.16.2 activation gap: self-heal the agentId provenance field for
379
+ // pre-0.16.2 server installs on first boot after upgrade. The server's own
380
+ // nightly captures its sessions to localhost:8787/distill and needs this id;
381
+ // without it every self-captured memory landed with source_agent_id NULL.
382
+ // Hardened wrapper: throws on a malformed config instead of wiping, saves
383
+ // only when a new id was generated. Mutate savedConfig so any downstream
384
+ // read picks up the id even before the file is re-read.
385
+ if (savedConfig) {
386
+ const { agentId } = (0, init_js_1.ensureAndPersistAgentId)((0, node_path_1.join)(stateDir, "config.json"));
387
+ savedConfig.agentId = agentId;
388
+ }
363
389
  if (savedConfig?.llmBackend === "claude-cli") {
364
390
  const claudePath = (0, llm_js_1.findClaudeBinary)();
365
391
  if (claudePath) {
@@ -597,7 +623,7 @@ async function startServer(options = {}) {
597
623
  res.status(503).json({ error: "Server not initialized" });
598
624
  return;
599
625
  }
600
- const { content, source_agent, project, memory_type, privacy, source_session, session_date } = req.body ?? {};
626
+ const { content, source_agent, source_agent_id, source_domain, project, memory_type, privacy, source_session, session_date } = req.body ?? {};
601
627
  if (!content || typeof content !== "string") {
602
628
  res.status(400).json({ error: "Missing or invalid 'content' field" });
603
629
  return;
@@ -619,10 +645,15 @@ async function startServer(options = {}) {
619
645
  const embedding = await (0, embedder_js_1.embed)(content);
620
646
  const id = storage.insertMemory(db, content, embedding, {
621
647
  sourceAgent: source_agent ?? "remote-client",
648
+ // Attribution + provenance passthrough (0.16.x); null when absent.
649
+ sourceAgentId: typeof source_agent_id === "string" ? source_agent_id : null,
650
+ sourceDomain: typeof source_domain === "string" ? source_domain : null,
622
651
  sourceSession: source_session ?? undefined,
623
652
  project: project ?? undefined,
624
653
  memoryType: memory_type ?? "episode",
625
- privacy: privacy ?? "WORK",
654
+ // 0.16.x: privacy defaults to null (vestigial column). A legacy client
655
+ // that sends an explicit value is honored; absent → null.
656
+ privacy: typeof privacy === "string" ? privacy : null,
626
657
  createdAt: session_date ? new Date(session_date).toISOString() : undefined,
627
658
  });
628
659
  res.status(201).json({ id, message: "Memory ingested" });
@@ -646,11 +677,12 @@ async function startServer(options = {}) {
646
677
  // No hardcoded default: absent limit → config-driven (searchLimit).
647
678
  const limit = req.query.limit ? Number(req.query.limit) : undefined;
648
679
  const project = typeof req.query.project === "string" && req.query.project ? req.query.project : undefined;
649
- const privacy = typeof req.query.privacy === "string" && req.query.privacy
650
- ? req.query.privacy.split(",").map((s) => s.trim()).filter(Boolean)
651
- : undefined;
680
+ // 0.16.x: `privacy` query param is ACCEPTED for backward compat (old
681
+ // clients/plugins still send it) but no longer read — retrieval ignores
682
+ // privacy entirely (the column is vestigial, never filtered).
683
+ warnDeprecatedPrivacyParamIfPresent(req.query, "search");
652
684
  try {
653
- const results = await retrieval.retrieve(db, embedder_js_1.embed, query, { limit, project, privacy });
685
+ const results = await retrieval.retrieve(db, embedder_js_1.embed, query, { limit, project });
654
686
  res.json({ results });
655
687
  }
656
688
  catch (err) {
@@ -692,9 +724,9 @@ async function startServer(options = {}) {
692
724
  limit,
693
725
  noStrengthen: true,
694
726
  // #203: project + mission_domains are SOFT affinity (zero-boost
695
- // neutral), threaded into computeScore. privacy stays a hard filter.
727
+ // neutral), threaded into computeScore. 0.16.x: privacy is no
728
+ // longer threaded (vestigial column, never filtered).
696
729
  project: filters?.project,
697
- privacy: filters?.privacy,
698
730
  missionDomains: filters?.mission_domains,
699
731
  queryEmbedding: queryVec,
700
732
  });
@@ -703,17 +735,18 @@ async function startServer(options = {}) {
703
735
  }, req.body);
704
736
  res.status(r.status).json(r.body);
705
737
  });
706
- // REST /memory?id=[&privacy=] — fetch one memory's full content (lazy-load
707
- // counterpart of /recall-index for REST clients: Hermes/OC plugins). Marks
708
- // it as used. Prefix ids resolve; a privacy filter miss reads as 404 (no
709
- // existence leak). Logic in handleMemoryGet (recall-index.ts).
738
+ // REST /memory?id= — fetch one memory's full content (lazy-load counterpart
739
+ // of /recall-index for REST clients: Hermes/OC plugins). Marks it as used.
740
+ // Prefix ids resolve. 0.16.x: the `privacy` query param is accepted but
741
+ // ignored (column is vestigial, never filtered). Logic in handleMemoryGet.
710
742
  app.get("/memory", (req, res) => {
711
743
  if (!db) {
712
744
  res.status(503).json({ error: "Server not initialized" });
713
745
  return;
714
746
  }
747
+ warnDeprecatedPrivacyParamIfPresent(req.query, "memory");
715
748
  try {
716
- const r = (0, recall_index_js_1.handleMemoryGet)(db, { id: req.query.id, privacy: req.query.privacy });
749
+ const r = (0, recall_index_js_1.handleMemoryGet)(db, { id: req.query.id });
717
750
  res.status(r.status).json(r.body);
718
751
  }
719
752
  catch (err) {
@@ -728,12 +761,11 @@ async function startServer(options = {}) {
728
761
  }
729
762
  const project = typeof req.query.project === "string" && req.query.project ? req.query.project : undefined;
730
763
  // No hardcoded default: absent limit → config-driven (recentLimit).
764
+ // 0.16.x: `privacy` query param accepted but ignored (vestigial column).
731
765
  const limit = req.query.limit ? Number(req.query.limit) : undefined;
732
- const privacy = typeof req.query.privacy === "string" && req.query.privacy
733
- ? req.query.privacy.split(",").map((s) => s.trim()).filter(Boolean)
734
- : undefined;
766
+ warnDeprecatedPrivacyParamIfPresent(req.query, "recent");
735
767
  try {
736
- const results = retrieval.searchRecent(db, { project, limit, privacy });
768
+ const results = retrieval.searchRecent(db, { project, limit });
737
769
  res.json({ results });
738
770
  }
739
771
  catch (err) {
@@ -807,7 +839,7 @@ async function startServer(options = {}) {
807
839
  res.status(503).json({ error: "No LLM configured — run npx @gamaze/hicortex init. Session will be retried." });
808
840
  return;
809
841
  }
810
- const { text, messages, source_agent, project, session_id, segment_id, session_date, privacy } = req.body ?? {};
842
+ const { text, messages, source_agent, source_agent_id, source_domain, project, session_id, segment_id, session_date, privacy } = req.body ?? {};
811
843
  // Resolve the conversation text from either the pre-denoised string or raw messages array.
812
844
  let conversationText;
813
845
  if (typeof text === "string" && text.length > 0) {
@@ -898,12 +930,18 @@ async function startServer(options = {}) {
898
930
  for (const { entry, embedding, i } of toStore) {
899
931
  out.push(storage.insertMemory(db, entry, embedding, {
900
932
  sourceAgent: source_agent ?? "unknown",
933
+ // Attribution + provenance only (0.16.x): client-declared, never
934
+ // filtered. Default null for older clients that don't send them.
935
+ sourceAgentId: typeof source_agent_id === "string" ? source_agent_id : null,
936
+ sourceDomain: typeof source_domain === "string" ? source_domain : null,
901
937
  // Per-chunk key: "<session_id>[#<segment_id>]#<i>". The prefix
902
938
  // matches the dedup checks above, so a re-run is idempotent.
903
939
  sourceSession: sourcePrefix ? `${sourcePrefix}#${i}` : undefined,
904
940
  project: project ?? undefined,
905
941
  memoryType: "episode",
906
- privacy: privacy ?? "WORK",
942
+ // 0.16.x: privacy defaults to null (vestigial column). A legacy
943
+ // client that sends an explicit value is honored; absent → null.
944
+ privacy: typeof privacy === "string" ? privacy : null,
907
945
  createdAt,
908
946
  }));
909
947
  }
@@ -1250,17 +1288,25 @@ async function startServer(options = {}) {
1250
1288
  const resolveMemoryId = storage.resolveMemoryId;
1251
1289
  /**
1252
1290
  * Read ~/.hicortex/config.json (persisted by init with LLM and license config).
1291
+ * Routes through loadConfigStrict: a malformed existing file (bad JSON /
1292
+ * non-object / unreadable) emits a visible WARN then fails-soft to null; an
1293
+ * absent file (ENOENT) silently returns null. Without this routing the
1294
+ * agentId self-heal's throw would be unreachable from boot (the old swallow
1295
+ * → null → `if (savedConfig)` guard skipped it).
1253
1296
  */
1254
1297
  function readConfigFile(stateDir) {
1298
+ const configPath = (0, node_path_1.join)(stateDir, "config.json");
1299
+ let loaded;
1255
1300
  try {
1256
- const { readFileSync } = require("node:fs");
1257
- const { join } = require("node:path");
1258
- const configPath = join(stateDir, "config.json");
1259
- return JSON.parse(readFileSync(configPath, "utf-8"));
1301
+ loaded = (0, init_js_1.loadConfigStrict)(configPath);
1260
1302
  }
1261
- catch {
1303
+ catch (e) {
1304
+ console.warn(`[hicortex] ${configPath} exists but could not be parsed — server booting degraded ` +
1305
+ `(config-driven LLM/decay/recall knobs and agentId self-heal will not apply). ` +
1306
+ `Fix the JSON and restart. Cause: ${e instanceof Error ? e.message : String(e)}`);
1262
1307
  return null;
1263
1308
  }
1309
+ return loaded.hadFile ? loaded.config : null;
1264
1310
  }
1265
1311
  /**
1266
1312
  * Self-heal: if the daemon plist/systemd unit has a pinned version