@tduyng/pi-context 0.0.0-stage → 0.0.2
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 +178 -0
- package/README.md +120 -2
- package/package.json +48 -4
- package/src/cache-state.ts +101 -0
- package/src/cached-compaction.ts +174 -0
- package/src/cached-request.ts +135 -0
- package/src/collapse.ts +245 -0
- package/src/compaction-handoff.ts +147 -0
- package/src/context-dedupe.ts +16 -0
- package/src/context.ts +313 -0
- package/src/history.ts +241 -0
- package/src/summary.ts +55 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { Validator } from 'ata-validator'
|
|
2
|
+
import { t } from 'ata-validator/t'
|
|
3
|
+
|
|
4
|
+
import { convertToLlm, sessionEntryToContextMessages, type SessionEntry } from '@earendil-works/pi-coding-agent'
|
|
5
|
+
import type { AssistantMessage } from '@earendil-works/pi-ai'
|
|
6
|
+
import type { Api, Model } from '@earendil-works/pi-ai'
|
|
7
|
+
const isString = new Validator(t.string()).isValidObject
|
|
8
|
+
|
|
9
|
+
export type BranchMessage = ReturnType<typeof sessionEntryToContextMessages>[number]
|
|
10
|
+
export type TranscriptMessage = Parameters<typeof convertToLlm>[0][number]
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The transcript of the last request, including the leading system message that carries
|
|
14
|
+
* the prompt and the tool declarations. Replaying it is what makes the summary a cached
|
|
15
|
+
* continuation rather than a new request.
|
|
16
|
+
*/
|
|
17
|
+
export interface CapturedRequest {
|
|
18
|
+
transcript: TranscriptMessage[]
|
|
19
|
+
model: Model<Api>
|
|
20
|
+
at: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CaptureStore {
|
|
24
|
+
captured: CapturedRequest | null
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function forget(store: CaptureStore): void {
|
|
28
|
+
store.captured = null
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TextBlockSchema = t.object({ type: t.literal('text'), text: t.string() })
|
|
32
|
+
const isTextBlockSchema = new Validator(TextBlockSchema).isValidObject
|
|
33
|
+
|
|
34
|
+
export function messageText(message: BranchMessage): string {
|
|
35
|
+
if (!('content' in message)) return ''
|
|
36
|
+
const content = message.content
|
|
37
|
+
if (isString(content)) return content
|
|
38
|
+
if (Array.isArray(content)) {
|
|
39
|
+
return content.flatMap((block) => (isTextBlockSchema(block) ? [block.text] : [])).join(' ')
|
|
40
|
+
}
|
|
41
|
+
return ''
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function assistantText(message: AssistantMessage): string {
|
|
45
|
+
return message.content.flatMap((block) => (isTextBlockSchema(block) ? [block.text] : [])).join('')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function sameMessage(a: BranchMessage, b: BranchMessage): boolean {
|
|
49
|
+
if (a.role !== b.role) return false
|
|
50
|
+
const aTool = 'toolCallId' in a ? a.toolCallId : undefined
|
|
51
|
+
const bTool = 'toolCallId' in b ? b.toolCallId : undefined
|
|
52
|
+
if (aTool !== bTool) return false
|
|
53
|
+
const aTime = 'timestamp' in a ? a.timestamp : undefined
|
|
54
|
+
const bTime = 'timestamp' in b ? b.timestamp : undefined
|
|
55
|
+
return aTime === bTime
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* What the branch gained since the captured request, and proof that the kept region never
|
|
60
|
+
* moved: pi's own entry projection must contain the captured transcript as a prefix. The
|
|
61
|
+
* proof would rest on a guess when an entry of an unknown shape cannot be matched, so null
|
|
62
|
+
* is returned instead.
|
|
63
|
+
*/
|
|
64
|
+
export function walkTrailing(captured: CapturedRequest, branchEntries: SessionEntry[]): BranchMessage[] | null {
|
|
65
|
+
const projected: BranchMessage[] = []
|
|
66
|
+
for (const entry of branchEntries) {
|
|
67
|
+
if (entry.type === 'compaction' || entry.type === 'session_info') continue
|
|
68
|
+
projected.push(...sessionEntryToContextMessages(entry))
|
|
69
|
+
}
|
|
70
|
+
// The capture starts with pi's leading system message; the branch projection holds the
|
|
71
|
+
// conversation only, so the prefix is compared on the messages both carry.
|
|
72
|
+
const conversation = convertToLlm(captured.transcript.filter((message) => message.role !== 'system'))
|
|
73
|
+
if (projected.length < conversation.length) return null
|
|
74
|
+
for (let index = 0; index < conversation.length; index++) {
|
|
75
|
+
const expected = conversation[index]
|
|
76
|
+
const actual = projected[index]
|
|
77
|
+
if (expected === undefined || actual === undefined || !sameMessage(expected, actual)) return null
|
|
78
|
+
}
|
|
79
|
+
return projected.slice(conversation.length)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface KeptStart {
|
|
83
|
+
text: string
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const QUOTE_CHARS = 120
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The opening of the first message pi keeps verbatim, quoted back to the summarizer. The
|
|
90
|
+
* quote marks where the kept window begins.
|
|
91
|
+
*/
|
|
92
|
+
export function keptStartQuote(branchEntries: SessionEntry[], firstKeptEntryId: string): KeptStart | null {
|
|
93
|
+
const start = branchEntries.findIndex((entry) => entry.id === firstKeptEntryId)
|
|
94
|
+
if (start === -1) return null
|
|
95
|
+
const kept = branchEntries.slice(start)
|
|
96
|
+
const quotes = kept
|
|
97
|
+
.map((entry) => sessionEntryToContextMessages(entry).map(messageText).join(' ').trim())
|
|
98
|
+
.filter((text) => text !== '')
|
|
99
|
+
const first = quotes[0]
|
|
100
|
+
if (first === undefined) return null
|
|
101
|
+
return { text: first.slice(0, QUOTE_CHARS) }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The summary instruction, appended as the next user turn. It says stop. It says call no
|
|
106
|
+
* tools. It quotes the opening the summarizer must stop at, and it keeps pi's checkpoint
|
|
107
|
+
* shape so the file lists and the recent window survive the round trip.
|
|
108
|
+
*/
|
|
109
|
+
export function summaryInstruction(keptStart: KeptStart | null, customFocus: string | undefined): BranchMessage {
|
|
110
|
+
const boundary =
|
|
111
|
+
keptStart === null
|
|
112
|
+
? 'Summarize the conversation so far.'
|
|
113
|
+
: `Summarize the conversation only up to the message beginning "${keptStart.text}".\n` +
|
|
114
|
+
'Do not include anything after the boundary.'
|
|
115
|
+
const message: BranchMessage = {
|
|
116
|
+
role: 'user',
|
|
117
|
+
content:
|
|
118
|
+
`${boundary}\n\n` +
|
|
119
|
+
'Stop here. Call no tools.\n\n' +
|
|
120
|
+
'Write the summary in this exact format:\n' +
|
|
121
|
+
'Goal: what the session was trying to accomplish.\n' +
|
|
122
|
+
'Constraints: hard constraints that shaped the approach.\n' +
|
|
123
|
+
'Done: what has been completed, including files and tests.\n' +
|
|
124
|
+
'In Progress: what was being worked on when the summary was requested.\n' +
|
|
125
|
+
'Blocked: blockers or unresolved issues.\n' +
|
|
126
|
+
'Key Decisions: the important decisions and why.\n' +
|
|
127
|
+
'Next Steps: what should happen next.\n' +
|
|
128
|
+
'Critical Context: the critical context to carry forward.\n' +
|
|
129
|
+
'File Lists: read and modified file lists. If none, write "None".\n' +
|
|
130
|
+
(customFocus === undefined ? '' : `\n${customFocus}\n`) +
|
|
131
|
+
'\nWrite no other text before or after the format.',
|
|
132
|
+
timestamp: Date.now()
|
|
133
|
+
}
|
|
134
|
+
return message
|
|
135
|
+
}
|
package/src/collapse.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
estimateTokens,
|
|
3
|
+
type ExtensionAPI,
|
|
4
|
+
type SessionBeforeTreeEvent,
|
|
5
|
+
type SessionEntry
|
|
6
|
+
} from '@earendil-works/pi-coding-agent'
|
|
7
|
+
import type { ImageContent, TextContent, Usage } from '@earendil-works/pi-ai'
|
|
8
|
+
import { Validator } from 'ata-validator'
|
|
9
|
+
import { t } from 'ata-validator/t'
|
|
10
|
+
|
|
11
|
+
const OUTCOME_CHARS = 2_000
|
|
12
|
+
const FILE_LIMIT = 12
|
|
13
|
+
const FAILURE_LIMIT = 5
|
|
14
|
+
const COMMAND_CHARS = 120
|
|
15
|
+
const FAILURE_CHARS = 200
|
|
16
|
+
|
|
17
|
+
const VALIDATION_COMMAND =
|
|
18
|
+
/(^|\s)(just\s+\S+|tsc\b|vitest\b|jest\b|pytest\b|ruff\b|cargo\s+(?:build|check|test)\b|go\s+test\b|swift\s+(?:build|test)\b|nix\s+build\b|npm\s+(?:run\s+)?(?:test|check|lint|typecheck|build)\b|pnpm\s+(?:run\s+)?(?:test|check|lint|typecheck|build)\b|yarn\s+(?:test|lint|typecheck|build)\b|bun\s+(?:run\s+|x\s+)?(?:test|check|lint|typecheck|build|vitest)\b)/
|
|
19
|
+
|
|
20
|
+
const PathInput = t.object({ path: t.string() })
|
|
21
|
+
const isPathInput = new Validator(PathInput).isValidObject
|
|
22
|
+
const BashInput = t.object({ command: t.string() })
|
|
23
|
+
const isBashInput = new Validator(BashInput).isValidObject
|
|
24
|
+
|
|
25
|
+
export interface CollapseDetails {
|
|
26
|
+
entries: number
|
|
27
|
+
tokens: number
|
|
28
|
+
commands: number
|
|
29
|
+
modifiedFiles: string[]
|
|
30
|
+
readFiles: string[]
|
|
31
|
+
validationCommands: string[]
|
|
32
|
+
failures: string[]
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Collapsed {
|
|
36
|
+
outcome: string
|
|
37
|
+
usage: Usage
|
|
38
|
+
details: CollapseDetails
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function parseTurns(args: string): number | null {
|
|
42
|
+
const trimmed = args.trim()
|
|
43
|
+
if (trimmed === '') return 1
|
|
44
|
+
if (!/^\d+$/.test(trimmed)) return null
|
|
45
|
+
const turns = Number(trimmed)
|
|
46
|
+
return turns >= 1 && turns <= 100 ? turns : null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function collapseAnchor(
|
|
50
|
+
entries: readonly SessionEntry[],
|
|
51
|
+
turns: number
|
|
52
|
+
): { anchorId: string; startIndex: number } | undefined {
|
|
53
|
+
const starts: number[] = []
|
|
54
|
+
entries.forEach((entry, index) => {
|
|
55
|
+
if (entry.type === 'message' && entry.message.role === 'user') starts.push(index)
|
|
56
|
+
})
|
|
57
|
+
const first = starts[starts.length - turns]
|
|
58
|
+
if (first === undefined || first >= entries.length - 1) return undefined
|
|
59
|
+
const anchorId = entries[first - 1]?.id
|
|
60
|
+
if (anchorId === undefined) return undefined
|
|
61
|
+
return { anchorId, startIndex: first }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function addUsage(total: Usage, usage: Usage): Usage {
|
|
65
|
+
return {
|
|
66
|
+
input: total.input + usage.input,
|
|
67
|
+
output: total.output + usage.output,
|
|
68
|
+
cacheRead: total.cacheRead + usage.cacheRead,
|
|
69
|
+
cacheWrite: total.cacheWrite + usage.cacheWrite,
|
|
70
|
+
cacheWrite1h: (total.cacheWrite1h ?? 0) + (usage.cacheWrite1h ?? 0),
|
|
71
|
+
reasoning: (total.reasoning ?? 0) + (usage.reasoning ?? 0),
|
|
72
|
+
totalTokens: total.totalTokens + usage.totalTokens,
|
|
73
|
+
cost: {
|
|
74
|
+
input: total.cost.input + usage.cost.input,
|
|
75
|
+
output: total.cost.output + usage.cost.output,
|
|
76
|
+
cacheRead: total.cost.cacheRead + usage.cost.cacheRead,
|
|
77
|
+
cacheWrite: total.cost.cacheWrite + usage.cost.cacheWrite,
|
|
78
|
+
total: total.cost.total + usage.cost.total
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function oneLine(text: string, limit: number): string {
|
|
84
|
+
const flat = text.replace(/\s+/g, ' ').trim()
|
|
85
|
+
return flat.length <= limit ? flat : `${flat.slice(0, limit)}...`
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function tail(text: string, limit: number): string {
|
|
89
|
+
const trimmed = text.trim()
|
|
90
|
+
return trimmed.length <= limit ? trimmed : `...${trimmed.slice(trimmed.length - limit)}`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function resultText(content: readonly (TextContent | ImageContent)[]): string {
|
|
94
|
+
return content
|
|
95
|
+
.filter((block) => block.type === 'text')
|
|
96
|
+
.map((block) => block.text)
|
|
97
|
+
.join(' ')
|
|
98
|
+
.trim()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function collapseEntries(entries: readonly SessionEntry[]): Collapsed {
|
|
102
|
+
const modified = new Set<string>()
|
|
103
|
+
const read = new Set<string>()
|
|
104
|
+
const validation = new Set<string>()
|
|
105
|
+
const failures: string[] = []
|
|
106
|
+
const commands = new Map<string, string>()
|
|
107
|
+
let outcome = ''
|
|
108
|
+
let commandCount = 0
|
|
109
|
+
let tokens = 0
|
|
110
|
+
let usage: Usage = {
|
|
111
|
+
input: 0,
|
|
112
|
+
output: 0,
|
|
113
|
+
cacheRead: 0,
|
|
114
|
+
cacheWrite: 0,
|
|
115
|
+
totalTokens: 0,
|
|
116
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const entry of entries) {
|
|
120
|
+
if (entry.type !== 'message') continue
|
|
121
|
+
const message = entry.message
|
|
122
|
+
tokens += estimateTokens(message)
|
|
123
|
+
if (message.role === 'assistant') {
|
|
124
|
+
usage = addUsage(usage, message.usage)
|
|
125
|
+
for (const block of message.content) {
|
|
126
|
+
if (block.type === 'text') {
|
|
127
|
+
outcome = block.text
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (block.type !== 'toolCall') continue
|
|
131
|
+
if (isBashInput(block.arguments)) {
|
|
132
|
+
commandCount += 1
|
|
133
|
+
const command = oneLine(block.arguments.command, COMMAND_CHARS)
|
|
134
|
+
commands.set(block.id, command)
|
|
135
|
+
if (VALIDATION_COMMAND.test(block.arguments.command)) validation.add(command)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
if (!isPathInput(block.arguments)) continue
|
|
139
|
+
if (block.name === 'read') read.add(block.arguments.path)
|
|
140
|
+
else if (block.name === 'write' || block.name === 'edit') modified.add(block.arguments.path)
|
|
141
|
+
}
|
|
142
|
+
continue
|
|
143
|
+
}
|
|
144
|
+
if (message.role !== 'toolResult' || !message.isError) continue
|
|
145
|
+
const command = commands.get(message.toolCallId)
|
|
146
|
+
const label = command === undefined ? message.toolName : `${message.toolName} \`${command}\``
|
|
147
|
+
failures.push(oneLine(`${label}: ${resultText(message.content)}`, FAILURE_CHARS))
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return {
|
|
151
|
+
outcome: tail(outcome, OUTCOME_CHARS),
|
|
152
|
+
usage,
|
|
153
|
+
details: {
|
|
154
|
+
entries: entries.length,
|
|
155
|
+
tokens,
|
|
156
|
+
commands: commandCount,
|
|
157
|
+
modifiedFiles: [...modified].toSorted(),
|
|
158
|
+
readFiles: [...read].filter((path) => !modified.has(path)).toSorted(),
|
|
159
|
+
validationCommands: [...validation].toSorted(),
|
|
160
|
+
failures
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function list(items: readonly string[], limit: number): string {
|
|
166
|
+
const shown = items.slice(0, limit)
|
|
167
|
+
const omitted = items.length - shown.length
|
|
168
|
+
const lines = shown.map((item) => `- ${item}`)
|
|
169
|
+
if (omitted > 0) lines.push(`- ... ${omitted} more`)
|
|
170
|
+
return lines.length === 0 ? '- none' : lines.join('\n')
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function renderSummary(details: CollapseDetails, outcome: string): string {
|
|
174
|
+
const sections = [`Collapsed ${details.entries} entries, about ${details.tokens.toLocaleString('en-US')} tokens.`]
|
|
175
|
+
if (outcome !== '') sections.push(`Outcome:\n${outcome}`)
|
|
176
|
+
if (details.modifiedFiles.length > 0)
|
|
177
|
+
sections.push(`Modified files (${details.modifiedFiles.length}):\n${list(details.modifiedFiles, FILE_LIMIT)}`)
|
|
178
|
+
if (details.readFiles.length > 0)
|
|
179
|
+
sections.push(`Read (${details.readFiles.length}):\n${list(details.readFiles, FILE_LIMIT)}`)
|
|
180
|
+
const validation =
|
|
181
|
+
details.validationCommands.length === 0
|
|
182
|
+
? ''
|
|
183
|
+
: `; validation: ${list(details.validationCommands, FAILURE_LIMIT)}`
|
|
184
|
+
sections.push(`Commands: ${details.commands}${validation}`)
|
|
185
|
+
if (details.failures.length > 0)
|
|
186
|
+
sections.push(`Failures (${details.failures.length}):\n${list(details.failures, FAILURE_LIMIT)}`)
|
|
187
|
+
return sections.join('\n\n')
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const HANDOFF = [
|
|
191
|
+
'Earlier turns were collapsed to reclaim context. The summary is below.',
|
|
192
|
+
'',
|
|
193
|
+
'Treat it as the state of that work. Do not search session files or logs for the raw turns, and read a project file again only when the next task needs its current content.',
|
|
194
|
+
'',
|
|
195
|
+
'<collapsed>'
|
|
196
|
+
].join('\n')
|
|
197
|
+
|
|
198
|
+
export default function collapseExtension(pi: ExtensionAPI): void {
|
|
199
|
+
let pending: { anchorId: string; summary?: string } | undefined
|
|
200
|
+
|
|
201
|
+
pi.on('session_before_tree', (event: SessionBeforeTreeEvent) => {
|
|
202
|
+
if (pending === undefined || event.preparation.targetId !== pending.anchorId) return undefined
|
|
203
|
+
const { outcome, usage, details } = collapseEntries(event.preparation.entriesToSummarize)
|
|
204
|
+
const summary = renderSummary(details, outcome)
|
|
205
|
+
pending.summary = summary
|
|
206
|
+
return { summary: { summary, details, usage } }
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
pi.registerCommand('collapse', {
|
|
210
|
+
description: 'Replace finished turns with a structural summary. Full history stays in the session tree.',
|
|
211
|
+
handler: async (args, ctx) => {
|
|
212
|
+
if (!ctx.isIdle()) {
|
|
213
|
+
ctx.ui.notify('Agent is busy. Wait for it to finish.', 'warning')
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
const turns = parseTurns(args)
|
|
217
|
+
if (turns === null) {
|
|
218
|
+
ctx.ui.notify('Usage: /collapse [turns], turns is 1-100.', 'warning')
|
|
219
|
+
return
|
|
220
|
+
}
|
|
221
|
+
const anchor = collapseAnchor(ctx.sessionManager.getBranch(), turns)
|
|
222
|
+
if (anchor === undefined) {
|
|
223
|
+
ctx.ui.notify(
|
|
224
|
+
`Nothing to collapse: ${turns} finished turn(s) need an entry before them to collapse into.`,
|
|
225
|
+
'warning'
|
|
226
|
+
)
|
|
227
|
+
return
|
|
228
|
+
}
|
|
229
|
+
pending = { anchorId: anchor.anchorId }
|
|
230
|
+
let summary: string | undefined
|
|
231
|
+
try {
|
|
232
|
+
await ctx.navigateTree(anchor.anchorId, { summarize: true, label: 'collapsed' })
|
|
233
|
+
summary = pending.summary
|
|
234
|
+
} finally {
|
|
235
|
+
pending = undefined
|
|
236
|
+
}
|
|
237
|
+
if (summary === undefined) return
|
|
238
|
+
pi.sendMessage(
|
|
239
|
+
{ customType: 'context-collapse', content: `${HANDOFF}\n${summary}\n</collapsed>`, display: false },
|
|
240
|
+
{ deliverAs: 'nextTurn' }
|
|
241
|
+
)
|
|
242
|
+
ctx.ui.notify(`Collapsed ${turns} turn(s). Full history stays in /tree.`, 'info')
|
|
243
|
+
}
|
|
244
|
+
})
|
|
245
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import type { CompactionEntry, ExtensionAPI, SessionEntry } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
const RECORD_LIMIT_CHARS = 20_000
|
|
4
|
+
const EXCERPT_CHARS = 4_000
|
|
5
|
+
const OVERHEAD_RESERVE_CHARS = 2_000
|
|
6
|
+
|
|
7
|
+
interface RecordInput {
|
|
8
|
+
label: string
|
|
9
|
+
timestamp: string
|
|
10
|
+
text: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface UserMessage {
|
|
14
|
+
role: string
|
|
15
|
+
toolName?: string
|
|
16
|
+
isError?: boolean
|
|
17
|
+
content?: string | Array<{ type: string; text?: string }>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface CustomMessageEntryLike {
|
|
21
|
+
type: 'custom_message'
|
|
22
|
+
customType: string
|
|
23
|
+
display: boolean
|
|
24
|
+
content?: string | Array<{ type: string; text?: string }>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function contentText(content: string | Array<{ type: string; text?: string }> | undefined): string {
|
|
28
|
+
if (!Array.isArray(content)) return content ?? ''
|
|
29
|
+
return content
|
|
30
|
+
.filter((block) => block.type === 'text')
|
|
31
|
+
.map((block) => block.text ?? '')
|
|
32
|
+
.join('\n')
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function excerpt(text: string, limit: number): string {
|
|
36
|
+
if (text.length <= limit) return text
|
|
37
|
+
const marker = '\n… middle omitted …\n'
|
|
38
|
+
const head = Math.floor((limit - marker.length) / 2)
|
|
39
|
+
return `${text.slice(0, head)}${marker}${text.slice(text.length - (limit - marker.length - head))}`
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function recoveryInput(entry: SessionEntry): RecordInput | undefined {
|
|
43
|
+
if (entry.type === 'message') {
|
|
44
|
+
const message = entry.message as UserMessage
|
|
45
|
+
if (message.role === 'user') {
|
|
46
|
+
return { label: 'user input', timestamp: entry.timestamp, text: contentText(message.content ?? '').trim() }
|
|
47
|
+
}
|
|
48
|
+
if (message.role === 'toolResult' && message.toolName === 'ask_question' && message.isError !== true) {
|
|
49
|
+
return {
|
|
50
|
+
label: 'user answer via ask_question',
|
|
51
|
+
timestamp: entry.timestamp,
|
|
52
|
+
text: contentText(message.content ?? '').trim()
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return undefined
|
|
56
|
+
}
|
|
57
|
+
if (entry.type !== 'custom_message') return undefined
|
|
58
|
+
const custom = entry as CustomMessageEntryLike
|
|
59
|
+
if (custom.display !== true || custom.customType === 'context-prune-nudge') return undefined
|
|
60
|
+
return {
|
|
61
|
+
label: `visible ${custom.customType ?? 'custom'} message (injected, not typed by the user)`,
|
|
62
|
+
timestamp: entry.timestamp,
|
|
63
|
+
text: contentText(custom.content).trim()
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function compactedSpan(branch: SessionEntry[], compaction: CompactionEntry): SessionEntry[] {
|
|
68
|
+
const indexOf = (id: string | undefined): number => branch.findIndex((entry) => entry.id === id)
|
|
69
|
+
const boundary = indexOf(compaction.firstKeptEntryId)
|
|
70
|
+
const end = boundary === -1 ? indexOf(compaction.id) : boundary
|
|
71
|
+
if (end < 1) return []
|
|
72
|
+
for (let i = end - 1; i >= 0; i--) {
|
|
73
|
+
const entry = branch[i]
|
|
74
|
+
if (entry?.type !== 'compaction' || entry.id === compaction.id) continue
|
|
75
|
+
const kept = indexOf(entry.firstKeptEntryId)
|
|
76
|
+
return branch.slice(kept === -1 ? i + 1 : kept, end)
|
|
77
|
+
}
|
|
78
|
+
return branch.slice(0, end)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function formatInput(input: RecordInput): string {
|
|
82
|
+
return `[${input.label} | ${input.timestamp}] ${excerpt(input.text, EXCERPT_CHARS)}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function buildRecoveryRecord(span: SessionEntry[]): string | undefined {
|
|
86
|
+
const inputs = span.map(recoveryInput).filter((input): input is RecordInput => input !== undefined)
|
|
87
|
+
if (inputs.length === 0) return undefined
|
|
88
|
+
const preamble = [
|
|
89
|
+
'Verbatim input record for the span the summary above covers.',
|
|
90
|
+
'This lists what was asked, not what happened; verify live state before stateful or external work.',
|
|
91
|
+
'Use the history tool to recover anything else from before the compaction.'
|
|
92
|
+
].join('\n')
|
|
93
|
+
|
|
94
|
+
const required = new Set<RecordInput>()
|
|
95
|
+
const formatted = new Map<RecordInput, string>()
|
|
96
|
+
const format = (input: RecordInput): string => {
|
|
97
|
+
const existing = formatted.get(input)
|
|
98
|
+
if (existing !== undefined) return existing
|
|
99
|
+
const value = formatInput(input)
|
|
100
|
+
formatted.set(input, value)
|
|
101
|
+
return value
|
|
102
|
+
}
|
|
103
|
+
const firstUser = inputs.find((input) => input.label === 'user input')
|
|
104
|
+
const latestUser = inputs.findLast((input) => input.label === 'user input')
|
|
105
|
+
const latest = inputs.at(-1)
|
|
106
|
+
for (const input of [firstUser, latestUser, latest]) if (input) required.add(input)
|
|
107
|
+
|
|
108
|
+
const fixedChars =
|
|
109
|
+
preamble.length +
|
|
110
|
+
[...required].reduce((sum, input) => sum + format(input).length + 2, 0) +
|
|
111
|
+
OVERHEAD_RESERVE_CHARS
|
|
112
|
+
let budget = Math.max(0, RECORD_LIMIT_CHARS - fixedChars)
|
|
113
|
+
for (let i = inputs.length - 1; i >= 0; i--) {
|
|
114
|
+
const input = inputs[i]!
|
|
115
|
+
if (required.has(input)) continue
|
|
116
|
+
const roughCost = Math.min(input.text.length, EXCERPT_CHARS) + input.label.length + input.timestamp.length + 32
|
|
117
|
+
if (roughCost > budget) continue
|
|
118
|
+
const cost = format(input).length + 2
|
|
119
|
+
if (cost > budget) continue
|
|
120
|
+
budget -= cost
|
|
121
|
+
required.add(input)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const omitted = inputs.filter((input) => !required.has(input))
|
|
125
|
+
const omission =
|
|
126
|
+
omitted.length > 0
|
|
127
|
+
? `Omitted ${omitted.length} input(s) within the record limit (${omitted[0]!.timestamp} through ${omitted.at(-1)!.timestamp}); recover them with the history tool.`
|
|
128
|
+
: undefined
|
|
129
|
+
return [preamble, ...inputs.filter((input) => required.has(input)).map((input) => format(input)), omission]
|
|
130
|
+
.filter(Boolean)
|
|
131
|
+
.join('\n\n')
|
|
132
|
+
.slice(0, RECORD_LIMIT_CHARS)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export default function compactionHandoff(pi: ExtensionAPI): void {
|
|
136
|
+
pi.on('session_compact', (event, ctx) => {
|
|
137
|
+
if (event.fromExtension) return
|
|
138
|
+
if (event.reason === 'manual') return
|
|
139
|
+
const span = compactedSpan(ctx.sessionManager.getBranch() as SessionEntry[], event.compactionEntry)
|
|
140
|
+
const record = buildRecoveryRecord(span)
|
|
141
|
+
if (!record) return
|
|
142
|
+
pi.sendMessage(
|
|
143
|
+
{ customType: 'compaction-input-record', content: record, display: false, details: undefined },
|
|
144
|
+
{ deliverAs: 'steer' }
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { ExtensionAPI } from '@earendil-works/pi-coding-agent'
|
|
2
|
+
|
|
3
|
+
export default function dedupeContextFiles(pi: ExtensionAPI): void {
|
|
4
|
+
pi.on('before_agent_start', (event) => {
|
|
5
|
+
const files = event.systemPromptOptions.contextFiles ?? []
|
|
6
|
+
if (!files.length) return
|
|
7
|
+
|
|
8
|
+
const seen = new Set<string>()
|
|
9
|
+
const deduped = files.filter((file) => {
|
|
10
|
+
if (seen.has(file.content)) return false
|
|
11
|
+
seen.add(file.content)
|
|
12
|
+
return true
|
|
13
|
+
})
|
|
14
|
+
if (deduped.length !== files.length) event.systemPromptOptions.contextFiles = deduped
|
|
15
|
+
})
|
|
16
|
+
}
|