@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/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/watchers.md +150 -0
- package/docs/dev/mcp-parity.md +1 -1
- package/docs/dev/skills.md +6 -4
- package/docs/user/README.md +24 -0
- package/docs/user/watchers.md +79 -0
- package/package.json +1 -1
- package/src/index.ts +61 -2
- package/src/mcp.ts +4 -1
- package/src/prompts.ts +43 -7
- package/src/session-db.ts +452 -0
- package/src/skills.ts +22 -46
- package/src/tool-defs.ts +269 -3
- package/src/tools.ts +11 -3
- package/src/watchers.ts +511 -0
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"
|
|
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
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
+
}
|