@gamaze/hicortex 0.19.5 → 0.20.0

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.
@@ -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.d.ts CHANGED
@@ -35,6 +35,37 @@ export declare function computeSince(stateDir: string, recaptureWindowDays?: num
35
35
  * Exported for unit tests (pure on the header value).
36
36
  */
37
37
  export declare function parseRetryAfterMs(resp: Response): number | undefined;
38
+ /**
39
+ * Evict-only nightly (#317) — the exported seam behind `nightly --evict-only`
40
+ * (the CLI path calls it via runNightly's evictOnly option; exporting it lets
41
+ * tests and the hosted evict timer exercise the exact mode on a temp DB
42
+ * without spawning a process).
43
+ *
44
+ * Runs ONLY stageMemoryCapEviction: opens the DB, evicts the lowest-value
45
+ * memories above the cap, closes. No capture, no watchdog, and NEVER an LLM
46
+ * client — the stage is pure database work, and the whole point of the mode
47
+ * is that a hosted trial tenant's corpus stays bounded without any LLM
48
+ * stages (the #298 trial-cost deviation). The cap resolves env-wins
49
+ * (resolveMemorySoftCap), so the hosted HICORTEX_MEMORY_CAP env pin —
50
+ * inherited by docker exec — is what governs here, immune to the
51
+ * tenant-writable config.json.
52
+ *
53
+ * Synchronous by design (better-sqlite3 is sync; there is nothing to await)
54
+ * and returns the stage result so callers (tests, operators scripting around
55
+ * the timer) can log/assert the outcome. The decay/recall/scoring knobs are
56
+ * configured from the same config a FULL nightly would read — the eviction
57
+ * ranker (effectiveStrength) must pick victims by the same clock the recall
58
+ * ranker uses, or an evict-only run would evict a different tail than the
59
+ * nightly would have.
60
+ */
61
+ export declare function runEvictionOnly(options?: {
62
+ dbPath?: string;
63
+ stateDir?: string;
64
+ dryRun?: boolean;
65
+ }): {
66
+ cap: number;
67
+ evicted: number;
68
+ };
38
69
  export declare function runNightly(options?: {
39
70
  dryRun?: boolean;
40
71
  captureOnly?: boolean;
@@ -57,4 +88,14 @@ export declare function runNightly(options?: {
57
88
  * DB — the tenant's agents push via /distill; the server only consolidates.
58
89
  */
59
90
  consolidateOnly?: boolean;
91
+ /**
92
+ * Evict-only mode (#317): run ONLY the memory-cap eviction stage — no
93
+ * watchdog, no capture, and NEVER an LLM client (the stage is pure database
94
+ * work, consolidate.ts stageMemoryCapEviction). This is the trial hard wall:
95
+ * hosted trials get a low HICORTEX_MEMORY_CAP env pin and a per-tenant
96
+ * evict timer that execs this mode, so a trial corpus stays bounded with
97
+ * zero LLM stages (the #298 no-LLM-for-trials cost deviation). Also the
98
+ * lifetime converge tool — idempotent, cheap, safe to run at any cadence.
99
+ */
100
+ evictOnly?: boolean;
60
101
  }): Promise<void>;
package/dist/nightly.js CHANGED
@@ -47,6 +47,7 @@ var __importStar = (this && this.__importStar) || (function () {
47
47
  Object.defineProperty(exports, "__esModule", { value: true });
48
48
  exports.computeSince = computeSince;
49
49
  exports.parseRetryAfterMs = parseRetryAfterMs;
50
+ exports.runEvictionOnly = runEvictionOnly;
50
51
  exports.runNightly = runNightly;
51
52
  const paths_js_1 = require("./paths.js");
52
53
  const node_fs_1 = require("node:fs");
@@ -68,6 +69,7 @@ const transcript_reader_js_1 = require("./transcript-reader.js");
68
69
  const hermes_transcript_reader_js_1 = require("./hermes-transcript-reader.js");
69
70
  const pi_transcript_reader_js_1 = require("./pi-transcript-reader.js");
70
71
  const oc_transcript_reader_js_1 = require("./oc-transcript-reader.js");
72
+ const opencode_transcript_reader_js_1 = require("./opencode-transcript-reader.js");
71
73
  const features_js_1 = require("./features.js");
72
74
  const retrieval_js_1 = require("./retrieval.js");
73
75
  const state_js_1 = require("./state.js");
@@ -291,14 +293,65 @@ function rotateNightlyLog(stateDir = HICORTEX_HOME) {
291
293
  }
292
294
  catch { /* no log file, or unreadable — nothing to rotate */ }
293
295
  }
296
+ /**
297
+ * Evict-only nightly (#317) — the exported seam behind `nightly --evict-only`
298
+ * (the CLI path calls it via runNightly's evictOnly option; exporting it lets
299
+ * tests and the hosted evict timer exercise the exact mode on a temp DB
300
+ * without spawning a process).
301
+ *
302
+ * Runs ONLY stageMemoryCapEviction: opens the DB, evicts the lowest-value
303
+ * memories above the cap, closes. No capture, no watchdog, and NEVER an LLM
304
+ * client — the stage is pure database work, and the whole point of the mode
305
+ * is that a hosted trial tenant's corpus stays bounded without any LLM
306
+ * stages (the #298 trial-cost deviation). The cap resolves env-wins
307
+ * (resolveMemorySoftCap), so the hosted HICORTEX_MEMORY_CAP env pin —
308
+ * inherited by docker exec — is what governs here, immune to the
309
+ * tenant-writable config.json.
310
+ *
311
+ * Synchronous by design (better-sqlite3 is sync; there is nothing to await)
312
+ * and returns the stage result so callers (tests, operators scripting around
313
+ * the timer) can log/assert the outcome. The decay/recall/scoring knobs are
314
+ * configured from the same config a FULL nightly would read — the eviction
315
+ * ranker (effectiveStrength) must pick victims by the same clock the recall
316
+ * ranker uses, or an evict-only run would evict a different tail than the
317
+ * nightly would have.
318
+ */
319
+ function runEvictionOnly(options = {}) {
320
+ const stateDir = options.stateDir ?? HICORTEX_HOME;
321
+ const savedConfig = readNightlyConfig(stateDir);
322
+ // Same clock as the full nightly (see runNightly's identical trio) — the
323
+ // eviction ranker reads these module knobs.
324
+ (0, retrieval_js_1.configureDecay)({ halfLifeDays: savedConfig?.decayHalfLifeDays });
325
+ (0, retrieval_js_1.configureRecall)(savedConfig);
326
+ (0, retrieval_js_1.configureScoring)(savedConfig);
327
+ const dbPath = (0, db_js_1.resolveDbPath)(options.dbPath);
328
+ console.log(`[hicortex] evict-only run${options.dryRun ? " (dry run)" : ""} — DB: ${dbPath}`);
329
+ const db = (0, db_js_1.initDb)(dbPath);
330
+ try {
331
+ const res = (0, consolidate_js_1.stageMemoryCapEviction)(db, options.dryRun ?? false, (0, consolidate_js_1.resolveMemorySoftCap)(savedConfig?.memorySoftCap));
332
+ console.log(`[hicortex] evict-only ${options.dryRun ? "would evict" : "evicted"} ${res.evicted} ` +
333
+ `memor${res.evicted === 1 ? "y" : "ies"} (cap ${res.cap})`);
334
+ return res;
335
+ }
336
+ finally {
337
+ db.close();
338
+ }
339
+ }
294
340
  async function runNightly(options = {}) {
295
341
  const dryRun = options.dryRun ?? false;
296
342
  let captureOnly = options.captureOnly ?? false;
297
343
  const watchdog = options.watchdog ?? false;
298
344
  const consolidateOnly = options.consolidateOnly ?? false;
345
+ const evictOnly = options.evictOnly ?? false;
299
346
  if (captureOnly && consolidateOnly) {
300
347
  throw new Error("runNightly: captureOnly and consolidateOnly are mutually exclusive");
301
348
  }
349
+ if (evictOnly && captureOnly) {
350
+ throw new Error("runNightly: evictOnly and captureOnly are mutually exclusive");
351
+ }
352
+ if (evictOnly && consolidateOnly) {
353
+ throw new Error("runNightly: evictOnly and consolidateOnly are mutually exclusive");
354
+ }
302
355
  const stateDir = options.stateDir ?? HICORTEX_HOME;
303
356
  const recaptureWindowDays = options.recaptureWindowDays;
304
357
  rotateNightlyLog(stateDir);
@@ -318,6 +371,20 @@ async function runNightly(options = {}) {
318
371
  const savedConfig = readNightlyConfig(stateDir);
319
372
  // 0.16.8 upgrade guard: warn if ignored per-stage keys are still present.
320
373
  (0, config_read_js_1.warnIgnoredConfigKeys)(savedConfig);
374
+ // Evict-only (#317): the pure-DB maintenance mode. Branches BEFORE the
375
+ // watchdog gate, the agentId self-heal, and every capture/LLM step — the
376
+ // mode must stay strictly "open DB, evict to cap, close" (see
377
+ // runEvictionOnly below). A client-mode machine has no local DB to evict
378
+ // (the corpus lives on the remote server); skipping WITHOUT creating one is
379
+ // the honest answer there.
380
+ if (evictOnly) {
381
+ if (savedConfig?.mode === "client") {
382
+ console.log("[hicortex] evict-only: client mode has no local DB — nothing to evict");
383
+ return;
384
+ }
385
+ runEvictionOnly({ dbPath: options.dbPath, stateDir, dryRun });
386
+ return;
387
+ }
321
388
  // 0.16.2 activation gap: pre-0.16.2 installs never re-run init, so their
322
389
  // config has no agentId → capture sent source_agent_id: null forever (the
323
390
  // provenance feature was inert for the whole existing fleet). Self-heal on
@@ -428,6 +495,7 @@ async function runNightly(options = {}) {
428
495
  let hermesBatches = [];
429
496
  let piBatches = [];
430
497
  let ocBatches = [];
498
+ let opencodeBatches = [];
431
499
  let batches = [];
432
500
  let memoriesIngested = 0;
433
501
  let hadTransientFailure = false;
@@ -487,7 +555,11 @@ async function runNightly(options = {}) {
487
555
  // OpenClaw persists sessions in the Pi v3 format at ~/.openclaw/agents/;
488
556
  // no-ops when OC isn't installed.
489
557
  ocBatches = (0, oc_transcript_reader_js_1.readOcTranscripts)(since, undefined, cursorMap);
490
- batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches];
558
+ // opencode persists sessions in one SQLite store
559
+ // (~/.local/share/opencode/opencode.db); no-ops when opencode isn't
560
+ // installed (#347).
561
+ opencodeBatches = (0, opencode_transcript_reader_js_1.readOpencodeSessions)(since, undefined, cursorMap);
562
+ batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches, ...opencodeBatches];
491
563
  if (ccBatches.length > 0)
492
564
  console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
493
565
  if (hermesBatches.length > 0)
@@ -496,6 +568,8 @@ async function runNightly(options = {}) {
496
568
  console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
497
569
  if (ocBatches.length > 0)
498
570
  console.log(`[hicortex] Found ${ocBatches.length} OpenClaw session(s)`);
571
+ if (opencodeBatches.length > 0)
572
+ console.log(`[hicortex] Found ${opencodeBatches.length} opencode session(s)`);
499
573
  console.log(`[hicortex] Total: ${batches.length} new session(s)`);
500
574
  if (batches.length === 0 && !dryRun) {
501
575
  console.log(captureOnly
@@ -549,12 +623,20 @@ async function runNightly(options = {}) {
549
623
  // Resolved cap (#245) for the dashboard snapshot. Hoisted so the snapshot
550
624
  // writer (outside the consolidation block) can stamp `capacity` even when
551
625
  // consolidation was skipped (the cap is still "in force" config-wise).
552
- const memorySoftCapResolved = (0, config_read_js_1.readNonNegativeConfig)(savedConfig ?? {}, "memorySoftCap", consolidate_js_1.DEFAULT_MEMORY_SOFT_CAP);
626
+ // #317: resolved through the shared env-wins resolver — a
627
+ // HICORTEX_MEMORY_CAP pin governs the eviction input AND the snapshot
628
+ // stamp from this one value (the dashboard live headline resolves through
629
+ // the same function, so enforced + displayed can never disagree).
630
+ const memorySoftCapResolved = (0, consolidate_js_1.resolveMemorySoftCap)(savedConfig?.memorySoftCap);
553
631
  // Consolidation outcome for telemetry (0.17). undefined on capture-only runs
554
632
  // (which send no nightly ping). "skipped" = runConsolidation's built-in
555
633
  // nothing-to-do short-circuit (zero LLM calls), NOT a failure.
556
634
  // "throttled" (#246) = the llmTokensPerMonth fair-use cap was projected to
557
635
  // be exceeded, so consolidation was skipped before any LLM call.
636
+ // "endpoint_down" (#337) = the pre-consolidation readiness probe failed, or
637
+ // the LLM circuit breaker was open after the run — transient (retried next
638
+ // run), and NEVER "completed": the stages fail soft, so without this
639
+ // override a dead-endpoint run would report clean.
558
640
  let consolidationStatus;
559
641
  // #246: total consolidation tokens consumed this run (hoisted for telemetry
560
642
  // + the dashboard snapshot). Undefined when consolidation didn't run at all
@@ -620,95 +702,119 @@ async function runNightly(options = {}) {
620
702
  }
621
703
  }
622
704
  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;
705
+ // #337: readiness probeONE minimal generation request before any
706
+ // consolidation phase. This REPLACES the #231 no-preflight decision
707
+ // (its premise "a failed phase costs latency, not data" was falsified
708
+ // by the 2026-08-23/24 incident: the gateway answered /v1/models
709
+ // liveness while generation was dead, and the nightly retried into
710
+ // it for ~5 h, making the wedge monotonically worse). A failed probe
711
+ // skips consolidation entirely with the diagnosis "LLM endpoint not
712
+ // generating" (not-generating, not slow) and status endpoint_down —
713
+ // a transient outcome: consolidation has resumable cursors, the
714
+ // nightly re-runs 2-4×/day, and capture is unaffected (the daemon's
715
+ // /distill path has its own probe). Zero LLM phases run when the
716
+ // probe fails — one fast failure is the whole cost.
717
+ const probeOk = await llm.probe();
718
+ if (!probeOk) {
719
+ console.error("[hicortex] LLM endpoint not generating — consolidation skipped " +
720
+ "(endpoint_down, will retry next run). See the ops runbook's " +
721
+ "known failure signatures; llmProbeTimeoutMs tunes the probe's patience.");
722
+ consolidationStatus = "endpoint_down";
666
723
  }
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);
724
+ else {
725
+ const cfgDomains = (0, domain_classify_js_1.parseConfigDomains)(savedConfig);
726
+ console.log(`[hicortex] Running consolidation...`);
727
+ const report = await (0, consolidate_js_1.runConsolidation)(db, llm, embedder_js_1.embed, dryRun, false, undefined, {
728
+ domains: cfgDomains,
729
+ contentDomainsReady: true,
730
+ weakPrimaryFloor: (0, nofit_js_1.resolveWeakPrimaryFloor)(savedConfig),
731
+ }, {
732
+ minSimilarity: savedConfig?.supersessionMinSimilarity,
733
+ maxCalls: savedConfig?.supersessionMaxCalls,
734
+ },
735
+ // #241: config-driven total LLM-call ceiling (default 5000, was 200).
736
+ (0, config_read_js_1.readPositiveConfig)(savedConfig ?? {}, "consolidateMaxLlmCalls", consolidate_js_1.CONSOLIDATE_MAX_LLM_CALLS),
737
+ // #245: soft cap on the corpus (default 10000; 0 disables eviction).
738
+ memorySoftCapResolved);
739
+ console.log(`[hicortex] Consolidation ${report.status} in ${report.elapsed_seconds}s` +
740
+ (report.stages.reflection ? ` (${report.stages.reflection.lessons_generated} lessons)` : ""));
741
+ consolidationStatus = report.status;
742
+ // #337: the stages fail soft, so a run against an endpoint that died
743
+ // MID-run would otherwise report "completed". An open breaker is the
744
+ // honest signal — override to endpoint_down (lastConsolidated still
745
+ // only advances on a clean "completed", so the work is re-run).
746
+ if (llm.breakerOpen) {
747
+ console.error(`[hicortex] LLM circuit breaker OPEN after consolidation — ` +
748
+ `overriding "${report.status}" to endpoint_down (stages failed ` +
749
+ `soft against a down endpoint; will retry next run).`);
750
+ consolidationStatus = "endpoint_down";
751
+ }
752
+ // #245: capture the eviction count for the dashboard snapshot. The
753
+ // stage always returns `evicted` (0 when under cap / disabled); report
754
+ // it as 0 (a real value), not undefined, when the stage ran.
755
+ evictedCount = report.stages.memory_cap?.evicted ?? 0;
756
+ // Only set when reflection actually RAN (not skipped). A skipped stage
757
+ // (e.g. endpoint offline, #232 fail-soft) must NOT collapse to 0 — that
758
+ // would make "endpoint down" indistinguishable from "prompt too tight"
759
+ // in the fleet aggregate. Leave undefined so the optional field is
760
+ // omitted and the aggregate buckets skipped runs separately.
761
+ const refl = report.stages.reflection;
762
+ if (refl && !refl.skipped)
763
+ lessonsGenerated = refl.lessons_generated;
764
+ // #246: surface token totals for telemetry + dashboard snapshot. Both
765
+ // fields stay undefined on a skipped/failed run (no metered calls →
766
+ // nothing to report; the optional fields are omitted from the ping).
767
+ const tokensTotal = report.budget?.tokens_total;
768
+ if (tokensTotal && tokensTotal.total > 0) {
769
+ tokensThisRun = tokensTotal.total;
770
+ tokensByStage = report.budget?.tokens_by_stage;
771
+ }
772
+ // #255: budget exhaustion — always populated when consolidation ran
773
+ // (report.budget.exhausted is a boolean). The dashboard + telemetry
774
+ // treat true as a quality-degradation health signal. The
775
+ // ran-vs-didn't-run distinction is carried by `budgetCallsUsed`/
776
+ // `budgetMaxCalls` (forwarded whenever consolidation ran), NOT by a
777
+ // false `budget_exhausted` flag — the snapshot forwards
778
+ // `budget_exhausted` only on exhaustion (alert state), so the
779
+ // aggregate reads: calls_used present + budget_exhausted undefined
780
+ // = "ran and didn't exhaust"; calls_used undefined = "didn't run".
781
+ budgetExhausted = report.budget?.exhausted;
782
+ budgetDeferredByStage = report.budget?.deferred_by_stage;
783
+ budgetCallsUsed = report.budget?.calls_used;
784
+ budgetMaxCalls = report.budget?.max_calls;
785
+ // #246: accrue to state.json (monthly reset + last-run estimate for
786
+ // the next throttle check). Written even on a failed run — a partial
787
+ // run that made metered calls before the failure still spent tokens,
788
+ // and the next run's estimate should reflect that.
789
+ if (!dryRun) {
790
+ (0, state_js_1.updateState)((s) => {
791
+ const now = new Date();
792
+ const cur = s.llmTokensThisPeriod;
793
+ let periodStart = cur?.periodStart ?? now.toISOString();
794
+ let prompt = cur?.prompt ?? 0;
795
+ let completion = cur?.completion ?? 0;
796
+ let total = cur?.total ?? 0;
797
+ // Monthly reset: if periodStart is in a previous calendar month,
798
+ // zero the accrual before adding this run's contribution.
799
+ const startD = new Date(periodStart);
800
+ if (startD.getUTCFullYear() !== now.getUTCFullYear() ||
801
+ startD.getUTCMonth() !== now.getUTCMonth()) {
802
+ periodStart = now.toISOString();
803
+ prompt = 0;
804
+ completion = 0;
805
+ total = 0;
806
+ }
807
+ if (tokensTotal) {
808
+ prompt += tokensTotal.prompt;
809
+ completion += tokensTotal.completion;
810
+ total += tokensTotal.total;
811
+ }
812
+ s.llmTokensThisPeriod = {
813
+ prompt, completion, total, periodStart,
814
+ };
815
+ s.llmTokensLastRun = tokensTotal?.total ?? 0;
816
+ }, stateDir);
817
+ }
712
818
  }
713
819
  }
714
820
  }
@@ -898,6 +1004,7 @@ async function runNightly(options = {}) {
898
1004
  hermesBatches.length > 0 && "hermes",
899
1005
  piBatches.length > 0 && "pi",
900
1006
  ocBatches.length > 0 && "oc",
1007
+ opencodeBatches.length > 0 && "opencode",
901
1008
  ].filter(Boolean);
902
1009
  const agentType = kinds.length > 1 ? "mixed" : (kinds[0] ?? "cc");
903
1010
  // Adoption aggregates (0.15.1): corpus-wide exposure vs use. uses/shown
@@ -1055,6 +1162,7 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
1055
1162
  let hermesBatches = [];
1056
1163
  let piBatches = [];
1057
1164
  let ocBatches = [];
1165
+ let opencodeBatches = [];
1058
1166
  let batches = [];
1059
1167
  let memoriesIngested = 0;
1060
1168
  let sessionsSent = 0;
@@ -1077,7 +1185,8 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
1077
1185
  hermesBatches = (0, hermes_transcript_reader_js_1.readHermesSessions)(since, undefined, cursorMap);
1078
1186
  piBatches = (0, pi_transcript_reader_js_1.readPiTranscripts)(since, undefined, cursorMap);
1079
1187
  ocBatches = (0, oc_transcript_reader_js_1.readOcTranscripts)(since, undefined, cursorMap);
1080
- batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches];
1188
+ opencodeBatches = (0, opencode_transcript_reader_js_1.readOpencodeSessions)(since, undefined, cursorMap);
1189
+ batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches, ...opencodeBatches];
1081
1190
  if (ccBatches.length > 0)
1082
1191
  console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
1083
1192
  if (hermesBatches.length > 0)
@@ -1086,6 +1195,8 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
1086
1195
  console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
1087
1196
  if (ocBatches.length > 0)
1088
1197
  console.log(`[hicortex] Found ${ocBatches.length} OpenClaw session(s)`);
1198
+ if (opencodeBatches.length > 0)
1199
+ console.log(`[hicortex] Found ${opencodeBatches.length} opencode session(s)`);
1089
1200
  console.log(`[hicortex] Total: ${batches.length} new session(s)`);
1090
1201
  if (batches.length === 0) {
1091
1202
  console.log(`[hicortex] Nothing to capture.`);
@@ -1133,6 +1244,7 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
1133
1244
  hermesBatches.length > 0 && "hermes",
1134
1245
  piBatches.length > 0 && "pi",
1135
1246
  ocBatches.length > 0 && "oc",
1247
+ opencodeBatches.length > 0 && "opencode",
1136
1248
  ].filter(Boolean);
1137
1249
  const agentType = kinds.length > 1 ? "mixed" : (kinds[0] ?? "cc");
1138
1250
  await (0, telemetry_js_1.sendTelemetry)({
@@ -0,0 +1,63 @@
1
+ /**
2
+ * opencode transcript reader — the nightly capture path for the opencode
3
+ * coding agent (#347).
4
+ *
5
+ * opencode persists every session in ONE SQLite store:
6
+ * ~/.local/share/opencode/opencode.db
7
+ *
8
+ * Schema (relevant columns, verified live on opencode 1.18.20/1.18.23):
9
+ * session(id TEXT PK, directory TEXT, parent_id TEXT NULL,
10
+ * time_created INTEGER, time_updated INTEGER) — epoch MILLISECONDS
11
+ * message(id TEXT PK, session_id TEXT, time_created INTEGER,
12
+ * time_updated INTEGER, data TEXT) — data = {"role",…}
13
+ * part(id TEXT PK, message_id TEXT, time_created INTEGER, data TEXT)
14
+ * — data = {"type","text",…}
15
+ *
16
+ * The message row holds METADATA ONLY (role, model, cost — no text); the
17
+ * conversation text lives in the message's part rows as
18
+ * {"type":"text","text":…}. part types tool/reasoning/step-start/step-finish
19
+ * are plumbing, not conversation, and are excluded by the `$.type`='text'
20
+ * filter. A message left with no text yields no entry.
21
+ *
22
+ * Cursor: message.time_created (epoch ms) — deliberately NOT rowid. message
23
+ * has a TEXT primary key, so rowid is implicit, reusable after the
24
+ * session-delete cascade and renumbered by VACUUM — a stored rowid cursor can
25
+ * silently skip rows (loss, breaking the dup-over-loss invariant). Unlike the
26
+ * Hermes cursor column (INTEGER PRIMARY KEY AUTOINCREMENT — strictly
27
+ * increasing, never reused), time_created is never rewritten. The delta is
28
+ * EXCLUSIVE (`time_created > cursor`, ORDER BY time_created, id — the id
29
+ * tie-break makes the order total) so a rediscovered session with no new
30
+ * messages produces an empty delta and posts nothing; segment ids stay
31
+ * byte-stable for the server's segment-exact dedup. Residual gap: two
32
+ * messages written in the same millisecond with the capture boundary inside
33
+ * that group — bounded by that group's size, not observed on real data
34
+ * (smallest observed inter-message gap 13 ms).
35
+ *
36
+ * Sub-agent sessions (session.parent_id set) are skipped, mirroring the CC
37
+ * reader's isSidechain drop. Written defensively: parent_id was NULL for
38
+ * every session on the verified installs, so whether opencode populates it
39
+ * is unconfirmed — the skip costs nothing if the column stays empty.
40
+ *
41
+ * Marker-fenced text parts (Hicortex injection echo) are skipped so memory
42
+ * never re-enters itself — defense in depth: the recall channel the opencode
43
+ * plugin uses (experimental.chat.messages.transform) is verified NOT to
44
+ * persist its output, but the reader guards regardless.
45
+ *
46
+ * No-ops (returns []) when the database, a table, or a column is absent —
47
+ * the schema is young (migration tables present), so the reader
48
+ * shape-guards and never crashes the nightly run.
49
+ */
50
+ import type { TranscriptBatch, CursorMap } from "./transcript-reader.js";
51
+ /**
52
+ * Read opencode sessions updated since `since` (the bulk watermark; opencode
53
+ * times are epoch milliseconds, so `since.getTime()` compares directly —
54
+ * NO *1000, unlike the Hermes reader's unix-seconds store). Returns one
55
+ * batch per session, parallel to readHermesSessions().
56
+ *
57
+ * @param opencodeHome opencode's data dir (default
58
+ * ~/.local/share/opencode); injectable for tests.
59
+ * @param cursors Per-session capture cursors (#189), keyed
60
+ * `opencode:<sessionId>`. The cursor value is the highest captured
61
+ * message.time_created; the delta is exclusive (`time_created > cursor`).
62
+ */
63
+ export declare function readOpencodeSessions(since: Date, opencodeHome?: string, cursors?: CursorMap): TranscriptBatch[];