@gamaze/hicortex 0.12.1 → 0.13.1

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.
@@ -23,9 +23,14 @@
23
23
  * CC session, and a broken /context fetch must never blank the whole output.
24
24
  */
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.titleCaseSection = titleCaseSection;
27
+ exports.orderSectionNames = orderSectionNames;
28
+ exports.renderContextBlock = renderContextBlock;
29
+ exports.gateAndRenderContext = gateAndRenderContext;
26
30
  exports.fetchLessonsContext = fetchLessonsContext;
27
31
  const node_fs_1 = require("node:fs");
28
32
  const node_path_1 = require("node:path");
33
+ const context_store_js_1 = require("./context-store.js");
29
34
  const features_js_1 = require("./features.js");
30
35
  const extensions_js_1 = require("./extensions.js");
31
36
  const state_js_1 = require("./state.js");
@@ -50,7 +55,13 @@ function resolveConfig() {
50
55
  const serverUrl = config.mode === "client" && typeof config.serverUrl === "string"
51
56
  ? config.serverUrl.replace(/\/+$/, "")
52
57
  : `http://127.0.0.1:${config.port ?? DEFAULT_PORT}`;
53
- return { serverUrl, authToken: config.authToken, home };
58
+ // Per-agent context id (0.13) via the shared resolver, so the id sent here
59
+ // always matches what `hicortex status` reports. No configured agentName →
60
+ // agentId null → NO ?agent= (bare fetch): CC's default is the shared global
61
+ // context. A configured agentName that sanitizes to null → agentId null too
62
+ // (NO ?agent=), never a 400 that the fail-soft hook would silently swallow.
63
+ const agentName = (0, context_store_js_1.resolveAgentIdentity)(config).agentId;
64
+ return { serverUrl, authToken: config.authToken, home, agentName };
54
65
  }
55
66
  function authHeaders(authToken) {
56
67
  return authToken ? { "Authorization": `Bearer ${authToken}` } : {};
@@ -112,6 +123,8 @@ async function fetchLessonsBlock(cfg) {
112
123
  /**
113
124
  * Title-case a section name for its heading: split on `-`/`_`, capitalize each
114
125
  * word ("user" → "User", "my_notes" → "My Notes").
126
+ * Exported so the OC plugin (index.ts) renders the `## Context` block
127
+ * identically to the CC hook rather than duplicating the logic.
115
128
  */
116
129
  function titleCaseSection(name) {
117
130
  return name
@@ -124,7 +137,7 @@ function titleCaseSection(name) {
124
137
  * Stable section ordering: `user` first, then `rules` (the seeded primary
125
138
  * sections, spec §8), then every other section alphabetically. Server-side
126
139
  * enumeration order (readdirSync) is FS-dependent, so we sort here for a
127
- * deterministic injection block.
140
+ * deterministic injection block. Exported for reuse by the OC plugin.
128
141
  */
129
142
  function orderSectionNames(names) {
130
143
  const primaries = ["user", "rules"].filter((p) => names.includes(p));
@@ -132,23 +145,13 @@ function orderSectionNames(names) {
132
145
  return [...primaries, ...rest];
133
146
  }
134
147
  /**
135
- * Fetch /context and build the `## Context` block, or null when nothing should
136
- * be injected: non-2xx, this harness not in `clients`, no sections, or all
137
- * sections empty. Throws propagate to the caller's fail-soft catch.
148
+ * Render the `## Context` block from a resolved section map, or null when there
149
+ * is nothing to inject (no sections, or every section blank after trimming).
150
+ * Pure — no gating, no I/O. Shared verbatim by the CC hook and the OC plugin so
151
+ * both harnesses emit an identical block. Sections are ordered (user, rules,
152
+ * then alphabetical) and rendered under title-cased `###` headings.
138
153
  */
139
- async function fetchContextBlock(cfg) {
140
- const resp = await fetch(`${cfg.serverUrl}/context`, {
141
- headers: authHeaders(cfg.authToken),
142
- signal: AbortSignal.timeout(3000),
143
- });
144
- if (!resp.ok)
145
- return null;
146
- const data = await resp.json();
147
- // Self-gate: only inject when this harness is in the server-resolved list.
148
- const clients = Array.isArray(data.clients) ? data.clients : [];
149
- if (!clients.includes(THIS_HARNESS))
150
- return null;
151
- const sections = data.sections;
154
+ function renderContextBlock(sections) {
152
155
  if (!sections || typeof sections !== "object" || Array.isArray(sections))
153
156
  return null;
154
157
  const names = orderSectionNames(Object.keys(sections));
@@ -163,6 +166,62 @@ async function fetchContextBlock(cfg) {
163
166
  return null;
164
167
  return ["## Context", "", ...bodyParts].join("\n");
165
168
  }
169
+ /**
170
+ * Gate a GET /context response and render the `## Context` block, or null when
171
+ * nothing should be injected: `harness` not in the server-resolved `clients`,
172
+ * an empty/blank section set, or — when `requireAgentEcho` — a response that
173
+ * does not echo `agent`. The SINGLE gate used by both CC and OC so the two can
174
+ * never drift (the Python Hermes plugin `provider.py::_context_block` mirrors
175
+ * this logic — keep them in sync).
176
+ *
177
+ * `requireAgentEcho` is the old-server guard, and it is the CALLER's decision:
178
+ * - OC passes `agentId !== null` — when it actually sent an id, a 0.12 server
179
+ * that ignores `?agent=` (200 global, no echo) must NOT leak global context
180
+ * into every persona; on a bare fetch (no id) the guard is off (amendment
181
+ * A2).
182
+ * - CC passes `false` ALWAYS and deliberately (see the call site): a thin CC
183
+ * client auto-upgrades via npx BEFORE bedrock does, so during the upgrade
184
+ * window it talks to a 0.12 server that cannot hold ANY per-agent config —
185
+ * global IS the operator's intended state there, and a guard would instead
186
+ * blank ALL context for every CC session in that window.
187
+ */
188
+ function gateAndRenderContext(data, harness, opts) {
189
+ if (!data || typeof data !== "object")
190
+ return null;
191
+ const clients = Array.isArray(data.clients) ? data.clients : [];
192
+ if (!clients.includes(harness))
193
+ return null;
194
+ if (opts.requireAgentEcho && typeof data.agent !== "string")
195
+ return null;
196
+ return renderContextBlock(data.sections ?? {});
197
+ }
198
+ /**
199
+ * Fetch /context and build the `## Context` block, or null when nothing should
200
+ * be injected: non-2xx, this harness not in `clients`, no sections, or all
201
+ * sections empty. Throws propagate to the caller's fail-soft catch.
202
+ */
203
+ async function fetchContextBlock(cfg) {
204
+ // Send ?agent= only when we have a valid id; the server does the merge and
205
+ // returns the resolved sections, so the hook stays dumb (no client-side mode
206
+ // logic). A null id (CC's default: no configured agentName, or a configured
207
+ // value that sanitizes to nothing) → bare /context → the shared global set.
208
+ const url = cfg.agentName
209
+ ? `${cfg.serverUrl}/context?agent=${encodeURIComponent(cfg.agentName)}`
210
+ : `${cfg.serverUrl}/context`;
211
+ const resp = await fetch(url, {
212
+ headers: authHeaders(cfg.authToken),
213
+ signal: AbortSignal.timeout(3000),
214
+ });
215
+ if (!resp.ok)
216
+ return null;
217
+ const data = await resp.json();
218
+ // CC deliberately passes requireAgentEcho: false (NOT the OC/Hermes old-server
219
+ // guard). A thin CC client auto-upgrades via npx BEFORE bedrock does, so
220
+ // mid-upgrade it may hit a 0.12 server that returns global context with no
221
+ // `agent` echo — and a 0.12 server cannot hold per-agent config, so global is
222
+ // the intended state. Guarding here would blank ALL CC context in that window.
223
+ return gateAndRenderContext(data, THIS_HARNESS, { requireAgentEcho: false });
224
+ }
166
225
  /**
167
226
  * Fetch context + lessons concurrently and return the combined Markdown block,
168
227
  * or null when neither yields anything (nothing to inject; caller prints
package/dist/llm.d.ts CHANGED
@@ -68,6 +68,25 @@ export declare function resolveExplicitLlmConfig(overrides?: {
68
68
  * the transition for any lingering call sites — remove after 0.10.0 ships.
69
69
  */
70
70
  export declare const resolveLlmConfigForCC: typeof resolveExplicitLlmConfig;
71
+ export type { ModelTierOverride } from "./types.js";
72
+ /**
73
+ * Normalize a nested `models: { <tier>: {model,baseUrl,apiKey,provider} }` block
74
+ * onto the flat `llm*` / `distill*` / `reflect*` / `classify*` keys the resolver
75
+ * already consumes. Nested overrides WIN over any flat key of the same name; every
76
+ * non-mapped key (llmBackend, licenseKey, distillFallback, contextClients, …)
77
+ * is preserved via spread. Pure: returns the SAME reference when there is no
78
+ * `models` key, so this is a provable no-op for every existing install.
79
+ *
80
+ * Robust to a malformed config.json: a config that parses to a scalar, array,
81
+ * or null is returned untouched (matching the pre-0.13.1 optional-chaining
82
+ * tolerance — this function must never throw at server/nightly boot).
83
+ *
84
+ * Fail-explicit (warn + skip, never throw): an invalid `models` value, an
85
+ * unknown tier name, a non-object tier value, a non-string field value, a tier
86
+ * apiKey/provider set without a baseUrl (they are baseUrl-gated downstream), and
87
+ * a dead score apiKey/provider under an ollama base.
88
+ */
89
+ export declare function applyModelsBlock(saved: Record<string, unknown> | null): Record<string, unknown> | null;
71
90
  /**
72
91
  * Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
73
92
  *
@@ -79,8 +98,12 @@ export declare const resolveLlmConfigForCC: typeof resolveExplicitLlmConfig;
79
98
  *
80
99
  * Returns `reason: "claude_binary_missing"` when claude-cli is configured but
81
100
  * the binary can't be found, so callers can log a context-specific message.
101
+ *
102
+ * `findBinary` is injectable (defaults to the real `findClaudeBinary`) so the
103
+ * claude-cli branch — including the missing-binary passthrough — can be pinned
104
+ * deterministically in tests without depending on the host filesystem.
82
105
  */
83
- export declare function resolveSavedLlmConfig(savedConfig: Record<string, unknown> | null): {
106
+ export declare function resolveSavedLlmConfig(savedConfig: Record<string, unknown> | null, findBinary?: () => string | null): {
84
107
  config: LlmConfig | null;
85
108
  reason?: "claude_binary_missing";
86
109
  };
package/dist/llm.js CHANGED
@@ -17,6 +17,7 @@
17
17
  Object.defineProperty(exports, "__esModule", { value: true });
18
18
  exports.LlmClient = exports.RateLimitError = exports.resolveLlmConfigForCC = void 0;
19
19
  exports.resolveExplicitLlmConfig = resolveExplicitLlmConfig;
20
+ exports.applyModelsBlock = applyModelsBlock;
20
21
  exports.resolveSavedLlmConfig = resolveSavedLlmConfig;
21
22
  exports.resolveClassifyProbeTarget = resolveClassifyProbeTarget;
22
23
  exports.findClaudeBinary = findClaudeBinary;
@@ -70,6 +71,117 @@ function resolveExplicitLlmConfig(overrides) {
70
71
  * the transition for any lingering call sites — remove after 0.10.0 ships.
71
72
  */
72
73
  exports.resolveLlmConfigForCC = resolveExplicitLlmConfig;
74
+ /**
75
+ * Map from a `models.<tier>` name to the flat config keys it feeds. The base
76
+ * tier is `score` — score IS the base model today (completeFast reads
77
+ * config.model), so it lands on the llm* keys and its `provider` is ignored
78
+ * (the base provider comes from llmBackend / detectProvider, not config).
79
+ * Tiers with a `provider` key (distill/reflect/classify) apply their apiKey +
80
+ * provider through a baseUrl-gated overlay downstream; `score` (no provider
81
+ * key) rides the base resolution.
82
+ */
83
+ const MODELS_TIER_KEYS = {
84
+ score: { model: "llmModel", baseUrl: "llmBaseUrl", apiKey: "llmApiKey" },
85
+ distill: { model: "distillModel", baseUrl: "distillBaseUrl", apiKey: "distillApiKey", provider: "distillProvider" },
86
+ reflect: { model: "reflectModel", baseUrl: "reflectBaseUrl", apiKey: "reflectApiKey", provider: "reflectProvider" },
87
+ classify: { model: "classifyModel", baseUrl: "classifyBaseUrl", apiKey: "classifyApiKey", provider: "classifyProvider" },
88
+ };
89
+ /**
90
+ * Normalize a nested `models: { <tier>: {model,baseUrl,apiKey,provider} }` block
91
+ * onto the flat `llm*` / `distill*` / `reflect*` / `classify*` keys the resolver
92
+ * already consumes. Nested overrides WIN over any flat key of the same name; every
93
+ * non-mapped key (llmBackend, licenseKey, distillFallback, contextClients, …)
94
+ * is preserved via spread. Pure: returns the SAME reference when there is no
95
+ * `models` key, so this is a provable no-op for every existing install.
96
+ *
97
+ * Robust to a malformed config.json: a config that parses to a scalar, array,
98
+ * or null is returned untouched (matching the pre-0.13.1 optional-chaining
99
+ * tolerance — this function must never throw at server/nightly boot).
100
+ *
101
+ * Fail-explicit (warn + skip, never throw): an invalid `models` value, an
102
+ * unknown tier name, a non-object tier value, a non-string field value, a tier
103
+ * apiKey/provider set without a baseUrl (they are baseUrl-gated downstream), and
104
+ * a dead score apiKey/provider under an ollama base.
105
+ */
106
+ function applyModelsBlock(saved) {
107
+ // Guard the container itself first — `"models" in saved` throws a TypeError on
108
+ // a truthy non-object (config.json = `true`/`5`/`"x"`); such configs must pass
109
+ // through so the boot path degrades to recall-only exactly as before.
110
+ if (typeof saved !== "object" || saved === null || Array.isArray(saved))
111
+ return saved;
112
+ if (!("models" in saved))
113
+ return saved;
114
+ const models = saved.models;
115
+ if (typeof models !== "object" || models === null || Array.isArray(models)) {
116
+ console.warn(`[hicortex] Ignoring invalid "models" config: expected an object of per-tier overrides, got ${Array.isArray(models) ? "array" : models === null ? "null" : typeof models}`);
117
+ return saved;
118
+ }
119
+ const ollamaBase = saved.llmBackend === "ollama";
120
+ const mapped = {};
121
+ for (const [tier, value] of Object.entries(models)) {
122
+ const keys = MODELS_TIER_KEYS[tier];
123
+ if (!keys) {
124
+ console.warn(`[hicortex] Ignoring unknown "models" tier "${tier}" (expected: score, distill, reflect, classify)`);
125
+ continue;
126
+ }
127
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
128
+ console.warn(`[hicortex] Ignoring invalid "models.${tier}" override: expected an object with model/baseUrl/apiKey/provider`);
129
+ continue;
130
+ }
131
+ const o = value;
132
+ // Per-field string validation: a non-string value would map verbatim and
133
+ // fail opaquely downstream (e.g. baseUrl: 11434), so drop it with a warning.
134
+ const strField = (name) => {
135
+ const v = o[name];
136
+ if (v === undefined)
137
+ return undefined;
138
+ if (typeof v !== "string") {
139
+ console.warn(`[hicortex] Ignoring non-string "models.${tier}.${name}" (expected a string)`);
140
+ return undefined;
141
+ }
142
+ return v;
143
+ };
144
+ const model = strField("model");
145
+ const baseUrl = strField("baseUrl");
146
+ const apiKey = strField("apiKey");
147
+ const provider = strField("provider");
148
+ if (model !== undefined)
149
+ mapped[keys.model] = model;
150
+ if (baseUrl !== undefined)
151
+ mapped[keys.baseUrl] = baseUrl;
152
+ if (keys.provider) {
153
+ // Overlay tier (distill/reflect/classify): the downstream overlay only
154
+ // consumes apiKey/provider when the tier ALSO sets its own baseUrl.
155
+ // Without one, they would silently bill to the base key — so warn + drop.
156
+ if ((apiKey !== undefined || provider !== undefined) && baseUrl === undefined) {
157
+ console.warn(`[hicortex] Ignoring "models.${tier}" apiKey/provider without a baseUrl: they only take effect when the tier sets its own baseUrl`);
158
+ }
159
+ else {
160
+ if (apiKey !== undefined)
161
+ mapped[keys.apiKey] = apiKey;
162
+ if (provider !== undefined)
163
+ mapped[keys.provider] = provider;
164
+ }
165
+ }
166
+ else {
167
+ // score = base tier: no separate provider key, and apiKey rides llmApiKey.
168
+ if (provider !== undefined) {
169
+ console.warn(`[hicortex] Ignoring "models.score.provider": the base provider comes from llmBackend (or is auto-detected from the endpoint)`);
170
+ }
171
+ if (apiKey !== undefined) {
172
+ if (ollamaBase) {
173
+ // The ollama base path hardcodes an empty api key and never reads
174
+ // llmApiKey, so score.apiKey is dead there.
175
+ console.warn(`[hicortex] Ignoring "models.score.apiKey": the base ollama path sends no api key`);
176
+ }
177
+ else {
178
+ mapped[keys.apiKey] = apiKey;
179
+ }
180
+ }
181
+ }
182
+ }
183
+ return { ...saved, ...mapped };
184
+ }
73
185
  /**
74
186
  * Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
75
187
  *
@@ -81,11 +193,16 @@ exports.resolveLlmConfigForCC = resolveExplicitLlmConfig;
81
193
  *
82
194
  * Returns `reason: "claude_binary_missing"` when claude-cli is configured but
83
195
  * the binary can't be found, so callers can log a context-specific message.
196
+ *
197
+ * `findBinary` is injectable (defaults to the real `findClaudeBinary`) so the
198
+ * claude-cli branch — including the missing-binary passthrough — can be pinned
199
+ * deterministically in tests without depending on the host filesystem.
84
200
  */
85
- function resolveSavedLlmConfig(savedConfig) {
201
+ function resolveSavedLlmConfig(savedConfig, findBinary = findClaudeBinary) {
202
+ savedConfig = applyModelsBlock(savedConfig);
86
203
  let llmConfig = null;
87
204
  if (savedConfig?.llmBackend === "claude-cli") {
88
- const claudePath = findClaudeBinary();
205
+ const claudePath = findBinary();
89
206
  if (claudePath) {
90
207
  llmConfig = claudeCliConfig(claudePath);
91
208
  }
@@ -81,6 +81,10 @@ let stateDir = "";
81
81
  // Resolved contextClients list (spec §2) — the harness names allowed to inject
82
82
  // the standing context layer. Echoed by GET /context so each hook self-gates.
83
83
  let contextClients = ["cc"];
84
+ // Resolved contextAgents map (0.13) — agent id → mode (override/global/off).
85
+ // Read once at boot (like contextClients); the drop-in-a-dir presence path is
86
+ // per-request, so only explicit config entries need a daemon restart to apply.
87
+ let contextAgents = {};
84
88
  // Cache detectChunkSize results keyed by "<provider>/<model>@<baseUrl>" so we
85
89
  // probe each endpoint once per server boot rather than once per /distill request.
86
90
  const chunkSizeCache = new Map();
@@ -328,7 +332,7 @@ async function startServer(options = {}) {
328
332
  // Named backends (claude-cli, ollama) → immediate config; everything else
329
333
  // goes through resolveExplicitLlmConfig which requires a user-chosen provider.
330
334
  // If nothing is configured: start recall-only with an unmissable warning.
331
- const savedConfig = readConfigFile(stateDir);
335
+ const savedConfig = (0, llm_js_1.applyModelsBlock)(readConfigFile(stateDir));
332
336
  if (savedConfig?.llmBackend === "claude-cli") {
333
337
  const claudePath = (0, llm_js_1.findClaudeBinary)();
334
338
  if (claudePath) {
@@ -436,6 +440,16 @@ async function startServer(options = {}) {
436
440
  console.warn(`[hicortex] Ignoring unknown contextClients: ${resolvedClients.dropped.join(", ")} ` +
437
441
  `(known: cc, hermes, oc)`);
438
442
  }
443
+ // Per-agent context (0.13): resolve the config-declared modes. Warn once per
444
+ // boot on dropped entries (bad key or bad mode) so typos surface. NOTE: this
445
+ // map is boot-time; editing contextAgents needs a daemon restart. Dropping an
446
+ // agents/<id> dir onto disk takes effect immediately (per-request presence).
447
+ const resolvedAgents = (0, context_store_js_1.resolveContextAgents)(savedConfig?.contextAgents);
448
+ contextAgents = resolvedAgents.agents;
449
+ if (resolvedAgents.dropped.length > 0) {
450
+ console.warn(`[hicortex] Ignoring invalid contextAgents entries: ${resolvedAgents.dropped.join(", ")} ` +
451
+ `(keys must match ^[a-z0-9][a-z0-9_-]*$; modes must be override|global|off)`);
452
+ }
439
453
  // Express app
440
454
  const app = (0, express_1.default)();
441
455
  // Raise the body limit — whole-session denoised transcripts exceed the 100 kB default.
@@ -620,7 +634,7 @@ async function startServer(options = {}) {
620
634
  // which the tests exercise directly — no mirror-app drift.
621
635
  app.get("/context", (req, res) => {
622
636
  try {
623
- const r = (0, context_store_js_1.handleContextGet)((0, node_path_1.join)(stateDir, "context"), contextClients, req.query);
637
+ const r = (0, context_store_js_1.handleContextGet)((0, node_path_1.join)(stateDir, "context"), contextClients, req.query, contextAgents);
624
638
  res.status(r.status).json(r.body);
625
639
  }
626
640
  catch (err) {
@@ -629,7 +643,7 @@ async function startServer(options = {}) {
629
643
  });
630
644
  app.put("/context", (req, res) => {
631
645
  try {
632
- const r = (0, context_store_js_1.handleContextPut)((0, node_path_1.join)(stateDir, "context"), req.body);
646
+ const r = (0, context_store_js_1.handleContextPut)((0, node_path_1.join)(stateDir, "context"), req.body, req.query, contextAgents);
633
647
  if (r.warn)
634
648
  console.warn(`[hicortex] ${r.warn}`);
635
649
  res.status(r.status).json(r.body);
@@ -703,7 +717,11 @@ async function startServer(options = {}) {
703
717
  ? `${session_id}${segment_id ? `#${segment_id}` : ""}`
704
718
  : undefined;
705
719
  try {
706
- const entries = await (0, distiller_js_1.distillSession)(llm, conversationText, project ?? "unknown", date, chunkSize);
720
+ // Collect gate-dropped entries so they can ride back in the response and
721
+ // land in the caller's file-persisted nightly log (#156 audit trail); the
722
+ // server-side per-entry console.log in distillChunk stays as well.
723
+ const dropped = [];
724
+ const entries = await (0, distiller_js_1.distillSession)(llm, conversationText, project ?? "unknown", date, chunkSize, dropped);
707
725
  const ids = [];
708
726
  for (let i = 0; i < entries.length; i++) {
709
727
  const entry = entries[i];
@@ -722,7 +740,11 @@ async function startServer(options = {}) {
722
740
  });
723
741
  ids.push(id);
724
742
  }
725
- res.status(201).json({ ids, distilled: ids.length });
743
+ res.status(201).json({
744
+ ids,
745
+ distilled: ids.length,
746
+ dropped: dropped.map((d) => (d.length > 120 ? `${d.slice(0, 120)}…` : d)),
747
+ });
726
748
  }
727
749
  catch (err) {
728
750
  res.status(500).json({ error: "Distillation failed", message: err instanceof Error ? err.message : String(err) });
@@ -18,6 +18,7 @@ const node_os_1 = require("node:os");
18
18
  const node_child_process_1 = require("node:child_process");
19
19
  const db_js_1 = require("./db.js");
20
20
  const state_js_1 = require("./state.js");
21
+ const llm_js_1 = require("./llm.js");
21
22
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
22
23
  const CONFIG_PATH = (0, node_path_1.join)(HICORTEX_HOME, "config.json");
23
24
  async function showNightlyStatus() {
@@ -36,7 +37,9 @@ async function showNightlyStatus() {
36
37
  }
37
38
  // LLM config
38
39
  try {
39
- const config = JSON.parse((0, node_fs_1.readFileSync)(CONFIG_PATH, "utf-8"));
40
+ // No `?? {}` coercion: a null/invalid parse must fall through to the catch
41
+ // below (as it did pre-0.13.1) rather than print a fabricated-healthy status.
42
+ const config = (0, llm_js_1.applyModelsBlock)(JSON.parse((0, node_fs_1.readFileSync)(CONFIG_PATH, "utf-8")));
40
43
  const backend = config.llmBackend ?? "auto-detect";
41
44
  const model = config.llmModel ?? "default";
42
45
  const mode = config.mode === "client" ? "client → " + (config.serverUrl ?? "?") : "server (local)";
package/dist/nightly.js CHANGED
@@ -228,6 +228,10 @@ async function runNightly(options = {}) {
228
228
  const data = await resp.json();
229
229
  memoriesIngested += data.distilled ?? 0;
230
230
  console.log(`[hicortex] → ${data.distilled ?? 0} memories extracted`);
231
+ // Durable audit trail (#156): the server truncates each dropped entry.
232
+ for (const d of data.dropped ?? []) {
233
+ console.log(`[hicortex] Substance gate: dropped "${d}"`);
234
+ }
231
235
  }
232
236
  else if (resp.status === 429) {
233
237
  const data = await resp.json();
@@ -444,6 +448,10 @@ async function runClientNightly(config, dryRun) {
444
448
  memoriesIngested += count;
445
449
  sessionsSent++;
446
450
  console.log(`[hicortex] → ${count} memories sent to server`);
451
+ // Durable audit trail (#156): the server truncates each dropped entry.
452
+ for (const d of data.dropped ?? []) {
453
+ console.log(`[hicortex] Substance gate: dropped "${d}"`);
454
+ }
447
455
  }
448
456
  else if (resp.status === 401) {
449
457
  console.error(`[hicortex] Auth failed. Check authToken in ~/.hicortex/config.json`);
package/dist/status.d.ts CHANGED
@@ -1,4 +1,13 @@
1
1
  /**
2
2
  * Hicortex status — show current configuration and stats.
3
3
  */
4
+ /**
5
+ * The value shown after "Agent name:" in `hicortex status` (#179, A3). Reports
6
+ * EXACTLY what the CC hook resolves (shared `resolveAgentIdentity`), so the
7
+ * operator never keys `contextAgents`/`agents/<id>/` on an id the install does
8
+ * not actually send. Unset → the install sends no `?agent=` and shares the
9
+ * global context (CC default). A configured-but-unsanitizable value is called
10
+ * out as invalid (the hook sends none) rather than silently accepted.
11
+ */
12
+ export declare function statusAgentLine(config: Record<string, unknown>): string;
4
13
  export declare function runStatus(): Promise<void>;
package/dist/status.js CHANGED
@@ -3,6 +3,7 @@
3
3
  * Hicortex status — show current configuration and stats.
4
4
  */
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.statusAgentLine = statusAgentLine;
6
7
  exports.runStatus = runStatus;
7
8
  const paths_js_1 = require("./paths.js");
8
9
  const node_fs_1 = require("node:fs");
@@ -12,9 +13,29 @@ const node_child_process_1 = require("node:child_process");
12
13
  const db_js_1 = require("./db.js");
13
14
  const features_js_1 = require("./features.js");
14
15
  const state_js_1 = require("./state.js");
16
+ const context_store_js_1 = require("./context-store.js");
15
17
  const HICORTEX_HOME = (0, paths_js_1.hicortexHome)();
16
18
  const CC_SETTINGS = (0, node_path_1.join)((0, node_os_1.homedir)(), ".claude", "settings.json");
17
19
  const OC_CONFIG = (0, node_path_1.join)((0, node_os_1.homedir)(), ".openclaw", "openclaw.json");
20
+ /**
21
+ * The value shown after "Agent name:" in `hicortex status` (#179, A3). Reports
22
+ * EXACTLY what the CC hook resolves (shared `resolveAgentIdentity`), so the
23
+ * operator never keys `contextAgents`/`agents/<id>/` on an id the install does
24
+ * not actually send. Unset → the install sends no `?agent=` and shares the
25
+ * global context (CC default). A configured-but-unsanitizable value is called
26
+ * out as invalid (the hook sends none) rather than silently accepted.
27
+ */
28
+ function statusAgentLine(config) {
29
+ const id = (0, context_store_js_1.resolveAgentIdentity)(config);
30
+ switch (id.source) {
31
+ case "configured":
32
+ return id.agentId;
33
+ case "invalid-config":
34
+ return `(invalid configured value "${id.rawConfigured}" — fix config.agentName; hook sends none)`;
35
+ default: // unset
36
+ return "(not set — global context)";
37
+ }
38
+ }
18
39
  async function runStatus() {
19
40
  console.log("Hicortex Status");
20
41
  console.log("─".repeat(40));
@@ -42,11 +63,12 @@ async function runStatus() {
42
63
  let licenseKey = "";
43
64
  let savedAuthToken = "";
44
65
  let isClientMode = false;
66
+ let parsedConfig = {};
45
67
  try {
46
- const config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
47
- licenseKey = config.licenseKey ?? "";
48
- savedAuthToken = config.authToken ?? "";
49
- isClientMode = config.mode === "client";
68
+ parsedConfig = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
69
+ licenseKey = parsedConfig.licenseKey ?? "";
70
+ savedAuthToken = parsedConfig.authToken ?? "";
71
+ isClientMode = parsedConfig.mode === "client";
50
72
  }
51
73
  catch { /* no config */ }
52
74
  const validated = (0, features_js_1.getValidatedLicense)();
@@ -65,6 +87,9 @@ async function runStatus() {
65
87
  else if (!isClientMode && !savedAuthToken) {
66
88
  console.log(`Auth token: not configured (run: npx @gamaze/hicortex init)`);
67
89
  }
90
+ // Per-agent context id (#179) — the id this install sends as ?agent= and the
91
+ // key operators use for contextAgents / agents/<id>/ dirs.
92
+ console.log(`Agent name: ${statusAgentLine(parsedConfig)}`);
68
93
  console.log();
69
94
  // Adapters
70
95
  console.log("Adapters:");
package/dist/types.d.ts CHANGED
@@ -115,6 +115,16 @@ export interface ConsolidationReport {
115
115
  calls_by_stage: Record<string, number>;
116
116
  };
117
117
  }
118
+ /**
119
+ * A single per-stage override inside the nested `models` server-config block.
120
+ * Consumed by `applyModelsBlock` (llm.ts), which re-exports this type.
121
+ */
122
+ export interface ModelTierOverride {
123
+ model?: string;
124
+ baseUrl?: string;
125
+ apiKey?: string;
126
+ provider?: string;
127
+ }
118
128
  /** Plugin configuration from openclaw.plugin.json configSchema. */
119
129
  export interface HicortexConfig {
120
130
  licenseKey?: string;
@@ -145,6 +155,16 @@ export interface HicortexConfig {
145
155
  classifyApiKey?: string;
146
156
  /** Optional provider for the classify endpoint (defaults to the base provider). */
147
157
  classifyProvider?: string;
158
+ /**
159
+ * Server config (NOT an OC-plugin key): nested per-stage model overrides.
160
+ * `{ score|distill|reflect|classify: { model?, baseUrl?, apiKey?, provider? } }`.
161
+ * Normalized onto the flat `llm*` / `distill*` / `reflect*` / `classify*`
162
+ * keys at read time (see applyModelsBlock in llm.ts); nested wins, and the
163
+ * flat keys remain supported at lower precedence. Happy path is a single model via
164
+ * `llmModel`; use this block only for per-stage routing. `score.provider` is
165
+ * ignored (base provider comes from llmBackend).
166
+ */
167
+ models?: Record<string, ModelTierOverride>;
148
168
  /** @deprecated Consolidation is owned by the server nightly. */
149
169
  consolidateHour?: number;
150
170
  /** @deprecated The OC plugin no longer opens its own database. */
@@ -14,11 +14,24 @@ Gives [Hermes](https://github.com/nousresearch/hermes-agent) agents self-learnin
14
14
  |---|---|---|
15
15
  | `prefetch(query)` | recall relevant memories before each turn | `GET /search` |
16
16
  | `queue_prefetch(query)` | background recall for the next turn | `GET /search` |
17
- | `system_prompt_block()` | inject distilled lessons + memory index | `GET /lessons` |
17
+ | `system_prompt_block()` | inject per-agent standing context + distilled lessons + memory index | `GET /context`, `GET /lessons` |
18
18
  | `get_tool_schemas()` | exposes the 8 unified tools | see tool table below |
19
19
 
20
20
  That's the whole surface. No `sync_turn`, no compaction/session-end capture — those are intentionally absent.
21
21
 
22
+ ### Per-agent standing context (0.13)
23
+
24
+ `system_prompt_block()` also injects the hand-edited **standing context layer** (`## Context`, above the lessons block) — "who you are + how to work", distinct from episodic memory. The server resolves it **per agent**: this profile's own sections override the global set (`override`), or it can be `global` or `off`. See the main repo's `/context` layer docs.
25
+
26
+ The plugin sends its **profile name** as `?agent=`, resolved in this order:
27
+
28
+ 1. `agent_name` in the plugin config (explicit override);
29
+ 2. the `HERMES_PROFILE` environment variable;
30
+ 3. a `HERMES_HOME` ending in `profiles/<name>` (the per-profile install path);
31
+ 4. none → the global context (backward compatible).
32
+
33
+ Leave `agent_name` blank to auto-derive (2–4). Context injection needs a Hicortex server **≥ 0.13**; against an older server the plugin detects the missing per-agent support and injects no context (lessons are unaffected). Context and lessons fail soft independently — a context failure never costs the lessons block.
34
+
22
35
  ### Tools (unified 8)
23
36
 
24
37
  | Tool | REST call | Description |
@@ -57,7 +70,7 @@ hermes memory setup # select "hicortex", enter the server URL/token when promp
57
70
 
58
71
  Run it once per profile if you use Hermes profiles. Hermes allows **one** external memory provider at a time, so disable Honcho (or any other) first, then restart the gateway.
59
72
 
60
- Config fields (`hicortex_url`, `default_project`, `recall_limit`, `privacy_filter`) can also be written to `$HERMES_HOME/plugins/hicortex/config.json` directly. The auth token is a **secret** — set it via env, not the JSON file:
73
+ Config fields (`hicortex_url`, `default_project`, `recall_limit`, `privacy_filter`, `agent_name`) can also be written to `$HERMES_HOME/plugins/hicortex/config.json` directly. `agent_name` pins the per-agent context id for this profile (leave blank to auto-derive — see [Per-agent standing context](#per-agent-standing-context-013)). The auth token is a **secret** — set it via env, not the JSON file:
61
74
 
62
75
  ```bash
63
76
  export HICORTEX_AUTH_TOKEN=hctx-default-token # or your custom token
@@ -96,6 +96,13 @@ class HicortexClient:
96
96
  def lessons(self) -> dict[str, Any]:
97
97
  return self._get("/lessons")
98
98
 
99
+ def context(self, agent: Optional[str] = None) -> dict[str, Any]:
100
+ """Standing context layer (L2). When ``agent`` is set, the server
101
+ resolves the per-agent scope and echoes ``agent``/``mode`` (0.13); a
102
+ pre-0.13 server ignores the param and returns the global set with no
103
+ echo — the caller uses that echo as an old-server guard."""
104
+ return self._get("/context", {"agent": agent})
105
+
99
106
  def index(self) -> dict[str, Any]:
100
107
  return self._get("/index")
101
108
 
@@ -57,6 +57,16 @@ CONFIG_SCHEMA: list[dict[str, Any]] = [
57
57
  "default": "WORK,PERSONAL",
58
58
  "required": False,
59
59
  },
60
+ {
61
+ "key": "agent_name",
62
+ "label": "Agent name (per-agent context)",
63
+ "description": (
64
+ "Identity sent as ?agent= when fetching the standing context layer, "
65
+ "so this profile gets its own context (0.13). Leave blank to "
66
+ "auto-derive from the running profile (HERMES_PROFILE / HERMES_HOME)."
67
+ ),
68
+ "required": False,
69
+ },
60
70
  # NOTE: recall-only plugin — no capture config. Capture is handled by the
61
71
  # nightly server-side reader of each agent's session store.
62
72
  ]
@@ -1,6 +1,6 @@
1
1
  name: hicortex
2
- version: 0.5.0
3
- description: "Self-learning memory for Hermes agents — every session is distilled into lessons overnight, and your agent wakes up wiser. Injects fresh lessons each turn and exposes the full 8-tool memory surface (search, recent, ingest, lessons, index, graph, update, delete) via a shared Hicortex server. Stdlib-only."
2
+ version: 0.6.0
3
+ description: "Self-learning memory for Hermes agents — every session is distilled into lessons overnight, and your agent wakes up wiser. Injects fresh lessons each turn plus a per-agent standing context block, and exposes the full 8-tool memory surface (search, recent, ingest, lessons, index, graph, update, delete) via a shared Hicortex server. Stdlib-only."
4
4
  pip_dependencies: []
5
5
  hooks: []
6
6
  requires_env: