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
@@ -0,0 +1,202 @@
1
+ /**
2
+ * Memory Plugin — Helpers
3
+ *
4
+ * Constants, compaction utilities, and tool formatting helpers.
5
+ * Pure functions — no plugin/closure dependencies.
6
+ */
7
+
8
+ import { Database } from "bun:sqlite";
9
+ import { readdir, readFile, stat } from "node:fs/promises";
10
+ import path from "node:path";
11
+ import type { ObservationType } from "./memory-db.js";
12
+
13
+ // ============================================================================
14
+ // Constants
15
+ // ============================================================================
16
+
17
+ export const VALID_TYPES: ObservationType[] = [
18
+ "decision",
19
+ "bugfix",
20
+ "feature",
21
+ "pattern",
22
+ "discovery",
23
+ "learning",
24
+ "warning",
25
+ ];
26
+
27
+ export const TYPE_ICONS: Record<string, string> = {
28
+ decision: "\u2696\uFE0F",
29
+ bugfix: "\uD83D\uDC1B",
30
+ feature: "\u2728",
31
+ pattern: "\uD83D\uDD04",
32
+ discovery: "\uD83D\uDD2D",
33
+ learning: "\uD83D\uDCDA",
34
+ warning: "\u26A0\uFE0F",
35
+ };
36
+
37
+ export const FILE_REF_PATTERNS = [
38
+ /(?:^|\s)(\S+\.(?:ts|tsx|js|jsx|json|md|yaml|yml|toml|sql|sh|py|rs|go)):(\d+)/g,
39
+ /`([^`]+\.(?:ts|tsx|js|jsx|json|md|yaml|yml|toml))`/g,
40
+ /(?:^|\s)(src\/\S+)/gm,
41
+ /(?:^|\s)(\.opencode\/\S+)/gm,
42
+ ];
43
+
44
+ // Compaction constants
45
+ export const MAX_SESSION_CONTEXT_CHARS = 3000;
46
+ export const MAX_PROJECT_FILES = 3;
47
+ export const MAX_PROJECT_FILE_CHARS = 900;
48
+ export const MAX_HANDOFF_CHARS = 2500;
49
+ export const MAX_BEADS = 8;
50
+ export const MAX_COMBINED_CONTEXT_CHARS = 10000;
51
+
52
+ // ============================================================================
53
+ // Compaction Helpers
54
+ // ============================================================================
55
+
56
+ export function truncate(text: string, maxChars: number): string {
57
+ if (text.length <= maxChars) return text;
58
+ return `${text.slice(0, maxChars)}\n...[truncated]`;
59
+ }
60
+
61
+ export async function safeReadFile(filePath: string): Promise<string> {
62
+ try {
63
+ return await readFile(filePath, "utf-8");
64
+ } catch {
65
+ return "";
66
+ }
67
+ }
68
+
69
+ export function renderSection(title: string, body: string): string {
70
+ if (!body.trim()) return "";
71
+ return `## ${title}\n${body.trim()}`;
72
+ }
73
+
74
+ export async function readProjectMemoryContext(
75
+ memoryDir: string,
76
+ ): Promise<string> {
77
+ const projectDir = path.join(memoryDir, "project");
78
+ let names: string[] = [];
79
+ try {
80
+ names = (await readdir(projectDir))
81
+ .filter((n) => n.endsWith(".md"))
82
+ .sort()
83
+ .slice(0, MAX_PROJECT_FILES);
84
+ } catch {
85
+ return "";
86
+ }
87
+
88
+ const chunks: string[] = [];
89
+ for (const name of names) {
90
+ const content = (await safeReadFile(path.join(projectDir, name))).trim();
91
+ if (!content) continue;
92
+ chunks.push(
93
+ `### ${name.replace(/\.md$/, "")}\n${truncate(content, MAX_PROJECT_FILE_CHARS)}`,
94
+ );
95
+ }
96
+ return chunks.join("\n\n");
97
+ }
98
+
99
+ export async function readLatestHandoff(handoffDir: string): Promise<string> {
100
+ let names: string[] = [];
101
+ try {
102
+ names = (await readdir(handoffDir)).filter((n) => n.endsWith(".md"));
103
+ } catch {
104
+ return "";
105
+ }
106
+ if (names.length === 0) return "";
107
+
108
+ const withMtime = await Promise.all(
109
+ names.map(async (name) => {
110
+ const fullPath = path.join(handoffDir, name);
111
+ try {
112
+ return { name, fullPath, mtimeMs: (await stat(fullPath)).mtimeMs };
113
+ } catch {
114
+ return { name, fullPath, mtimeMs: 0 };
115
+ }
116
+ }),
117
+ );
118
+ withMtime.sort((a, b) => b.mtimeMs - a.mtimeMs);
119
+ const latest = withMtime[0];
120
+ const content = (await safeReadFile(latest.fullPath)).trim();
121
+ if (!content) return "";
122
+ return `Source: ${latest.name}\n${truncate(content, MAX_HANDOFF_CHARS)}`;
123
+ }
124
+
125
+ export function readInProgressBeads(directory: string): string {
126
+ const dbPath = path.join(directory, ".beads", "beads.db");
127
+ let db: Database | undefined;
128
+ try {
129
+ db = new Database(dbPath, { readonly: true });
130
+ const rows = db
131
+ .query<{ id: string; title: string }, [number]>(
132
+ "SELECT id, title FROM issues WHERE status = 'in_progress' ORDER BY updated_at DESC LIMIT ?",
133
+ )
134
+ .all(MAX_BEADS);
135
+ return rows.length > 0
136
+ ? rows.map((r) => `- ${r.id}: ${r.title}`).join("\n")
137
+ : "";
138
+ } catch {
139
+ return "";
140
+ } finally {
141
+ db?.close();
142
+ }
143
+ }
144
+
145
+ // ============================================================================
146
+ // Tool Helpers
147
+ // ============================================================================
148
+
149
+ export function autoDetectFiles(text: string): string[] {
150
+ const files = new Set<string>();
151
+ for (const pattern of FILE_REF_PATTERNS) {
152
+ const regex = new RegExp(pattern.source, pattern.flags);
153
+ let match: RegExpExecArray | null;
154
+ while ((match = regex.exec(text)) !== null) {
155
+ files.add(match[1]);
156
+ }
157
+ }
158
+ return [...files];
159
+ }
160
+
161
+ export function parseCSV(value: string | undefined): string[] | undefined {
162
+ if (!value) return undefined;
163
+ return value
164
+ .split(",")
165
+ .map((s) => s.trim())
166
+ .filter((s) => s.length > 0);
167
+ }
168
+
169
+ export function formatObservation(obs: {
170
+ id: number;
171
+ type: string;
172
+ title: string;
173
+ subtitle?: string | null;
174
+ confidence?: string | null;
175
+ concepts?: string | null;
176
+ files_read?: string | null;
177
+ files_modified?: string | null;
178
+ facts?: string | null;
179
+ narrative?: string | null;
180
+ bead_id?: string | null;
181
+ supersedes?: number | null;
182
+ superseded_by?: number | null;
183
+ source?: string | null;
184
+ created_at?: string | null;
185
+ }): string {
186
+ const icon = TYPE_ICONS[obs.type] ?? "\uD83D\uDCCC";
187
+ const lines = [`${icon} **#${obs.id}** [${obs.type}] ${obs.title}`];
188
+ if (obs.subtitle) lines.push(` _${obs.subtitle}_`);
189
+ if (obs.confidence) lines.push(` Confidence: ${obs.confidence}`);
190
+ if (obs.source && obs.source !== "manual")
191
+ lines.push(` Source: ${obs.source}`);
192
+ if (obs.concepts) lines.push(` Concepts: ${obs.concepts}`);
193
+ if (obs.files_read) lines.push(` Files read: ${obs.files_read}`);
194
+ if (obs.files_modified) lines.push(` Files modified: ${obs.files_modified}`);
195
+ if (obs.facts) lines.push(` Facts: ${obs.facts}`);
196
+ if (obs.bead_id) lines.push(` Bead: ${obs.bead_id}`);
197
+ if (obs.supersedes) lines.push(` Supersedes: #${obs.supersedes}`);
198
+ if (obs.superseded_by) lines.push(` Superseded by: #${obs.superseded_by}`);
199
+ if (obs.narrative) lines.push(`\n${obs.narrative}`);
200
+ if (obs.created_at) lines.push(`\n _Created: ${obs.created_at}_`);
201
+ return lines.join("\n");
202
+ }
@@ -0,0 +1,240 @@
1
+ /**
2
+ * Memory Plugin — Hooks
3
+ *
4
+ * All event handlers, transforms, and compaction logic.
5
+ * Uses factory pattern: createHooks(deps) returns hook definitions.
6
+ *
7
+ * Hook architecture (from @opencode-ai/plugin Hooks interface):
8
+ * - `event` — generic handler for ALL events (session.idle, message.updated, etc.)
9
+ * - Named hooks — separate handlers with (input, output) signature:
10
+ * - "tool.execute.after", "chat.message", "experimental.chat.*", etc.
11
+ *
12
+ * Events NOT in the Hooks interface (handled via generic `event`):
13
+ * - session.idle, session.error, session.created, session.deleted
14
+ * - message.updated, message.removed, message.part.updated, message.part.removed
15
+ */
16
+
17
+ import path from "node:path";
18
+ import { captureMessageMeta, captureMessagePart } from "./capture.js";
19
+ import { manageContext } from "./context.js";
20
+ import { curateFromDistillations } from "./curator.js";
21
+ import { distillSession } from "./distill.js";
22
+ import { buildInjection } from "./inject.js";
23
+ import {
24
+ checkFTS5Available,
25
+ checkpointWAL,
26
+ getDatabaseSizes,
27
+ optimizeFTS5,
28
+ searchObservationsFTS,
29
+ } from "./memory-db.js";
30
+ import {
31
+ MAX_COMBINED_CONTEXT_CHARS,
32
+ MAX_SESSION_CONTEXT_CHARS,
33
+ readInProgressBeads,
34
+ readLatestHandoff,
35
+ readProjectMemoryContext,
36
+ renderSection,
37
+ safeReadFile,
38
+ truncate,
39
+ } from "./memory-helpers.js";
40
+
41
+ interface HookDeps {
42
+ memoryDir: string;
43
+ handoffDir: string;
44
+ directory: string;
45
+ showToast: (
46
+ title: string,
47
+ message: string,
48
+ variant?: "info" | "warning",
49
+ ) => Promise<void>;
50
+ log: (message: string, level?: "info" | "warn") => Promise<void>;
51
+ }
52
+
53
+ export function createHooks(deps: HookDeps) {
54
+ const { memoryDir, handoffDir, directory, showToast, log } = deps;
55
+
56
+ return {
57
+ // ================================================================
58
+ // Generic event handler — ALL events route through here
59
+ // Receives: { event: { type, properties? } }
60
+ // ================================================================
61
+ event: async (input: unknown) => {
62
+ const { event } = input as {
63
+ event: {
64
+ type?: string;
65
+ properties?: Record<string, unknown>;
66
+ };
67
+ };
68
+ if (!event?.type) return;
69
+
70
+ // --- Message capture ---
71
+ if (event.type === "message.updated") {
72
+ try {
73
+ captureMessageMeta(
74
+ event.properties as Parameters<typeof captureMessageMeta>[0],
75
+ );
76
+ } catch {
77
+ /* Non-fatal */
78
+ }
79
+ }
80
+
81
+ if (event.type === "message.part.updated") {
82
+ try {
83
+ captureMessagePart(
84
+ event.properties as Parameters<typeof captureMessagePart>[0],
85
+ );
86
+ } catch {
87
+ /* Non-fatal */
88
+ }
89
+ }
90
+
91
+ // --- Session idle: distill + curate + optimize ---
92
+ if (event.type === "session.idle") {
93
+ const sessionId =
94
+ (event.properties as { sessionID?: string })?.sessionID ??
95
+ (event as unknown as { sessionID?: string })?.sessionID;
96
+ try {
97
+ if (sessionId) distillSession(sessionId);
98
+ curateFromDistillations(sessionId, 5);
99
+ if (checkFTS5Available()) optimizeFTS5();
100
+ const sizes = getDatabaseSizes();
101
+ if (sizes.wal > 1024 * 1024) checkpointWAL();
102
+ } catch (err) {
103
+ const msg = err instanceof Error ? err.message : String(err);
104
+ await log(`Idle maintenance failed: ${msg}`, "warn");
105
+ }
106
+ }
107
+
108
+ // --- Session error: warn user ---
109
+ if (event.type === "session.error") {
110
+ await showToast(
111
+ "Session Error",
112
+ "Save important learnings with observation tool",
113
+ "warning",
114
+ );
115
+ }
116
+ },
117
+
118
+ // ================================================================
119
+ // Named hook: tool.execute.after
120
+ // Receives: (input: { tool, sessionID, callID, args }, output: { title, output, metadata })
121
+ // ================================================================
122
+ "tool.execute.after": async (input: {
123
+ tool?: string;
124
+ sessionID?: string;
125
+ }) => {
126
+ try {
127
+ if (input.tool === "observation" && typeof showToast === "function") {
128
+ await showToast("Saved", "Observation added to memory");
129
+ }
130
+ } catch {
131
+ /* Toast is cosmetic, never block tool execution */
132
+ }
133
+ },
134
+
135
+ // ================================================================
136
+ // LTM injection into system prompt
137
+ // ================================================================
138
+ "experimental.chat.system.transform": async (
139
+ _input: unknown,
140
+ output: { system: string[] },
141
+ ) => {
142
+ try {
143
+ const injection = buildInjection(output.system);
144
+ if (injection) output.system.push(injection);
145
+ } catch {
146
+ /* Non-fatal */
147
+ }
148
+ },
149
+
150
+ // ================================================================
151
+ // Context window management
152
+ // ================================================================
153
+ "experimental.chat.messages.transform": async (
154
+ _input: unknown,
155
+ output: { messages: unknown[] },
156
+ ) => {
157
+ try {
158
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
159
+ output.messages = manageContext(output.messages as any) as any;
160
+ } catch {
161
+ /* Non-fatal */
162
+ }
163
+ },
164
+
165
+ // ================================================================
166
+ // Compaction — inject session continuity context
167
+ // Receives: (input: { sessionID }, output: { context, prompt? })
168
+ // ================================================================
169
+ "experimental.session.compacting": async (
170
+ input: { sessionID?: string },
171
+ output: { context: string[]; prompt?: string },
172
+ ) => {
173
+ const sessionContext = truncate(
174
+ (await safeReadFile(path.join(memoryDir, "session-context.md"))).trim(),
175
+ MAX_SESSION_CONTEXT_CHARS,
176
+ );
177
+
178
+ const [projectContext, handoffContext] = await Promise.all([
179
+ readProjectMemoryContext(memoryDir),
180
+ readLatestHandoff(handoffDir),
181
+ ]);
182
+
183
+ const beadsContext = readInProgressBeads(directory);
184
+
185
+ // Add relevant observations for session
186
+ let knowledgeContext = "";
187
+ if (input.sessionID) {
188
+ try {
189
+ const recentObs = searchObservationsFTS("", { limit: 5 });
190
+ if (recentObs.length > 0) {
191
+ knowledgeContext = recentObs
192
+ .map((o) => `- [${o.type}] #${o.id}: ${o.title}`)
193
+ .join("\n");
194
+ }
195
+ } catch {
196
+ /* Non-fatal */
197
+ }
198
+ }
199
+
200
+ const combined = [
201
+ renderSection("Session Continuity", sessionContext),
202
+ renderSection("Active Beads", beadsContext),
203
+ renderSection("Previous Handoff", handoffContext),
204
+ renderSection("Recent Knowledge", knowledgeContext),
205
+ renderSection("Project Memory", projectContext),
206
+ ]
207
+ .filter(Boolean)
208
+ .join("\n\n");
209
+
210
+ if (combined) {
211
+ output.context.push(
212
+ `## Session Context\n${truncate(combined, MAX_COMBINED_CONTEXT_CHARS)}\n`,
213
+ );
214
+ }
215
+
216
+ output.prompt = `${output.prompt ?? ""}
217
+
218
+ <compaction_task>
219
+ Summarize conversation state for reliable continuation after compaction.
220
+ </compaction_task>
221
+
222
+ <compaction_rules>
223
+ - Preserve exact IDs, file paths, and unresolved constraints.
224
+ - Distinguish completed work from current in-progress work.
225
+ - Keep summary concise and execution-focused.
226
+ - If critical context is missing, state uncertainty explicitly.
227
+ </compaction_rules>
228
+
229
+ <compaction_output>
230
+ Include:
231
+ - What was done
232
+ - What is being worked on now
233
+ - Files currently in play
234
+ - Next actions
235
+ - Persistent user constraints/preferences
236
+ </compaction_output>
237
+ `;
238
+ },
239
+ };
240
+ }