opencode-blackhole 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +35 -0
- package/LICENSE.vendor +21 -0
- package/README.md +377 -2
- package/dist/server.js +5025 -0
- package/index.ts +1 -0
- package/package.json +57 -4
- package/src/adapter/opencode-messages.ts +270 -0
- package/src/compaction/build.ts +264 -0
- package/src/compaction/handoff.ts +421 -0
- package/src/config.ts +316 -0
- package/src/index.ts +714 -0
- package/src/memory/ledger.ts +254 -0
- package/src/memory/manager.ts +659 -0
- package/src/memory/models.ts +172 -0
- package/src/memory/prompts.ts +145 -0
- package/src/recall/recall.ts +327 -0
- package/src/state/paths.ts +42 -0
- package/src/store/capture.ts +84 -0
- package/src/store/transcript.ts +279 -0
- package/src/vcc/README.md +51 -0
- package/src/vcc/core/brief.ts +407 -0
- package/src/vcc/core/build-sections.ts +81 -0
- package/src/vcc/core/compact-args.ts +45 -0
- package/src/vcc/core/content.ts +156 -0
- package/src/vcc/core/drill-down.ts +302 -0
- package/src/vcc/core/filter-noise.ts +41 -0
- package/src/vcc/core/format-recall.ts +100 -0
- package/src/vcc/core/format.ts +104 -0
- package/src/vcc/core/lineage.ts +26 -0
- package/src/vcc/core/normalize.ts +76 -0
- package/src/vcc/core/rank.ts +283 -0
- package/src/vcc/core/recall-request.ts +160 -0
- package/src/vcc/core/recall-scope.ts +30 -0
- package/src/vcc/core/render-entries.ts +55 -0
- package/src/vcc/core/sanitize.ts +5 -0
- package/src/vcc/core/search-entries.ts +594 -0
- package/src/vcc/core/skill-collapse.ts +35 -0
- package/src/vcc/core/summarize.ts +289 -0
- package/src/vcc/core/token-estimate.ts +100 -0
- package/src/vcc/core/tool-args.ts +16 -0
- package/src/vcc/details.ts +11 -0
- package/src/vcc/extract/commits.ts +96 -0
- package/src/vcc/extract/files.ts +87 -0
- package/src/vcc/extract/goals.ts +79 -0
- package/src/vcc/extract/preferences.ts +55 -0
- package/src/vcc/extract/tracked-commands.ts +232 -0
- package/src/vcc/load-messages.ts +42 -0
- package/src/vcc/pi-types.ts +53 -0
- package/src/vcc/sections.ts +12 -0
- package/src/vcc/types.ts +16 -0
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./dist/server.js"
|
package/package.json
CHANGED
|
@@ -1,6 +1,59 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-blackhole",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Deterministic compaction + observational memory for OpenCode. Replaces the native LLM summarizer with a zero-cost structural summary, and keeps durable session memory across compactions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/server.js",
|
|
8
|
+
"module": "./dist/server.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
"./server": {
|
|
11
|
+
"import": "./dist/server.js"
|
|
12
|
+
},
|
|
13
|
+
".": {
|
|
14
|
+
"import": "./dist/server.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist/",
|
|
19
|
+
"src/",
|
|
20
|
+
"index.ts",
|
|
21
|
+
"README.md",
|
|
22
|
+
"LICENSE",
|
|
23
|
+
"LICENSE.vendor"
|
|
24
|
+
],
|
|
25
|
+
"keywords": [
|
|
26
|
+
"opencode",
|
|
27
|
+
"opencode-plugin",
|
|
28
|
+
"compaction",
|
|
29
|
+
"context",
|
|
30
|
+
"memory",
|
|
31
|
+
"observational-memory"
|
|
32
|
+
],
|
|
33
|
+
"engines": {
|
|
34
|
+
"opencode": ">=2.0.28"
|
|
35
|
+
},
|
|
36
|
+
"scripts": {
|
|
37
|
+
"build": "bun build src/index.ts --outfile dist/server.js --target node --format esm --external @opencode/plugin",
|
|
38
|
+
"dev": "bun build src/index.ts --outfile dist/server.js --target node --format esm --external @opencode/plugin --watch",
|
|
39
|
+
"test": "bun test ./tests",
|
|
40
|
+
"typecheck": "tsc --noEmit",
|
|
41
|
+
"check": "bun run typecheck && bun test ./tests && bun run build",
|
|
42
|
+
"bench": "bun run scripts/bench-capture.ts",
|
|
43
|
+
"prepublishOnly": "bun run check"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@opencode/plugin": "^2.0.32"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^24.0.0",
|
|
50
|
+
"bun-types": "^1.4.3",
|
|
51
|
+
"typescript": "^5.6.0"
|
|
52
|
+
},
|
|
53
|
+
"peerDependencies": {
|
|
54
|
+
"@opencode/plugin": ">=2.0.0"
|
|
55
|
+
},
|
|
56
|
+
"publishConfig": {
|
|
57
|
+
"access": "public"
|
|
58
|
+
}
|
|
59
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode → vcc message adapter.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode's model-request messages are AI-SDK style: `{ role, content: Part[] }`
|
|
5
|
+
* where a part is `text`, `reasoning`, `tool-call` or `tool-result`. The vcc
|
|
6
|
+
* core expects Pi's shape: `{ role, content: string | Part[] }` with
|
|
7
|
+
* `toolCall` parts, a `toolResult` role and a `bashExecution` role that
|
|
8
|
+
* carries a command and its output together.
|
|
9
|
+
*
|
|
10
|
+
* This adapter is the only place that knows about OpenCode's message format.
|
|
11
|
+
*/
|
|
12
|
+
import type { ContentPart, Message, ToolCall } from "../vcc/pi-types"
|
|
13
|
+
|
|
14
|
+
interface OpenCodePart {
|
|
15
|
+
type: string
|
|
16
|
+
text?: string
|
|
17
|
+
id?: string
|
|
18
|
+
name?: string
|
|
19
|
+
input?: unknown
|
|
20
|
+
output?: unknown
|
|
21
|
+
toolCallId?: string
|
|
22
|
+
mimeType?: string
|
|
23
|
+
state?: { status?: string; output?: unknown; content?: unknown }
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface OpenCodeMessage {
|
|
27
|
+
id?: string
|
|
28
|
+
role: string
|
|
29
|
+
content?: unknown
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A tool call, kept so a later tool result can be paired with its call. */
|
|
33
|
+
export interface ToolCallInfo {
|
|
34
|
+
name: string
|
|
35
|
+
input: Record<string, unknown>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface AdaptedMessages {
|
|
39
|
+
messages: Message[]
|
|
40
|
+
/**
|
|
41
|
+
* Session-global index per adapted message, parallel to `messages`.
|
|
42
|
+
* `undefined` means the message has no stable index yet.
|
|
43
|
+
*/
|
|
44
|
+
sourceIndices: Array<number | undefined>
|
|
45
|
+
/** The previous compaction's summary, when the request starts with a checkpoint. */
|
|
46
|
+
previousSummary?: string
|
|
47
|
+
/** The previous compaction's verbatim tail, when present. */
|
|
48
|
+
previousRecent?: string
|
|
49
|
+
/** The tool calls known after this batch — pass the map in, get it back. */
|
|
50
|
+
calls: Map<string, ToolCallInfo>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface AdaptOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Tool calls already known for the session.
|
|
56
|
+
*
|
|
57
|
+
* The adapter is called with only the *new* messages of a request, so a
|
|
58
|
+
* tool result whose call was captured in an earlier request needs the call
|
|
59
|
+
* to be supplied from here.
|
|
60
|
+
*/
|
|
61
|
+
calls?: Map<string, ToolCallInfo>
|
|
62
|
+
/** Resolve the session-global index of a message id. */
|
|
63
|
+
resolveIndex?: (id: string) => number | undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* OpenCode tool names → Pi tool names the vcc core knows about.
|
|
68
|
+
*
|
|
69
|
+
* The core matches on Pi's vocabulary (`bash` for shell execution,
|
|
70
|
+
* `apply_patch` for multi-file edits) to decide what is a file read, a file
|
|
71
|
+
* write, or a shell command worth ranking.
|
|
72
|
+
*/
|
|
73
|
+
const TOOL_NAME_MAP: Record<string, string> = {
|
|
74
|
+
shell: "bash",
|
|
75
|
+
bash: "bash",
|
|
76
|
+
patch: "apply_patch",
|
|
77
|
+
apply_patch: "apply_patch",
|
|
78
|
+
multiedit: "multiedit",
|
|
79
|
+
write: "write",
|
|
80
|
+
edit: "edit",
|
|
81
|
+
read: "read",
|
|
82
|
+
view: "read",
|
|
83
|
+
glob: "glob",
|
|
84
|
+
grep: "grep",
|
|
85
|
+
list: "list",
|
|
86
|
+
todowrite: "todowrite",
|
|
87
|
+
webfetch: "webfetch",
|
|
88
|
+
websearch: "websearch",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const mapToolName = (name: string): string => TOOL_NAME_MAP[name.toLowerCase()] ?? name
|
|
92
|
+
|
|
93
|
+
const textOf = (content: unknown): string => {
|
|
94
|
+
if (typeof content === "string") return content
|
|
95
|
+
if (!Array.isArray(content)) return ""
|
|
96
|
+
return content
|
|
97
|
+
.map((part) => {
|
|
98
|
+
const p = part as OpenCodePart
|
|
99
|
+
if (p?.type === "text" && typeof p.text === "string") return p.text
|
|
100
|
+
return ""
|
|
101
|
+
})
|
|
102
|
+
.filter(Boolean)
|
|
103
|
+
.join("\n")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const partsOf = (message: OpenCodeMessage): OpenCodePart[] => {
|
|
107
|
+
const raw = message.content
|
|
108
|
+
if (typeof raw === "string") return [{ type: "text", text: raw }]
|
|
109
|
+
if (!Array.isArray(raw)) return []
|
|
110
|
+
return raw as OpenCodePart[]
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Tool result payloads arrive as a string or as `{ content, metadata }`. */
|
|
114
|
+
const toolResultText = (part: OpenCodePart): string => {
|
|
115
|
+
const output = part.output ?? part.state?.output ?? part.state?.content
|
|
116
|
+
if (typeof output === "string") return output
|
|
117
|
+
if (output && typeof output === "object") {
|
|
118
|
+
const content = (output as { content?: unknown }).content
|
|
119
|
+
if (typeof content === "string") return content
|
|
120
|
+
if (Array.isArray(content)) {
|
|
121
|
+
return content
|
|
122
|
+
.map((item) => {
|
|
123
|
+
if (typeof item === "string") return item
|
|
124
|
+
if (item && typeof item === "object") {
|
|
125
|
+
const text = (item as { text?: unknown }).text
|
|
126
|
+
if (typeof text === "string") return text
|
|
127
|
+
}
|
|
128
|
+
return ""
|
|
129
|
+
})
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
.join("\n")
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
return JSON.stringify(output)
|
|
135
|
+
} catch {
|
|
136
|
+
return ""
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return textOf(part.output)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const exitCodeOf = (part: OpenCodePart): number | undefined => {
|
|
143
|
+
const output = part.output ?? part.state?.output
|
|
144
|
+
if (output && typeof output === "object") {
|
|
145
|
+
const metadata = (output as { metadata?: { exit?: number } }).metadata
|
|
146
|
+
if (typeof metadata?.exit === "number") return metadata.exit
|
|
147
|
+
}
|
|
148
|
+
return undefined
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const CHECKPOINT_OPEN = "<conversation-checkpoint>"
|
|
152
|
+
const CHECKPOINT_CLOSE = "</conversation-checkpoint>"
|
|
153
|
+
|
|
154
|
+
/** Parse OpenCode's checkpoint wrapper (a completed compaction message). */
|
|
155
|
+
const parseCheckpoint = (text: string): { summary?: string; recent?: string } | undefined => {
|
|
156
|
+
if (!text.includes(CHECKPOINT_OPEN) || !text.includes(CHECKPOINT_CLOSE)) return undefined
|
|
157
|
+
const summary = /<summary>\n?([\s\S]*?)\n?<\/summary>/.exec(text)?.[1]
|
|
158
|
+
const recent = /<recent-context>\n?([\s\S]*?)\n?<\/recent-context>/.exec(text)?.[1]
|
|
159
|
+
return { summary, recent }
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Convert OpenCode messages into the vcc core's message shape.
|
|
164
|
+
*
|
|
165
|
+
* Shell executions are folded back into Pi's `bashExecution` role so the core's
|
|
166
|
+
* shell-aware ranking, commit extraction and tracked-command extraction all
|
|
167
|
+
* keep working.
|
|
168
|
+
*/
|
|
169
|
+
export const adaptMessages = (
|
|
170
|
+
messages: readonly OpenCodeMessage[],
|
|
171
|
+
options: AdaptOptions = {},
|
|
172
|
+
): AdaptedMessages => {
|
|
173
|
+
const resolveIndex = options.resolveIndex
|
|
174
|
+
// The call map persists across requests, so a result can be paired with a
|
|
175
|
+
// call that was captured earlier in the session.
|
|
176
|
+
const calls = options.calls ?? new Map<string, ToolCallInfo>()
|
|
177
|
+
|
|
178
|
+
// Pass 1: index tool calls so results can be paired with their call.
|
|
179
|
+
for (const message of messages) {
|
|
180
|
+
if (message.role !== "assistant") continue
|
|
181
|
+
for (const part of partsOf(message)) {
|
|
182
|
+
if (part.type !== "tool-call" || !part.id) continue
|
|
183
|
+
if (calls.has(part.id)) continue
|
|
184
|
+
calls.set(part.id, {
|
|
185
|
+
name: mapToolName(part.name ?? "tool"),
|
|
186
|
+
input:
|
|
187
|
+
part.input && typeof part.input === "object"
|
|
188
|
+
? (part.input as Record<string, unknown>)
|
|
189
|
+
: {},
|
|
190
|
+
})
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const out: Message[] = []
|
|
195
|
+
const sourceIndices: Array<number | undefined> = []
|
|
196
|
+
let previousSummary: string | undefined
|
|
197
|
+
let previousRecent: string | undefined
|
|
198
|
+
|
|
199
|
+
const push = (message: Message, id?: string) => {
|
|
200
|
+
out.push(id ? { ...message, id } : message)
|
|
201
|
+
sourceIndices.push(id && resolveIndex ? resolveIndex(id) : undefined)
|
|
202
|
+
}
|
|
203
|
+
for (const message of messages) {
|
|
204
|
+
const parts = partsOf(message)
|
|
205
|
+
|
|
206
|
+
if (message.role === "user") {
|
|
207
|
+
const text = textOf(message.content)
|
|
208
|
+
const checkpoint = parseCheckpoint(text)
|
|
209
|
+
if (checkpoint) {
|
|
210
|
+
previousSummary = checkpoint.summary
|
|
211
|
+
previousRecent = checkpoint.recent
|
|
212
|
+
continue
|
|
213
|
+
}
|
|
214
|
+
const content: ContentPart[] = []
|
|
215
|
+
for (const part of parts) {
|
|
216
|
+
if (part.type === "text" && part.text) content.push({ type: "text", text: part.text })
|
|
217
|
+
}
|
|
218
|
+
if (content.length > 0) push({ role: "user", content }, message.id)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (message.role === "assistant") {
|
|
223
|
+
const content: ContentPart[] = []
|
|
224
|
+
for (const part of parts) {
|
|
225
|
+
if (part.type === "text" && part.text) content.push({ type: "text", text: part.text })
|
|
226
|
+
else if (part.type === "tool-call") {
|
|
227
|
+
const call = calls.get(part.id ?? "")
|
|
228
|
+
content.push({
|
|
229
|
+
type: "toolCall",
|
|
230
|
+
id: part.id ?? "",
|
|
231
|
+
name: call?.name ?? mapToolName(part.name ?? "tool"),
|
|
232
|
+
arguments: call?.input ?? {},
|
|
233
|
+
})
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (content.length > 0) push({ role: "assistant", content }, message.id)
|
|
237
|
+
continue
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (message.role === "tool") {
|
|
241
|
+
let seen = 0
|
|
242
|
+
for (const part of parts) {
|
|
243
|
+
if (part.type !== "tool-result") continue
|
|
244
|
+
const call = calls.get(part.toolCallId ?? "")
|
|
245
|
+
const name = call?.name ?? mapToolName(part.name ?? "tool")
|
|
246
|
+
const text = toolResultText(part)
|
|
247
|
+
// One OpenCode tool message can carry several results; keep a stable
|
|
248
|
+
// id per result so the transcript store can index them separately.
|
|
249
|
+
const id = seen === 0 ? message.id : `${message.id}~${part.toolCallId ?? seen}`
|
|
250
|
+
seen++
|
|
251
|
+
if (name === "bash") {
|
|
252
|
+
push(
|
|
253
|
+
{
|
|
254
|
+
role: "bashExecution",
|
|
255
|
+
command: typeof call?.input.command === "string" ? (call.input.command as string) : "",
|
|
256
|
+
output: text,
|
|
257
|
+
exitCode: exitCodeOf(part),
|
|
258
|
+
},
|
|
259
|
+
id,
|
|
260
|
+
)
|
|
261
|
+
} else {
|
|
262
|
+
push({ role: "toolResult", toolName: name, content: text }, id)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return { messages: out, sourceIndices, previousSummary, previousRecent, calls }
|
|
270
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic summary construction for OpenCode's compaction hook.
|
|
3
|
+
*
|
|
4
|
+
* The vcc core produces the summary; this module adapts OpenCode's messages,
|
|
5
|
+
* sizes the brief transcript against a token budget, and enforces the safety
|
|
6
|
+
* valve: if the extraction yields nothing useful, we return no result at all
|
|
7
|
+
* so OpenCode falls back to its own summarizer.
|
|
8
|
+
*/
|
|
9
|
+
import { adaptMessages } from "../adapter/opencode-messages"
|
|
10
|
+
import { stripHandoff } from "./handoff"
|
|
11
|
+
import { compileRanked } from "../vcc/core/summarize"
|
|
12
|
+
import {
|
|
13
|
+
calibrateCharsPerToken,
|
|
14
|
+
DEFAULT_CHARS_PER_TOKEN,
|
|
15
|
+
estimateTokensFromChars,
|
|
16
|
+
} from "../vcc/core/token-estimate"
|
|
17
|
+
import type { FileOps } from "../vcc/types"
|
|
18
|
+
|
|
19
|
+
export interface BuildSummaryInput {
|
|
20
|
+
/** The compaction hook's request messages. */
|
|
21
|
+
messages: readonly unknown[]
|
|
22
|
+
/** Session-global index resolver, keyed by OpenCode message id. */
|
|
23
|
+
resolveIndex?: (id: string) => number | undefined
|
|
24
|
+
/**
|
|
25
|
+
* Authoritative file activity for the whole session. OpenCode keeps the
|
|
26
|
+
* newest exchange out of the summarization request (it is retained
|
|
27
|
+
* verbatim), so files touched there are only known from the transcript.
|
|
28
|
+
*/
|
|
29
|
+
fileOps?: FileOps
|
|
30
|
+
/** Token budget for the brief transcript. */
|
|
31
|
+
briefTokens: number
|
|
32
|
+
/** Hard cap for the whole summary. */
|
|
33
|
+
maxSummaryTokens: number
|
|
34
|
+
/** Extra note appended to the summary (project conventions, etc.). */
|
|
35
|
+
extraNote?: string
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface BuildSummaryResult {
|
|
39
|
+
summary?: string
|
|
40
|
+
/** Estimated size of the summary, in tokens. */
|
|
41
|
+
tokens?: number
|
|
42
|
+
/** The previous checkpoint's summary, when the request started with one. */
|
|
43
|
+
previousSummary?: string
|
|
44
|
+
/** True when the previous checkpoint was reused because nothing new was in the window. */
|
|
45
|
+
carried?: boolean
|
|
46
|
+
/** Why the summary was rejected, when it was. */
|
|
47
|
+
rejected?: string
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SECTION_MARKERS = ["[Session Goal]", "[Files And Changes]", "[Commits]", "[Outstanding Context]"]
|
|
51
|
+
const SEPARATOR = "\n\n---\n\n"
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The vcc core ends every summary with a recall hint naming Pi's `vcc_recall`
|
|
55
|
+
* tool. We keep the vendored core pristine so upstream stays mergeable, and
|
|
56
|
+
* rewrite the hint here to name OpenCode's tool.
|
|
57
|
+
*/
|
|
58
|
+
const RECALL_TOOL = "blackhole_recall"
|
|
59
|
+
const rewriteRecallNote = (summary: string): string => summary.replace(/`vcc_recall`/g, "`" + RECALL_TOOL + "`")
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Split a compiled summary into three parts: the section headers, the brief
|
|
63
|
+
* transcript and the trailing recall note.
|
|
64
|
+
*
|
|
65
|
+
* The vcc format is `sections --- brief --- note`, and the separator appears
|
|
66
|
+
* once or twice depending on whether there were sections.
|
|
67
|
+
*/
|
|
68
|
+
const splitSummary = (summary: string): { head: string; brief: string; note: string } => {
|
|
69
|
+
let body = summary
|
|
70
|
+
let note = ""
|
|
71
|
+
|
|
72
|
+
const noteIndex = body.lastIndexOf(SEPARATOR)
|
|
73
|
+
if (noteIndex >= 0) {
|
|
74
|
+
const tail = body.slice(noteIndex + SEPARATOR.length)
|
|
75
|
+
if (tail.includes("recall")) {
|
|
76
|
+
note = tail.trim()
|
|
77
|
+
body = body.slice(0, noteIndex).trim()
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const index = body.indexOf(SEPARATOR)
|
|
82
|
+
if (index < 0) {
|
|
83
|
+
const startsWithSection = SECTION_MARKERS.some((marker) => body.trimStart().startsWith(marker))
|
|
84
|
+
return startsWithSection ? { head: body.trim(), brief: "", note } : { head: "", brief: body.trim(), note }
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
head: body.slice(0, index).trim(),
|
|
88
|
+
brief: body.slice(index + SEPARATOR.length).trim(),
|
|
89
|
+
note,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Trim a section's bullet list to fit a character budget, dropping the
|
|
95
|
+
* oldest lines first (the brief transcript is the only part that can grow
|
|
96
|
+
* without bound).
|
|
97
|
+
*/
|
|
98
|
+
const trimToChars = (text: string, maxChars: number): string => {
|
|
99
|
+
if (text.length <= maxChars) return text
|
|
100
|
+
const lines = text.split("\n")
|
|
101
|
+
const kept: string[] = []
|
|
102
|
+
let size = 0
|
|
103
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
104
|
+
const line = lines[i]
|
|
105
|
+
if (size + line.length + 1 > maxChars) break
|
|
106
|
+
kept.unshift(line)
|
|
107
|
+
size += line.length + 1
|
|
108
|
+
}
|
|
109
|
+
if (kept.length > 0) return kept.join("\n")
|
|
110
|
+
// The last line alone is over budget: cut it instead of dropping it.
|
|
111
|
+
const last = lines[lines.length - 1] ?? ""
|
|
112
|
+
return last.slice(0, Math.max(0, maxChars))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Trim a summary's header sections to fit a character budget.
|
|
117
|
+
*
|
|
118
|
+
* Section headers are kept (they carry the structure); the bullet lists under
|
|
119
|
+
* them give way first, oldest lines first, so the most recent facts survive.
|
|
120
|
+
*/
|
|
121
|
+
const trimSections = (head: string, maxChars: number): string => {
|
|
122
|
+
if (head.length <= maxChars) return head
|
|
123
|
+
const lines = head.split("\n")
|
|
124
|
+
const kept: string[] = []
|
|
125
|
+
let size = 0
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
const isHeader = line.startsWith("[")
|
|
128
|
+
const cost = line.length + 1
|
|
129
|
+
if (!isHeader && size + cost > maxChars) continue
|
|
130
|
+
if (isHeader && size + cost > maxChars) break
|
|
131
|
+
kept.push(line)
|
|
132
|
+
size += cost
|
|
133
|
+
}
|
|
134
|
+
return kept.join("\n").trim()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export const buildSummary = (input: BuildSummaryInput): BuildSummaryResult => {
|
|
138
|
+
const { messages, resolveIndex, fileOps, briefTokens, maxSummaryTokens, extraNote } = input
|
|
139
|
+
if (!Array.isArray(messages) || messages.length === 0) return { rejected: "no messages" }
|
|
140
|
+
|
|
141
|
+
const adapted = adaptMessages(messages as any[], { resolveIndex })
|
|
142
|
+
// A compaction request can carry the previous checkpoint and nothing else:
|
|
143
|
+
// the window since the last compaction held no new text. That is not an
|
|
144
|
+
// empty conversation - the checkpoint *is* the conversation now. Upstream
|
|
145
|
+
// merges the checkpoint's sections in this case; declining it hands the
|
|
146
|
+
// compaction back to OpenCode's own summarizer, which is the one thing
|
|
147
|
+
// this plugin exists to prevent.
|
|
148
|
+
const previous = adapted.previousSummary?.trim()
|
|
149
|
+
if (adapted.messages.length === 0 && !previous) return { rejected: "no conversation content" }
|
|
150
|
+
|
|
151
|
+
// Chars-per-token: heuristic unless the caller can calibrate. OpenCode does
|
|
152
|
+
// not expose the last request's token usage to plugins, so we use the
|
|
153
|
+
// conservative default and let the token cap do the real work.
|
|
154
|
+
const charsPerToken = DEFAULT_CHARS_PER_TOKEN
|
|
155
|
+
|
|
156
|
+
// A request that carries only the previous checkpoint has nothing new to
|
|
157
|
+
// summarise. Rebuilding it from its own sections was both wasteful and the
|
|
158
|
+
// source of the stacked handoff blocks: the rebuilt checkpoint looked like
|
|
159
|
+
// "fresh" content, so the hook appended another block every time.
|
|
160
|
+
if (adapted.messages.length === 0 && previous) {
|
|
161
|
+
const carried = rewriteRecallNote(previous)
|
|
162
|
+
return {
|
|
163
|
+
summary: carried,
|
|
164
|
+
tokens: estimateTokensFromChars(carried.length, charsPerToken),
|
|
165
|
+
previousSummary: adapted.previousSummary,
|
|
166
|
+
carried: true,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
// 0 (or less) means no brief at all: do not pay for a floor the caller
|
|
170
|
+
// explicitly turned off.
|
|
171
|
+
const briefChars = briefTokens <= 0 ? 0 : Math.max(400, Math.round(briefTokens * charsPerToken))
|
|
172
|
+
|
|
173
|
+
let summary = compileRanked({
|
|
174
|
+
messages: adapted.messages,
|
|
175
|
+
sourceIndices: adapted.sourceIndices,
|
|
176
|
+
// The handoff block is not a section: strip it before merging so a
|
|
177
|
+
// section that has no fresh counterpart cannot carry the whole block
|
|
178
|
+
// forward verbatim. The raw checkpoint still goes to the handoff prompt.
|
|
179
|
+
previousSummary: previous ? stripHandoff(previous) : undefined,
|
|
180
|
+
fileOps,
|
|
181
|
+
ranking: {
|
|
182
|
+
maxBriefChars: briefChars,
|
|
183
|
+
maxBriefCharsCeiling: Math.round(briefChars * 1.5),
|
|
184
|
+
briefCharsPerBlock: Math.round(15 * charsPerToken),
|
|
185
|
+
maxBlocks: 64,
|
|
186
|
+
// Upstream keeps the newest 16 blocks whatever their score
|
|
187
|
+
// (rank.ts: `options.preserveRecentBlocks ?? DEFAULT_RECENT_BLOCKS`).
|
|
188
|
+
// A smaller value lets older, higher-scoring blocks - bug reports and
|
|
189
|
+
// diffs - crowd out the turns the brief exists to preserve.
|
|
190
|
+
preserveRecentBlocks: 16,
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
let { head, brief, note } = splitSummary(summary)
|
|
195
|
+
if (briefTokens <= 0) brief = ""
|
|
196
|
+
|
|
197
|
+
// Enforce the hard cap: the brief transcript is trimmed first, then the
|
|
198
|
+
// section bullets, and only then is the summary rejected outright.
|
|
199
|
+
const maxChars = Math.max(600, Math.round(maxSummaryTokens * charsPerToken))
|
|
200
|
+
if (brief.length > 0) {
|
|
201
|
+
const room = maxChars - head.length
|
|
202
|
+
if (room <= 200) {
|
|
203
|
+
brief = ""
|
|
204
|
+
} else {
|
|
205
|
+
brief = trimToChars(brief, room)
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const hasSections = SECTION_MARKERS.some((marker) => head.includes(marker))
|
|
210
|
+
if (!hasSections && brief.length === 0) {
|
|
211
|
+
// Nothing new to extract. The previous checkpoint still summarises the
|
|
212
|
+
// conversation, so it is carried forward rather than paying a model to
|
|
213
|
+
// rewrite it.
|
|
214
|
+
if (previous) {
|
|
215
|
+
const carried = rewriteRecallNote(previous)
|
|
216
|
+
return {
|
|
217
|
+
summary: carried,
|
|
218
|
+
tokens: estimateTokensFromChars(carried.length, charsPerToken),
|
|
219
|
+
previousSummary: adapted.previousSummary,
|
|
220
|
+
carried: true,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { rejected: "extraction produced no usable content", previousSummary: adapted.previousSummary }
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const assemble = (parts: string[]) => parts.filter((part) => part.length > 0).join(SEPARATOR)
|
|
227
|
+
const withNote = (h: string, b: string) => rewriteRecallNote(assemble([h, b, note]))
|
|
228
|
+
|
|
229
|
+
summary = withNote(head, brief)
|
|
230
|
+
if (extraNote && extraNote.trim()) {
|
|
231
|
+
summary = `${summary}\n\n${extraNote.trim()}`
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let tokens = estimateTokensFromChars(summary.length, charsPerToken)
|
|
235
|
+
if (tokens > maxSummaryTokens) {
|
|
236
|
+
// Drop the recall note first, then the brief, then trim the section
|
|
237
|
+
// bullets — the summary is only rejected when nothing usable is left.
|
|
238
|
+
summary = rewriteRecallNote(assemble([head, brief]))
|
|
239
|
+
tokens = estimateTokensFromChars(summary.length, charsPerToken)
|
|
240
|
+
if (tokens > maxSummaryTokens && brief.length > 0) {
|
|
241
|
+
summary = rewriteRecallNote(head)
|
|
242
|
+
tokens = estimateTokensFromChars(summary.length, charsPerToken)
|
|
243
|
+
}
|
|
244
|
+
if (tokens > maxSummaryTokens) {
|
|
245
|
+
const headerOnly = trimSections(head, maxChars)
|
|
246
|
+
const headerTokens = estimateTokensFromChars(headerOnly.length, charsPerToken)
|
|
247
|
+
if (headerTokens > maxSummaryTokens) {
|
|
248
|
+
return { rejected: "summary exceeded the token cap", previousSummary: adapted.previousSummary }
|
|
249
|
+
}
|
|
250
|
+
summary = headerOnly
|
|
251
|
+
}
|
|
252
|
+
if (extraNote && extraNote.trim()) {
|
|
253
|
+
summary = `${summary}\n\n${extraNote.trim()}`
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
return {
|
|
258
|
+
summary,
|
|
259
|
+
tokens: estimateTokensFromChars(summary.length, charsPerToken),
|
|
260
|
+
previousSummary: adapted.previousSummary,
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export { calibrateCharsPerToken }
|