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 +1 -1
- package/dist/template/.opencode/AGENTS.md +3 -0
- package/dist/template/.opencode/dcp-prompts/overrides/compress-range.md +89 -0
- package/dist/template/.opencode/opencode.json +98 -0
- package/dist/template/.opencode/plugin/README.md +10 -0
- package/dist/template/.opencode/plugin/session-summary.ts +542 -0
- package/package.json +2 -2
package/dist/index.js
CHANGED
|
@@ -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.
|
|
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",
|