@jeffober/thatch 0.1.35 → 0.1.36

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/src/index.ts CHANGED
@@ -21,6 +21,8 @@ import { installSkills, SHARED_SKILLS, OPENCODE_ONLY_SKILLS } from "./skills";
21
21
  import { hygieneReport } from "./hygiene";
22
22
  import { seedDefaultBehaviors } from "./seed-behaviors";
23
23
  import { startVersionChecker, stopVersionChecker, getVersionChecker, readOnDiskVersion, compareSemver } from "./version-check";
24
+ import { WatcherRegistry, ghApiRun, ghAvailable } from "./watchers";
25
+ import { watcherNotificationNudge } from "./prompts";
24
26
  import pkg from "../package.json";
25
27
 
26
28
  // ---------------------------------------------------------------------------
@@ -83,6 +85,43 @@ export const server: Plugin = async ({ client, worktree }) => {
83
85
  // non-restart.
84
86
  const runningVersion = pkg.version;
85
87
 
88
+ // Latest observed opencode session status per session ("busy" | "idle" |
89
+ // "retry"). The event hook records these; the watcher registry's
90
+ // canDeliver gate reads them so proactive notification prompts only fire
91
+ // into idle sessions - never into a turn that is already running.
92
+ const sessionStatus = new Map<string, string>();
93
+
94
+ // In-memory watcher registry for proactive event notifications (GitHub PR
95
+ // watching today). Deliberately process-scoped: no SQLite, no cross-restart
96
+ // state. See src/watchers.ts for the rationale.
97
+ //
98
+ // Delivery prompts the session with a synthetic part - the same mechanism
99
+ // opencode uses for background task completions - so a watched event
100
+ // triggers a model turn even when the user is away. Events only carry
101
+ // pointer data; the model fetches details itself with gh.
102
+ const watchers = new WatcherRegistry({
103
+ deliver: async (sessionID, events) => {
104
+ const target = events[0]?.url.match(/github\.com\/([^/]+\/[^/]+)\/pull\/(\d+)/)?.[0]
105
+ ?.replace(/github\.com\//, "")
106
+ .replace(/\/pull\//, "#") ?? "watched PR";
107
+ await client.session.promptAsync({
108
+ path: { id: sessionID },
109
+ body: {
110
+ parts: [{ type: "text", text: watcherNotificationNudge(target, events), synthetic: true }],
111
+ },
112
+ });
113
+ },
114
+ canDeliver: (sessionID) =>
115
+ !compacting.has(sessionID) && sessionStatus.get(sessionID) === "idle",
116
+ ghRunner: ghApiRun,
117
+ });
118
+ // gh presence decides whether watch_create works; checked lazily by the
119
+ // tool, but log once at startup so misconfiguration is visible in debug logs.
120
+ void ghAvailable().then((ok) => {
121
+ if (!ok) console.error("[thatch] gh CLI not found - watch tools will report unavailable");
122
+ });
123
+ watchers.start();
124
+
86
125
  // Sessions currently being compacted. chat.message nudges are skipped while
87
126
  // a session is in this set - the agent can't call tools during summary
88
127
  // generation, so a recall or extraction nudge would cause a blocked-tool
@@ -242,6 +281,7 @@ export const server: Plugin = async ({ client, worktree }) => {
242
281
  if (all.length === 0) return null;
243
282
  return extraction.buildPayload(all, repo);
244
283
  },
284
+ watcherRegistry: watchers,
245
285
  }),
246
286
 
247
287
  // 1. System prompt - always in context.
@@ -600,8 +640,13 @@ export const server: Plugin = async ({ client, worktree }) => {
600
640
  }
601
641
  return;
602
642
  }
603
- if (event.type === "session.status" && event.properties.status?.type === "idle") {
643
+ if (event.type === "session.status") {
604
644
  const sessionID = event.properties.sessionID;
645
+ const statusType = event.properties.status?.type;
646
+ // Record the latest status so the watcher registry can gate
647
+ // proactive prompt delivery on idle sessions.
648
+ if (sessionID && statusType) sessionStatus.set(sessionID, statusType);
649
+ if (statusType !== "idle") return;
605
650
  const parentID = sessionID ? childToParent.get(sessionID) : undefined;
606
651
  if (parentID && sessionID) {
607
652
  // A child session went idle. Two cases:
@@ -685,6 +730,15 @@ export const server: Plugin = async ({ client, worktree }) => {
685
730
  extracting.delete(sessionID);
686
731
  }
687
732
  }
733
+ // The session just became idle, so watcher events queued while it
734
+ // was busy can be delivered now - the poll interval may otherwise
735
+ // hold them for up to a full cycle. Best-effort; failures stay
736
+ // pending and retry on the next poll.
737
+ try {
738
+ await watchers.deliverPending();
739
+ } catch (err) {
740
+ console.error(`[thatch] watcher delivery on idle failed: ${err}`);
741
+ }
688
742
  return;
689
743
  }
690
744
  if (event.type === "session.deleted") {
@@ -699,9 +753,13 @@ export const server: Plugin = async ({ client, worktree }) => {
699
753
  parentSnapshots.delete(id);
700
754
  childMetrics.delete(id);
701
755
  extractionChildren.delete(id);
702
- // A deleted parent takes its accepted entries with it.
756
+ // A deleted parent takes its accepted entries with it, and its
757
+ // watchers die with it - the session that would receive their
758
+ // notifications no longer exists.
703
759
  extraction.completeAccepted(id);
704
760
  extracting.delete(id);
761
+ sessionStatus.delete(id);
762
+ watchers.cancelSession(id);
705
763
  return;
706
764
  }
707
765
 
@@ -739,6 +797,7 @@ export const server: Plugin = async ({ client, worktree }) => {
739
797
 
740
798
  dispose: async () => {
741
799
  stopVersionChecker();
800
+ watchers.dispose();
742
801
  db.close();
743
802
  },
744
803
  };
package/src/mcp.ts CHANGED
@@ -60,9 +60,12 @@ interface CompiledTool {
60
60
  inputSchema: Record<string, unknown>;
61
61
  }
62
62
 
63
- function compileTools(): Map<string, CompiledTool> {
63
+ export function compileTools(): Map<string, CompiledTool> {
64
64
  const map = new Map<string, CompiledTool>();
65
65
  for (const def of TOOL_DEFS) {
66
+ // opencode-only tools depend on host capabilities MCP servers don't
67
+ // have (e.g. session identity via HostToolContext) - never expose them.
68
+ if (def.opencodeOnly) continue;
66
69
  const schema = z.object(def.args);
67
70
  map.set(def.name, {
68
71
  def,
package/src/prompts.ts CHANGED
@@ -17,13 +17,15 @@ Thatch provides persistent memory across opencode sessions. Use it to persist
17
17
  knowledge so future sessions can build on what you've already learned.
18
18
 
19
19
  Tools: thatch_memory_remember, thatch_memory_recall, thatch_memory_list,
20
- thatch_memory_show, thatch_memory_forget, thatch_store_list,
21
- thatch_find_duplicates, thatch_dedup_mark_checked,
22
- thatch_extraction_done, thatch_get_extraction_payload,
23
- thatch_prediction_query, thatch_prediction_update, thatch_prediction_list,
24
- thatch_prediction_delete,
25
- thatch_behavior_codify, thatch_behavior_feedback, thatch_behavior_list,
26
- thatch_behavior_delete
20
+ thatch_memory_show, thatch_memory_forget, thatch_store_list,
21
+ thatch_find_duplicates, thatch_dedup_mark_checked,
22
+ thatch_extraction_done, thatch_get_extraction_payload,
23
+ thatch_prediction_query, thatch_prediction_update, thatch_prediction_list,
24
+ thatch_prediction_delete,
25
+ thatch_behavior_codify, thatch_behavior_feedback, thatch_behavior_list,
26
+ thatch_behavior_delete, thatch_get_session_info,
27
+ thatch_session_search, thatch_session_get,
28
+ thatch_watch_create, thatch_watch_list, thatch_watch_cancel
27
29
 
28
30
  ## Stores
29
31
 
@@ -107,6 +109,20 @@ the same prompt. If the re-dispatch also fails or times out, note the gap and
107
109
  move on. Do not retry more than once - repeated failures indicate an
108
110
  intractable prompt, not a transient hang.
109
111
 
112
+ ## Watchers
113
+
114
+ Use thatch_watch_create to watch a GitHub PR for events (comments, review
115
+ comments and replies, commits, status changes, description edits, CI check
116
+ completions). thatch polls the PR in the background and injects a
117
+ notification into this session when a watched event happens. The
118
+ notification carries pointer data only (author, URL); fetch details with
119
+ the gh CLI when you decide to act. When the user asks you to watch
120
+ something, state your handling policy for notifications out loud first
121
+ ("I'll report human comments and handle bugbot replies myself") so the
122
+ notification turn knows what to do. Treat watcher notifications like
123
+ background task completions: they are system events, not user input, and
124
+ not approval to advance other pending work.
125
+
110
126
  ## User Decision Model
111
127
 
112
128
  A statistical model of the user's decision-making preferences is maintained by
@@ -270,6 +286,10 @@ Tools are prefixed in ${host}: \`mcp__thatch__memory_remember\`,
270
286
  \`mcp__thatch__prediction_list\`, \`mcp__thatch__prediction_delete\`,
271
287
  \`mcp__thatch__behavior_codify\`, \`mcp__thatch__behavior_feedback\`,
272
288
  \`mcp__thatch__behavior_list\`, \`mcp__thatch__behavior_delete\`. Bare names used below for readability.
289
+ get_session_info, session_search, session_get, watch_create, watch_list,
290
+ and watch_cancel are intentionally absent: they are opencode-only (MCP
291
+ hosts have no session concept, session database, or proactive-prompt
292
+ channel), so do not expect them here.
273
293
 
274
294
  ## Stores
275
295
 
@@ -668,3 +688,19 @@ export function behaviorNudge(items: BehaviorNudgeItem[]): string {
668
688
  });
669
689
  return `[thatch] Situational behaviors\n${lines.join("\n")}`;
670
690
  }
691
+
692
+ /**
693
+ * Watcher notification for the poller's promptAsync delivery. Injected as a
694
+ * synthetic part that triggers a model turn. Events carry pointer data only
695
+ * (author, URL) - never external content - so the model fetches details on
696
+ * demand with the gh CLI. The wrapper text borrows the background-task
697
+ * completion framing: a system event, not user input, not approval to
698
+ * advance other pending work.
699
+ */
700
+ export function watcherNotificationNudge(target: string, events: { type: string; summary: string; url: string }[]): string {
701
+ const lines = events.map((e) => `- ${e.type}: ${e.summary} ${e.url}`);
702
+ return `[thatch] Watcher notification for ${target}
703
+ ${lines.join("\n")}
704
+
705
+ This is a system notification, not user input. Decide whether to act now or keep waiting - the user's instructions from when the watch was created govern how to handle it. Fetch details with the gh CLI if you need them. Do not treat this notification as approval to advance other pending work. If you act, tell the user what you did and why; if not, stop and wait.`;
706
+ }
@@ -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
+ }