@gamaze/hicortex 0.19.6 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -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 +68 -8
- 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 +10 -1
- package/dist/init.js +210 -22
- package/dist/llm-flight.d.ts +30 -0
- package/dist/llm-flight.js +190 -0
- package/dist/llm.d.ts +32 -0
- package/dist/llm.js +69 -2
- package/dist/nightly.d.ts +41 -0
- package/dist/nightly.js +92 -5
- 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 +5 -5
- package/dist/telemetry.js +5 -5
- package/dist/uninstall.js +34 -0
- package/hermes-plugin/hicortex/README.md +2 -2
- package/hermes-plugin/hicortex/config.py +17 -56
- package/hermes-plugin/hicortex/plugin.yaml +2 -2
- package/hermes-plugin/hicortex/provider.py +4 -1
- package/opencode-plugin/hicortex/index.ts +981 -0
- package/package.json +6 -4
- package/pi-extension/hicortex/README.md +56 -0
- package/pi-extension/hicortex/index.ts +888 -0
package/dist/llm.js
CHANGED
|
@@ -25,7 +25,13 @@ exports.resolveSavedLlmConfig = resolveSavedLlmConfig;
|
|
|
25
25
|
exports.findClaudeBinary = findClaudeBinary;
|
|
26
26
|
exports.claudeCliConfig = claudeCliConfig;
|
|
27
27
|
exports.probeOllama = probeOllama;
|
|
28
|
+
exports.isTotalFailure = isTotalFailure;
|
|
28
29
|
const config_read_js_1 = require("./config-read.js");
|
|
30
|
+
// #355: single-flight guard + canonical home (where the per-endpoint flight
|
|
31
|
+
// lock files live — the daemon and the nightly share the home, so the lock
|
|
32
|
+
// serializes them across processes).
|
|
33
|
+
const llm_flight_js_1 = require("./llm-flight.js");
|
|
34
|
+
const paths_js_1 = require("./paths.js");
|
|
29
35
|
// #337: the openai-compat + anthropic request paths fetch through undici's OWN
|
|
30
36
|
// fetch with an explicit dispatcher (below). Node's global fetch is also undici
|
|
31
37
|
// under the hood, but with a hidden 5-minute response-HEADER timer that fires
|
|
@@ -133,6 +139,16 @@ function applyTierTuningOverlay(llmConfig, savedConfig) {
|
|
|
133
139
|
if (savedConfig.llmProbeTtlMs !== undefined) {
|
|
134
140
|
llmConfig.probeTtlMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "llmProbeTtlMs", 300000);
|
|
135
141
|
}
|
|
142
|
+
// #355 single-flight. Default ON (a correctness property, not a tuning
|
|
143
|
+
// option); the wait budget defaults to the timeout ceiling so a queued call
|
|
144
|
+
// never gives up before the in-flight call's own ceiling expires.
|
|
145
|
+
const singleFlight = (0, config_read_js_1.readStrictBoolean)(savedConfig, "llmSingleFlight");
|
|
146
|
+
if (singleFlight !== undefined) {
|
|
147
|
+
llmConfig.singleFlight = singleFlight;
|
|
148
|
+
}
|
|
149
|
+
if (savedConfig.llmSingleFlightWaitMs !== undefined) {
|
|
150
|
+
llmConfig.singleFlightWaitMs = (0, config_read_js_1.readPositiveConfig)(savedConfig, "llmSingleFlightWaitMs", 900000);
|
|
151
|
+
}
|
|
136
152
|
}
|
|
137
153
|
/**
|
|
138
154
|
* Resolve an LlmConfig from a saved ~/.hicortex/config.json object.
|
|
@@ -440,7 +456,8 @@ class LlmClient {
|
|
|
440
456
|
*/
|
|
441
457
|
async probe(timeoutMs) {
|
|
442
458
|
try {
|
|
443
|
-
|
|
459
|
+
const budget = timeoutMs ?? this.config.probeTimeoutMs ?? 60_000;
|
|
460
|
+
await this.completeOnce(this.config.model, "Reply with OK.", 1, budget, budget);
|
|
444
461
|
return true;
|
|
445
462
|
}
|
|
446
463
|
catch {
|
|
@@ -492,7 +509,57 @@ class LlmClient {
|
|
|
492
509
|
this.recordBreakerFailure();
|
|
493
510
|
throw lastErr;
|
|
494
511
|
}
|
|
495
|
-
async completeOnce(model, prompt, maxTokens, timeoutMs
|
|
512
|
+
async completeOnce(model, prompt, maxTokens, timeoutMs,
|
|
513
|
+
/** Optional cap on the single-flight WAIT budget (the probe passes its
|
|
514
|
+
* own timeout so a busy endpoint costs one bounded failure, not the full
|
|
515
|
+
* 900 s queue budget — the #337 "one fast failure" contract). */
|
|
516
|
+
maxFlightWaitMs) {
|
|
517
|
+
// #355 single-flight: at most ONE in-flight request per endpoint, ever —
|
|
518
|
+
// wrapping the per-attempt dispatch (not the ladder) so the lock is
|
|
519
|
+
// released between retries and a crashed attempt cannot outlive its call.
|
|
520
|
+
// The probe serializes too: a probe racing a real call would be exactly
|
|
521
|
+
// the two-concurrent-callers pattern this guard exists to prevent.
|
|
522
|
+
// Fail-open by construction: acquireLlmFlight never throws; a filesystem
|
|
523
|
+
// refusal degrades to unserialized dispatch.
|
|
524
|
+
const waitMs = Math.min(this.flightWaitMs(timeoutMs), maxFlightWaitMs ?? Infinity);
|
|
525
|
+
const guard = await this.acquireFlightGuard(timeoutMs, waitMs);
|
|
526
|
+
if (guard?.kind === "timeout") {
|
|
527
|
+
// Message deliberately contains "timeout" so isTotalFailure() matches:
|
|
528
|
+
// the ladder retries it and the breaker accrues on exhaustion — a
|
|
529
|
+
// persistently contended endpoint is indistinguishable from a slow one.
|
|
530
|
+
throw new Error(`single-flight wait timeout after ${waitMs}ms for ${this.endpointKey} — ` +
|
|
531
|
+
`another LLM call holds the flight lock (treated as endpoint-down)`);
|
|
532
|
+
}
|
|
533
|
+
try {
|
|
534
|
+
return await this.dispatchOnce(model, prompt, maxTokens, timeoutMs);
|
|
535
|
+
}
|
|
536
|
+
finally {
|
|
537
|
+
if (guard?.kind === "acquired")
|
|
538
|
+
guard.release();
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
/** The single-flight wait budget for a call with ceiling `timeoutMs`.
|
|
542
|
+
* An explicit `llmSingleFlightWaitMs` wins; otherwise the default is
|
|
543
|
+
* max(900 s, llmTimeoutMs) so a waiter never gives up before a legitimate
|
|
544
|
+
* in-flight call's own (possibly raised) ceiling expires (2nd-review
|
|
545
|
+
* finding 2 — a hardcoded 900 s made a raised-timeout install treat a
|
|
546
|
+
* healthy-busy endpoint as down). */
|
|
547
|
+
flightWaitMs(timeoutMs) {
|
|
548
|
+
return this.config.singleFlightWaitMs ?? Math.max(900_000, timeoutMs);
|
|
549
|
+
}
|
|
550
|
+
/** Resolve the flight guard for this call, or undefined when disabled.
|
|
551
|
+
* `staleMs` is derived from THIS call's timeout ceiling (≥ the 30-min
|
|
552
|
+
* floor, 2× timeout) so a raised `llmTimeoutMs` can never get a live
|
|
553
|
+
* call's lock reclaimed mid-flight (CR #355 finding 3). The lock file
|
|
554
|
+
* records its own lease, so reclaim is judged by the HOLDER's lease, not
|
|
555
|
+
* this waiter's parameter (2nd-review finding 3). */
|
|
556
|
+
async acquireFlightGuard(timeoutMs, waitMs) {
|
|
557
|
+
if (this.config.singleFlight === false)
|
|
558
|
+
return undefined; // kill switch
|
|
559
|
+
const staleMs = Math.max(llm_flight_js_1.DEFAULT_STALE_MS, 2 * timeoutMs);
|
|
560
|
+
return (0, llm_flight_js_1.acquireLlmFlight)((0, paths_js_1.hicortexHome)(), this.endpointKey, waitMs, staleMs);
|
|
561
|
+
}
|
|
562
|
+
async dispatchOnce(model, prompt, maxTokens, timeoutMs) {
|
|
496
563
|
if (this.config.provider === "claude-cli") {
|
|
497
564
|
return this.completeClaude(model, prompt, timeoutMs);
|
|
498
565
|
}
|
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,7 +623,11 @@ 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.
|
|
@@ -663,8 +741,11 @@ async function runNightly(options = {}) {
|
|
|
663
741
|
consolidationStatus = report.status;
|
|
664
742
|
// #337: the stages fail soft, so a run against an endpoint that died
|
|
665
743
|
// MID-run would otherwise report "completed". An open breaker is the
|
|
666
|
-
// honest signal — override to endpoint_down
|
|
667
|
-
//
|
|
744
|
+
// honest signal — override to endpoint_down. lastConsolidated only
|
|
745
|
+
// advances on a clean completed AND a closed breaker (the same
|
|
746
|
+
// llm.breakerOpen signal, gated in consolidate.ts — keep the two
|
|
747
|
+
// sites in lockstep), so the importance/reflection/domain work is
|
|
748
|
+
// re-run on the next pass.
|
|
668
749
|
if (llm.breakerOpen) {
|
|
669
750
|
console.error(`[hicortex] LLM circuit breaker OPEN after consolidation — ` +
|
|
670
751
|
`overriding "${report.status}" to endpoint_down (stages failed ` +
|
|
@@ -926,6 +1007,7 @@ async function runNightly(options = {}) {
|
|
|
926
1007
|
hermesBatches.length > 0 && "hermes",
|
|
927
1008
|
piBatches.length > 0 && "pi",
|
|
928
1009
|
ocBatches.length > 0 && "oc",
|
|
1010
|
+
opencodeBatches.length > 0 && "opencode",
|
|
929
1011
|
].filter(Boolean);
|
|
930
1012
|
const agentType = kinds.length > 1 ? "mixed" : (kinds[0] ?? "cc");
|
|
931
1013
|
// Adoption aggregates (0.15.1): corpus-wide exposure vs use. uses/shown
|
|
@@ -1083,6 +1165,7 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
|
|
|
1083
1165
|
let hermesBatches = [];
|
|
1084
1166
|
let piBatches = [];
|
|
1085
1167
|
let ocBatches = [];
|
|
1168
|
+
let opencodeBatches = [];
|
|
1086
1169
|
let batches = [];
|
|
1087
1170
|
let memoriesIngested = 0;
|
|
1088
1171
|
let sessionsSent = 0;
|
|
@@ -1105,7 +1188,8 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
|
|
|
1105
1188
|
hermesBatches = (0, hermes_transcript_reader_js_1.readHermesSessions)(since, undefined, cursorMap);
|
|
1106
1189
|
piBatches = (0, pi_transcript_reader_js_1.readPiTranscripts)(since, undefined, cursorMap);
|
|
1107
1190
|
ocBatches = (0, oc_transcript_reader_js_1.readOcTranscripts)(since, undefined, cursorMap);
|
|
1108
|
-
|
|
1191
|
+
opencodeBatches = (0, opencode_transcript_reader_js_1.readOpencodeSessions)(since, undefined, cursorMap);
|
|
1192
|
+
batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches, ...opencodeBatches];
|
|
1109
1193
|
if (ccBatches.length > 0)
|
|
1110
1194
|
console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
|
|
1111
1195
|
if (hermesBatches.length > 0)
|
|
@@ -1114,6 +1198,8 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
|
|
|
1114
1198
|
console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
|
|
1115
1199
|
if (ocBatches.length > 0)
|
|
1116
1200
|
console.log(`[hicortex] Found ${ocBatches.length} OpenClaw session(s)`);
|
|
1201
|
+
if (opencodeBatches.length > 0)
|
|
1202
|
+
console.log(`[hicortex] Found ${opencodeBatches.length} opencode session(s)`);
|
|
1117
1203
|
console.log(`[hicortex] Total: ${batches.length} new session(s)`);
|
|
1118
1204
|
if (batches.length === 0) {
|
|
1119
1205
|
console.log(`[hicortex] Nothing to capture.`);
|
|
@@ -1161,6 +1247,7 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
|
|
|
1161
1247
|
hermesBatches.length > 0 && "hermes",
|
|
1162
1248
|
piBatches.length > 0 && "pi",
|
|
1163
1249
|
ocBatches.length > 0 && "oc",
|
|
1250
|
+
opencodeBatches.length > 0 && "opencode",
|
|
1164
1251
|
].filter(Boolean);
|
|
1165
1252
|
const agentType = kinds.length > 1 ? "mixed" : (kinds[0] ?? "cc");
|
|
1166
1253
|
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[];
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* opencode transcript reader — the nightly capture path for the opencode
|
|
4
|
+
* coding agent (#347).
|
|
5
|
+
*
|
|
6
|
+
* opencode persists every session in ONE SQLite store:
|
|
7
|
+
* ~/.local/share/opencode/opencode.db
|
|
8
|
+
*
|
|
9
|
+
* Schema (relevant columns, verified live on opencode 1.18.20/1.18.23):
|
|
10
|
+
* session(id TEXT PK, directory TEXT, parent_id TEXT NULL,
|
|
11
|
+
* time_created INTEGER, time_updated INTEGER) — epoch MILLISECONDS
|
|
12
|
+
* message(id TEXT PK, session_id TEXT, time_created INTEGER,
|
|
13
|
+
* time_updated INTEGER, data TEXT) — data = {"role",…}
|
|
14
|
+
* part(id TEXT PK, message_id TEXT, time_created INTEGER, data TEXT)
|
|
15
|
+
* — data = {"type","text",…}
|
|
16
|
+
*
|
|
17
|
+
* The message row holds METADATA ONLY (role, model, cost — no text); the
|
|
18
|
+
* conversation text lives in the message's part rows as
|
|
19
|
+
* {"type":"text","text":…}. part types tool/reasoning/step-start/step-finish
|
|
20
|
+
* are plumbing, not conversation, and are excluded by the `$.type`='text'
|
|
21
|
+
* filter. A message left with no text yields no entry.
|
|
22
|
+
*
|
|
23
|
+
* Cursor: message.time_created (epoch ms) — deliberately NOT rowid. message
|
|
24
|
+
* has a TEXT primary key, so rowid is implicit, reusable after the
|
|
25
|
+
* session-delete cascade and renumbered by VACUUM — a stored rowid cursor can
|
|
26
|
+
* silently skip rows (loss, breaking the dup-over-loss invariant). Unlike the
|
|
27
|
+
* Hermes cursor column (INTEGER PRIMARY KEY AUTOINCREMENT — strictly
|
|
28
|
+
* increasing, never reused), time_created is never rewritten. The delta is
|
|
29
|
+
* EXCLUSIVE (`time_created > cursor`, ORDER BY time_created, id — the id
|
|
30
|
+
* tie-break makes the order total) so a rediscovered session with no new
|
|
31
|
+
* messages produces an empty delta and posts nothing; segment ids stay
|
|
32
|
+
* byte-stable for the server's segment-exact dedup. Residual gap: two
|
|
33
|
+
* messages written in the same millisecond with the capture boundary inside
|
|
34
|
+
* that group — bounded by that group's size, not observed on real data
|
|
35
|
+
* (smallest observed inter-message gap 13 ms).
|
|
36
|
+
*
|
|
37
|
+
* Sub-agent sessions (session.parent_id set) are skipped, mirroring the CC
|
|
38
|
+
* reader's isSidechain drop. Written defensively: parent_id was NULL for
|
|
39
|
+
* every session on the verified installs, so whether opencode populates it
|
|
40
|
+
* is unconfirmed — the skip costs nothing if the column stays empty.
|
|
41
|
+
*
|
|
42
|
+
* Marker-fenced text parts (Hicortex injection echo) are skipped so memory
|
|
43
|
+
* never re-enters itself — defense in depth: the recall channel the opencode
|
|
44
|
+
* plugin uses (experimental.chat.messages.transform) is verified NOT to
|
|
45
|
+
* persist its output, but the reader guards regardless.
|
|
46
|
+
*
|
|
47
|
+
* No-ops (returns []) when the database, a table, or a column is absent —
|
|
48
|
+
* the schema is young (migration tables present), so the reader
|
|
49
|
+
* shape-guards and never crashes the nightly run.
|
|
50
|
+
*/
|
|
51
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
52
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
53
|
+
};
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.readOpencodeSessions = readOpencodeSessions;
|
|
56
|
+
const node_fs_1 = require("node:fs");
|
|
57
|
+
const node_path_1 = require("node:path");
|
|
58
|
+
const node_os_1 = require("node:os");
|
|
59
|
+
const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
|
|
60
|
+
const OPENCODE_DATA_HOME = (0, node_path_1.join)((0, node_os_1.homedir)(), ".local", "share", "opencode");
|
|
61
|
+
/**
|
|
62
|
+
* Fence around every block the opencode plugin injects (opencode-plugin/
|
|
63
|
+
* hicortex/index.ts CONTEXT_START/END). Duplicated here because the plugin
|
|
64
|
+
* is dependency-free and outside the package — keep the strings in sync.
|
|
65
|
+
*/
|
|
66
|
+
const FENCE_START = "<!-- hicortex-context-start -->";
|
|
67
|
+
const FENCE_END = "<!-- hicortex-context-end -->";
|
|
68
|
+
/**
|
|
69
|
+
* Read opencode sessions updated since `since` (the bulk watermark; opencode
|
|
70
|
+
* times are epoch milliseconds, so `since.getTime()` compares directly —
|
|
71
|
+
* NO *1000, unlike the Hermes reader's unix-seconds store). Returns one
|
|
72
|
+
* batch per session, parallel to readHermesSessions().
|
|
73
|
+
*
|
|
74
|
+
* @param opencodeHome opencode's data dir (default
|
|
75
|
+
* ~/.local/share/opencode); injectable for tests.
|
|
76
|
+
* @param cursors Per-session capture cursors (#189), keyed
|
|
77
|
+
* `opencode:<sessionId>`. The cursor value is the highest captured
|
|
78
|
+
* message.time_created; the delta is exclusive (`time_created > cursor`).
|
|
79
|
+
*/
|
|
80
|
+
function readOpencodeSessions(since, opencodeHome = OPENCODE_DATA_HOME, cursors = {}) {
|
|
81
|
+
const dbPath = (0, node_path_1.join)(opencodeHome, "opencode.db");
|
|
82
|
+
if (!(0, node_fs_1.existsSync)(dbPath))
|
|
83
|
+
return []; // no opencode on this machine
|
|
84
|
+
let db;
|
|
85
|
+
try {
|
|
86
|
+
db = new better_sqlite3_1.default(dbPath, { readonly: true, fileMustExist: true });
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return []; // locked / unreadable — skip, retry next run
|
|
90
|
+
}
|
|
91
|
+
const batches = [];
|
|
92
|
+
try {
|
|
93
|
+
// Discovery: sessions touched since the watermark. parent_id IS NULL
|
|
94
|
+
// drops sub-agent sessions (the CC isSidechain equivalent).
|
|
95
|
+
const sessions = db
|
|
96
|
+
.prepare(`SELECT id, directory, parent_id FROM session
|
|
97
|
+
WHERE time_updated > ? AND parent_id IS NULL
|
|
98
|
+
ORDER BY time_created`)
|
|
99
|
+
.all(since.getTime());
|
|
100
|
+
// Delta rows for one session. EXCLUSIVE on time_created; the id
|
|
101
|
+
// tie-break makes the read order total when two messages share a
|
|
102
|
+
// millisecond. json_extract pulls the role out of the JSON data column.
|
|
103
|
+
const msgStmt = db.prepare(`SELECT id, time_created, json_extract(data, '$.role') AS role
|
|
104
|
+
FROM message
|
|
105
|
+
WHERE session_id = ? AND time_created > ?
|
|
106
|
+
ORDER BY time_created, id`);
|
|
107
|
+
// The message's text parts in part.time_created order (distinct within a
|
|
108
|
+
// message on real data, so the join order is deterministic). Non-text
|
|
109
|
+
// part types (tool/reasoning/step-start/step-finish) are excluded here.
|
|
110
|
+
const partStmt = db.prepare(`SELECT json_extract(data, '$.text') AS text
|
|
111
|
+
FROM part
|
|
112
|
+
WHERE message_id = ? AND json_extract(data, '$.type') = 'text'
|
|
113
|
+
ORDER BY time_created`);
|
|
114
|
+
// Highest time_created in the session — used only for the shrink guard.
|
|
115
|
+
const maxStmt = db.prepare("SELECT MAX(time_created) AS m FROM message WHERE session_id = ?");
|
|
116
|
+
for (const s of sessions) {
|
|
117
|
+
const cursorKey = `opencode:${s.id}`;
|
|
118
|
+
const pos = cursors[cursorKey] ?? { cursor: 0, gen: 0 };
|
|
119
|
+
let startCursor = pos.cursor;
|
|
120
|
+
let gen = pos.gen;
|
|
121
|
+
// Shrink guard (the Hermes fix-8 pattern): a stored cursor above the
|
|
122
|
+
// session's max time_created means the DB was reset/restored — re-read
|
|
123
|
+
// from 0 and bump the generation so post-reset segment ids can't
|
|
124
|
+
// collide with pre-reset ones on the server's content-blind dedup.
|
|
125
|
+
if (startCursor > 0) {
|
|
126
|
+
const max = maxStmt.get(s.id).m ?? 0;
|
|
127
|
+
if (startCursor > max) {
|
|
128
|
+
startCursor = 0;
|
|
129
|
+
gen = pos.gen + 1;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const rows = msgStmt.all(s.id, startCursor);
|
|
133
|
+
if (rows.length === 0)
|
|
134
|
+
continue; // empty delta — nothing to post
|
|
135
|
+
const entries = [];
|
|
136
|
+
const entryCursors = [];
|
|
137
|
+
for (const r of rows) {
|
|
138
|
+
const parts = partStmt.all(r.id);
|
|
139
|
+
const content = parts
|
|
140
|
+
.map((p) => (typeof p.text === "string" ? p.text : ""))
|
|
141
|
+
// Fenced parts are Hicortex injection echo, not conversation.
|
|
142
|
+
.filter((t) => t !== "" && !t.includes(FENCE_START) && !t.includes(FENCE_END))
|
|
143
|
+
.join("\n");
|
|
144
|
+
// A message with no surviving text (tool-only / fenced-only) yields
|
|
145
|
+
// no entry. It also stays unconsumed (the cursor advances only to the
|
|
146
|
+
// last ENTRY), so it is re-scanned next run — dup-over-loss, and the
|
|
147
|
+
// entryless tail costs one no-op query until a text message lands.
|
|
148
|
+
if (content.trim() === "")
|
|
149
|
+
continue;
|
|
150
|
+
// A NULL role passes through as "" — extractConversationText renders
|
|
151
|
+
// anything not "user" as ASSISTANT, so no turn is fabricated.
|
|
152
|
+
entries.push({ role: r.role ?? "", content });
|
|
153
|
+
entryCursors.push(r.time_created); // one end-cursor per entry
|
|
154
|
+
}
|
|
155
|
+
if (entries.length === 0)
|
|
156
|
+
continue; // entryless delta posts nothing
|
|
157
|
+
// date = the delta's LAST message time — already milliseconds, so
|
|
158
|
+
// unlike the Hermes reader there is no *1000 (copying that line would
|
|
159
|
+
// put the date ~57,000 years out).
|
|
160
|
+
batches.push({
|
|
161
|
+
sessionId: s.id,
|
|
162
|
+
projectName: (0, node_path_1.basename)(s.directory ?? "") || "opencode",
|
|
163
|
+
sourceAgent: "opencode",
|
|
164
|
+
date: new Date(rows[rows.length - 1].time_created).toISOString().slice(0, 10),
|
|
165
|
+
entries,
|
|
166
|
+
cursorKey,
|
|
167
|
+
startCursor,
|
|
168
|
+
generation: gen,
|
|
169
|
+
entryCursors,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Query failed (schema drift on an opencode upgrade) — no-op, don't crash the run.
|
|
175
|
+
return [];
|
|
176
|
+
}
|
|
177
|
+
finally {
|
|
178
|
+
db.close();
|
|
179
|
+
}
|
|
180
|
+
return batches;
|
|
181
|
+
}
|
package/dist/status.js
CHANGED
|
@@ -132,6 +132,29 @@ async function runStatus() {
|
|
|
132
132
|
}
|
|
133
133
|
catch { /* no CC settings */ }
|
|
134
134
|
console.log(` CC MCP: ${ccRegistered ? `registered → ${ccUrl}` : "not registered"}`);
|
|
135
|
+
// Pi (#348): the bundled extension at ~/.pi/agent/extensions/hicortex.ts.
|
|
136
|
+
// "not found" = no Pi on this machine; "not installed" = Pi present, run
|
|
137
|
+
// init to place the extension.
|
|
138
|
+
const piAgentDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".pi", "agent");
|
|
139
|
+
if ((0, node_fs_1.existsSync)(piAgentDir)) {
|
|
140
|
+
const piInstalled = (0, node_fs_1.existsSync)((0, node_path_1.join)(piAgentDir, "extensions", "hicortex.ts"));
|
|
141
|
+
console.log(` Pi plugin: ${piInstalled ? "installed" : "not installed (run: npx @gamaze/hicortex init)"}`);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
console.log(" Pi plugin: not found");
|
|
145
|
+
}
|
|
146
|
+
// opencode (#347): the bundled plugin at ~/.config/opencode/plugins/hicortex.ts.
|
|
147
|
+
// "not found" = no opencode on this machine; "not installed" = opencode
|
|
148
|
+
// present, run init to place the plugin.
|
|
149
|
+
const opencodeConfigDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "opencode");
|
|
150
|
+
const opencodeDataDir = (0, node_path_1.join)((0, node_os_1.homedir)(), ".local", "share", "opencode");
|
|
151
|
+
if ((0, node_fs_1.existsSync)(opencodeConfigDir) || (0, node_fs_1.existsSync)(opencodeDataDir)) {
|
|
152
|
+
const openCodeInstalled = (0, node_fs_1.existsSync)((0, node_path_1.join)(opencodeConfigDir, "plugins", "hicortex.ts"));
|
|
153
|
+
console.log(` opencode: ${openCodeInstalled ? "installed" : "not installed (run: npx @gamaze/hicortex init)"}`);
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
console.log(" opencode: not found");
|
|
157
|
+
}
|
|
135
158
|
console.log();
|
|
136
159
|
// Server status. /health/detail carries the diagnostics (version, memories,
|
|
137
160
|
// llm) — /health itself is the public minimal {status:"ok"} probe (#253).
|
package/dist/telemetry.d.ts
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
* v — package version
|
|
7
7
|
* pv — payload schema version
|
|
8
8
|
* mode — server or client
|
|
9
|
-
* agent — cc, pi, oc, or mixed (detected from session
|
|
10
|
-
* when the agent type is genuinely unknown
|
|
11
|
-
* transcripts read yet). The admin summary
|
|
12
|
-
* as "?", distinct from any real type, so
|
|
13
|
-
* client is never miscounted as "cc".
|
|
9
|
+
* agent — cc, hermes, pi, oc, opencode, or mixed (detected from session
|
|
10
|
+
* sources); OMITTED when the agent type is genuinely unknown
|
|
11
|
+
* (pre-flight abort — no transcripts read yet). The admin summary
|
|
12
|
+
* buckets a missing agent as "?", distinct from any real type, so
|
|
13
|
+
* an aborting Hermes/OC client is never miscounted as "cc".
|
|
14
14
|
* mem — total memory count
|
|
15
15
|
* lessons — total lesson count
|
|
16
16
|
* lessonsGenerated — lessons created THIS run (server mode only; the per-run
|