opencode-session-search 0.1.0

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/index.ts ADDED
@@ -0,0 +1,425 @@
1
+ import { type Plugin, tool } from "@opencode-ai/plugin";
2
+ import { Database } from "bun:sqlite";
3
+ import { spawnSync } from "node:child_process";
4
+ import { join } from "node:path";
5
+
6
+ const FALLBACK_DB_PATH = join(
7
+ process.env.HOME || "",
8
+ ".local",
9
+ "share",
10
+ "opencode",
11
+ "opencode.db",
12
+ );
13
+
14
+ const resolveDbPath = () => {
15
+ if (process.env.OPENCODE_DB_PATH) {
16
+ return process.env.OPENCODE_DB_PATH;
17
+ }
18
+
19
+ try {
20
+ const result = spawnSync("opencode", ["db", "path"], {
21
+ encoding: "utf8",
22
+ stdio: ["ignore", "pipe", "ignore"],
23
+ });
24
+
25
+ if (result.status === 0) {
26
+ const path = (result.stdout || "").trim();
27
+ if (path) return path;
28
+ }
29
+ } catch {
30
+ // fall through to fallback path
31
+ }
32
+
33
+ return FALLBACK_DB_PATH;
34
+ };
35
+
36
+ const DEFAULT_DB_PATH = resolveDbPath();
37
+
38
+ const openReadonlyDb = () => {
39
+ try {
40
+ return {
41
+ db: new Database(DEFAULT_DB_PATH, { readonly: true, create: false, strict: true }),
42
+ error: null,
43
+ };
44
+ } catch (error) {
45
+ return {
46
+ db: null,
47
+ error: error instanceof Error ? error.message : String(error),
48
+ };
49
+ }
50
+ };
51
+
52
+ const escapeLike = (value: string) =>
53
+ value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
54
+
55
+ const SEARCH_CONFIG = {
56
+ roles: ["user", "assistant"],
57
+ defaultSessions: 6,
58
+ maxSessions: 12,
59
+ snippetsPerSession: 2,
60
+ snippetLength: 220,
61
+ sinceHours: 24 * 180,
62
+ } as const;
63
+
64
+ const TRANSCRIPT_CONFIG = {
65
+ roles: ["user", "assistant"],
66
+ defaultLimit: 80,
67
+ maxLimit: 120,
68
+ maxCharsPerEntry: 600,
69
+ includeEmpty: false,
70
+ } as const;
71
+
72
+ type SessionMetaRow = {
73
+ id: string;
74
+ title: string | null;
75
+ directory: string | null;
76
+ slug: string | null;
77
+ time_created: number;
78
+ time_updated: number;
79
+ worktree: string | null;
80
+ project_name: string | null;
81
+ };
82
+
83
+ type SessionSearchInput = {
84
+ query: string;
85
+ limitSessions?: number;
86
+ };
87
+
88
+ const toSafeLimitSessions = (value: unknown) => {
89
+ const parsed = Number(value);
90
+ if (!Number.isFinite(parsed)) return SEARCH_CONFIG.defaultSessions;
91
+ const integer = Math.trunc(parsed);
92
+ if (integer < 1) return 1;
93
+ if (integer > SEARCH_CONFIG.maxSessions) return SEARCH_CONFIG.maxSessions;
94
+ return integer;
95
+ };
96
+
97
+ export const runSessionSearch = (input: SessionSearchInput) => {
98
+ const term = String(input.query || "").trim();
99
+
100
+ if (!term) {
101
+ return {
102
+ query: term,
103
+ sessions: [],
104
+ stats: {
105
+ totalSessions: 0,
106
+ totalMatches: 0,
107
+ },
108
+ error: {
109
+ code: "INVALID_QUERY",
110
+ message: "query must contain at least one non-whitespace character",
111
+ },
112
+ };
113
+ }
114
+
115
+ const { db, error } = openReadonlyDb();
116
+
117
+ if (!db) {
118
+ return {
119
+ query: term,
120
+ sessions: [],
121
+ stats: {
122
+ totalSessions: 0,
123
+ totalMatches: 0,
124
+ },
125
+ error: {
126
+ code: "DB_OPEN_FAILED",
127
+ message: "Unable to open OpenCode history database",
128
+ details: error,
129
+ dbPath: DEFAULT_DB_PATH,
130
+ },
131
+ };
132
+ }
133
+
134
+ try {
135
+ const textExpr = "json_extract(p.data, '$.text')";
136
+ const words = term.toLowerCase().split(/\s+/).filter(Boolean);
137
+
138
+ const where: string[] = [
139
+ "json_extract(p.data, '$.type') = 'text'",
140
+ `${textExpr} IS NOT NULL`,
141
+ "json_extract(m.data, '$.role') IN ('user','assistant')",
142
+ ];
143
+
144
+ const cutoff = Date.now() - SEARCH_CONFIG.sinceHours * 60 * 60 * 1000;
145
+ const params: any[] = [];
146
+
147
+ for (const word of words) {
148
+ where.push("lower(json_extract(p.data, '$.text')) LIKE ? ESCAPE '\\'");
149
+ params.push(`%${escapeLike(word)}%`);
150
+ }
151
+
152
+ where.push("p.time_created >= ?");
153
+ params.push(cutoff);
154
+
155
+ const sessionSql = `
156
+ SELECT
157
+ s.id AS session_id,
158
+ s.title AS title,
159
+ s.directory AS directory,
160
+ COUNT(*) AS match_count,
161
+ MAX(p.time_created) AS last_match_ms
162
+ FROM part p
163
+ JOIN message m ON m.id = p.message_id
164
+ JOIN session s ON s.id = p.session_id
165
+ WHERE ${where.join(" AND ")}
166
+ GROUP BY s.id
167
+ ORDER BY COUNT(*) DESC, MAX(p.time_created) DESC
168
+ LIMIT ?
169
+ `;
170
+
171
+ const limitSessions = toSafeLimitSessions(input.limitSessions);
172
+ const sessionRows = db.query(sessionSql).all(...params, limitSessions);
173
+ const totalMatches = sessionRows.reduce((sum: number, row: any) => sum + Number(row.match_count || 0), 0);
174
+
175
+ const snippetSql = `
176
+ SELECT
177
+ p.session_id AS session_id,
178
+ p.time_created AS time_created,
179
+ json_extract(m.data, '$.role') AS role,
180
+ substr(${textExpr}, 1, ?) AS snippet
181
+ FROM part p
182
+ JOIN message m ON m.id = p.message_id
183
+ JOIN session s ON s.id = p.session_id
184
+ WHERE ${where.join(" AND ")}
185
+ AND p.session_id = ?
186
+ ORDER BY p.time_created DESC
187
+ LIMIT ?
188
+ `;
189
+
190
+ const baseSnippetParams = [...params];
191
+ const sessions = sessionRows.map((row: any) => {
192
+ const snippetRows = db
193
+ .query(snippetSql)
194
+ .all(SEARCH_CONFIG.snippetLength, ...baseSnippetParams, row.session_id, SEARCH_CONFIG.snippetsPerSession)
195
+ .map((snippet: any) => ({
196
+ time: new Date(Number(snippet.time_created)).toISOString(),
197
+ role: snippet.role,
198
+ text: snippet.snippet,
199
+ }));
200
+
201
+ return {
202
+ sessionId: row.session_id,
203
+ title: row.title,
204
+ directory: row.directory,
205
+ matchCount: Number(row.match_count || 0),
206
+ lastMatch: new Date(Number(row.last_match_ms)).toISOString(),
207
+ snippets: snippetRows,
208
+ };
209
+ });
210
+
211
+ const suggestedTranscriptCalls = sessions.slice(0, 3).map((session: any) => ({
212
+ tool: "session-transcript",
213
+ args: {
214
+ sessionId: session.sessionId,
215
+ limit: 60,
216
+ order: "asc",
217
+ },
218
+ }));
219
+
220
+ return {
221
+ query: term,
222
+ filters: {
223
+ roles: [...SEARCH_CONFIG.roles],
224
+ sinceHours: SEARCH_CONFIG.sinceHours,
225
+ snippetsPerSession: SEARCH_CONFIG.snippetsPerSession,
226
+ snippetLength: SEARCH_CONFIG.snippetLength,
227
+ },
228
+ stats: {
229
+ totalSessions: sessions.length,
230
+ totalMatches,
231
+ },
232
+ sessions,
233
+ nextStep: {
234
+ message:
235
+ "If you need full context, always ask the user to pick a session with the question tool (first option is recommended), then call session-transcript for the selected sessionId.",
236
+ suggestedCalls: suggestedTranscriptCalls,
237
+ suggestedQuestionCall: {
238
+ tool: "question",
239
+ args: {
240
+ questions: [
241
+ {
242
+ header: "Pick session",
243
+ question: "Which session should I open for full transcript context?",
244
+ options: sessions.slice(0, 8).map((session: any, index: number) => ({
245
+ label: `${session.sessionId.slice(0, 24)}${index === 0 ? "*" : ""}`,
246
+ description:
247
+ session.title || session.directory || `matchCount=${session.matchCount}`,
248
+ })),
249
+ multiple: false,
250
+ },
251
+ ],
252
+ },
253
+ },
254
+ },
255
+ };
256
+ } finally {
257
+ db.close();
258
+ }
259
+ };
260
+
261
+ export const SessionHistoryPlugin: Plugin = async () => {
262
+ return {
263
+ tool: {
264
+ "session-search": tool({
265
+ description:
266
+ "Read-only search over local opencode chat history. Returns matching sessions and snippets. Use the session-transcript tool to fetch the full conversation context.",
267
+ args: {
268
+ query: tool.schema
269
+ .string()
270
+ .trim()
271
+ .min(1)
272
+ .max(200)
273
+ .describe("Text to search for in chat history."),
274
+ limitSessions: tool.schema
275
+ .number()
276
+ .int()
277
+ .min(1)
278
+ .max(SEARCH_CONFIG.maxSessions)
279
+ .default(SEARCH_CONFIG.defaultSessions)
280
+ .describe("Maximum number of matching sessions to return."),
281
+ },
282
+ async execute(args: any) {
283
+ return JSON.stringify(runSessionSearch(args));
284
+ },
285
+ }),
286
+
287
+ "session-transcript": tool({
288
+ description:
289
+ "Read-only transcript reconstruction for a specific opencode session. Use after session-search to fetch full conversational context.",
290
+ args: {
291
+ sessionId: tool.schema
292
+ .string()
293
+ .min(5)
294
+ .describe("Session ID to reconstruct transcript from (for example, ses_xxx)."),
295
+ limit: tool.schema
296
+ .number()
297
+ .int()
298
+ .min(1)
299
+ .max(TRANSCRIPT_CONFIG.maxLimit)
300
+ .default(TRANSCRIPT_CONFIG.defaultLimit)
301
+ .describe("Maximum transcript entries returned."),
302
+ order: tool.schema
303
+ .enum(["asc", "desc"])
304
+ .default("asc")
305
+ .describe("Chronological or reverse-chronological ordering."),
306
+ },
307
+ async execute(args: any) {
308
+ const { db, error } = openReadonlyDb();
309
+
310
+ if (!db) {
311
+ return JSON.stringify({
312
+ sessionId: args.sessionId,
313
+ found: false,
314
+ error: {
315
+ code: "DB_OPEN_FAILED",
316
+ message: "Unable to open OpenCode history database",
317
+ details: error,
318
+ dbPath: DEFAULT_DB_PATH,
319
+ },
320
+ entries: [],
321
+ });
322
+ }
323
+
324
+ try {
325
+ const sessionMeta = db
326
+ .query(
327
+ `
328
+ SELECT
329
+ s.id,
330
+ s.title,
331
+ s.directory,
332
+ s.slug,
333
+ s.time_created,
334
+ s.time_updated,
335
+ p.worktree,
336
+ p.name AS project_name
337
+ FROM session s
338
+ LEFT JOIN project p ON p.id = s.project_id
339
+ WHERE s.id = ?
340
+ LIMIT 1
341
+ `,
342
+ )
343
+ .get(args.sessionId) as SessionMetaRow | null;
344
+
345
+ if (!sessionMeta) {
346
+ return JSON.stringify({
347
+ sessionId: args.sessionId,
348
+ found: false,
349
+ error: "Session not found",
350
+ entries: [],
351
+ });
352
+ }
353
+
354
+ const where: string[] = [
355
+ "p.session_id = ?",
356
+ "json_extract(p.data, '$.type') = 'text'",
357
+ "json_extract(m.data, '$.role') IN ('user','assistant')",
358
+ ];
359
+ const params: any[] = [args.sessionId];
360
+
361
+ if (!TRANSCRIPT_CONFIG.includeEmpty) {
362
+ where.push("json_extract(p.data, '$.text') IS NOT NULL");
363
+ where.push("length(trim(json_extract(p.data, '$.text'))) > 0");
364
+ }
365
+
366
+ const orderSql = args.order === "desc" ? "DESC" : "ASC";
367
+ const sql = `
368
+ SELECT
369
+ p.id AS part_id,
370
+ p.message_id AS message_id,
371
+ p.time_created AS time_created,
372
+ json_extract(m.data, '$.role') AS role,
373
+ substr(json_extract(p.data, '$.text'), 1, ?) AS text
374
+ FROM part p
375
+ JOIN message m ON m.id = p.message_id
376
+ WHERE ${where.join(" AND ")}
377
+ ORDER BY p.time_created ${orderSql}
378
+ LIMIT ?
379
+ `;
380
+
381
+ const rows = db.query(sql).all(TRANSCRIPT_CONFIG.maxCharsPerEntry, ...params, args.limit);
382
+
383
+ const entries = rows.map((row: any) => ({
384
+ partId: row.part_id,
385
+ messageId: row.message_id,
386
+ timeMs: Number(row.time_created),
387
+ time: new Date(Number(row.time_created)).toISOString(),
388
+ role: row.role,
389
+ text: row.text,
390
+ }));
391
+
392
+ return JSON.stringify({
393
+ sessionId: sessionMeta.id,
394
+ found: true,
395
+ session: {
396
+ title: sessionMeta.title,
397
+ slug: sessionMeta.slug,
398
+ directory: sessionMeta.directory,
399
+ projectName: sessionMeta.project_name,
400
+ projectWorktree: sessionMeta.worktree,
401
+ timeCreated: new Date(Number(sessionMeta.time_created)).toISOString(),
402
+ timeUpdated: new Date(Number(sessionMeta.time_updated)).toISOString(),
403
+ },
404
+ filters: {
405
+ roles: [...TRANSCRIPT_CONFIG.roles],
406
+ order: args.order,
407
+ includeEmpty: TRANSCRIPT_CONFIG.includeEmpty,
408
+ maxCharsPerEntry: TRANSCRIPT_CONFIG.maxCharsPerEntry,
409
+ },
410
+ stats: {
411
+ entriesReturned: entries.length,
412
+ limit: args.limit,
413
+ },
414
+ entries,
415
+ });
416
+ } finally {
417
+ db.close();
418
+ }
419
+ },
420
+ }),
421
+ },
422
+ };
423
+ };
424
+
425
+ export default SessionHistoryPlugin;
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "opencode-session-search",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin for searching and retrieving chat history",
5
+ "main": "./dist/index.js",
6
+ "exports": "./dist/index.js",
7
+ "type": "module",
8
+ "packageManager": "bun@1.3.9",
9
+ "files": [
10
+ "dist",
11
+ "index.ts",
12
+ "README.md",
13
+ "CHANGELOG.md",
14
+ "LICENSE",
15
+ "scripts/install-local.sh"
16
+ ],
17
+ "scripts": {
18
+ "build": "bun build ./index.ts --outfile ./dist/index.js --target bun --format esm",
19
+ "typecheck": "bunx tsc --noEmit",
20
+ "install:local": "bash scripts/install-local.sh",
21
+ "test": "bun run typecheck",
22
+ "prepublishOnly": "bun run build && bun run typecheck"
23
+ },
24
+ "keywords": [
25
+ "opencode",
26
+ "plugin",
27
+ "history",
28
+ "search",
29
+ "chat"
30
+ ],
31
+ "author": "Arthur Tyukayev",
32
+ "license": "MIT",
33
+ "repository": "github:arthurtyukayev/opencode-session-history",
34
+ "bugs": "https://github.com/arthurtyukayev/opencode-session-history/issues",
35
+ "homepage": "https://github.com/arthurtyukayev/opencode-session-history#readme",
36
+ "engines": {
37
+ "bun": ">=1.0.0"
38
+ },
39
+ "peerDependencies": {
40
+ "@opencode-ai/plugin": "^1.0.0",
41
+ "bun": "^1.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/bun": "latest",
45
+ "typescript": "^5.0.0"
46
+ }
47
+ }
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ PLUGIN_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
7
+
8
+ TARGET_DIR="${1:-$HOME/.config/opencode/plugins/history}"
9
+
10
+ mkdir -p "$TARGET_DIR"
11
+
12
+ cp "$PLUGIN_ROOT/index.ts" "$TARGET_DIR/index.ts"
13
+
14
+ echo "Installed index.ts to: $TARGET_DIR"
15
+ echo "Restart OpenCode to load the updated plugin."