selftune 0.1.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/CHANGELOG.md +23 -0
- package/README.md +259 -0
- package/bin/selftune.cjs +29 -0
- package/cli/selftune/constants.ts +71 -0
- package/cli/selftune/eval/hooks-to-evals.ts +422 -0
- package/cli/selftune/evolution/audit.ts +44 -0
- package/cli/selftune/evolution/deploy-proposal.ts +244 -0
- package/cli/selftune/evolution/evolve.ts +406 -0
- package/cli/selftune/evolution/extract-patterns.ts +145 -0
- package/cli/selftune/evolution/propose-description.ts +146 -0
- package/cli/selftune/evolution/rollback.ts +242 -0
- package/cli/selftune/evolution/stopping-criteria.ts +69 -0
- package/cli/selftune/evolution/validate-proposal.ts +137 -0
- package/cli/selftune/grading/grade-session.ts +459 -0
- package/cli/selftune/hooks/prompt-log.ts +52 -0
- package/cli/selftune/hooks/session-stop.ts +54 -0
- package/cli/selftune/hooks/skill-eval.ts +73 -0
- package/cli/selftune/index.ts +104 -0
- package/cli/selftune/ingestors/codex-rollout.ts +416 -0
- package/cli/selftune/ingestors/codex-wrapper.ts +332 -0
- package/cli/selftune/ingestors/opencode-ingest.ts +565 -0
- package/cli/selftune/init.ts +297 -0
- package/cli/selftune/monitoring/watch.ts +328 -0
- package/cli/selftune/observability.ts +255 -0
- package/cli/selftune/types.ts +255 -0
- package/cli/selftune/utils/jsonl.ts +75 -0
- package/cli/selftune/utils/llm-call.ts +192 -0
- package/cli/selftune/utils/logging.ts +40 -0
- package/cli/selftune/utils/schema-validator.ts +47 -0
- package/cli/selftune/utils/seeded-random.ts +31 -0
- package/cli/selftune/utils/transcript.ts +260 -0
- package/package.json +29 -0
- package/skill/SKILL.md +120 -0
- package/skill/Workflows/Doctor.md +145 -0
- package/skill/Workflows/Evals.md +193 -0
- package/skill/Workflows/Evolve.md +159 -0
- package/skill/Workflows/Grade.md +157 -0
- package/skill/Workflows/Ingest.md +159 -0
- package/skill/Workflows/Initialize.md +125 -0
- package/skill/Workflows/Rollback.md +131 -0
- package/skill/Workflows/Watch.md +128 -0
- package/skill/references/grading-methodology.md +176 -0
- package/skill/references/invocation-taxonomy.md +144 -0
- package/skill/references/logs.md +168 -0
- package/skill/settings_snippet.json +41 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* OpenCode session ingestor: opencode-ingest.ts
|
|
4
|
+
*
|
|
5
|
+
* Ingests OpenCode session history from its SQLite database into our shared
|
|
6
|
+
* skill eval log format.
|
|
7
|
+
*
|
|
8
|
+
* OpenCode stores sessions in:
|
|
9
|
+
* ~/.local/share/opencode/opencode.db (current, SQLite, from ~Feb 2026)
|
|
10
|
+
*
|
|
11
|
+
* Older installations may still have JSON files at:
|
|
12
|
+
* ~/.local/share/opencode/storage/session/*.json
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* bun opencode-ingest.ts
|
|
16
|
+
* bun opencode-ingest.ts --since 2026-01-01
|
|
17
|
+
* bun opencode-ingest.ts --data-dir /custom/path
|
|
18
|
+
* bun opencode-ingest.ts --dry-run
|
|
19
|
+
* bun opencode-ingest.ts --force
|
|
20
|
+
* bun opencode-ingest.ts --show-schema
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { Database } from "bun:sqlite";
|
|
24
|
+
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
25
|
+
import { homedir } from "node:os";
|
|
26
|
+
import { basename, join } from "node:path";
|
|
27
|
+
import { parseArgs } from "node:util";
|
|
28
|
+
import { QUERY_LOG, SKILL_LOG, TELEMETRY_LOG } from "../constants.js";
|
|
29
|
+
import type { QueryLogRecord, SessionTelemetryRecord, SkillUsageRecord } from "../types.js";
|
|
30
|
+
import { appendJsonl, loadMarker, saveMarker } from "../utils/jsonl.js";
|
|
31
|
+
|
|
32
|
+
const XDG_DATA_HOME = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
|
|
33
|
+
const DEFAULT_DATA_DIR = join(XDG_DATA_HOME, "opencode");
|
|
34
|
+
const MARKER_FILE = join(homedir(), ".claude", "opencode_ingested_sessions.json");
|
|
35
|
+
|
|
36
|
+
const SAFE_IDENTIFIER_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
37
|
+
|
|
38
|
+
/** Validate that a string is a safe SQL identifier. Throws on invalid input. */
|
|
39
|
+
function assertSafeIdentifier(name: string): string {
|
|
40
|
+
if (!SAFE_IDENTIFIER_RE.test(name)) {
|
|
41
|
+
throw new Error(`Unsafe SQL identifier rejected: ${JSON.stringify(name)}`);
|
|
42
|
+
}
|
|
43
|
+
return name;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const OPENCODE_SKILLS_DIRS = [
|
|
47
|
+
join(process.cwd(), ".opencode", "skills"),
|
|
48
|
+
join(homedir(), ".config", "opencode", "skills"),
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Return skill names from OpenCode skill directories. */
|
|
52
|
+
export function findSkillNames(dirs: string[] = OPENCODE_SKILLS_DIRS): Set<string> {
|
|
53
|
+
const names = new Set<string>();
|
|
54
|
+
for (const dir of dirs) {
|
|
55
|
+
if (!existsSync(dir)) continue;
|
|
56
|
+
for (const entry of readdirSync(dir)) {
|
|
57
|
+
const skillDir = join(dir, entry);
|
|
58
|
+
try {
|
|
59
|
+
if (statSync(skillDir).isDirectory() && existsSync(join(skillDir, "SKILL.md"))) {
|
|
60
|
+
names.add(entry);
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// skip entries that can't be stat'd (broken symlinks, permission errors, etc.)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return names;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ParsedSession {
|
|
71
|
+
timestamp: string;
|
|
72
|
+
session_id: string;
|
|
73
|
+
source: string;
|
|
74
|
+
transcript_path: string;
|
|
75
|
+
cwd: string;
|
|
76
|
+
last_user_query: string;
|
|
77
|
+
query: string;
|
|
78
|
+
tool_calls: Record<string, number>;
|
|
79
|
+
total_tool_calls: number;
|
|
80
|
+
bash_commands: string[];
|
|
81
|
+
skills_triggered: string[];
|
|
82
|
+
assistant_turns: number;
|
|
83
|
+
errors_encountered: number;
|
|
84
|
+
transcript_chars: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Return a human-readable schema summary for --show-schema. */
|
|
88
|
+
export function getDbSchema(dbPath: string): string {
|
|
89
|
+
const db = new Database(dbPath, { readonly: true });
|
|
90
|
+
const tables = db
|
|
91
|
+
.query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
92
|
+
.all() as Array<{
|
|
93
|
+
name: string;
|
|
94
|
+
}>;
|
|
95
|
+
|
|
96
|
+
const lines: string[] = [];
|
|
97
|
+
for (const { name } of tables) {
|
|
98
|
+
const safeName = assertSafeIdentifier(name);
|
|
99
|
+
const cols = db.query(`PRAGMA table_info(${safeName})`).all() as Array<{
|
|
100
|
+
name: string;
|
|
101
|
+
type: string;
|
|
102
|
+
}>;
|
|
103
|
+
lines.push(`\nTable: ${name}`);
|
|
104
|
+
for (const col of cols) {
|
|
105
|
+
lines.push(` ${col.name.padEnd(30)} ${col.type}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
db.close();
|
|
109
|
+
return lines.join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Normalize raw message content into an array of content blocks. */
|
|
113
|
+
function normalizeContent(rawContent: unknown): Array<Record<string, unknown>> {
|
|
114
|
+
let content: unknown;
|
|
115
|
+
if (typeof rawContent === "string") {
|
|
116
|
+
try {
|
|
117
|
+
content = JSON.parse(rawContent);
|
|
118
|
+
} catch {
|
|
119
|
+
content = [{ type: "text", text: rawContent }];
|
|
120
|
+
}
|
|
121
|
+
} else {
|
|
122
|
+
content = rawContent;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (typeof content === "string") {
|
|
126
|
+
return [{ type: "text", text: content }];
|
|
127
|
+
}
|
|
128
|
+
if (Array.isArray(content)) {
|
|
129
|
+
return content.filter((b): b is Record<string, unknown> => typeof b === "object" && b !== null);
|
|
130
|
+
}
|
|
131
|
+
if (typeof content === "object" && content !== null) {
|
|
132
|
+
return [content as Record<string, unknown>];
|
|
133
|
+
}
|
|
134
|
+
return [];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Read OpenCode sessions from SQLite database.
|
|
139
|
+
*/
|
|
140
|
+
export function readSessionsFromSqlite(
|
|
141
|
+
dbPath: string,
|
|
142
|
+
sinceTs: number | null,
|
|
143
|
+
skillNames: Set<string>,
|
|
144
|
+
): ParsedSession[] {
|
|
145
|
+
const db = new Database(dbPath, { readonly: true });
|
|
146
|
+
|
|
147
|
+
// Detect available tables
|
|
148
|
+
const tableRows = db.query("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{
|
|
149
|
+
name: string;
|
|
150
|
+
}>;
|
|
151
|
+
const tables = new Set(tableRows.map((r) => r.name));
|
|
152
|
+
|
|
153
|
+
const sessionsTable = [...tables].find((t) => t.toLowerCase().includes("session"));
|
|
154
|
+
const messagesTable = [...tables].find((t) => t.toLowerCase().includes("message"));
|
|
155
|
+
|
|
156
|
+
if (!sessionsTable || !messagesTable) {
|
|
157
|
+
console.warn(`[WARN] Could not find session/message tables in ${dbPath}`);
|
|
158
|
+
console.warn(` Available tables: ${[...tables].sort().join(", ")}`);
|
|
159
|
+
db.close();
|
|
160
|
+
return [];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const safeSessionsTable = assertSafeIdentifier(sessionsTable);
|
|
164
|
+
const safeMessagesTable = assertSafeIdentifier(messagesTable);
|
|
165
|
+
|
|
166
|
+
// Get sessions
|
|
167
|
+
let whereClause = "";
|
|
168
|
+
const queryParams: unknown[] = [];
|
|
169
|
+
if (sinceTs) {
|
|
170
|
+
whereClause = "WHERE created > ?";
|
|
171
|
+
queryParams.push(Math.floor(sinceTs * 1000));
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
let sessionRows: Array<Record<string, unknown>>;
|
|
175
|
+
try {
|
|
176
|
+
sessionRows = db
|
|
177
|
+
.query(`SELECT * FROM ${safeSessionsTable} ${whereClause} ORDER BY created ASC`)
|
|
178
|
+
.all(...queryParams) as Array<Record<string, unknown>>;
|
|
179
|
+
} catch (e) {
|
|
180
|
+
console.warn(`[WARN] Could not query sessions: ${e}`);
|
|
181
|
+
db.close();
|
|
182
|
+
return [];
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const parsedSessions: ParsedSession[] = [];
|
|
186
|
+
|
|
187
|
+
for (const sessionRow of sessionRows) {
|
|
188
|
+
const sessionId = String(sessionRow.id);
|
|
189
|
+
const createdMs = sessionRow.created as number;
|
|
190
|
+
const timestamp = new Date(createdMs).toISOString();
|
|
191
|
+
|
|
192
|
+
// Get messages for this session
|
|
193
|
+
let msgRows: Array<Record<string, unknown>>;
|
|
194
|
+
try {
|
|
195
|
+
msgRows = db
|
|
196
|
+
.query(`SELECT * FROM ${safeMessagesTable} WHERE session_id = ? ORDER BY created ASC`)
|
|
197
|
+
.all(sessionRow.id) as Array<Record<string, unknown>>;
|
|
198
|
+
} catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
let firstUserQuery = "";
|
|
203
|
+
const toolCalls: Record<string, number> = {};
|
|
204
|
+
const bashCommands: string[] = [];
|
|
205
|
+
const skillsTriggered: string[] = [];
|
|
206
|
+
let errors = 0;
|
|
207
|
+
let assistantTurns = 0;
|
|
208
|
+
|
|
209
|
+
for (const msg of msgRows) {
|
|
210
|
+
const role = (msg.role as string) ?? "";
|
|
211
|
+
const blocks = normalizeContent(msg.content ?? "[]");
|
|
212
|
+
|
|
213
|
+
if (role === "user") {
|
|
214
|
+
if (!firstUserQuery) {
|
|
215
|
+
for (const block of blocks) {
|
|
216
|
+
if (block.type === "text") {
|
|
217
|
+
const text = ((block.text as string) ?? "").trim();
|
|
218
|
+
if (text && text.length >= 4) {
|
|
219
|
+
firstUserQuery = text;
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// Fallback: join all text blocks
|
|
225
|
+
if (!firstUserQuery) {
|
|
226
|
+
const texts = blocks
|
|
227
|
+
.filter((b) => b.type === "text")
|
|
228
|
+
.map((b) => ((b.text as string) ?? "").trim())
|
|
229
|
+
.filter((t) => t.length > 0);
|
|
230
|
+
firstUserQuery = texts.join(" ").trim();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
} else if (role === "assistant") {
|
|
234
|
+
assistantTurns += 1;
|
|
235
|
+
for (const block of blocks) {
|
|
236
|
+
const blockType = (block.type as string) ?? "";
|
|
237
|
+
|
|
238
|
+
// Anthropic tool use format
|
|
239
|
+
if (blockType === "tool_use") {
|
|
240
|
+
const toolName = (block.name as string) ?? "unknown";
|
|
241
|
+
toolCalls[toolName] = (toolCalls[toolName] ?? 0) + 1;
|
|
242
|
+
const inp = (block.input as Record<string, unknown>) ?? {};
|
|
243
|
+
|
|
244
|
+
if (["Bash", "bash", "execute_bash"].includes(toolName)) {
|
|
245
|
+
const cmd = ((inp.command as string) ?? (inp.cmd as string) ?? "").trim();
|
|
246
|
+
if (cmd) bashCommands.push(cmd);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Skill detection: file reads of SKILL.md
|
|
250
|
+
if (["Read", "read_file"].includes(toolName)) {
|
|
251
|
+
const filePath = (inp.file_path as string) ?? (inp.path as string) ?? "";
|
|
252
|
+
if (basename(filePath).toUpperCase() === "SKILL.MD") {
|
|
253
|
+
const skillName = basename(join(filePath, ".."));
|
|
254
|
+
if (!skillsTriggered.includes(skillName)) {
|
|
255
|
+
skillsTriggered.push(skillName);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// OpenAI tool calls format
|
|
262
|
+
if (blockType === "tool_calls") {
|
|
263
|
+
const tcs = (block.tool_calls as Array<Record<string, unknown>>) ?? [];
|
|
264
|
+
for (const tc of tcs) {
|
|
265
|
+
const fn = (tc.function as Record<string, unknown>) ?? {};
|
|
266
|
+
const toolName = (fn.name as string) ?? "unknown";
|
|
267
|
+
toolCalls[toolName] = (toolCalls[toolName] ?? 0) + 1;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Check text content for skill name mentions
|
|
272
|
+
const textContent = (block.text as string) ?? "";
|
|
273
|
+
for (const skillName of skillNames) {
|
|
274
|
+
if (textContent.includes(skillName) && !skillsTriggered.includes(skillName)) {
|
|
275
|
+
skillsTriggered.push(skillName);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Count errors from tool_result blocks
|
|
282
|
+
for (const block of blocks) {
|
|
283
|
+
if (block.type === "tool_result") {
|
|
284
|
+
if (block.is_error || block.error) {
|
|
285
|
+
errors += 1;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
parsedSessions.push({
|
|
292
|
+
timestamp,
|
|
293
|
+
session_id: sessionId,
|
|
294
|
+
source: "opencode",
|
|
295
|
+
transcript_path: dbPath,
|
|
296
|
+
cwd: "",
|
|
297
|
+
last_user_query: firstUserQuery,
|
|
298
|
+
query: firstUserQuery,
|
|
299
|
+
tool_calls: toolCalls,
|
|
300
|
+
total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
|
|
301
|
+
bash_commands: bashCommands,
|
|
302
|
+
skills_triggered: skillsTriggered,
|
|
303
|
+
assistant_turns: assistantTurns,
|
|
304
|
+
errors_encountered: errors,
|
|
305
|
+
transcript_chars: 0,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
db.close();
|
|
310
|
+
return parsedSessions;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Read OpenCode sessions from legacy JSON files at:
|
|
315
|
+
* <storage_dir>/session/*.json
|
|
316
|
+
*/
|
|
317
|
+
export function readSessionsFromJsonFiles(
|
|
318
|
+
storageDir: string,
|
|
319
|
+
sinceTs: number | null,
|
|
320
|
+
skillNames: Set<string>,
|
|
321
|
+
): ParsedSession[] {
|
|
322
|
+
const sessionDir = join(storageDir, "session");
|
|
323
|
+
if (!existsSync(sessionDir)) return [];
|
|
324
|
+
|
|
325
|
+
const sessions: ParsedSession[] = [];
|
|
326
|
+
|
|
327
|
+
const jsonFiles = readdirSync(sessionDir)
|
|
328
|
+
.filter((f) => f.endsWith(".json"))
|
|
329
|
+
.sort();
|
|
330
|
+
|
|
331
|
+
for (const file of jsonFiles) {
|
|
332
|
+
const filePath = join(sessionDir, file);
|
|
333
|
+
let data: Record<string, unknown>;
|
|
334
|
+
try {
|
|
335
|
+
data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
336
|
+
} catch {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const sessionId = (data.id as string) ?? basename(file, ".json");
|
|
341
|
+
let created = (data.created as number) ?? (data.createdAt as number) ?? 0;
|
|
342
|
+
|
|
343
|
+
// Convert timestamp (may be seconds or milliseconds)
|
|
344
|
+
if (typeof created === "number" && created > 1e10) {
|
|
345
|
+
created = created / 1000;
|
|
346
|
+
}
|
|
347
|
+
if (sinceTs && created < sinceTs) continue;
|
|
348
|
+
|
|
349
|
+
const timestamp = new Date(created * 1000).toISOString();
|
|
350
|
+
const messages = (data.messages as Array<Record<string, unknown>>) ?? [];
|
|
351
|
+
|
|
352
|
+
let firstUserQuery = "";
|
|
353
|
+
const toolCalls: Record<string, number> = {};
|
|
354
|
+
const bashCommands: string[] = [];
|
|
355
|
+
const skillsTriggered: string[] = [];
|
|
356
|
+
let errors = 0;
|
|
357
|
+
let turns = 0;
|
|
358
|
+
|
|
359
|
+
for (const msg of messages) {
|
|
360
|
+
const role = (msg.role as string) ?? "";
|
|
361
|
+
const blocks = normalizeContent(msg.content ?? []);
|
|
362
|
+
|
|
363
|
+
if (role === "user" && !firstUserQuery) {
|
|
364
|
+
for (const block of blocks) {
|
|
365
|
+
if (block.type === "text") {
|
|
366
|
+
const text = ((block.text as string) ?? "").trim();
|
|
367
|
+
if (text && text.length >= 4 && !text.startsWith("tool_result")) {
|
|
368
|
+
firstUserQuery = text;
|
|
369
|
+
break;
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
} else if (role === "assistant") {
|
|
374
|
+
turns += 1;
|
|
375
|
+
for (const block of blocks) {
|
|
376
|
+
if (block.type === "tool_use") {
|
|
377
|
+
const toolName = (block.name as string) ?? "unknown";
|
|
378
|
+
toolCalls[toolName] = (toolCalls[toolName] ?? 0) + 1;
|
|
379
|
+
const inp = (block.input as Record<string, unknown>) ?? {};
|
|
380
|
+
if (["Bash", "bash"].includes(toolName)) {
|
|
381
|
+
const cmd = ((inp.command as string) ?? "").trim();
|
|
382
|
+
if (cmd) bashCommands.push(cmd);
|
|
383
|
+
}
|
|
384
|
+
if (["Read", "read_file"].includes(toolName)) {
|
|
385
|
+
const fp = (inp.file_path as string) ?? "";
|
|
386
|
+
if (basename(fp).toUpperCase() === "SKILL.MD") {
|
|
387
|
+
const sn = basename(join(fp, ".."));
|
|
388
|
+
if (!skillsTriggered.includes(sn)) {
|
|
389
|
+
skillsTriggered.push(sn);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const text = (block.text as string) ?? "";
|
|
396
|
+
for (const skillName of skillNames) {
|
|
397
|
+
if (text.includes(skillName) && !skillsTriggered.includes(skillName)) {
|
|
398
|
+
skillsTriggered.push(skillName);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Count errors from tool_result blocks (same as SQLite path)
|
|
405
|
+
for (const block of blocks) {
|
|
406
|
+
if (block.type === "tool_result") {
|
|
407
|
+
if (block.is_error || block.error) {
|
|
408
|
+
errors += 1;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
sessions.push({
|
|
415
|
+
timestamp,
|
|
416
|
+
session_id: sessionId,
|
|
417
|
+
source: "opencode_json",
|
|
418
|
+
transcript_path: filePath,
|
|
419
|
+
cwd: "",
|
|
420
|
+
last_user_query: firstUserQuery,
|
|
421
|
+
query: firstUserQuery,
|
|
422
|
+
tool_calls: toolCalls,
|
|
423
|
+
total_tool_calls: Object.values(toolCalls).reduce((a, b) => a + b, 0),
|
|
424
|
+
bash_commands: bashCommands,
|
|
425
|
+
skills_triggered: skillsTriggered,
|
|
426
|
+
assistant_turns: turns,
|
|
427
|
+
errors_encountered: errors,
|
|
428
|
+
transcript_chars: statSync(filePath).size,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return sessions;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Write a parsed session to our shared logs. */
|
|
436
|
+
export function writeSession(
|
|
437
|
+
session: ParsedSession,
|
|
438
|
+
dryRun = false,
|
|
439
|
+
queryLogPath: string = QUERY_LOG,
|
|
440
|
+
telemetryLogPath: string = TELEMETRY_LOG,
|
|
441
|
+
skillLogPath: string = SKILL_LOG,
|
|
442
|
+
): void {
|
|
443
|
+
const { query: prompt, session_id: sessionId, skills_triggered: skills } = session;
|
|
444
|
+
|
|
445
|
+
if (dryRun) {
|
|
446
|
+
console.log(
|
|
447
|
+
` [DRY] session=${sessionId.slice(0, 12)}... turns=${session.assistant_turns} skills=${JSON.stringify(skills)}`,
|
|
448
|
+
);
|
|
449
|
+
if (prompt) console.log(` query: ${prompt.slice(0, 80)}`);
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (prompt && prompt.length >= 4) {
|
|
454
|
+
const queryRecord: QueryLogRecord = {
|
|
455
|
+
timestamp: session.timestamp,
|
|
456
|
+
session_id: sessionId,
|
|
457
|
+
query: prompt,
|
|
458
|
+
source: session.source,
|
|
459
|
+
};
|
|
460
|
+
appendJsonl(queryLogPath, queryRecord, "all_queries");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const { query: _q, ...telemetry } = session;
|
|
464
|
+
appendJsonl(telemetryLogPath, telemetry, "session_telemetry");
|
|
465
|
+
|
|
466
|
+
for (const skillName of skills) {
|
|
467
|
+
const skillRecord: SkillUsageRecord = {
|
|
468
|
+
timestamp: session.timestamp,
|
|
469
|
+
session_id: sessionId,
|
|
470
|
+
skill_name: skillName,
|
|
471
|
+
skill_path: `(opencode:${skillName})`,
|
|
472
|
+
query: prompt,
|
|
473
|
+
triggered: true,
|
|
474
|
+
source: session.source,
|
|
475
|
+
};
|
|
476
|
+
appendJsonl(skillLogPath, skillRecord, "skill_usage");
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// --- CLI main ---
|
|
481
|
+
export function cliMain(): void {
|
|
482
|
+
const { values } = parseArgs({
|
|
483
|
+
options: {
|
|
484
|
+
"data-dir": { type: "string", default: DEFAULT_DATA_DIR },
|
|
485
|
+
since: { type: "string" },
|
|
486
|
+
"dry-run": { type: "boolean", default: false },
|
|
487
|
+
force: { type: "boolean", default: false },
|
|
488
|
+
"show-schema": { type: "boolean", default: false },
|
|
489
|
+
verbose: { type: "boolean", short: "v", default: false },
|
|
490
|
+
},
|
|
491
|
+
strict: true,
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
|
|
495
|
+
const dbPath = join(dataDir, "opencode.db");
|
|
496
|
+
const storageDir = join(dataDir, "storage");
|
|
497
|
+
|
|
498
|
+
if (values["show-schema"]) {
|
|
499
|
+
if (existsSync(dbPath)) {
|
|
500
|
+
console.log(getDbSchema(dbPath));
|
|
501
|
+
} else {
|
|
502
|
+
console.log(`No database found at ${dbPath}`);
|
|
503
|
+
}
|
|
504
|
+
process.exit(0);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (!existsSync(dataDir)) {
|
|
508
|
+
console.log(`OpenCode data directory not found: ${dataDir}`);
|
|
509
|
+
console.log("Is OpenCode installed? Try --data-dir to specify a custom location.");
|
|
510
|
+
process.exit(1);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
let sinceTs: number | null = null;
|
|
514
|
+
if (values.since) {
|
|
515
|
+
const parsed = new Date(`${values.since}T00:00:00Z`);
|
|
516
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
517
|
+
console.error(`[ERROR] Invalid --since date: "${values.since}". Use YYYY-MM-DD format.`);
|
|
518
|
+
process.exit(1);
|
|
519
|
+
}
|
|
520
|
+
sinceTs = parsed.getTime() / 1000;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const skillNames = findSkillNames();
|
|
524
|
+
const alreadyIngested = values.force ? new Set<string>() : loadMarker(MARKER_FILE);
|
|
525
|
+
let allSessions: ParsedSession[] = [];
|
|
526
|
+
|
|
527
|
+
if (existsSync(dbPath)) {
|
|
528
|
+
console.log(`Reading SQLite database: ${dbPath}`);
|
|
529
|
+
allSessions = readSessionsFromSqlite(dbPath, sinceTs, skillNames);
|
|
530
|
+
} else if (existsSync(storageDir)) {
|
|
531
|
+
console.log(`Reading legacy JSON files: ${storageDir}/session/`);
|
|
532
|
+
allSessions = readSessionsFromJsonFiles(storageDir, sinceTs, skillNames);
|
|
533
|
+
} else {
|
|
534
|
+
console.log(`No OpenCode data found in ${dataDir}`);
|
|
535
|
+
console.log("Expected either opencode.db or storage/session/*.json");
|
|
536
|
+
process.exit(1);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
const pending = allSessions.filter((s) => !alreadyIngested.has(s.session_id));
|
|
540
|
+
console.log(`Found ${allSessions.length} total sessions, ${pending.length} not yet ingested.`);
|
|
541
|
+
|
|
542
|
+
const newIngested = new Set<string>();
|
|
543
|
+
let ingestedCount = 0;
|
|
544
|
+
|
|
545
|
+
for (const session of pending) {
|
|
546
|
+
if (values.verbose || values["dry-run"]) {
|
|
547
|
+
console.log(
|
|
548
|
+
` ${values["dry-run"] ? "[DRY] " : ""}Ingesting: ${session.session_id.slice(0, 12)}...`,
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
writeSession(session, values["dry-run"]);
|
|
552
|
+
newIngested.add(session.session_id);
|
|
553
|
+
ingestedCount += 1;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (!values["dry-run"]) {
|
|
557
|
+
saveMarker(MARKER_FILE, new Set([...alreadyIngested, ...newIngested]));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
console.log(`\nDone. Ingested ${ingestedCount} sessions.`);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (import.meta.main) {
|
|
564
|
+
cliMain();
|
|
565
|
+
}
|