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.
Files changed (35) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -0
  3. package/package.json +57 -0
  4. package/src/client/dist/assets/index-BN8enf1I.css +1 -0
  5. package/src/client/dist/assets/index-qN9nCkAq.js +41 -0
  6. package/src/client/dist/index.html +14 -0
  7. package/src/client/dist/vite.svg +1 -0
  8. package/src/server/cli.ts +84 -0
  9. package/src/server/index.ts +78 -0
  10. package/src/server/middleware/error.ts +36 -0
  11. package/src/server/routes/health.ts +7 -0
  12. package/src/server/routes/index.ts +18 -0
  13. package/src/server/routes/parts.ts +19 -0
  14. package/src/server/routes/plan.ts +15 -0
  15. package/src/server/routes/poll.ts +77 -0
  16. package/src/server/routes/projects.ts +34 -0
  17. package/src/server/routes/sessions.ts +118 -0
  18. package/src/server/routes/sse.ts +91 -0
  19. package/src/server/services/pollService.ts +220 -0
  20. package/src/server/services/sessionService.ts +476 -0
  21. package/src/server/services/statsService.ts +53 -0
  22. package/src/server/storage/boulderParser.ts +113 -0
  23. package/src/server/storage/messageParser.ts +169 -0
  24. package/src/server/storage/partParser.ts +519 -0
  25. package/src/server/storage/sessionParser.ts +180 -0
  26. package/src/server/utils/sessionStatus.ts +123 -0
  27. package/src/server/validation.ts +34 -0
  28. package/src/server/watcher.ts +160 -0
  29. package/src/shared/constants.ts +22 -0
  30. package/src/shared/index.ts +2 -0
  31. package/src/shared/types/index.ts +326 -0
  32. package/src/shared/utils/RingBuffer.ts +79 -0
  33. package/src/shared/utils/activityUtils.ts +66 -0
  34. package/src/shared/utils/burstGrouping.ts +99 -0
  35. 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
+ }