@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.
- package/README.md +15 -0
- package/bin/thatch +157 -0
- package/docs/dev/README.md +10 -3
- package/docs/dev/features/README.md +1 -0
- package/docs/dev/features/multi-host.md +16 -10
- package/docs/dev/features/qa-system.md +3 -3
- package/docs/dev/features/watchers.md +161 -0
- package/docs/dev/mcp-parity.md +1 -1
- package/docs/dev/skills.md +7 -5
- package/docs/user/README.md +24 -0
- package/docs/user/watchers.md +91 -0
- package/package.json +1 -1
- package/src/index.ts +61 -2
- package/src/mcp.ts +4 -1
- package/src/prompts.ts +48 -9
- package/src/session-db.ts +452 -0
- package/src/skills.ts +22 -46
- package/src/tool-defs.ts +339 -3
- package/src/tools.ts +11 -3
- package/src/watchers.ts +787 -0
|
@@ -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
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
//
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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
|
-
*
|
|
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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
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
|
-
//
|
|
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
|
}
|