@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/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
- await this.completeOnce(this.config.model, "Reply with OK.", 1, timeoutMs ?? this.config.probeTimeoutMs ?? 60_000);
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
- 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,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
- 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.
@@ -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 (lastConsolidated still
667
- // only advances on a clean "completed", so the work is re-run).
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
- batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches];
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).
@@ -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 sources); OMITTED
10
- * when the agent type is genuinely unknown (pre-flight abort — no
11
- * transcripts read yet). The admin summary buckets a missing agent
12
- * as "?", distinct from any real type, so an aborting Hermes/OC
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