opencodekit 0.23.0 → 0.23.1

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 CHANGED
@@ -20,7 +20,7 @@ var __require = /* @__PURE__ */ createRequire(import.meta.url);
20
20
 
21
21
  //#endregion
22
22
  //#region package.json
23
- var version = "0.23.0";
23
+ var version = "0.23.1";
24
24
 
25
25
  //#endregion
26
26
  //#region src/utils/license.ts
@@ -191,6 +191,9 @@ Prefer `edit` for modifications; reserve `write` for new files or deliberate ful
191
191
  - Keep context high-signal
192
192
  - Use DCP/VCC tools to compress completed phases and recover targeted history
193
193
  - After any context compaction, re-read: (1) this `AGENTS.md`, (2) the current task details, (3) active state
194
+ - **The `<session_summary>` block in the system prompt** is the artifact trail. It tracks files read/modified/created, decisions made, and next steps. Use it to orient after compression — do not re-read files listed there without checking if you already have the information.
195
+ - **Update the session summary** when you make a structural decision: the `observation(type:decision)` tool auto-populates the decisions section. For file operations (read/edit/write), tracking happens automatically via the session-summary plugin.
196
+ - **Anchored summarization**: when compressing a range, check the session summary first. Your compression should preserve and update the information there — don't drop file paths or decisions that are still relevant.
194
197
 
195
198
  ---
196
199
 
@@ -0,0 +1,89 @@
1
+ Collapse a range in the conversation into a detailed summary.
2
+
3
+ THE SUMMARY
4
+ Your summary must be EXHAUSTIVE. Use the following 7-section structure to ensure nothing is lost.
5
+ Capture file paths, function signatures, decisions made, constraints discovered, key findings, tool outcomes, and user intent... EVERYTHING that preserves the value of the compressed range.
6
+
7
+ STRUCTURED SUMMARY FORMAT
8
+ When summarizing the range, organize findings into these sections (omit empty sections):
9
+
10
+ 1. **Session Intent** — What the user requested. Quote exact instructions when short.
11
+ 2. **Key Technical Details** — File paths with line ranges (`path/file.ts:42-67`). Function signatures. Error messages verbatim. Code snippets critical to understanding.
12
+ 3. **Artifact Map** — Enumeration of files created, modified, and read, with what changed in each:
13
+ - `file created: path/to/new.ts` — what the file contains
14
+ - `file modified: path/to/existing.ts` — what changed (functions added/removed, logic changed)
15
+ - `file read: path/to/examined.ts` — why it was examined, key findings
16
+ 4. **Decisions** — What was decided, alternatives considered, rationale. Preserve the reasoning chain.
17
+ 5. **Problem Solving** — Approaches tried (including failures). What worked and why. Root cause analysis.
18
+ 6. **Current State** — Where we are in the task: what's done, what's active, what's blocked.
19
+ 7. **Continuation** — Exact next steps to resume work without re-fetching context. Include pending tasks with status.
20
+
21
+ USER INTENT FIDELITY
22
+ When the compressed range includes user messages, preserve the user's intent with extra care. Do not change scope, constraints, priorities, acceptance criteria, or requested outcomes.
23
+ Directly quote user messages when they are short enough to include safely. Direct quotes are preferred when they best preserve exact meaning.
24
+
25
+ Yet be LEAN. Strip away the noise: failed attempts that led nowhere, verbose tool outputs, back-and-forth exploration. What remains should be pure signal — golden nuggets of detail that preserve full understanding with zero ambiguity.
26
+
27
+ COMPRESSED BLOCK PLACEHOLDERS
28
+ When the selected range includes previously compressed blocks, use this exact placeholder format when referencing one:
29
+
30
+ - `(bN)`
31
+
32
+ Compressed block sections in context are clearly marked with a header:
33
+
34
+ - `[Compressed conversation section]`
35
+
36
+ Compressed block IDs always use the `bN` form (never `mNNNN`) and are represented in the same XML metadata tag format.
37
+
38
+ Rules:
39
+
40
+ - Include every required block placeholder exactly once.
41
+ - Do not invent placeholders for blocks outside the selected range.
42
+ - Treat `(bN)` placeholders as RESERVED TOKENS. Do not emit `(bN)` text anywhere except intentional placeholders.
43
+ - If you need to mention a block in prose, use plain text like `compressed bN` (not as a placeholder).
44
+ - Preflight check before finalizing: the set of `(bN)` placeholders in your summary must exactly match the required set, with no duplicates.
45
+
46
+ These placeholders are semantic references. They will be replaced with the full stored compressed block content when the tool processes your output.
47
+
48
+ FLOW PRESERVATION WITH PLACEHOLDERS
49
+ When you use compressed block placeholders, write the surrounding summary text so it still reads correctly AFTER placeholder expansion.
50
+
51
+ - Treat each placeholder as a stand-in for a full conversation segment, not as a short label.
52
+ - Ensure transitions before and after each placeholder preserve chronology and causality.
53
+ - Do not write text that depends on the placeholder staying literal (for example, "as noted in `(b2)`").
54
+ - Your final meaning must be coherent once each placeholder is replaced with its full compressed block content.
55
+
56
+ BOUNDARY IDS
57
+ You specify boundaries by ID using the injected IDs visible in the conversation:
58
+
59
+ - `mNNNN` IDs identify raw messages
60
+ - `bN` IDs identify previously compressed blocks
61
+
62
+ Each message has an ID inside XML metadata tags like `<dcp-message-id>m0001</dcp-message-id>`.
63
+ The same ID tag appears in every tool output of the message it belongs to — each unique ID identifies one complete message.
64
+ Treat these tags as boundary metadata only, not as tool result content.
65
+
66
+ Rules:
67
+
68
+ - Pick `startId` and `endId` directly from injected IDs in context.
69
+ - IDs must exist in the current visible context.
70
+ - `startId` must appear before `endId`.
71
+ - Do not invent IDs. Use only IDs that are present in context.
72
+
73
+ BATCHING
74
+ When multiple independent ranges are ready and their boundaries do not overlap, include all of them as separate entries in the `content` array of a single tool call. Each entry should have its own `startId`, `endId`, and `summary`.
75
+
76
+ THE FORMAT OF COMPRESS
77
+
78
+ ```
79
+ {
80
+ topic: string, // Short label (3-5 words) for the overall batch
81
+ content: [ // One or more ranges to compress independently
82
+ {
83
+ startId: string, // ID at range start: mNNNN or bN
84
+ endId: string, // ID at range end: mNNNN or bN
85
+ summary: string // Complete technical summary replacing that range (use 7-section format above)
86
+ }
87
+ ]
88
+ }
89
+ ```
@@ -678,6 +678,104 @@
678
678
  "baseURL": "http://127.0.0.1:8317/v1",
679
679
  "includeUsage": true
680
680
  }
681
+ },
682
+ "opencode-go": {
683
+ "description": "OpenCode Go subscription — $10/mo for open coding models",
684
+ "options": {
685
+ "baseURL": "https://opencode.ai/zen/go/v1"
686
+ },
687
+ "models": {
688
+ "deepseek-v4-flash": {
689
+ "limit": {
690
+ "context": 200000,
691
+ "output": 64000
692
+ },
693
+ "reasoning": true,
694
+ "tool_call": true
695
+ },
696
+ "mimo-v2.5": {
697
+ "limit": {
698
+ "context": 180000,
699
+ "output": 64000
700
+ },
701
+ "modalities": {
702
+ "input": ["text", "image"],
703
+ "output": ["text"]
704
+ },
705
+ "reasoning": true,
706
+ "tool_call": true
707
+ },
708
+ "deepseek-v4-pro": {
709
+ "limit": {
710
+ "context": 240000,
711
+ "output": 64000
712
+ },
713
+ "reasoning": true,
714
+ "tool_call": true
715
+ },
716
+ "mimo-v2.5-pro": {
717
+ "limit": {
718
+ "context": 220000,
719
+ "output": 64000
720
+ },
721
+ "modalities": {
722
+ "input": ["text", "image", "audio"],
723
+ "output": ["text"]
724
+ },
725
+ "reasoning": true,
726
+ "tool_call": true
727
+ }
728
+ }
729
+ },
730
+ "deepseek": {
731
+ "description": "Direct DeepSeek API — deepseek-v4-flash & deepseek-v4-pro 1M native context",
732
+ "models": {
733
+ "deepseek-v4-flash": {
734
+ "limit": {
735
+ "context": 200000,
736
+ "output": 64000
737
+ },
738
+ "reasoning": true,
739
+ "tool_call": true
740
+ },
741
+ "deepseek-v4-pro": {
742
+ "limit": {
743
+ "context": 240000,
744
+ "output": 64000
745
+ },
746
+ "reasoning": true,
747
+ "tool_call": true
748
+ }
749
+ }
750
+ },
751
+ "xiaomi-mimo": {
752
+ "description": "Direct Xiaomi MiMo API — mimo-v2.5 & mimo-v2.5-pro 1M native context",
753
+ "models": {
754
+ "mimo-v2.5": {
755
+ "limit": {
756
+ "context": 180000,
757
+ "output": 64000
758
+ },
759
+ "modalities": {
760
+ "input": ["text", "image", "audio"],
761
+ "output": ["text"]
762
+ },
763
+ "reasoning": true,
764
+ "tool_call": true
765
+ },
766
+ "mimo-v2.5-pro": {
767
+ "limit": {
768
+ "context": 220000,
769
+ "output": 64000
770
+ },
771
+ "modalities": {
772
+ "input": ["text", "image", "audio"],
773
+ "output": ["text"]
774
+ },
775
+ "reasoning": true,
776
+ "tool_call": true
777
+ }
778
+ }
681
779
  }
682
780
  },
683
781
  "share": "manual",
@@ -11,6 +11,7 @@ plugin/
11
11
  ├── copilot-auth.ts # GitHub Copilot provider/auth integration
12
12
  ├── prompt-leverage.ts # Prompt pre-processing with structured execution framing
13
13
  ├── rtk.ts # Optional RTK command-output compression hook
14
+ ├── session-summary.ts # Structured persistent session summary (artifact trail, decisions, anchored merge)
14
15
  ├── skill-mcp.ts # Skill-scoped MCP bridge (skill_mcp tools)
15
16
  └── lib/
16
17
  ├── memory-tools.ts # 6 core memory tools (observation, search, get, read, update, timeline)
@@ -69,6 +70,15 @@ plugin/
69
70
  - Rewrites low-risk `bash`/`shell` commands through `rtk rewrite`
70
71
  - Keeps an idempotency guard for symlinked global/project config double-loading
71
72
 
73
+ - `session-summary.ts`
74
+ - Maintains a structured, incrementally-updated session summary that survives DCP compression cycles
75
+ - **File-artifact tracking**: intercepts `read`, `edit`, `write`, `srcwalk_read` via `tool.execute.before` to track which files were read, modified, or created
76
+ - **Decision capture**: auto-tracks `observation(type:decision)` calls to log decisions with rationale
77
+ - **Anchored merge**: persists summary to `.opencode/state/session-summary.md` before compaction, merges incrementally rather than regenerating from scratch
78
+ - **Context injection**: injects structured `<session_summary>` block into system prompt via `experimental.chat.system.transform`
79
+ - **Intent guessing**: captures session intent from the first user message
80
+ - Inspired by Factory.ai's anchored iterative summarization research
81
+
72
82
  ## Notes
73
83
 
74
84
  - OpenCode auto-discovers every `.ts` file in `plugin/` as a plugin — keep helper modules in `lib/`
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencodekit",
3
- "version": "0.23.0",
3
+ "version": "0.23.1",
4
4
  "description": "CLI tool for bootstrapping and managing OpenCodeKit projects",
5
5
  "keywords": [
6
6
  "agents",
@@ -34,7 +34,7 @@
34
34
  },
35
35
  "scripts": {
36
36
  "dev": "tsx src/index.ts",
37
- "build": "tsdown && mkdir -p dist/template && rsync -av --exclude=node_modules --exclude=dist --exclude=.git --exclude=coverage --exclude=.next --exclude=.turbo --exclude=logs --exclude=package-lock.json --exclude='plugin/*.bak' --exclude=memory.db --exclude=memory.db-shm --exclude=memory.db-wal --exclude='memory.db.corrupt.*' --exclude=memory-recovery.log .opencode/ dist/template/.opencode/",
37
+ "build": "tsdown && mkdir -p dist/template && rsync -av --exclude=node_modules --exclude=dist --exclude=.git --exclude=coverage --exclude=.next --exclude=.turbo --exclude=logs --exclude=package-lock.json --exclude='plugin/*.bak' --exclude=memory.db --exclude=memory.db-shm --exclude=memory.db-wal --exclude='memory.db.corrupt.*' --exclude=memory-recovery.log --exclude=state/ .opencode/ dist/template/.opencode/",
38
38
  "typecheck": "tsgo --noEmit",
39
39
  "test": "vitest run",
40
40
  "test:watch": "vitest",