@gamaze/hicortex 0.19.4 → 0.19.6
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 +5 -0
- package/dist/distiller.d.ts +46 -1
- package/dist/distiller.js +92 -9
- package/dist/llm.d.ts +43 -0
- package/dist/llm.js +159 -12
- package/dist/mcp-server.d.ts +16 -0
- package/dist/mcp-server.js +46 -1
- package/dist/nightly.js +116 -88
- package/dist/prompts.d.ts +17 -14
- package/dist/prompts.js +37 -17
- package/dist/telemetry.d.ts +7 -3
- package/dist/types.d.ts +42 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -234,6 +234,11 @@ Config at `~/.hicortex/config.json`. Created by `init`. Key options:
|
|
|
234
234
|
| `maxTokens` | Max output tokens for all phases (default 8192). A ceiling, not a target — the model stops early when done. |
|
|
235
235
|
| `ollamaFlushEvery` | Flush ollama's accumulated memory every N scoring calls. **Off by default (0)** — opt-in only for an **ollama** install whose runner RSS growth (~171 MB/call) swap-thrashes long consolidations on a RAM-constrained box; N=15 caps a cycle at ~2.5 GB. Gated on the provider being ollama (local **or** remote) — no effect for non-ollama providers. Only you can judge whether your ollama endpoint actually suffers the growth (a managed/cloud ollama host may not), so it stays off until you set it. |
|
|
236
236
|
| `ollamaFlushWaitMs` | Milliseconds to wait after an ollama flush for the runner to exit + release memory (default 180000 = 3 min). |
|
|
237
|
+
| `llmTimeoutMs` | The ONE timeout ceiling on every LLM call in every phase (default 900000 = 15 min). The LLM request paths disable the HTTP client's hidden 5-minute response-header timer, so this knob is the only bound — one place to tune when the endpoint is slow, no per-phase special cases. |
|
|
238
|
+
| `llmBreakerThreshold` | Consecutive fully-failed LLM calls (after their built-in retry ladder) that open the per-endpoint circuit breaker (default 3; **0 disables the breaker**). While open, calls fail fast with no network I/O; an HTTP error with a response body, a malformed-reply parse error, or a rate-limit 429 never counts — only the endpoint being unreachable/hung does. |
|
|
239
|
+
| `llmBreakerCooldownMs` | How long the breaker stays open before one half-open trial call goes out (default 600000 = 10 min). A failing trial re-opens it; a succeeding one resets the counter. |
|
|
240
|
+
| `llmProbeTimeoutMs` | Patience of the readiness probe — one minimal 1-token generation request the nightly sends before consolidating and the daemon sends before distilling (default 60000 = 1 min). Catches a gateway that answers health/model-list queries while generation is dead; a failed probe skips consolidation (`endpoint_down`, retried next run) and answers `/distill` with a 503 so capture holds its cursor. |
|
|
241
|
+
| `llmProbeTtlMs` | How long the daemon caches a `/distill` probe outcome (default 300000 = 5 min). A healthy capture cadence pays at most one probe per window; a dead endpoint turns into fast cached 503s instead of every request paying the probe timeout. |
|
|
237
242
|
| `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`. |
|
|
238
243
|
| `corsAllowedOrigins` | Browser origins allowed to read cross-origin responses, e.g. `["https://ui.example.com"]`. **Empty by default** — the server sends no `Access-Control-Allow-Origin` and never `Allow-Credentials`, so no external web page can read its data. The bundled `/viz` and `/identity/ui` pages are same-origin and need no entry. |
|
|
239
244
|
| `licenseKey` | Commercial license key (optional; for display in `hicortex status`) |
|
package/dist/distiller.d.ts
CHANGED
|
@@ -38,10 +38,24 @@ export declare function extractConversationText(messages: unknown[], redactionCo
|
|
|
38
38
|
* `droppedOut`, when provided, is filled with every entry the substance gate
|
|
39
39
|
* discarded (full text). Callers use it to build a durable audit trail (#156);
|
|
40
40
|
* omitting it leaves gate behaviour unchanged.
|
|
41
|
+
*
|
|
42
|
+
* `segmentLabel` (optional) identifies the caller's segment in the #339
|
|
43
|
+
* over-firing warning (e.g. the capture pipeline's segment_id). Purely for
|
|
44
|
+
* log correlation — omitting it falls back to "chunk".
|
|
41
45
|
*/
|
|
42
46
|
export declare function distillSession(llm: LlmClient, conversation: string, projectName: string, date: string, chunkSizeChars?: number, droppedOut?: string[],
|
|
43
47
|
/** Called with each chunk's token usage (#5 budget metering). Optional. */
|
|
44
|
-
onUsage?: (usage: LlmUsage) => void
|
|
48
|
+
onUsage?: (usage: LlmUsage) => void,
|
|
49
|
+
/** Segment identifier for the #339 NO_EXTRACT warning. Optional. */
|
|
50
|
+
segmentLabel?: string): Promise<DistilledEntry[]>;
|
|
51
|
+
/**
|
|
52
|
+
* The NO_EXTRACT check distillChunk applies to an LLM response. EXPORTED and
|
|
53
|
+
* shared (not copy-pasted) with scripts/distill-ab-check/, whose counts must
|
|
54
|
+
* classify empty verdicts exactly as production does (#339 CR finding 3).
|
|
55
|
+
* Tolerant by design: a literal NO_EXTRACT anywhere in the first 20 chars
|
|
56
|
+
* counts (models prepend stray whitespace or a short phrase).
|
|
57
|
+
*/
|
|
58
|
+
export declare function isNoExtractResponse(result: string): boolean;
|
|
45
59
|
/**
|
|
46
60
|
* Reject ONLY structurally-empty distiller fragments before they become
|
|
47
61
|
* memories (#156). The distiller occasionally emits leftovers that parse into
|
|
@@ -76,3 +90,34 @@ export interface DistilledEntry {
|
|
|
76
90
|
content: string;
|
|
77
91
|
memoryType: "experience" | "knowledge" | "decisions";
|
|
78
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Map a single-letter type tag to the stored memory_type. Unknown/absent →
|
|
95
|
+
* experience (the pre-#216 default). `[L]` is explicitly rejected →
|
|
96
|
+
* experience: the distiller must NEVER emit learnings (that's the reflection
|
|
97
|
+
* stage's job), so a model that emits `[L]` is wrong and we do not propagate
|
|
98
|
+
* it as a learning.
|
|
99
|
+
*
|
|
100
|
+
* The single-letter tags ([E]/[K]/[D]) are unchanged from the raw-enum era —
|
|
101
|
+
* the model is taught these as "EXPERIENCE/KNOWLEDGE/DECISIONS" concepts in prompts.ts
|
|
102
|
+
* (ordinary English the model understands), and only the resulting STORED
|
|
103
|
+
* value changed in #264 (episode→experience, fact→knowledge, decision→
|
|
104
|
+
* decisions). The tag letters stay stable so neither the prompt nor the
|
|
105
|
+
* parser needs to change; only this mapping table moves.
|
|
106
|
+
*
|
|
107
|
+
* EXPORTED (with parseDistilledEntries) for scripts/distill-ab-check/ (#339 CR
|
|
108
|
+
* finding 3): the A/B harness computes its counts from each variant build's own
|
|
109
|
+
* parser instead of a copy-pasted mirror, so harness numbers are by construction
|
|
110
|
+
* the numbers that build's production would store. tests/distill-ab-parser-contract.test.ts
|
|
111
|
+
* pins the src and dist parsers against the same corpus.
|
|
112
|
+
*/
|
|
113
|
+
export declare function typeFromTag(letter: string | undefined): DistilledEntry["memoryType"];
|
|
114
|
+
/**
|
|
115
|
+
* Parse distilled markdown into individual memory entries with type tags.
|
|
116
|
+
* Each bullet becomes a separate memory. The leading `[E]`/`[F]`/`[D]` type
|
|
117
|
+
* tag is extracted (→ memoryType), stripped from the stored content, and
|
|
118
|
+
* passed to `insertMemory` via the `memoryType` option (#216). Bullets with
|
|
119
|
+
* no tag default to "experience" (backward compatible with pre-#216 distiller
|
|
120
|
+
* output that never carried a tag). EXPORTED for the A/B harness — see
|
|
121
|
+
* typeFromTag's comment (#339 CR finding 3).
|
|
122
|
+
*/
|
|
123
|
+
export declare function parseDistilledEntries(markdown: string): DistilledEntry[];
|
package/dist/distiller.js
CHANGED
|
@@ -8,11 +8,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
8
8
|
exports.detectChunkSize = detectChunkSize;
|
|
9
9
|
exports.extractConversationText = extractConversationText;
|
|
10
10
|
exports.distillSession = distillSession;
|
|
11
|
+
exports.isNoExtractResponse = isNoExtractResponse;
|
|
11
12
|
exports.hasMinimalSubstance = hasMinimalSubstance;
|
|
13
|
+
exports.typeFromTag = typeFromTag;
|
|
14
|
+
exports.parseDistilledEntries = parseDistilledEntries;
|
|
12
15
|
const prompts_js_1 = require("./prompts.js");
|
|
13
16
|
const redact_js_1 = require("./redact.js");
|
|
14
17
|
const MAX_TRANSCRIPT_CHARS = 80_000;
|
|
15
18
|
const MIN_CONVERSATION_CHARS = 200;
|
|
19
|
+
// #339 (2026-08-24 postmortem): NO_EXTRACT over-firing visibility threshold.
|
|
20
|
+
// Real summary-led segments that the model wrongly abandoned ran 38-64K
|
|
21
|
+
// denoised chars; genuine pure-status noise is ~2.6K. A segment larger than
|
|
22
|
+
// this whose every chunk returns an empty LLM verdict is far more likely the
|
|
23
|
+
// model pattern-matching a bookkeeping-heavy OPENING to the ephemera gate than
|
|
24
|
+
// a legitimately empty segment — so it gets a warning line. Warning ONLY: no
|
|
25
|
+
// auto-retry (cost); the goal is that silent segment loss shows up in the
|
|
26
|
+
// nightly log instead of in a weeks-later eval.
|
|
27
|
+
//
|
|
28
|
+
// The threshold compares the PRE-CHUNKING conversation length, never the chunk
|
|
29
|
+
// length: default ollama chunking (numCtx 8192 → ~19.6K chars) and the
|
|
30
|
+
// small-model speed cap (20K) both keep every chunk at or below this number,
|
|
31
|
+
// so a chunk-level check would be unreachable exactly where the incident lived.
|
|
32
|
+
const NO_EXTRACT_WARN_MIN_CHARS = 20_000;
|
|
16
33
|
// Chunk size limits by model parameter count (for local/CPU inference)
|
|
17
34
|
// Small models are slow on CPU — cap input size to keep inference under ~60s
|
|
18
35
|
const SMALL_MODEL_PARAMS = 8_000_000_000; // 8B — threshold for "small"
|
|
@@ -225,10 +242,16 @@ function extractConversationText(messages, redactionConfig) {
|
|
|
225
242
|
* `droppedOut`, when provided, is filled with every entry the substance gate
|
|
226
243
|
* discarded (full text). Callers use it to build a durable audit trail (#156);
|
|
227
244
|
* omitting it leaves gate behaviour unchanged.
|
|
245
|
+
*
|
|
246
|
+
* `segmentLabel` (optional) identifies the caller's segment in the #339
|
|
247
|
+
* over-firing warning (e.g. the capture pipeline's segment_id). Purely for
|
|
248
|
+
* log correlation — omitting it falls back to "chunk".
|
|
228
249
|
*/
|
|
229
250
|
async function distillSession(llm, conversation, projectName, date, chunkSizeChars, droppedOut,
|
|
230
251
|
/** Called with each chunk's token usage (#5 budget metering). Optional. */
|
|
231
|
-
onUsage
|
|
252
|
+
onUsage,
|
|
253
|
+
/** Segment identifier for the #339 NO_EXTRACT warning. Optional. */
|
|
254
|
+
segmentLabel) {
|
|
232
255
|
if (conversation.length < MIN_CONVERSATION_CHARS) {
|
|
233
256
|
return [];
|
|
234
257
|
}
|
|
@@ -241,9 +264,11 @@ onUsage) {
|
|
|
241
264
|
const chunkSize = chunkSizeChars ?? MAX_TRANSCRIPT_CHARS;
|
|
242
265
|
// If transcript fits in one chunk, distill directly (errors propagate)
|
|
243
266
|
if (transcript.length <= chunkSize) {
|
|
244
|
-
const { entries, dropped } = await distillChunk(llm, transcript, projectName, date, onUsage);
|
|
267
|
+
const { entries, dropped, emptyVerdict } = await distillChunk(llm, transcript, projectName, date, onUsage);
|
|
245
268
|
if (droppedOut)
|
|
246
269
|
droppedOut.push(...dropped);
|
|
270
|
+
if (emptyVerdict)
|
|
271
|
+
warnSuspiciousEmptySegment(segmentLabel, conversation.length);
|
|
247
272
|
return entries;
|
|
248
273
|
}
|
|
249
274
|
// Chunk large transcripts and distill each segment.
|
|
@@ -259,11 +284,14 @@ onUsage) {
|
|
|
259
284
|
const allEntries = [];
|
|
260
285
|
const seen = new Set();
|
|
261
286
|
let chunkFailures = 0;
|
|
287
|
+
let emptyVerdicts = 0;
|
|
262
288
|
let lastError = null;
|
|
263
289
|
for (let i = 0; i < chunks.length; i++) {
|
|
264
290
|
console.log(`[hicortex] Chunk ${i + 1}/${chunks.length} (${chunks[i].length} chars)`);
|
|
265
291
|
try {
|
|
266
|
-
const { entries, dropped } = await distillChunk(llm, chunks[i], projectName, date, onUsage);
|
|
292
|
+
const { entries, dropped, emptyVerdict } = await distillChunk(llm, chunks[i], projectName, date, onUsage);
|
|
293
|
+
if (emptyVerdict)
|
|
294
|
+
emptyVerdicts++;
|
|
267
295
|
if (droppedOut)
|
|
268
296
|
droppedOut.push(...dropped);
|
|
269
297
|
for (const entry of entries) {
|
|
@@ -292,8 +320,38 @@ onUsage) {
|
|
|
292
320
|
if (chunkFailures > 0) {
|
|
293
321
|
console.warn(`[hicortex] Partial distillation: ${chunks.length - chunkFailures}/${chunks.length} chunks succeeded`);
|
|
294
322
|
}
|
|
323
|
+
// #339 segment-level net (CR finding 1): fire when the WHOLE segment produced
|
|
324
|
+
// zero memories and every processed chunk returned an empty LLM verdict. Fires
|
|
325
|
+
// only with zero chunk failures — failed chunks are already loudly visible,
|
|
326
|
+
// and "all failed" throws above. The size gate lives INSIDE
|
|
327
|
+
// warnSuspiciousEmptySegment (strict >, pre-chunking conversation length).
|
|
328
|
+
if (allEntries.length === 0 &&
|
|
329
|
+
chunkFailures === 0 &&
|
|
330
|
+
emptyVerdicts === chunks.length) {
|
|
331
|
+
warnSuspiciousEmptySegment(segmentLabel, conversation.length);
|
|
332
|
+
}
|
|
295
333
|
return allEntries;
|
|
296
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* #339 over-firing visibility net: an empty result this large is the silent-loss
|
|
337
|
+
* signature (real summary-led segments are 38-64K chars; legitimate pure-status
|
|
338
|
+
* noise is ~2.6K). Warning-only, content-free — segment id + size, nothing from
|
|
339
|
+
* the transcript. The empty SUCCESS semantics are unchanged (no throw, no
|
|
340
|
+
* retry): the cursor advances, but the loss is now VISIBLE in the nightly log
|
|
341
|
+
* instead of surfacing weeks later in an eval.
|
|
342
|
+
*
|
|
343
|
+
* The size check lives here, not at call sites, so no path can skip it. Strict
|
|
344
|
+
* comparison: exactly NO_EXTRACT_WARN_MIN_CHARS chars is a legitimate small
|
|
345
|
+
* segment and stays silent.
|
|
346
|
+
*/
|
|
347
|
+
function warnSuspiciousEmptySegment(segmentLabel, segmentChars) {
|
|
348
|
+
if (segmentChars <= NO_EXTRACT_WARN_MIN_CHARS)
|
|
349
|
+
return;
|
|
350
|
+
console.warn(`[hicortex] Suspicious empty distillation: zero memories for a ${segmentChars}-char ` +
|
|
351
|
+
`${segmentLabel ? `segment ${segmentLabel}` : "segment"} ` +
|
|
352
|
+
`(every chunk returned NO_EXTRACT or nothing parseable) — segments this large almost always ` +
|
|
353
|
+
`contain extractable material; if this repeats, suspect gate over-firing (logged only, not retried)`);
|
|
354
|
+
}
|
|
297
355
|
/**
|
|
298
356
|
* Distill a single chunk of conversation text.
|
|
299
357
|
*
|
|
@@ -309,6 +367,13 @@ onUsage) {
|
|
|
309
367
|
*
|
|
310
368
|
* `dropped` carries entries the substance gate rejected (full text) so the
|
|
311
369
|
* caller can surface them in a durable audit trail (#156).
|
|
370
|
+
*
|
|
371
|
+
* `emptyVerdict` is true when the chunk was processed successfully but the LLM's
|
|
372
|
+
* verdict contained nothing extractable: bare NO_EXTRACT, an empty response, or
|
|
373
|
+
* text that parses to zero bullets (prose — the silent twin of NO_EXTRACT,
|
|
374
|
+
* #339 CR finding 2). The caller aggregates these for the segment-level
|
|
375
|
+
* over-firing warning; entries extracted then dropped by the substance gate do
|
|
376
|
+
* NOT count (their drops are already logged).
|
|
312
377
|
*/
|
|
313
378
|
async function distillChunk(llm, transcript, projectName, date, onUsage) {
|
|
314
379
|
const prompt = (0, prompts_js_1.distillation)(projectName, date, transcript);
|
|
@@ -323,10 +388,8 @@ async function distillChunk(llm, transcript, projectName, date, onUsage) {
|
|
|
323
388
|
// trip a budget.
|
|
324
389
|
if (usage && onUsage)
|
|
325
390
|
onUsage(usage);
|
|
326
|
-
if (!result)
|
|
327
|
-
return { entries: [], dropped: [] };
|
|
328
|
-
if (result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT")) {
|
|
329
|
-
return { entries: [], dropped: [] };
|
|
391
|
+
if (!result || isNoExtractResponse(result)) {
|
|
392
|
+
return { entries: [], dropped: [], emptyVerdict: true };
|
|
330
393
|
}
|
|
331
394
|
const parsed = parseDistilledEntries(result);
|
|
332
395
|
// Smoke alarm (PR #218 review): the prompt enforces topic-first, but models
|
|
@@ -356,7 +419,20 @@ async function distillChunk(llm, transcript, projectName, date, onUsage) {
|
|
|
356
419
|
}
|
|
357
420
|
console.log(`[hicortex] Substance gate: dropped ${dropped.length}/${parsed.length} content-free fragment(s)`);
|
|
358
421
|
}
|
|
359
|
-
|
|
422
|
+
// Parsed-zero bypass (#339 CR finding 2): a non-empty response with no
|
|
423
|
+
// NO_EXTRACT token that still parses to zero bullets is the silent twin of
|
|
424
|
+
// NO_EXTRACT — an empty verdict for the over-firing net.
|
|
425
|
+
return { entries, dropped, emptyVerdict: parsed.length === 0 };
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* The NO_EXTRACT check distillChunk applies to an LLM response. EXPORTED and
|
|
429
|
+
* shared (not copy-pasted) with scripts/distill-ab-check/, whose counts must
|
|
430
|
+
* classify empty verdicts exactly as production does (#339 CR finding 3).
|
|
431
|
+
* Tolerant by design: a literal NO_EXTRACT anywhere in the first 20 chars
|
|
432
|
+
* counts (models prepend stray whitespace or a short phrase).
|
|
433
|
+
*/
|
|
434
|
+
function isNoExtractResponse(result) {
|
|
435
|
+
return result === "NO_EXTRACT" || result.slice(0, 20).includes("NO_EXTRACT");
|
|
360
436
|
}
|
|
361
437
|
/**
|
|
362
438
|
* Split transcript text into chunks at natural boundaries (double newlines).
|
|
@@ -445,6 +521,12 @@ function hasMinimalSubstance(entry) {
|
|
|
445
521
|
* value changed in #264 (episode→experience, fact→knowledge, decision→
|
|
446
522
|
* decisions). The tag letters stay stable so neither the prompt nor the
|
|
447
523
|
* parser needs to change; only this mapping table moves.
|
|
524
|
+
*
|
|
525
|
+
* EXPORTED (with parseDistilledEntries) for scripts/distill-ab-check/ (#339 CR
|
|
526
|
+
* finding 3): the A/B harness computes its counts from each variant build's own
|
|
527
|
+
* parser instead of a copy-pasted mirror, so harness numbers are by construction
|
|
528
|
+
* the numbers that build's production would store. tests/distill-ab-parser-contract.test.ts
|
|
529
|
+
* pins the src and dist parsers against the same corpus.
|
|
448
530
|
*/
|
|
449
531
|
function typeFromTag(letter) {
|
|
450
532
|
switch (letter) {
|
|
@@ -467,7 +549,8 @@ function typeFromTag(letter) {
|
|
|
467
549
|
* tag is extracted (→ memoryType), stripped from the stored content, and
|
|
468
550
|
* passed to `insertMemory` via the `memoryType` option (#216). Bullets with
|
|
469
551
|
* no tag default to "experience" (backward compatible with pre-#216 distiller
|
|
470
|
-
* output that never carried a tag).
|
|
552
|
+
* output that never carried a tag). EXPORTED for the A/B harness — see
|
|
553
|
+
* typeFromTag's comment (#339 CR finding 3).
|
|
471
554
|
*/
|
|
472
555
|
function parseDistilledEntries(markdown) {
|
|
473
556
|
const entries = [];
|
package/dist/llm.d.ts
CHANGED
|
@@ -32,6 +32,22 @@ export interface LlmConfig {
|
|
|
32
32
|
ollamaFlushEvery?: number;
|
|
33
33
|
/** Ms to wait after an ollama flush for the runner to release. */
|
|
34
34
|
ollamaFlushWaitMs?: number;
|
|
35
|
+
/** ONE per-call timeout ceiling for every phase (#337). Default 900000 — the
|
|
36
|
+
* AbortSignal.timeout value passed by all four phase wrappers (the old
|
|
37
|
+
* 600000 scoring special-case is gone). See HicortexConfig.llmTimeoutMs. */
|
|
38
|
+
timeoutMs?: number;
|
|
39
|
+
/** Consecutive ladder-exhausted total failures before the circuit breaker
|
|
40
|
+
* opens (#337). Default 3; 0 disables. See HicortexConfig.llmBreakerThreshold. */
|
|
41
|
+
breakerThreshold?: number;
|
|
42
|
+
/** How long an OPEN breaker stays open before the next call becomes a trial
|
|
43
|
+
* (#337). Default 600000. See HicortexConfig.llmBreakerCooldownMs. */
|
|
44
|
+
breakerCooldownMs?: number;
|
|
45
|
+
/** Timeout for the readiness probe's single generation attempt (#337).
|
|
46
|
+
* Default 60000. See HicortexConfig.llmProbeTimeoutMs. */
|
|
47
|
+
probeTimeoutMs?: number;
|
|
48
|
+
/** TTL the daemon caches a probe outcome for (#337). Default 300000.
|
|
49
|
+
* See HicortexConfig.llmProbeTtlMs. */
|
|
50
|
+
probeTtlMs?: number;
|
|
35
51
|
}
|
|
36
52
|
/**
|
|
37
53
|
* Resolve LLM configuration from explicit config-file overrides or
|
|
@@ -135,6 +151,10 @@ export interface LlmResult {
|
|
|
135
151
|
text: string;
|
|
136
152
|
usage?: LlmUsage;
|
|
137
153
|
}
|
|
154
|
+
/** Thrown when the per-endpoint circuit breaker is open (#337) — no network I/O happened. */
|
|
155
|
+
export declare class LlmCircuitOpenError extends Error {
|
|
156
|
+
constructor(endpoint: string, cooldownRemainingMs: number);
|
|
157
|
+
}
|
|
138
158
|
export declare class LlmClient {
|
|
139
159
|
private config;
|
|
140
160
|
private ollamaCallCount;
|
|
@@ -144,6 +164,18 @@ export declare class LlmClient {
|
|
|
144
164
|
private get rateLimitedUntil();
|
|
145
165
|
/** Check if we're currently rate limited */
|
|
146
166
|
get isRateLimited(): boolean;
|
|
167
|
+
/**
|
|
168
|
+
* True once the endpoint's circuit breaker has tripped and no success has
|
|
169
|
+
* reset it since (#337). Note this stays true past the cooldown until a
|
|
170
|
+
* trial succeeds — "past cooldown" means calls are LET THROUGH (one trial),
|
|
171
|
+
* not "healthy". The nightly reads this after runConsolidation to override
|
|
172
|
+
* a fail-soft "completed" report with "endpoint_down".
|
|
173
|
+
*/
|
|
174
|
+
get breakerOpen(): boolean;
|
|
175
|
+
/** Record one ladder-exhausted TOTAL failure; open the breaker at threshold. */
|
|
176
|
+
private recordBreakerFailure;
|
|
177
|
+
/** Any success resets the endpoint's breaker (closed + counter zeroed). */
|
|
178
|
+
private resetBreaker;
|
|
147
179
|
private handleRateLimit;
|
|
148
180
|
/**
|
|
149
181
|
* Fast-tier completion (importance scoring, simple tasks). One model serves
|
|
@@ -168,6 +200,17 @@ export declare class LlmClient {
|
|
|
168
200
|
* serves all phases (#231) — thin wrapper kept for call-site readability.
|
|
169
201
|
*/
|
|
170
202
|
completeClassify(prompt: string, maxTokens?: number): Promise<LlmResult>;
|
|
203
|
+
/**
|
|
204
|
+
* Readiness probe (#337): ONE minimal generation request (max output 1
|
|
205
|
+
* token) through the normal provider dispatch. Asks the question liveness
|
|
206
|
+
* checks CANNOT: "can this endpoint GENERATE right now?" — the incident
|
|
207
|
+
* gateway kept answering /v1/models for hours while every completion hung.
|
|
208
|
+
* Single attempt: no retry ladder (a dead endpoint must cost one fast
|
|
209
|
+
* failure, not a 3.5-min ladder), and it never accrues to the circuit
|
|
210
|
+
* breaker (probing is diagnosis, not traffic). Catch-all → false; the
|
|
211
|
+
* callers translate that into "endpoint_down" / a 503, never an exception.
|
|
212
|
+
*/
|
|
213
|
+
probe(timeoutMs?: number): Promise<boolean>;
|
|
171
214
|
private complete;
|
|
172
215
|
private completeOnce;
|
|
173
216
|
/**
|
package/dist/llm.js
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* OpenAI, Anthropic, Google, Ollama, OpenRouter, and Claude CLI.
|
|
19
19
|
*/
|
|
20
20
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
-
exports.LlmClient = exports.RateLimitError = exports.resolveLlmConfigForCC = void 0;
|
|
21
|
+
exports.LlmClient = exports.LlmCircuitOpenError = exports.RateLimitError = exports.resolveLlmConfigForCC = void 0;
|
|
22
22
|
exports.resolveExplicitLlmConfig = resolveExplicitLlmConfig;
|
|
23
23
|
exports.applyTierTuningOverlay = applyTierTuningOverlay;
|
|
24
24
|
exports.resolveSavedLlmConfig = resolveSavedLlmConfig;
|
|
@@ -26,6 +26,15 @@ exports.findClaudeBinary = findClaudeBinary;
|
|
|
26
26
|
exports.claudeCliConfig = claudeCliConfig;
|
|
27
27
|
exports.probeOllama = probeOllama;
|
|
28
28
|
const config_read_js_1 = require("./config-read.js");
|
|
29
|
+
// #337: the openai-compat + anthropic request paths fetch through undici's OWN
|
|
30
|
+
// fetch with an explicit dispatcher (below). Node's global fetch is also undici
|
|
31
|
+
// under the hood, but with a hidden 5-minute response-HEADER timer that fires
|
|
32
|
+
// FIRST on non-streaming completions — a completion only sends its headers after
|
|
33
|
+
// generation finishes, so a legitimate >5-min generation is abandoned client-side
|
|
34
|
+
// while the server keeps generating for the dead client (the 2026-08-23/24
|
|
35
|
+
// incident's amplification mechanism). The ollama path already streams to dodge
|
|
36
|
+
// this; these paths get the dispatcher instead.
|
|
37
|
+
const undici_1 = require("undici");
|
|
29
38
|
/**
|
|
30
39
|
* Resolve LLM configuration from explicit config-file overrides or
|
|
31
40
|
* Hicortex-specific env vars only. Returns null when nothing explicit is set.
|
|
@@ -104,6 +113,26 @@ function applyTierTuningOverlay(llmConfig, savedConfig) {
|
|
|
104
113
|
if (savedConfig.ollamaFlushWaitMs !== undefined) {
|
|
105
114
|
llmConfig.ollamaFlushWaitMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "ollamaFlushWaitMs", 180000);
|
|
106
115
|
}
|
|
116
|
+
// #337 resilience knobs. Same boundary discipline as the keys above: absent =
|
|
117
|
+
// call-site defaults (timeout 900 s, threshold 3, cooldown 10 min, probe
|
|
118
|
+
// timeout 60 s, probe TTL 5 min), wrong-typed values warn and fall back.
|
|
119
|
+
// breakerThreshold uses readNonNegativeConfig because 0 is a VALID value
|
|
120
|
+
// ("disable the breaker") — the same reason ollamaFlushEvery uses it.
|
|
121
|
+
if (savedConfig.llmTimeoutMs !== undefined) {
|
|
122
|
+
llmConfig.timeoutMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "llmTimeoutMs", 900000);
|
|
123
|
+
}
|
|
124
|
+
if (savedConfig.llmBreakerThreshold !== undefined) {
|
|
125
|
+
llmConfig.breakerThreshold = (0, config_read_js_1.readNonNegativeConfig)(savedConfig, "llmBreakerThreshold", 3);
|
|
126
|
+
}
|
|
127
|
+
if (savedConfig.llmBreakerCooldownMs !== undefined) {
|
|
128
|
+
llmConfig.breakerCooldownMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "llmBreakerCooldownMs", 600000);
|
|
129
|
+
}
|
|
130
|
+
if (savedConfig.llmProbeTimeoutMs !== undefined) {
|
|
131
|
+
llmConfig.probeTimeoutMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "llmProbeTimeoutMs", 60000);
|
|
132
|
+
}
|
|
133
|
+
if (savedConfig.llmProbeTtlMs !== undefined) {
|
|
134
|
+
llmConfig.probeTtlMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "llmProbeTtlMs", 300000);
|
|
135
|
+
}
|
|
107
136
|
}
|
|
108
137
|
/**
|
|
109
138
|
* Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
|
|
@@ -234,6 +263,18 @@ async function probeOllama(baseUrl = "http://localhost:11434") {
|
|
|
234
263
|
// ---------------------------------------------------------------------------
|
|
235
264
|
// LLM Client class
|
|
236
265
|
// ---------------------------------------------------------------------------
|
|
266
|
+
// #337: one timeout ceiling. undici (Node's fetch implementation) silently
|
|
267
|
+
// enforces a ~5-minute response-header timer and a body timer on every request.
|
|
268
|
+
// On a NON-STREAMING completion the headers only arrive when generation
|
|
269
|
+
// finishes, so that hidden timer — not our AbortSignal — was the real ceiling,
|
|
270
|
+
// and when it fired the server kept generating for the abandoned client. This
|
|
271
|
+
// module-scoped Agent disables both timers for the LLM request paths that opt
|
|
272
|
+
// in (completeAnthropic + completeOpenAiCompat), making `llmTimeoutMs` the only
|
|
273
|
+
// ceiling. Module scope = one connection pool shared by every LlmClient in the
|
|
274
|
+
// process (the ollama path keeps global fetch — it already streams, so the
|
|
275
|
+
// header timer can't fire there; claude-cli is a subprocess with its own
|
|
276
|
+
// timeout).
|
|
277
|
+
const llmDispatcher = new undici_1.Agent({ headersTimeout: 0, bodyTimeout: 0 });
|
|
237
278
|
const DEFAULT_RATE_LIMIT_RETRY_MS = 5 * 60 * 60 * 1000 + 60_000; // 5h01m safety margin
|
|
238
279
|
class RateLimitError extends Error {
|
|
239
280
|
retryAfterMs;
|
|
@@ -251,6 +292,28 @@ exports.RateLimitError = RateLimitError;
|
|
|
251
292
|
// (#231), so in practice there is a single client per process today; the
|
|
252
293
|
// module-level map keeps the state shared correctly if that ever changes.
|
|
253
294
|
const rateLimitedUntilByEndpoint = new Map();
|
|
295
|
+
const breakerByEndpoint = new Map();
|
|
296
|
+
/** Thrown when the per-endpoint circuit breaker is open (#337) — no network I/O happened. */
|
|
297
|
+
class LlmCircuitOpenError extends Error {
|
|
298
|
+
constructor(endpoint, cooldownRemainingMs) {
|
|
299
|
+
super(`LLM circuit breaker open for ${endpoint} — failing fast ` +
|
|
300
|
+
`(endpoint deemed down; next trial in ~${Math.round(cooldownRemainingMs / 1000)}s)`);
|
|
301
|
+
this.name = "LlmCircuitOpenError";
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
exports.LlmCircuitOpenError = LlmCircuitOpenError;
|
|
305
|
+
/**
|
|
306
|
+
* The TOTAL-failure class the retry ladder matches (#337, unchanged strings —
|
|
307
|
+
* this is the same matcher the ladder has always used, now also the breaker's
|
|
308
|
+
* definition of "endpoint may be down"). A fast HTTP 500, a parse error, or a
|
|
309
|
+
* rate limit is NOT in this class: those prove the endpoint ANSWERS.
|
|
310
|
+
*/
|
|
311
|
+
function isTotalFailure(message) {
|
|
312
|
+
return (message.includes("fetch failed") ||
|
|
313
|
+
message.includes("ECONNREFUSED") ||
|
|
314
|
+
message.includes("timeout") ||
|
|
315
|
+
message.includes("Headers Timeout"));
|
|
316
|
+
}
|
|
254
317
|
class LlmClient {
|
|
255
318
|
config;
|
|
256
319
|
ollamaCallCount = 0;
|
|
@@ -268,6 +331,43 @@ class LlmClient {
|
|
|
268
331
|
get isRateLimited() {
|
|
269
332
|
return Date.now() < this.rateLimitedUntil;
|
|
270
333
|
}
|
|
334
|
+
/**
|
|
335
|
+
* True once the endpoint's circuit breaker has tripped and no success has
|
|
336
|
+
* reset it since (#337). Note this stays true past the cooldown until a
|
|
337
|
+
* trial succeeds — "past cooldown" means calls are LET THROUGH (one trial),
|
|
338
|
+
* not "healthy". The nightly reads this after runConsolidation to override
|
|
339
|
+
* a fail-soft "completed" report with "endpoint_down".
|
|
340
|
+
*/
|
|
341
|
+
get breakerOpen() {
|
|
342
|
+
const st = breakerByEndpoint.get(this.endpointKey);
|
|
343
|
+
return st !== undefined && st.openedAt !== null;
|
|
344
|
+
}
|
|
345
|
+
/** Record one ladder-exhausted TOTAL failure; open the breaker at threshold. */
|
|
346
|
+
recordBreakerFailure() {
|
|
347
|
+
const threshold = this.config.breakerThreshold ?? 3;
|
|
348
|
+
if (threshold <= 0)
|
|
349
|
+
return; // 0 disables — never open, never fast-fail
|
|
350
|
+
const st = breakerByEndpoint.get(this.endpointKey) ?? { failures: 0, openedAt: null };
|
|
351
|
+
st.failures += 1;
|
|
352
|
+
if (st.failures >= threshold) {
|
|
353
|
+
// (Re)open. Re-opening (a failed trial past cooldown) restarts the
|
|
354
|
+
// cooldown window from NOW — the endpoint just proved itself still dead.
|
|
355
|
+
st.openedAt = Date.now();
|
|
356
|
+
// One structured line per opening — the runbook's grep target. Same
|
|
357
|
+
// key=value style as event=budget_exhausted (consolidate.ts).
|
|
358
|
+
console.warn(`[hicortex] event=circuit_open endpoint=${this.endpointKey} ` +
|
|
359
|
+
`failures=${st.failures} threshold=${threshold} ` +
|
|
360
|
+
`cooldown_ms=${this.config.breakerCooldownMs ?? 600_000}`);
|
|
361
|
+
}
|
|
362
|
+
breakerByEndpoint.set(this.endpointKey, st);
|
|
363
|
+
}
|
|
364
|
+
/** Any success resets the endpoint's breaker (closed + counter zeroed). */
|
|
365
|
+
resetBreaker() {
|
|
366
|
+
const st = breakerByEndpoint.get(this.endpointKey);
|
|
367
|
+
if (st && (st.failures !== 0 || st.openedAt !== null)) {
|
|
368
|
+
breakerByEndpoint.set(this.endpointKey, { failures: 0, openedAt: null });
|
|
369
|
+
}
|
|
370
|
+
}
|
|
271
371
|
handleRateLimit(resp) {
|
|
272
372
|
// Parse Retry-After header if present (seconds)
|
|
273
373
|
const retryAfter = resp.headers.get("retry-after");
|
|
@@ -289,7 +389,11 @@ class LlmClient {
|
|
|
289
389
|
*/
|
|
290
390
|
async completeFast(prompt, maxTokens) {
|
|
291
391
|
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
292
|
-
|
|
392
|
+
// #337: ONE ceiling for every phase (llmTimeoutMs, default 900 s). The old
|
|
393
|
+
// 600 s scoring special-case assumed fast-tier calls are short — but the
|
|
394
|
+
// ceiling only ever mattered when the endpoint was wedged, and a wedged
|
|
395
|
+
// endpoint wedges scoring too. One knob, one place.
|
|
396
|
+
const result = await this.complete(this.config.model, prompt, tokens, this.config.timeoutMs ?? 900_000);
|
|
293
397
|
const flushEvery = this.config.ollamaFlushEvery ?? 0;
|
|
294
398
|
if (this.config.provider === "ollama" && flushEvery > 0) {
|
|
295
399
|
this.ollamaCallCount++;
|
|
@@ -306,7 +410,7 @@ class LlmClient {
|
|
|
306
410
|
*/
|
|
307
411
|
async completeReflect(prompt, maxTokens) {
|
|
308
412
|
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
309
|
-
return this.complete(this.config.model, prompt, tokens, 900_000);
|
|
413
|
+
return this.complete(this.config.model, prompt, tokens, this.config.timeoutMs ?? 900_000);
|
|
310
414
|
}
|
|
311
415
|
/**
|
|
312
416
|
* Distillation-tier completion (session knowledge extraction). One model
|
|
@@ -314,7 +418,7 @@ class LlmClient {
|
|
|
314
418
|
*/
|
|
315
419
|
async completeDistill(prompt, maxTokens) {
|
|
316
420
|
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
317
|
-
return this.complete(this.config.model, prompt, tokens, 900_000);
|
|
421
|
+
return this.complete(this.config.model, prompt, tokens, this.config.timeoutMs ?? 900_000);
|
|
318
422
|
}
|
|
319
423
|
/**
|
|
320
424
|
* Classification-tier completion (memory tag classification). One model
|
|
@@ -322,9 +426,39 @@ class LlmClient {
|
|
|
322
426
|
*/
|
|
323
427
|
async completeClassify(prompt, maxTokens) {
|
|
324
428
|
const tokens = maxTokens ?? this.config.maxTokens ?? 8192;
|
|
325
|
-
return this.complete(this.config.model, prompt, tokens, 900_000);
|
|
429
|
+
return this.complete(this.config.model, prompt, tokens, this.config.timeoutMs ?? 900_000);
|
|
430
|
+
}
|
|
431
|
+
/**
|
|
432
|
+
* Readiness probe (#337): ONE minimal generation request (max output 1
|
|
433
|
+
* token) through the normal provider dispatch. Asks the question liveness
|
|
434
|
+
* checks CANNOT: "can this endpoint GENERATE right now?" — the incident
|
|
435
|
+
* gateway kept answering /v1/models for hours while every completion hung.
|
|
436
|
+
* Single attempt: no retry ladder (a dead endpoint must cost one fast
|
|
437
|
+
* failure, not a 3.5-min ladder), and it never accrues to the circuit
|
|
438
|
+
* breaker (probing is diagnosis, not traffic). Catch-all → false; the
|
|
439
|
+
* callers translate that into "endpoint_down" / a 503, never an exception.
|
|
440
|
+
*/
|
|
441
|
+
async probe(timeoutMs) {
|
|
442
|
+
try {
|
|
443
|
+
await this.completeOnce(this.config.model, "Reply with OK.", 1, timeoutMs ?? this.config.probeTimeoutMs ?? 60_000);
|
|
444
|
+
return true;
|
|
445
|
+
}
|
|
446
|
+
catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
326
449
|
}
|
|
327
450
|
async complete(model, prompt, maxTokens, timeoutMs) {
|
|
451
|
+
// Breaker BEFORE anything else (#337) — an open breaker must cost zero
|
|
452
|
+
// network I/O and zero ladder time. Past the cooldown we fall through:
|
|
453
|
+
// this call IS the trial.
|
|
454
|
+
const breakerSt = breakerByEndpoint.get(this.endpointKey);
|
|
455
|
+
if (breakerSt !== undefined && breakerSt.openedAt !== null) {
|
|
456
|
+
const elapsed = Date.now() - breakerSt.openedAt;
|
|
457
|
+
const cooldownMs = this.config.breakerCooldownMs ?? 600_000;
|
|
458
|
+
if (elapsed < cooldownMs) {
|
|
459
|
+
throw new LlmCircuitOpenError(this.endpointKey, cooldownMs - elapsed);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
328
462
|
if (this.isRateLimited) {
|
|
329
463
|
throw new RateLimitError(this.rateLimitedUntil - Date.now());
|
|
330
464
|
}
|
|
@@ -332,21 +466,30 @@ class LlmClient {
|
|
|
332
466
|
let lastErr;
|
|
333
467
|
for (let attempt = 0; attempt <= retryDelays.length; attempt++) {
|
|
334
468
|
try {
|
|
335
|
-
|
|
469
|
+
const result = await this.completeOnce(model, prompt, maxTokens, timeoutMs);
|
|
470
|
+
this.resetBreaker(); // any success closes the endpoint's breaker
|
|
471
|
+
return result;
|
|
336
472
|
}
|
|
337
473
|
catch (err) {
|
|
338
474
|
lastErr = err instanceof Error ? err : new Error(String(err));
|
|
339
|
-
|
|
340
|
-
if (attempt < retryDelays.length && (msg.includes("fetch failed") || msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("Headers Timeout"))) {
|
|
475
|
+
if (attempt < retryDelays.length && isTotalFailure(lastErr.message)) {
|
|
341
476
|
const delay = retryDelays[attempt];
|
|
342
|
-
console.log(`[hicortex] LLM call failed (${
|
|
477
|
+
console.log(`[hicortex] LLM call failed (${lastErr.message.slice(0, 60)}), retry ${attempt + 1}/${retryDelays.length} in ${delay / 1000}s...`);
|
|
343
478
|
await new Promise(r => setTimeout(r, delay));
|
|
344
479
|
}
|
|
345
|
-
else {
|
|
480
|
+
else if (!isTotalFailure(lastErr.message)) {
|
|
481
|
+
// Non-retryable (HTTP error with a response, parse error,
|
|
482
|
+
// RateLimitError): fail this call now — and it NEVER accrues to the
|
|
483
|
+
// breaker; an endpoint that answers is not breaker-down.
|
|
346
484
|
throw lastErr;
|
|
347
485
|
}
|
|
486
|
+
// else: total-class failure on the LAST attempt — the ladder is
|
|
487
|
+
// exhausted; fall out of the loop to count + throw below.
|
|
348
488
|
}
|
|
349
489
|
}
|
|
490
|
+
// Ladder exhausted on total-class errors — the only path that accrues to
|
|
491
|
+
// the breaker (#337).
|
|
492
|
+
this.recordBreakerFailure();
|
|
350
493
|
throw lastErr;
|
|
351
494
|
}
|
|
352
495
|
async completeOnce(model, prompt, maxTokens, timeoutMs) {
|
|
@@ -512,7 +655,7 @@ class LlmClient {
|
|
|
512
655
|
const baseUrl = this.config.baseUrl.replace(/\/$/, "");
|
|
513
656
|
const hasVersion = /\/v\d+\/?$/.test(baseUrl);
|
|
514
657
|
const url = hasVersion ? `${baseUrl}/messages` : `${baseUrl}/v1/messages`;
|
|
515
|
-
const resp = await fetch(url, {
|
|
658
|
+
const resp = await (0, undici_1.fetch)(url, {
|
|
516
659
|
method: "POST",
|
|
517
660
|
headers: {
|
|
518
661
|
"Content-Type": "application/json",
|
|
@@ -525,6 +668,8 @@ class LlmClient {
|
|
|
525
668
|
max_tokens: maxTokens,
|
|
526
669
|
}),
|
|
527
670
|
signal: AbortSignal.timeout(timeoutMs),
|
|
671
|
+
// #337: disable undici's hidden header/body timers (see llmDispatcher).
|
|
672
|
+
dispatcher: llmDispatcher,
|
|
528
673
|
});
|
|
529
674
|
if (resp.status === 429)
|
|
530
675
|
this.handleRateLimit(resp);
|
|
@@ -584,11 +729,13 @@ class LlmClient {
|
|
|
584
729
|
if (thinking !== undefined) {
|
|
585
730
|
body.chat_template_kwargs = { enable_thinking: thinking };
|
|
586
731
|
}
|
|
587
|
-
const resp = await fetch(url, {
|
|
732
|
+
const resp = await (0, undici_1.fetch)(url, {
|
|
588
733
|
method: "POST",
|
|
589
734
|
headers,
|
|
590
735
|
body: JSON.stringify(body),
|
|
591
736
|
signal: AbortSignal.timeout(timeoutMs),
|
|
737
|
+
// #337: disable undici's hidden header/body timers (see llmDispatcher).
|
|
738
|
+
dispatcher: llmDispatcher,
|
|
592
739
|
});
|
|
593
740
|
if (resp.status === 429)
|
|
594
741
|
this.handleRateLimit(resp);
|
package/dist/mcp-server.d.ts
CHANGED
|
@@ -12,6 +12,22 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import express from "express";
|
|
14
14
|
import type { MemorySearchResult } from "./types.js";
|
|
15
|
+
/**
|
|
16
|
+
* Resolve the /distill probe gate (#337): true when the endpoint recently
|
|
17
|
+
* proved it can GENERATE (cached outcome inside its TTL, or a fresh probe),
|
|
18
|
+
* false when the probe failed — the caller answers 503 so the capture client
|
|
19
|
+
* holds its cursor and retries next run (nothing lost, dup-over-loss).
|
|
20
|
+
* Structural `llm` parameter (anything with probe()) so wiring tests drive
|
|
21
|
+
* the real cache + TTL discipline with a counting stub.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveDistillProbeGate(llm: {
|
|
24
|
+
probe(timeoutMs?: number): Promise<boolean>;
|
|
25
|
+
}, llmConfig: {
|
|
26
|
+
provider: string;
|
|
27
|
+
model: string;
|
|
28
|
+
baseUrl: string;
|
|
29
|
+
probeTtlMs?: number;
|
|
30
|
+
}): Promise<boolean>;
|
|
15
31
|
/**
|
|
16
32
|
* Resolve the request body-size limit in MB (#7, #328 item 2b). Pure —
|
|
17
33
|
* exported for tests. Precedence: HICORTEX_DISTILL_BODY_LIMIT_MB env >
|
package/dist/mcp-server.js
CHANGED
|
@@ -48,6 +48,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
48
48
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
49
49
|
};
|
|
50
50
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
|
+
exports.resolveDistillProbeGate = resolveDistillProbeGate;
|
|
51
52
|
exports.resolveBodyLimitMb = resolveBodyLimitMb;
|
|
52
53
|
exports.makeBodyLimitErrorHandler = makeBodyLimitErrorHandler;
|
|
53
54
|
exports.makeContentLengthGate = makeContentLengthGate;
|
|
@@ -126,6 +127,33 @@ let memoryInstructionsEnabled = true;
|
|
|
126
127
|
// Cache detectChunkSize results keyed by "<provider>/<model>@<baseUrl>" so we
|
|
127
128
|
// probe each endpoint once per server boot rather than once per /distill request.
|
|
128
129
|
const chunkSizeCache = new Map();
|
|
130
|
+
// #337: /distill readiness-probe outcomes, keyed like chunkSizeCache. The
|
|
131
|
+
// daemon probes the GENERATION path (one 1-token completion) before
|
|
132
|
+
// distilling — liveness-style endpoint checks cannot catch the incident
|
|
133
|
+
// signature (a wedged gateway that keeps answering /v1/models while every
|
|
134
|
+
// completion hangs). Outcomes are cached for llmProbeTtlMs (default 5 min),
|
|
135
|
+
// so a healthy capture cadence pays at most one probe per window and a DEAD
|
|
136
|
+
// endpoint turns into fast cached 503s instead of every request paying the
|
|
137
|
+
// probe timeout. Module-scoped like chunkSizeCache (state spans requests).
|
|
138
|
+
const distillProbeCache = new Map();
|
|
139
|
+
/**
|
|
140
|
+
* Resolve the /distill probe gate (#337): true when the endpoint recently
|
|
141
|
+
* proved it can GENERATE (cached outcome inside its TTL, or a fresh probe),
|
|
142
|
+
* false when the probe failed — the caller answers 503 so the capture client
|
|
143
|
+
* holds its cursor and retries next run (nothing lost, dup-over-loss).
|
|
144
|
+
* Structural `llm` parameter (anything with probe()) so wiring tests drive
|
|
145
|
+
* the real cache + TTL discipline with a counting stub.
|
|
146
|
+
*/
|
|
147
|
+
async function resolveDistillProbeGate(llm, llmConfig) {
|
|
148
|
+
const key = `${llmConfig.provider}/${llmConfig.model}@${llmConfig.baseUrl}`;
|
|
149
|
+
const ttlMs = llmConfig.probeTtlMs ?? 300_000;
|
|
150
|
+
const cached = distillProbeCache.get(key);
|
|
151
|
+
if (cached && Date.now() - cached.at < ttlMs)
|
|
152
|
+
return cached.ok;
|
|
153
|
+
const ok = await llm.probe();
|
|
154
|
+
distillProbeCache.set(key, { ok, at: Date.now() });
|
|
155
|
+
return ok;
|
|
156
|
+
}
|
|
129
157
|
let VERSION = "0.3.x";
|
|
130
158
|
try {
|
|
131
159
|
const pkg = JSON.parse(require("node:fs").readFileSync(require("node:path").join(__dirname, "..", "package.json"), "utf-8"));
|
|
@@ -1165,6 +1193,16 @@ async function startServer(options = {}) {
|
|
|
1165
1193
|
return;
|
|
1166
1194
|
}
|
|
1167
1195
|
}
|
|
1196
|
+
// #337: readiness gate — cached minimal generation probe BEFORE
|
|
1197
|
+
// detectChunkSize + distillSession, so a dead endpoint never even pays the
|
|
1198
|
+
// chunk-size probe. Placed AFTER the dedup short-circuits (a duplicate
|
|
1199
|
+
// costs nothing and must not trip the gate). On failure: 503 with the
|
|
1200
|
+
// diagnosis — the capture client treats non-201/200 as transient and holds
|
|
1201
|
+
// its cursor (capture.ts), so the segment is retried next run, never lost.
|
|
1202
|
+
if (!(await resolveDistillProbeGate(llm, llmConfig))) {
|
|
1203
|
+
res.status(503).json({ error: "LLM endpoint not generating — session will be retried" });
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1168
1206
|
// Cache detectChunkSize per endpoint so we probe at most once per server boot.
|
|
1169
1207
|
// numCtx is passed so chunk size derives from the request's ACTUAL context
|
|
1170
1208
|
// window (#231, #228) — the chunker and the request agree by construction.
|
|
@@ -1198,7 +1236,14 @@ async function startServer(options = {}) {
|
|
|
1198
1236
|
distillUsage.prompt += u.prompt_tokens ?? 0;
|
|
1199
1237
|
distillUsage.completion += u.completion_tokens ?? 0;
|
|
1200
1238
|
distillUsage.total += u.total_tokens ?? 0;
|
|
1201
|
-
}
|
|
1239
|
+
},
|
|
1240
|
+
// #339: identify this POST in the NO_EXTRACT over-firing warning —
|
|
1241
|
+
// segment_id (incremental capture), else session_id (legacy), else none.
|
|
1242
|
+
typeof segment_id === "string" && segment_id
|
|
1243
|
+
? segment_id
|
|
1244
|
+
: typeof session_id === "string" && session_id
|
|
1245
|
+
? session_id
|
|
1246
|
+
: undefined);
|
|
1202
1247
|
// Phase 1 — embed every chunk up front (async). If ANY embed fails we
|
|
1203
1248
|
// never reach the insert, so nothing is stored.
|
|
1204
1249
|
const createdAt = new Date(date).toISOString();
|
package/dist/nightly.js
CHANGED
|
@@ -555,6 +555,10 @@ async function runNightly(options = {}) {
|
|
|
555
555
|
// nothing-to-do short-circuit (zero LLM calls), NOT a failure.
|
|
556
556
|
// "throttled" (#246) = the llmTokensPerMonth fair-use cap was projected to
|
|
557
557
|
// be exceeded, so consolidation was skipped before any LLM call.
|
|
558
|
+
// "endpoint_down" (#337) = the pre-consolidation readiness probe failed, or
|
|
559
|
+
// the LLM circuit breaker was open after the run — transient (retried next
|
|
560
|
+
// run), and NEVER "completed": the stages fail soft, so without this
|
|
561
|
+
// override a dead-endpoint run would report clean.
|
|
558
562
|
let consolidationStatus;
|
|
559
563
|
// #246: total consolidation tokens consumed this run (hoisted for telemetry
|
|
560
564
|
// + the dashboard snapshot). Undefined when consolidation didn't run at all
|
|
@@ -620,95 +624,119 @@ async function runNightly(options = {}) {
|
|
|
620
624
|
}
|
|
621
625
|
}
|
|
622
626
|
if (consolidationStatus !== "throttled") {
|
|
623
|
-
//
|
|
624
|
-
//
|
|
625
|
-
//
|
|
626
|
-
//
|
|
627
|
-
//
|
|
628
|
-
//
|
|
629
|
-
//
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
(0, config_read_js_1.readPositiveConfig)(savedConfig ?? {}, "consolidateMaxLlmCalls", consolidate_js_1.CONSOLIDATE_MAX_LLM_CALLS),
|
|
642
|
-
// #245: soft cap on the corpus (default 10000; 0 disables eviction).
|
|
643
|
-
memorySoftCapResolved);
|
|
644
|
-
console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
|
|
645
|
-
(report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
|
|
646
|
-
consolidationStatus = report.status;
|
|
647
|
-
// #245: capture the eviction count for the dashboard snapshot. The
|
|
648
|
-
// stage always returns `evicted` (0 when under cap / disabled); report
|
|
649
|
-
// it as 0 (a real value), not undefined, when the stage ran.
|
|
650
|
-
evictedCount = report.stages.memory_cap?.evicted ?? 0;
|
|
651
|
-
// Only set when reflection actually RAN (not skipped). A skipped stage
|
|
652
|
-
// (e.g. endpoint offline, #232 fail-soft) must NOT collapse to 0 — that
|
|
653
|
-
// would make "endpoint down" indistinguishable from "prompt too tight"
|
|
654
|
-
// in the fleet aggregate. Leave undefined so the optional field is
|
|
655
|
-
// omitted and the aggregate buckets skipped runs separately.
|
|
656
|
-
const refl = report.stages.reflection;
|
|
657
|
-
if (refl && !refl.skipped)
|
|
658
|
-
lessonsGenerated = refl.lessons_generated;
|
|
659
|
-
// #246: surface token totals for telemetry + dashboard snapshot. Both
|
|
660
|
-
// fields stay undefined on a skipped/failed run (no metered calls →
|
|
661
|
-
// nothing to report; the optional fields are omitted from the ping).
|
|
662
|
-
const tokensTotal = report.budget?.tokens_total;
|
|
663
|
-
if (tokensTotal && tokensTotal.total > 0) {
|
|
664
|
-
tokensThisRun = tokensTotal.total;
|
|
665
|
-
tokensByStage = report.budget?.tokens_by_stage;
|
|
627
|
+
// #337: readiness probe — ONE minimal generation request before any
|
|
628
|
+
// consolidation phase. This REPLACES the #231 no-preflight decision
|
|
629
|
+
// (its premise "a failed phase costs latency, not data" was falsified
|
|
630
|
+
// by the 2026-08-23/24 incident: the gateway answered /v1/models —
|
|
631
|
+
// liveness — while generation was dead, and the nightly retried into
|
|
632
|
+
// it for ~5 h, making the wedge monotonically worse). A failed probe
|
|
633
|
+
// skips consolidation entirely with the diagnosis "LLM endpoint not
|
|
634
|
+
// generating" (not-generating, not slow) and status endpoint_down —
|
|
635
|
+
// a transient outcome: consolidation has resumable cursors, the
|
|
636
|
+
// nightly re-runs 2-4×/day, and capture is unaffected (the daemon's
|
|
637
|
+
// /distill path has its own probe). Zero LLM phases run when the
|
|
638
|
+
// probe fails — one fast failure is the whole cost.
|
|
639
|
+
const probeOk = await llm.probe();
|
|
640
|
+
if (!probeOk) {
|
|
641
|
+
console.error("[hicortex] LLM endpoint not generating — consolidation skipped " +
|
|
642
|
+
"(endpoint_down, will retry next run). See the ops runbook's " +
|
|
643
|
+
"known failure signatures; llmProbeTimeoutMs tunes the probe's patience.");
|
|
644
|
+
consolidationStatus = "endpoint_down";
|
|
666
645
|
}
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
646
|
+
else {
|
|
647
|
+
const cfgDomains = (0, domain_classify_js_1.parseConfigDomains)(savedConfig);
|
|
648
|
+
console.log(`[hicortex] Running consolidation...`);
|
|
649
|
+
const report = await (0, consolidate_js_1.runConsolidation)(db, llm, embedder_js_1.embed, dryRun, false, undefined, {
|
|
650
|
+
domains: cfgDomains,
|
|
651
|
+
contentDomainsReady: true,
|
|
652
|
+
weakPrimaryFloor: (0, nofit_js_1.resolveWeakPrimaryFloor)(savedConfig),
|
|
653
|
+
}, {
|
|
654
|
+
minSimilarity: savedConfig?.supersessionMinSimilarity,
|
|
655
|
+
maxCalls: savedConfig?.supersessionMaxCalls,
|
|
656
|
+
},
|
|
657
|
+
// #241: config-driven total LLM-call ceiling (default 5000, was 200).
|
|
658
|
+
(0, config_read_js_1.readPositiveConfig)(savedConfig ?? {}, "consolidateMaxLlmCalls", consolidate_js_1.CONSOLIDATE_MAX_LLM_CALLS),
|
|
659
|
+
// #245: soft cap on the corpus (default 10000; 0 disables eviction).
|
|
660
|
+
memorySoftCapResolved);
|
|
661
|
+
console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
|
|
662
|
+
(report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
|
|
663
|
+
consolidationStatus = report.status;
|
|
664
|
+
// #337: the stages fail soft, so a run against an endpoint that died
|
|
665
|
+
// MID-run would otherwise report "completed". An open breaker is the
|
|
666
|
+
// honest signal — override to endpoint_down (lastConsolidated still
|
|
667
|
+
// only advances on a clean "completed", so the work is re-run).
|
|
668
|
+
if (llm.breakerOpen) {
|
|
669
|
+
console.error(`[hicortex] LLM circuit breaker OPEN after consolidation — ` +
|
|
670
|
+
`overriding "${report.status}" to endpoint_down (stages failed ` +
|
|
671
|
+
`soft against a down endpoint; will retry next run).`);
|
|
672
|
+
consolidationStatus = "endpoint_down";
|
|
673
|
+
}
|
|
674
|
+
// #245: capture the eviction count for the dashboard snapshot. The
|
|
675
|
+
// stage always returns `evicted` (0 when under cap / disabled); report
|
|
676
|
+
// it as 0 (a real value), not undefined, when the stage ran.
|
|
677
|
+
evictedCount = report.stages.memory_cap?.evicted ?? 0;
|
|
678
|
+
// Only set when reflection actually RAN (not skipped). A skipped stage
|
|
679
|
+
// (e.g. endpoint offline, #232 fail-soft) must NOT collapse to 0 — that
|
|
680
|
+
// would make "endpoint down" indistinguishable from "prompt too tight"
|
|
681
|
+
// in the fleet aggregate. Leave undefined so the optional field is
|
|
682
|
+
// omitted and the aggregate buckets skipped runs separately.
|
|
683
|
+
const refl = report.stages.reflection;
|
|
684
|
+
if (refl && !refl.skipped)
|
|
685
|
+
lessonsGenerated = refl.lessons_generated;
|
|
686
|
+
// #246: surface token totals for telemetry + dashboard snapshot. Both
|
|
687
|
+
// fields stay undefined on a skipped/failed run (no metered calls →
|
|
688
|
+
// nothing to report; the optional fields are omitted from the ping).
|
|
689
|
+
const tokensTotal = report.budget?.tokens_total;
|
|
690
|
+
if (tokensTotal && tokensTotal.total > 0) {
|
|
691
|
+
tokensThisRun = tokensTotal.total;
|
|
692
|
+
tokensByStage = report.budget?.tokens_by_stage;
|
|
693
|
+
}
|
|
694
|
+
// #255: budget exhaustion — always populated when consolidation ran
|
|
695
|
+
// (report.budget.exhausted is a boolean). The dashboard + telemetry
|
|
696
|
+
// treat true as a quality-degradation health signal. The
|
|
697
|
+
// ran-vs-didn't-run distinction is carried by `budgetCallsUsed`/
|
|
698
|
+
// `budgetMaxCalls` (forwarded whenever consolidation ran), NOT by a
|
|
699
|
+
// false `budget_exhausted` flag — the snapshot forwards
|
|
700
|
+
// `budget_exhausted` only on exhaustion (alert state), so the
|
|
701
|
+
// aggregate reads: calls_used present + budget_exhausted undefined
|
|
702
|
+
// = "ran and didn't exhaust"; calls_used undefined = "didn't run".
|
|
703
|
+
budgetExhausted = report.budget?.exhausted;
|
|
704
|
+
budgetDeferredByStage = report.budget?.deferred_by_stage;
|
|
705
|
+
budgetCallsUsed = report.budget?.calls_used;
|
|
706
|
+
budgetMaxCalls = report.budget?.max_calls;
|
|
707
|
+
// #246: accrue to state.json (monthly reset + last-run estimate for
|
|
708
|
+
// the next throttle check). Written even on a failed run — a partial
|
|
709
|
+
// run that made metered calls before the failure still spent tokens,
|
|
710
|
+
// and the next run's estimate should reflect that.
|
|
711
|
+
if (!dryRun) {
|
|
712
|
+
(0, state_js_1.updateState)((s) => {
|
|
713
|
+
const now = new Date();
|
|
714
|
+
const cur = s.llmTokensThisPeriod;
|
|
715
|
+
let periodStart = cur?.periodStart ?? now.toISOString();
|
|
716
|
+
let prompt = cur?.prompt ?? 0;
|
|
717
|
+
let completion = cur?.completion ?? 0;
|
|
718
|
+
let total = cur?.total ?? 0;
|
|
719
|
+
// Monthly reset: if periodStart is in a previous calendar month,
|
|
720
|
+
// zero the accrual before adding this run's contribution.
|
|
721
|
+
const startD = new Date(periodStart);
|
|
722
|
+
if (startD.getUTCFullYear() !== now.getUTCFullYear() ||
|
|
723
|
+
startD.getUTCMonth() !== now.getUTCMonth()) {
|
|
724
|
+
periodStart = now.toISOString();
|
|
725
|
+
prompt = 0;
|
|
726
|
+
completion = 0;
|
|
727
|
+
total = 0;
|
|
728
|
+
}
|
|
729
|
+
if (tokensTotal) {
|
|
730
|
+
prompt += tokensTotal.prompt;
|
|
731
|
+
completion += tokensTotal.completion;
|
|
732
|
+
total += tokensTotal.total;
|
|
733
|
+
}
|
|
734
|
+
s.llmTokensThisPeriod = {
|
|
735
|
+
prompt, completion, total, periodStart,
|
|
736
|
+
};
|
|
737
|
+
s.llmTokensLastRun = tokensTotal?.total ?? 0;
|
|
738
|
+
}, stateDir);
|
|
739
|
+
}
|
|
712
740
|
}
|
|
713
741
|
}
|
|
714
742
|
}
|
package/dist/prompts.d.ts
CHANGED
|
@@ -19,20 +19,23 @@ export declare function reflection(memoriesBlock: string, recentLessons?: string
|
|
|
19
19
|
/**
|
|
20
20
|
* Distillation prompt. Extracts knowledge from a session transcript.
|
|
21
21
|
*
|
|
22
|
-
* LAYOUT (
|
|
23
|
-
*
|
|
24
|
-
* prefix
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
22
|
+
* LAYOUT (REVERTED 2026-08-24): transcript BEFORE the static instruction
|
|
23
|
+
* block — the pre-0.19.4 order. The #329 item-6 reorder (static-first, for
|
|
24
|
+
* provider prefix caching) was REVERTED after a deterministic A/B on real
|
|
25
|
+
* segments: with instructions first, the model over-fires NO_EXTRACT on
|
|
26
|
+
* summary-led and long mixed sessions (a real coding segment: 15 memories →
|
|
27
|
+
* 0; a real Hermes session: rich → 0; isolation proved the LAYOUT caused it,
|
|
28
|
+
* not the item-5 sentence, which is KEPT). Silent shape-dependent segment
|
|
29
|
+
* loss beats any caching win. Re-attempting instructions-first requires a
|
|
30
|
+
* gate fix that passes the A/B matrix harness first.
|
|
31
|
+
*
|
|
32
|
+
* #339 gate hardening (same day): the NO_EXTRACT rule now carries an explicit
|
|
33
|
+
* whole-transcript guard + counter-example (a summary-led session that
|
|
34
|
+
* contains later decisions MUST be extracted) — the over-firing mechanism was
|
|
35
|
+
* the model pattern-matching a bookkeeping-heavy OPENING to the ephemera gate
|
|
36
|
+
* and abandoning the whole segment. Companion visibility net (warning on
|
|
37
|
+
* large empty results) lives in distiller.ts; the release-gate harness is
|
|
38
|
+
* scripts/distill-ab-check/.
|
|
36
39
|
*/
|
|
37
40
|
export declare function distillation(projectName: string, date: string, transcript: string): string;
|
|
38
41
|
/**
|
package/dist/prompts.js
CHANGED
|
@@ -107,25 +107,31 @@ Respond with a JSON array. Empty array [] is a valid response.`;
|
|
|
107
107
|
/**
|
|
108
108
|
* Distillation prompt. Extracts knowledge from a session transcript.
|
|
109
109
|
*
|
|
110
|
-
* LAYOUT (
|
|
111
|
-
*
|
|
112
|
-
* prefix
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
110
|
+
* LAYOUT (REVERTED 2026-08-24): transcript BEFORE the static instruction
|
|
111
|
+
* block — the pre-0.19.4 order. The #329 item-6 reorder (static-first, for
|
|
112
|
+
* provider prefix caching) was REVERTED after a deterministic A/B on real
|
|
113
|
+
* segments: with instructions first, the model over-fires NO_EXTRACT on
|
|
114
|
+
* summary-led and long mixed sessions (a real coding segment: 15 memories →
|
|
115
|
+
* 0; a real Hermes session: rich → 0; isolation proved the LAYOUT caused it,
|
|
116
|
+
* not the item-5 sentence, which is KEPT). Silent shape-dependent segment
|
|
117
|
+
* loss beats any caching win. Re-attempting instructions-first requires a
|
|
118
|
+
* gate fix that passes the A/B matrix harness first.
|
|
119
|
+
*
|
|
120
|
+
* #339 gate hardening (same day): the NO_EXTRACT rule now carries an explicit
|
|
121
|
+
* whole-transcript guard + counter-example (a summary-led session that
|
|
122
|
+
* contains later decisions MUST be extracted) — the over-firing mechanism was
|
|
123
|
+
* the model pattern-matching a bookkeeping-heavy OPENING to the ephemera gate
|
|
124
|
+
* and abandoning the whole segment. Companion visibility net (warning on
|
|
125
|
+
* large empty results) lives in distiller.ts; the release-gate harness is
|
|
126
|
+
* scripts/distill-ab-check/.
|
|
124
127
|
*/
|
|
125
128
|
function distillation(projectName, date, transcript) {
|
|
126
129
|
return `You are a memory extraction agent. Analyze this AI session transcript and extract
|
|
127
130
|
knowledge worth remembering long-term.
|
|
128
131
|
|
|
132
|
+
SESSION TRANSCRIPT (project: ${projectName}, date: ${date}):
|
|
133
|
+
${transcript}
|
|
134
|
+
|
|
129
135
|
EXTRACT into this markdown format:
|
|
130
136
|
|
|
131
137
|
# Session Memory: ${date} - ${projectName}
|
|
@@ -213,6 +219,23 @@ user-confirmed standardization it embodies qualifies.
|
|
|
213
219
|
If EVERY item in the transcript is never-record ephemera, output ONLY:
|
|
214
220
|
"NO_EXTRACT" — zero memories is the correct result for a pure-status segment.
|
|
215
221
|
|
|
222
|
+
NO_EXTRACT guard (a verdict on the WHOLE transcript, never on its opening):
|
|
223
|
+
"NO_EXTRACT" requires that NO durable decision, knowledge, or correction
|
|
224
|
+
appears ANYWHERE in the transcript — including after long bookkeeping
|
|
225
|
+
stretches. The opening is not evidence about the rest: real sessions often
|
|
226
|
+
OPEN with bookkeeping (a compaction summary, a task notification, a status
|
|
227
|
+
recap) and CONTAIN extractable material later. Read to the END of the
|
|
228
|
+
transcript before deciding; NO_EXTRACT on a long, mixed session is almost
|
|
229
|
+
always a mistake — when in doubt, extract the durable items.
|
|
230
|
+
Counter-example (MUST be extracted, never NO_EXTRACT): a session opens with
|
|
231
|
+
"Session summary: continuing the API migration; prior PR merged, tests
|
|
232
|
+
green" but later the user confirms "standardize on the queue-based worker —
|
|
233
|
+
make it the documented default" and corrects the assistant: "no, don't gate
|
|
234
|
+
retries behind a flag — remove the flag entirely". That session yields at
|
|
235
|
+
least a [D] standardization and an [E] correction; the summary opening
|
|
236
|
+
changes nothing. Emitting NO_EXTRACT there would lose the only record of
|
|
237
|
+
both.
|
|
238
|
+
|
|
216
239
|
RULES:
|
|
217
240
|
- Extract MAX 20 items total (quality over quantity)
|
|
218
241
|
- Use EXACT names/versions/paths/numbers as they appear in the transcript —
|
|
@@ -231,9 +254,6 @@ RULES:
|
|
|
231
254
|
"[Strong Negative] User rejected per-agent billing"). The subject always comes first.
|
|
232
255
|
- Omit any section that has zero items (don't include empty sections)
|
|
233
256
|
- If nothing worth extracting, output ONLY: "NO_EXTRACT"
|
|
234
|
-
|
|
235
|
-
SESSION TRANSCRIPT (project: ${projectName}, date: ${date}):
|
|
236
|
-
${transcript}
|
|
237
257
|
`;
|
|
238
258
|
}
|
|
239
259
|
/**
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -89,15 +89,19 @@ export interface TelemetryPayload {
|
|
|
89
89
|
* Consolidation outcome for THIS full nightly (server mode only —
|
|
90
90
|
* capture-only runs send no nightly ping, so the field is absent there).
|
|
91
91
|
* `runConsolidation`'s status: "completed" | "skipped" | "failed", plus
|
|
92
|
-
* "no_llm" when consolidation was skipped because no LLM was configured,
|
|
92
|
+
* "no_llm" when consolidation was skipped because no LLM was configured,
|
|
93
93
|
* "throttled" (#246) when the run was skipped because the
|
|
94
|
-
* `llmTokensPerMonth` fair-use cap was projected to be exceeded
|
|
94
|
+
* `llmTokensPerMonth` fair-use cap was projected to be exceeded, and
|
|
95
|
+
* "endpoint_down" (#337) when the pre-consolidation readiness probe failed
|
|
96
|
+
* or the LLM circuit breaker was open after the run — a TRANSIENT state
|
|
97
|
+
* (retried next run), never reported as "completed" even though the stages
|
|
98
|
+
* fail soft.
|
|
95
99
|
* "skipped" = the built-in nothing-to-do short-circuit (no new + no unscored
|
|
96
100
|
* memories → zero LLM calls), NOT a failure. Lets the fleet aggregate tell a
|
|
97
101
|
* real consolidation run from a no-op without repurposing `ok` (which is the
|
|
98
102
|
* capture-health signal). 0.17+.
|
|
99
103
|
*/
|
|
100
|
-
consolidation?: "completed" | "skipped" | "failed" | "no_llm" | "throttled";
|
|
104
|
+
consolidation?: "completed" | "skipped" | "failed" | "no_llm" | "throttled" | "endpoint_down";
|
|
101
105
|
/**
|
|
102
106
|
* Total LLM tokens consumed by THIS nightly's consolidation (#246) — the
|
|
103
107
|
* BudgetTracker total. Server-mode only (capture-only + client runs make no
|
package/dist/types.d.ts
CHANGED
|
@@ -392,6 +392,48 @@ export interface HicortexConfig {
|
|
|
392
392
|
* doubled for margin). Only relevant when `ollamaFlushEvery` > 0.
|
|
393
393
|
*/
|
|
394
394
|
ollamaFlushWaitMs?: number;
|
|
395
|
+
/**
|
|
396
|
+
* ONE per-attempt timeout ceiling (ms) for every LLM phase — distill,
|
|
397
|
+
* reflect, classify, and scoring alike (#337). Default 900000 (15 min). The
|
|
398
|
+
* openai-compat and anthropic requests fetch through an undici dispatcher
|
|
399
|
+
* with undici's hidden 5-minute header/body timers disabled, so this knob is
|
|
400
|
+
* the ONLY ceiling: a legitimate long generation is no longer abandoned
|
|
401
|
+
* client-side at 5 min while the server keeps generating for the dead
|
|
402
|
+
* client (the 2026-08-23/24 incident's amplification mechanism). Before
|
|
403
|
+
* #337, scoring used a 600 s ceiling and the other phases 900 s; one knob
|
|
404
|
+
* now covers all four. No effect on the ollama path (already streams) or
|
|
405
|
+
* claude-cli (subprocess timeout).
|
|
406
|
+
*/
|
|
407
|
+
llmTimeoutMs?: number;
|
|
408
|
+
/**
|
|
409
|
+
* Consecutive ladder-exhausted TOTAL failures (fetch-failed / ECONNREFUSED /
|
|
410
|
+
* timeout / "Headers Timeout" class) after which the per-endpoint circuit
|
|
411
|
+
* breaker opens (#337). Default 3; `0` disables. While open, calls throw
|
|
412
|
+
* `LlmCircuitOpenError` immediately with NO network I/O. HTTP error statuses
|
|
413
|
+
* with a response, parse errors, and rate limits never count (they throw
|
|
414
|
+
* before the retry ladder can be exhausted). Any success resets the counter.
|
|
415
|
+
*/
|
|
416
|
+
llmBreakerThreshold?: number;
|
|
417
|
+
/**
|
|
418
|
+
* How long (ms) an open circuit breaker stays open before the next call
|
|
419
|
+
* becomes a half-open trial (#337). Default 600000 (10 min). A trial failure
|
|
420
|
+
* re-opens the breaker; a trial success resets it.
|
|
421
|
+
*/
|
|
422
|
+
llmBreakerCooldownMs?: number;
|
|
423
|
+
/**
|
|
424
|
+
* Timeout (ms) for the readiness probe's single 1-token generation attempt
|
|
425
|
+
* (#337). Default 60000. The probe asks "can this endpoint GENERATE", which
|
|
426
|
+
* /health-style liveness checks cannot answer (a wedged gateway keeps
|
|
427
|
+
* answering /v1/models). Read by the nightly before consolidation and by the
|
|
428
|
+
* daemon before distilling.
|
|
429
|
+
*/
|
|
430
|
+
llmProbeTimeoutMs?: number;
|
|
431
|
+
/**
|
|
432
|
+
* How long (ms) the daemon caches a /distill probe outcome before probing
|
|
433
|
+
* again (#337). Default 300000 — a healthy capture cadence pays at most one
|
|
434
|
+
* probe per window. Nightly runs are single-shot and never cache.
|
|
435
|
+
*/
|
|
436
|
+
llmProbeTtlMs?: number;
|
|
395
437
|
/**
|
|
396
438
|
* Max lessons injected into an agent's session-start context (default 10).
|
|
397
439
|
* Lessons are ranked per-session by project/domain affinity + recency +
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "hicortex",
|
|
3
3
|
"name": "Hicortex — Long-term Memory That Learns",
|
|
4
4
|
"description": "Your agents remember past decisions, avoid repeated mistakes, and get smarter every day. Nightly reflection generates actionable lessons that automatically update agent behavior.",
|
|
5
|
-
"version": "0.19.
|
|
5
|
+
"version": "0.19.5",
|
|
6
6
|
"kind": "lifecycle",
|
|
7
7
|
"skills": ["./skills/hicortex-memory"],
|
|
8
8
|
"configSchema": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gamaze/hicortex",
|
|
3
|
-
"version": "0.19.
|
|
3
|
+
"version": "0.19.6",
|
|
4
4
|
"description": "Persistent agent identity for AI agents \u2014 a hand-edited identity layer, nightly-distilled experience, and lessons injected every session, shared across your whole fleet. Works with Hermes, OpenClaw, Claude Code, and Pi.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -71,6 +71,7 @@
|
|
|
71
71
|
"better-sqlite3": "^12.11.1",
|
|
72
72
|
"express": "^4.21.0",
|
|
73
73
|
"sqlite-vec": "^0.1.7",
|
|
74
|
-
"tar-stream": "^2.2.0"
|
|
74
|
+
"tar-stream": "^2.2.0",
|
|
75
|
+
"undici": "^8.10.0"
|
|
75
76
|
}
|
|
76
77
|
}
|