ocwatch 0.1.1
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/LICENSE +21 -0
- package/README.md +45 -0
- package/package.json +57 -0
- package/src/client/dist/assets/index-BN8enf1I.css +1 -0
- package/src/client/dist/assets/index-qN9nCkAq.js +41 -0
- package/src/client/dist/index.html +14 -0
- package/src/client/dist/vite.svg +1 -0
- package/src/server/cli.ts +84 -0
- package/src/server/index.ts +78 -0
- package/src/server/middleware/error.ts +36 -0
- package/src/server/routes/health.ts +7 -0
- package/src/server/routes/index.ts +18 -0
- package/src/server/routes/parts.ts +19 -0
- package/src/server/routes/plan.ts +15 -0
- package/src/server/routes/poll.ts +77 -0
- package/src/server/routes/projects.ts +34 -0
- package/src/server/routes/sessions.ts +118 -0
- package/src/server/routes/sse.ts +91 -0
- package/src/server/services/pollService.ts +220 -0
- package/src/server/services/sessionService.ts +476 -0
- package/src/server/services/statsService.ts +53 -0
- package/src/server/storage/boulderParser.ts +113 -0
- package/src/server/storage/messageParser.ts +169 -0
- package/src/server/storage/partParser.ts +519 -0
- package/src/server/storage/sessionParser.ts +180 -0
- package/src/server/utils/sessionStatus.ts +123 -0
- package/src/server/validation.ts +34 -0
- package/src/server/watcher.ts +160 -0
- package/src/shared/constants.ts +22 -0
- package/src/shared/index.ts +2 -0
- package/src/shared/types/index.ts +326 -0
- package/src/shared/utils/RingBuffer.ts +79 -0
- package/src/shared/utils/activityUtils.ts +66 -0
- package/src/shared/utils/burstGrouping.ts +99 -0
- package/src/shared/utils/formatTime.ts +27 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { SessionStats, ActivitySession, MessageMeta } from "../../shared/types";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Aggregate session statistics from activity sessions and their messages
|
|
5
|
+
* @param activitySessions - Array of activity sessions
|
|
6
|
+
* @param allMessages - Map of sessionID to messages
|
|
7
|
+
* @returns SessionStats with total tokens, cost, and model breakdown
|
|
8
|
+
*/
|
|
9
|
+
export function aggregateSessionStats(
|
|
10
|
+
activitySessions: ActivitySession[],
|
|
11
|
+
allMessages: Map<string, MessageMeta[]>
|
|
12
|
+
): SessionStats {
|
|
13
|
+
let totalTokens = 0;
|
|
14
|
+
let totalCost = 0;
|
|
15
|
+
let hasCost = false;
|
|
16
|
+
const modelTokensMap = new Map<string, { modelID: string; providerID?: string; tokens: number }>();
|
|
17
|
+
|
|
18
|
+
for (const session of activitySessions) {
|
|
19
|
+
const messages = allMessages.get(session.id) || [];
|
|
20
|
+
|
|
21
|
+
for (const msg of messages) {
|
|
22
|
+
if (msg.tokens) {
|
|
23
|
+
totalTokens += msg.tokens;
|
|
24
|
+
|
|
25
|
+
const modelKey = `${msg.modelID || 'unknown'}:${msg.providerID || ''}`;
|
|
26
|
+
const existing = modelTokensMap.get(modelKey);
|
|
27
|
+
if (existing) {
|
|
28
|
+
existing.tokens += msg.tokens;
|
|
29
|
+
} else {
|
|
30
|
+
modelTokensMap.set(modelKey, {
|
|
31
|
+
modelID: msg.modelID || 'unknown',
|
|
32
|
+
providerID: msg.providerID,
|
|
33
|
+
tokens: msg.tokens,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (msg.cost !== undefined) {
|
|
39
|
+
totalCost += msg.cost;
|
|
40
|
+
hasCost = true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const modelBreakdown = Array.from(modelTokensMap.values())
|
|
46
|
+
.sort((a, b) => b.tokens - a.tokens);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
totalTokens,
|
|
50
|
+
totalCost: hasCost ? totalCost : undefined,
|
|
51
|
+
modelBreakdown,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Boulder Parser - Parse .sisyphus/boulder.json for plan progress
|
|
3
|
+
* Reads plan state and calculates progress from markdown checkboxes
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { readFile } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { Boulder, PlanProgress } from "../../shared/types";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Internal JSON structure from .sisyphus/boulder.json
|
|
12
|
+
*/
|
|
13
|
+
interface BoulderJSON {
|
|
14
|
+
activePlan?: string;
|
|
15
|
+
sessionIDs: string[];
|
|
16
|
+
status: string;
|
|
17
|
+
startedAt: number;
|
|
18
|
+
planName: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Parse boulder.json file
|
|
23
|
+
* @param projectDir - Project directory containing .sisyphus/boulder.json
|
|
24
|
+
* @returns Boulder or null if file doesn't exist or is invalid
|
|
25
|
+
*/
|
|
26
|
+
export async function parseBoulder(projectDir: string): Promise<Boulder | null> {
|
|
27
|
+
try {
|
|
28
|
+
const filePath = join(projectDir, ".sisyphus", "boulder.json");
|
|
29
|
+
const content = await readFile(filePath, "utf-8");
|
|
30
|
+
const json: BoulderJSON = JSON.parse(content);
|
|
31
|
+
|
|
32
|
+
let activePlan = json.activePlan;
|
|
33
|
+
if (activePlan && !activePlan.startsWith("/")) {
|
|
34
|
+
activePlan = join(projectDir, activePlan);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
activePlan,
|
|
39
|
+
sessionIDs: json.sessionIDs,
|
|
40
|
+
status: json.status,
|
|
41
|
+
startedAt: new Date(json.startedAt),
|
|
42
|
+
planName: json.planName,
|
|
43
|
+
};
|
|
44
|
+
} catch (error) {
|
|
45
|
+
if (error instanceof SyntaxError) {
|
|
46
|
+
console.warn(`Corrupted boulder.json: ${projectDir}/.sisyphus/boulder.json`);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Calculate progress from markdown plan file
|
|
54
|
+
* Counts checkboxes: - [ ] (incomplete) vs - [x] or - [X] (complete)
|
|
55
|
+
* @param planPath - Absolute path to plan markdown file
|
|
56
|
+
* @returns PlanProgress or null if file doesn't exist
|
|
57
|
+
*/
|
|
58
|
+
export async function calculatePlanProgress(
|
|
59
|
+
planPath: string
|
|
60
|
+
): Promise<PlanProgress | null> {
|
|
61
|
+
try {
|
|
62
|
+
const content = await readFile(planPath, "utf-8");
|
|
63
|
+
const { completed, total, tasks } = parseCheckboxes(content);
|
|
64
|
+
|
|
65
|
+
const progress = total > 0 ? (completed / total) * 100 : 0;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
completed,
|
|
69
|
+
total,
|
|
70
|
+
progress,
|
|
71
|
+
tasks,
|
|
72
|
+
};
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Parse markdown checkboxes from plan content
|
|
80
|
+
* @param content - Markdown content
|
|
81
|
+
* @returns Object with completed count, total count, and task list
|
|
82
|
+
*/
|
|
83
|
+
function parseCheckboxes(content: string): {
|
|
84
|
+
completed: number;
|
|
85
|
+
total: number;
|
|
86
|
+
tasks: Array<{ description: string; completed: boolean }>;
|
|
87
|
+
} {
|
|
88
|
+
const checkboxRegex = /-\s+\[([ xX])\]\s*(.+)/g;
|
|
89
|
+
const matches = [...content.matchAll(checkboxRegex)];
|
|
90
|
+
|
|
91
|
+
let completed = 0;
|
|
92
|
+
const tasks: Array<{ description: string; completed: boolean }> = [];
|
|
93
|
+
|
|
94
|
+
for (const match of matches) {
|
|
95
|
+
const isChecked = match[1] === "x" || match[1] === "X";
|
|
96
|
+
const taskText = match[2].trim();
|
|
97
|
+
|
|
98
|
+
if (isChecked) {
|
|
99
|
+
completed++;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
tasks.push({
|
|
103
|
+
description: taskText,
|
|
104
|
+
completed: isChecked,
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
completed,
|
|
110
|
+
total: matches.length,
|
|
111
|
+
tasks,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
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
|
+
}
|