@gamaze/hicortex 0.19.5 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -89,15 +89,19 @@ export interface TelemetryPayload {
89
89
  * Consolidation outcome for THIS full nightly (server mode only —
90
90
  * capture-only runs send no nightly ping, so the field is absent there).
91
91
  * `runConsolidation`'s status: "completed" | "skipped" | "failed", plus
92
- * "no_llm" when consolidation was skipped because no LLM was configured, and
92
+ * "no_llm" when consolidation was skipped because no LLM was configured,
93
93
  * "throttled" (#246) when the run was skipped because the
94
- * `llmTokensPerMonth` fair-use cap was projected to be exceeded.
94
+ * `llmTokensPerMonth` fair-use cap was projected to be exceeded, and
95
+ * "endpoint_down" (#337) when the pre-consolidation readiness probe failed
96
+ * or the LLM circuit breaker was open after the run — a TRANSIENT state
97
+ * (retried next run), never reported as "completed" even though the stages
98
+ * fail soft.
95
99
  * "skipped" = the built-in nothing-to-do short-circuit (no new + no unscored
96
100
  * memories → zero LLM calls), NOT a failure. Lets the fleet aggregate tell a
97
101
  * real consolidation run from a no-op without repurposing `ok` (which is the
98
102
  * capture-health signal). 0.17+.
99
103
  */
100
- consolidation?: "completed" | "skipped" | "failed" | "no_llm" | "throttled";
104
+ consolidation?: "completed" | "skipped" | "failed" | "no_llm" | "throttled" | "endpoint_down";
101
105
  /**
102
106
  * Total LLM tokens consumed by THIS nightly's consolidation (#246) — the
103
107
  * BudgetTracker total. Server-mode only (capture-only + client runs make no
package/dist/telemetry.js CHANGED
@@ -7,11 +7,11 @@
7
7
  * v — package version
8
8
  * pv — payload schema version
9
9
  * mode — server or client
10
- * agent — cc, pi, oc, or mixed (detected from session sources); OMITTED
11
- * when the agent type is genuinely unknown (pre-flight abort — no
12
- * transcripts read yet). The admin summary buckets a missing agent
13
- * as "?", distinct from any real type, so an aborting Hermes/OC
14
- * client is never miscounted as "cc".
10
+ * agent — cc, hermes, pi, oc, opencode, or mixed (detected from session
11
+ * sources); OMITTED when the agent type is genuinely unknown
12
+ * (pre-flight abort — no transcripts read yet). The admin summary
13
+ * buckets a missing agent as "?", distinct from any real type, so
14
+ * an aborting Hermes/OC client is never miscounted as "cc".
15
15
  * mem — total memory count
16
16
  * lessons — total lesson count
17
17
  * lessonsGenerated — lessons created THIS run (server mode only; the per-run
package/dist/types.d.ts CHANGED
@@ -392,6 +392,48 @@ export interface HicortexConfig {
392
392
  * doubled for margin). Only relevant when `ollamaFlushEvery` > 0.
393
393
  */
394
394
  ollamaFlushWaitMs?: number;
395
+ /**
396
+ * ONE per-attempt timeout ceiling (ms) for every LLM phase — distill,
397
+ * reflect, classify, and scoring alike (#337). Default 900000 (15 min). The
398
+ * openai-compat and anthropic requests fetch through an undici dispatcher
399
+ * with undici's hidden 5-minute header/body timers disabled, so this knob is
400
+ * the ONLY ceiling: a legitimate long generation is no longer abandoned
401
+ * client-side at 5 min while the server keeps generating for the dead
402
+ * client (the 2026-08-23/24 incident's amplification mechanism). Before
403
+ * #337, scoring used a 600 s ceiling and the other phases 900 s; one knob
404
+ * now covers all four. No effect on the ollama path (already streams) or
405
+ * claude-cli (subprocess timeout).
406
+ */
407
+ llmTimeoutMs?: number;
408
+ /**
409
+ * Consecutive ladder-exhausted TOTAL failures (fetch-failed / ECONNREFUSED /
410
+ * timeout / "Headers Timeout" class) after which the per-endpoint circuit
411
+ * breaker opens (#337). Default 3; `0` disables. While open, calls throw
412
+ * `LlmCircuitOpenError` immediately with NO network I/O. HTTP error statuses
413
+ * with a response, parse errors, and rate limits never count (they throw
414
+ * before the retry ladder can be exhausted). Any success resets the counter.
415
+ */
416
+ llmBreakerThreshold?: number;
417
+ /**
418
+ * How long (ms) an open circuit breaker stays open before the next call
419
+ * becomes a half-open trial (#337). Default 600000 (10 min). A trial failure
420
+ * re-opens the breaker; a trial success resets it.
421
+ */
422
+ llmBreakerCooldownMs?: number;
423
+ /**
424
+ * Timeout (ms) for the readiness probe's single 1-token generation attempt
425
+ * (#337). Default 60000. The probe asks "can this endpoint GENERATE", which
426
+ * /health-style liveness checks cannot answer (a wedged gateway keeps
427
+ * answering /v1/models). Read by the nightly before consolidation and by the
428
+ * daemon before distilling.
429
+ */
430
+ llmProbeTimeoutMs?: number;
431
+ /**
432
+ * How long (ms) the daemon caches a /distill probe outcome before probing
433
+ * again (#337). Default 300000 — a healthy capture cadence pays at most one
434
+ * probe per window. Nightly runs are single-shot and never cache.
435
+ */
436
+ llmProbeTtlMs?: number;
395
437
  /**
396
438
  * Max lessons injected into an agent's session-start context (default 10).
397
439
  * Lessons are ranked per-session by project/domain affinity + recency +
package/dist/uninstall.js CHANGED
@@ -234,6 +234,40 @@ async function runUninstall() {
234
234
  if ((0, claude_md_js_1.removeLessonsBlock)(CLAUDE_MD)) {
235
235
  console.log(" ✓ Removed Hicortex Learnings block from CLAUDE.md");
236
236
  }
237
+ // 6. Remove the Pi extension (#348). Guarded on the "hicortex" marker the
238
+ // installer always ships — "hicortex.ts" is a plausible user filename,
239
+ // and uninstall never deletes a file we did not write. Fail-soft when
240
+ // absent (no Pi on the machine) or unreadable.
241
+ const piExtension = (0, node_path_1.join)((0, node_os_1.homedir)(), ".pi", "agent", "extensions", "hicortex.ts");
242
+ if ((0, node_fs_1.existsSync)(piExtension)) {
243
+ try {
244
+ if ((0, node_fs_1.readFileSync)(piExtension, "utf-8").toLowerCase().includes("hicortex")) {
245
+ (0, node_fs_1.unlinkSync)(piExtension);
246
+ console.log(" ✓ Removed Pi extension (~/.pi/agent/extensions/hicortex.ts)");
247
+ }
248
+ else {
249
+ console.log(" ⚠ Skipping ~/.pi/agent/extensions/hicortex.ts — not a Hicortex file, left untouched");
250
+ }
251
+ }
252
+ catch { /* unreadable — leave it */ }
253
+ }
254
+ // 7. Remove the opencode plugin (#347). Same marker guard — the plugins
255
+ // directory is shared (third-party files live there too), and uninstall
256
+ // never deletes a file we did not write. Fail-soft when absent (no
257
+ // opencode on the machine) or unreadable.
258
+ const openCodePlugin = (0, node_path_1.join)((0, node_os_1.homedir)(), ".config", "opencode", "plugins", "hicortex.ts");
259
+ if ((0, node_fs_1.existsSync)(openCodePlugin)) {
260
+ try {
261
+ if ((0, node_fs_1.readFileSync)(openCodePlugin, "utf-8").toLowerCase().includes("hicortex")) {
262
+ (0, node_fs_1.unlinkSync)(openCodePlugin);
263
+ console.log(" ✓ Removed opencode plugin (~/.config/opencode/plugins/hicortex.ts)");
264
+ }
265
+ else {
266
+ console.log(" ⚠ Skipping ~/.config/opencode/plugins/hicortex.ts — not a Hicortex file, left untouched");
267
+ }
268
+ }
269
+ catch { /* unreadable — leave it */ }
270
+ }
237
271
  console.log(`\n✓ Uninstalled. Database preserved at ${HICORTEX_HOME}/hicortex.db`);
238
272
  console.log(" To remove all data: rm -rf ~/.hicortex");
239
273
  }