@jeffober/thatch 0.1.35 → 0.1.37

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,452 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { existsSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ /**
7
+ * Read-only access to the opencode session database (opencode.db). opencode
8
+ * stores its conversation transcripts in three tables: `session` (one row per
9
+ * conversation), `message` (one row per user/assistant turn; `data` carries
10
+ * the role), and `part` (one row per content piece of a message; `data` is a
11
+ * JSON blob whose `type` field distinguishes text, tool, reasoning, patch,
12
+ * and step boundaries). This module is host-agnostic: the CLI subcommands use
13
+ * it directly, and the opencode-only session tools reach the same db from
14
+ * inside the plugin process.
15
+ *
16
+ * The database is opened read-only so a thatch bug can never corrupt the
17
+ * host's session history. Concurrent reads are safe - the daemon holds the
18
+ * write lock, readers never block.
19
+ */
20
+
21
+ /** The piece types emitted by list/search. step-start/step-finish are
22
+ * bookkeeping boundaries opencode writes between model calls. */
23
+ export type PartType = "text" | "tool" | "reasoning" | "patch" | "step-start" | "step-finish" | "other";
24
+
25
+ /** Decoded role from the message row's data blob ("user" or "assistant"). */
26
+ export type MessageRole = "user" | "assistant" | string;
27
+
28
+ /** One content piece of a conversation, decoded from the part table. */
29
+ export interface SessionPart {
30
+ partId: string;
31
+ messageId: string;
32
+ sessionId: string;
33
+ /** Millisecond epoch from the part row. */
34
+ timeCreated: number;
35
+ role: MessageRole;
36
+ type: PartType;
37
+ /** Raw decoded data blob - shape varies by type (see listEntries docs). */
38
+ data: Record<string, unknown>;
39
+ }
40
+
41
+ /**
42
+ * Resolves the opencode session database path. Precedence mirrors opencode
43
+ * itself: OPENCODE_DB (its documented override, read at opencode startup)
44
+ * wins, then the XDG_DATA_HOME-derived default. Returns null when no
45
+ * database file exists - the CLI turns this into a useful error and the
46
+ * session tools report unavailability.
47
+ */
48
+ export function resolveOpencodeDbPath(): string | null {
49
+ if (process.env.OPENCODE_DB) {
50
+ return existsSync(process.env.OPENCODE_DB) ? process.env.OPENCODE_DB : null;
51
+ }
52
+ const dataHome = process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
53
+ const path = join(dataHome, "opencode", "opencode.db");
54
+ return existsSync(path) ? path : null;
55
+ }
56
+
57
+ export interface SessionRow {
58
+ id: string;
59
+ title: string;
60
+ directory: string;
61
+ agent: string | null;
62
+ model: string | null;
63
+ timeCreated: number;
64
+ timeUpdated: number;
65
+ }
66
+
67
+ /** Truncate for list/search preview fields. Kept modest so a full screen of
68
+ * timeline stays scannable; `session get` returns untruncated content. */
69
+ const PREVIEW_LEN = 200;
70
+
71
+ function truncate(text: string, maxLen = PREVIEW_LEN): string {
72
+ return text.length <= maxLen ? text : text.slice(0, maxLen) + "...";
73
+ }
74
+
75
+ function toIso(ms: number): string {
76
+ return new Date(ms).toISOString();
77
+ }
78
+
79
+ /** Extract every human-readable string from a decoded part blob. Used by
80
+ * search so patterns match real content, not JSON field names or escaped
81
+ * syntax in the raw blob. */
82
+ function searchableStrings(data: Record<string, unknown>): string[] {
83
+ const out: string[] = [];
84
+ const walk = (value: unknown, depth: number): void => {
85
+ if (depth > 6) return;
86
+ if (typeof value === "string") {
87
+ out.push(value);
88
+ } else if (Array.isArray(value)) {
89
+ for (const item of value) walk(item, depth + 1);
90
+ } else if (value && typeof value === "object") {
91
+ for (const item of Object.values(value)) walk(item, depth + 1);
92
+ }
93
+ };
94
+ walk(data, 0);
95
+ return out;
96
+ }
97
+
98
+ /**
99
+ * Read-only facade over opencode.db. All methods throw when the underlying
100
+ * file is missing or has an unexpected schema - callers surface the error.
101
+ */
102
+ export class SessionDB {
103
+ readonly path: string;
104
+ #db: Database;
105
+
106
+ constructor(path: string) {
107
+ this.path = path;
108
+ this.#db = new Database(path, { readonly: true });
109
+ }
110
+
111
+ close(): void {
112
+ this.#db.close();
113
+ }
114
+
115
+ /** Fetches one session row, or null for an unknown id. */
116
+ getSession(sessionID: string): SessionRow | null {
117
+ const row = this.#db
118
+ .query(
119
+ "SELECT id, title, directory, agent, model, time_created, time_updated FROM session WHERE id = ?",
120
+ )
121
+ .get(sessionID) as
122
+ | { id: string; title: string; directory: string; agent: string | null; model: string | null; time_created: number; time_updated: number }
123
+ | undefined;
124
+ if (!row) return null;
125
+ return {
126
+ id: row.id,
127
+ title: row.title,
128
+ directory: row.directory,
129
+ agent: row.agent,
130
+ model: row.model,
131
+ timeCreated: row.time_created,
132
+ timeUpdated: row.time_updated,
133
+ };
134
+ }
135
+
136
+ /**
137
+ * Lists a session's parts in conversation order (message time, then part
138
+ * order within the message), each joined with its message role.
139
+ * afterMs/beforeMs bound the part time window (epoch ms, inclusive).
140
+ */
141
+ listParts(sessionID: string, afterMs?: number, beforeMs?: number): SessionPart[] {
142
+ const rows = this.#db
143
+ .query(
144
+ `SELECT p.id AS part_id, p.message_id, p.session_id, p.time_created, p.data AS part_data,
145
+ m.data AS message_data
146
+ FROM part p
147
+ JOIN message m ON m.id = p.message_id
148
+ WHERE p.session_id = ?
149
+ AND (? IS NULL OR p.time_created >= ?)
150
+ AND (? IS NULL OR p.time_created <= ?)
151
+ ORDER BY m.time_created, p.time_created, p.id`,
152
+ )
153
+ .all(sessionID, afterMs ?? null, afterMs ?? null, beforeMs ?? null, beforeMs ?? null) as {
154
+ part_id: string;
155
+ message_id: string;
156
+ session_id: string;
157
+ time_created: number;
158
+ part_data: string;
159
+ message_data: string;
160
+ }[];
161
+
162
+ return rows.map((row) => {
163
+ const partData = JSON.parse(row.part_data) as Record<string, unknown>;
164
+ const messageData = JSON.parse(row.message_data) as Record<string, unknown>;
165
+ return {
166
+ partId: row.part_id,
167
+ messageId: row.message_id,
168
+ sessionId: row.session_id,
169
+ timeCreated: row.time_created,
170
+ role: (messageData.role as MessageRole) ?? "unknown",
171
+ type: (partData.type as PartType) ?? "other",
172
+ data: partData,
173
+ };
174
+ });
175
+ }
176
+
177
+ /**
178
+ * Fetches one part by its part id. No session filter - part ids are
179
+ * globally unique primary keys, so a bare --id works without -s.
180
+ */
181
+ getPart(partID: string): SessionPart | null {
182
+ const row = this.#db
183
+ .query(
184
+ `SELECT p.id AS part_id, p.message_id, p.session_id, p.time_created, p.data AS part_data,
185
+ m.data AS message_data
186
+ FROM part p
187
+ JOIN message m ON m.id = p.message_id
188
+ WHERE p.id = ?`,
189
+ )
190
+ .get(partID) as
191
+ | { part_id: string; message_id: string; session_id: string; time_created: number; part_data: string; message_data: string }
192
+ | undefined;
193
+ if (!row) return null;
194
+ const partData = JSON.parse(row.part_data) as Record<string, unknown>;
195
+ const messageData = JSON.parse(row.message_data) as Record<string, unknown>;
196
+ return {
197
+ partId: row.part_id,
198
+ messageId: row.message_id,
199
+ sessionId: row.session_id,
200
+ timeCreated: row.time_created,
201
+ role: (messageData.role as MessageRole) ?? "unknown",
202
+ type: (partData.type as PartType) ?? "other",
203
+ data: partData,
204
+ };
205
+ }
206
+
207
+ /** Fetches one message row (decoded) with all of its parts in order. */
208
+ getMessage(messageID: string): { messageId: string; sessionId: string; timeCreated: number; role: MessageRole; data: Record<string, unknown>; parts: SessionPart[] } | null {
209
+ const row = this.#db
210
+ .query("SELECT id, session_id, time_created, data FROM message WHERE id = ?")
211
+ .get(messageID) as { id: string; session_id: string; time_created: number; data: string } | undefined;
212
+ if (!row) return null;
213
+ const decoded = JSON.parse(row.data) as Record<string, unknown>;
214
+ const parts = this.listParts(row.session_id).filter((p) => p.messageId === messageID);
215
+ return {
216
+ messageId: messageID,
217
+ sessionId: row.session_id,
218
+ timeCreated: row.time_created,
219
+ role: (decoded.role as MessageRole) ?? "unknown",
220
+ data: decoded,
221
+ parts,
222
+ };
223
+ }
224
+
225
+ /** Lists a session's messages in order, each with its parts. Used by the
226
+ * transcript builder, which needs per-message grouping rather than the
227
+ * flat part stream listParts produces. */
228
+ listMessages(sessionID: string): { messageId: string; sessionId: string; timeCreated: number; role: MessageRole; data: Record<string, unknown>; parts: SessionPart[] }[] {
229
+ const messages = this.#db
230
+ .query("SELECT id, session_id, time_created, data FROM message WHERE session_id = ? ORDER BY time_created, id")
231
+ .all(sessionID) as { id: string; session_id: string; time_created: number; data: string }[];
232
+ const parts = this.listParts(sessionID);
233
+ return messages.map((row) => {
234
+ const decoded = JSON.parse(row.data) as Record<string, unknown>;
235
+ return {
236
+ messageId: row.id,
237
+ sessionId: row.session_id,
238
+ timeCreated: row.time_created,
239
+ role: (decoded.role as MessageRole) ?? "unknown",
240
+ data: decoded,
241
+ parts: parts.filter((p) => p.messageId === row.id),
242
+ };
243
+ });
244
+ }
245
+
246
+ /**
247
+ * Searches decoded part content across sessions. Plain queries are
248
+ * case-insensitive substrings; regex queries use the pattern as-is.
249
+ * Scans the part table in conversation order, so results come back in
250
+ * chronological order grouped by session. sessionID narrows to one
251
+ * session; limit caps matches (applied after the scan completes, oldest
252
+ * first).
253
+ */
254
+ search(query: string, opts?: { regex?: boolean; sessionID?: string; limit?: number }): SessionPart[] {
255
+ let matcher: (s: string) => boolean;
256
+ if (opts?.regex) {
257
+ const re = new RegExp(query);
258
+ matcher = (s) => re.test(s);
259
+ } else {
260
+ const needle = query.toLowerCase();
261
+ matcher = (s) => s.toLowerCase().includes(needle);
262
+ }
263
+
264
+ const rows = (opts?.sessionID
265
+ ? this.#db
266
+ .query(
267
+ `SELECT p.id AS part_id, p.message_id, p.session_id, p.time_created, p.data AS part_data,
268
+ m.data AS message_data
269
+ FROM part p JOIN message m ON m.id = p.message_id
270
+ WHERE p.session_id = ?
271
+ ORDER BY m.time_created, p.id`,
272
+ )
273
+ .all(opts.sessionID)
274
+ : this.#db
275
+ .query(
276
+ `SELECT p.id AS part_id, p.message_id, p.session_id, p.time_created, p.data AS part_data,
277
+ m.data AS message_data
278
+ FROM part p JOIN message m ON m.id = p.message_id
279
+ ORDER BY p.session_id, m.time_created, p.time_created, p.id`,
280
+ )
281
+ .all()) as {
282
+ part_id: string;
283
+ message_id: string;
284
+ session_id: string;
285
+ time_created: number;
286
+ part_data: string;
287
+ message_data: string;
288
+ }[];
289
+
290
+ const limit = opts?.limit ?? 200;
291
+ const matches: SessionPart[] = [];
292
+ for (const row of rows) {
293
+ let decoded: Record<string, unknown>;
294
+ let role: MessageRole;
295
+ try {
296
+ decoded = JSON.parse(row.part_data) as Record<string, unknown>;
297
+ role = (JSON.parse(row.message_data) as Record<string, unknown>).role as MessageRole;
298
+ } catch {
299
+ continue;
300
+ }
301
+ if (searchableStrings(decoded).some(matcher)) {
302
+ matches.push({
303
+ partId: row.part_id,
304
+ messageId: row.message_id,
305
+ sessionId: row.session_id,
306
+ timeCreated: row.time_created,
307
+ role: role ?? "unknown",
308
+ type: (decoded.type as PartType) ?? "other",
309
+ data: decoded,
310
+ });
311
+ if (matches.length >= limit) break;
312
+ }
313
+ }
314
+ return matches;
315
+ }
316
+ }
317
+
318
+ /** Human-recognizable epoch-ms timestamp for JSONL output. */
319
+ export function iso(ms: number): string {
320
+ return toIso(ms);
321
+ }
322
+
323
+ /**
324
+ * Full-content serialization of one part (the `session get` shape). Uses the
325
+ * same snake_case field names as the timeline entries so consumers pipe
326
+ * list/search output into get without renaming. `data` is the complete
327
+ * decoded part blob - tool parts carry their full input and output here,
328
+ * untruncated.
329
+ */
330
+ export function partToFullJson(part: SessionPart): Record<string, unknown> {
331
+ return {
332
+ part_id: part.partId,
333
+ message_id: part.messageId,
334
+ session_id: part.sessionId,
335
+ timestamp: iso(part.timeCreated),
336
+ role: part.role,
337
+ type: part.type,
338
+ data: part.data,
339
+ };
340
+ }
341
+
342
+ /** Full-content serialization of one message with all of its parts. */
343
+ export function messageToFullJson(message: {
344
+ messageId: string;
345
+ sessionId: string;
346
+ timeCreated: number;
347
+ role: MessageRole;
348
+ data: Record<string, unknown>;
349
+ parts: SessionPart[];
350
+ }): Record<string, unknown> {
351
+ return {
352
+ message_id: message.messageId,
353
+ session_id: message.sessionId,
354
+ timestamp: iso(message.timeCreated),
355
+ role: message.role,
356
+ data: message.data,
357
+ parts: message.parts.map(partToFullJson),
358
+ };
359
+ }
360
+
361
+ /**
362
+ * Builds the `session list` timeline entry for one part: a flat object with
363
+ * common identity fields plus type-specific preview fields. Tool parts keep
364
+ * their callID so callers can trace a call back to its transcript row.
365
+ */
366
+ export function partToTimelineEntry(part: SessionPart): Record<string, unknown> {
367
+ const base: Record<string, unknown> = {
368
+ timestamp: iso(part.timeCreated),
369
+ msg_id: part.messageId,
370
+ part_id: part.partId,
371
+ role: part.role,
372
+ type: part.type,
373
+ };
374
+ switch (part.type) {
375
+ case "text":
376
+ case "reasoning":
377
+ base.detail = truncate(String(part.data.text ?? ""));
378
+ break;
379
+ case "tool": {
380
+ const state = (part.data.state ?? {}) as Record<string, unknown>;
381
+ const toolName = String(part.data.tool ?? "");
382
+ base.call_id = part.data.callID;
383
+ base.tool = toolName;
384
+ base.detail = toolName;
385
+ base.args_preview = truncate(JSON.stringify(state.input ?? {}));
386
+ base.output = truncate(String(state.output ?? ""));
387
+ break;
388
+ }
389
+ case "patch":
390
+ base.detail = ((part.data.files as string[]) ?? []).map((f) => f.split("/").pop()).join(", ");
391
+ break;
392
+ case "step-finish": {
393
+ const tokens = (part.data.tokens ?? {}) as Record<string, unknown>;
394
+ base.detail = `tokens: ${tokens.total ?? "?"} cost: ${part.data.cost ?? "?"}`;
395
+ break;
396
+ }
397
+ default:
398
+ base.detail = "";
399
+ }
400
+ return base;
401
+ }
402
+
403
+ /**
404
+ * Builds the `session transcript` OpenAI chat-completions entry set for one
405
+ * message. Returns 0..n lines: user messages become one {role:"user"} line;
406
+ * assistant messages become one {role:"assistant"} line (with a tool_calls
407
+ * array when the step made tool calls) followed by one {role:"tool"} line
408
+ * per tool result. Reasoning and step boundaries have no chat-completions
409
+ * equivalent and are dropped.
410
+ */
411
+ export function partToTranscriptEntries(
412
+ messageRole: MessageRole,
413
+ parts: SessionPart[],
414
+ ): Record<string, unknown>[] {
415
+ const entries: Record<string, unknown>[] = [];
416
+ if (messageRole === "user") {
417
+ const text = parts
418
+ .filter((p) => p.type === "text")
419
+ .map((p) => String(p.data.text ?? ""))
420
+ .join("\n");
421
+ if (text) entries.push({ role: "user", content: text });
422
+ return entries;
423
+ }
424
+ if (messageRole !== "assistant") return entries;
425
+
426
+ const textParts = parts.filter((p) => p.type === "text");
427
+ const toolParts = parts.filter((p) => p.type === "tool");
428
+ const content = textParts.map((p) => String(p.data.text ?? "")).join("\n");
429
+ // A message with neither text nor tool calls (e.g. reasoning-only) has no
430
+ // chat-completions representation - emit nothing rather than an empty line.
431
+ if (!content && toolParts.length === 0) return entries;
432
+ const assistant: Record<string, unknown> = { role: "assistant", content };
433
+ if (toolParts.length > 0) {
434
+ assistant.tool_calls = toolParts.map((p) => ({
435
+ id: p.data.callID,
436
+ type: "function",
437
+ function: {
438
+ name: p.data.tool,
439
+ arguments: JSON.stringify((p.data.state as Record<string, unknown>)?.input ?? {}),
440
+ },
441
+ }));
442
+ }
443
+ entries.push(assistant);
444
+ for (const p of toolParts) {
445
+ entries.push({
446
+ role: "tool",
447
+ tool_call_id: p.data.callID,
448
+ content: String((p.data.state as Record<string, unknown>)?.output ?? ""),
449
+ });
450
+ }
451
+ return entries;
452
+ }
package/src/skills.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { mkdirSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import pkg from "../package.json";
4
3
 
5
4
  export interface SkillFile {
6
5
  name: string;
@@ -73,46 +72,32 @@ export const OPENCODE_ONLY_SKILLS: SkillDef[] = loadOpencodeOnlySkills();
73
72
  // Stale skill cleanup - runs before install on every skillsDir
74
73
  // ---------------------------------------------------------------------------
75
74
 
76
- // Skills renamed from non-prefixed to thatch-prefixed in v0.1.27. The old
77
- // names are cleaned up during install for a limited version window because
78
- // non-prefixed names could be adopted by third parties after we abandon them.
79
- // After RENAME_MIGRATION_MAX_VERSION, the cleanup stops and leftover stale
80
- // files are the user's responsibility.
81
- const RENAMED_SKILLS = new Set([
82
- "pr-description",
83
- "ticket-description",
84
- "split-overlarge-pr",
85
- ]);
86
-
87
- // Last version that ships the non-prefixed-to-prefixed migration cleanup.
88
- // Users who upgrade to this version or earlier get automatic cleanup of the
89
- // old skill directories. Users who skip past this version must manually
90
- // delete the stale directories.
91
- const RENAME_MIGRATION_MAX_VERSION = "0.1.35";
92
-
93
- function compareVersions(a: string, b: string): number {
94
- const [aMaj, aMin, aPatch] = a.split(".").map(Number);
95
- const [bMaj, bMin, bPatch] = b.split(".").map(Number);
96
- if (aMaj !== bMaj) return aMaj - bMaj;
97
- if (aMin !== bMin) return aMin - bMin;
98
- return aPatch - bPatch;
99
- }
75
+ // ---------------------------------------------------------------------------
76
+ // Stale skill cleanup - runs before install on every skillsDir
77
+ // ---------------------------------------------------------------------------
78
+
79
+ // Skills renamed from non-prefixed to thatch-prefixed in v0.1.27
80
+ // (pr-description, ticket-description, split-overlarge-pr) were cleaned up
81
+ // during install through a version-gated window that closed with v0.1.35.
82
+ // The gate is gone because it can never reopen: package versions only grow,
83
+ // so this code only ever runs at a version past the window. The standing
84
+ // contract it protected remains: non-prefixed skill names belong to third
85
+ // parties and are NEVER removed - cleanup only touches thatch-* dirs, where
86
+ // the prefix is our namespace. Users upgrading from before v0.1.27 delete
87
+ // the old directories manually.
100
88
 
101
89
  /**
102
90
  * Remove stale skill directories before installing the current set.
103
91
  *
104
- * Two cleanup rules:
92
+ * One rule: delete any `thatch-*` skill directory that is not in the current
93
+ * install set. The `thatch-` prefix is our namespace - no third party should
94
+ * use it, so unconditional removal is safe. This catches skills we've
95
+ * renamed or removed in any release.
105
96
  *
106
- * 1. Always: delete any `thatch-*` skill directory that is not in the current
107
- * install set. The `thatch-` prefix is our namespace - no third party should
108
- * use it, so unconditional removal is safe. This catches skills we've
109
- * renamed or removed in any release.
110
- *
111
- * 2. Version-gated: delete directories matching old non-prefixed skill names
112
- * (pr-description, ticket-description, split-overlarge-pr) through
113
- * RENAME_MIGRATION_MAX_VERSION. These names lack the thatch- prefix and
114
- * could be adopted by third parties after we abandon them, so the cleanup
115
- * is time-boxed. Users who upgrade past the window must manually clean up.
97
+ * Non-prefixed directories are never touched. The v0.1.27 rename migration
98
+ * (pr-description -> thatch-pr-description and siblings) removed old
99
+ * non-prefixed dirs through a version window that closed with v0.1.35; see
100
+ * the block comment above.
116
101
  */
117
102
  function cleanupStaleSkills(skillsDir: string, currentNames: string[]): void {
118
103
  const currentSet = new Set(currentNames);
@@ -123,9 +108,6 @@ function cleanupStaleSkills(skillsDir: string, currentNames: string[]): void {
123
108
  return; // dir doesn't exist or isn't readable - nothing to clean
124
109
  }
125
110
 
126
- const withinMigrationWindow =
127
- compareVersions(pkg.version, RENAME_MIGRATION_MAX_VERSION) <= 0;
128
-
129
111
  for (const entry of entries) {
130
112
  // isDirectory() returns false for symlinks (even symlinked dirs), so
131
113
  // check isSymbolicLink() too - a symlinked skill dir is just as stale
@@ -149,15 +131,9 @@ function cleanupStaleSkills(skillsDir: string, currentNames: string[]): void {
149
131
  }
150
132
  };
151
133
 
152
- // Rule 1: stale thatch-* dirs (our namespace, always safe to remove).
134
+ // Stale thatch-* dirs: our namespace, always safe to remove.
153
135
  if (name.startsWith("thatch-") && !currentSet.has(name)) {
154
136
  remove();
155
- continue;
156
- }
157
-
158
- // Rule 2: renamed non-prefixed dirs (version-gated, third-party risk).
159
- if (RENAMED_SKILLS.has(name) && withinMigrationWindow) {
160
- remove();
161
137
  }
162
138
  }
163
139
  }