@tduyng/pi-context 0.0.0-stage → 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,143 @@
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
+ repeats: number
85
+ }
86
+
87
+ const QUOTE_CHARS = 120
88
+
89
+ /**
90
+ * The opening of the first message pi keeps verbatim, quoted back to the summarizer. The
91
+ * quote marks where the kept window begins. A repeated opening is counted from the end,
92
+ * so the quote names the last of them.
93
+ */
94
+ export function keptStartQuote(branchEntries: SessionEntry[], firstKeptEntryId: string): KeptStart | null {
95
+ const start = branchEntries.findIndex((entry) => entry.id === firstKeptEntryId)
96
+ if (start === -1) return null
97
+ const kept = branchEntries.slice(start)
98
+ const quotes = kept
99
+ .map((entry) => sessionEntryToContextMessages(entry).map(messageText).join(' ').trim())
100
+ .filter((text) => text !== '')
101
+ const first = quotes[0]
102
+ if (first === undefined) return null
103
+ const text = first.slice(0, QUOTE_CHARS)
104
+ let repeats = 0
105
+ for (const quote of quotes) {
106
+ if (quote.startsWith(text)) repeats++
107
+ }
108
+ return { text, repeats }
109
+ }
110
+
111
+ /**
112
+ * The summary instruction, appended as the next user turn. It says stop. It says call no
113
+ * tools. It quotes the opening the summarizer must stop at, and it keeps pi's checkpoint
114
+ * shape so the file lists and the recent window survive the round trip.
115
+ */
116
+ export function summaryInstruction(keptStart: KeptStart | null, customFocus: string | undefined): BranchMessage {
117
+ const boundary =
118
+ keptStart === null
119
+ ? 'Summarize the conversation so far.'
120
+ : `Summarize the conversation only up to the message beginning "${keptStart.text}".\n` +
121
+ 'A message with that opening may appear more than once: the boundary is the last of them.\n' +
122
+ 'Do not include anything after the boundary.'
123
+ const message: BranchMessage = {
124
+ role: 'user',
125
+ content:
126
+ `${boundary}\n\n` +
127
+ 'Stop here. Call no tools.\n\n' +
128
+ 'Write the summary in this exact format:\n' +
129
+ 'Goal: what the session was trying to accomplish.\n' +
130
+ 'Constraints: hard constraints that shaped the approach.\n' +
131
+ 'Done: what has been completed, including files and tests.\n' +
132
+ 'In Progress: what was being worked on when the summary was requested.\n' +
133
+ 'Blocked: blockers or unresolved issues.\n' +
134
+ 'Key Decisions: the important decisions and why.\n' +
135
+ 'Next Steps: what should happen next.\n' +
136
+ 'Critical Context: the critical context to carry forward.\n' +
137
+ 'File Lists: read and modified file lists. If none, write "None".\n' +
138
+ (customFocus === undefined ? '' : `\n${customFocus}\n`) +
139
+ '\nWrite no other text before or after the format.',
140
+ timestamp: Date.now()
141
+ }
142
+ return message
143
+ }
@@ -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
+ }