opencodekit 0.17.13 → 0.18.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.
Files changed (36) hide show
  1. package/dist/index.js +4 -6
  2. package/dist/template/.opencode/dcp.jsonc +81 -81
  3. package/dist/template/.opencode/memory/memory.db +0 -0
  4. package/dist/template/.opencode/memory.db +0 -0
  5. package/dist/template/.opencode/memory.db-shm +0 -0
  6. package/dist/template/.opencode/memory.db-wal +0 -0
  7. package/dist/template/.opencode/opencode.json +199 -23
  8. package/dist/template/.opencode/opencode.json.tui-migration.bak +1380 -0
  9. package/dist/template/.opencode/package.json +1 -1
  10. package/dist/template/.opencode/plugin/lib/capture.ts +177 -0
  11. package/dist/template/.opencode/plugin/lib/context.ts +194 -0
  12. package/dist/template/.opencode/plugin/lib/curator.ts +234 -0
  13. package/dist/template/.opencode/plugin/lib/db/maintenance.ts +312 -0
  14. package/dist/template/.opencode/plugin/lib/db/observations.ts +299 -0
  15. package/dist/template/.opencode/plugin/lib/db/pipeline.ts +520 -0
  16. package/dist/template/.opencode/plugin/lib/db/schema.ts +356 -0
  17. package/dist/template/.opencode/plugin/lib/db/types.ts +211 -0
  18. package/dist/template/.opencode/plugin/lib/distill.ts +376 -0
  19. package/dist/template/.opencode/plugin/lib/inject.ts +126 -0
  20. package/dist/template/.opencode/plugin/lib/memory-admin-tools.ts +188 -0
  21. package/dist/template/.opencode/plugin/lib/memory-db.ts +54 -936
  22. package/dist/template/.opencode/plugin/lib/memory-helpers.ts +202 -0
  23. package/dist/template/.opencode/plugin/lib/memory-hooks.ts +240 -0
  24. package/dist/template/.opencode/plugin/lib/memory-tools.ts +341 -0
  25. package/dist/template/.opencode/plugin/memory.ts +56 -60
  26. package/dist/template/.opencode/plugin/sessions.ts +372 -93
  27. package/dist/template/.opencode/tui.json +15 -0
  28. package/package.json +1 -1
  29. package/dist/template/.opencode/tool/action-queue.ts +0 -313
  30. package/dist/template/.opencode/tool/memory-admin.ts +0 -445
  31. package/dist/template/.opencode/tool/memory-get.ts +0 -143
  32. package/dist/template/.opencode/tool/memory-read.ts +0 -45
  33. package/dist/template/.opencode/tool/memory-search.ts +0 -264
  34. package/dist/template/.opencode/tool/memory-timeline.ts +0 -105
  35. package/dist/template/.opencode/tool/memory-update.ts +0 -63
  36. package/dist/template/.opencode/tool/observation.ts +0 -357
@@ -1,19 +1,102 @@
1
1
  /**
2
2
  * OpenCode Session Tools
3
- * Simplified to match AmpCode's find_thread / read_thread
3
+ * Direct SQLite access for fast, ranked session search.
4
4
  *
5
5
  * Core tools:
6
- * - find_sessions: Search sessions by keyword (like AmpCode's find_thread)
7
- * - read_session: Read session messages (like AmpCode's read_thread)
6
+ * - find_sessions: Multi-word AND search with relevance ranking
7
+ * - read_session: Full transcript with keyword filtering
8
+ *
9
+ * Requires Bun runtime (uses bun:sqlite for zero-dep DB access).
8
10
  */
9
11
 
12
+ import { Database } from "bun:sqlite";
13
+ import { spawnSync } from "node:child_process";
14
+ import { join } from "node:path";
10
15
  import type { Plugin } from "@opencode-ai/plugin";
11
16
  import { tool } from "@opencode-ai/plugin/tool";
12
17
 
13
- export const SessionsPlugin: Plugin = async ({ client }) => {
18
+ // --- Configuration ---
19
+
20
+ const SEARCH_CONFIG = {
21
+ roles: ["user", "assistant"],
22
+ defaultLimit: 6,
23
+ maxLimit: 12,
24
+ snippetsPerSession: 2,
25
+ snippetLength: 220,
26
+ sinceHours: 24 * 180, // 180-day lookback window
27
+ } as const;
28
+
29
+ const TRANSCRIPT_CONFIG = {
30
+ roles: ["user", "assistant"],
31
+ defaultLimit: 80,
32
+ maxLimit: 120,
33
+ maxCharsPerEntry: 600,
34
+ } as const;
35
+
36
+ // --- DB helpers ---
37
+
38
+ const resolveDbPath = (): string => {
39
+ // 1. Env override
40
+ if (process.env.OPENCODE_DB_PATH) return process.env.OPENCODE_DB_PATH;
41
+
42
+ // 2. CLI introspection — ask the running binary where the DB is
43
+ try {
44
+ const result = spawnSync("opencode", ["db", "path"], {
45
+ encoding: "utf8",
46
+ stdio: ["ignore", "pipe", "ignore"],
47
+ });
48
+ if (result.status === 0) {
49
+ const p = (result.stdout || "").trim();
50
+ if (p) return p;
51
+ }
52
+ } catch {
53
+ /* fall through */
54
+ }
55
+
56
+ // 3. XDG fallback
57
+ return join(
58
+ process.env.HOME || "",
59
+ ".local",
60
+ "share",
61
+ "opencode",
62
+ "opencode.db",
63
+ );
64
+ };
65
+
66
+ /** Resolved once at module load — no per-request resolution cost. */
67
+ const DEFAULT_DB_PATH = resolveDbPath();
68
+
69
+ const openReadonlyDb = (): { db: Database | null; error: string | null } => {
70
+ try {
71
+ return {
72
+ db: new Database(DEFAULT_DB_PATH, {
73
+ readonly: true,
74
+ create: false,
75
+ strict: true,
76
+ }),
77
+ error: null,
78
+ };
79
+ } catch (err) {
80
+ return {
81
+ db: null,
82
+ error: err instanceof Error ? err.message : String(err),
83
+ };
84
+ }
85
+ };
86
+
87
+ /** Escape SQL LIKE wildcards for safe pattern matching. */
88
+ const escapeLike = (value: string): string =>
89
+ value.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_");
90
+
91
+ const formatTime = (ms: number): string => new Date(ms).toISOString();
92
+
93
+ const ROLE_LIST = SEARCH_CONFIG.roles.map((r) => `'${r}'`).join(",");
94
+
95
+ // --- Plugin ---
96
+
97
+ export const SessionsPlugin: Plugin = async () => {
14
98
  return {
15
99
  tool: {
16
- /** Like AmpCode's find_thread */
17
100
  find_sessions: tool({
18
101
  description: `Search sessions by keyword.
19
102
 
@@ -27,53 +110,174 @@ find_sessions({ query: "auth", limit: 5 })`,
27
110
  .describe("Max results (default: 10)"),
28
111
  },
29
112
  async execute(args: { query: string; limit?: number }) {
30
- const sessions = await client.session.list();
31
- const results: string[] = [];
32
- let searched = 0;
33
- const searchLimit = args.limit || 10;
113
+ const trimmed = args.query.trim();
114
+ if (!trimmed) {
115
+ return JSON.stringify({
116
+ error: "INVALID_QUERY",
117
+ message: "Query cannot be empty",
118
+ dbPath: DEFAULT_DB_PATH,
119
+ });
120
+ }
34
121
 
35
- if (!sessions.data) return "No sessions found.";
122
+ const { db, error } = openReadonlyDb();
123
+ if (!db) {
124
+ return JSON.stringify({
125
+ error: "DB_OPEN_FAILED",
126
+ message: error,
127
+ dbPath: DEFAULT_DB_PATH,
128
+ });
129
+ }
36
130
 
37
- for (const session of sessions.data) {
38
- if (results.length >= searchLimit) break;
131
+ try {
132
+ const words = trimmed.toLowerCase().split(/\s+/).filter(Boolean);
133
+ const limit = Math.min(
134
+ args.limit || SEARCH_CONFIG.defaultLimit,
135
+ SEARCH_CONFIG.maxLimit,
136
+ );
137
+ const cutoffMs = Date.now() - SEARCH_CONFIG.sinceHours * 3_600_000;
39
138
 
40
- try {
41
- const messages = await client.session.messages({
42
- path: { id: session.id },
43
- });
44
- const messageData = messages.data;
45
- if (!messageData) continue;
46
-
47
- const matches = messageData.filter(
48
- (m: any) =>
49
- m.info &&
50
- JSON.stringify(m.info)
51
- .toLowerCase()
52
- .includes(args.query.toLowerCase()),
53
- );
54
-
55
- if (matches.length > 0) {
56
- const excerpt = extractContent(matches[0].info) || "";
57
- results.push(
58
- `**${session.id}** - ${session.title || "Untitled"}\n Matches: ${matches.length}\n ${excerpt.substring(0, 100)}...`,
59
- );
60
- }
139
+ // Phase 1: Rank sessions by match count + recency
140
+ const likeClauses = words
141
+ .map(
142
+ () =>
143
+ `lower(json_extract(p.data, '$.text')) LIKE ? ESCAPE '\\'`,
144
+ )
145
+ .join(" AND ");
61
146
 
62
- searched++;
63
- if (searched >= 50) break;
64
- } catch {
65
- // Skip inaccessible
147
+ const rankSql = `
148
+ SELECT s.id AS session_id, s.title, s.directory,
149
+ COUNT(*) AS match_count,
150
+ MAX(p.time_created) AS last_match_ms
151
+ FROM part p
152
+ JOIN message m ON m.id = p.message_id
153
+ JOIN session s ON s.id = p.session_id
154
+ WHERE json_extract(p.data, '$.type') = 'text'
155
+ AND json_extract(p.data, '$.text') IS NOT NULL
156
+ AND json_extract(m.data, '$.role') IN (${ROLE_LIST})
157
+ AND ${likeClauses}
158
+ AND p.time_created >= ?
159
+ GROUP BY s.id
160
+ ORDER BY COUNT(*) DESC, MAX(p.time_created) DESC
161
+ LIMIT ?`;
162
+
163
+ const likeParams = words.map((w) => `%${escapeLike(w)}%`);
164
+ const rankParams = [...likeParams, cutoffMs, limit];
165
+ const sessions = db.prepare(rankSql).all(...rankParams) as Array<{
166
+ session_id: string;
167
+ title: string;
168
+ directory: string;
169
+ match_count: number;
170
+ last_match_ms: number;
171
+ }>;
172
+
173
+ if (sessions.length === 0) {
174
+ return JSON.stringify({
175
+ query: trimmed,
176
+ sessions: [],
177
+ stats: { totalSessions: 0, totalMatches: 0 },
178
+ });
66
179
  }
67
- }
68
180
 
69
- if (results.length === 0)
70
- return `No matches for "${args.query}" in ${searched} sessions.`;
181
+ // Phase 2: Extract snippets per session
182
+ const snippetSql = `
183
+ SELECT p.session_id, p.time_created,
184
+ json_extract(m.data, '$.role') AS role,
185
+ substr(json_extract(p.data, '$.text'), 1, ${SEARCH_CONFIG.snippetLength}) AS snippet
186
+ FROM part p
187
+ JOIN message m ON m.id = p.message_id
188
+ WHERE json_extract(p.data, '$.type') = 'text'
189
+ AND json_extract(p.data, '$.text') IS NOT NULL
190
+ AND json_extract(m.data, '$.role') IN (${ROLE_LIST})
191
+ AND ${likeClauses}
192
+ AND p.time_created >= ?
193
+ AND p.session_id = ?
194
+ ORDER BY p.time_created DESC
195
+ LIMIT ?`;
196
+
197
+ const results = sessions.map((s) => {
198
+ const snippetParams = [
199
+ ...likeParams,
200
+ cutoffMs,
201
+ s.session_id,
202
+ SEARCH_CONFIG.snippetsPerSession,
203
+ ];
204
+ const snippets = db
205
+ .prepare(snippetSql)
206
+ .all(...snippetParams) as Array<{
207
+ time_created: number;
208
+ role: string;
209
+ snippet: string;
210
+ }>;
211
+
212
+ return {
213
+ sessionId: s.session_id,
214
+ title: s.title,
215
+ directory: s.directory,
216
+ matchCount: s.match_count,
217
+ lastMatch: formatTime(s.last_match_ms),
218
+ snippets: snippets.map((sn) => ({
219
+ time: formatTime(sn.time_created),
220
+ role: sn.role,
221
+ text: sn.snippet,
222
+ })),
223
+ };
224
+ });
71
225
 
72
- return `# Results: "${args.query}"\n\n${results.join("\n\n")}`;
226
+ const totalMatches = results.reduce(
227
+ (sum, r) => sum + r.matchCount,
228
+ 0,
229
+ );
230
+
231
+ return JSON.stringify({
232
+ query: trimmed,
233
+ filters: {
234
+ roles: SEARCH_CONFIG.roles,
235
+ sinceHours: SEARCH_CONFIG.sinceHours,
236
+ snippetsPerSession: SEARCH_CONFIG.snippetsPerSession,
237
+ snippetLength: SEARCH_CONFIG.snippetLength,
238
+ },
239
+ stats: {
240
+ totalSessions: results.length,
241
+ totalMatches,
242
+ },
243
+ sessions: results,
244
+ nextStep: {
245
+ message:
246
+ "Use read_session to get the full transcript. Present options to the user with the question tool.",
247
+ suggestedCalls: results.slice(0, 3).map((s) => ({
248
+ tool: "read_session",
249
+ args: {
250
+ session_id: s.sessionId,
251
+ limit: 60,
252
+ order: "asc",
253
+ },
254
+ })),
255
+ suggestedQuestionCall: {
256
+ tool: "question",
257
+ args: {
258
+ questions: [
259
+ {
260
+ header: "Pick session",
261
+ question:
262
+ "Which session should I open for full transcript context?",
263
+ options: results.slice(0, 8).map((s, i) => ({
264
+ label: `${s.sessionId.slice(0, 24)}${i === 0 ? " (Recommended)" : ""}`,
265
+ description:
266
+ s.title || s.directory || `${s.matchCount} matches`,
267
+ })),
268
+ multiple: false,
269
+ },
270
+ ],
271
+ },
272
+ },
273
+ },
274
+ });
275
+ } finally {
276
+ db.close();
277
+ }
73
278
  },
74
279
  }),
75
280
 
76
- /** Like AmpCode's read_thread */
77
281
  read_session: tool({
78
282
  description: `Read session messages.
79
283
 
@@ -84,66 +288,141 @@ read_session({ session_id: "abc123", focus: "auth" })`,
84
288
  session_id: tool.schema.string().describe("Session ID"),
85
289
  focus: tool.schema.string().optional().describe("Filter by keyword"),
86
290
  },
87
- async execute(args: { session_id: string; focus?: string }) {
88
- const session = await client.session.get({
89
- path: { id: args.session_id },
90
- });
91
- if (!session.data) return `Session ${args.session_id} not found.`;
92
-
93
- const messages = await client.session.messages({
94
- path: { id: args.session_id },
95
- });
96
- const messageData = messages.data;
97
- if (!messageData) return `No messages in ${args.session_id}.`;
98
-
99
- let summary = `# ${session.data.title || "Untitled"}\n`;
100
- summary += `ID: ${session.data.id}\n`;
101
- summary += `Created: ${session.data.time?.created ? new Date(session.data.time.created).toLocaleString() : "Unknown"}\n`;
102
- summary += `Messages: ${messageData.length}\n\n`;
103
-
104
- if (args.focus) {
105
- const focusLower = args.focus.toLowerCase();
106
- const relevant = messageData.filter(
107
- (m: any) =>
108
- m.info &&
109
- JSON.stringify(m.info).toLowerCase().includes(focusLower),
110
- );
111
- summary += `## Matching "${args.focus}" (${relevant.length})\n\n`;
112
- relevant.slice(0, 5).forEach((m: any, i: number) => {
113
- summary += `${i + 1}. **${m.info.role}**: ${extractContent(m.info).substring(0, 200)}\n\n`;
291
+ async execute(args: {
292
+ session_id: string;
293
+ focus?: string;
294
+ limit?: number;
295
+ order?: string;
296
+ }) {
297
+ const { db, error } = openReadonlyDb();
298
+ if (!db) {
299
+ return JSON.stringify({
300
+ error: "DB_OPEN_FAILED",
301
+ message: error,
302
+ dbPath: DEFAULT_DB_PATH,
114
303
  });
115
- } else {
116
- // Show recent user messages
117
- const userMessages = messageData.filter(
118
- (m: any) => m.info?.role === "user",
119
- );
120
- summary += "## Recent User Messages\n\n";
121
- for (let i = 0; i < Math.min(userMessages.length, 5); i++) {
122
- summary += `${i + 1}. ${extractContent(userMessages[i].info).substring(0, 200)}\n`;
304
+ }
305
+
306
+ try {
307
+ // Session metadata with project join
308
+ const session = db
309
+ .prepare(
310
+ `SELECT s.id, s.title, s.directory, s.slug,
311
+ s.time_created, s.time_updated,
312
+ p.worktree, p.name AS project_name
313
+ FROM session s
314
+ LEFT JOIN project p ON p.id = s.project_id
315
+ WHERE s.id = ?
316
+ LIMIT 1`,
317
+ )
318
+ .get(args.session_id) as {
319
+ id: string;
320
+ title: string;
321
+ directory: string;
322
+ slug: string;
323
+ time_created: number;
324
+ time_updated: number;
325
+ worktree: string;
326
+ project_name: string;
327
+ } | null;
328
+
329
+ if (!session) {
330
+ return JSON.stringify({
331
+ sessionId: args.session_id,
332
+ found: false,
333
+ message: "Session not found",
334
+ });
123
335
  }
124
336
 
125
- // Last assistant response
126
- const assistantMessages = messageData.filter(
127
- (m: any) => m.info?.role === "assistant",
337
+ const entryLimit = Math.min(
338
+ args.limit || TRANSCRIPT_CONFIG.defaultLimit,
339
+ TRANSCRIPT_CONFIG.maxLimit,
128
340
  );
129
- if (assistantMessages.length > 0) {
130
- const last = assistantMessages[assistantMessages.length - 1];
131
- summary += `\n## Last Response\n\n${extractContent(last.info).substring(0, 500)}\n`;
341
+ const sortOrder = args.order === "desc" ? "DESC" : "ASC";
342
+
343
+ // Build transcript query with optional focus filter
344
+ const params: (string | number)[] = [args.session_id];
345
+ let focusClauses = "";
346
+
347
+ if (args.focus) {
348
+ const words = args.focus
349
+ .toLowerCase()
350
+ .split(/\s+/)
351
+ .filter(Boolean);
352
+ for (const word of words) {
353
+ focusClauses += ` AND lower(json_extract(p.data, '$.text')) LIKE ? ESCAPE '\\'`;
354
+ params.push(`%${escapeLike(word)}%`);
355
+ }
132
356
  }
133
- }
134
357
 
135
- return summary;
358
+ params.push(entryLimit);
359
+
360
+ const entries = db
361
+ .prepare(
362
+ `SELECT p.id AS part_id, p.message_id, p.time_created,
363
+ json_extract(m.data, '$.role') AS role,
364
+ substr(json_extract(p.data, '$.text'), 1, ${TRANSCRIPT_CONFIG.maxCharsPerEntry}) AS text
365
+ FROM part p
366
+ JOIN message m ON m.id = p.message_id
367
+ WHERE p.session_id = ?
368
+ AND json_extract(p.data, '$.type') = 'text'
369
+ AND json_extract(m.data, '$.role') IN (${ROLE_LIST})
370
+ AND json_extract(p.data, '$.text') IS NOT NULL
371
+ AND length(trim(json_extract(p.data, '$.text'))) > 0
372
+ ${focusClauses}
373
+ ORDER BY p.time_created ${sortOrder}
374
+ LIMIT ?`,
375
+ )
376
+ .all(...params) as Array<{
377
+ part_id: string;
378
+ message_id: string;
379
+ time_created: number;
380
+ role: string;
381
+ text: string;
382
+ }>;
383
+
384
+ return JSON.stringify({
385
+ sessionId: args.session_id,
386
+ found: true,
387
+ session: {
388
+ title: session.title,
389
+ slug: session.slug,
390
+ directory: session.directory,
391
+ projectName: session.project_name,
392
+ projectWorktree: session.worktree,
393
+ timeCreated: session.time_created
394
+ ? formatTime(session.time_created)
395
+ : null,
396
+ timeUpdated: session.time_updated
397
+ ? formatTime(session.time_updated)
398
+ : null,
399
+ },
400
+ filters: {
401
+ roles: TRANSCRIPT_CONFIG.roles,
402
+ order: sortOrder.toLowerCase(),
403
+ maxCharsPerEntry: TRANSCRIPT_CONFIG.maxCharsPerEntry,
404
+ ...(args.focus ? { focus: args.focus } : {}),
405
+ },
406
+ stats: {
407
+ entriesReturned: entries.length,
408
+ limit: entryLimit,
409
+ },
410
+ entries: entries.map((e) => ({
411
+ partId: e.part_id,
412
+ messageId: e.message_id,
413
+ timeMs: e.time_created,
414
+ time: formatTime(e.time_created),
415
+ role: e.role,
416
+ text: e.text,
417
+ })),
418
+ });
419
+ } finally {
420
+ db.close();
421
+ }
136
422
  },
137
423
  }),
138
424
  },
139
425
  };
140
426
  };
141
427
 
142
- function extractContent(messageInfo: any): string {
143
- if (!messageInfo) return "[No info]";
144
- if (typeof messageInfo.summary === "object" && messageInfo.summary !== null) {
145
- if (messageInfo.summary.title) return messageInfo.summary.title;
146
- if (messageInfo.summary.body) return messageInfo.summary.body;
147
- }
148
- return "[No content]";
149
- }
428
+ export default SessionsPlugin;
@@ -0,0 +1,15 @@
1
+ {
2
+ "$schema": "https://opencode.ai/tui.json",
3
+ "keybinds": {
4
+ "command_list": ";",
5
+ "leader": "`",
6
+ "session_child_cycle": "ctrl+alt+right",
7
+ "session_child_cycle_reverse": "ctrl+alt+left",
8
+ "session_compact": "ctrl+k"
9
+ },
10
+ "scroll_speed": 3,
11
+ "scroll_acceleration": {
12
+ "enabled": true
13
+ },
14
+ "diff_style": "auto"
15
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencodekit",
3
- "version": "0.17.13",
3
+ "version": "0.18.0",
4
4
  "description": "CLI tool for bootstrapping and managing OpenCodeKit projects",
5
5
  "keywords": ["agents", "cli", "mcp", "opencode", "opencodekit", "template"],
6
6
  "license": "MIT",