@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 +28 -7
- package/dist/distiller.d.ts +27 -1
- package/dist/distiller.js +81 -9
- package/dist/init.d.ts +8 -0
- package/dist/init.js +15 -2
- package/dist/llm.d.ts +24 -1
- package/dist/llm.js +119 -2
- package/dist/mcp-server.js +11 -3
- package/dist/nightly-status.js +4 -1
- package/dist/nightly.js +8 -0
- package/dist/types.d.ts +20 -0
- package/package.json +1 -1
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 |
|
|
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"]
|
|
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` |
|
|
195
|
-
| `
|
|
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.
|
package/dist/distiller.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
283
|
-
* transcript produced no entries). These are
|
|
284
|
-
* processed successfully, there's just
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
205
|
+
const claudePath = findBinary();
|
|
89
206
|
if (claudePath) {
|
|
90
207
|
llmConfig = claudeCliConfig(claudePath);
|
|
91
208
|
}
|
package/dist/mcp-server.js
CHANGED
|
@@ -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
|
-
|
|
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({
|
|
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) });
|
package/dist/nightly-status.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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": {
|