@zuzuucodes/cli 1.0.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.
Files changed (84) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +90 -0
  3. package/bin/zuzuu.mjs +133 -0
  4. package/experiments/experiment-1-trace-capture/adapters/claude-code.mjs +220 -0
  5. package/experiments/experiment-1-trace-capture/adapters/codex.mjs +201 -0
  6. package/experiments/experiment-1-trace-capture/adapters/gemini-cli.mjs +113 -0
  7. package/experiments/experiment-1-trace-capture/adapters/host-adapter.mjs +43 -0
  8. package/experiments/experiment-1-trace-capture/adapters/opencode.mjs +205 -0
  9. package/experiments/experiment-1-trace-capture/adapters/pi.mjs +218 -0
  10. package/experiments/experiment-1-trace-capture/adapters/registry.mjs +20 -0
  11. package/experiments/experiment-1-trace-capture/adapters/signals.mjs +44 -0
  12. package/experiments/experiment-1-trace-capture/core/event.mjs +58 -0
  13. package/experiments/experiment-1-trace-capture/core/ids.mjs +32 -0
  14. package/experiments/experiment-1-trace-capture/core/otlp.mjs +54 -0
  15. package/experiments/experiment-1-trace-capture/core/render.mjs +63 -0
  16. package/experiments/experiment-1-trace-capture/core/spans.mjs +43 -0
  17. package/package.json +56 -0
  18. package/zuzuu/actions/adapter.mjs +130 -0
  19. package/zuzuu/actions/convert.mjs +27 -0
  20. package/zuzuu/actions/dispatch.mjs +87 -0
  21. package/zuzuu/actions/inbox.mjs +56 -0
  22. package/zuzuu/actions/manifest.mjs +72 -0
  23. package/zuzuu/actions/marker.mjs +4 -0
  24. package/zuzuu/actions/runner.mjs +37 -0
  25. package/zuzuu/actions/schema.mjs +73 -0
  26. package/zuzuu/actions/trail.mjs +22 -0
  27. package/zuzuu/capture-core.mjs +49 -0
  28. package/zuzuu/commands/act-author.mjs +72 -0
  29. package/zuzuu/commands/act.mjs +101 -0
  30. package/zuzuu/commands/capture.mjs +32 -0
  31. package/zuzuu/commands/code.mjs +84 -0
  32. package/zuzuu/commands/digest.mjs +23 -0
  33. package/zuzuu/commands/distill.mjs +46 -0
  34. package/zuzuu/commands/doctor.mjs +197 -0
  35. package/zuzuu/commands/enable.mjs +195 -0
  36. package/zuzuu/commands/eval.mjs +101 -0
  37. package/zuzuu/commands/explain.mjs +119 -0
  38. package/zuzuu/commands/generation.mjs +107 -0
  39. package/zuzuu/commands/hook.mjs +209 -0
  40. package/zuzuu/commands/inbox.mjs +73 -0
  41. package/zuzuu/commands/init.mjs +89 -0
  42. package/zuzuu/commands/knowledge.mjs +152 -0
  43. package/zuzuu/commands/migrate.mjs +125 -0
  44. package/zuzuu/commands/review.mjs +299 -0
  45. package/zuzuu/commands/status.mjs +82 -0
  46. package/zuzuu/commands/trace.mjs +19 -0
  47. package/zuzuu/digest.mjs +149 -0
  48. package/zuzuu/eval/rank.mjs +31 -0
  49. package/zuzuu/eval/score.mjs +85 -0
  50. package/zuzuu/eval/signals.mjs +57 -0
  51. package/zuzuu/faculty/contract.mjs +19 -0
  52. package/zuzuu/faculty/gate.mjs +65 -0
  53. package/zuzuu/faculty/generation.mjs +392 -0
  54. package/zuzuu/faculty/proposal.mjs +166 -0
  55. package/zuzuu/faculty/provenance.mjs +35 -0
  56. package/zuzuu/faculty/registry.mjs +33 -0
  57. package/zuzuu/faculty/trail.mjs +27 -0
  58. package/zuzuu/guardrails/adapter.mjs +134 -0
  59. package/zuzuu/guardrails.mjs +89 -0
  60. package/zuzuu/inject.mjs +46 -0
  61. package/zuzuu/instructions/adapter.mjs +93 -0
  62. package/zuzuu/knowledge/adapter.mjs +99 -0
  63. package/zuzuu/knowledge/distill.mjs +237 -0
  64. package/zuzuu/knowledge/embed.mjs +52 -0
  65. package/zuzuu/knowledge/er.mjs +98 -0
  66. package/zuzuu/knowledge/inbox.mjs +43 -0
  67. package/zuzuu/knowledge/index.mjs +194 -0
  68. package/zuzuu/knowledge/items.mjs +154 -0
  69. package/zuzuu/knowledge/proposals.mjs +196 -0
  70. package/zuzuu/knowledge/registry.mjs +115 -0
  71. package/zuzuu/live/install.mjs +76 -0
  72. package/zuzuu/live/live-store.mjs +78 -0
  73. package/zuzuu/live/probe.mjs +55 -0
  74. package/zuzuu/live/reconcile.mjs +33 -0
  75. package/zuzuu/memory/adapter.mjs +121 -0
  76. package/zuzuu/miners/actions.mjs +118 -0
  77. package/zuzuu/miners/guardrails.mjs +174 -0
  78. package/zuzuu/miners/instructions.mjs +152 -0
  79. package/zuzuu/miners/knowledge.mjs +22 -0
  80. package/zuzuu/miners/memory.mjs +27 -0
  81. package/zuzuu/miners/registry.mjs +31 -0
  82. package/zuzuu/scaffold.mjs +213 -0
  83. package/zuzuu/session.mjs +72 -0
  84. package/zuzuu/store.mjs +104 -0
@@ -0,0 +1,113 @@
1
+ // Gemini CLI adapter — parses ~/.gemini/tmp/<project>/logs.json.
2
+ //
3
+ // Deliberately the THIN counterpart to claude-code, and the honest proof of the
4
+ // README's thesis that "observability completeness varies by host". Gemini's
5
+ // logs.json is a flat user-prompt timeline only:
6
+ // { sessionId, messageId, type:"user", message, timestamp }
7
+ // — no assistant turns, no tool calls (those live in Gemini *checkpoint* files,
8
+ // deferred). So this adapter emits SESSION -> TURN (prompt) and stops there.
9
+ // Same core, second host, different shape => host-agnosticity DEMONSTRATED, not
10
+ // asserted; the missing tool layer is a capture gap, not a core change.
11
+
12
+ import { homedir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
15
+ import { event, trace, EventKind } from '../core/event.mjs';
16
+ import { emptySignals } from './signals.mjs';
17
+
18
+ const TMP_DIR = join(homedir(), '.gemini', 'tmp');
19
+
20
+ const ms = (iso) => (iso ? Date.parse(iso) : NaN);
21
+ const clean = (s) => String(s).replace(/\s+/g, ' ').trim();
22
+ const truncate = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
23
+
24
+ function readLog(file) {
25
+ try {
26
+ const data = JSON.parse(readFileSync(file, 'utf8'));
27
+ return Array.isArray(data) ? data : [];
28
+ } catch {
29
+ return [];
30
+ }
31
+ }
32
+
33
+ export const geminiCli = {
34
+ name: 'gemini-cli',
35
+
36
+ detect() {
37
+ return existsSync(TMP_DIR);
38
+ },
39
+
40
+ // Cross-host distill: logs.json is PROMPT-ONLY (no tool calls) → empty superset.
41
+ // Honest: gemini's on-disk capture is thin (tool calls live in checkpoints).
42
+ mineSignals() {
43
+ return emptySignals();
44
+ },
45
+
46
+ listSessions() {
47
+ if (!existsSync(TMP_DIR)) return [];
48
+ const out = [];
49
+ for (const project of readdirSync(TMP_DIR)) {
50
+ const file = join(TMP_DIR, project, 'logs.json');
51
+ if (!existsSync(file)) continue;
52
+ const rows = readLog(file);
53
+ const sessions = new Map(); // sessionId -> {count, last}
54
+ for (const r of rows) {
55
+ if (!r.sessionId) continue;
56
+ const s = sessions.get(r.sessionId) || { count: 0, last: 0 };
57
+ s.count++;
58
+ s.last = Math.max(s.last, ms(r.timestamp) || 0);
59
+ sessions.set(r.sessionId, s);
60
+ }
61
+ for (const [sessionId, s] of sessions) {
62
+ out.push({ sessionId, label: `${project} (${s.count} msgs)`, ref: { file, sessionId }, mtime: s.last });
63
+ }
64
+ }
65
+ return out.sort((a, b) => b.mtime - a.mtime);
66
+ },
67
+
68
+ parse(ref) {
69
+ const { file, sessionId } = ref;
70
+ const rows = readLog(file)
71
+ .filter((r) => r.sessionId === sessionId && r.type === 'user')
72
+ .sort((a, b) => (a.messageId ?? 0) - (b.messageId ?? 0));
73
+
74
+ const times = rows.map((r) => ms(r.timestamp)).filter(Number.isFinite);
75
+ const startMs = times.length ? Math.min(...times) : 0;
76
+ const endMs = times.length ? Math.max(...times) : startMs;
77
+
78
+ const events = rows.map((r, i) => {
79
+ const t = ms(r.timestamp);
80
+ const start = Number.isFinite(t) ? t : startMs;
81
+ // Tile prompts: a prompt span runs until the next prompt (gives readable durations).
82
+ const nextT = ms(rows[i + 1]?.timestamp);
83
+ const end = Number.isFinite(nextT) ? nextT : Math.max(start, endMs);
84
+ const text = clean(r.message);
85
+ return event({
86
+ kind: EventKind.TURN,
87
+ refId: `${sessionId}:${r.messageId ?? i}`,
88
+ parentRefId: sessionId,
89
+ name: 'turn: ' + (truncate(text, 60) || '(empty)'),
90
+ startMs: start,
91
+ endMs: end,
92
+ attributes: { 'turn.prompt.bytes': text.length },
93
+ });
94
+ });
95
+
96
+ events.unshift(
97
+ event({
98
+ kind: EventKind.SESSION,
99
+ refId: sessionId,
100
+ parentRefId: null,
101
+ name: `session ${sessionId.slice(0, 8)} (gemini-cli)`,
102
+ startMs,
103
+ endMs,
104
+ attributes: {
105
+ 'host.name': 'gemini-cli',
106
+ 'host.capture.note': 'logs.json = user prompts only; tool calls live in checkpoints (not captured)',
107
+ },
108
+ }),
109
+ );
110
+
111
+ return trace({ host: 'gemini-cli', sessionId, events });
112
+ },
113
+ };
@@ -0,0 +1,43 @@
1
+ // The HostAdapter contract — the host-agnostic seam.
2
+ //
3
+ // Shape borrowed from entire.io's audited adapter model (inspiration/
4
+ // entire-io-host-adapter-audit.md): a small required surface to DETECT a host and
5
+ // RESOLVE + PARSE its session transcript, normalizing into the one Event vocabulary.
6
+ // The dispatcher (registry/capture) routes by capability, never by host name — so
7
+ // the core stays agnostic and a new host is "just another adapter".
8
+ //
9
+ // Capture today is transcript-parsing, which works for ANY host that writes a
10
+ // session log to disk (all of them) and needs zero cooperation from the host.
11
+ // Live hooks are an OPTIONAL later capability (HookSupport), not the foundation.
12
+ //
13
+ // An adapter is a plain object implementing:
14
+ //
15
+ // name: string
16
+ // stable adapter id, also the OTel `host.name` (e.g. "claude-code").
17
+ //
18
+ // detect(): boolean
19
+ // is this host present on the machine? (its data dir exists)
20
+ //
21
+ // listSessions(opts?): Array<{ sessionId, label, ref }>
22
+ // enumerate available sessions. `ref` is whatever parse() needs to load it
23
+ // (a file path, or {file, sessionId} when a file holds many sessions).
24
+ // Sorted most-recent-first when possible.
25
+ //
26
+ // parse(ref): import('../core/event.mjs').trace
27
+ // load one session and normalize it into a { host, sessionId, title, events }
28
+ // trace whose events are exactly one SESSION root + its descendants.
29
+ //
30
+ // Optional (not implemented in Experiment 1):
31
+ // installHooks(), parseHookEvent(stdin) -> Event // live capture where supported
32
+
33
+ export const HOST_ADAPTER_KEYS = ['name', 'detect', 'listSessions', 'parse'];
34
+
35
+ /** Throw if an object doesn't implement the required adapter surface. */
36
+ export function assertAdapter(a) {
37
+ for (const k of HOST_ADAPTER_KEYS) {
38
+ if (typeof a?.[k] !== (k === 'name' ? 'string' : 'function')) {
39
+ throw new Error(`invalid HostAdapter: missing ${k}`);
40
+ }
41
+ }
42
+ return a;
43
+ }
@@ -0,0 +1,205 @@
1
+ // OpenCode adapter — reads ~/.local/share/opencode/opencode.db (SQLite).
2
+ //
3
+ // Built against REAL wire data (a live `opencode run` session via the Google
4
+ // provider). Confirmed shapes (v1.16.2):
5
+ // session row: { id, directory, title, model(JSON string), time_created, time_updated }
6
+ // message row: { id, session_id, time_created, data:JSON{ role: user|assistant, time } }
7
+ // part row: { id, message_id, session_id, time_created, data:JSON{ type, ... } }
8
+ // part.data.type ∈ text | tool | reasoning | step-start | step-finish
9
+ // text → { text } (user prompt + assistant text live here)
10
+ // tool → { tool, callID, state:{ status, input, output, time:{start,end}, metadata:{exit} } }
11
+ //
12
+ // node:sqlite is loaded LAZILY (createRequire) so importing this adapter never
13
+ // breaks the registry on Node <22 — only `opencode` commands touch SQLite.
14
+
15
+ import { homedir } from 'node:os';
16
+ import { join } from 'node:path';
17
+ import { existsSync } from 'node:fs';
18
+ import { createRequire } from 'node:module';
19
+ import { event, trace, EventKind, Status } from '../core/event.mjs';
20
+ import { assembleSignals, emptySignals } from './signals.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const DB_PATH = join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
24
+
25
+ const clean = (s) => String(s).replace(/\s+/g, ' ').trim();
26
+ const truncate = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
27
+ const openDb = (path) => new (require('node:sqlite').DatabaseSync)(path, { readOnly: true });
28
+
29
+ /**
30
+ * Pure normalization — host-agnostic, hermetically testable.
31
+ * @param {object} t
32
+ * @param {{id,directory,title,model,time_created,time_updated}} t.session
33
+ * @param {Array<{id,role,time_created}>} t.messages
34
+ * @param {Array<{messageId,time_created,type,text?,tool?,callID?,state?}>} t.parts
35
+ */
36
+ export function buildTrace({ session, messages, parts }) {
37
+ const sid = session.id;
38
+ const events = [];
39
+
40
+ const partsByMsg = new Map();
41
+ for (const p of parts) {
42
+ if (!partsByMsg.has(p.messageId)) partsByMsg.set(p.messageId, []);
43
+ partsByMsg.get(p.messageId).push(p);
44
+ }
45
+
46
+ // Turns: one per user message; prompt text = its text parts.
47
+ const turns = [];
48
+ for (const m of messages.filter((m) => m.role === 'user').sort((a, b) => a.time_created - b.time_created)) {
49
+ const text = (partsByMsg.get(m.id) || []).filter((p) => p.type === 'text').map((p) => p.text || '').join(' ');
50
+ turns.push({ refId: m.id, start: m.time_created });
51
+ events.push(
52
+ event({
53
+ kind: EventKind.TURN,
54
+ refId: m.id,
55
+ parentRefId: sid,
56
+ name: 'turn: ' + (truncate(clean(text), 60) || '(empty)'),
57
+ startMs: m.time_created,
58
+ endMs: m.time_created,
59
+ attributes: { 'turn.prompt.bytes': text.length },
60
+ }),
61
+ );
62
+ }
63
+
64
+ // Tool spans: from tool parts, paired durations via state.time.{start,end}.
65
+ const turnEnd = new Map();
66
+ for (const p of parts.filter((p) => p.type === 'tool').sort((a, b) => (a.state?.time?.start || 0) - (b.state?.time?.start || 0))) {
67
+ const st = p.state || {};
68
+ const startMs = st.time?.start || p.time_created || session.time_created;
69
+ const endMs = st.time?.end || startMs;
70
+ let parent = sid;
71
+ for (const t of turns) if (t.start <= startMs) parent = t.refId; // most recent user turn
72
+ const input = JSON.stringify(st.input ?? {});
73
+ const output = typeof st.output === 'string' ? st.output : JSON.stringify(st.output ?? '');
74
+ events.push(
75
+ event({
76
+ kind: EventKind.TOOL_CALL,
77
+ refId: p.callID || `${sid}:call:${events.length}`,
78
+ parentRefId: parent,
79
+ name: p.tool || 'tool',
80
+ startMs,
81
+ endMs,
82
+ status: st.status === 'error' ? Status.ERROR : Status.OK,
83
+ attributes: {
84
+ 'gen_ai.operation.name': 'execute_tool',
85
+ 'gen_ai.tool.name': p.tool || '',
86
+ 'host.tool.name': p.tool || '',
87
+ 'tool.input.bytes': input.length,
88
+ 'tool.result.bytes': output.length,
89
+ },
90
+ }),
91
+ );
92
+ turnEnd.set(parent, Math.max(turnEnd.get(parent) ?? 0, endMs));
93
+ }
94
+
95
+ for (const e of events) if (e.kind === EventKind.TURN && turnEnd.has(e.refId)) e.endMs = Math.max(e.endMs, turnEnd.get(e.refId));
96
+
97
+ const ends = events.map((e) => e.endMs);
98
+ const startMs = session.time_created || Math.min(...turns.map((t) => t.start), session.time_updated || 0);
99
+ const endMs = Math.max(session.time_updated || 0, startMs, ...ends);
100
+
101
+ let model = '';
102
+ try {
103
+ const m = typeof session.model === 'string' ? JSON.parse(session.model) : session.model;
104
+ if (m) model = `${m.providerID || ''}/${m.id || m.modelID || ''}`;
105
+ } catch {
106
+ /* model stays '' */
107
+ }
108
+
109
+ events.unshift(
110
+ event({
111
+ kind: EventKind.SESSION,
112
+ refId: sid,
113
+ parentRefId: null,
114
+ name: `session ${String(sid).slice(0, 12)} (opencode)`,
115
+ startMs,
116
+ endMs,
117
+ attributes: { 'host.name': 'opencode', 'host.session.model': model, 'host.cwd': session.directory || '' },
118
+ }),
119
+ );
120
+
121
+ return trace({ host: 'opencode', sessionId: sid, title: session.title || '', events });
122
+ }
123
+
124
+ // opencode shell tool (real-wire): tool part name "bash", state.input.command
125
+ // (a string), state.status enum ("completed" | "error"). Pure → testable.
126
+ const OC_SHELL = new Set(['bash', 'shell']);
127
+ export function signalsFromParts(parts) {
128
+ const shellCalls = [];
129
+ for (const p of parts.filter((p) => p.type === 'tool').sort((a, b) => (a.state?.time?.start || a.time_created || 0) - (b.state?.time?.start || b.time_created || 0))) {
130
+ if (!OC_SHELL.has(p.tool)) continue;
131
+ const cmd = p.state?.input?.command;
132
+ if (typeof cmd !== 'string' || !cmd) continue;
133
+ shellCalls.push({ cmd, failed: p.state?.status === 'error', tool: p.tool });
134
+ }
135
+ return assembleSignals(shellCalls);
136
+ }
137
+
138
+ export const opencode = {
139
+ name: 'opencode',
140
+
141
+ detect() {
142
+ return existsSync(DB_PATH);
143
+ },
144
+
145
+ // Cross-host distill: shell command TEXT + status enum from SQLite parts.
146
+ mineSignals(ref) {
147
+ try {
148
+ const dbPath = ref?.db || DB_PATH;
149
+ const sid = ref?.sessionId || ref;
150
+ if (!existsSync(dbPath)) return emptySignals();
151
+ const db = openDb(dbPath);
152
+ try {
153
+ const parts = db
154
+ .prepare('SELECT time_created, data FROM part WHERE session_id = ? ORDER BY time_created')
155
+ .all(sid)
156
+ .map((p) => {
157
+ const d = JSON.parse(p.data);
158
+ return { time_created: p.time_created, type: d.type, tool: d.tool, state: d.state };
159
+ });
160
+ return signalsFromParts(parts);
161
+ } finally {
162
+ db.close();
163
+ }
164
+ } catch {
165
+ return emptySignals();
166
+ }
167
+ },
168
+
169
+ listSessions() {
170
+ if (!existsSync(DB_PATH)) return [];
171
+ const db = openDb(DB_PATH);
172
+ try {
173
+ return db
174
+ .prepare('SELECT id, title, time_updated FROM session ORDER BY time_updated DESC')
175
+ .all()
176
+ .map((r) => ({ sessionId: r.id, ref: { db: DB_PATH, sessionId: r.id }, label: r.title || 'opencode', mtime: r.time_updated }));
177
+ } finally {
178
+ db.close();
179
+ }
180
+ },
181
+
182
+ parse(ref) {
183
+ const dbPath = ref?.db || DB_PATH;
184
+ const sid = ref?.sessionId || ref;
185
+ const db = openDb(dbPath);
186
+ try {
187
+ const session = db.prepare('SELECT id, directory, title, model, time_created, time_updated FROM session WHERE id = ?').get(sid);
188
+ if (!session) throw new Error(`opencode session not found: ${sid}`);
189
+ const messages = db
190
+ .prepare('SELECT id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created')
191
+ .all(sid)
192
+ .map((m) => ({ id: m.id, time_created: m.time_created, role: JSON.parse(m.data).role }));
193
+ const parts = db
194
+ .prepare('SELECT message_id, time_created, data FROM part WHERE session_id = ? ORDER BY time_created')
195
+ .all(sid)
196
+ .map((p) => {
197
+ const d = JSON.parse(p.data);
198
+ return { messageId: p.message_id, time_created: p.time_created, type: d.type, text: d.text, tool: d.tool, callID: d.callID, state: d.state };
199
+ });
200
+ return buildTrace({ session, messages, parts });
201
+ } finally {
202
+ db.close();
203
+ }
204
+ },
205
+ };
@@ -0,0 +1,218 @@
1
+ // pi CLI adapter — parses ~/.pi/agent/sessions/<slug>/*.jsonl.
2
+ //
3
+ // Built against REAL wire data (a captured `pi` probe session), not docs.
4
+ // Confirmed shapes (one JSON object per line):
5
+ // { type:"session", version:3, id, timestamp, cwd } → the session header (line 1)
6
+ // { type:"model_change", id, parentId, timestamp, provider, modelId }
7
+ // { type:"thinking_level_change", id, parentId, timestamp, thinkingLevel } (metadata)
8
+ // { type:"message", id, parentId, timestamp, message:{ role, content:[…], … } }
9
+ // role "user" → content [{ type:"text", text }] (the prompt → a TURN)
10
+ // role "assistant" → content [{ type:"thinking", … } | { type:"text", text }
11
+ // | { type:"toolCall", id, name, arguments }] (toolCall → a TOOL span)
12
+ // role "toolResult" → { toolCallId, toolName, content:[{type:"text",text}], isError }
13
+ //
14
+ // Tool spans pair the assistant's content.toolCall to its toolResult message by
15
+ // toolCall.id === toolResult.message.toolCallId, giving real durations + an
16
+ // isError → Status.ERROR flag. Turns come from role:"user" messages (clean prompt).
17
+
18
+ import { homedir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import { existsSync, readdirSync, statSync, readFileSync } from 'node:fs';
21
+ import { event, trace, EventKind, Status } from '../core/event.mjs';
22
+ import { assembleSignals, emptySignals } from './signals.mjs';
23
+
24
+ const SESSIONS_DIR = join(homedir(), '.pi', 'agent', 'sessions');
25
+
26
+ // pi shell tool (real-wire): assistant content[].toolCall name "bash",
27
+ // arguments.command (an object, not a JSON string); paired toolResult.isError.
28
+ const PI_SHELL = new Set(['bash', 'shell']);
29
+ function piCmdText(args) {
30
+ const a = typeof args === 'string' ? (() => { try { return JSON.parse(args); } catch { return {}; } })() : args ?? {};
31
+ if (typeof a.command === 'string') return a.command;
32
+ if (Array.isArray(a.command)) return a.command.join(' ');
33
+ if (typeof a.cmd === 'string') return a.cmd;
34
+ return '';
35
+ }
36
+ const ms = (iso) => (iso ? Date.parse(iso) : NaN);
37
+ const clean = (s) => String(s).replace(/\s+/g, ' ').trim();
38
+ const truncate = (s, n) => (s.length > n ? s.slice(0, n - 1) + '…' : s);
39
+
40
+ function readJsonl(file) {
41
+ return readFileSync(file, 'utf8')
42
+ .split('\n')
43
+ .filter(Boolean)
44
+ .map((l) => {
45
+ try {
46
+ return JSON.parse(l);
47
+ } catch {
48
+ return null;
49
+ }
50
+ })
51
+ .filter(Boolean);
52
+ }
53
+
54
+ // First [{type:"text"}] item's text, joined — pi content is an array of parts.
55
+ function textOf(content) {
56
+ if (!Array.isArray(content)) return '';
57
+ return content
58
+ .filter((c) => c && c.type === 'text' && typeof c.text === 'string')
59
+ .map((c) => c.text)
60
+ .join(' ');
61
+ }
62
+
63
+ export const pi = {
64
+ name: 'pi',
65
+
66
+ detect() {
67
+ return existsSync(SESSIONS_DIR);
68
+ },
69
+
70
+ listSessions() {
71
+ if (!existsSync(SESSIONS_DIR)) return [];
72
+ return readdirSync(SESSIONS_DIR, { recursive: true })
73
+ .filter((f) => typeof f === 'string' && /\.jsonl$/.test(f))
74
+ .map((f) => {
75
+ const path = join(SESSIONS_DIR, f);
76
+ // filename: <iso>_<uuid>.jsonl — fall back to the uuid for the id.
77
+ const m = f.match(/([0-9a-f-]{36})\.jsonl$/i);
78
+ return { sessionId: m ? m[1] : f, ref: path, label: 'pi', mtime: statSync(path).mtimeMs };
79
+ })
80
+ .sort((a, b) => b.mtime - a.mtime);
81
+ },
82
+
83
+ // Cross-host distill: shell command TEXT + isError flag from the raw jsonl.
84
+ mineSignals(ref) {
85
+ try {
86
+ const file = typeof ref === 'string' ? ref : ref.ref;
87
+ const rows = readJsonl(file);
88
+ const errors = new Map(); // toolCallId -> isError
89
+ for (const r of rows) {
90
+ if (r.type !== 'message') continue;
91
+ const m = r.message || {};
92
+ if (m.role === 'toolResult' && m.toolCallId) errors.set(m.toolCallId, !!m.isError);
93
+ }
94
+ const shellCalls = [];
95
+ for (const r of rows) {
96
+ if (r.type !== 'message') continue;
97
+ const m = r.message || {};
98
+ if (m.role !== 'assistant' || !Array.isArray(m.content)) continue;
99
+ for (const c of m.content) {
100
+ if (!c || c.type !== 'toolCall' || !PI_SHELL.has(c.name)) continue;
101
+ const cmd = piCmdText(c.arguments);
102
+ if (cmd) shellCalls.push({ cmd, failed: errors.get(c.id) === true, tool: c.name });
103
+ }
104
+ }
105
+ return assembleSignals(shellCalls);
106
+ } catch {
107
+ return emptySignals();
108
+ }
109
+ },
110
+
111
+ parse(ref) {
112
+ const file = typeof ref === 'string' ? ref : ref.ref;
113
+ const rows = readJsonl(file);
114
+
115
+ const header = rows.find((r) => r.type === 'session') || {};
116
+ let sessionId = header.id || '';
117
+ const meta = { startMs: Infinity, endMs: -Infinity, model: '', cwd: header.cwd || '' };
118
+
119
+ // Pass 1: index toolResult messages by toolCallId (end time + size + error flag).
120
+ const results = new Map();
121
+ for (const r of rows) {
122
+ if (r.type !== 'message') continue;
123
+ const m = r.message || {};
124
+ if (m.role === 'toolResult' && m.toolCallId) {
125
+ const out = textOf(m.content);
126
+ results.set(m.toolCallId, { endMs: ms(r.timestamp), bytes: out.length, isError: !!m.isError });
127
+ }
128
+ }
129
+
130
+ // Pass 2: walk in order; turns from user messages, tools from assistant toolCalls.
131
+ const events = [];
132
+ const turnEnd = new Map();
133
+ let currentTurn = null;
134
+ let turnIdx = 0;
135
+
136
+ for (const r of rows) {
137
+ const t = ms(r.timestamp);
138
+ if (Number.isFinite(t)) {
139
+ meta.startMs = Math.min(meta.startMs, t);
140
+ meta.endMs = Math.max(meta.endMs, t);
141
+ }
142
+ if (r.type === 'model_change') meta.model ||= r.modelId || '';
143
+ if (r.type !== 'message') continue;
144
+
145
+ const m = r.message || {};
146
+
147
+ if (m.role === 'user') {
148
+ const text = clean(textOf(m.content));
149
+ const refId = r.id || `${sessionId || 'pi'}:turn:${turnIdx}`;
150
+ currentTurn = refId;
151
+ turnIdx++;
152
+ events.push(
153
+ event({
154
+ kind: EventKind.TURN,
155
+ refId,
156
+ parentRefId: sessionId || 'session',
157
+ name: 'turn: ' + (truncate(text, 60) || '(empty)'),
158
+ startMs: Number.isFinite(t) ? t : meta.startMs,
159
+ endMs: Number.isFinite(t) ? t : meta.startMs,
160
+ attributes: { 'turn.prompt.bytes': text.length },
161
+ }),
162
+ );
163
+ }
164
+
165
+ if (m.role === 'assistant' && Array.isArray(m.content)) {
166
+ for (const c of m.content) {
167
+ if (!c || c.type !== 'toolCall') continue;
168
+ const res = results.get(c.id) || {};
169
+ const startMs = Number.isFinite(t) ? t : meta.startMs;
170
+ const endMs = Number.isFinite(res.endMs) ? res.endMs : startMs;
171
+ const args = typeof c.arguments === 'string' ? c.arguments : JSON.stringify(c.arguments ?? {});
172
+ const parent = currentTurn || sessionId || 'session';
173
+ events.push(
174
+ event({
175
+ kind: EventKind.TOOL_CALL,
176
+ refId: c.id || `${sessionId}:call:${events.length}`,
177
+ parentRefId: parent,
178
+ name: 'tool: ' + (c.name || 'tool'),
179
+ startMs,
180
+ endMs,
181
+ status: res.isError ? Status.ERROR : Status.OK,
182
+ attributes: {
183
+ 'gen_ai.operation.name': 'execute_tool',
184
+ 'gen_ai.tool.name': c.name || '',
185
+ 'host.tool.name': c.name || '',
186
+ 'tool.input.bytes': args.length,
187
+ 'tool.result.bytes': res.bytes ?? 0,
188
+ },
189
+ }),
190
+ );
191
+ turnEnd.set(parent, Math.max(turnEnd.get(parent) ?? 0, endMs));
192
+ }
193
+ }
194
+ }
195
+
196
+ sessionId ||= (file.match(/([0-9a-f-]{36})\.jsonl$/i) || [])[1] || file.split('/').pop();
197
+ if (!Number.isFinite(meta.startMs)) meta.startMs = ms(header.timestamp) || 0;
198
+ if (!Number.isFinite(meta.endMs)) meta.endMs = meta.startMs;
199
+
200
+ for (const e of events) {
201
+ if (e.kind === EventKind.TURN && turnEnd.has(e.refId)) e.endMs = Math.max(e.endMs, turnEnd.get(e.refId));
202
+ }
203
+
204
+ events.unshift(
205
+ event({
206
+ kind: EventKind.SESSION,
207
+ refId: sessionId,
208
+ parentRefId: null,
209
+ name: `session ${String(sessionId).slice(0, 8)} (pi)`,
210
+ startMs: meta.startMs,
211
+ endMs: meta.endMs,
212
+ attributes: { 'host.name': 'pi', 'host.session.model': meta.model, 'host.cwd': meta.cwd },
213
+ }),
214
+ );
215
+
216
+ return trace({ host: 'pi', sessionId: String(sessionId), title: meta.cwd, events });
217
+ },
218
+ };
@@ -0,0 +1,20 @@
1
+ // Adapter registry. Routes by capability/detection, never by host name baked into
2
+ // the core. Adding a host = add an adapter here; nothing else changes.
3
+
4
+ import { assertAdapter } from './host-adapter.mjs';
5
+ import { claudeCode } from './claude-code.mjs';
6
+ import { geminiCli } from './gemini-cli.mjs';
7
+ import { codex } from './codex.mjs';
8
+ import { opencode } from './opencode.mjs';
9
+ import { pi } from './pi.mjs';
10
+
11
+ export const ADAPTERS = [claudeCode, geminiCli, codex, opencode, pi].map(assertAdapter);
12
+
13
+ export function byName(name) {
14
+ return ADAPTERS.find((a) => a.name === name) || null;
15
+ }
16
+
17
+ /** Adapters whose host has on-disk data right now. */
18
+ export function detected() {
19
+ return ADAPTERS.filter((a) => a.detect());
20
+ }
@@ -0,0 +1,44 @@
1
+ // Shared cross-host signal-mining helpers.
2
+ //
3
+ // CROSS-HOST DISTILL: each adapter's `mineSignals(ref)` extracts the SAME shape
4
+ // as the Claude miner (`{commands, files, failures, sequences, correctionTurns,
5
+ // destructiveFailures}`) from that host's RAW transcript — command TEXT + a
6
+ // failed flag + adjacent-command 2-gram sequences + destructive-failure shapes.
7
+ // The aggregation thresholds live in `mns/knowledge/distill.mjs`; this is the
8
+ // per-host extraction primitives, factored here so every adapter shares one
9
+ // `norm`, one destructive-shape set, and one assembler (DRY, real-wire-built).
10
+
11
+ export const norm = (cmd) => String(cmd).trim().replace(/\s+/g, ' ').slice(0, 200);
12
+
13
+ const SEQ_SEP = ' && '; // joins adjacent shell commands into a 2-gram label
14
+ const DESTRUCTIVE_SHAPES = [/\brm\s+-[a-z]*r/, /git\s+push\s+.*--force/, /DROP\s+TABLE/i, /chmod\s+-R/];
15
+ export const isDestructive = (cmd) => DESTRUCTIVE_SHAPES.some((re) => re.test(cmd));
16
+
17
+ /** The empty superset — what an unminable / malformed / prompt-only host returns. */
18
+ export function emptySignals() {
19
+ return { commands: [], files: [], failures: [], sequences: [], correctionTurns: [], destructiveFailures: [] };
20
+ }
21
+
22
+ /**
23
+ * Assemble the signal superset from an ordered list of shell tool calls.
24
+ * @param {Array<{cmd:string, failed:boolean, tool?:string}>} shellCalls — in transcript order
25
+ * @returns the {commands, files, failures, sequences, correctionTurns, destructiveFailures} shape
26
+ */
27
+ export function assembleSignals(shellCalls) {
28
+ const out = emptySignals();
29
+ const order = [];
30
+ for (const call of shellCalls) {
31
+ const cmd = norm(call.cmd);
32
+ if (!cmd) continue;
33
+ const failed = !!call.failed;
34
+ const tool = call.tool || 'bash';
35
+ out.commands.push({ cmd, failed });
36
+ order.push(cmd);
37
+ if (failed) {
38
+ out.failures.push(tool);
39
+ if (isDestructive(cmd)) out.destructiveFailures.push({ cmd, tool });
40
+ }
41
+ }
42
+ for (let i = 0; i + 1 < order.length; i++) out.sequences.push(order[i] + SEQ_SEP + order[i + 1]);
43
+ return out;
44
+ }