opencodekit 0.12.4 → 0.12.5

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 (60) hide show
  1. package/dist/index.js +2 -2
  2. package/dist/template/.opencode/command/accessibility-check.md +7 -10
  3. package/dist/template/.opencode/command/analyze-mockup.md +3 -16
  4. package/dist/template/.opencode/command/analyze-project.md +57 -69
  5. package/dist/template/.opencode/command/brainstorm.md +3 -11
  6. package/dist/template/.opencode/command/commit.md +10 -18
  7. package/dist/template/.opencode/command/create.md +4 -8
  8. package/dist/template/.opencode/command/design-audit.md +24 -51
  9. package/dist/template/.opencode/command/design.md +10 -17
  10. package/dist/template/.opencode/command/finish.md +9 -9
  11. package/dist/template/.opencode/command/fix-ci.md +7 -28
  12. package/dist/template/.opencode/command/fix-types.md +3 -7
  13. package/dist/template/.opencode/command/fix-ui.md +5 -11
  14. package/dist/template/.opencode/command/fix.md +4 -10
  15. package/dist/template/.opencode/command/handoff.md +8 -14
  16. package/dist/template/.opencode/command/implement.md +13 -16
  17. package/dist/template/.opencode/command/import-plan.md +20 -38
  18. package/dist/template/.opencode/command/init.md +9 -13
  19. package/dist/template/.opencode/command/integration-test.md +11 -13
  20. package/dist/template/.opencode/command/issue.md +4 -8
  21. package/dist/template/.opencode/command/new-feature.md +20 -40
  22. package/dist/template/.opencode/command/plan.md +8 -12
  23. package/dist/template/.opencode/command/pr.md +29 -38
  24. package/dist/template/.opencode/command/quick-build.md +3 -7
  25. package/dist/template/.opencode/command/research-and-implement.md +4 -6
  26. package/dist/template/.opencode/command/research.md +10 -7
  27. package/dist/template/.opencode/command/resume.md +12 -24
  28. package/dist/template/.opencode/command/revert-feature.md +21 -56
  29. package/dist/template/.opencode/command/review-codebase.md +21 -23
  30. package/dist/template/.opencode/command/skill-create.md +1 -5
  31. package/dist/template/.opencode/command/skill-optimize.md +3 -10
  32. package/dist/template/.opencode/command/status.md +28 -25
  33. package/dist/template/.opencode/command/triage.md +19 -31
  34. package/dist/template/.opencode/command/ui-review.md +6 -13
  35. package/dist/template/.opencode/command.backup/analyze-project.md +465 -0
  36. package/dist/template/.opencode/command.backup/finish.md +167 -0
  37. package/dist/template/.opencode/command.backup/implement.md +143 -0
  38. package/dist/template/.opencode/command.backup/pr.md +252 -0
  39. package/dist/template/.opencode/command.backup/status.md +376 -0
  40. package/dist/template/.opencode/memory/project/SHELL_OUTPUT_MIGRATION_PLAN.md +551 -0
  41. package/dist/template/.opencode/memory/project/gotchas.md +33 -28
  42. package/dist/template/.opencode/opencode.json +14 -28
  43. package/dist/template/.opencode/package.json +1 -3
  44. package/dist/template/.opencode/plugin/compaction.ts +51 -129
  45. package/dist/template/.opencode/plugin/handoff.ts +18 -163
  46. package/dist/template/.opencode/plugin/notification.ts +1 -1
  47. package/dist/template/.opencode/plugin/package.json +7 -0
  48. package/dist/template/.opencode/plugin/sessions.ts +185 -651
  49. package/dist/template/.opencode/plugin/skill-mcp.ts +2 -1
  50. package/dist/template/.opencode/plugin/truncator.ts +19 -41
  51. package/dist/template/.opencode/plugin/tsconfig.json +14 -13
  52. package/dist/template/.opencode/tool/bd-inbox.ts +109 -0
  53. package/dist/template/.opencode/tool/bd-msg.ts +62 -0
  54. package/dist/template/.opencode/tool/bd-release.ts +71 -0
  55. package/dist/template/.opencode/tool/bd-reserve.ts +120 -0
  56. package/package.json +2 -2
  57. package/dist/template/.opencode/plugin/beads.ts +0 -1419
  58. package/dist/template/.opencode/plugin/compactor.ts +0 -107
  59. package/dist/template/.opencode/plugin/enforcer.ts +0 -190
  60. package/dist/template/.opencode/plugin/injector.ts +0 -150
@@ -1,716 +1,250 @@
1
- import { existsSync, readdirSync } from "fs";
2
- import { join } from "path";
3
- import { type Plugin, tool } from "@opencode-ai/plugin";
4
- import { readFile } from "fs/promises";
5
-
6
1
  /**
7
- * read-session plugin - AmpCode-style thread context transfer
8
- *
9
- * Enables short, focused sessions by allowing cross-session context reads.
10
- * Similar to AmpCode's read_thread tool.
11
- *
12
- * OpenCode Storage Structure:
13
- * - Sessions: ~/.local/share/opencode/storage/session/<project_hash>/<session_id>.json
14
- * - Messages: ~/.local/share/opencode/storage/message/<session_id>/msg_*.json
15
- * - Parts: ~/.local/share/opencode/storage/part/<message_id>/*.json
16
- * - Diffs: ~/.local/share/opencode/storage/session_diff/<session_id>.json
2
+ * OpenCode Session Tools
3
+ * Provides session browsing, searching, and context transfer
17
4
  */
18
5
 
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
-
44
- interface SessionMetadata {
45
- id: string;
46
- title?: string;
47
- directory?: string;
48
- projectID?: string;
49
- time?: {
50
- created: number;
51
- updated: number;
52
- };
53
- summary?: any;
54
- }
55
-
56
- interface SessionInfo extends SessionMetadata {
57
- messageCount?: number;
58
- fileCount?: number;
59
- }
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
-
80
- function parseDate(dateStr: string): Date | null {
81
- const today = new Date();
82
- today.setHours(0, 0, 0, 0);
83
-
84
- if (dateStr === "today") {
85
- return today;
86
- }
87
-
88
- if (dateStr === "yesterday") {
89
- const yesterday = new Date(today);
90
- yesterday.setDate(yesterday.getDate() - 1);
91
- return yesterday;
92
- }
93
-
94
- if (dateStr === "this week") {
95
- const weekStart = new Date(today);
96
- weekStart.setDate(weekStart.getDate() - weekStart.getDay());
97
- return weekStart;
98
- }
99
-
100
- // Try parsing ISO date
101
- const parsed = new Date(dateStr);
102
- if (!Number.isNaN(parsed.getTime())) {
103
- return parsed;
104
- }
105
-
106
- return null;
107
- }
108
-
109
- export const SessionsPlugin: Plugin = async ({ client, directory }) => {
110
- const storageDir = join(
111
- process.env.HOME || "",
112
- ".local/share/opencode/storage",
113
- );
114
-
115
- async function getSessionMetadata(
116
- sessionId: string,
117
- ): Promise<SessionMetadata | null> {
118
- const sessionDir = join(storageDir, "session");
119
- if (!existsSync(sessionDir)) return null;
120
-
121
- // Search all project directories
122
- const projectDirs = readdirSync(sessionDir);
123
- for (const projectHash of projectDirs) {
124
- const sessionFile = join(sessionDir, projectHash, `${sessionId}.json`);
125
- if (existsSync(sessionFile)) {
126
- const content = await readFile(sessionFile, "utf-8");
127
- return JSON.parse(content);
128
- }
129
- }
130
- return null;
131
- }
132
-
133
- async function listSessions(
134
- projectDir?: string,
135
- since?: string,
136
- limit = 20,
137
- ): Promise<SessionInfo[]> {
138
- const messageDir = join(storageDir, "message");
139
- if (!existsSync(messageDir)) return [];
140
-
141
- const sessionIds = readdirSync(messageDir).filter((f) =>
142
- f.startsWith("ses_"),
143
- );
144
-
145
- const sessions: SessionInfo[] = [];
146
- let sinceDate: Date | null = null;
147
-
148
- if (since) {
149
- sinceDate = parseDate(since);
150
- }
151
-
152
- for (const sessionId of sessionIds) {
153
- const metadata = await getSessionMetadata(sessionId);
154
- if (!metadata) continue;
155
-
156
- // Filter by project
157
- if (projectDir && metadata.directory !== projectDir) {
158
- continue;
159
- }
160
-
161
- // Filter by date
162
- if (sinceDate && metadata.time) {
163
- const sessionDate = new Date(metadata.time.created);
164
- if (sessionDate < sinceDate) {
165
- continue;
166
- }
167
- }
168
-
169
- // Get message count
170
- const sessionMsgDir = join(messageDir, sessionId);
171
- let messageCount = 0;
172
- if (existsSync(sessionMsgDir)) {
173
- messageCount = readdirSync(sessionMsgDir).filter((f) =>
174
- f.endsWith(".json"),
175
- ).length;
176
- }
177
-
178
- // Get file count
179
- const diffFile = join(storageDir, "session_diff", `${sessionId}.json`);
180
- let fileCount = 0;
181
- if (existsSync(diffFile)) {
182
- const diffContent = await readFile(diffFile, "utf-8");
183
- const diffs = JSON.parse(diffContent);
184
- fileCount = diffs.length;
185
- }
186
-
187
- sessions.push({
188
- ...metadata,
189
- messageCount,
190
- fileCount,
191
- });
192
- }
193
-
194
- // Sort by time descending
195
- sessions.sort((a, b) => {
196
- const aTime = a.time?.created || 0;
197
- const bTime = b.time?.created || 0;
198
- return bTime - aTime;
199
- });
200
-
201
- return sessions.slice(0, limit);
202
- }
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
- }
6
+ import type { Plugin } from "@opencode-ai/plugin";
7
+ import { tool } from "@opencode-ai/plugin/tool";
306
8
 
9
+ export const SessionsPlugin: Plugin = async ({ client }) => {
307
10
  return {
308
11
  tool: {
309
- summarize_session: tool({
310
- description:
311
- "Generate an AI summary of a session using the new auto parameter. Useful for quickly understanding what happened in a previous session.",
312
- args: {
313
- session_id: tool.schema
314
- .string()
315
- .describe("Session ID to summarize (e.g. 'ses_abc123')"),
316
- },
317
- async execute(args) {
318
- try {
319
- // Use compaction model from config: proxypal/gemini-3-flash-preview
320
- const result = await client.session.summarize({
321
- path: { id: args.session_id },
322
- body: {
323
- providerID: "proxypal",
324
- modelID: "gemini-3-flash-preview",
325
- },
326
- });
327
- return `Session summarized successfully.\n\n${JSON.stringify(result, null, 2)}`;
328
- } catch (error) {
329
- return `Error summarizing session: ${error instanceof Error ? error.message : String(error)}`;
330
- }
331
- },
332
- }),
333
-
334
12
  list_sessions: tool({
335
- description:
336
- "List OpenCode sessions with metadata. Filter by project and date. Use this before read_session to discover available sessions.",
13
+ description: "List OpenCode sessions with metadata",
337
14
  args: {
338
- project: tool.schema
339
- .string()
340
- .optional()
341
- .describe(
342
- "Filter by project: 'current' (default), 'all', or absolute path",
343
- ),
344
15
  since: tool.schema
345
16
  .string()
346
17
  .optional()
347
18
  .describe(
348
- "Filter by date: 'today', 'yesterday', 'this week', or ISO date (e.g. '2024-12-10')",
19
+ "Filter by date (today, yesterday, this week, or ISO date)",
349
20
  ),
350
21
  limit: tool.schema
351
22
  .number()
352
23
  .optional()
353
24
  .describe("Max sessions to return (default: 20)"),
354
25
  },
355
- async execute(args) {
356
- const projectFilter =
357
- !args.project || args.project === "current"
358
- ? directory
359
- : args.project === "all"
360
- ? undefined
361
- : args.project;
362
-
363
- try {
364
- const sessions = await withTimeout(
365
- listSessions(projectFilter, args.since, args.limit || 20),
366
- LIST_TIMEOUT_MS,
367
- "List sessions",
368
- );
369
-
370
- if (sessions.length === 0) {
371
- return "No sessions found matching filters.";
26
+ async execute(args: { since?: string; limit?: number }) {
27
+ const result = await client.session.list();
28
+ if (!result.data) return "No sessions found.";
29
+
30
+ let sessions = result.data;
31
+
32
+ // Filter by date
33
+ if (args.since) {
34
+ const sinceDate = parseDate(args.since);
35
+ if (sinceDate) {
36
+ sessions = sessions.filter((s) => {
37
+ const created = s.time?.created
38
+ ? new Date(s.time.created)
39
+ : null;
40
+ return created && created >= sinceDate;
41
+ });
372
42
  }
43
+ }
373
44
 
374
- let output = `# Sessions (${sessions.length})\n\n`;
45
+ // Limit results
46
+ const limited = sessions.slice(0, args.limit || 20);
375
47
 
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
- });
48
+ if (limited.length === 0)
49
+ return "No sessions found matching criteria.";
404
50
 
405
- return output;
406
- } catch (error) {
407
- return `Error listing sessions: ${error instanceof Error ? error.message : String(error)}`;
408
- }
51
+ return `# Sessions\n\n${limited
52
+ .map(
53
+ (s) =>
54
+ `**${s.id}** - ${s.title || "Untitled"}\n Created: ${s.time?.created ? new Date(s.time.created).toLocaleString() : "Unknown"}`,
55
+ )
56
+ .join("\n\n")}`;
409
57
  },
410
58
  }),
411
59
 
412
- search_session: tool({
413
- description:
414
- "Full-text search across session messages. Returns matching messages with excerpts.",
60
+ read_session: tool({
61
+ description: "Read session context for handoff or reference",
415
62
  args: {
416
- query: tool.schema.string().describe("Search query"),
417
- session_id: tool.schema
63
+ session_reference: tool.schema
64
+ .string()
65
+ .describe("Session ID, or 'last' for most recent session"),
66
+ focus: tool.schema
418
67
  .string()
419
68
  .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)"),
69
+ .describe("Focus on specific topic (filters messages by keyword)"),
429
70
  },
430
- async execute(args) {
431
- if (!args.query || args.query.trim().length === 0) {
432
- return "Error: Search query cannot be empty.";
71
+ async execute(args: { session_reference: string; focus?: string }) {
72
+ let sessionId = args.session_reference;
73
+
74
+ // Handle "last" reference
75
+ if (sessionId === "last") {
76
+ const sessions = await client.session.list();
77
+ if (!sessions.data?.length) return "No sessions found.";
78
+ sessionId = sessions.data[0].id;
433
79
  }
434
80
 
435
- const caseSensitive = args.case_sensitive ?? false;
436
- const limit = args.limit ?? 20;
81
+ const session = await client.session.get({ path: { id: sessionId } });
82
+ if (!session.data) return `Session ${sessionId} not found.`;
437
83
 
438
- try {
439
- const searchOperation = async () => {
440
- const allResults: SearchResult[] = [];
84
+ const messages = await client.session.messages({
85
+ path: { id: sessionId },
86
+ });
87
+ const messageData = messages.data;
441
88
 
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
- );
89
+ if (!messageData) return `No messages found in session ${sessionId}.`;
458
90
 
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
- }
470
- }
91
+ let summary = `# Session: ${session.data.title || "Untitled"}\n\n`;
92
+ summary += `**ID:** ${session.data.id}\n`;
93
+ summary += `**Created:** ${session.data.time?.created ? new Date(session.data.time.created).toLocaleString() : "Unknown"}\n`;
94
+ summary += `**Messages:** ${messageData.length}\n\n`;
471
95
 
472
- return allResults;
473
- };
96
+ // Focus filtering
97
+ if (args.focus) {
98
+ summary += `**Focus:** ${args.focus}\n\n`;
99
+ const focusLower = args.focus.toLowerCase();
474
100
 
475
- const results = await withTimeout(
476
- searchOperation(),
477
- SEARCH_TIMEOUT_MS,
478
- "Session search",
101
+ // Filter messages by keyword
102
+ const relevant = messageData.filter(
103
+ (m) =>
104
+ m.info &&
105
+ JSON.stringify(m.info).toLowerCase().includes(focusLower),
479
106
  );
107
+ summary += `Found ${relevant.length} relevant messages.\n\n`;
480
108
 
481
- if (results.length === 0) {
482
- return `No matches found for "${args.query}"${args.session_id ? ` in session ${args.session_id}` : ""}.`;
109
+ relevant.slice(0, 5).forEach((m, i) => {
110
+ summary += `${i + 1}. **${m.info.role}**: `;
111
+ const content = extractContent(m.info);
112
+ summary += `${content.substring(0, 200)}\n\n`;
113
+ });
114
+ } else {
115
+ // Show last 5 user messages
116
+ const userMessages = messageData.filter(
117
+ (m) => m.info?.role === "user",
118
+ );
119
+ summary += `## Recent User Messages\n\n`;
120
+ for (let i = 0; i < Math.min(userMessages.length, 5); i++) {
121
+ const m = userMessages[i];
122
+ const content = extractContent(m.info);
123
+ summary += `${i + 1}. ${content.substring(0, 200)}\n`;
483
124
  }
484
125
 
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}`;
126
+ // Show last assistant message
127
+ const assistantMessages = messageData.filter(
128
+ (m) => m.info?.role === "assistant",
129
+ );
130
+ if (assistantMessages.length > 0) {
131
+ const last = assistantMessages[assistantMessages.length - 1];
132
+ const lastContent = extractContent(last.info);
133
+ summary += `\n## Last Assistant Response\n\n${lastContent.substring(0, 500)}\n`;
489
134
  }
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
135
  }
136
+
137
+ return summary;
503
138
  },
504
139
  }),
505
140
 
506
- read_session: tool({
507
- description: `Read context from a previous OpenCode session. Use list_sessions first to discover sessions. Supports project filtering and date-based selection.`,
141
+ search_session: tool({
142
+ description: "Full-text search across session messages",
508
143
  args: {
509
- session_reference: tool.schema
510
- .string()
511
- .describe(
512
- "Session ID (e.g. 'ses_abc123'), relative ref ('last', 'previous', '2 ago'), or date ('today', 'yesterday')",
513
- ),
514
- project: tool.schema
515
- .string()
516
- .optional()
517
- .describe(
518
- "Filter by project: 'current' (default), 'all', or absolute path. Applied when using relative/date references.",
519
- ),
520
- focus: tool.schema
521
- .string()
144
+ query: tool.schema.string().describe("Search query text"),
145
+ limit: tool.schema
146
+ .number()
522
147
  .optional()
523
- .describe(
524
- "Optional: specific aspect to extract (e.g. 'implementation', 'bug findings', 'file changes')",
525
- ),
148
+ .describe("Max results (default: 10)"),
526
149
  },
527
- async execute(args) {
528
- if (!existsSync(storageDir)) {
529
- return "Error: OpenCode storage directory not found.";
530
- }
150
+ async execute(args: { query: string; limit?: number }) {
151
+ const sessions = await client.session.list();
152
+ const results: string[] = [];
153
+ let searched = 0;
154
+ const searchLimit = args.limit || 10;
531
155
 
532
- let sessionId = args.session_reference;
533
- const projectFilter =
534
- !args.project || args.project === "current"
535
- ? directory
536
- : args.project === "all"
537
- ? undefined
538
- : args.project;
539
-
540
- // Handle date-based references
541
- const parsedDate = parseDate(sessionId);
542
- if (parsedDate) {
543
- const sessions = await listSessions(projectFilter, sessionId, 1);
544
- if (sessions.length === 0) {
545
- return `Error: No sessions found for '${sessionId}' in ${projectFilter || "all projects"}`;
546
- }
547
- sessionId = sessions[0].id;
548
- }
549
- // Handle relative references
550
- else if (sessionId.match(/^(last|previous|\d+\s*(ago|back))$/i)) {
551
- const sessions = await listSessions(projectFilter, undefined, 50);
156
+ if (!sessions.data) return "No sessions found.";
552
157
 
553
- if (sessions.length === 0) {
554
- return `Error: No sessions found in ${projectFilter || "all projects"}`;
555
- }
556
-
557
- if (
558
- sessionId.toLowerCase() === "last" ||
559
- sessionId.toLowerCase() === "previous"
560
- ) {
561
- sessionId = sessions[0].id;
562
- } else {
563
- const match = sessionId.match(/^(\d+)/);
564
- const index = match ? Number.parseInt(match[1]) : 1;
565
- if (index >= sessions.length) {
566
- return `Error: Only ${sessions.length} sessions available, requested ${index} ago`;
567
- }
568
- sessionId = sessions[index].id;
569
- }
570
- }
158
+ // Search sessions until we find enough results
159
+ for (const session of sessions.data) {
160
+ if (results.length >= searchLimit) break;
571
161
 
572
- // Clean session ID
573
- sessionId = sessionId.split("/").pop() || sessionId;
574
- if (!sessionId.startsWith("ses_")) {
575
- return `Error: Invalid session ID format. Expected 'ses_...' but got '${sessionId}'`;
576
- }
162
+ try {
163
+ const messages = await client.session.messages({
164
+ path: { id: session.id },
165
+ });
166
+ const messageData = messages.data;
577
167
 
578
- // Read session metadata, messages, and diffs
579
- const messageDir = join(storageDir, "message", sessionId);
580
- const diffFile = join(
581
- storageDir,
582
- "session_diff",
583
- `${sessionId}.json`,
584
- );
585
-
586
- if (!existsSync(messageDir)) {
587
- const available = await listSessions(projectFilter, undefined, 5);
588
- const list = available.map((s) => s.id).join("\n");
589
- return `Error: Session '${sessionId}' not found.\n\nRecent sessions:\n${list}`;
590
- }
168
+ if (!messageData) continue;
591
169
 
592
- const metadata = await getSessionMetadata(sessionId);
593
-
594
- // Read all message files
595
- const messageFiles = readdirSync(messageDir).filter((f) =>
596
- f.endsWith(".json"),
597
- );
598
- const messages = await Promise.all(
599
- messageFiles.map(async (file) => {
600
- const content = await readFile(join(messageDir, file), "utf-8");
601
- return JSON.parse(content);
602
- }),
603
- );
604
-
605
- // Read diffs if available
606
- let diffs = [];
607
- if (existsSync(diffFile)) {
608
- const diffContent = await readFile(diffFile, "utf-8");
609
- diffs = JSON.parse(diffContent);
610
- }
170
+ const matches = messageData.filter(
171
+ (m) =>
172
+ m.info &&
173
+ JSON.stringify(m.info)
174
+ .toLowerCase()
175
+ .includes(args.query.toLowerCase()),
176
+ );
611
177
 
612
- // Build summary
613
- let summary = `# Session: ${sessionId}\n\n`;
178
+ if (matches.length > 0) {
179
+ const excerpt = extractContent(matches[0].info) || "";
180
+ results.push(
181
+ `**${session.id}** - ${session.title || "Untitled"}\n Matches: ${matches.length}\n Excerpt: ${excerpt.substring(0, 150)}...`,
182
+ );
183
+ }
614
184
 
615
- if (metadata) {
616
- summary += `**Title:** ${metadata.title || "Untitled"}\n`;
617
- summary += `**Project:** ${metadata.directory || "Unknown"}\n`;
618
- if (metadata.time?.created) {
619
- summary += `**Date:** ${new Date(metadata.time.created).toLocaleString()}\n`;
185
+ searched++;
186
+ if (searched >= 50) break; // Don't search too many sessions
187
+ } catch (e) {
188
+ // Skip inaccessible sessions
620
189
  }
621
- summary += "\n";
622
- }
623
-
624
- // Message analysis
625
- const userMessages = messages.filter((m: any) => m.role === "user");
626
- const assistantMessages = messages.filter(
627
- (m: any) => m.role === "assistant",
628
- );
629
-
630
- summary += `**Messages:** ${messages.length} total (${userMessages.length} user, ${assistantMessages.length} assistant)\n`;
631
-
632
- if (diffs.length > 0) {
633
- const totalAdditions = diffs.reduce(
634
- (sum: number, d: any) => sum + (d.additions || 0),
635
- 0,
636
- );
637
- const totalDeletions = diffs.reduce(
638
- (sum: number, d: any) => sum + (d.deletions || 0),
639
- 0,
640
- );
641
- summary += `**File Changes:** ${diffs.length} files (+${totalAdditions}/-${totalDeletions})\n\n`;
642
- } else {
643
- summary += "\n";
644
190
  }
645
191
 
646
- // Focus filtering
647
- if (args.focus) {
648
- summary += `**Focus:** ${args.focus}\n\n`;
649
-
650
- if (
651
- args.focus.toLowerCase().includes("file") ||
652
- args.focus.toLowerCase().includes("change") ||
653
- args.focus.toLowerCase().includes("diff")
654
- ) {
655
- // Show file changes
656
- if (diffs.length > 0) {
657
- summary += `## File Changes\n\n`;
658
- diffs.slice(0, 10).forEach((diff: any) => {
659
- summary += `### ${diff.file}\n`;
660
- summary += `+${diff.additions || 0}/-${diff.deletions || 0}\n\n`;
661
- if (diff.before || diff.after) {
662
- summary += `**Before:** ${(diff.before || "").substring(0, 200)}...\n`;
663
- summary += `**After:** ${(diff.after || "").substring(0, 200)}...\n\n`;
664
- }
665
- });
666
- }
667
- } else {
668
- // Filter messages by keyword
669
- const relevant = messages.filter((m: any) =>
670
- JSON.stringify(m)
671
- .toLowerCase()
672
- .includes(args.focus!.toLowerCase()),
673
- );
674
- summary += `Found ${relevant.length} relevant messages.\n\n`;
675
- }
676
- } else {
677
- // Full summary
678
- summary += `## User Messages\n\n`;
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]";
686
- summary += `${i + 1}. ${content.substring(0, 200)}\n`;
687
- }
688
-
689
- if (diffs.length > 0) {
690
- summary += `\n## Files Modified\n\n`;
691
- diffs.slice(0, 10).forEach((diff: any) => {
692
- summary += `- ${diff.file} (+${diff.additions || 0}/-${diff.deletions || 0})\n`;
693
- });
694
- }
192
+ if (results.length === 0)
193
+ return `No matches found for "${args.query}" in ${searched} sessions searched.`;
695
194
 
696
- // Last assistant message
697
- if (assistantMessages.length > 0) {
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);
702
- const lastContent =
703
- lastPartText ||
704
- last.summary?.body ||
705
- last.content ||
706
- "[No content]";
707
- summary += `\n## Last Assistant Response\n\n${lastContent.substring(0, 500)}\n`;
708
- }
709
- }
195
+ return `# Search Results: "${args.query}"\n\n${results.join("\n\n")}`;
196
+ },
197
+ }),
710
198
 
711
- return summary;
199
+ summarize_session: tool({
200
+ description: "Generate AI summary of a session",
201
+ args: {
202
+ session_id: tool.schema.string().describe("Session ID to summarize"),
203
+ },
204
+ async execute(args: { session_id: string }) {
205
+ // Request summary via OpenCode's summarization
206
+ await client.session.summarize({
207
+ path: { id: args.session_id },
208
+ body: { providerID: "proxypal", modelID: "gemini-3-flash-preview" },
209
+ });
210
+
211
+ return `Summarizing session ${args.session_id}... Summary will be available shortly.`;
712
212
  },
713
213
  }),
714
214
  },
715
215
  };
716
216
  };
217
+
218
+ function parseDate(dateStr: string): Date | null {
219
+ const today = new Date();
220
+ today.setHours(0, 0, 0, 0);
221
+
222
+ if (dateStr === "today") return today;
223
+ if (dateStr === "yesterday") {
224
+ const yesterday = new Date(today);
225
+ yesterday.setDate(yesterday.getDate() - 1);
226
+ return yesterday;
227
+ }
228
+ if (dateStr === "this week") {
229
+ const weekStart = new Date(today);
230
+ weekStart.setDate(weekStart.getDate() - weekStart.getDay());
231
+ return weekStart;
232
+ }
233
+
234
+ const parsed = new Date(dateStr);
235
+ if (!Number.isNaN(parsed.getTime())) return parsed;
236
+
237
+ return null;
238
+ }
239
+
240
+ function extractContent(messageInfo: any): string {
241
+ if (!messageInfo) return "[No info]";
242
+
243
+ // Check for summary object
244
+ if (typeof messageInfo.summary === "object" && messageInfo.summary !== null) {
245
+ if (messageInfo.summary.title) return messageInfo.summary.title;
246
+ if (messageInfo.summary.body) return messageInfo.summary.body;
247
+ }
248
+
249
+ return "[No content]";
250
+ }