@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.
- package/README.md +13 -4
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +8 -1
- package/dist/consolidate.d.ts +21 -0
- package/dist/consolidate.js +37 -0
- package/dist/dashboard.js +5 -2
- package/dist/identity-store.d.ts +3 -3
- package/dist/identity-store.js +2 -2
- package/dist/init.d.ts +3 -1
- package/dist/init.js +93 -1
- package/dist/llm-flight.d.ts +30 -0
- package/dist/llm-flight.js +190 -0
- package/dist/llm.d.ts +75 -0
- package/dist/llm.js +227 -13
- package/dist/mcp-server.d.ts +16 -0
- package/dist/mcp-server.js +38 -0
- package/dist/nightly.d.ts +41 -0
- package/dist/nightly.js +203 -91
- package/dist/opencode-transcript-reader.d.ts +63 -0
- package/dist/opencode-transcript-reader.js +181 -0
- package/dist/status.js +23 -0
- package/dist/telemetry.d.ts +12 -8
- package/dist/telemetry.js +5 -5
- package/dist/types.d.ts +42 -0
- package/dist/uninstall.js +34 -0
- package/opencode-plugin/hicortex/index.ts +981 -0
- package/package.json +8 -5
- package/pi-extension/hicortex/README.md +56 -0
- package/pi-extension/hicortex/index.ts +888 -0
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.
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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;
|
|
705
|
+
// #337: readiness probe — ONE 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[];
|