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
package/src/server/watcher.ts
CHANGED
|
@@ -1,32 +1,38 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
* Uses fs.watch() to detect changes and trigger cache invalidation
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { watch, type FSWatcher } from "node:fs";
|
|
7
|
-
import { join } from "node:path";
|
|
1
|
+
import { existsSync, watch, type FSWatcher } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
8
3
|
import { EventEmitter } from "node:events";
|
|
9
|
-
import {
|
|
4
|
+
import { homedir } from "node:os";
|
|
10
5
|
|
|
11
6
|
export interface WatcherOptions {
|
|
7
|
+
dbPath?: string;
|
|
12
8
|
storagePath?: string;
|
|
13
9
|
projectPath?: string;
|
|
14
10
|
debounceMs?: number;
|
|
15
11
|
}
|
|
16
12
|
|
|
17
13
|
export class Watcher extends EventEmitter {
|
|
18
|
-
private
|
|
14
|
+
private dbWatcher: FSWatcher | null = null;
|
|
15
|
+
private boulderWatcher: FSWatcher | null = null;
|
|
19
16
|
private debounceTimer: Timer | null = null;
|
|
17
|
+
private rebindTimer: Timer | null = null;
|
|
20
18
|
private readonly debounceMs: number;
|
|
21
|
-
private readonly
|
|
19
|
+
private readonly dbPath: string;
|
|
20
|
+
private readonly walPath: string;
|
|
21
|
+
private readonly boulderPath: string;
|
|
22
|
+
private readonly boulderDirPath: string;
|
|
22
23
|
private readonly projectPath: string;
|
|
24
|
+
private dbWatcherTarget: string | null = null;
|
|
25
|
+
private boulderWatcherTarget: string | null = null;
|
|
23
26
|
private isRunning: boolean = false;
|
|
24
27
|
|
|
25
28
|
constructor(options: WatcherOptions = {}) {
|
|
26
29
|
super();
|
|
27
|
-
this.
|
|
30
|
+
this.dbPath = resolveDbPath(options);
|
|
31
|
+
this.walPath = `${this.dbPath}-wal`;
|
|
28
32
|
this.projectPath = options.projectPath || process.cwd();
|
|
29
|
-
this.
|
|
33
|
+
this.boulderPath = join(this.projectPath, ".sisyphus", "boulder.json");
|
|
34
|
+
this.boulderDirPath = join(this.projectPath, ".sisyphus");
|
|
35
|
+
this.debounceMs = options.debounceMs ?? 100;
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
start(): void {
|
|
@@ -36,58 +42,10 @@ export class Watcher extends EventEmitter {
|
|
|
36
42
|
|
|
37
43
|
this.isRunning = true;
|
|
38
44
|
|
|
39
|
-
const sessionDir = join(this.storagePath, "opencode", "storage", "session");
|
|
40
|
-
const messageDir = join(this.storagePath, "opencode", "storage", "message");
|
|
41
|
-
|
|
42
45
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
this.handleChange.bind(this)
|
|
47
|
-
);
|
|
48
|
-
this.watchers.push(sessionWatcher);
|
|
49
|
-
|
|
50
|
-
const messageWatcher = watch(
|
|
51
|
-
messageDir,
|
|
52
|
-
{ recursive: true },
|
|
53
|
-
this.handleChange.bind(this)
|
|
54
|
-
);
|
|
55
|
-
this.watchers.push(messageWatcher);
|
|
56
|
-
|
|
57
|
-
const partDir = join(this.storagePath, "opencode", "storage", "part");
|
|
58
|
-
try {
|
|
59
|
-
const partWatcher = watch(
|
|
60
|
-
partDir,
|
|
61
|
-
{ recursive: true },
|
|
62
|
-
this.handleChange.bind(this)
|
|
63
|
-
);
|
|
64
|
-
this.watchers.push(partWatcher);
|
|
65
|
-
} catch {
|
|
66
|
-
// part directory may not exist yet
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const boulderDir = join(this.projectPath, ".sisyphus");
|
|
70
|
-
try {
|
|
71
|
-
const boulderWatcher = watch(
|
|
72
|
-
boulderDir,
|
|
73
|
-
{ recursive: true },
|
|
74
|
-
this.handleChange.bind(this)
|
|
75
|
-
);
|
|
76
|
-
this.watchers.push(boulderWatcher);
|
|
77
|
-
} catch {
|
|
78
|
-
// boulder directory may not exist yet
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
try {
|
|
82
|
-
const projectWatcher = watch(
|
|
83
|
-
this.projectPath,
|
|
84
|
-
{ recursive: true },
|
|
85
|
-
this.handleProjectChange.bind(this)
|
|
86
|
-
);
|
|
87
|
-
this.watchers.push(projectWatcher);
|
|
88
|
-
} catch {
|
|
89
|
-
// project watcher may fail on unsupported environments
|
|
90
|
-
}
|
|
46
|
+
this.rebindDbWatcher();
|
|
47
|
+
this.rebindBoulderWatcher();
|
|
48
|
+
this.startRebindLoop();
|
|
91
49
|
|
|
92
50
|
this.emit("started");
|
|
93
51
|
} catch (error) {
|
|
@@ -96,15 +54,7 @@ export class Watcher extends EventEmitter {
|
|
|
96
54
|
}
|
|
97
55
|
}
|
|
98
56
|
|
|
99
|
-
private
|
|
100
|
-
if (!filename) {
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
if (!filename.endsWith(".json")) {
|
|
105
|
-
return;
|
|
106
|
-
}
|
|
107
|
-
|
|
57
|
+
private emitDebouncedChange(eventType: string, filename: string): void {
|
|
108
58
|
if (this.debounceTimer) {
|
|
109
59
|
clearTimeout(this.debounceTimer);
|
|
110
60
|
}
|
|
@@ -115,20 +65,138 @@ export class Watcher extends EventEmitter {
|
|
|
115
65
|
}, this.debounceMs);
|
|
116
66
|
}
|
|
117
67
|
|
|
118
|
-
private
|
|
119
|
-
if (
|
|
68
|
+
private getPreferredDbWatchTarget(): string {
|
|
69
|
+
if (existsSync(this.walPath)) {
|
|
70
|
+
return this.walPath;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return this.dbPath;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
private rebindDbWatcher(): void {
|
|
77
|
+
const preferredTarget = this.getPreferredDbWatchTarget();
|
|
78
|
+
if (this.dbWatcher && this.dbWatcherTarget === preferredTarget) {
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (this.dbWatcher) {
|
|
83
|
+
this.dbWatcher.close();
|
|
84
|
+
this.dbWatcher = null;
|
|
85
|
+
this.dbWatcherTarget = null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (!existsSync(preferredTarget)) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
try {
|
|
93
|
+
this.dbWatcher = watch(preferredTarget, (eventType, filename) => {
|
|
94
|
+
this.handleDbEvent(eventType, filename);
|
|
95
|
+
});
|
|
96
|
+
this.dbWatcherTarget = preferredTarget;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
99
|
+
if (code === "ENOENT") {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
throw error;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private handleDbEvent(eventType: string, _filename: string | null): void {
|
|
107
|
+
const watchedTarget = this.dbWatcherTarget;
|
|
108
|
+
if (!watchedTarget) {
|
|
120
109
|
return;
|
|
121
110
|
}
|
|
122
111
|
|
|
123
|
-
|
|
112
|
+
this.emitDebouncedChange(eventType, basename(watchedTarget));
|
|
113
|
+
|
|
114
|
+
if (eventType === "rename" || !existsSync(watchedTarget)) {
|
|
115
|
+
this.rebindDbWatcherSafe();
|
|
124
116
|
return;
|
|
125
117
|
}
|
|
126
118
|
|
|
127
|
-
if (
|
|
119
|
+
if (this.getPreferredDbWatchTarget() !== watchedTarget) {
|
|
120
|
+
this.rebindDbWatcherSafe();
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private rebindBoulderWatcher(): void {
|
|
125
|
+
const preferredTarget = existsSync(this.boulderPath)
|
|
126
|
+
? this.boulderPath
|
|
127
|
+
: this.boulderDirPath;
|
|
128
|
+
|
|
129
|
+
if (this.boulderWatcher && this.boulderWatcherTarget === preferredTarget) {
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (this.boulderWatcher) {
|
|
134
|
+
this.boulderWatcher.close();
|
|
135
|
+
this.boulderWatcher = null;
|
|
136
|
+
this.boulderWatcherTarget = null;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (!existsSync(preferredTarget)) {
|
|
128
140
|
return;
|
|
129
141
|
}
|
|
130
142
|
|
|
131
|
-
this.
|
|
143
|
+
this.boulderWatcher = watch(preferredTarget, (eventType, filename) => {
|
|
144
|
+
this.handleBoulderEvent(eventType, filename);
|
|
145
|
+
});
|
|
146
|
+
this.boulderWatcherTarget = preferredTarget;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private handleBoulderEvent(eventType: string, filename: string | null): void {
|
|
150
|
+
if (this.boulderWatcherTarget === this.boulderDirPath) {
|
|
151
|
+
if (!filename || filename !== "boulder.json") {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
this.emitDebouncedChange(eventType, ".sisyphus/boulder.json");
|
|
157
|
+
|
|
158
|
+
if (eventType === "rename") {
|
|
159
|
+
this.rebindBoulderWatcherSafe();
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const preferredTarget = existsSync(this.boulderPath)
|
|
164
|
+
? this.boulderPath
|
|
165
|
+
: this.boulderDirPath;
|
|
166
|
+
if (preferredTarget !== this.boulderWatcherTarget) {
|
|
167
|
+
this.rebindBoulderWatcherSafe();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private startRebindLoop(): void {
|
|
172
|
+
if (this.rebindTimer) {
|
|
173
|
+
clearInterval(this.rebindTimer);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
this.rebindTimer = setInterval(() => {
|
|
177
|
+
if (!this.isRunning) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
this.rebindDbWatcherSafe();
|
|
182
|
+
this.rebindBoulderWatcherSafe();
|
|
183
|
+
}, 1000);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
private rebindDbWatcherSafe(): void {
|
|
187
|
+
try {
|
|
188
|
+
this.rebindDbWatcher();
|
|
189
|
+
} catch (error) {
|
|
190
|
+
console.warn('[watcher] Failed to rebind DB watcher:', error instanceof Error ? error.message : error);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private rebindBoulderWatcherSafe(): void {
|
|
195
|
+
try {
|
|
196
|
+
this.rebindBoulderWatcher();
|
|
197
|
+
} catch (error) {
|
|
198
|
+
console.warn('[watcher] Failed to rebind boulder watcher:', error instanceof Error ? error.message : error);
|
|
199
|
+
}
|
|
132
200
|
}
|
|
133
201
|
|
|
134
202
|
stop(): void {
|
|
@@ -141,20 +209,57 @@ export class Watcher extends EventEmitter {
|
|
|
141
209
|
this.debounceTimer = null;
|
|
142
210
|
}
|
|
143
211
|
|
|
144
|
-
|
|
145
|
-
|
|
212
|
+
if (this.rebindTimer) {
|
|
213
|
+
clearInterval(this.rebindTimer);
|
|
214
|
+
this.rebindTimer = null;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (this.dbWatcher) {
|
|
218
|
+
this.dbWatcher.close();
|
|
219
|
+
this.dbWatcher = null;
|
|
220
|
+
this.dbWatcherTarget = null;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (this.boulderWatcher) {
|
|
224
|
+
this.boulderWatcher.close();
|
|
225
|
+
this.boulderWatcher = null;
|
|
226
|
+
this.boulderWatcherTarget = null;
|
|
146
227
|
}
|
|
147
228
|
|
|
148
|
-
this.watchers = [];
|
|
149
229
|
this.isRunning = false;
|
|
150
230
|
this.emit("stopped");
|
|
151
231
|
}
|
|
152
232
|
|
|
233
|
+
close(): void {
|
|
234
|
+
this.stop();
|
|
235
|
+
}
|
|
236
|
+
|
|
153
237
|
getIsRunning(): boolean {
|
|
154
238
|
return this.isRunning;
|
|
155
239
|
}
|
|
156
240
|
}
|
|
157
241
|
|
|
158
|
-
|
|
159
|
-
|
|
242
|
+
function resolveDbPath(options: WatcherOptions): string {
|
|
243
|
+
if (options.dbPath) {
|
|
244
|
+
return normalizeDbPathInput(options.dbPath);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (options.storagePath) {
|
|
248
|
+
return join(options.storagePath, "opencode", "opencode.db");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const storageRoot = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
|
|
252
|
+
return join(storageRoot, "opencode", "opencode.db");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function normalizeDbPathInput(dbPathOrStoragePath: string): string {
|
|
256
|
+
if (dbPathOrStoragePath.endsWith(".db")) {
|
|
257
|
+
return dbPathOrStoragePath;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return join(dbPathOrStoragePath, "opencode", "opencode.db");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export function createWatcher(dbPath?: string, projectPath?: string): Watcher {
|
|
264
|
+
return new Watcher({ dbPath, projectPath });
|
|
160
265
|
}
|
package/src/shared/constants.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const TWENTY_FOUR_HOURS_MS = 86400000 as const;
|
|
|
10
10
|
|
|
11
11
|
// API limits
|
|
12
12
|
export const MAX_SESSIONS_LIMIT = 20 as const;
|
|
13
|
+
/** Messages returned per session in API responses (client-facing limit) */
|
|
13
14
|
export const MAX_MESSAGES_LIMIT = 100 as const;
|
|
14
15
|
|
|
15
16
|
// Cache TTL
|
|
@@ -21,6 +21,36 @@ export interface SessionMetadata {
|
|
|
21
21
|
updatedAt: Date;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* SessionSummary is a lightweight session representation used in poll responses
|
|
26
|
+
* and as the summary field in SessionDetail
|
|
27
|
+
*/
|
|
28
|
+
export interface SessionSummary {
|
|
29
|
+
id: string;
|
|
30
|
+
projectID: string;
|
|
31
|
+
title: string;
|
|
32
|
+
status?: SessionStatus;
|
|
33
|
+
activityType?: SessionActivityType;
|
|
34
|
+
currentAction?: string | null;
|
|
35
|
+
agent?: string | null;
|
|
36
|
+
modelID?: string | null;
|
|
37
|
+
providerID?: string | null;
|
|
38
|
+
updatedAt: Date;
|
|
39
|
+
createdAt: Date;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* SessionDetail is the full detail response for a single session
|
|
44
|
+
* Contains summary, messages, activity tree, todos, and optional stats
|
|
45
|
+
*/
|
|
46
|
+
export interface SessionDetail {
|
|
47
|
+
session: SessionSummary;
|
|
48
|
+
messages: MessageMeta[];
|
|
49
|
+
activity: ActivitySession[];
|
|
50
|
+
todos: TodoItem[];
|
|
51
|
+
stats?: SessionStats;
|
|
52
|
+
}
|
|
53
|
+
|
|
24
54
|
/**
|
|
25
55
|
* MessageMeta represents a message in a session
|
|
26
56
|
*/
|
|
@@ -229,6 +259,13 @@ export interface PlanProgress {
|
|
|
229
259
|
tasks: Array<{ description: string; completed: boolean }>;
|
|
230
260
|
}
|
|
231
261
|
|
|
262
|
+
export interface TodoItem {
|
|
263
|
+
content: string;
|
|
264
|
+
status: string;
|
|
265
|
+
priority: string;
|
|
266
|
+
position: number;
|
|
267
|
+
}
|
|
268
|
+
|
|
232
269
|
/**
|
|
233
270
|
* ModelTokens represents token usage for a specific model
|
|
234
271
|
*/
|
|
@@ -316,13 +353,10 @@ export interface AgentPhase {
|
|
|
316
353
|
* Contains current session state, plan progress, and activity data
|
|
317
354
|
*/
|
|
318
355
|
export interface PollResponse {
|
|
319
|
-
sessions:
|
|
320
|
-
|
|
356
|
+
sessions: SessionSummary[];
|
|
357
|
+
activeSessionId: string | null;
|
|
321
358
|
planProgress: PlanProgress | null;
|
|
322
359
|
planName?: string;
|
|
323
|
-
messages: MessageMeta[];
|
|
324
|
-
activitySessions: ActivitySession[];
|
|
325
|
-
sessionStats?: SessionStats;
|
|
326
360
|
lastUpdate: number;
|
|
327
361
|
}
|
|
328
362
|
|
|
@@ -1,169 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Message Parser - Parse OpenCode message JSON files
|
|
3
|
-
* Reads from ~/.local/share/opencode/storage/message/{sessionID}/{messageID}.json
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { readdir, readFile } from "node:fs/promises";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
import type { MessageMeta } from "../../shared/types";
|
|
9
|
-
import { getStoragePath } from "./sessionParser";
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* Internal JSON structure from OpenCode storage
|
|
13
|
-
*/
|
|
14
|
-
interface MessageJSON {
|
|
15
|
-
id: string;
|
|
16
|
-
sessionID: string;
|
|
17
|
-
role: string;
|
|
18
|
-
time: {
|
|
19
|
-
created: number;
|
|
20
|
-
completed?: number;
|
|
21
|
-
};
|
|
22
|
-
parentID?: string;
|
|
23
|
-
modelID?: string;
|
|
24
|
-
model?: {
|
|
25
|
-
modelID?: string;
|
|
26
|
-
providerID?: string;
|
|
27
|
-
};
|
|
28
|
-
providerID?: string;
|
|
29
|
-
mode?: string;
|
|
30
|
-
agent?: string;
|
|
31
|
-
path?: {
|
|
32
|
-
cwd: string;
|
|
33
|
-
root: string;
|
|
34
|
-
};
|
|
35
|
-
cost?: number;
|
|
36
|
-
tokens?: {
|
|
37
|
-
input: number;
|
|
38
|
-
output: number;
|
|
39
|
-
reasoning?: number;
|
|
40
|
-
cache?: {
|
|
41
|
-
read: number;
|
|
42
|
-
write: number;
|
|
43
|
-
};
|
|
44
|
-
};
|
|
45
|
-
finish?: string;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Parse a single message JSON file
|
|
50
|
-
* @param filePath - Absolute path to message JSON file
|
|
51
|
-
* @returns MessageMeta or null if file doesn't exist or is invalid
|
|
52
|
-
*/
|
|
53
|
-
export async function parseMessage(
|
|
54
|
-
filePath: string
|
|
55
|
-
): Promise<MessageMeta | null> {
|
|
56
|
-
try {
|
|
57
|
-
const content = await readFile(filePath, "utf-8");
|
|
58
|
-
const json: MessageJSON = JSON.parse(content);
|
|
59
|
-
|
|
60
|
-
const totalTokens = json.tokens
|
|
61
|
-
? json.tokens.input + json.tokens.output
|
|
62
|
-
: undefined;
|
|
63
|
-
|
|
64
|
-
return {
|
|
65
|
-
id: json.id,
|
|
66
|
-
sessionID: json.sessionID,
|
|
67
|
-
role: json.role,
|
|
68
|
-
agent: json.agent,
|
|
69
|
-
mode: json.mode,
|
|
70
|
-
modelID: json.modelID || json.model?.modelID,
|
|
71
|
-
providerID: json.providerID || json.model?.providerID,
|
|
72
|
-
parentID: json.parentID,
|
|
73
|
-
tokens: totalTokens,
|
|
74
|
-
cost: json.cost,
|
|
75
|
-
createdAt: new Date(json.time.created),
|
|
76
|
-
finish: json.finish,
|
|
77
|
-
};
|
|
78
|
-
} catch (error) {
|
|
79
|
-
if (error instanceof SyntaxError) {
|
|
80
|
-
console.warn(`Corrupted JSON file: ${filePath}`);
|
|
81
|
-
}
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Get a specific message by messageID and sessionID
|
|
88
|
-
* @param messageID - Message ID
|
|
89
|
-
* @param sessionID - Session ID
|
|
90
|
-
* @param storagePath - Optional custom storage path (defaults to XDG path)
|
|
91
|
-
* @returns MessageMeta or null if not found
|
|
92
|
-
*/
|
|
93
|
-
export async function getMessage(
|
|
94
|
-
messageID: string,
|
|
95
|
-
sessionID: string,
|
|
96
|
-
storagePath?: string
|
|
97
|
-
): Promise<MessageMeta | null> {
|
|
98
|
-
const basePath = storagePath || getStoragePath();
|
|
99
|
-
const filePath = join(
|
|
100
|
-
basePath,
|
|
101
|
-
"opencode",
|
|
102
|
-
"storage",
|
|
103
|
-
"message",
|
|
104
|
-
sessionID,
|
|
105
|
-
`${messageID}.json`
|
|
106
|
-
);
|
|
107
|
-
|
|
108
|
-
return parseMessage(filePath);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* List all messages for a given session
|
|
113
|
-
* @param sessionID - Session ID to filter by
|
|
114
|
-
* @param storagePath - Optional custom storage path (defaults to XDG path)
|
|
115
|
-
* @returns Array of MessageMeta (empty array if directory doesn't exist)
|
|
116
|
-
*/
|
|
117
|
-
export async function listMessages(
|
|
118
|
-
sessionID: string,
|
|
119
|
-
storagePath?: string
|
|
120
|
-
): Promise<MessageMeta[]> {
|
|
121
|
-
const basePath = storagePath || getStoragePath();
|
|
122
|
-
const messageDir = join(basePath, "opencode", "storage", "message", sessionID);
|
|
123
|
-
|
|
124
|
-
try {
|
|
125
|
-
const entries = await readdir(messageDir);
|
|
126
|
-
const messages: MessageMeta[] = [];
|
|
127
|
-
|
|
128
|
-
for (const entry of entries) {
|
|
129
|
-
if (!entry.endsWith(".json")) {
|
|
130
|
-
continue;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const messageID = entry.slice(0, -5);
|
|
134
|
-
const message = await getMessage(messageID, sessionID, storagePath);
|
|
135
|
-
|
|
136
|
-
if (message) {
|
|
137
|
-
messages.push(message);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
return messages;
|
|
142
|
-
} catch (error) {
|
|
143
|
-
return [];
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
/**
|
|
148
|
-
* Get the first assistant message in a session
|
|
149
|
-
* @param sessionID - Session ID to search
|
|
150
|
-
* @param storagePath - Optional custom storage path (defaults to XDG path)
|
|
151
|
-
* @returns First assistant message or null if none found
|
|
152
|
-
*/
|
|
153
|
-
export async function getFirstAssistantMessage(
|
|
154
|
-
sessionID: string,
|
|
155
|
-
storagePath?: string
|
|
156
|
-
): Promise<MessageMeta | null> {
|
|
157
|
-
const messages = await listMessages(sessionID, storagePath);
|
|
158
|
-
const sorted = messages.sort(
|
|
159
|
-
(a, b) => b.createdAt.getTime() - a.createdAt.getTime()
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
for (const message of sorted) {
|
|
163
|
-
if (message.role === "assistant") {
|
|
164
|
-
return message;
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return null;
|
|
169
|
-
}
|