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.
Files changed (50) hide show
  1. package/LICENSE +35 -0
  2. package/LICENSE.vendor +21 -0
  3. package/README.md +377 -2
  4. package/dist/server.js +5025 -0
  5. package/index.ts +1 -0
  6. package/package.json +57 -4
  7. package/src/adapter/opencode-messages.ts +270 -0
  8. package/src/compaction/build.ts +264 -0
  9. package/src/compaction/handoff.ts +421 -0
  10. package/src/config.ts +316 -0
  11. package/src/index.ts +714 -0
  12. package/src/memory/ledger.ts +254 -0
  13. package/src/memory/manager.ts +659 -0
  14. package/src/memory/models.ts +172 -0
  15. package/src/memory/prompts.ts +145 -0
  16. package/src/recall/recall.ts +327 -0
  17. package/src/state/paths.ts +42 -0
  18. package/src/store/capture.ts +84 -0
  19. package/src/store/transcript.ts +279 -0
  20. package/src/vcc/README.md +51 -0
  21. package/src/vcc/core/brief.ts +407 -0
  22. package/src/vcc/core/build-sections.ts +81 -0
  23. package/src/vcc/core/compact-args.ts +45 -0
  24. package/src/vcc/core/content.ts +156 -0
  25. package/src/vcc/core/drill-down.ts +302 -0
  26. package/src/vcc/core/filter-noise.ts +41 -0
  27. package/src/vcc/core/format-recall.ts +100 -0
  28. package/src/vcc/core/format.ts +104 -0
  29. package/src/vcc/core/lineage.ts +26 -0
  30. package/src/vcc/core/normalize.ts +76 -0
  31. package/src/vcc/core/rank.ts +283 -0
  32. package/src/vcc/core/recall-request.ts +160 -0
  33. package/src/vcc/core/recall-scope.ts +30 -0
  34. package/src/vcc/core/render-entries.ts +55 -0
  35. package/src/vcc/core/sanitize.ts +5 -0
  36. package/src/vcc/core/search-entries.ts +594 -0
  37. package/src/vcc/core/skill-collapse.ts +35 -0
  38. package/src/vcc/core/summarize.ts +289 -0
  39. package/src/vcc/core/token-estimate.ts +100 -0
  40. package/src/vcc/core/tool-args.ts +16 -0
  41. package/src/vcc/details.ts +11 -0
  42. package/src/vcc/extract/commits.ts +96 -0
  43. package/src/vcc/extract/files.ts +87 -0
  44. package/src/vcc/extract/goals.ts +79 -0
  45. package/src/vcc/extract/preferences.ts +55 -0
  46. package/src/vcc/extract/tracked-commands.ts +232 -0
  47. package/src/vcc/load-messages.ts +42 -0
  48. package/src/vcc/pi-types.ts +53 -0
  49. package/src/vcc/sections.ts +12 -0
  50. 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.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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 }