opencodekit 0.12.4 → 0.12.6
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/dist/index.js +17 -5
- package/dist/template/.opencode/agent/build.md +25 -11
- package/dist/template/.opencode/agent/rush.md +6 -4
- package/dist/template/.opencode/command/accessibility-check.md +7 -10
- package/dist/template/.opencode/command/analyze-mockup.md +3 -16
- package/dist/template/.opencode/command/analyze-project.md +57 -69
- package/dist/template/.opencode/command/brainstorm.md +3 -11
- package/dist/template/.opencode/command/commit.md +10 -18
- package/dist/template/.opencode/command/create.md +4 -8
- package/dist/template/.opencode/command/design-audit.md +24 -51
- package/dist/template/.opencode/command/design.md +10 -17
- package/dist/template/.opencode/command/finish.md +9 -9
- package/dist/template/.opencode/command/fix-ci.md +7 -28
- package/dist/template/.opencode/command/fix-types.md +3 -7
- package/dist/template/.opencode/command/fix-ui.md +5 -11
- package/dist/template/.opencode/command/fix.md +4 -10
- package/dist/template/.opencode/command/handoff.md +8 -14
- package/dist/template/.opencode/command/implement.md +13 -16
- package/dist/template/.opencode/command/import-plan.md +20 -38
- package/dist/template/.opencode/command/init.md +9 -13
- package/dist/template/.opencode/command/integration-test.md +11 -13
- package/dist/template/.opencode/command/issue.md +4 -8
- package/dist/template/.opencode/command/new-feature.md +20 -40
- package/dist/template/.opencode/command/plan.md +8 -12
- package/dist/template/.opencode/command/pr.md +29 -38
- package/dist/template/.opencode/command/quick-build.md +3 -7
- package/dist/template/.opencode/command/research-and-implement.md +4 -6
- package/dist/template/.opencode/command/research.md +10 -7
- package/dist/template/.opencode/command/resume.md +12 -24
- package/dist/template/.opencode/command/revert-feature.md +21 -56
- package/dist/template/.opencode/command/review-codebase.md +21 -23
- package/dist/template/.opencode/command/skill-create.md +1 -5
- package/dist/template/.opencode/command/skill-optimize.md +3 -10
- package/dist/template/.opencode/command/status.md +28 -25
- package/dist/template/.opencode/command/triage.md +19 -31
- package/dist/template/.opencode/command/ui-review.md +6 -13
- package/dist/template/.opencode/command.backup/analyze-project.md +465 -0
- package/dist/template/.opencode/command.backup/finish.md +167 -0
- package/dist/template/.opencode/command.backup/implement.md +143 -0
- package/dist/template/.opencode/command.backup/pr.md +252 -0
- package/dist/template/.opencode/command.backup/status.md +376 -0
- package/dist/template/.opencode/memory/project/SHELL_OUTPUT_MIGRATION_PLAN.md +551 -0
- package/dist/template/.opencode/memory/project/gotchas.md +33 -28
- package/dist/template/.opencode/opencode.json +24 -84
- package/dist/template/.opencode/package.json +1 -3
- package/dist/template/.opencode/plugin/compaction.ts +51 -129
- package/dist/template/.opencode/plugin/handoff.ts +18 -163
- package/dist/template/.opencode/plugin/notification.ts +1 -1
- package/dist/template/.opencode/plugin/package.json +7 -0
- package/dist/template/.opencode/plugin/sessions.ts +185 -651
- package/dist/template/.opencode/plugin/skill-mcp.ts +2 -1
- package/dist/template/.opencode/plugin/truncator.ts +19 -41
- package/dist/template/.opencode/plugin/tsconfig.json +14 -13
- package/dist/template/.opencode/tool/bd-inbox.ts +109 -0
- package/dist/template/.opencode/tool/bd-msg.ts +62 -0
- package/dist/template/.opencode/tool/bd-release.ts +71 -0
- package/dist/template/.opencode/tool/bd-reserve.ts +120 -0
- package/package.json +17 -5
- package/dist/template/.opencode/plugin/beads.ts +0 -1419
- package/dist/template/.opencode/plugin/compactor.ts +0 -107
- package/dist/template/.opencode/plugin/enforcer.ts +0 -190
- 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
|
-
*
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
-
|
|
45
|
+
// Limit results
|
|
46
|
+
const limited = sessions.slice(0, args.limit || 20);
|
|
375
47
|
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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
|
-
|
|
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
|
-
|
|
417
|
-
|
|
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("
|
|
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
|
-
|
|
432
|
-
|
|
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
|
|
436
|
-
|
|
81
|
+
const session = await client.session.get({ path: { id: sessionId } });
|
|
82
|
+
if (!session.data) return `Session ${sessionId} not found.`;
|
|
437
83
|
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
84
|
+
const messages = await client.session.messages({
|
|
85
|
+
path: { id: sessionId },
|
|
86
|
+
});
|
|
87
|
+
const messageData = messages.data;
|
|
441
88
|
|
|
442
|
-
|
|
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
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
473
|
-
|
|
96
|
+
// Focus filtering
|
|
97
|
+
if (args.focus) {
|
|
98
|
+
summary += `**Focus:** ${args.focus}\n\n`;
|
|
99
|
+
const focusLower = args.focus.toLowerCase();
|
|
474
100
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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
|
-
|
|
482
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
-
|
|
507
|
-
description:
|
|
141
|
+
search_session: tool({
|
|
142
|
+
description: "Full-text search across session messages",
|
|
508
143
|
args: {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
.
|
|
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
|
-
|
|
529
|
-
|
|
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
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
162
|
+
try {
|
|
163
|
+
const messages = await client.session.messages({
|
|
164
|
+
path: { id: session.id },
|
|
165
|
+
});
|
|
166
|
+
const messageData = messages.data;
|
|
577
167
|
|
|
578
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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
|
-
|
|
613
|
-
|
|
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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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
|
-
|
|
647
|
-
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
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
|
-
|
|
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
|
+
}
|