ocwatch 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/client/dist/assets/{index-BYMVif3u.js → index-CzAaOuyw.js} +14 -14
- package/src/client/dist/index.html +1 -1
- package/src/server/__tests__/helpers/testDb.ts +220 -0
- package/src/server/index.ts +20 -5
- package/src/server/logic/activityLogic.ts +260 -0
- package/src/server/logic/index.ts +2 -0
- package/src/server/logic/sessionLogic.ts +107 -0
- package/src/server/routes/parts.ts +9 -7
- package/src/server/routes/poll.ts +34 -45
- package/src/server/routes/projects.ts +4 -4
- package/src/server/routes/sessions.ts +107 -68
- package/src/server/routes/sse.ts +10 -4
- package/src/server/services/parsing.ts +211 -0
- package/src/server/services/pollService.ts +292 -114
- package/src/server/services/sessionService.ts +178 -106
- package/src/server/storage/db.ts +71 -0
- package/src/server/storage/index.ts +22 -0
- package/src/server/storage/queries.ts +325 -0
- package/src/server/utils/projectResolver.ts +2 -2
- package/src/server/utils/sessionStatus.ts +4 -70
- package/src/server/watcher.ts +187 -82
- package/src/shared/constants.ts +1 -0
- package/src/shared/types/index.ts +39 -5
- package/src/server/storage/messageParser.ts +0 -169
- package/src/server/storage/partParser.ts +0 -532
- package/src/server/storage/sessionParser.ts +0 -180
|
@@ -1,532 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Part Parser - Parse OpenCode part JSON files (lazy loading)
|
|
3
|
-
* Reads from ~/.local/share/opencode/storage/part/{partID}.json
|
|
4
|
-
*
|
|
5
|
-
* IMPORTANT: With 25,748+ part files, this parser uses lazy loading.
|
|
6
|
-
* Only load individual part files on demand, never read all at once.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import { readFile, readdir } from "node:fs/promises";
|
|
10
|
-
import { join } from "node:path";
|
|
11
|
-
import type { PartMeta, ToolCallSummary } from "../../shared/types";
|
|
12
|
-
import { getStoragePath } from "./sessionParser";
|
|
13
|
-
import { listMessages } from "./messageParser";
|
|
14
|
-
|
|
15
|
-
interface PartStateJSON {
|
|
16
|
-
status?: string;
|
|
17
|
-
input?: Record<string, unknown>;
|
|
18
|
-
output?: string;
|
|
19
|
-
error?: string;
|
|
20
|
-
title?: string;
|
|
21
|
-
time?: {
|
|
22
|
-
start: number;
|
|
23
|
-
end?: number;
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
interface PartJSON {
|
|
28
|
-
id: string;
|
|
29
|
-
sessionID: string;
|
|
30
|
-
messageID: string;
|
|
31
|
-
type: string;
|
|
32
|
-
callID?: string;
|
|
33
|
-
tool?: string;
|
|
34
|
-
state?: string | PartStateJSON;
|
|
35
|
-
text?: string;
|
|
36
|
-
time?: {
|
|
37
|
-
start: number;
|
|
38
|
-
end?: number;
|
|
39
|
-
};
|
|
40
|
-
snapshot?: string;
|
|
41
|
-
reason?: string;
|
|
42
|
-
files?: string[];
|
|
43
|
-
input?: Record<string, unknown>;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Parse a single part JSON file (lazy loading)
|
|
48
|
-
* @param filePath - Absolute path to part JSON file
|
|
49
|
-
* @returns PartMeta or null if file doesn't exist or is invalid
|
|
50
|
-
*/
|
|
51
|
-
export async function parsePart(filePath: string): Promise<PartMeta | null> {
|
|
52
|
-
try {
|
|
53
|
-
const content = await readFile(filePath, "utf-8");
|
|
54
|
-
const json: PartJSON = JSON.parse(content);
|
|
55
|
-
|
|
56
|
-
let state: string | undefined;
|
|
57
|
-
let input: PartMeta["input"];
|
|
58
|
-
let title: string | undefined;
|
|
59
|
-
|
|
60
|
-
if (typeof json.state === "string") {
|
|
61
|
-
state = json.state;
|
|
62
|
-
} else if (json.state && typeof json.state === "object") {
|
|
63
|
-
state = json.state.status;
|
|
64
|
-
title = json.state.title;
|
|
65
|
-
if (json.state.input) {
|
|
66
|
-
input = { ...json.state.input };
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
if (!input && json.input) {
|
|
71
|
-
input = { ...json.input };
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
let error: string | undefined;
|
|
75
|
-
if (state === "error" || state === "failed") {
|
|
76
|
-
if (typeof json.state === "object" && json.state !== null) {
|
|
77
|
-
const errorText = json.state.error || json.state.output;
|
|
78
|
-
if (errorText && typeof errorText === "string") {
|
|
79
|
-
error = errorText.length > 500 ? errorText.slice(0, 500) : errorText;
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Time can be at top level (json.time) or inside state object (json.state.time) for tool parts
|
|
85
|
-
let timeStart: number | undefined;
|
|
86
|
-
let timeEnd: number | undefined;
|
|
87
|
-
|
|
88
|
-
if (json.time) {
|
|
89
|
-
timeStart = json.time.start;
|
|
90
|
-
timeEnd = json.time.end;
|
|
91
|
-
} else if (typeof json.state === "object" && json.state !== null && json.state.time) {
|
|
92
|
-
timeStart = json.state.time.start;
|
|
93
|
-
timeEnd = json.state.time.end;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const startedAt = timeStart ? new Date(timeStart) : undefined;
|
|
97
|
-
const completedAt = timeEnd ? new Date(timeEnd) : undefined;
|
|
98
|
-
|
|
99
|
-
return {
|
|
100
|
-
id: json.id,
|
|
101
|
-
sessionID: json.sessionID,
|
|
102
|
-
messageID: json.messageID,
|
|
103
|
-
type: json.type,
|
|
104
|
-
callID: json.callID,
|
|
105
|
-
tool: json.tool,
|
|
106
|
-
state,
|
|
107
|
-
input,
|
|
108
|
-
title,
|
|
109
|
-
error,
|
|
110
|
-
startedAt,
|
|
111
|
-
completedAt,
|
|
112
|
-
stepSnapshot: json.snapshot,
|
|
113
|
-
stepFinishReason: json.reason === "stop" || json.reason === "tool-calls" ? json.reason : undefined,
|
|
114
|
-
reasoningText: json.type === "reasoning" ? json.text : undefined,
|
|
115
|
-
patchFiles: json.files,
|
|
116
|
-
};
|
|
117
|
-
} catch (error) {
|
|
118
|
-
return null;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Get a specific part by partID (lazy loading)
|
|
124
|
-
* @param partID - Part ID
|
|
125
|
-
* @param storagePath - Optional custom storage path (defaults to XDG path)
|
|
126
|
-
* @returns PartMeta or null if not found
|
|
127
|
-
*/
|
|
128
|
-
export async function getPart(
|
|
129
|
-
partID: string,
|
|
130
|
-
storagePath?: string
|
|
131
|
-
): Promise<PartMeta | null> {
|
|
132
|
-
const basePath = storagePath || getStoragePath();
|
|
133
|
-
const filePath = join(
|
|
134
|
-
basePath,
|
|
135
|
-
"opencode",
|
|
136
|
-
"storage",
|
|
137
|
-
"part",
|
|
138
|
-
`${partID}.json`
|
|
139
|
-
);
|
|
140
|
-
|
|
141
|
-
return parsePart(filePath);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
const MAX_PATH_LENGTH = 40;
|
|
145
|
-
|
|
146
|
-
function truncatePath(path: string): string {
|
|
147
|
-
if (path.length <= MAX_PATH_LENGTH) {
|
|
148
|
-
return path;
|
|
149
|
-
}
|
|
150
|
-
return "..." + path.slice(-MAX_PATH_LENGTH + 3);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
|
154
|
-
read: "Reading",
|
|
155
|
-
write: "Writing",
|
|
156
|
-
edit: "Editing",
|
|
157
|
-
bash: "Running",
|
|
158
|
-
grep: "Searching",
|
|
159
|
-
glob: "Finding",
|
|
160
|
-
task: "Delegating",
|
|
161
|
-
webfetch: "Fetching",
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
function getToolDisplayName(tool: string): string {
|
|
165
|
-
const normalized = tool.replace(/^mcp_/, "").toLowerCase();
|
|
166
|
-
return TOOL_DISPLAY_NAMES[normalized] || tool;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export function formatCurrentAction(part: PartMeta): string | null {
|
|
170
|
-
if (!part.tool) {
|
|
171
|
-
return null;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
const toolName = getToolDisplayName(part.tool);
|
|
175
|
-
|
|
176
|
-
// Handle specific tools FIRST (before generic input field matching)
|
|
177
|
-
|
|
178
|
-
// Handle delegate_task (tool name is "task" or "delegate_task")
|
|
179
|
-
if (part.tool === "task" || part.tool === "delegate_task") {
|
|
180
|
-
const input = part.input as { description?: string; subagent_type?: string } | undefined;
|
|
181
|
-
const desc = input?.description;
|
|
182
|
-
const agentType = input?.subagent_type;
|
|
183
|
-
if (desc && agentType) return `${desc} (${agentType})`;
|
|
184
|
-
if (desc) return desc;
|
|
185
|
-
if (agentType) return `Delegating (${agentType})`;
|
|
186
|
-
return "Delegating task";
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// Handle todowrite
|
|
190
|
-
if (part.tool === "todowrite") {
|
|
191
|
-
const input = part.input as { todos?: Array<{ content?: string }> } | undefined;
|
|
192
|
-
const todos = input?.todos;
|
|
193
|
-
if (!todos || todos.length === 0) return "Cleared todos";
|
|
194
|
-
const preview = todos
|
|
195
|
-
.slice(0, 2)
|
|
196
|
-
.map(t => (t.content || "").slice(0, 30))
|
|
197
|
-
.filter(Boolean)
|
|
198
|
-
.join(", ");
|
|
199
|
-
return `Updated ${todos.length} todo${todos.length !== 1 ? "s" : ""}: ${preview}${todos.length > 2 ? "..." : ""}`;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
// Handle todoread
|
|
203
|
-
if (part.tool === "todoread") {
|
|
204
|
-
return "Reading todos";
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
// Generic input field matching (fallback for other tools)
|
|
208
|
-
if (part.input) {
|
|
209
|
-
if (part.input.filePath) {
|
|
210
|
-
return `${toolName} ${truncatePath(part.input.filePath)}`;
|
|
211
|
-
}
|
|
212
|
-
if (part.input.command) {
|
|
213
|
-
const cmd = part.input.command.length > 30
|
|
214
|
-
? part.input.command.slice(0, 27) + "..."
|
|
215
|
-
: part.input.command;
|
|
216
|
-
return `${toolName} ${cmd}`;
|
|
217
|
-
}
|
|
218
|
-
if (part.input.pattern) {
|
|
219
|
-
return `${toolName} for "${part.input.pattern}"`;
|
|
220
|
-
}
|
|
221
|
-
if (part.input.url) {
|
|
222
|
-
return `${toolName} ${truncatePath(part.input.url)}`;
|
|
223
|
-
}
|
|
224
|
-
if (part.input.query) {
|
|
225
|
-
return `${toolName} "${part.input.query}"`;
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
if (part.title) {
|
|
230
|
-
return part.title;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
return toolName;
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// All states that indicate a tool is currently executing or waiting to execute
|
|
237
|
-
const ACTIVE_TOOL_STATES = ["pending", "running", "in_progress"];
|
|
238
|
-
|
|
239
|
-
export function isPendingToolCall(part: PartMeta): boolean {
|
|
240
|
-
if (!part.tool || part.type !== "tool") {
|
|
241
|
-
return false;
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
if (!part.state) {
|
|
245
|
-
return false;
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
return ACTIVE_TOOL_STATES.includes(part.state);
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
export interface SessionToolState {
|
|
252
|
-
hasPendingToolCall: boolean;
|
|
253
|
-
pendingCount: number;
|
|
254
|
-
completedCount: number;
|
|
255
|
-
lastToolCompletedAt: Date | null;
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
export function getSessionToolState(parts: PartMeta[]): SessionToolState {
|
|
259
|
-
let pendingCount = 0;
|
|
260
|
-
let completedCount = 0;
|
|
261
|
-
let lastToolCompletedAt: Date | null = null;
|
|
262
|
-
|
|
263
|
-
for (const part of parts) {
|
|
264
|
-
if (part.type !== "tool" || !part.tool) {
|
|
265
|
-
continue;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
if (isPendingToolCall(part)) {
|
|
269
|
-
pendingCount++;
|
|
270
|
-
} else if (part.state === "completed") {
|
|
271
|
-
completedCount++;
|
|
272
|
-
|
|
273
|
-
if (part.completedAt) {
|
|
274
|
-
if (!lastToolCompletedAt || part.completedAt > lastToolCompletedAt) {
|
|
275
|
-
lastToolCompletedAt = part.completedAt;
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
|
|
281
|
-
return {
|
|
282
|
-
hasPendingToolCall: pendingCount > 0,
|
|
283
|
-
pendingCount,
|
|
284
|
-
completedCount,
|
|
285
|
-
lastToolCompletedAt,
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
export interface SessionActivityState {
|
|
290
|
-
hasPendingToolCall: boolean;
|
|
291
|
-
pendingCount: number;
|
|
292
|
-
completedCount: number;
|
|
293
|
-
lastToolCompletedAt: Date | null;
|
|
294
|
-
isReasoning: boolean;
|
|
295
|
-
reasoningPreview: string | null;
|
|
296
|
-
patchFilesCount: number;
|
|
297
|
-
stepFinishReason: "stop" | "tool-calls" | null;
|
|
298
|
-
activeToolNames: string[];
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
export function getSessionActivityState(parts: PartMeta[]): SessionActivityState {
|
|
302
|
-
let pendingCount = 0;
|
|
303
|
-
let completedCount = 0;
|
|
304
|
-
let lastToolCompletedAt: Date | null = null;
|
|
305
|
-
let isReasoning = false;
|
|
306
|
-
let reasoningPreview: string | null = null;
|
|
307
|
-
let patchFilesCount = 0;
|
|
308
|
-
let stepFinishReason: "stop" | "tool-calls" | null = null;
|
|
309
|
-
const activeToolNames: string[] = [];
|
|
310
|
-
|
|
311
|
-
const sortedParts = [...parts].sort((a, b) => {
|
|
312
|
-
const timeA = a.startedAt?.getTime() || 0;
|
|
313
|
-
const timeB = b.startedAt?.getTime() || 0;
|
|
314
|
-
return timeB - timeA;
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
for (const part of sortedParts) {
|
|
318
|
-
if (part.type === "tool" && part.tool) {
|
|
319
|
-
if (isPendingToolCall(part)) {
|
|
320
|
-
pendingCount++;
|
|
321
|
-
activeToolNames.push(part.tool.replace(/^mcp_/, ""));
|
|
322
|
-
} else if (part.state === "completed") {
|
|
323
|
-
completedCount++;
|
|
324
|
-
if (part.completedAt && (!lastToolCompletedAt || part.completedAt > lastToolCompletedAt)) {
|
|
325
|
-
lastToolCompletedAt = part.completedAt;
|
|
326
|
-
}
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
if (part.type === "reasoning" && part.reasoningText && !isReasoning) {
|
|
331
|
-
isReasoning = true;
|
|
332
|
-
const text = part.reasoningText.trim();
|
|
333
|
-
reasoningPreview = text.length > 40 ? text.slice(0, 37) + "..." : text;
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
if (part.type === "patch" && part.patchFiles && !part.completedAt) {
|
|
337
|
-
patchFilesCount += part.patchFiles.length;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
if (part.type === "step-finish" && part.stepFinishReason && !stepFinishReason) {
|
|
341
|
-
stepFinishReason = part.stepFinishReason;
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
return {
|
|
346
|
-
hasPendingToolCall: pendingCount > 0,
|
|
347
|
-
pendingCount,
|
|
348
|
-
completedCount,
|
|
349
|
-
lastToolCompletedAt,
|
|
350
|
-
isReasoning,
|
|
351
|
-
reasoningPreview,
|
|
352
|
-
patchFilesCount,
|
|
353
|
-
stepFinishReason,
|
|
354
|
-
activeToolNames,
|
|
355
|
-
};
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
import type { SessionActivityType, SessionStatus } from "../../shared/types";
|
|
359
|
-
|
|
360
|
-
export function deriveActivityType(
|
|
361
|
-
activityState: SessionActivityState,
|
|
362
|
-
lastAssistantFinished: boolean,
|
|
363
|
-
isSubagent: boolean,
|
|
364
|
-
status: SessionStatus,
|
|
365
|
-
waitingReason?: "user" | "children"
|
|
366
|
-
): SessionActivityType {
|
|
367
|
-
if (status === "completed") {
|
|
368
|
-
return "idle";
|
|
369
|
-
}
|
|
370
|
-
if ((waitingReason === "user" || (!waitingReason && lastAssistantFinished)) && !isSubagent && status === "waiting") {
|
|
371
|
-
return "waiting-user";
|
|
372
|
-
}
|
|
373
|
-
if (activityState.pendingCount > 0) {
|
|
374
|
-
return "tool";
|
|
375
|
-
}
|
|
376
|
-
if (activityState.isReasoning) {
|
|
377
|
-
return "reasoning";
|
|
378
|
-
}
|
|
379
|
-
if (activityState.patchFilesCount > 0) {
|
|
380
|
-
return "patch";
|
|
381
|
-
}
|
|
382
|
-
if (activityState.stepFinishReason === "tool-calls") {
|
|
383
|
-
return "waiting-tools";
|
|
384
|
-
}
|
|
385
|
-
return "idle";
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
export function generateActivityMessage(
|
|
389
|
-
activityState: SessionActivityState,
|
|
390
|
-
lastAssistantFinished: boolean,
|
|
391
|
-
isSubagent: boolean,
|
|
392
|
-
status: SessionStatus,
|
|
393
|
-
pendingPart?: PartMeta,
|
|
394
|
-
waitingReason?: "user" | "children"
|
|
395
|
-
): string | null {
|
|
396
|
-
if (status === "completed") {
|
|
397
|
-
return null;
|
|
398
|
-
}
|
|
399
|
-
if ((waitingReason === "user" || (!waitingReason && lastAssistantFinished)) && !isSubagent && status === "waiting") {
|
|
400
|
-
return "Waiting for user input";
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
if (activityState.pendingCount > 1) {
|
|
404
|
-
const toolNames = activityState.activeToolNames.slice(0, 3).join(", ");
|
|
405
|
-
const firstToolAction = pendingPart ? formatCurrentAction(pendingPart) : null;
|
|
406
|
-
if (firstToolAction) {
|
|
407
|
-
return `Running ${activityState.pendingCount} tools (${firstToolAction})`;
|
|
408
|
-
}
|
|
409
|
-
return `Running ${activityState.pendingCount} tools: ${toolNames}${activityState.activeToolNames.length > 3 ? "..." : ""}`;
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
if (activityState.pendingCount === 1 && pendingPart) {
|
|
413
|
-
return formatCurrentAction(pendingPart);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
if (activityState.isReasoning && activityState.reasoningPreview) {
|
|
417
|
-
return `Analyzing: ${activityState.reasoningPreview}`;
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
if (activityState.patchFilesCount > 0) {
|
|
421
|
-
return `Writing ${activityState.patchFilesCount} file${activityState.patchFilesCount !== 1 ? "s" : ""}...`;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
if (activityState.stepFinishReason === "tool-calls") {
|
|
425
|
-
return "Waiting for tool results";
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
return null;
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
export async function getPartsForSession(
|
|
432
|
-
sessionID: string,
|
|
433
|
-
storagePath?: string
|
|
434
|
-
): Promise<PartMeta[]> {
|
|
435
|
-
const basePath = storagePath || getStoragePath();
|
|
436
|
-
const messages = await listMessages(sessionID, storagePath);
|
|
437
|
-
const parts: PartMeta[] = [];
|
|
438
|
-
|
|
439
|
-
for (const message of messages) {
|
|
440
|
-
const messagePartDir = join(
|
|
441
|
-
basePath,
|
|
442
|
-
"opencode",
|
|
443
|
-
"storage",
|
|
444
|
-
"part",
|
|
445
|
-
message.id
|
|
446
|
-
);
|
|
447
|
-
|
|
448
|
-
try {
|
|
449
|
-
const entries = await readdir(messagePartDir);
|
|
450
|
-
|
|
451
|
-
for (const entry of entries) {
|
|
452
|
-
if (!entry.endsWith(".json")) {
|
|
453
|
-
continue;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
|
-
const partPath = join(messagePartDir, entry);
|
|
457
|
-
const part = await parsePart(partPath);
|
|
458
|
-
|
|
459
|
-
if (part) {
|
|
460
|
-
parts.push(part);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
} catch (error) {
|
|
464
|
-
continue;
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
return parts;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/**
|
|
472
|
-
* Get tool call summaries for a session
|
|
473
|
-
* @param sessionID - Session ID
|
|
474
|
-
* @param messageAgent - Map of message ID to agent name
|
|
475
|
-
* @param storagePath - Optional custom storage path (defaults to XDG path)
|
|
476
|
-
* @returns Array of ToolCallSummary (max 50, sorted by timestamp desc)
|
|
477
|
-
*/
|
|
478
|
-
export async function getToolCallsForSession(
|
|
479
|
-
sessionID: string,
|
|
480
|
-
messageAgent: Map<string, string>,
|
|
481
|
-
storagePath?: string,
|
|
482
|
-
partsOverride?: PartMeta[]
|
|
483
|
-
): Promise<ToolCallSummary[]> {
|
|
484
|
-
const parts = partsOverride ?? (await getPartsForSession(sessionID, storagePath));
|
|
485
|
-
|
|
486
|
-
// Filter for tool type parts only
|
|
487
|
-
const toolParts = parts.filter((part) => part.type === "tool" && part.tool);
|
|
488
|
-
|
|
489
|
-
// Map to ToolCallSummary
|
|
490
|
-
const toolCalls: ToolCallSummary[] = toolParts.map((part) => {
|
|
491
|
-
const agentName = messageAgent.get(part.messageID) || "unknown";
|
|
492
|
-
|
|
493
|
-
// Map state to ToolCallSummary state
|
|
494
|
-
let state: "pending" | "complete" | "error" = "complete";
|
|
495
|
-
if (part.state) {
|
|
496
|
-
if (ACTIVE_TOOL_STATES.includes(part.state)) {
|
|
497
|
-
state = "pending";
|
|
498
|
-
} else if (part.state === "error" || part.state === "failed") {
|
|
499
|
-
state = "error";
|
|
500
|
-
} else {
|
|
501
|
-
state = "complete";
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Generate summary using formatCurrentAction
|
|
506
|
-
const summary = formatCurrentAction(part) || part.tool || "Unknown tool";
|
|
507
|
-
|
|
508
|
-
// Use completedAt if available, otherwise startedAt for stable hashing
|
|
509
|
-
const timestamp = (part.completedAt || part.startedAt)?.toISOString() || "";
|
|
510
|
-
|
|
511
|
-
return {
|
|
512
|
-
id: part.id,
|
|
513
|
-
name: part.tool || "unknown",
|
|
514
|
-
state,
|
|
515
|
-
summary,
|
|
516
|
-
input: part.input || {},
|
|
517
|
-
error: part.error,
|
|
518
|
-
timestamp,
|
|
519
|
-
agentName,
|
|
520
|
-
};
|
|
521
|
-
});
|
|
522
|
-
|
|
523
|
-
// Sort by timestamp descending (newest first)
|
|
524
|
-
toolCalls.sort((a, b) => {
|
|
525
|
-
const timeA = new Date(a.timestamp).getTime();
|
|
526
|
-
const timeB = new Date(b.timestamp).getTime();
|
|
527
|
-
return timeB - timeA;
|
|
528
|
-
});
|
|
529
|
-
|
|
530
|
-
// Limit to 50 most recent
|
|
531
|
-
return toolCalls.slice(0, 50);
|
|
532
|
-
}
|
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Session Parser - Parse OpenCode session JSON files
|
|
3
|
-
* Reads from ~/.local/share/opencode/storage/session/{projectID}/{sessionID}.json
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
import { homedir } from "node:os";
|
|
9
|
-
import type { SessionMetadata } from "../../shared/types";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Internal JSON structure from OpenCode storage
|
|
13
|
-
*/
|
|
14
|
-
interface SessionJSON {
|
|
15
|
-
id: string;
|
|
16
|
-
slug: string;
|
|
17
|
-
version?: string;
|
|
18
|
-
projectID: string;
|
|
19
|
-
directory: string;
|
|
20
|
-
title: string;
|
|
21
|
-
parentID?: string;
|
|
22
|
-
time: {
|
|
23
|
-
created: number; // Unix timestamp in milliseconds
|
|
24
|
-
updated: number;
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function getStoragePath(): string {
|
|
29
|
-
const xdgDataHome = process.env.XDG_DATA_HOME;
|
|
30
|
-
if (xdgDataHome) {
|
|
31
|
-
return xdgDataHome;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const home = homedir();
|
|
35
|
-
return join(home, ".local", "share");
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export async function checkStorageExists(): Promise<boolean> {
|
|
39
|
-
try {
|
|
40
|
-
const basePath = getStoragePath();
|
|
41
|
-
const storagePath = join(basePath, "opencode", "storage");
|
|
42
|
-
await readdir(storagePath);
|
|
43
|
-
return true;
|
|
44
|
-
} catch {
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Parse a single session JSON file
|
|
51
|
-
* @param filePath - Absolute path to session JSON file
|
|
52
|
-
* @returns SessionMetadata or null if file doesn't exist or is invalid
|
|
53
|
-
*/
|
|
54
|
-
export async function parseSession(
|
|
55
|
-
filePath: string
|
|
56
|
-
): Promise<SessionMetadata | null> {
|
|
57
|
-
try {
|
|
58
|
-
const content = await readFile(filePath, "utf-8");
|
|
59
|
-
const json: SessionJSON = JSON.parse(content);
|
|
60
|
-
|
|
61
|
-
return {
|
|
62
|
-
id: json.id,
|
|
63
|
-
projectID: json.projectID,
|
|
64
|
-
directory: json.directory,
|
|
65
|
-
title: json.title,
|
|
66
|
-
parentID: json.parentID,
|
|
67
|
-
createdAt: new Date(json.time.created),
|
|
68
|
-
updatedAt: new Date(json.time.updated),
|
|
69
|
-
};
|
|
70
|
-
} catch (error) {
|
|
71
|
-
if (error instanceof SyntaxError) {
|
|
72
|
-
console.warn(`Corrupted JSON file: ${filePath}`);
|
|
73
|
-
}
|
|
74
|
-
return null;
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Get a specific session by projectID and sessionID
|
|
80
|
-
* @param projectID - Project hash ID
|
|
81
|
-
* @param sessionID - Session ID
|
|
82
|
-
* @param storagePath - Optional custom storage path (defaults to XDG path)
|
|
83
|
-
* @returns SessionMetadata or null if not found
|
|
84
|
-
*/
|
|
85
|
-
export async function getSession(
|
|
86
|
-
projectID: string,
|
|
87
|
-
sessionID: string,
|
|
88
|
-
storagePath?: string
|
|
89
|
-
): Promise<SessionMetadata | null> {
|
|
90
|
-
const basePath = storagePath || getStoragePath();
|
|
91
|
-
const filePath = join(
|
|
92
|
-
basePath,
|
|
93
|
-
"opencode",
|
|
94
|
-
"storage",
|
|
95
|
-
"session",
|
|
96
|
-
projectID,
|
|
97
|
-
`${sessionID}.json`
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
return parseSession(filePath);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/**
|
|
104
|
-
* List all sessions for a given project
|
|
105
|
-
* @param projectID - Project hash ID
|
|
106
|
-
* @param storagePath - Optional custom storage path (defaults to XDG path)
|
|
107
|
-
* @returns Array of SessionMetadata (empty array if directory doesn't exist)
|
|
108
|
-
*/
|
|
109
|
-
export async function listSessions(
|
|
110
|
-
projectID: string,
|
|
111
|
-
storagePath?: string
|
|
112
|
-
): Promise<SessionMetadata[]> {
|
|
113
|
-
const basePath = storagePath || getStoragePath();
|
|
114
|
-
const sessionDir = join(
|
|
115
|
-
basePath,
|
|
116
|
-
"opencode",
|
|
117
|
-
"storage",
|
|
118
|
-
"session",
|
|
119
|
-
projectID
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
try {
|
|
123
|
-
const entries = await readdir(sessionDir);
|
|
124
|
-
const results = await Promise.all(
|
|
125
|
-
entries
|
|
126
|
-
.filter((entry) => entry.endsWith(".json"))
|
|
127
|
-
.map((entry) => getSession(projectID, entry.slice(0, -5), storagePath))
|
|
128
|
-
);
|
|
129
|
-
|
|
130
|
-
return results.filter(
|
|
131
|
-
(session): session is SessionMetadata => session !== null
|
|
132
|
-
);
|
|
133
|
-
} catch (error) {
|
|
134
|
-
// Graceful handling: return empty array if directory doesn't exist
|
|
135
|
-
return [];
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
/**
|
|
140
|
-
* List all project IDs from the session storage
|
|
141
|
-
* @param storagePath - Optional custom storage path (defaults to XDG path)
|
|
142
|
-
* @returns Array of project IDs (empty array if directory doesn't exist)
|
|
143
|
-
*/
|
|
144
|
-
export async function listProjects(
|
|
145
|
-
storagePath?: string
|
|
146
|
-
): Promise<string[]> {
|
|
147
|
-
const basePath = storagePath || getStoragePath();
|
|
148
|
-
const sessionBaseDir = join(basePath, "opencode", "storage", "session");
|
|
149
|
-
|
|
150
|
-
try {
|
|
151
|
-
const entries = await readdir(sessionBaseDir, { withFileTypes: true });
|
|
152
|
-
const projectIDs: string[] = [];
|
|
153
|
-
|
|
154
|
-
for (const entry of entries) {
|
|
155
|
-
if (entry.isDirectory()) {
|
|
156
|
-
projectIDs.push(entry.name);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
return projectIDs;
|
|
161
|
-
} catch (error) {
|
|
162
|
-
return [];
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* List all sessions across all projects
|
|
168
|
-
* @param storagePath - Optional custom storage path (defaults to XDG path)
|
|
169
|
-
* @returns Array of SessionMetadata (empty array if directory doesn't exist)
|
|
170
|
-
*/
|
|
171
|
-
export async function listAllSessions(
|
|
172
|
-
storagePath?: string
|
|
173
|
-
): Promise<SessionMetadata[]> {
|
|
174
|
-
const projectIDs = await listProjects(storagePath);
|
|
175
|
-
const allResults = await Promise.all(
|
|
176
|
-
projectIDs.map((projectID) => listSessions(projectID, storagePath))
|
|
177
|
-
);
|
|
178
|
-
|
|
179
|
-
return allResults.flat();
|
|
180
|
-
}
|