opencodekit 0.12.0 → 0.12.2

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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencodekit",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "CLI tool for bootstrapping and managing OpenCodeKit projects",
5
5
  "type": "module",
6
6
  "repository": {