@gamaze/hicortex 0.13.0 → 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.
package/README.md CHANGED
@@ -65,7 +65,7 @@ The plugin connects to `http://127.0.0.1:8787` by default. For a remote server,
65
65
 
66
66
  | When | What | How |
67
67
  |------|------|-----|
68
- | Agent start | Recent lessons fetched fresh and injected into context | CC SessionStart hook (calls `hicortex lessons-context`) / Hermes plugin prefetch / OC `before_agent_start` hook |
68
+ | Agent start | Standing context (`## Context`) + recent lessons fetched fresh and injected | CC SessionStart hook (calls `hicortex lessons-context`) / Hermes plugin `system_prompt_block` (+ `prefetch` recall) / OC `before_agent_start` hook |
69
69
  | Nightly | Denoise sessions → POST /distill → server distills + embeds + stores → consolidate (score, reflect, link, decay) | Automatic pipeline — no manual steps |
70
70
 
71
71
  ## Memory Domains & Tags
@@ -126,7 +126,7 @@ Beyond auto-distilled memories and lessons, Hicortex holds a **hand-edited conte
126
126
 
127
127
  - **Storage:** plain files on the server at `~/.hicortex/context/*.md` — one file per section (recommended starter sections `user.md` + `rules.md`, which you create — nothing is pre-populated; add more by dropping in a file). It lives outside the memories table; consolidation never touches it.
128
128
  - **Edit:** the web editor at `http://localhost:8787/context/ui` (one tab per section, Save), or the CLI `hicortex context show [name]` / `hicortex context edit <name>`.
129
- - **Delivery:** injected into the harnesses listed in `contextClients` (default `["cc"]` — Claude Code only in v1; `"all"` or any subset of `cc`/`hermes`/`oc`).
129
+ - **Delivery:** injected into the harnesses listed in `contextClients` (default `["cc"]`; `"all"` or any subset of `cc`/`hermes`/`oc` — all three are supported since 0.13).
130
130
  - **Deletion** is filesystem-only — remove the file on the server (as the daemon user).
131
131
 
132
132
  ### Per-agent context (0.13)
@@ -191,12 +191,9 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
191
191
  |-------|-------------|
192
192
  | `mode` | `"server"` (default) or `"client"` |
193
193
  | `serverUrl` | Remote server URL (client mode) |
194
- | `llmModel` | Model for importance scoring (server mode) |
195
- | `distillModel` | Model for session distillation9b+ recommended (server mode) |
196
- | `distillBaseUrl` | Separate Ollama instance for distillation (server mode) |
194
+ | `llmModel` | The one model the whole pipeline uses (server mode). One model is the happy path; route individual stages with `models` — see [Advanced: per-stage models](#advanced-per-stage-models) |
195
+ | `models` | Optional nested per-stage model overrides (`score`/`distill`/`reflect`/`classify`) see [Advanced: per-stage models](#advanced-per-stage-models) |
197
196
  | `distillFallback` | `"strict"` (default) — abort on remote distill failure, retry next run; `"local"` — fall back to base model (lower quality, 0.9.0 behaviour) |
198
- | `reflectModel` | Model for nightly reflection — largest available (server mode) |
199
- | `reflectBaseUrl` | Separate Ollama instance for reflection (server mode) |
200
197
  | `authToken` | Bearer token for endpoint auth. Generated on first `init` in server mode. Find the active token with `hicortex status` or in `~/.hicortex/config.json`. |
201
198
  | `licenseKey` | Commercial license key (optional; for display in `hicortex status`) |
202
199
  | `domains` | Your memory domain list (`[{name, description}]`). Scaffolded by `init`; edit freely — see [Memory Domains & Tags](#memory-domains--tags) |
@@ -263,6 +260,30 @@ LLM selection is **user-controlled**: `npx @gamaze/hicortex init` detects candid
263
260
 
264
261
  If no LLM is configured, the server starts in **recall-only mode**: search, lessons, and context work; `/distill` and consolidation are disabled. Run `npx @gamaze/hicortex init` to configure.
265
262
 
263
+ ### Advanced: per-stage models
264
+
265
+ The happy path is **one model** (`llmModel`) for the whole pipeline. If you want to route the four pipeline stages to different models or endpoints, add a nested `models` block to `~/.hicortex/config.json`:
266
+
267
+ ```json
268
+ {
269
+ "llmBackend": "ollama",
270
+ "llmModel": "qwen3.5:4b",
271
+ "models": {
272
+ "score": { "model": "qwen3.5:4b" },
273
+ "distill": { "model": "qwen3.5:35b-a3b", "baseUrl": "http://gpu-box:11434" },
274
+ "reflect": { "model": "qwen3.5:35b-a3b", "baseUrl": "http://gpu-box:11434" },
275
+ "classify": { "model": "gemma4-31b", "baseUrl": "http://gpu-box:11434" }
276
+ }
277
+ }
278
+ ```
279
+
280
+ The four tiers: `score` (importance scoring — this **is** the base model), `distill` (session distillation, 9b+ recommended), `reflect` (nightly reflection, largest available), `classify` (memory-domain tagging). Each accepts `model`, `baseUrl`, `apiKey`, and `provider`. Omitting a tier inherits: `distill` and `reflect` fall back to the **base** (`score`) model; **`classify` falls back to the `reflect` tier** (not the base — `classify` delegates to the reflect path when unset).
281
+
282
+ - **Flat keys still work.** `distillModel`/`distillBaseUrl`/`reflectModel`/`reflectBaseUrl`/`classifyModel`/`classifyBaseUrl` remain supported at **lower precedence** — a `models` entry wins over the flat key of the same name.
283
+ - **A tier's `apiKey`/`provider` require the tier's own `baseUrl`.** They only take effect when the tier sets `baseUrl`; set on a tier without a `baseUrl` they are **ignored with a warning** (a bare `models.reflect: { model, apiKey }` would otherwise silently bill to the base key). Set `provider` when a tier's `baseUrl` points at a different provider type than the base (e.g. an OpenAI-compatible API while the base is Ollama); set `apiKey` for an API-provider tier over an Ollama base, whose base key is empty (`""`).
284
+ - **`init` writes a flat `llmModel`.** The already-configured guard now recognizes a nested-only config (`models.score`), so re-running `init` on one is a no-op. But if a config has **both** a flat `llmModel` and a `models.score.model`, the nested value **shadows** the flat one (nested > flat) — keep the model in one place.
285
+ - **`score.provider` (and `score.apiKey` on an Ollama base) are ignored** (a warning is logged): the base provider comes from `llmBackend` (or is auto-detected from the base endpoint), and the Ollama base path sends no api key.
286
+
266
287
  ## Database
267
288
 
268
289
  Canonical location: `~/.hicortex/hicortex.db`. The OC plugin no longer owns its own database — it is a thin client to the server. Previously, OC installations at `~/.openclaw/data/hicortex.db` were migrated automatically on upgrade; this migration path remains in the server's `resolveDbPath` for any pre-0.10.0 installations.
@@ -26,5 +26,31 @@ export declare function extractConversationText(messages: unknown[], redactionCo
26
26
  * Send filtered conversation to LLM for knowledge extraction.
27
27
  * For large transcripts, chunks into segments to avoid overwhelming small models.
28
28
  * Returns an array of memory entries to ingest, or empty array if nothing worth extracting.
29
+ *
30
+ * `droppedOut`, when provided, is filled with every entry the substance gate
31
+ * discarded (full text). Callers use it to build a durable audit trail (#156);
32
+ * omitting it leaves gate behaviour unchanged.
33
+ */
34
+ export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number, droppedOut?: string[]): Promise<string[]>;
35
+ /**
36
+ * Reject ONLY structurally-empty distiller fragments before they become
37
+ * memories (#156). The distiller occasionally emits leftovers that parse into
38
+ * entries but carry no recallable content:
39
+ * - bare section prefixes: "[Specific AI Content:]", "[Facts Learned]"
40
+ * - echoed template placeholders: "[decision]: [reasoning] (2026-07-05)"
41
+ * - pseudo-header bullets: "**Facts Learned:**"
42
+ * - metadata-only lines: "(2026-07-05)"
43
+ *
44
+ * PRECISION OVER RECALL — deliberate trade: the gate rejects only shapes that
45
+ * are structurally empty of content, never on a length or word-count threshold.
46
+ * A kept artifact ("Classification: WORK" style) is cheaply pruned later by the
47
+ * no-fit decay path; a wrongly-dropped genuine memory is unrecoverable. So when
48
+ * in doubt, keep. Consequence documented for the reviewer: metadata lines like
49
+ * "Classification: WORK" now PASS the gate — that is intended.
50
+ *
51
+ * Stripping is scoped and anchored (one leading section prefix, one trailing
52
+ * date stamp), never global, so bracketed payloads ("use [ollama] not
53
+ * [claude-cli]") and content-bearing dates ("deadline moved (2026-08-01)")
54
+ * survive. Stripping affects only this gate's decision, never stored text.
29
55
  */
30
- export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number): Promise<string[]>;
56
+ export declare function hasMinimalSubstance(entry: string): boolean;
package/dist/distiller.js CHANGED
@@ -8,6 +8,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
8
8
  exports.detectChunkSize = detectChunkSize;
9
9
  exports.extractConversationText = extractConversationText;
10
10
  exports.distillSession = distillSession;
11
+ exports.hasMinimalSubstance = hasMinimalSubstance;
11
12
  const prompts_js_1 = require("./prompts.js");
12
13
  const redact_js_1 = require("./redact.js");
13
14
  const MAX_TRANSCRIPT_CHARS = 80_000;
@@ -215,8 +216,12 @@ function extractConversationText(messages, redactionConfig) {
215
216
  * Send filtered conversation to LLM for knowledge extraction.
216
217
  * For large transcripts, chunks into segments to avoid overwhelming small models.
217
218
  * Returns an array of memory entries to ingest, or empty array if nothing worth extracting.
219
+ *
220
+ * `droppedOut`, when provided, is filled with every entry the substance gate
221
+ * discarded (full text). Callers use it to build a durable audit trail (#156);
222
+ * omitting it leaves gate behaviour unchanged.
218
223
  */
219
- async function distillSession(llm, conversation, projectName, date, chunkSizeChars) {
224
+ async function distillSession(llm, conversation, projectName, date, chunkSizeChars, droppedOut) {
220
225
  if (conversation.length < MIN_CONVERSATION_CHARS) {
221
226
  return [];
222
227
  }
@@ -229,7 +234,10 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
229
234
  const chunkSize = chunkSizeChars ?? MAX_TRANSCRIPT_CHARS;
230
235
  // If transcript fits in one chunk, distill directly (errors propagate)
231
236
  if (transcript.length <= chunkSize) {
232
- return distillChunk(llm, transcript, projectName, date);
237
+ const { entries, dropped } = await distillChunk(llm, transcript, projectName, date);
238
+ if (droppedOut)
239
+ droppedOut.push(...dropped);
240
+ return entries;
233
241
  }
234
242
  // Chunk large transcripts and distill each segment.
235
243
  //
@@ -248,7 +256,9 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
248
256
  for (let i = 0; i < chunks.length; i++) {
249
257
  console.log(`[hicortex] Chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
250
258
  try {
251
- const entries = await distillChunk(llm, chunks[i], projectName, date);
259
+ const { entries, dropped } = await distillChunk(llm, chunks[i], projectName, date);
260
+ if (droppedOut)
261
+ droppedOut.push(...dropped);
252
262
  for (const entry of entries) {
253
263
  // Deduplicate by normalized content
254
264
  const key = entry.toLowerCase().replace(/\s+/g, " ").slice(0, 100);
@@ -279,13 +289,17 @@ async function distillSession(llm, conversation, projectName, date, chunkSizeCha
279
289
  * Distill a single chunk of conversation text.
280
290
  *
281
291
  * Behaviour contract:
282
- * - Returns `[]` for legitimate empty results (NO_EXTRACT, empty LLM response,
283
- * transcript produced no entries). These are terminal states — the chunk was
284
- * processed successfully, there's just nothing worth keeping.
292
+ * - Returns `{entries: [], dropped: []}` for legitimate empty results
293
+ * (NO_EXTRACT, empty LLM response, transcript produced no entries). These are
294
+ * terminal states — the chunk was processed successfully, there's just
295
+ * nothing worth keeping.
285
296
  * - Throws for transient errors (LLM unreachable, HTTP 4xx/5xx, timeout, model
286
297
  * not found, rate limit). These MUST propagate so the nightly pipeline can
287
298
  * distinguish "nothing to extract" from "try again later" and avoid
288
299
  * advancing the last-run watermark past sessions it never actually processed.
300
+ *
301
+ * `dropped` carries entries the substance gate rejected (full text) so the
302
+ * caller can surface them in a durable audit trail (#156).
289
303
  */
290
304
  async function distillChunk(llm, transcript, projectName, date) {
291
305
  const prompt = (0, prompts_js_1.distillation)(projectName, date, transcript);
@@ -295,11 +309,24 @@ async function distillChunk(llm, transcript, projectName, date) {
295
309
  // "processed successfully with zero extractions".
296
310
  const result = await llm.completeDistill(prompt);
297
311
  if (!result)
298
- return [];
312
+ return { entries: [], dropped: [] };
299
313
  if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
300
- return [];
314
+ return { entries: [], dropped: [] };
301
315
  }
302
- return parseDistilledEntries(result);
316
+ const parsed = parseDistilledEntries(result);
317
+ const entries = [];
318
+ const dropped = [];
319
+ for (const entry of parsed) {
320
+ (hasMinimalSubstance(entry) ? entries : dropped).push(entry);
321
+ }
322
+ if (dropped.length > 0) {
323
+ for (const d of dropped) {
324
+ const preview = d.length > 120 ? `${d.slice(0, 120)}…` : d;
325
+ console.log(`[hicortex] Substance gate: dropped "${preview}"`);
326
+ }
327
+ console.log(`[hicortex] Substance gate: dropped ${dropped.length}/${parsed.length} content-free fragment(s)`);
328
+ }
329
+ return { entries, dropped };
303
330
  }
304
331
  /**
305
332
  * Split transcript text into chunks at natural boundaries (double newlines).
@@ -330,6 +357,51 @@ function splitIntoChunks(text, maxChars) {
330
357
  }
331
358
  return chunks.filter((c) => c.length >= MIN_CONVERSATION_CHARS);
332
359
  }
360
+ // Entries longer than this trivially carry substance; the cap short-circuits
361
+ // the checks below and bounds every regex to a small input, so no pathological
362
+ // input can make the gate expensive (#156).
363
+ const MAX_GATE_LENGTH = 2000;
364
+ /**
365
+ * Reject ONLY structurally-empty distiller fragments before they become
366
+ * memories (#156). The distiller occasionally emits leftovers that parse into
367
+ * entries but carry no recallable content:
368
+ * - bare section prefixes: "[Specific AI Content:]", "[Facts Learned]"
369
+ * - echoed template placeholders: "[decision]: [reasoning] (2026-07-05)"
370
+ * - pseudo-header bullets: "**Facts Learned:**"
371
+ * - metadata-only lines: "(2026-07-05)"
372
+ *
373
+ * PRECISION OVER RECALL — deliberate trade: the gate rejects only shapes that
374
+ * are structurally empty of content, never on a length or word-count threshold.
375
+ * A kept artifact ("Classification: WORK" style) is cheaply pruned later by the
376
+ * no-fit decay path; a wrongly-dropped genuine memory is unrecoverable. So when
377
+ * in doubt, keep. Consequence documented for the reviewer: metadata lines like
378
+ * "Classification: WORK" now PASS the gate — that is intended.
379
+ *
380
+ * Stripping is scoped and anchored (one leading section prefix, one trailing
381
+ * date stamp), never global, so bracketed payloads ("use [ollama] not
382
+ * [claude-cli]") and content-bearing dates ("deadline moved (2026-08-01)")
383
+ * survive. Stripping affects only this gate's decision, never stored text.
384
+ */
385
+ function hasMinimalSubstance(entry) {
386
+ const raw = entry.trim();
387
+ if (raw.length > MAX_GATE_LENGTH)
388
+ return true;
389
+ // Strip ONE leading section prefix (anchored + length-bounded, never global).
390
+ let body = raw.replace(/^\[[^\]]{0,80}\]\s*/, "");
391
+ // Strip ONE trailing date stamp (anchored to end).
392
+ body = body.replace(/\(\s*\d{4}-\d{2}-\d{2}\s*\)\s*$/, "");
393
+ // Markdown decoration.
394
+ body = body.replace(/[*_`#>]/g, " ").trim();
395
+ if (!body)
396
+ return false; // metadata-only line or bare section prefix
397
+ if (/:$/.test(body))
398
+ return false; // pseudo-header: "Facts Learned:"
399
+ // Pure placeholder echo — nothing but bracketed tokens and separators,
400
+ // e.g. "[decision]: [reasoning]".
401
+ if (/^(?:\[[^\]]{0,80}\]|[\s:.,;–-])+$/.test(body))
402
+ return false;
403
+ return true;
404
+ }
333
405
  /**
334
406
  * Parse distilled markdown into individual memory entry strings.
335
407
  * Each section item becomes a separate memory.
package/dist/init.d.ts CHANGED
@@ -29,6 +29,14 @@ export declare function parseMcpListStatus(mcpListOutput: string): "connected" |
29
29
  * Exported for testability.
30
30
  */
31
31
  export declare function parseEnvFile(content: string): Record<string, string>;
32
+ /**
33
+ * True when an LLM is already persisted and `init` must NOT re-run provider
34
+ * selection: a named/flat backend, a flat baseUrl+apiKey pair, OR a nested-only
35
+ * `models.score` (model or baseUrl). The last clause (0.13.1) stops init from
36
+ * walking a nested-only config back through selection and writing flat keys that
37
+ * a `models.score` would then silently shadow (nested > flat).
38
+ */
39
+ export declare function isLlmConfigured(config: Record<string, unknown>): boolean;
32
40
  /**
33
41
  * Generate a random auth token in the format hctx-<32 hex chars>.
34
42
  * Exported for testability.
package/dist/init.js CHANGED
@@ -20,6 +20,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
20
20
  exports.GENERIC_DEFAULT_DOMAINS = void 0;
21
21
  exports.parseMcpListStatus = parseMcpListStatus;
22
22
  exports.parseEnvFile = parseEnvFile;
23
+ exports.isLlmConfigured = isLlmConfigured;
23
24
  exports.generateAuthToken = generateAuthToken;
24
25
  exports.persistAuthToken = persistAuthToken;
25
26
  exports.decideAgentName = decideAgentName;
@@ -493,6 +494,18 @@ function mergeByKey(candidates) {
493
494
  }
494
495
  return [...seen.values()];
495
496
  }
497
+ /**
498
+ * True when an LLM is already persisted and `init` must NOT re-run provider
499
+ * selection: a named/flat backend, a flat baseUrl+apiKey pair, OR a nested-only
500
+ * `models.score` (model or baseUrl). The last clause (0.13.1) stops init from
501
+ * walking a nested-only config back through selection and writing flat keys that
502
+ * a `models.score` would then silently shadow (nested > flat).
503
+ */
504
+ function isLlmConfigured(config) {
505
+ const modelsScore = config.models?.score;
506
+ const hasModelsScore = Boolean(modelsScore?.model || modelsScore?.baseUrl);
507
+ return Boolean(config.llmBackend || (config.llmApiKey && config.llmBaseUrl) || hasModelsScore);
508
+ }
496
509
  /**
497
510
  * Detect or ask for LLM config and persist to ~/.hicortex/config.json.
498
511
  * The daemon can't inherit shell env vars, so we persist here.
@@ -508,8 +521,8 @@ async function persistLlmConfig() {
508
521
  config = JSON.parse((0, node_fs_1.readFileSync)(configPath, "utf-8"));
509
522
  }
510
523
  catch { /* new file */ }
511
- // Don't overwrite if LLM config already persisted
512
- if (config.llmBackend || (config.llmApiKey && config.llmBaseUrl)) {
524
+ // Don't overwrite if LLM config already persisted (incl. a nested-only config).
525
+ if (isLlmConfigured(config)) {
513
526
  console.log(` ✓ LLM config already configured`);
514
527
  return;
515
528
  }
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
  }
@@ -332,7 +332,7 @@ async function startServer(options = {}) {
332
332
  // Named backends (claude-cli, ollama) → immediate config; everything else
333
333
  // goes through resolveExplicitLlmConfig which requires a user-chosen provider.
334
334
  // If nothing is configured: start recall-only with an unmissable warning.
335
- const savedConfig = readConfigFile(stateDir);
335
+ const savedConfig = (0, llm_js_1.applyModelsBlock)(readConfigFile(stateDir));
336
336
  if (savedConfig?.llmBackend === "claude-cli") {
337
337
  const claudePath = (0, llm_js_1.findClaudeBinary)();
338
338
  if (claudePath) {
@@ -717,7 +717,11 @@ async function startServer(options = {}) {
717
717
  ? `${session_id}${segment_id ? `#${segment_id}` : ""}`
718
718
  : undefined;
719
719
  try {
720
- 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);
721
725
  const ids = [];
722
726
  for (let i = 0; i < entries.length; i++) {
723
727
  const entry = entries[i];
@@ -736,7 +740,11 @@ async function startServer(options = {}) {
736
740
  });
737
741
  ids.push(id);
738
742
  }
739
- 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
+ });
740
748
  }
741
749
  catch (err) {
742
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/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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "Self-learning memory for AI agents \u2014 experience captured automatically, distilled into lessons overnight, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {