@manyoars/oars 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -29
- package/dist/cli.d.ts +4 -1
- package/dist/cli.js +102 -32
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.js +1 -1
- package/dist/gate/hook-main.js +13 -1
- package/dist/gate/hook-process.d.ts +11 -1
- package/dist/gate/hook-process.js +50 -2
- package/dist/gate/hook-record.d.ts +3 -0
- package/dist/gate/hook-record.js +8 -0
- package/dist/gate/hook.d.ts +2 -0
- package/dist/gate/hook.js +3 -0
- package/dist/gate/replay.js +2 -5
- package/dist/gate/report.js +1 -1
- package/dist/gate/transcripts.js +1 -1
- package/dist/hook-bin.js +27 -2
- package/dist/issuer.d.ts +7 -9
- package/dist/issuer.js +10 -32
- package/dist/main.js +3 -0
- package/dist/sessions/actions.js +78 -0
- package/dist/sessions/allow.d.ts +10 -1
- package/dist/sessions/allow.js +29 -9
- package/dist/sessions/baseline.d.ts +1 -1
- package/dist/sessions/baseline.js +6 -4
- package/dist/sessions/call-shape.d.ts +19 -0
- package/dist/sessions/call-shape.js +85 -0
- package/dist/sessions/cell-increments.d.ts +5 -1
- package/dist/sessions/cell-increments.js +84 -50
- package/dist/sessions/cells.d.ts +33 -3
- package/dist/sessions/cells.js +89 -9
- package/dist/sessions/codex.js +93 -27
- package/dist/sessions/command.d.ts +11 -1
- package/dist/sessions/command.js +64 -18
- package/dist/sessions/coverage.d.ts +19 -0
- package/dist/sessions/coverage.js +56 -0
- package/dist/sessions/cursor.js +2 -2
- package/dist/sessions/derive.js +72 -6
- package/dist/sessions/exclusion.d.ts +6 -0
- package/dist/sessions/exclusion.js +8 -0
- package/dist/sessions/extract.d.ts +4 -2
- package/dist/sessions/extract.js +58 -26
- package/dist/sessions/hook-calls.js +1 -1
- package/dist/sessions/hook-index.d.ts +14 -1
- package/dist/sessions/hook-index.js +10 -3
- package/dist/sessions/hooks.d.ts +5 -1
- package/dist/sessions/hooks.js +133 -22
- package/dist/sessions/identity.d.ts +8 -3
- package/dist/sessions/identity.js +45 -15
- package/dist/sessions/install.d.ts +18 -2
- package/dist/sessions/install.js +53 -8
- package/dist/sessions/mcp.d.ts +3 -2
- package/dist/sessions/mcp.js +8 -4
- package/dist/sessions/motif.d.ts +2 -1
- package/dist/sessions/motif.js +2 -2
- package/dist/sessions/opencode.d.ts +33 -0
- package/dist/sessions/opencode.js +319 -0
- package/dist/sessions/otel.d.ts +60 -0
- package/dist/sessions/otel.js +494 -0
- package/dist/sessions/package-books.d.ts +48 -0
- package/dist/sessions/package-books.js +120 -0
- package/dist/sessions/package-resolve.d.ts +2 -0
- package/dist/sessions/package-resolve.js +3 -1
- package/dist/sessions/pi.d.ts +36 -0
- package/dist/sessions/pi.js +353 -0
- package/dist/sessions/pool-books.d.ts +21 -0
- package/dist/sessions/pool-books.js +75 -0
- package/dist/sessions/pool-choices.d.ts +5 -0
- package/dist/sessions/pool-choices.js +11 -0
- package/dist/sessions/pool-command.d.ts +76 -5
- package/dist/sessions/pool-command.js +263 -136
- package/dist/sessions/pool-nightly-deps.d.ts +3 -0
- package/dist/sessions/pool-nightly-deps.js +11 -0
- package/dist/sessions/pool-nightly.d.ts +10 -0
- package/dist/sessions/pool-nightly.js +85 -0
- package/dist/sessions/pool-outbox.d.ts +15 -2
- package/dist/sessions/pool-outbox.js +79 -26
- package/dist/sessions/pool-print.d.ts +1 -1
- package/dist/sessions/pool-raw.d.ts +27 -0
- package/dist/sessions/pool-raw.js +68 -0
- package/dist/sessions/pool-replica.d.ts +35 -0
- package/dist/sessions/pool-replica.js +91 -0
- package/dist/sessions/pool-send-lock.d.ts +20 -0
- package/dist/sessions/pool-send-lock.js +87 -0
- package/dist/sessions/pool-sent.d.ts +4 -0
- package/dist/sessions/pool-sent.js +26 -0
- package/dist/sessions/pool-totals.d.ts +10 -0
- package/dist/sessions/pool-totals.js +21 -0
- package/dist/sessions/pool-wire.d.ts +240 -30
- package/dist/sessions/pool-wire.js +695 -215
- package/dist/sessions/public-vocabulary.d.ts +2 -3
- package/dist/sessions/public-vocabulary.js +2 -9
- package/dist/sessions/query.d.ts +16 -4
- package/dist/sessions/query.js +32 -10
- package/dist/sessions/read-cost.js +14 -3
- package/dist/sessions/read-recovery-edge.d.ts +2 -0
- package/dist/sessions/read-recovery-edge.js +26 -0
- package/dist/sessions/read.d.ts +8 -1
- package/dist/sessions/read.js +6 -3
- package/dist/sessions/recall-bundle.d.ts +12 -0
- package/dist/sessions/recall-bundle.js +33 -0
- package/dist/sessions/recall-frames.d.ts +18 -0
- package/dist/sessions/recall-frames.js +44 -0
- package/dist/sessions/recall-next.d.ts +145 -0
- package/dist/sessions/recall-next.js +241 -0
- package/dist/sessions/recall-object.d.ts +2 -0
- package/dist/sessions/recall-object.js +1 -0
- package/dist/sessions/recall-push.d.ts +2 -2
- package/dist/sessions/recall-push.js +3 -51
- package/dist/sessions/recall-reads.d.ts +7 -0
- package/dist/sessions/recall-reads.js +7 -0
- package/dist/sessions/recovery-calls.d.ts +21 -0
- package/dist/sessions/recovery-calls.js +25 -0
- package/dist/sessions/redact.d.ts +11 -0
- package/dist/sessions/redact.js +59 -0
- package/dist/sessions/schema.d.ts +59 -9
- package/dist/sessions/schema.js +22 -3
- package/dist/sessions/session-shape.d.ts +6 -3
- package/dist/sessions/session-shape.js +19 -6
- package/dist/sessions/sources.js +75 -1
- package/dist/sessions/store.d.ts +13 -1
- package/dist/sessions/store.js +36 -16
- package/dist/sessions/survival.d.ts +2 -0
- package/dist/sessions/survival.js +2 -2
- package/dist/sessions/transcript-tail.d.ts +14 -0
- package/dist/sessions/transcript-tail.js +64 -0
- package/dist/sessions/vscode-copilot.js +24 -7
- package/dist/telemetry/command.d.ts +8 -0
- package/dist/telemetry/command.js +85 -0
- package/dist/telemetry/consent.d.ts +17 -0
- package/dist/telemetry/consent.js +63 -0
- package/dist/telemetry/crash.d.ts +8 -0
- package/dist/telemetry/crash.js +52 -0
- package/dist/telemetry/emit.d.ts +31 -0
- package/dist/telemetry/emit.js +42 -0
- package/dist/telemetry/fields.d.ts +16 -0
- package/dist/telemetry/fields.js +26 -0
- package/dist/telemetry/health.d.ts +21 -0
- package/dist/telemetry/health.js +108 -0
- package/dist/telemetry/hook-emit.d.ts +4 -0
- package/dist/telemetry/hook-emit.js +18 -0
- package/dist/telemetry/measures.d.ts +32 -0
- package/dist/telemetry/measures.js +128 -0
- package/dist/telemetry/observe.d.ts +13 -0
- package/dist/telemetry/observe.js +51 -0
- package/dist/telemetry/read-events.d.ts +9 -0
- package/dist/telemetry/read-events.js +17 -0
- package/dist/telemetry/record.d.ts +17 -0
- package/dist/telemetry/record.js +26 -0
- package/dist/telemetry/report.d.ts +77 -0
- package/dist/telemetry/report.js +188 -0
- package/dist/telemetry/schema.d.ts +40 -0
- package/dist/telemetry/schema.js +187 -0
- package/dist/telemetry/send-main.d.ts +1 -0
- package/dist/telemetry/send-main.js +11 -0
- package/dist/telemetry/send.d.ts +36 -0
- package/dist/telemetry/send.js +172 -0
- package/dist/telemetry/spool.d.ts +17 -0
- package/dist/telemetry/spool.js +70 -0
- package/dist/telemetry/wire.d.ts +25 -0
- package/dist/telemetry/wire.js +76 -0
- package/package.json +2 -2
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** The OpenCode data folder for an environment; a test passes its own so it never reads the real one. */
|
|
2
|
+
export declare const opencodeRoot: (env: {
|
|
3
|
+
[name: string]: string | undefined;
|
|
4
|
+
}, home?: string) => string;
|
|
5
|
+
/** A stored path in the form this platform uses: OpenCode stores a Windows path with forward slashes (core/src/database/path.ts:6-34). */
|
|
6
|
+
export declare const platformPath: (path: string, platform?: NodeJS.Platform) => string;
|
|
7
|
+
/** A session found in the store, before its messages are read. */
|
|
8
|
+
export interface OpencodeSession {
|
|
9
|
+
/** The `ses_...` id, which names the skeleton. */
|
|
10
|
+
id: string;
|
|
11
|
+
/** `session_v2.directory`; null when it names none. */
|
|
12
|
+
folder: string | null;
|
|
13
|
+
/** The parent session's id for a subagent, else null. */
|
|
14
|
+
parent: string | null;
|
|
15
|
+
agent: string | null;
|
|
16
|
+
version: string | null;
|
|
17
|
+
/** Whether the session was forked from another, whose messages it copied. */
|
|
18
|
+
forked: boolean;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
/** Messages the session holds; with `mtimeMs` (its `time_updated`) the cursor `state.json` keeps. */
|
|
21
|
+
size: number;
|
|
22
|
+
mtimeMs: number;
|
|
23
|
+
}
|
|
24
|
+
export interface OpencodeStore {
|
|
25
|
+
sessions: OpencodeSession[];
|
|
26
|
+
/** The session as transcript text for `captureSession`; throws when it holds no parseable message. */
|
|
27
|
+
transcript: (session: OpencodeSession) => string;
|
|
28
|
+
/** Sessions skipped because their id is not a safe file name (it names a skeleton), counted so the skip is never silent. */
|
|
29
|
+
rejected: number;
|
|
30
|
+
close: () => void;
|
|
31
|
+
}
|
|
32
|
+
/** Opens the OpenCode store under `root`; undefined when it holds no database, so a machine without OpenCode is skipped silently. A database that cannot be read throws. */
|
|
33
|
+
export declare const openOpencodeStore: (root: string) => Promise<OpencodeStore | undefined>;
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// The OpenCode source: an OpenCode session becomes the same capture records a Claude Code transcript does.
|
|
2
|
+
//
|
|
3
|
+
// FIXTURE-DERIVED: written from the public source of OpenCode (github.com/sst/opencode @ 21bf6b493b76efcf3769ade999f05163760d61af, MIT, packages cli and core 2.2.5), not
|
|
4
|
+
// from a live store. Nothing was copied; the reader is original. No vendor text promises the database layout (the repository promises deprecation aliases for the SDK and
|
|
5
|
+
// plugin API only, AGENTS.md:174-180), and no hook or SDK surface carries history, so `docs/sessions-store.md` says what a live run would still have to prove.
|
|
6
|
+
//
|
|
7
|
+
// Where it reads from (`opencodeRoot`): `$XDG_DATA_HOME/opencode`, else `~/.local/share/opencode`, on every platform (packages/util/src/global-roots.ts:5,
|
|
8
|
+
// global.ts:13); the file is `opencode.db` (packages/cli/src/database-path.ts:4-12; `OPENCODE_DB` and a channel build's `opencode-<channel>.db` are not read). It is
|
|
9
|
+
// SQLite in WAL mode, opened read-only. Three tables hold a session (packages/core/src/session/sql.ts):
|
|
10
|
+
// session_v2 :29 id (`ses_...`), parent_id (a subagent's parent), fork_session_id, directory (the latest cwd), version (the OpenCode version), agent, time_updated
|
|
11
|
+
// session_message :87 id, session_id, type, seq, time_created, data: the message minus `type` and `id` (projector.ts:662), times in epoch milliseconds
|
|
12
|
+
// user {text, files} the operator's prompt
|
|
13
|
+
// assistant {agent, model {id, providerID}, content[], tokens {input, output, reasoning, cache {read, write}}, time {created}} one model step
|
|
14
|
+
// shell {command, status, exit, output {output}} the operator's own `!cmd`
|
|
15
|
+
// location-switched {location {directory}, previous {location {directory}}} a directory change (schema/src/session-message.ts:59-70)
|
|
16
|
+
// synthetic, system, skill, compaction, idle, agent-switched, model-switched not a prompt and not read
|
|
17
|
+
// session_part :108 message_id, ordinal, type, data: an assistant's content as rows, written only when `experimental.part_rows` is on (projector.ts:316-320). Upstream
|
|
18
|
+
// reads the parts first and any inline content after them (session/history.ts:90-125), and so does this reader.
|
|
19
|
+
// A content block is `text`, `reasoning` (not read), or `tool {id, name, state, time}`; `state.status` is streaming, running, completed, or error. A tool that is still
|
|
20
|
+
// streaming or running has no outcome. The shell tool completes whatever the exit code and records it in `state.metadata.exit` (core/src/shell/result.ts:20-28, :36-43; written by core/src/tool/plugin/shell.ts:85-88), with a notice
|
|
21
|
+
// line appended to the text (:30-35), so the exit code decides, not the status; a thrown failure is `state.error {type, message}`.
|
|
22
|
+
// A fork copies its parent's messages as `msg_<event id>_<seq>` (projector.ts:198, :226); those are the parent's and are skipped. A subagent is its own session with `parent_id`.
|
|
23
|
+
import { existsSync } from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { join, posix, win32 } from 'node:path';
|
|
26
|
+
import { pathToFileURL } from 'node:url';
|
|
27
|
+
/** The OpenCode data folder for an environment; a test passes its own so it never reads the real one. */
|
|
28
|
+
export const opencodeRoot = (env, home = homedir()) => {
|
|
29
|
+
const set = env.XDG_DATA_HOME;
|
|
30
|
+
return join(set === undefined || set === '' ? join(home, '.local', 'share') : set, 'opencode');
|
|
31
|
+
};
|
|
32
|
+
/** A stored path in the form this platform uses: OpenCode stores a Windows path with forward slashes (core/src/database/path.ts:6-34). */
|
|
33
|
+
export const platformPath = (path, platform = process.platform) => (platform === 'win32' && /^(?:[A-Za-z]:\/|\/\/)/.test(path) ? path.replaceAll('/', '\\') : path);
|
|
34
|
+
const isObj = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
35
|
+
const str = (value) => (typeof value === 'string' && value !== '' ? value : undefined);
|
|
36
|
+
const num = (value) => (typeof value === 'number' && Number.isFinite(value) ? value : undefined);
|
|
37
|
+
const parse = (text) => {
|
|
38
|
+
if (typeof text !== 'string')
|
|
39
|
+
return undefined;
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(text);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
/** `node:sqlite` is loaded only when an OpenCode store exists: it prints an experimental-feature warning on first use. */
|
|
48
|
+
const open = async (file) => {
|
|
49
|
+
const { DatabaseSync } = await import('node:sqlite');
|
|
50
|
+
// OpenCode keeps the database in WAL mode (core/src/database/database.ts:42), and even a read-only open of a WAL database writes a `-shm` and a `-wal` beside it.
|
|
51
|
+
// With no `-wal` file OpenCode has closed cleanly and every commit is in the main file, so `immutable=1` reads it and creates nothing. With a `-wal` file OpenCode
|
|
52
|
+
// is running (or died), its newest rows are only there, and a plain read-only open is the one that reads them; the `-shm` it needs is already beside it.
|
|
53
|
+
if (!existsSync(`${file}-wal`))
|
|
54
|
+
return new DatabaseSync(`${pathToFileURL(file).href}?immutable=1`, { readOnly: true });
|
|
55
|
+
return new DatabaseSync(file, { readOnly: true });
|
|
56
|
+
};
|
|
57
|
+
const SAFE_ID = /^(?!\.+$)[A-Za-z0-9._-]+$/;
|
|
58
|
+
const DATABASE = 'opencode.db';
|
|
59
|
+
/** A message a fork copied from its parent: `msg_<event id>_<seq>`; OpenCode's own ids hold no underscore after the prefix (schema/src/identifier.ts:5-9). */
|
|
60
|
+
const COPIED = /^msg_[0-9A-Za-z]+_\d+$/;
|
|
61
|
+
const Y2000 = Date.parse('2000-01-01T00:00:00Z');
|
|
62
|
+
const PATCH_HEADER = /^\*\*\* (?:Add File|Update File|Delete File|Move to): (.+)$/gm;
|
|
63
|
+
/** The notice the shell tool appends to its text for a nonzero exit, a signal, or a timeout (core/src/shell/result.ts:30-35); the reader writes its own header. */
|
|
64
|
+
const NOTICE = /(?:^|\n)(?:Exited with code \d+|Killed by \S+|Timed out before completion)$/;
|
|
65
|
+
/** OpenCode's tool names, in the vocabulary prompt classes and `files` are computed in. A name with no counterpart keeps its own. */
|
|
66
|
+
const TOOL_NAMES = { shell: 'Bash', edit: 'Edit', write: 'Write', patch: 'Edit', read: 'Read', grep: 'Grep', glob: 'Glob' };
|
|
67
|
+
/** A path the model named, made absolute against the session's folder; the folder's own style (POSIX or Windows) decides the join. */
|
|
68
|
+
const absolute = (path, cwd) => {
|
|
69
|
+
if (cwd === undefined || posix.isAbsolute(path) || win32.isAbsolute(path))
|
|
70
|
+
return path;
|
|
71
|
+
return (cwd.startsWith('/') ? posix : win32).join(cwd, path);
|
|
72
|
+
};
|
|
73
|
+
/** The text parts of a tool state's `content[]` (`{type: "text", text}`, schema/src/tool.ts:67-71). */
|
|
74
|
+
const contentText = (state) => (Array.isArray(state.content) ? state.content : []).flatMap((part) => (isObj(part) && part.type === 'text' && typeof part.text === 'string' ? [part.text] : [])).join('\n');
|
|
75
|
+
/** The files a call changes or touches, from the keys its input declares (`path` for edit, write, and read; the headers of `patchText` for patch). */
|
|
76
|
+
const pathsOf = (name, input, cwd) => {
|
|
77
|
+
if (name === 'patch')
|
|
78
|
+
return [...(str(input.patchText) ?? '').matchAll(PATCH_HEADER)].map((match) => absolute(String(match[1]).trim(), cwd));
|
|
79
|
+
const path = str(input.path);
|
|
80
|
+
return path === undefined ? [] : [absolute(path, cwd)];
|
|
81
|
+
};
|
|
82
|
+
/** What a tool state says happened. A shell call is read by its recorded exit, signal, and timeout, not by `status`. */
|
|
83
|
+
const outcomeOf = (name, state) => {
|
|
84
|
+
const status = str(state.status);
|
|
85
|
+
const text = contentText(state);
|
|
86
|
+
if (status === 'error') {
|
|
87
|
+
const error = isObj(state.error) ? str(state.error.message) : undefined;
|
|
88
|
+
// Only the message goes on the line: the wrapper (`{type, message, status, response}`) would never match a Claude Code error signature.
|
|
89
|
+
return { settled: true, failure: error ?? (text === '' ? 'tool failed' : text), output: '' };
|
|
90
|
+
}
|
|
91
|
+
if (status !== 'completed')
|
|
92
|
+
return { settled: false, failure: undefined, output: '' };
|
|
93
|
+
if (name !== 'shell')
|
|
94
|
+
return { settled: true, failure: undefined, output: '' };
|
|
95
|
+
const meta = isObj(state.metadata) ? state.metadata : {};
|
|
96
|
+
const exit = num(meta.exit);
|
|
97
|
+
const signal = str(meta.signal);
|
|
98
|
+
const body = text.replace(NOTICE, '');
|
|
99
|
+
if (exit !== undefined && exit !== 0)
|
|
100
|
+
return { settled: true, failure: `Exit code ${String(exit)}\n${body}`, output: '' };
|
|
101
|
+
if (meta.timeout === true)
|
|
102
|
+
return { settled: true, failure: body === '' ? 'Timed out before completion' : body, output: '' };
|
|
103
|
+
if (signal !== undefined)
|
|
104
|
+
return { settled: true, failure: body === '' ? `Killed by ${signal}` : body, output: '' };
|
|
105
|
+
return { settled: true, failure: undefined, output: body };
|
|
106
|
+
};
|
|
107
|
+
/** One session's rows as the lines `captureSession` reads. */
|
|
108
|
+
const linesOf = (session, rows, parts) => {
|
|
109
|
+
const lines = [];
|
|
110
|
+
let bad = 0;
|
|
111
|
+
const parsed = rows.flatMap((row) => {
|
|
112
|
+
const data = parse(row.data);
|
|
113
|
+
const id = str(row.id);
|
|
114
|
+
const type = str(row.type);
|
|
115
|
+
if (!isObj(data) || id === undefined || type === undefined) {
|
|
116
|
+
bad += 1;
|
|
117
|
+
return [];
|
|
118
|
+
}
|
|
119
|
+
// A fork's copied prefix is its parent's session, with the parent's usage and prompts.
|
|
120
|
+
return session.forked && COPIED.test(id) ? [] : [{ id, type, data, created: num(row.time_created) }];
|
|
121
|
+
});
|
|
122
|
+
if (rows.length > 0 && parsed.length === 0 && bad > 0)
|
|
123
|
+
throw new Error('no parseable message');
|
|
124
|
+
// The folder the session started in: the latest `directory` holds for a session that moved, so the first switch's `previous` names the earlier one.
|
|
125
|
+
const firstSwitch = parsed.find((row) => row.type === 'location-switched');
|
|
126
|
+
const previous = firstSwitch !== undefined && isObj(firstSwitch.data.previous) && isObj(firstSwitch.data.previous.location) ? str(firstSwitch.data.previous.location.directory) : undefined;
|
|
127
|
+
let cwd = previous === undefined ? (session.folder ?? undefined) : platformPath(previous);
|
|
128
|
+
let clock = session.createdAt >= Y2000 ? session.createdAt : 0;
|
|
129
|
+
// A missing, relative, or earlier time says nothing about when: the latest time so far stands, so no time goes backward.
|
|
130
|
+
const stamp = (value, fallback) => {
|
|
131
|
+
const own = num(value);
|
|
132
|
+
const known = own !== undefined && own >= Y2000 ? own : fallback !== undefined && fallback >= Y2000 ? fallback : undefined;
|
|
133
|
+
if (known !== undefined && known > clock)
|
|
134
|
+
clock = known;
|
|
135
|
+
return new Date(clock).toISOString();
|
|
136
|
+
};
|
|
137
|
+
const timeOf = (data, key) => (isObj(data.time) ? data.time[key] : undefined);
|
|
138
|
+
const base = () => ({ sessionId: session.id, ...(cwd === undefined ? {} : { cwd }), ...(session.version === null ? {} : { version: session.version }) });
|
|
139
|
+
for (const row of parsed) {
|
|
140
|
+
const { id, data } = row;
|
|
141
|
+
if (row.type === 'location-switched') {
|
|
142
|
+
const to = isObj(data.location) ? str(data.location.directory) : undefined;
|
|
143
|
+
if (to !== undefined)
|
|
144
|
+
cwd = platformPath(to);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
if (row.type === 'user') {
|
|
148
|
+
const text = str(data.text);
|
|
149
|
+
const files = Array.isArray(data.files) && data.files.length > 0;
|
|
150
|
+
if (text === undefined && !files)
|
|
151
|
+
continue;
|
|
152
|
+
lines.push({ ...base(), type: 'user', uuid: id, timestamp: stamp(timeOf(data, 'created'), row.created), message: { role: 'user', content: [text === undefined ? { type: 'image' } : { type: 'text', text }] } });
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (row.type === 'shell') {
|
|
156
|
+
const command = str(data.command);
|
|
157
|
+
if (command === undefined)
|
|
158
|
+
continue;
|
|
159
|
+
const started = stamp(timeOf(data, 'created'), row.created);
|
|
160
|
+
// The operator's own `!cmd` is their turn, not the agent's: Claude Code writes the same act as a user line holding `<bash-input>`, and its output as a
|
|
161
|
+
// `<bash-stdout>`/`<bash-stderr>` line, so `assistantTurns`, `bashCmds`, and the error rates compare. It carries no tool_use and no tool_result.
|
|
162
|
+
lines.push({ ...base(), type: 'user', uuid: id, timestamp: started, message: { role: 'user', content: [{ type: 'text', text: `<bash-input>${command}</bash-input>` }] } });
|
|
163
|
+
const status = str(data.status);
|
|
164
|
+
if (status !== 'exited' && status !== 'timeout' && status !== 'killed')
|
|
165
|
+
continue;
|
|
166
|
+
const output = isObj(data.output) ? (str(data.output.output) ?? '') : '';
|
|
167
|
+
if (output === '')
|
|
168
|
+
continue;
|
|
169
|
+
const exit = num(data.exit);
|
|
170
|
+
const failed = (exit !== undefined && exit !== 0) || status !== 'exited';
|
|
171
|
+
const text = failed ? `<bash-stdout></bash-stdout><bash-stderr>${output}</bash-stderr>` : `<bash-stdout>${output}</bash-stdout><bash-stderr></bash-stderr>`;
|
|
172
|
+
lines.push({ ...base(), type: 'user', uuid: `${id}-result`, timestamp: stamp(timeOf(data, 'completed'), row.created), message: { role: 'user', content: [{ type: 'text', text }] } });
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (row.type !== 'assistant')
|
|
176
|
+
continue;
|
|
177
|
+
const model = isObj(data.model) ? str(data.model.id) : undefined;
|
|
178
|
+
const content = [];
|
|
179
|
+
const results = [];
|
|
180
|
+
const blocks = [...(parts.get(id) ?? []), ...(Array.isArray(data.content) ? data.content.filter(isObj) : [])];
|
|
181
|
+
const started = stamp(timeOf(data, 'created'), row.created);
|
|
182
|
+
for (const block of blocks) {
|
|
183
|
+
if (block.type === 'text') {
|
|
184
|
+
const text = str(block.text);
|
|
185
|
+
if (text !== undefined)
|
|
186
|
+
content.push({ type: 'text', text });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const name = block.type === 'tool' ? str(block.name) : undefined;
|
|
190
|
+
const callId = str(block.id);
|
|
191
|
+
if (name === undefined || callId === undefined)
|
|
192
|
+
continue;
|
|
193
|
+
const state = isObj(block.state) ? block.state : {};
|
|
194
|
+
const input = isObj(state.input) ? state.input : {};
|
|
195
|
+
const claude = TOOL_NAMES[name];
|
|
196
|
+
const files = claude === 'Edit' || claude === 'Write' || claude === 'Read' ? pathsOf(name, input, cwd) : [];
|
|
197
|
+
const shaped = name === 'shell'
|
|
198
|
+
? { command: str(input.command) }
|
|
199
|
+
: files[0] !== undefined
|
|
200
|
+
? { file_path: files[0] }
|
|
201
|
+
: name === 'grep' || name === 'glob'
|
|
202
|
+
? { pattern: str(input.pattern), path: str(input.path) }
|
|
203
|
+
: name === 'skill'
|
|
204
|
+
? { skill: str(input.id) }
|
|
205
|
+
: input;
|
|
206
|
+
content.push({ type: 'tool_use', id: callId, name: claude ?? name, native: name, input: Object.fromEntries(Object.entries(shaped).filter(([, value]) => value !== undefined)) });
|
|
207
|
+
const outcome = outcomeOf(name, state);
|
|
208
|
+
const time = isObj(block.time) ? block.time : {};
|
|
209
|
+
if (outcome.settled)
|
|
210
|
+
results.push({ id: callId, outcome, at: time.completed ?? time.ran });
|
|
211
|
+
// A patch names several files; the first rides the call's own block, and each other gets its own in the same response, with the same outcome.
|
|
212
|
+
for (const [index, extra] of files.slice(1).entries()) {
|
|
213
|
+
const extraId = `${callId}:${String(index + 2)}`;
|
|
214
|
+
content.push({ type: 'tool_use', id: extraId, name: 'Edit', native: name, input: { file_path: extra } });
|
|
215
|
+
if (outcome.settled)
|
|
216
|
+
results.push({ id: extraId, outcome, at: time.completed ?? time.ran });
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const tokens = isObj(data.tokens) ? data.tokens : {};
|
|
220
|
+
const cache = isObj(tokens.cache) ? tokens.cache : {};
|
|
221
|
+
const input = num(tokens.input) ?? 0;
|
|
222
|
+
const output = num(tokens.output) ?? 0;
|
|
223
|
+
const reasoning = num(tokens.reasoning) ?? 0;
|
|
224
|
+
const read = num(cache.read) ?? 0;
|
|
225
|
+
const write = num(cache.write) ?? 0;
|
|
226
|
+
// `tokens.input` is the non-cached part (core/src/session/usage.ts:12), already disjoint from the cache fields, so it is not reduced again; `output` excludes the reasoning
|
|
227
|
+
// tokens (ai/src/schema/events.ts:88-90), while Claude Code's `output_tokens` includes them, so the two are added and the reasoning is kept as `thinking`.
|
|
228
|
+
// All five zero means OpenCode recorded no figure (usage.ts:9-12 turns a missing one into zeros), so no usage is written rather than a measured zero.
|
|
229
|
+
const reported = input + output + reasoning + read + write > 0;
|
|
230
|
+
const message = { role: 'assistant', id, content };
|
|
231
|
+
if (reported)
|
|
232
|
+
message.usage = { input_tokens: input, cache_read_input_tokens: read, cache_creation_input_tokens: write, output_tokens: output + reasoning, output_tokens_details: { thinking_tokens: reasoning } };
|
|
233
|
+
if (content.length === 0 && !reported)
|
|
234
|
+
continue;
|
|
235
|
+
lines.push({ ...base(), type: 'assistant', uuid: id, requestId: id, timestamp: started, ...(model === undefined ? {} : { requestedModel: model }), message });
|
|
236
|
+
for (const [index, result] of results.entries()) {
|
|
237
|
+
lines.push({
|
|
238
|
+
...base(),
|
|
239
|
+
type: 'user',
|
|
240
|
+
uuid: `${id}-r${String(index + 1)}`,
|
|
241
|
+
timestamp: stamp(result.at, undefined),
|
|
242
|
+
// The call's start is not paired with its completion here, so no duration is claimed.
|
|
243
|
+
noDuration: true,
|
|
244
|
+
message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: result.id, is_error: result.outcome.failure !== undefined, content: result.outcome.failure ?? (result.outcome.output === '' ? '' : `Exit code 0\n${result.outcome.output}`) }] },
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return { lines, bad };
|
|
249
|
+
};
|
|
250
|
+
/** Opens the OpenCode store under `root`; undefined when it holds no database, so a machine without OpenCode is skipped silently. A database that cannot be read throws. */
|
|
251
|
+
export const openOpencodeStore = async (root) => {
|
|
252
|
+
const file = join(root, DATABASE);
|
|
253
|
+
if (!existsSync(file))
|
|
254
|
+
return undefined;
|
|
255
|
+
const db = await open(file);
|
|
256
|
+
const sessions = [];
|
|
257
|
+
let rejected = 0;
|
|
258
|
+
try {
|
|
259
|
+
const found = db
|
|
260
|
+
.prepare('select s.id as id, s.parent_id as parent_id, s.fork_session_id as fork_session_id, s.directory as directory, s.version as version, s.agent as agent, s.time_created as time_created, ' +
|
|
261
|
+
// Upstream settles a tool call, a `!cmd`, and text in place through updateMessage and part updates (projector.ts:337-339, :539-560, :1076-1113), which bump only
|
|
262
|
+
// the message and part rows, so the cursor is the newest of the three.
|
|
263
|
+
'max(s.time_updated, coalesce((select max(m.time_updated) from session_message m where m.session_id = s.id), 0), coalesce((select max(p.time_updated) from session_part p where p.session_id = s.id), 0)) as time_updated, ' +
|
|
264
|
+
'(select count(*) from session_message m where m.session_id = s.id) as messages from session_v2 s')
|
|
265
|
+
.all();
|
|
266
|
+
// Read here so a database without the message tables throws at open, where the deposit warns naming the file.
|
|
267
|
+
db.prepare('select 1 from session_part limit 1').all();
|
|
268
|
+
for (const row of found) {
|
|
269
|
+
const id = str(row.id);
|
|
270
|
+
const messages = num(row.messages) ?? 0;
|
|
271
|
+
if (id === undefined || !SAFE_ID.test(id)) {
|
|
272
|
+
rejected += 1;
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
// A session with no message was opened and left; it has nothing to capture.
|
|
276
|
+
if (messages === 0)
|
|
277
|
+
continue;
|
|
278
|
+
const directory = str(row.directory);
|
|
279
|
+
sessions.push({
|
|
280
|
+
id,
|
|
281
|
+
folder: directory === undefined ? null : platformPath(directory),
|
|
282
|
+
parent: str(row.parent_id) ?? null,
|
|
283
|
+
agent: str(row.agent) ?? null,
|
|
284
|
+
version: str(row.version) ?? null,
|
|
285
|
+
forked: str(row.fork_session_id) !== undefined,
|
|
286
|
+
createdAt: num(row.time_created) ?? 0,
|
|
287
|
+
size: messages,
|
|
288
|
+
mtimeMs: num(row.time_updated) ?? 0,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
catch (error) {
|
|
293
|
+
db.close();
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
sessions.sort((a, b) => a.mtimeMs - b.mtimeMs || a.id.localeCompare(b.id));
|
|
297
|
+
return {
|
|
298
|
+
sessions,
|
|
299
|
+
rejected,
|
|
300
|
+
close: () => {
|
|
301
|
+
db.close();
|
|
302
|
+
},
|
|
303
|
+
transcript: (session) => {
|
|
304
|
+
const rows = db.prepare('select id, type, time_created, data from session_message where session_id = ? order by seq').all(session.id);
|
|
305
|
+
const parts = new Map();
|
|
306
|
+
for (const row of db.prepare('select message_id, type, data from session_part where session_id = ? order by message_id, ordinal').all(session.id)) {
|
|
307
|
+
const messageId = str(row.message_id);
|
|
308
|
+
const data = parse(row.data);
|
|
309
|
+
if (messageId === undefined || !isObj(data))
|
|
310
|
+
continue;
|
|
311
|
+
parts.set(messageId, [...(parts.get(messageId) ?? []), { type: str(row.type), ...data }]);
|
|
312
|
+
}
|
|
313
|
+
const { lines, bad } = linesOf(session, rows, parts);
|
|
314
|
+
// A row that is not JSON is kept as a line that is not JSON, so `captureSession` counts it and the deposit line does not under-report.
|
|
315
|
+
const text = [...lines.map((line) => JSON.stringify(line)), ...Array.from({ length: bad }, () => '<unreadable session_message row>')];
|
|
316
|
+
return text.join('\n') + (text.length === 0 ? '' : '\n');
|
|
317
|
+
},
|
|
318
|
+
};
|
|
319
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/** The folder of OTLP JSON trace files for an environment; a test passes its own so it never reads a real one. */
|
|
2
|
+
export declare const otelRoot: (env: {
|
|
3
|
+
[name: string]: string | undefined;
|
|
4
|
+
}, home?: string, platform?: NodeJS.Platform) => string;
|
|
5
|
+
type Obj = {
|
|
6
|
+
[key: string]: unknown;
|
|
7
|
+
};
|
|
8
|
+
interface Span {
|
|
9
|
+
trace: string;
|
|
10
|
+
id: string;
|
|
11
|
+
parent: string | undefined;
|
|
12
|
+
name: string;
|
|
13
|
+
start: number | undefined;
|
|
14
|
+
end: number | undefined;
|
|
15
|
+
failed: boolean;
|
|
16
|
+
statusMessage: string | undefined;
|
|
17
|
+
attrs: Obj;
|
|
18
|
+
events: Obj[];
|
|
19
|
+
framework: string;
|
|
20
|
+
/** Position in the input, so spans with the same start keep the order they were written in. */
|
|
21
|
+
order: number;
|
|
22
|
+
/** The file the span was read from, blamed when its session cannot be read. */
|
|
23
|
+
file: string;
|
|
24
|
+
}
|
|
25
|
+
/** One session as the lines `captureSession` reads. */
|
|
26
|
+
export interface OtelSession {
|
|
27
|
+
id: string;
|
|
28
|
+
size: number;
|
|
29
|
+
mtimeMs: number;
|
|
30
|
+
lines: Obj[];
|
|
31
|
+
subagent: {
|
|
32
|
+
parent: string;
|
|
33
|
+
agentType: string | null;
|
|
34
|
+
spawnedBy: string | null;
|
|
35
|
+
} | undefined;
|
|
36
|
+
file: string;
|
|
37
|
+
}
|
|
38
|
+
/** The sessions of a set of spans (every trace of every file). */
|
|
39
|
+
export declare const otelSessions: (spans: readonly Span[]) => OtelSession[];
|
|
40
|
+
/** A trace session found in the folder, before its lines are read. */
|
|
41
|
+
export interface OtelFound {
|
|
42
|
+
id: string;
|
|
43
|
+
/** Always null: a trace names no working folder. */
|
|
44
|
+
folder: null;
|
|
45
|
+
size: number;
|
|
46
|
+
mtimeMs: number;
|
|
47
|
+
subagent: OtelSession['subagent'];
|
|
48
|
+
file: string;
|
|
49
|
+
}
|
|
50
|
+
export interface OtelStore {
|
|
51
|
+
sessions: OtelFound[];
|
|
52
|
+
transcript: (session: OtelFound) => string;
|
|
53
|
+
rejected: number;
|
|
54
|
+
unreadable: {
|
|
55
|
+
file: string;
|
|
56
|
+
reason: string;
|
|
57
|
+
}[];
|
|
58
|
+
}
|
|
59
|
+
export declare const openOtelStore: (root: string) => Promise<OtelStore | undefined>;
|
|
60
|
+
export {};
|