opencodekit 0.11.1 → 0.12.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.
@@ -12,9 +12,35 @@ import { readFile } from "fs/promises";
12
12
  * OpenCode Storage Structure:
13
13
  * - Sessions: ~/.local/share/opencode/storage/session/<project_hash>/<session_id>.json
14
14
  * - Messages: ~/.local/share/opencode/storage/message/<session_id>/msg_*.json
15
+ * - Parts: ~/.local/share/opencode/storage/part/<message_id>/*.json
15
16
  * - Diffs: ~/.local/share/opencode/storage/session_diff/<session_id>.json
16
17
  */
17
18
 
19
+ // Constants
20
+ const MAX_SESSIONS_TO_SCAN = 50;
21
+ const SEARCH_TIMEOUT_MS = 60000;
22
+ const LIST_TIMEOUT_MS = 30000;
23
+ const READ_TIMEOUT_MS = 30000;
24
+
25
+ /**
26
+ * Wrap a promise with a timeout
27
+ */
28
+ function withTimeout<T>(
29
+ promise: Promise<T>,
30
+ ms: number,
31
+ operation: string,
32
+ ): Promise<T> {
33
+ return Promise.race([
34
+ promise,
35
+ new Promise<T>((_, reject) =>
36
+ setTimeout(
37
+ () => reject(new Error(`${operation} timed out after ${ms}ms`)),
38
+ ms,
39
+ ),
40
+ ),
41
+ ]);
42
+ }
43
+
18
44
  interface SessionMetadata {
19
45
  id: string;
20
46
  title?: string;
@@ -32,6 +58,25 @@ interface SessionInfo extends SessionMetadata {
32
58
  fileCount?: number;
33
59
  }
34
60
 
61
+ interface MessagePart {
62
+ id: string;
63
+ type: string;
64
+ text?: string;
65
+ thinking?: string;
66
+ tool?: string;
67
+ input?: unknown;
68
+ output?: unknown;
69
+ }
70
+
71
+ interface SearchResult {
72
+ session_id: string;
73
+ message_id: string;
74
+ role: string;
75
+ excerpt: string;
76
+ match_count: number;
77
+ timestamp?: number;
78
+ }
79
+
35
80
  function parseDate(dateStr: string): Date | null {
36
81
  const today = new Date();
37
82
  today.setHours(0, 0, 0, 0);
@@ -156,6 +201,109 @@ export const SessionsPlugin: Plugin = async ({ client, directory }) => {
156
201
  return sessions.slice(0, limit);
157
202
  }
158
203
 
204
+ /**
205
+ * Read message parts from part storage
206
+ */
207
+ async function readMessageParts(messageId: string): Promise<MessagePart[]> {
208
+ const partDir = join(storageDir, "part", messageId);
209
+ if (!existsSync(partDir)) return [];
210
+
211
+ try {
212
+ const partFiles = readdirSync(partDir).filter((f) => f.endsWith(".json"));
213
+ const parts = await Promise.all(
214
+ partFiles.map(async (file) => {
215
+ try {
216
+ const content = await readFile(join(partDir, file), "utf-8");
217
+ return JSON.parse(content) as MessagePart;
218
+ } catch {
219
+ return null;
220
+ }
221
+ }),
222
+ );
223
+ return parts.filter((p): p is MessagePart => p !== null);
224
+ } catch {
225
+ return [];
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Extract text content from message parts
231
+ */
232
+ function extractTextFromParts(parts: MessagePart[]): string {
233
+ return parts
234
+ .filter((p) => p.type === "text" && p.text)
235
+ .map((p) => p.text!)
236
+ .join("\n");
237
+ }
238
+
239
+ /**
240
+ * Search within a single session
241
+ */
242
+ async function searchInSession(
243
+ sessionId: string,
244
+ query: string,
245
+ caseSensitive: boolean,
246
+ maxResults: number,
247
+ ): Promise<SearchResult[]> {
248
+ const messageDir = join(storageDir, "message", sessionId);
249
+ if (!existsSync(messageDir)) return [];
250
+
251
+ const results: SearchResult[] = [];
252
+ const searchQuery = caseSensitive ? query : query.toLowerCase();
253
+
254
+ try {
255
+ const messageFiles = readdirSync(messageDir).filter((f) =>
256
+ f.endsWith(".json"),
257
+ );
258
+
259
+ for (const file of messageFiles) {
260
+ if (results.length >= maxResults) break;
261
+
262
+ try {
263
+ const content = await readFile(join(messageDir, file), "utf-8");
264
+ const message = JSON.parse(content);
265
+ const messageId = message.id || file.replace(".json", "");
266
+
267
+ // Read parts for this message
268
+ const parts = await readMessageParts(messageId);
269
+ const textContent = extractTextFromParts(parts);
270
+ const searchText = caseSensitive
271
+ ? textContent
272
+ : textContent.toLowerCase();
273
+
274
+ if (searchText.includes(searchQuery)) {
275
+ // Count matches
276
+ const matchCount = searchText.split(searchQuery).length - 1;
277
+
278
+ // Extract excerpt (50 chars around first match)
279
+ const matchIndex = searchText.indexOf(searchQuery);
280
+ const start = Math.max(0, matchIndex - 25);
281
+ const end = Math.min(
282
+ textContent.length,
283
+ matchIndex + query.length + 25,
284
+ );
285
+ const excerpt = textContent.substring(start, end);
286
+
287
+ results.push({
288
+ session_id: sessionId,
289
+ message_id: messageId,
290
+ role: message.role || "unknown",
291
+ excerpt: `...${excerpt}...`,
292
+ match_count: matchCount,
293
+ timestamp: message.time?.created,
294
+ });
295
+ }
296
+ } catch {
297
+ // Skip corrupted message files
298
+ }
299
+ }
300
+ } catch {
301
+ // Skip inaccessible session directories
302
+ }
303
+
304
+ return results;
305
+ }
306
+
159
307
  return {
160
308
  tool: {
161
309
  summarize_session: tool({
@@ -212,48 +360,146 @@ export const SessionsPlugin: Plugin = async ({ client, directory }) => {
212
360
  ? undefined
213
361
  : args.project;
214
362
 
215
- const sessions = await listSessions(
216
- projectFilter,
217
- args.since,
218
- args.limit || 20,
219
- );
363
+ try {
364
+ const sessions = await withTimeout(
365
+ listSessions(projectFilter, args.since, args.limit || 20),
366
+ LIST_TIMEOUT_MS,
367
+ "List sessions",
368
+ );
220
369
 
221
- if (sessions.length === 0) {
222
- return "No sessions found matching filters.";
223
- }
370
+ if (sessions.length === 0) {
371
+ return "No sessions found matching filters.";
372
+ }
224
373
 
225
- let output = `# Sessions (${sessions.length})\n\n`;
374
+ let output = `# Sessions (${sessions.length})\n\n`;
226
375
 
227
- if (projectFilter && projectFilter !== "all") {
228
- output += `**Project:** ${projectFilter}\n`;
376
+ if (projectFilter && projectFilter !== "all") {
377
+ output += `**Project:** ${projectFilter}\n`;
378
+ }
379
+ if (args.since) {
380
+ output += `**Since:** ${args.since}\n`;
381
+ }
382
+ output += "\n";
383
+
384
+ sessions.forEach((s, i) => {
385
+ const date = s.time?.created
386
+ ? new Date(s.time.created).toLocaleString()
387
+ : "Unknown";
388
+ const title = s.title || s.id;
389
+ output += `${i + 1}. **${s.id}**\n`;
390
+ output += ` Title: ${title}\n`;
391
+ output += ` Date: ${date}\n`;
392
+ output += ` Messages: ${s.messageCount || 0}, Files: ${s.fileCount || 0}\n`;
393
+ if (s.summary && typeof s.summary === "object") {
394
+ const sumObj = s.summary as any;
395
+ if (
396
+ sumObj.additions !== undefined ||
397
+ sumObj.deletions !== undefined
398
+ ) {
399
+ output += ` Changes: +${sumObj.additions || 0}/-${sumObj.deletions || 0}\n`;
400
+ }
401
+ }
402
+ output += "\n";
403
+ });
404
+
405
+ return output;
406
+ } catch (error) {
407
+ return `Error listing sessions: ${error instanceof Error ? error.message : String(error)}`;
229
408
  }
230
- if (args.since) {
231
- output += `**Since:** ${args.since}\n`;
409
+ },
410
+ }),
411
+
412
+ search_session: tool({
413
+ description:
414
+ "Full-text search across session messages. Returns matching messages with excerpts.",
415
+ args: {
416
+ query: tool.schema.string().describe("Search query"),
417
+ session_id: tool.schema
418
+ .string()
419
+ .optional()
420
+ .describe("Limit search to specific session ID"),
421
+ case_sensitive: tool.schema
422
+ .boolean()
423
+ .optional()
424
+ .describe("Case sensitive search (default: false)"),
425
+ limit: tool.schema
426
+ .number()
427
+ .optional()
428
+ .describe("Max results to return (default: 20)"),
429
+ },
430
+ async execute(args) {
431
+ if (!args.query || args.query.trim().length === 0) {
432
+ return "Error: Search query cannot be empty.";
232
433
  }
233
- output += "\n";
234
-
235
- sessions.forEach((s, i) => {
236
- const date = s.time?.created
237
- ? new Date(s.time.created).toLocaleString()
238
- : "Unknown";
239
- const title = s.title || s.id;
240
- output += `${i + 1}. **${s.id}**\n`;
241
- output += ` Title: ${title}\n`;
242
- output += ` Date: ${date}\n`;
243
- output += ` Messages: ${s.messageCount || 0}, Files: ${s.fileCount || 0}\n`;
244
- if (s.summary && typeof s.summary === "object") {
245
- const sumObj = s.summary as any;
246
- if (
247
- sumObj.additions !== undefined ||
248
- sumObj.deletions !== undefined
249
- ) {
250
- output += ` Changes: +${sumObj.additions || 0}/-${sumObj.deletions || 0}\n`;
434
+
435
+ const caseSensitive = args.case_sensitive ?? false;
436
+ const limit = args.limit ?? 20;
437
+
438
+ try {
439
+ const searchOperation = async () => {
440
+ const allResults: SearchResult[] = [];
441
+
442
+ if (args.session_id) {
443
+ // Search specific session
444
+ const results = await searchInSession(
445
+ args.session_id,
446
+ args.query,
447
+ caseSensitive,
448
+ limit,
449
+ );
450
+ allResults.push(...results);
451
+ } else {
452
+ // Search across sessions (limit to MAX_SESSIONS_TO_SCAN)
453
+ const sessions = await listSessions(
454
+ directory,
455
+ undefined,
456
+ MAX_SESSIONS_TO_SCAN,
457
+ );
458
+
459
+ for (const session of sessions) {
460
+ if (allResults.length >= limit) break;
461
+
462
+ const results = await searchInSession(
463
+ session.id,
464
+ args.query,
465
+ caseSensitive,
466
+ limit - allResults.length,
467
+ );
468
+ allResults.push(...results);
469
+ }
251
470
  }
471
+
472
+ return allResults;
473
+ };
474
+
475
+ const results = await withTimeout(
476
+ searchOperation(),
477
+ SEARCH_TIMEOUT_MS,
478
+ "Session search",
479
+ );
480
+
481
+ if (results.length === 0) {
482
+ return `No matches found for "${args.query}"${args.session_id ? ` in session ${args.session_id}` : ""}.`;
252
483
  }
253
- output += "\n";
254
- });
255
484
 
256
- return output;
485
+ let output = `# Search Results: "${args.query}"\n\n`;
486
+ output += `Found ${results.length} match${results.length === 1 ? "" : "es"}`;
487
+ if (args.session_id) {
488
+ output += ` in session ${args.session_id}`;
489
+ }
490
+ output += "\n\n";
491
+
492
+ results.forEach((r, i) => {
493
+ output += `${i + 1}. **${r.session_id}** (${r.role})\n`;
494
+ output += ` Message: ${r.message_id}\n`;
495
+ output += ` Matches: ${r.match_count}\n`;
496
+ output += ` Excerpt: ${r.excerpt}\n\n`;
497
+ });
498
+
499
+ return output;
500
+ } catch (error) {
501
+ return `Error searching sessions: ${error instanceof Error ? error.message : String(error)}`;
502
+ }
257
503
  },
258
504
  }),
259
505
 
@@ -430,10 +676,15 @@ export const SessionsPlugin: Plugin = async ({ client, directory }) => {
430
676
  } else {
431
677
  // Full summary
432
678
  summary += `## User Messages\n\n`;
433
- userMessages.slice(0, 5).forEach((m: any, i: number) => {
434
- const content = m.summary?.title || m.content || "[No content]";
679
+ for (let i = 0; i < Math.min(userMessages.length, 5); i++) {
680
+ const m = userMessages[i];
681
+ const messageId = m.id || `msg_${i}`;
682
+ const parts = await readMessageParts(messageId);
683
+ const partText = extractTextFromParts(parts);
684
+ const content =
685
+ partText || m.summary?.title || m.content || "[No content]";
435
686
  summary += `${i + 1}. ${content.substring(0, 200)}\n`;
436
- });
687
+ }
437
688
 
438
689
  if (diffs.length > 0) {
439
690
  summary += `\n## Files Modified\n\n`;
@@ -445,8 +696,14 @@ export const SessionsPlugin: Plugin = async ({ client, directory }) => {
445
696
  // Last assistant message
446
697
  if (assistantMessages.length > 0) {
447
698
  const last = assistantMessages[assistantMessages.length - 1];
699
+ const lastMessageId = last.id || `msg_last`;
700
+ const lastParts = await readMessageParts(lastMessageId);
701
+ const lastPartText = extractTextFromParts(lastParts);
448
702
  const lastContent =
449
- last.summary?.body || last.content || "[No content]";
703
+ lastPartText ||
704
+ last.summary?.body ||
705
+ last.content ||
706
+ "[No content]";
450
707
  summary += `\n## Last Assistant Response\n\n${lastContent.substring(0, 500)}\n`;
451
708
  }
452
709
  }
@@ -129,10 +129,6 @@ bd_ls({ status: "all", limit: 20 });
129
129
  // See ready work (unblocked tasks)
130
130
  bd_ready();
131
131
  // Returns tasks where all dependencies are closed
132
-
133
- // See execution plan with parallel tracks
134
- bd_plan();
135
- // Groups ready tasks by priority
136
132
  ```
137
133
 
138
134
  ## Session Start Protocol
@@ -354,29 +350,21 @@ bd_status({ include_agents: true });
354
350
 
355
351
  Shows ready tasks, in-progress count, active locks, agent info.
356
352
 
357
- ### Find Bottlenecks
358
-
359
- ```typescript
360
- bd_insights();
361
- ```
362
-
363
- Returns blocked tasks, high-priority keystones, dependency analysis.
364
-
365
- ### Priority Recommendations
353
+ ### Find Blocked Work
366
354
 
367
355
  ```typescript
368
- bd_priority({ limit: 5 });
356
+ bd_blocked();
369
357
  ```
370
358
 
371
- Suggests what to work on next based on priority and dependencies.
359
+ Returns tasks that have unresolved dependencies.
372
360
 
373
- ### Execution Plan
361
+ ### View Dependencies
374
362
 
375
363
  ```typescript
376
- bd_plan();
364
+ bd_dep({ action: "tree", child: "<task-id>", type: "blocks" });
377
365
  ```
378
366
 
379
- Groups ready tasks by priority into parallel execution tracks.
367
+ Shows dependency tree for a specific task.
380
368
 
381
369
  ## Git Sync
382
370
 
@@ -4,7 +4,7 @@ Beads supports task dependencies for ordering work.
4
4
 
5
5
  ## Overview
6
6
 
7
- Dependencies affect what work is "ready" - tasks with unmet dependencies won't appear in `bd_claim()` or `bd_priority()`.
7
+ Dependencies affect what work is "ready" - tasks with unmet dependencies won't appear in `bd_claim()` results.
8
8
 
9
9
  ## Creating Dependencies
10
10
 
@@ -125,6 +125,6 @@ bd_add({ title: "API", deps: ["task:tests"] }) // API depends on tests?
125
125
  bd_show({ id: "task-abc" });
126
126
  // Shows what blocks this task and what this task blocks
127
127
 
128
- bd_insights();
129
- // Shows bottlenecks and blocked work across project
128
+ bd_blocked();
129
+ // Shows all blocked tasks across project
130
130
  ```
@@ -138,7 +138,7 @@ Session Start with in_progress:
138
138
  ```
139
139
  Unblocking:
140
140
  - [ ] bd_ls({ status: "open" }) to see all tasks
141
- - [ ] bd_insights() to find bottlenecks
141
+ - [ ] bd_blocked() to find blocked tasks
142
142
  - [ ] Identify blocker tasks
143
143
  - [ ] Work on blockers first
144
144
  - [ ] Closing blocker unblocks dependent work
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencodekit",
3
- "version": "0.11.1",
3
+ "version": "0.12.1",
4
4
  "description": "CLI tool for bootstrapping and managing OpenCodeKit projects",
5
5
  "type": "module",
6
6
  "repository": {