@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 +8 -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 +2 -4
- package/dist/db.js +23 -0
- package/dist/dedup.js +1 -1
- package/dist/distiller.js +2 -2
- package/dist/domain-classify.d.ts +1 -1
- package/dist/domain-classify.js +1 -5
- package/dist/eval/run-eval.js +0 -1
- package/dist/index.js +0 -1
- package/dist/init.d.ts +165 -0
- package/dist/init.js +283 -57
- package/dist/mcp-server.js +71 -25
- package/dist/nightly.js +35 -3
- package/dist/nofit.d.ts +1 -1
- package/dist/nofit.js +1 -2
- package/dist/prompts.js +0 -7
- package/dist/recall-index.d.ts +13 -17
- package/dist/recall-index.js +11 -21
- 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.js +0 -1
- 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 +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
|
-
|
|
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}`);
|
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
|
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
|
|
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=
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|