opencodekit 0.23.0 → 0.23.2

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 (23) hide show
  1. package/dist/index.js +354 -825
  2. package/dist/template/.opencode/AGENTS.md +15 -0
  3. package/dist/template/.opencode/command/init.md +198 -34
  4. package/dist/template/.opencode/context/fallow.md +137 -0
  5. package/dist/template/.opencode/dcp-prompts/overrides/compress-range.md +89 -0
  6. package/dist/template/.opencode/opencode.json +110 -315
  7. package/dist/template/.opencode/plugin/README.md +10 -0
  8. package/dist/template/.opencode/plugin/memory/compile.ts +171 -186
  9. package/dist/template/.opencode/plugin/memory/index-generator.ts +118 -133
  10. package/dist/template/.opencode/plugin/memory/lint.ts +253 -275
  11. package/dist/template/.opencode/plugin/memory/tools.ts +224 -268
  12. package/dist/template/.opencode/plugin/memory/validate.ts +154 -164
  13. package/dist/template/.opencode/plugin/sdk/copilot/responses/tool/web-search-preview.ts +13 -30
  14. package/dist/template/.opencode/plugin/sdk/copilot/responses/tool/web-search-shared.ts +25 -0
  15. package/dist/template/.opencode/plugin/sdk/copilot/responses/tool/web-search.ts +17 -34
  16. package/dist/template/.opencode/plugin/session-summary.ts +542 -0
  17. package/dist/template/.opencode/plugin/srcwalk.ts +775 -661
  18. package/dist/template/.opencode/skill/condition-based-waiting/example.ts +15 -2
  19. package/dist/template/.opencode/skill/fallow/SKILL.md +409 -0
  20. package/dist/template/.opencode/skill/fallow/references/cli-reference.md +1905 -0
  21. package/dist/template/.opencode/skill/fallow/references/gotchas.md +644 -0
  22. package/dist/template/.opencode/skill/fallow/references/patterns.md +791 -0
  23. package/package.json +2 -2
@@ -0,0 +1,542 @@
1
+ /**
2
+ * Session Summary Plugin — Structured Persistent Context
3
+ *
4
+ * Maintains a structured, incrementally-updated session summary that survives
5
+ * DCP compression cycles ("anchored iterative summarization" — inspired by
6
+ * Factory.ai's approach). Tracks:
7
+ *
8
+ * 1. File artifact trail — which files were read, modified, or created
9
+ * 2. Decisions — what was decided and why (rationale + alternatives)
10
+ * 3. Session intent and state — what we're doing and where we are
11
+ * 4. Continuation — next steps to resume work without re-fetching
12
+ *
13
+ * On each system.transform, the summary is injected into context.
14
+ * On compaction, the summary is persisted to disk so it survives the cycle.
15
+ * The anchored design means we merge new information incrementally rather
16
+ * than regenerating from scratch (avoiding semantic drift per Factory's research).
17
+ *
18
+ * Persistence: .opencode/state/session-summary.md
19
+ * Hooks: tool.execute.before, experimental.chat.system.transform, experimental.session.compacting
20
+ *
21
+ * Inspired by: https://factory.ai/news/evaluating-compression
22
+ */
23
+ import fs from "node:fs";
24
+ import path from "node:path";
25
+ import type { Plugin } from "@opencode-ai/plugin";
26
+
27
+ // ============================================================================
28
+ // Types
29
+ // ============================================================================
30
+
31
+ interface Decision {
32
+ what: string;
33
+ rationale: string;
34
+ }
35
+
36
+ interface SessionSummaryData {
37
+ intent: string;
38
+ state: "exploring" | "implementing" | "verifying" | "done" | "unknown";
39
+ files: {
40
+ modified: Map<string, string>; // path → what changed
41
+ created: Set<string>; // paths
42
+ read: Map<string, string>; // path → why examined / key finding
43
+ };
44
+ decisions: Decision[];
45
+ nextSteps: string[];
46
+ }
47
+
48
+ // ============================================================================
49
+ // Constants
50
+ // ============================================================================
51
+
52
+ /** Max artifact entries before we start evicting oldest reads */
53
+ const MAX_READS = 30;
54
+ const MAX_MODIFIED = 20;
55
+ const MAX_CREATED = 10;
56
+ const MAX_DECISIONS = 10;
57
+ const MAX_NEXT_STEPS = 8;
58
+ /** Target summary size in chars (~400 tokens * ~4 chars/token) */
59
+ const MAX_SUMMARY_CHARS = 1600;
60
+
61
+ // ============================================================================
62
+ // Helpers
63
+ // ============================================================================
64
+
65
+ /**
66
+ * Extract a short change description from edit tool args.
67
+ */
68
+ function extractEditDetail(args: Record<string, unknown>): string {
69
+ const oldStr = String(args.oldString ?? "").trim();
70
+ const newStr = String(args.newString ?? "").trim();
71
+ if (!oldStr || !newStr) return "Modified";
72
+
73
+ // If old is much longer than new, it's a deletion/truncation
74
+ if (oldStr.length > newStr.length * 3) return "Truncated/reduced content";
75
+ // If new is much longer than old, it's an addition
76
+ if (newStr.length > oldStr.length * 3) return "Expanded content";
77
+ // Single-line change: show the first line
78
+ const oldLine = oldStr.split("\n")[0]?.trim() ?? "";
79
+ const newLine = newStr.split("\n")[0]?.trim() ?? "";
80
+ if (oldLine && newLine && oldLine !== newLine) {
81
+ const maxLen = 60;
82
+ const shortOld = oldLine.length > maxLen ? `${oldLine.slice(0, maxLen)}…` : oldLine;
83
+ const shortNew = newLine.length > maxLen ? `${newLine.slice(0, maxLen)}…` : newLine;
84
+ return `"${shortOld}" → "${shortNew}"`;
85
+ }
86
+ return "Modified";
87
+ }
88
+
89
+ /**
90
+ * Normalize file path: strip leading ./ and cwd prefix.
91
+ */
92
+ function normalizePath(filePath: string, cwd: string): string {
93
+ let normalized = filePath.startsWith("./") ? filePath.slice(2) : filePath;
94
+
95
+ // Strip path:line or path:start-end suffix (from srcwalk_read path:line format).
96
+ // e.g., "src/app.ts:44-89" → "src/app.ts"
97
+ normalized = normalized.replace(/:\d+(-\d+)?$/, "");
98
+
99
+ // If it's an absolute path, try to make it relative to cwd
100
+ if (path.isAbsolute(normalized)) {
101
+ const relative = path.relative(cwd, normalized);
102
+ // If relative doesn't start with .., it's inside cwd
103
+ if (!relative.startsWith("..")) return relative;
104
+ // Otherwise keep absolute but shortened
105
+ return normalized;
106
+ }
107
+ return normalized;
108
+ }
109
+
110
+ /**
111
+ * Format the summary for context injection (compact markdown).
112
+ * Uses XML-like block for easy delimiting in system prompt.
113
+ */
114
+ function formatSummary(s: SessionSummaryData): string {
115
+ const lines: string[] = [`intent: ${s.intent}`, `state: ${s.state}`, ""];
116
+
117
+ // Files section
118
+ const fileParts: string[] = [];
119
+
120
+ if (s.files.created.size > 0) {
121
+ fileParts.push(`created: ${[...s.files.created].map((p) => `\`${p}\``).join(", ")}`);
122
+ }
123
+
124
+ if (s.files.modified.size > 0) {
125
+ for (const [p, detail] of s.files.modified) {
126
+ fileParts.push(`modified: \`${p}\` — ${detail}`);
127
+ }
128
+ }
129
+
130
+ if (s.files.read.size > 0) {
131
+ // Only include reads that have a reason, plus a summary count
132
+ const readsWithReason = [...s.files.read.entries()].filter(([, r]) => r.length > 0);
133
+ if (readsWithReason.length > 0) {
134
+ for (const [p, reason] of readsWithReason) {
135
+ fileParts.push(`read: \`${p}\` — ${reason}`);
136
+ }
137
+ }
138
+ // Always note total read count
139
+ const extraReads = s.files.read.size - readsWithReason.length;
140
+ if (extraReads > 0) {
141
+ fileParts.push(`read: ${extraReads} more files (no specific notes)`);
142
+ }
143
+ }
144
+
145
+ if (fileParts.length > 0) {
146
+ lines.push("== files ==");
147
+ lines.push(...fileParts);
148
+ lines.push("");
149
+ }
150
+
151
+ // Decisions section
152
+ if (s.decisions.length > 0) {
153
+ lines.push("== decisions ==");
154
+ for (const d of s.decisions) {
155
+ const maxWhat = 120;
156
+ const what = d.what.length > maxWhat ? `${d.what.slice(0, maxWhat)}…` : d.what;
157
+ const maxRat = 200;
158
+ const rationale =
159
+ d.rationale.length > maxRat ? `${d.rationale.slice(0, maxRat)}…` : d.rationale;
160
+ lines.push(`- ${what} | ${rationale}`);
161
+ }
162
+ lines.push("");
163
+ }
164
+
165
+ // Next steps
166
+ if (s.nextSteps.length > 0) {
167
+ lines.push("== next ==");
168
+ for (const step of s.nextSteps) {
169
+ lines.push(`- ${step}`);
170
+ }
171
+ lines.push("");
172
+ }
173
+
174
+ let result = lines.join("\n").trim();
175
+
176
+ // Trim to max chars at line boundary
177
+ if (result.length > MAX_SUMMARY_CHARS) {
178
+ result = result.slice(0, MAX_SUMMARY_CHARS);
179
+ const lastNewline = result.lastIndexOf("\n");
180
+ if (lastNewline > 0) result = result.slice(0, lastNewline);
181
+ result += "\n… (summary truncated)";
182
+ }
183
+
184
+ return result;
185
+ }
186
+
187
+ /**
188
+ * Serialize summary to compact line-based format for disk persistence.
189
+ * Each section uses a single-letter prefix for parseability.
190
+ */
191
+ function serializeSummary(s: SessionSummaryData): string {
192
+ const lines: string[] = [];
193
+ lines.push(`I: ${s.intent}`);
194
+ lines.push(`S: ${s.state}`);
195
+
196
+ for (const p of s.files.created) {
197
+ lines.push(`C: ${p}`);
198
+ }
199
+ for (const [p, d] of s.files.modified) {
200
+ const detail = d.replace(/\n/g, " ");
201
+ if (detail) {
202
+ lines.push(`M: ${p} | ${detail}`);
203
+ } else {
204
+ lines.push(`M: ${p}`);
205
+ }
206
+ }
207
+ for (const [p, r] of s.files.read) {
208
+ const reason = r.replace(/\n/g, " ");
209
+ if (reason) {
210
+ lines.push(`R: ${p} | ${reason}`);
211
+ } else {
212
+ lines.push(`R: ${p}`);
213
+ }
214
+ }
215
+ for (const d of s.decisions) {
216
+ const what = d.what.replace(/\n/g, " ");
217
+ const rat = d.rationale.replace(/\n/g, " ");
218
+ if (rat) {
219
+ lines.push(`D: ${what} | ${rat}`);
220
+ } else {
221
+ lines.push(`D: ${what}`);
222
+ }
223
+ }
224
+ for (const step of s.nextSteps) {
225
+ lines.push(`N: ${step}`);
226
+ }
227
+
228
+ return lines.join("\n");
229
+ }
230
+
231
+ /**
232
+ * Parse the serialized format back into a SessionSummaryData.
233
+ */
234
+ function deserializeSummary(text: string): SessionSummaryData {
235
+ const summary: SessionSummaryData = {
236
+ intent: "",
237
+ state: "unknown",
238
+ files: {
239
+ modified: new Map(),
240
+ created: new Set(),
241
+ read: new Map(),
242
+ },
243
+ decisions: [],
244
+ nextSteps: [],
245
+ };
246
+
247
+ for (const line of text.split("\n")) {
248
+ const trimmed = line.trim();
249
+ if (!trimmed || trimmed.length < 3) continue;
250
+
251
+ const prefix = trimmed[0];
252
+ const content = trimmed.slice(2).trim();
253
+
254
+ if (!content) continue;
255
+
256
+ switch (prefix) {
257
+ case "I":
258
+ summary.intent = content;
259
+ break;
260
+ case "S":
261
+ if (["exploring", "implementing", "verifying", "done", "unknown"].includes(content)) {
262
+ summary.state = content as SessionSummaryData["state"];
263
+ }
264
+ break;
265
+ case "C":
266
+ summary.files.created.add(content);
267
+ break;
268
+ case "M": {
269
+ const pipeIdx = content.indexOf(" | ");
270
+ if (pipeIdx > 0) {
271
+ summary.files.modified.set(content.slice(0, pipeIdx), content.slice(pipeIdx + 3).trim());
272
+ } else {
273
+ summary.files.modified.set(content, "Modified");
274
+ }
275
+ break;
276
+ }
277
+ case "R": {
278
+ const pipeIdx = content.indexOf(" | ");
279
+ if (pipeIdx > 0) {
280
+ summary.files.read.set(content.slice(0, pipeIdx), content.slice(pipeIdx + 3).trim());
281
+ } else {
282
+ summary.files.read.set(content, "");
283
+ }
284
+ break;
285
+ }
286
+ case "D": {
287
+ const pipeIdx = content.indexOf(" | ");
288
+ if (pipeIdx > 0) {
289
+ summary.decisions.push({
290
+ what: content.slice(0, pipeIdx),
291
+ rationale: content.slice(pipeIdx + 3).trim(),
292
+ });
293
+ } else {
294
+ summary.decisions.push({ what: content, rationale: "" });
295
+ }
296
+ break;
297
+ }
298
+ case "N":
299
+ summary.nextSteps.push(content);
300
+ break;
301
+ }
302
+ }
303
+
304
+ return summary;
305
+ }
306
+
307
+ // ============================================================================
308
+ // Enforce max sizes — evict oldest entries
309
+ // ============================================================================
310
+
311
+ function enforceLimits(summary: SessionSummaryData): void {
312
+ // Reads: keep newest (Map preserves insertion order)
313
+ if (summary.files.read.size > MAX_READS) {
314
+ const entries = [...summary.files.read.entries()];
315
+ summary.files.read = new Map(entries.slice(entries.length - MAX_READS));
316
+ }
317
+ if (summary.files.modified.size > MAX_MODIFIED) {
318
+ const entries = [...summary.files.modified.entries()];
319
+ summary.files.modified = new Map(entries.slice(entries.length - MAX_MODIFIED));
320
+ }
321
+ if (summary.files.created.size > MAX_CREATED) {
322
+ summary.files.created = new Set([...summary.files.created].slice(-MAX_CREATED));
323
+ }
324
+ if (summary.decisions.length > MAX_DECISIONS) {
325
+ summary.decisions = summary.decisions.slice(-MAX_DECISIONS);
326
+ }
327
+ if (summary.nextSteps.length > MAX_NEXT_STEPS) {
328
+ summary.nextSteps = summary.nextSteps.slice(-MAX_NEXT_STEPS);
329
+ }
330
+ }
331
+
332
+ // ============================================================================
333
+ // Helper: addRead with dedup and reason preservation
334
+ // ============================================================================
335
+
336
+ function addRead(summary: SessionSummaryData, filePath: string, reason = ""): void {
337
+ // If already tracked as read, update reason only if new one is non-empty
338
+ if (summary.files.read.has(filePath) && !reason) return;
339
+ // Re-insert to update insertion order (move to "newest")
340
+ summary.files.read.delete(filePath);
341
+ summary.files.read.set(filePath, reason);
342
+ }
343
+
344
+ function addModified(summary: SessionSummaryData, filePath: string, detail: string): void {
345
+ summary.files.modified.set(filePath, detail);
346
+ // Remove from created if it was tracked as created
347
+ summary.files.created.delete(filePath);
348
+ }
349
+
350
+ function addDecision(summary: SessionSummaryData, what: string, rationale: string): void {
351
+ summary.decisions.push({ what, rationale });
352
+ }
353
+
354
+ function addCreated(summary: SessionSummaryData, filePath: string): void {
355
+ summary.files.created.add(filePath);
356
+ }
357
+
358
+ // ============================================================================
359
+ // Persistence
360
+ // ============================================================================
361
+
362
+ function ensureDir(dir: string): void {
363
+ if (!fs.existsSync(dir)) {
364
+ fs.mkdirSync(dir, { recursive: true });
365
+ }
366
+ }
367
+
368
+ function loadSummary(filePath: string): SessionSummaryData {
369
+ try {
370
+ if (fs.existsSync(filePath)) {
371
+ const text = fs.readFileSync(filePath, "utf-8").trim();
372
+ if (text) return deserializeSummary(text);
373
+ }
374
+ } catch {
375
+ /* Corrupted or missing — start fresh */
376
+ }
377
+ return {
378
+ intent: "",
379
+ state: "unknown",
380
+ files: { modified: new Map(), created: new Set(), read: new Map() },
381
+ decisions: [],
382
+ nextSteps: [],
383
+ };
384
+ }
385
+
386
+ function saveSummary(filePath: string, summary: SessionSummaryData): void {
387
+ try {
388
+ ensureDir(path.dirname(filePath));
389
+ fs.writeFileSync(filePath, serializeSummary(summary), "utf-8");
390
+ } catch {
391
+ /* Non-fatal — summary is best-effort */
392
+ }
393
+ }
394
+
395
+ // ============================================================================
396
+ // Plugin Export
397
+ // ============================================================================
398
+
399
+ export const SessionSummaryPlugin: Plugin = async ({ client, directory }) => {
400
+ const cwd = process.cwd();
401
+ const stateDir = path.join(directory, ".opencode", "state");
402
+ const summaryPath = path.join(stateDir, "session-summary.md");
403
+
404
+ // Load persisted summary (survives compaction)
405
+ const summary = loadSummary(summaryPath);
406
+
407
+ // Helper to log
408
+ const log = async (message: string, level: "info" | "warn" = "info") => {
409
+ try {
410
+ await client.app.log({
411
+ body: { service: "session-summary", level, message },
412
+ });
413
+ } catch {
414
+ /* Best-effort */
415
+ }
416
+ };
417
+
418
+ // Attempt to guess intent from the first user message we see
419
+ let intentGuessed = summary.intent.length > 0;
420
+
421
+ return {
422
+ // ================================================================
423
+ // File-Artifact Instrumentation
424
+ // Intercept tool calls before execution to track file operations.
425
+ // ================================================================
426
+ "tool.execute.before": async (input, output) => {
427
+ const tool = input.tool?.toLowerCase() ?? "";
428
+ const args = (output.args as Record<string, unknown>) ?? {};
429
+ const filePath = String(args.filePath ?? args.path ?? "").trim();
430
+
431
+ if (!filePath) return;
432
+
433
+ const normalized = normalizePath(filePath, cwd);
434
+
435
+ switch (tool) {
436
+ case "read":
437
+ addRead(summary, normalized);
438
+ break;
439
+ case "edit":
440
+ addModified(summary, normalized, extractEditDetail(args));
441
+ break;
442
+ case "write": {
443
+ // Distinguish create from overwrite
444
+ const absolutePath = path.isAbsolute(normalized)
445
+ ? normalized
446
+ : path.join(cwd, normalized);
447
+ if (!fs.existsSync(absolutePath)) {
448
+ addCreated(summary, normalized);
449
+ }
450
+ addModified(summary, normalized, "Written/created");
451
+ break;
452
+ }
453
+ case "srcwalk_read":
454
+ addRead(summary, normalized, "Code navigation");
455
+ break;
456
+ case "grep":
457
+ case "srcwalk_search":
458
+ case "glob":
459
+ case "srcwalk_files":
460
+ // Search tools — not tracking individual files
461
+ break;
462
+ }
463
+ },
464
+
465
+ // ================================================================
466
+ // Context Injection
467
+ // Inject the structured summary into the system prompt on every
468
+ // system.transform cycle, so it's always available after DCP
469
+ // compression clears older conversation spans.
470
+ // ================================================================
471
+ "experimental.chat.system.transform": async (_input, output) => {
472
+ // If summary is empty, don't waste tokens
473
+ const hasContent =
474
+ summary.intent ||
475
+ summary.files.modified.size > 0 ||
476
+ summary.files.created.size > 0 ||
477
+ summary.decisions.length > 0;
478
+
479
+ if (!hasContent) return;
480
+
481
+ const formatted = formatSummary(summary);
482
+ output.system.push(`\n<session_summary>\n${formatted}\n</session_summary>`);
483
+ },
484
+
485
+ // ================================================================
486
+ // Compaction Anchor
487
+ // Before DCP compression fires:
488
+ // 1. Persist the summary to disk (so it survives the compression)
489
+ // 2. Instruct the summarizer to preserve the summary's data
490
+ // ================================================================
491
+ "experimental.session.compacting": async (_input, output) => {
492
+ // Persist current summary state
493
+ enforceLimits(summary);
494
+ saveSummary(summaryPath, summary);
495
+ await log("Session summary persisted for compaction");
496
+
497
+ // Add instructions to the compaction prompt
498
+ const existingPrompt = output.prompt ?? "";
499
+ output.prompt = `${existingPrompt}
500
+
501
+ <session_summary_anchor>
502
+ The session artifact trail is tracked in .opencode/state/session-summary.md.
503
+ Preserve all file paths, decisions, and next steps noted there.
504
+ Include the updated summary in your compression output.
505
+ </session_summary_anchor>`;
506
+ },
507
+
508
+ // ================================================================
509
+ // Generic Event Handler
510
+ // Capture session intent from first user message.
511
+ // Also hook observation(type:decision) to auto-track decisions.
512
+ // ================================================================
513
+ event: async (input: unknown) => {
514
+ const ev = (input as { event?: { type?: string; properties?: Record<string, unknown> } })
515
+ ?.event;
516
+ if (!ev?.type) return;
517
+
518
+ // Capture session intent from first substantive user message
519
+ if (!intentGuessed && ev.type === "message.updated") {
520
+ const props = ev.properties as Record<string, unknown> | undefined;
521
+ const content = props?.content as string | undefined;
522
+ if (content && content.length > 10 && content.length < 500) {
523
+ summary.intent = content.slice(0, 200);
524
+ intentGuessed = true;
525
+ }
526
+ }
527
+
528
+ // Track decisions from observation tool
529
+ if (ev.type === "tool.execute.after") {
530
+ const props = ev.properties as Record<string, unknown> | undefined;
531
+ if (props?.tool === "observation") {
532
+ const args = props?.args as Record<string, unknown> | undefined;
533
+ if (args?.type === "decision" && args?.title) {
534
+ addDecision(summary, String(args.title), String(args.narrative ?? args.content ?? ""));
535
+ }
536
+ }
537
+ }
538
+ },
539
+ };
540
+ };
541
+
542
+ export default SessionSummaryPlugin;