@gamaze/hicortex 0.19.5 → 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 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/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
- const result = await this.complete(this.config.model, prompt, tokens, 600_000);
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
- return await this.completeOnce(model, prompt, maxTokens, timeoutMs);
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
- const msg = lastErr.message;
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 (${msg.slice(0, 60)}), retry ${attempt + 1}/${retryDelays.length} in ${delay / 1000}s...`);
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);
@@ -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 >
@@ -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.
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
- // One model serves all phases (#231) there is no separate endpoint to
624
- // pre-flight. If the model doesn't answer, `complete()` already retries at
625
- // 30s/60s/120s (~3.5 min); anything still failing after that is an outage,
626
- // not a blip. A failed phase costs latency, not data: capture cursors hold
627
- // on failure (dup-over-loss), and consolidation has resumable cursors
628
- // (domainCursor, supersessionCursor). The nightly runs 2-4×/day, so the
629
- // wait is hours no polling, no new config. (Issue #231.)
630
- const cfgDomains = (0, domain_classify_js_1.parseConfigDomains)(savedConfig);
631
- console.log(`[hicortex] Running consolidation...`);
632
- const report = await (0, consolidate_js_1.runConsolidation)(db, llm, embedder_js_1.embed, dryRun, false, undefined, {
633
- domains: cfgDomains,
634
- contentDomainsReady: true,
635
- weakPrimaryFloor: (0, nofit_js_1.resolveWeakPrimaryFloor)(savedConfig),
636
- }, {
637
- minSimilarity: savedConfig?.supersessionMinSimilarity,
638
- maxCalls: savedConfig?.supersessionMaxCalls,
639
- },
640
- // #241: config-driven total LLM-call ceiling (default 5000, was 200).
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 probeONE 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
- // #255: budget exhaustion — always populated when consolidation ran
668
- // (report.budget.exhausted is a boolean). The dashboard + telemetry
669
- // treat true as a quality-degradation health signal. The
670
- // ran-vs-didn't-run distinction is carried by `budgetCallsUsed`/
671
- // `budgetMaxCalls` (forwarded whenever consolidation ran), NOT by a
672
- // false `budget_exhausted` flag — the snapshot forwards
673
- // `budget_exhausted` only on exhaustion (alert state), so the
674
- // aggregate reads: calls_used present + budget_exhausted undefined
675
- // = "ran and didn't exhaust"; calls_used undefined = "didn't run".
676
- budgetExhausted = report.budget?.exhausted;
677
- budgetDeferredByStage = report.budget?.deferred_by_stage;
678
- budgetCallsUsed = report.budget?.calls_used;
679
- budgetMaxCalls = report.budget?.max_calls;
680
- // #246: accrue to state.json (monthly reset + last-run estimate for
681
- // the next throttle check). Written even on a failed run — a partial
682
- // run that made metered calls before the failure still spent tokens,
683
- // and the next run's estimate should reflect that.
684
- if (!dryRun) {
685
- (0, state_js_1.updateState)((s) => {
686
- const now = new Date();
687
- const cur = s.llmTokensThisPeriod;
688
- let periodStart = cur?.periodStart ?? now.toISOString();
689
- let prompt = cur?.prompt ?? 0;
690
- let completion = cur?.completion ?? 0;
691
- let total = cur?.total ?? 0;
692
- // Monthly reset: if periodStart is in a previous calendar month,
693
- // zero the accrual before adding this run's contribution.
694
- const startD = new Date(periodStart);
695
- if (startD.getUTCFullYear() !== now.getUTCFullYear() ||
696
- startD.getUTCMonth() !== now.getUTCMonth()) {
697
- periodStart = now.toISOString();
698
- prompt = 0;
699
- completion = 0;
700
- total = 0;
701
- }
702
- if (tokensTotal) {
703
- prompt += tokensTotal.prompt;
704
- completion += tokensTotal.completion;
705
- total += tokensTotal.total;
706
- }
707
- s.llmTokensThisPeriod = {
708
- prompt, completion, total, periodStart,
709
- };
710
- s.llmTokensLastRun = tokensTotal?.total ?? 0;
711
- }, stateDir);
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
  }
@@ -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, and
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gamaze/hicortex",
3
- "version": "0.19.5",
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
  }