@gamaze/hicortex 0.19.6 → 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/dist/llm.d.ts CHANGED
@@ -48,6 +48,15 @@ export interface LlmConfig {
48
48
  /** TTL the daemon caches a probe outcome for (#337). Default 300000.
49
49
  * See HicortexConfig.llmProbeTtlMs. */
50
50
  probeTtlMs?: number;
51
+ /** Single-flight serialization: at most ONE in-flight LLM request per
52
+ * endpoint, ever (#355). Default true — a correctness property for local
53
+ * single-user model servers (two concurrent large-context calls stall/OOM
54
+ * the server and the machine under it). See HicortexConfig.llmSingleFlight. */
55
+ singleFlight?: boolean;
56
+ /** How long a queued call waits for the in-flight call before failing as
57
+ * endpoint-down (#355). Default 900000 — the same ceiling as llmTimeoutMs.
58
+ * See HicortexConfig.llmSingleFlightWaitMs. */
59
+ singleFlightWaitMs?: number;
51
60
  }
52
61
  /**
53
62
  * Resolve LLM configuration from explicit config-file overrides or
@@ -155,6 +164,14 @@ export interface LlmResult {
155
164
  export declare class LlmCircuitOpenError extends Error {
156
165
  constructor(endpoint: string, cooldownRemainingMs: number);
157
166
  }
167
+ /**
168
+ * The TOTAL-failure class the retry ladder matches (#337, unchanged strings —
169
+ * this is the same matcher the ladder has always used, now also the breaker's
170
+ * definition of "endpoint may be down"). A fast HTTP 500, a parse error, or a
171
+ * rate limit is NOT in this class: those prove the endpoint ANSWERS.
172
+ */
173
+ declare function isTotalFailure(message: string): boolean;
174
+ export { isTotalFailure };
158
175
  export declare class LlmClient {
159
176
  private config;
160
177
  private ollamaCallCount;
@@ -213,6 +230,21 @@ export declare class LlmClient {
213
230
  probe(timeoutMs?: number): Promise<boolean>;
214
231
  private complete;
215
232
  private completeOnce;
233
+ /** The single-flight wait budget for a call with ceiling `timeoutMs`.
234
+ * An explicit `llmSingleFlightWaitMs` wins; otherwise the default is
235
+ * max(900 s, llmTimeoutMs) so a waiter never gives up before a legitimate
236
+ * in-flight call's own (possibly raised) ceiling expires (2nd-review
237
+ * finding 2 — a hardcoded 900 s made a raised-timeout install treat a
238
+ * healthy-busy endpoint as down). */
239
+ private flightWaitMs;
240
+ /** Resolve the flight guard for this call, or undefined when disabled.
241
+ * `staleMs` is derived from THIS call's timeout ceiling (≥ the 30-min
242
+ * floor, 2× timeout) so a raised `llmTimeoutMs` can never get a live
243
+ * call's lock reclaimed mid-flight (CR #355 finding 3). The lock file
244
+ * records its own lease, so reclaim is judged by the HOLDER's lease, not
245
+ * this waiter's parameter (2nd-review finding 3). */
246
+ private acquireFlightGuard;
247
+ private dispatchOnce;
216
248
  /**
217
249
  * Claude CLI: shell out to `claude -p` for subscription users.
218
250
  * No API key needed — uses CC's authenticated session.
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.
@@ -926,6 +1004,7 @@ async function runNightly(options = {}) {
926
1004
  hermesBatches.length > 0 && "hermes",
927
1005
  piBatches.length > 0 && "pi",
928
1006
  ocBatches.length > 0 && "oc",
1007
+ opencodeBatches.length > 0 && "opencode",
929
1008
  ].filter(Boolean);
930
1009
  const agentType = kinds.length > 1 ? "mixed" : (kinds[0] ?? "cc");
931
1010
  // Adoption aggregates (0.15.1): corpus-wide exposure vs use. uses/shown
@@ -1083,6 +1162,7 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
1083
1162
  let hermesBatches = [];
1084
1163
  let piBatches = [];
1085
1164
  let ocBatches = [];
1165
+ let opencodeBatches = [];
1086
1166
  let batches = [];
1087
1167
  let memoriesIngested = 0;
1088
1168
  let sessionsSent = 0;
@@ -1105,7 +1185,8 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
1105
1185
  hermesBatches = (0, hermes_transcript_reader_js_1.readHermesSessions)(since, undefined, cursorMap);
1106
1186
  piBatches = (0, pi_transcript_reader_js_1.readPiTranscripts)(since, undefined, cursorMap);
1107
1187
  ocBatches = (0, oc_transcript_reader_js_1.readOcTranscripts)(since, undefined, cursorMap);
1108
- batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches];
1188
+ opencodeBatches = (0, opencode_transcript_reader_js_1.readOpencodeSessions)(since, undefined, cursorMap);
1189
+ batches = [...ccBatches, ...hermesBatches, ...piBatches, ...ocBatches, ...opencodeBatches];
1109
1190
  if (ccBatches.length > 0)
1110
1191
  console.log(`[hicortex] Found ${ccBatches.length} CC session(s)`);
1111
1192
  if (hermesBatches.length > 0)
@@ -1114,6 +1195,8 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
1114
1195
  console.log(`[hicortex] Found ${piBatches.length} Pi session(s)`);
1115
1196
  if (ocBatches.length > 0)
1116
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)`);
1117
1200
  console.log(`[hicortex] Total: ${batches.length} new session(s)`);
1118
1201
  if (batches.length === 0) {
1119
1202
  console.log(`[hicortex] Nothing to capture.`);
@@ -1161,6 +1244,7 @@ async function runClientNightly(config, dryRun, stateDir = HICORTEX_HOME, recapt
1161
1244
  hermesBatches.length > 0 && "hermes",
1162
1245
  piBatches.length > 0 && "pi",
1163
1246
  ocBatches.length > 0 && "oc",
1247
+ opencodeBatches.length > 0 && "opencode",
1164
1248
  ].filter(Boolean);
1165
1249
  const agentType = kinds.length > 1 ? "mixed" : (kinds[0] ?? "cc");
1166
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[];
@@ -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
+ }