opencodekit 0.22.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/README.md +1 -1
- package/dist/index.js +4 -25
- package/dist/template/.opencode/.template-manifest.json +115 -188
- package/dist/template/.opencode/AGENTS.md +21 -4
- package/dist/template/.opencode/README.md +1 -1
- package/dist/template/.opencode/agent/build.md +155 -13
- package/dist/template/.opencode/agent/plan.md +7 -16
- package/dist/template/.opencode/agent/scout.md +2 -2
- package/dist/template/.opencode/artifacts/.active +1 -0
- package/dist/template/.opencode/artifacts/example/plan.md +12 -0
- package/dist/template/.opencode/artifacts/example/progress.md +4 -0
- package/dist/template/.opencode/artifacts/example/research.md +4 -0
- package/dist/template/.opencode/artifacts/example/spec.md +16 -0
- package/dist/template/.opencode/artifacts/todo.md +5 -0
- package/dist/template/.opencode/artifacts/verify.log +4 -0
- package/dist/template/.opencode/command/clarify.md +6 -8
- package/dist/template/.opencode/command/create.md +29 -71
- package/dist/template/.opencode/command/design.md +1 -2
- package/dist/template/.opencode/command/explore.md +3 -4
- package/dist/template/.opencode/command/fix.md +0 -1
- package/dist/template/.opencode/command/init.md +1 -4
- package/dist/template/.opencode/command/plan.md +30 -60
- package/dist/template/.opencode/command/pr.md +10 -28
- package/dist/template/.opencode/command/refactor.md +0 -1
- package/dist/template/.opencode/command/research.md +7 -29
- package/dist/template/.opencode/command/review-codebase.md +6 -13
- package/dist/template/.opencode/command/ship.md +136 -78
- package/dist/template/.opencode/command/ui-review.md +2 -4
- package/dist/template/.opencode/command/verify.md +15 -23
- package/dist/template/.opencode/dcp-prompts/overrides/compress-range.md +89 -0
- package/dist/template/.opencode/dcp.jsonc +96 -96
- package/dist/template/.opencode/memory/README.md +1 -1
- package/dist/template/.opencode/memory/_templates/prd.md +1 -1
- package/dist/template/.opencode/memory/_templates/roadmap.md +1 -1
- package/dist/template/.opencode/memory/_templates/state.md +1 -1
- package/dist/template/.opencode/memory/project/gotchas.md +3 -3
- package/dist/template/.opencode/memory/project/project.md +2 -2
- package/dist/template/.opencode/memory/project/roadmap.md +1 -1
- package/dist/template/.opencode/memory/project/state.md +2 -2
- package/dist/template/.opencode/memory/project/tech-stack.md +2 -2
- package/dist/template/.opencode/opencode.json +112 -152
- package/dist/template/.opencode/plugin/README.md +11 -1
- package/dist/template/.opencode/plugin/session-summary.ts +542 -0
- package/dist/template/.opencode/skill/brainstorming/SKILL.md +1 -1
- package/dist/template/.opencode/skill/context-engineering/SKILL.md +1 -1
- package/dist/template/.opencode/skill/development-lifecycle/SKILL.md +26 -45
- package/dist/template/.opencode/skill/gemini-large-context/SKILL.md +4 -4
- package/dist/template/.opencode/skill/opensrc/references/example-workflow.md +1 -1
- package/dist/template/.opencode/skill/subagent-driven-development/SKILL.md +1 -1
- package/dist/template/.opencode/skill/using-git-worktrees/SKILL.md +6 -6
- package/dist/template/.opencode/skill/verification-before-completion/SKILL.md +6 -6
- package/dist/template/.opencode/skill/verification-before-completion/references/VERIFICATION_PROTOCOL.md +5 -5
- package/package.json +76 -76
- package/dist/template/.opencode/plans/1768385996691-silent-wizard.md +0 -247
- package/dist/template/.opencode/plans/1770006237537-mighty-otter.md +0 -418
- package/dist/template/.opencode/plans/1770006913647-glowing-forest.md +0 -170
- package/dist/template/.opencode/plans/1770013678126-witty-planet.md +0 -278
- package/dist/template/.opencode/plans/1770112267595-shiny-rocket.md +0 -258
- package/dist/template/.opencode/plans/swarm-protocol.md +0 -123
- package/dist/template/.opencode/skill/beads/SKILL.md +0 -182
- package/dist/template/.opencode/skill/beads/references/BEST_PRACTICES.md +0 -27
- package/dist/template/.opencode/skill/beads/references/BOUNDARIES.md +0 -219
- package/dist/template/.opencode/skill/beads/references/DEPENDENCIES.md +0 -124
- package/dist/template/.opencode/skill/beads/references/EXAMPLES.md +0 -45
- package/dist/template/.opencode/skill/beads/references/FILE_CLAIMING.md +0 -101
- package/dist/template/.opencode/skill/beads/references/GIT_SYNC.md +0 -25
- package/dist/template/.opencode/skill/beads/references/HIERARCHY.md +0 -71
- package/dist/template/.opencode/skill/beads/references/MULTI_AGENT.md +0 -40
- package/dist/template/.opencode/skill/beads/references/RESUMABILITY.md +0 -177
- package/dist/template/.opencode/skill/beads/references/SESSION_PROTOCOL.md +0 -61
- package/dist/template/.opencode/skill/beads/references/TASK_CREATION.md +0 -38
- package/dist/template/.opencode/skill/beads/references/TROUBLESHOOTING.md +0 -38
- package/dist/template/.opencode/skill/beads/references/WORKFLOWS.md +0 -226
|
@@ -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)
|
|
@@ -41,7 +42,7 @@ plugin/
|
|
|
41
42
|
- Curates observations from distillations via pattern matching
|
|
42
43
|
- Injects relevant knowledge into system prompt (BM25 _ recency _ confidence scoring)
|
|
43
44
|
- Manages context window via messages.transform (token budget enforcement)
|
|
44
|
-
- Merges compaction logic (
|
|
45
|
+
- Merges compaction logic (plans, handoffs, project memory, knowledge)
|
|
45
46
|
- Provides 3 tools: observation, memory-search, memory-admin
|
|
46
47
|
|
|
47
48
|
- `sessions.ts`
|
|
@@ -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;
|
|
@@ -57,7 +57,7 @@ Start by understanding the current project context, then ask questions one at a
|
|
|
57
57
|
|
|
58
58
|
**Documentation:**
|
|
59
59
|
|
|
60
|
-
- Write the validated design to `.
|
|
60
|
+
- Write the validated design to `.opencode/artifacts/<slug>/design.md`
|
|
61
61
|
- Use template from `.opencode/memory/_templates/design.md`
|
|
62
62
|
- Use elements-of-style:writing-clearly-and-concisely skill if available
|
|
63
63
|
- Commit the design document to git
|
|
@@ -169,7 +169,7 @@ These files are the project's invariant layer. Always available, never stale:
|
|
|
169
169
|
These are created fresh per task and cleaned up after:
|
|
170
170
|
|
|
171
171
|
```
|
|
172
|
-
.
|
|
172
|
+
.opencode/artifacts/<slug>/
|
|
173
173
|
├── delegation.md # Task-specific instructions
|
|
174
174
|
├── spec.md # Technical requirements
|
|
175
175
|
└── progress.txt # Task state (append-only)
|