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
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The handoff block: the narrative half of a checkpoint.
|
|
3
|
+
*
|
|
4
|
+
* vcc's sections are an inventory - goals, files, commits. OpenCode's own
|
|
5
|
+
* summarizer is asked for something else: decisions and why, what is done,
|
|
6
|
+
* what is blocked, what happens next. That is the half a resumed session
|
|
7
|
+
* misses, and no deterministic extractor can invent it.
|
|
8
|
+
*
|
|
9
|
+
* So compaction spends one model call on exactly that block. The
|
|
10
|
+
* deterministic sections are still built and still win when the call fails:
|
|
11
|
+
* the block is additive, never a dependency. When the model is unreachable,
|
|
12
|
+
* `fallbackHandoff` derives the little that can be derived without one - the
|
|
13
|
+
* work state, and the question the session ended on.
|
|
14
|
+
*/
|
|
15
|
+
import type { ModelRef } from "../config"
|
|
16
|
+
|
|
17
|
+
export const HANDOFF_HEADINGS = ["Decisions", "Work State", "Next Move", "Important Context"] as const
|
|
18
|
+
export type HandoffHeading = (typeof HANDOFF_HEADINGS)[number]
|
|
19
|
+
|
|
20
|
+
/** The separator vcc uses between sections, the brief and the recall note. */
|
|
21
|
+
const SEPARATOR = "\n\n---\n\n"
|
|
22
|
+
|
|
23
|
+
export interface HandoffPromptInput {
|
|
24
|
+
/** The checkpoint this window follows, when the request started with one. */
|
|
25
|
+
previousSummary?: string
|
|
26
|
+
/** The deterministic sections already extracted from this window. */
|
|
27
|
+
sections: string
|
|
28
|
+
/** The rendered conversation. */
|
|
29
|
+
window: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The prompt.
|
|
34
|
+
*
|
|
35
|
+
* Deliberately close to OpenCode's own summary template: the point is to
|
|
36
|
+
* recover the sections its summarizer produces, not to invent a new shape.
|
|
37
|
+
* The "never invent" and "carry forward" rules matter more than the wording:
|
|
38
|
+
* a handoff that states a decision the session never made is worse than none.
|
|
39
|
+
*/
|
|
40
|
+
export const HANDOFF_PROMPT = `You are writing the part of a session checkpoint that a deterministic extractor cannot write.
|
|
41
|
+
|
|
42
|
+
The checkpoint replaces the conversation. A future assistant continues from it and cannot see the transcript, so anything you leave out is lost. Write the working knowledge: what was decided and why, where the work stands, what happens next, and the facts that are expensive to rediscover.
|
|
43
|
+
|
|
44
|
+
Reply with these sections and nothing else:
|
|
45
|
+
|
|
46
|
+
[Decisions]
|
|
47
|
+
- <what was decided> — <why, in a few words>
|
|
48
|
+
|
|
49
|
+
[Work State]
|
|
50
|
+
- Done: <finished work>
|
|
51
|
+
- Active: <work in progress>
|
|
52
|
+
- Blocked: <what is blocking, and why>
|
|
53
|
+
|
|
54
|
+
[Next Move]
|
|
55
|
+
1. <the next concrete action>
|
|
56
|
+
|
|
57
|
+
[Important Context]
|
|
58
|
+
- <a fact the next assistant cannot continue without>
|
|
59
|
+
|
|
60
|
+
Rules:
|
|
61
|
+
- Terse, single-line bullets. No prose paragraphs, no nested lists.
|
|
62
|
+
- Only what the conversation supports. Never invent a decision, a reason or a result.
|
|
63
|
+
- Keep exact file paths, symbols, commands, error strings, URLs and identifiers.
|
|
64
|
+
- Carry forward anything unresolved from the previous checkpoint, and keep the user's exact wording for user constraints.
|
|
65
|
+
- If a section has nothing, write "(none)".
|
|
66
|
+
- At most 6 bullets per section.`
|
|
67
|
+
|
|
68
|
+
interface Part {
|
|
69
|
+
type?: string
|
|
70
|
+
text?: string
|
|
71
|
+
id?: string
|
|
72
|
+
name?: string
|
|
73
|
+
input?: unknown
|
|
74
|
+
output?: unknown
|
|
75
|
+
state?: { input?: unknown; output?: unknown; content?: unknown; status?: string }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface Message {
|
|
79
|
+
role?: string
|
|
80
|
+
content?: unknown
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const partsOf = (message: Message): Part[] => {
|
|
84
|
+
if (typeof message.content === "string") return [{ type: "text", text: message.content }]
|
|
85
|
+
return Array.isArray(message.content) ? (message.content as Part[]) : []
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const clip = (text: string, max: number): string => {
|
|
89
|
+
const flat = text.replace(/\s+/g, " ").trim()
|
|
90
|
+
return flat.length <= max ? flat : `${flat.slice(0, max)}…`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const outputText = (part: Part): string => {
|
|
94
|
+
const output = part.output ?? part.state?.output ?? part.state?.content
|
|
95
|
+
if (typeof output === "string") return output
|
|
96
|
+
if (output && typeof output === "object") {
|
|
97
|
+
const content = (output as { content?: unknown }).content
|
|
98
|
+
if (typeof content === "string") return content
|
|
99
|
+
if (Array.isArray(content)) {
|
|
100
|
+
return content
|
|
101
|
+
.map((item) =>
|
|
102
|
+
typeof item === "string" ? item : typeof (item as { text?: unknown })?.text === "string" ? (item as { text: string }).text : "",
|
|
103
|
+
)
|
|
104
|
+
.filter(Boolean)
|
|
105
|
+
.join("\n")
|
|
106
|
+
}
|
|
107
|
+
try {
|
|
108
|
+
return JSON.stringify(output)
|
|
109
|
+
} catch {
|
|
110
|
+
return ""
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return ""
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const inputText = (part: Part): string => {
|
|
117
|
+
const input = part.input ?? part.state?.input
|
|
118
|
+
if (typeof input === "string") return clip(input, 200)
|
|
119
|
+
if (input && typeof input === "object") {
|
|
120
|
+
const command = (input as { command?: unknown; cmd?: unknown }).command ?? (input as { cmd?: unknown }).cmd
|
|
121
|
+
if (typeof command === "string") return clip(command, 200)
|
|
122
|
+
try {
|
|
123
|
+
return clip(JSON.stringify(input), 200)
|
|
124
|
+
} catch {
|
|
125
|
+
return ""
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return ""
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Render a conversation for the handoff prompt.
|
|
133
|
+
*
|
|
134
|
+
* Text only, with tool traffic clipped: the model needs to see what was
|
|
135
|
+
* decided, not the contents of every file it read. When the window does not
|
|
136
|
+
* fit, the middle is dropped rather than the end - the opening states the
|
|
137
|
+
* task, the tail states where it stands.
|
|
138
|
+
*/
|
|
139
|
+
export const renderWindow = (messages: readonly unknown[], maxChars: number): string => {
|
|
140
|
+
const lines: string[] = []
|
|
141
|
+
for (const raw of messages) {
|
|
142
|
+
const message = raw as Message
|
|
143
|
+
if (!message || typeof message !== "object") continue
|
|
144
|
+
const role = message.role ?? "user"
|
|
145
|
+
const rendered: string[] = []
|
|
146
|
+
for (const part of partsOf(message)) {
|
|
147
|
+
const type = part?.type ?? ""
|
|
148
|
+
if (type === "text" && typeof part.text === "string") {
|
|
149
|
+
const text = part.text.trim()
|
|
150
|
+
if (text.length > 0) rendered.push(role === "user" ? `[User]: ${clip(text, 1_200)}` : `[Assistant]: ${clip(text, 800)}`)
|
|
151
|
+
continue
|
|
152
|
+
}
|
|
153
|
+
if (type === "reasoning") continue
|
|
154
|
+
if (type === "tool-call" || type === "toolCall") {
|
|
155
|
+
rendered.push(`[Tool call]: ${part.name ?? "tool"}(${inputText(part)})`)
|
|
156
|
+
continue
|
|
157
|
+
}
|
|
158
|
+
if (type === "tool-result" || type === "toolResult") {
|
|
159
|
+
const text = outputText(part)
|
|
160
|
+
if (text.length > 0) rendered.push(`[Tool result]: ${clip(text, 300)}`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (rendered.length > 0) lines.push(rendered.join("\n"))
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const text = lines.join("\n")
|
|
167
|
+
if (text.length <= maxChars) return text
|
|
168
|
+
const head = Math.floor(maxChars * 0.35)
|
|
169
|
+
const tail = Math.max(0, maxChars - head)
|
|
170
|
+
return `${text.slice(0, head)}\n[... middle of the conversation omitted ...]\n${text.slice(text.length - tail)}`
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export const buildHandoffPrompt = (input: HandoffPromptInput): string => {
|
|
174
|
+
const parts = [HANDOFF_PROMPT]
|
|
175
|
+
if (input.previousSummary && input.previousSummary.trim().length > 0) {
|
|
176
|
+
parts.push(`PREVIOUS CHECKPOINT\n${input.previousSummary.trim()}`)
|
|
177
|
+
}
|
|
178
|
+
if (input.sections.trim().length > 0) {
|
|
179
|
+
parts.push(`SECTIONS ALREADY EXTRACTED (reuse their facts; do not restate the file list)\n${input.sections.trim()}`)
|
|
180
|
+
}
|
|
181
|
+
parts.push(`CONVERSATION\n${input.window}`)
|
|
182
|
+
return parts.join("\n\n")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Keep only the four sections, drop the ones the model left empty, and trim
|
|
187
|
+
* to the budget. Returns undefined when nothing usable came back, so the
|
|
188
|
+
* caller can fall back instead of writing an empty block.
|
|
189
|
+
*/
|
|
190
|
+
export const cleanHandoff = (text: string, maxChars: number): string | undefined => {
|
|
191
|
+
const body = text.replace(/```[a-z]*\n?/gi, "").replace(/```/g, "")
|
|
192
|
+
const lines = body.split("\n")
|
|
193
|
+
|
|
194
|
+
const sections = new Map<HandoffHeading, string[]>()
|
|
195
|
+
let current: HandoffHeading | undefined
|
|
196
|
+
for (const line of lines) {
|
|
197
|
+
const header = /^\s*\[([^\]]+)\]/.exec(line)
|
|
198
|
+
if (header) {
|
|
199
|
+
const name = HANDOFF_HEADINGS.find((heading) => heading.toLowerCase() === header[1].trim().toLowerCase())
|
|
200
|
+
if (name) {
|
|
201
|
+
current = name
|
|
202
|
+
sections.set(name, [])
|
|
203
|
+
} else if (current) {
|
|
204
|
+
// Some other heading ends the block; do not swallow the rest.
|
|
205
|
+
current = undefined
|
|
206
|
+
}
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
if (!current) continue
|
|
210
|
+
const trimmed = line.trim()
|
|
211
|
+
if (trimmed.length === 0) continue
|
|
212
|
+
if (/^(?:[-*]\s*)?\(none\)$/i.test(trimmed)) continue
|
|
213
|
+
sections.get(current)!.push(trimmed)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const kept = HANDOFF_HEADINGS.filter((heading) => (sections.get(heading)?.length ?? 0) > 0)
|
|
217
|
+
if (kept.length === 0) return undefined
|
|
218
|
+
|
|
219
|
+
const render = (headings: readonly HandoffHeading[]) =>
|
|
220
|
+
headings.map((heading) => `[${heading}]\n${(sections.get(heading) ?? []).join("\n")}`).join("\n\n")
|
|
221
|
+
|
|
222
|
+
let selected = [...kept]
|
|
223
|
+
let block = render(selected)
|
|
224
|
+
// Trim from the last section backwards, one bullet at a time.
|
|
225
|
+
while (block.length > maxChars && selected.length > 0) {
|
|
226
|
+
const last = selected[selected.length - 1]
|
|
227
|
+
const bullets = sections.get(last)!
|
|
228
|
+
bullets.pop()
|
|
229
|
+
if (bullets.length === 0) selected = selected.slice(0, -1)
|
|
230
|
+
block = render(selected)
|
|
231
|
+
}
|
|
232
|
+
return block.length > 0 ? block : undefined
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** The four headings the handoff block owns. */
|
|
236
|
+
const HANDOFF_START = /(?:^|\n)\[(?:Decisions|Work State|Next Move|Important Context)\][ \t]*(?=\n|$)/
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Remove a handoff block from a checkpoint.
|
|
240
|
+
*
|
|
241
|
+
* The block is not one of the sections the vcc merge knows, and `insertBlock`
|
|
242
|
+
* puts it before the `---`. Without this it is swallowed by whichever section
|
|
243
|
+
* comes last (usually `[User Preferences]`), and the next merge carries that
|
|
244
|
+
* section forward verbatim while a fresh block is appended - one block per
|
|
245
|
+
* compaction, each repeating the one before it.
|
|
246
|
+
*/
|
|
247
|
+
export const stripHandoff = (summary: string): string => {
|
|
248
|
+
const index = summary.indexOf(SEPARATOR)
|
|
249
|
+
const head = index < 0 ? summary : summary.slice(0, index)
|
|
250
|
+
const rest = index < 0 ? "" : summary.slice(index)
|
|
251
|
+
const match = HANDOFF_START.exec(head)
|
|
252
|
+
if (!match) return summary
|
|
253
|
+
return `${head.slice(0, match.index).trimEnd()}${rest}`
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Put the block at the end of the deterministic sections, before the brief
|
|
258
|
+
* and the recall note, so a reader meets the inventory first and the
|
|
259
|
+
* narrative second.
|
|
260
|
+
*
|
|
261
|
+
* A block left by an earlier checkpoint is replaced, never stacked: the new
|
|
262
|
+
* block is the whole narrative, and the model has the old one as input.
|
|
263
|
+
*/
|
|
264
|
+
export const insertBlock = (summary: string, block: string): string => {
|
|
265
|
+
if (!block.trim()) return summary
|
|
266
|
+
const base = stripHandoff(summary)
|
|
267
|
+
const index = base.indexOf(SEPARATOR)
|
|
268
|
+
if (index < 0) return `${base}\n\n${block}`
|
|
269
|
+
return `${base.slice(0, index)}\n\n${block}${base.slice(index)}`
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Resolve with the promise, or with undefined once the deadline passes.
|
|
274
|
+
*
|
|
275
|
+
* Compaction blocks the session, so a model that never answers must not hold
|
|
276
|
+
* it hostage: the deterministic summary is always a valid checkpoint.
|
|
277
|
+
*/
|
|
278
|
+
export const withTimeout = async <T>(promise: Promise<T>, milliseconds: number): Promise<T | undefined> => {
|
|
279
|
+
if (!Number.isFinite(milliseconds) || milliseconds <= 0) return promise
|
|
280
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
281
|
+
try {
|
|
282
|
+
return await Promise.race([
|
|
283
|
+
promise,
|
|
284
|
+
new Promise<undefined>((resolve) => {
|
|
285
|
+
timer = setTimeout(() => resolve(undefined), milliseconds)
|
|
286
|
+
}),
|
|
287
|
+
])
|
|
288
|
+
} finally {
|
|
289
|
+
if (timer) clearTimeout(timer)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ------------------------------------------------------------------ fallback
|
|
294
|
+
|
|
295
|
+
export interface FallbackInput {
|
|
296
|
+
/** The deterministic summary, used for its commits and file list. */
|
|
297
|
+
summary: string
|
|
298
|
+
/** The conversation, used for the last command and the closing question. */
|
|
299
|
+
messages: readonly unknown[]
|
|
300
|
+
/** The previous checkpoint. Its decisions are carried, never invented. */
|
|
301
|
+
previousSummary?: string
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const FALLBACK_SECTION_MAX = 900
|
|
305
|
+
|
|
306
|
+
const clampSection = (text: string, max: number): string => {
|
|
307
|
+
if (text.length <= max) return text
|
|
308
|
+
const cut = text.lastIndexOf("\n", max)
|
|
309
|
+
return (cut > 0 ? text.slice(0, cut) : text.slice(0, max)).trimEnd()
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const sectionBody = (summary: string, name: string): string => {
|
|
313
|
+
const start = summary.indexOf(`[${name}]`)
|
|
314
|
+
if (start < 0) return ""
|
|
315
|
+
const rest = summary.slice(start + name.length + 2)
|
|
316
|
+
const next = rest.search(/\n\[[A-Z]/)
|
|
317
|
+
return (next < 0 ? rest : rest.slice(0, next)).trim()
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const COMMAND_TOOLS = new Set(["bash", "shell", "exec", "exec_command", "run_command"])
|
|
321
|
+
|
|
322
|
+
const lastCommand = (messages: readonly unknown[]): { command: string; exit?: number } | undefined => {
|
|
323
|
+
let found: { command: string; exit?: number } | undefined
|
|
324
|
+
for (let i = 0; i < messages.length; i++) {
|
|
325
|
+
const message = messages[i] as Message
|
|
326
|
+
for (const part of partsOf(message)) {
|
|
327
|
+
if (part?.type !== "tool-call" && part?.type !== "toolCall") continue
|
|
328
|
+
if (!COMMAND_TOOLS.has(String(part.name ?? ""))) continue
|
|
329
|
+
const input = part.input ?? part.state?.input
|
|
330
|
+
const command =
|
|
331
|
+
typeof input === "string"
|
|
332
|
+
? input
|
|
333
|
+
: typeof (input as { command?: unknown })?.command === "string"
|
|
334
|
+
? String((input as { command: string }).command)
|
|
335
|
+
: typeof (input as { cmd?: unknown })?.cmd === "string"
|
|
336
|
+
? String((input as { cmd: string }).cmd)
|
|
337
|
+
: ""
|
|
338
|
+
if (!command) continue
|
|
339
|
+
found = { command: clip(command, 120) }
|
|
340
|
+
// The matching result usually follows in the same or the next message.
|
|
341
|
+
for (let j = i + 1; j < messages.length && j <= i + 3; j++) {
|
|
342
|
+
for (const result of partsOf(messages[j] as Message)) {
|
|
343
|
+
const output = (result.output ?? result.state?.output) as { metadata?: { exit?: number } } | undefined
|
|
344
|
+
const exit = output?.metadata?.exit
|
|
345
|
+
if (typeof exit === "number") found.exit = exit
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return found
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const closingAsk = (messages: readonly unknown[]): string | undefined => {
|
|
354
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
355
|
+
const message = messages[i] as Message
|
|
356
|
+
if (message?.role !== "assistant") continue
|
|
357
|
+
const texts = partsOf(message)
|
|
358
|
+
.filter((part) => part?.type === "text" && typeof part.text === "string")
|
|
359
|
+
.map((part) => part.text!.trim())
|
|
360
|
+
.filter(Boolean)
|
|
361
|
+
if (texts.length === 0) continue
|
|
362
|
+
const last = texts[texts.length - 1].split("\n").filter(Boolean).pop() ?? ""
|
|
363
|
+
if (last.length < 8 || last.length > 300) continue
|
|
364
|
+
if (/[?]$/.test(last) || /(want me to|should i|shall i|let me know|next step|say the word)/i.test(last)) {
|
|
365
|
+
return clip(last, 240)
|
|
366
|
+
}
|
|
367
|
+
return undefined
|
|
368
|
+
}
|
|
369
|
+
return undefined
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* What can be said about the handoff without a model.
|
|
374
|
+
*
|
|
375
|
+
* Only two things are honest here: the work state, which is already recorded
|
|
376
|
+
* (commits, modified files, the last command), and the question the session
|
|
377
|
+
* ended on. There is no deterministic way to recover a decision or its
|
|
378
|
+
* reason, so this block never claims one.
|
|
379
|
+
*/
|
|
380
|
+
export const fallbackHandoff = (input: FallbackInput): string | undefined => {
|
|
381
|
+
const parts: string[] = []
|
|
382
|
+
|
|
383
|
+
// A decision that was already recorded is not invented by repeating it.
|
|
384
|
+
// Without this a failed model call would drop the whole narrative, because
|
|
385
|
+
// the merge no longer keeps the previous block in the sections.
|
|
386
|
+
const prevHead = (input.previousSummary ?? "").split(SEPARATOR)[0]
|
|
387
|
+
const decisions = clampSection(sectionBody(prevHead, "Decisions"), FALLBACK_SECTION_MAX)
|
|
388
|
+
if (decisions) parts.push(`[Decisions]\n${decisions}`)
|
|
389
|
+
|
|
390
|
+
const lines: string[] = []
|
|
391
|
+
|
|
392
|
+
const files = sectionBody(input.summary, "Files And Changes")
|
|
393
|
+
const modified = files.split("\n").find((line) => /^-?\s*Modified:/.test(line))
|
|
394
|
+
if (modified) {
|
|
395
|
+
const count = (modified.match(/,\s/g)?.length ?? 0) + 1
|
|
396
|
+
const more = /\+(\d+) more/.exec(modified)
|
|
397
|
+
const total = count + (more ? Number(more[1]) : 0)
|
|
398
|
+
lines.push(`- Modified: ${total} file${total === 1 ? "" : "s"}`)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const commits = sectionBody(input.summary, "Commits")
|
|
402
|
+
.split("\n")
|
|
403
|
+
.filter((line) => line.trim().startsWith("-"))
|
|
404
|
+
.slice(-2)
|
|
405
|
+
if (commits.length > 0) lines.push(`- Commits: ${commits.map((line) => line.replace(/^\s*-\s*/, "").trim()).join("; ")}`)
|
|
406
|
+
|
|
407
|
+
const command = lastCommand(input.messages)
|
|
408
|
+
if (command) {
|
|
409
|
+
lines.push(`- Last command: \`${command.command}\`${typeof command.exit === "number" ? ` (exit ${command.exit})` : ""}`)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if (lines.length > 0) parts.push(`[Work State]\n${lines.join("\n")}`)
|
|
413
|
+
|
|
414
|
+
const ask = closingAsk(input.messages)
|
|
415
|
+
if (ask) parts.push(`[Next Move]\n- ${ask}`)
|
|
416
|
+
|
|
417
|
+
const important = clampSection(sectionBody(prevHead, "Important Context"), FALLBACK_SECTION_MAX)
|
|
418
|
+
if (important) parts.push(`[Important Context]\n${important}`)
|
|
419
|
+
|
|
420
|
+
return parts.length > 0 ? parts.join("\n\n") : undefined
|
|
421
|
+
}
|