@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/src/context.ts ADDED
@@ -0,0 +1,313 @@
1
+ import {
2
+ buildSessionContext,
3
+ DynamicBorder,
4
+ estimateTokens,
5
+ type ExtensionAPI,
6
+ type ThemeColor
7
+ } from '@earendil-works/pi-coding-agent'
8
+ import { Container, HStack, matchesKey, Spacer, Text, truncateToWidth } from '@earendil-works/pi-tui'
9
+
10
+ const CHARS_PER_TOKEN = 4
11
+ const MAP_COLS = 24
12
+ const MIN_MAP_ROWS = 6
13
+ const MAX_CHILDREN_SHOWN = 30
14
+ const PANEL_SHARE = 0.7
15
+ const CHROME_ROWS = 6
16
+ const MIN_RIGHT_WIDTH = 30
17
+ const PALETTE: ThemeColor[] = [
18
+ 'accent',
19
+ 'success',
20
+ 'warning',
21
+ 'error',
22
+ 'syntaxString',
23
+ 'syntaxFunction',
24
+ 'syntaxKeyword',
25
+ 'syntaxNumber'
26
+ ]
27
+
28
+ function textTokens(text: string): number {
29
+ return Math.ceil(text.length / CHARS_PER_TOKEN)
30
+ }
31
+
32
+ function formatTokens(tokens: number): string {
33
+ return tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens)
34
+ }
35
+
36
+ interface Row {
37
+ label: string
38
+ tokens: number
39
+ children?: Map<string, number>
40
+ }
41
+
42
+ function addTo(totals: Map<string, number>, label: string, tokens: number): void {
43
+ totals.set(label, (totals.get(label) ?? 0) + tokens)
44
+ }
45
+
46
+ function buildMap(
47
+ rows: Row[],
48
+ windowTokens: number,
49
+ mapRows: number,
50
+ fg: (color: ThemeColor, text: string) => string
51
+ ): string[] {
52
+ const totalCells = MAP_COLS * mapRows
53
+ const cells: string[] = []
54
+ let cumTokens = 0
55
+ let filledCells = 0
56
+ rows.forEach((row, index) => {
57
+ cumTokens += row.tokens
58
+ const target = Math.min(totalCells, Math.round((cumTokens / windowTokens) * totalCells))
59
+ const count = Math.max(0, target - filledCells)
60
+ filledCells = target
61
+ const glyph = fg(PALETTE[index % PALETTE.length] as ThemeColor, '■')
62
+ for (let i = 0; i < count; i++) cells.push(glyph)
63
+ })
64
+ const freeGlyph = fg('muted', '▫')
65
+ while (cells.length < totalCells) cells.push(freeGlyph)
66
+
67
+ const lines: string[] = []
68
+ for (let r = 0; r < mapRows; r++) lines.push(cells.slice(r * MAP_COLS, (r + 1) * MAP_COLS).join(' '))
69
+ return lines
70
+ }
71
+
72
+ function leaderLine(label: string, value: string, leaderWidth: number, indent: string): string {
73
+ const dots = '.'.repeat(Math.max(1, leaderWidth - label.length))
74
+ return `${indent}${label} ${dots} ${value}`
75
+ }
76
+
77
+ function childLines(
78
+ children: Map<string, number>,
79
+ leaderWidth: number,
80
+ fg: (c: ThemeColor, t: string) => string
81
+ ): string[] {
82
+ const sorted = [...children.entries()].toSorted((a, b) => b[1] - a[1])
83
+ const shown = sorted.slice(0, MAX_CHILDREN_SHOWN)
84
+ const rest = sorted.slice(MAX_CHILDREN_SHOWN)
85
+ const lines = shown.map(([name, tokens]) =>
86
+ fg('dim', leaderLine(name, formatTokens(tokens).padStart(7), leaderWidth, ' ↳ '))
87
+ )
88
+ if (rest.length > 0) {
89
+ const restTokens = rest.reduce((sum, [, tokens]) => sum + tokens, 0)
90
+ lines.push(
91
+ fg('dim', leaderLine(`+${rest.length} more`, formatTokens(restTokens).padStart(7), leaderWidth, ' ↳ '))
92
+ )
93
+ }
94
+ return lines
95
+ }
96
+
97
+ export default function (pi: ExtensionAPI) {
98
+ pi.registerCommand('context', {
99
+ description: 'Show context window usage as a colored map and category list',
100
+ handler: async (_args, ctx) => {
101
+ if (ctx.mode !== 'tui') {
102
+ ctx.ui.notify('/context requires TUI mode.', 'warning')
103
+ return
104
+ }
105
+
106
+ const rowsByLabel = new Map<string, Row>()
107
+ const rowFor = (label: string): Row => {
108
+ let row = rowsByLabel.get(label)
109
+ if (!row) {
110
+ row = { label, tokens: 0 }
111
+ rowsByLabel.set(label, row)
112
+ }
113
+ return row
114
+ }
115
+ const add = (label: string, tokens: number): void => {
116
+ rowFor(label).tokens += tokens
117
+ }
118
+ const addChild = (label: string, childName: string, tokens: number): void => {
119
+ const row = rowFor(label)
120
+ row.tokens += tokens
121
+ row.children ??= new Map()
122
+ addTo(row.children, childName, tokens)
123
+ }
124
+
125
+ add('System prompt', textTokens(ctx.getSystemPrompt()))
126
+
127
+ const activeTools = new Set(pi.getActiveTools())
128
+ for (const tool of pi.getAllTools()) {
129
+ if (!activeTools.has(tool.name)) continue
130
+ const tokens = textTokens(tool.name + (tool.description ?? '') + JSON.stringify(tool.parameters ?? {}))
131
+ addChild(`Tools (${activeTools.size})`, tool.name, tokens)
132
+ }
133
+
134
+ const { messages } = buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId())
135
+ for (const message of messages) {
136
+ switch (message.role) {
137
+ case 'user':
138
+ add('User messages', estimateTokens(message))
139
+ break
140
+ case 'assistant':
141
+ for (const block of message.content) {
142
+ if (block.type === 'text') add('Assistant text', textTokens(block.text))
143
+ else if (block.type === 'thinking') add('Assistant thinking', textTokens(block.thinking))
144
+ else if (block.type === 'toolCall') {
145
+ add('Assistant tool calls', textTokens(block.name + JSON.stringify(block.arguments)))
146
+ }
147
+ }
148
+ break
149
+ case 'toolResult':
150
+ addChild('Tool results', message.toolName, estimateTokens(message))
151
+ break
152
+ case 'bashExecution':
153
+ add('Bash executions', estimateTokens(message))
154
+ break
155
+ case 'custom':
156
+ add(`Extension: ${message.customType}`, estimateTokens(message))
157
+ break
158
+ case 'branchSummary':
159
+ case 'compactionSummary':
160
+ add('Compacted history', estimateTokens(message))
161
+ break
162
+ }
163
+ }
164
+
165
+ const rows = [...rowsByLabel.values()]
166
+ .filter((row) => row.tokens > 0)
167
+ .toSorted((a, b) => b.tokens - a.tokens)
168
+ const total = rows.reduce((sum, row) => sum + row.tokens, 0)
169
+ const reported = ctx.getContextUsage()
170
+ const windowTokens = reported?.contextWindow ?? (total || 1)
171
+ const maxParentLabel = Math.max(...rows.map((row) => row.label.length), 'Free space'.length)
172
+ const maxChildLabel = Math.max(
173
+ 0,
174
+ ...rows.flatMap((row) => (row.children ? [...row.children.keys()] : [])).map((k) => k.length)
175
+ )
176
+ const childLeaderWidth = maxChildLabel + 1
177
+ const parentLeaderWidth = Math.max(maxParentLabel + 1, childLeaderWidth + 4)
178
+
179
+ const reportedPercent = reported?.percent != null ? `${Math.round(reported.percent)}%` : '?%'
180
+ const headerLine = [
181
+ ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined,
182
+ reported?.tokens != null
183
+ ? `${formatTokens(reported.tokens)}/${formatTokens(reported.contextWindow)} (${reportedPercent})`
184
+ : undefined
185
+ ]
186
+ .filter(Boolean)
187
+ .join(' · ')
188
+
189
+ await ctx.ui.custom((tui, theme, _kb, done) => {
190
+ const fg = (color: ThemeColor, text: string) => theme.fg(color, text)
191
+ const groups = rows.map((row, index) => {
192
+ const percent = Math.round((row.tokens / windowTokens) * 100)
193
+ const square = fg(PALETTE[index % PALETTE.length] as ThemeColor, '■')
194
+ const value = `${formatTokens(row.tokens).padStart(7)} ${String(percent).padStart(3)}%`
195
+ return {
196
+ parent: `${square} ${leaderLine(row.label, value, parentLeaderWidth, '')}`,
197
+ children: row.children ? childLines(row.children, childLeaderWidth, fg) : []
198
+ }
199
+ })
200
+ const freeTokens = Math.max(0, windowTokens - total)
201
+ const freeLine =
202
+ reported?.tokens != null
203
+ ? fg(
204
+ 'muted',
205
+ `${fg('muted', '▫')} ${leaderLine('Free space', `${formatTokens(freeTokens).padStart(7)} ${String(Math.round((freeTokens / windowTokens) * 100)).padStart(3)}%`, parentLeaderWidth, '')}`
206
+ )
207
+ : undefined
208
+ const titleText = [
209
+ fg('accent', theme.bold('Context usage')),
210
+ headerLine ? fg('dim', headerLine) : undefined
211
+ ]
212
+ .filter((part): part is string => part !== undefined)
213
+ .join(' ')
214
+ const MAP_TEXT_PAD_X = 1
215
+ const mapWidth = MAP_COLS * 2 - 1 + MAP_TEXT_PAD_X * 2
216
+
217
+ let cached: { width: number; rows: number; lines: string[] } | undefined
218
+
219
+ const compose = (width: number): string[] => {
220
+ const terminalRows = tui.terminal.rows || 24
221
+ const panelRows = Math.max(4, Math.min(terminalRows - 2, Math.floor(terminalRows * PANEL_SHARE)))
222
+ const contentRows = Math.max(1, panelRows - CHROME_ROWS)
223
+ const showMap = width - mapWidth - 3 >= MIN_RIGHT_WIDTH && contentRows >= MIN_MAP_ROWS
224
+ const rightWidth = Math.max(16, (showMap ? width - mapWidth - 3 : width) - 2)
225
+ // Parents first: a short window keeps every category it can before any
226
+ // child detail, because the categories are the summary and the children
227
+ // are the breakdown.
228
+ const clipped: string[] = []
229
+ if (groups.length > contentRows) {
230
+ if (contentRows === 1) {
231
+ clipped.push(groups[0]?.parent ?? fg('dim', 'No context in use'))
232
+ } else {
233
+ const hidden = groups.length - (contentRows - 1)
234
+ clipped.push(...groups.slice(0, contentRows - 1).map((group) => group.parent))
235
+ clipped.push(
236
+ fg(
237
+ 'dim',
238
+ truncateToWidth(
239
+ `${hidden} more ${hidden === 1 ? 'category' : 'categories'}`,
240
+ rightWidth,
241
+ '...'
242
+ )
243
+ )
244
+ )
245
+ }
246
+ } else {
247
+ const reserveFree = freeLine === undefined ? 0 : 1
248
+ let childRows = Math.max(0, contentRows - groups.length - reserveFree)
249
+ for (const group of groups) {
250
+ clipped.push(group.parent)
251
+ for (const child of group.children) {
252
+ if (childRows <= 0) break
253
+ clipped.push(child)
254
+ childRows -= 1
255
+ }
256
+ }
257
+ if (freeLine !== undefined && clipped.length < contentRows) clipped.push(freeLine)
258
+ }
259
+ const lines = clipped.map((line) => truncateToWidth(line, rightWidth, '...'))
260
+ const mapRows = Math.min(
261
+ contentRows,
262
+ Math.max(Math.min(MIN_MAP_ROWS, contentRows), lines.length)
263
+ )
264
+
265
+ const container = new Container()
266
+ const border = new DynamicBorder((s: string) => theme.fg('accent', s))
267
+ container.addChild(border)
268
+ container.addChild(new Text(truncateToWidth(titleText, width - 2, '...'), 1, 0))
269
+ container.addChild(new Spacer(1))
270
+ if (showMap) {
271
+ const mapLines = buildMap(rows, windowTokens, mapRows, fg)
272
+ container.addChild(
273
+ new HStack(
274
+ [
275
+ {
276
+ component: new Text(mapLines.join('\n'), MAP_TEXT_PAD_X, 0),
277
+ basis: mapWidth,
278
+ grow: 0,
279
+ shrink: 0
280
+ },
281
+ { component: new Text(lines.join('\n'), 0, 0), grow: 1 }
282
+ ],
283
+ { gap: 3 }
284
+ )
285
+ )
286
+ } else {
287
+ container.addChild(new Text(lines.join('\n'), 0, 0))
288
+ }
289
+ container.addChild(new Spacer(1))
290
+ container.addChild(new Text(fg('dim', 'Press Enter or Esc to close'), 1, 0))
291
+ container.addChild(border)
292
+ return container.render(width)
293
+ }
294
+
295
+ return {
296
+ render: (width: number) => {
297
+ const rowsNow = tui.terminal.rows || 24
298
+ if (cached?.width !== width || cached.rows !== rowsNow) {
299
+ cached = { width, rows: rowsNow, lines: compose(width) }
300
+ }
301
+ return cached.lines
302
+ },
303
+ invalidate: () => {
304
+ cached = undefined
305
+ },
306
+ handleInput: (data: string) => {
307
+ if (matchesKey(data, 'enter') || matchesKey(data, 'escape')) done(undefined)
308
+ }
309
+ }
310
+ })
311
+ }
312
+ })
313
+ }
package/src/history.ts ADDED
@@ -0,0 +1,241 @@
1
+ import { createReadStream, existsSync, readdirSync, statSync } from 'node:fs'
2
+ import { join, relative } from 'node:path'
3
+ import { createInterface } from 'node:readline'
4
+ import type { ExtensionAPI, SessionEntry } from '@earendil-works/pi-coding-agent'
5
+ import type { Infer } from 'ata-validator'
6
+ import { t } from 'ata-validator/t'
7
+
8
+ const HISTORY_TOOL_NAME = 'history'
9
+ const MAX_READ_CHARS = 20_000
10
+ const MIN_READ_CHARS = 2_000
11
+ const CHARS_PER_TOKEN = 4
12
+ const PAGE_MARGIN_TOKENS = 1_000
13
+ const EXCERPT_LEAD = 100
14
+ const EXCERPT_CHARS = 400
15
+
16
+ interface FlatEntry {
17
+ id: string
18
+ timestamp: string
19
+ text: string
20
+ echo: boolean
21
+ }
22
+
23
+ interface MessageLike {
24
+ role?: string
25
+ toolName?: string
26
+ command?: string
27
+ output?: string
28
+ content?: string | Array<{ type: string; text?: string }>
29
+ }
30
+
31
+ function contentText(content: string | Array<{ type: string; text?: string }> | undefined): string {
32
+ if (!Array.isArray(content)) return content ?? ''
33
+ return content
34
+ .filter((block) => block.type === 'text')
35
+ .map((block) => block.text ?? '')
36
+ .join('\n')
37
+ }
38
+
39
+ export function isEcho(entry: SessionEntry): boolean {
40
+ if (entry.type === 'compaction' || entry.type === 'branch_summary') return true
41
+ if (entry.type === 'custom_message') return entry.customType === 'compaction-input-record'
42
+ const message = entry.type === 'message' ? (entry.message as MessageLike) : undefined
43
+ return message?.role === 'toolResult' && message.toolName === 'history'
44
+ }
45
+
46
+ export function flatten(entry: SessionEntry): FlatEntry | undefined {
47
+ let text: string | undefined
48
+ if (entry.type === 'message') {
49
+ const message = entry.message as MessageLike
50
+ const body =
51
+ message.role === 'bashExecution'
52
+ ? `$ ${message.command ?? ''}\n${message.output ?? ''}`
53
+ : contentText(message.content)
54
+ text = `[${message.role ?? 'message'}${message.toolName ? `:${message.toolName}` : ''}] ${body}`
55
+ } else if (entry.type === 'compaction' || entry.type === 'branch_summary') {
56
+ text = `[${entry.type}] ${entry.summary}`
57
+ } else if (entry.type === 'custom_message') {
58
+ text = `[custom:${entry.customType}] ${contentText(entry.content)}`
59
+ }
60
+ if (!text?.trim()) return undefined
61
+ return { id: entry.id, timestamp: entry.timestamp, text, echo: isEcho(entry) }
62
+ }
63
+
64
+ export function pageChars(usage: { tokens: number | null; contextWindow: number } | undefined): number {
65
+ if (!usage || usage.tokens == null) return MAX_READ_CHARS
66
+ const fits = Math.max(0, usage.contextWindow - usage.tokens - PAGE_MARGIN_TOKENS) * CHARS_PER_TOKEN
67
+ return Math.max(MIN_READ_CHARS, Math.min(MAX_READ_CHARS, fits))
68
+ }
69
+
70
+ export function queryTerms(query: string): string[] {
71
+ return query.toLowerCase().split(/\s+/).filter(Boolean)
72
+ }
73
+
74
+ export function scoreText(text: string, terms: string[]): { score: number; term: string } | undefined {
75
+ const lower = text.toLowerCase()
76
+ const phrase = terms.join(' ')
77
+ if (terms.length > 1 && lower.includes(phrase)) return { score: terms.length + 1, term: phrase }
78
+ let score = 0
79
+ let earliest: string | undefined
80
+ let earliestAt = Number.POSITIVE_INFINITY
81
+ for (const term of terms) {
82
+ const at = lower.indexOf(term)
83
+ if (at === -1) continue
84
+ score++
85
+ if (at < earliestAt) {
86
+ earliestAt = at
87
+ earliest = term
88
+ }
89
+ }
90
+ return earliest ? { score, term: earliest } : undefined
91
+ }
92
+
93
+ export function excerptAround(text: string, query: string): string | undefined {
94
+ const at = text.toLowerCase().indexOf(query)
95
+ if (at === -1) return undefined
96
+ const start = Math.max(0, at - EXCERPT_LEAD)
97
+ const end = start + EXCERPT_CHARS
98
+ return `${start > 0 ? '…' : ''}${text.slice(start, end)}${end < text.length ? '…' : ''}`
99
+ }
100
+
101
+ export function byOriginalFirst(a: { score: number; echo: boolean }, b: { score: number; echo: boolean }): number {
102
+ return Number(a.echo) - Number(b.echo) || b.score - a.score
103
+ }
104
+
105
+ function formatHit(entry: FlatEntry, excerpt: string, source: string): string {
106
+ return `${source ? `${source} ` : ''}${entry.timestamp} [${entry.id}] ${entry.echo ? '[echo] ' : ''}${excerpt}`
107
+ }
108
+
109
+ function sessionFiles(dir: string): string[] {
110
+ try {
111
+ if (!existsSync(dir)) return []
112
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
113
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl'))
114
+ .map((entry) => {
115
+ const file = join(entry.parentPath, entry.name)
116
+ return { file, mtimeMs: statSync(file).mtimeMs }
117
+ })
118
+ .toSorted((a, b) => b.mtimeMs - a.mtimeMs)
119
+ .map((entry) => entry.file)
120
+ } catch {
121
+ return []
122
+ }
123
+ }
124
+
125
+ async function* fileEntries(file: string, signal?: AbortSignal): AsyncGenerator<FlatEntry> {
126
+ const stream = createReadStream(file, { encoding: 'utf8', signal })
127
+ const lines = createInterface({ input: stream, crlfDelay: Number.POSITIVE_INFINITY })
128
+ try {
129
+ for await (const line of lines) {
130
+ let entry: FlatEntry | undefined
131
+ try {
132
+ entry = flatten(JSON.parse(line) as SessionEntry)
133
+ } catch {
134
+ continue
135
+ }
136
+ if (entry) yield entry
137
+ }
138
+ } finally {
139
+ lines.close()
140
+ stream.destroy()
141
+ }
142
+ }
143
+
144
+ function textResult(text: string) {
145
+ return { content: [{ type: 'text' as const, text }], details: undefined }
146
+ }
147
+
148
+ const historyParams = t.object({
149
+ op: t.union([t.literal('search'), t.literal('read')]),
150
+ query: t.optional(t.string()),
151
+ id: t.optional(t.string()),
152
+ all: t.optional(t.boolean()),
153
+ limit: t.optional(t.integer({ minimum: 1, maximum: 50 })),
154
+ offset: t.optional(t.integer({ minimum: 0 }))
155
+ })
156
+
157
+ export default function history(pi: ExtensionAPI): void {
158
+ pi.registerTool({
159
+ name: HISTORY_TOOL_NAME,
160
+ label: 'History',
161
+ description:
162
+ 'Search or read earlier conversation, including pruned or compacted detail. Search the current branch or all project sessions, newest first. Read an entry by id with offset.',
163
+ promptSnippet: 'Recover earlier conversation',
164
+ exposure: 'deferred',
165
+ namespace: {
166
+ name: 'context',
167
+ description: 'Earlier conversation: search or read pruned and compacted turns.'
168
+ },
169
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
170
+ parameters: historyParams,
171
+
172
+ async execute(_toolCallId, params: Infer<typeof historyParams>, signal, _onUpdate, ctx) {
173
+ const manager = ctx.sessionManager
174
+ const dir = manager.getSessionDir()
175
+
176
+ if (params.op === 'search') {
177
+ const query = params.query?.trim()
178
+ if (!query) return textResult('query is required for op "search".')
179
+ const terms = queryTerms(query)
180
+ const limit = params.limit ?? 10
181
+ const maxCandidates = limit * 10
182
+ const candidates: Array<{ score: number; echo: boolean; line: string }> = []
183
+ const candidate = (entry: FlatEntry, source: string) => {
184
+ const hit = scoreText(entry.text, terms)
185
+ if (!hit) return undefined
186
+ return {
187
+ score: hit.score,
188
+ echo: entry.echo,
189
+ line: formatHit(entry, excerptAround(entry.text, hit.term)!, source)
190
+ }
191
+ }
192
+
193
+ if (params.all) {
194
+ for (const file of sessionFiles(dir)) {
195
+ const recent: Array<{ score: number; echo: boolean; line: string }> = []
196
+ for await (const entry of fileEntries(file, signal)) {
197
+ const hit = candidate(entry, relative(dir, file))
198
+ if (!hit) continue
199
+ recent.push(hit)
200
+ if (recent.length > maxCandidates) recent.shift()
201
+ }
202
+ candidates.push(...recent)
203
+ if (candidates.length >= maxCandidates) break
204
+ }
205
+ } else {
206
+ const branch = manager.getBranch() as SessionEntry[]
207
+ for (let i = branch.length - 1; i >= 0 && candidates.length < maxCandidates; i--) {
208
+ const entry = flatten(branch[i]!)
209
+ const hit = entry && candidate(entry, '')
210
+ if (hit) candidates.push(hit)
211
+ }
212
+ }
213
+ const ranked = candidates.toSorted(byOriginalFirst).slice(0, limit)
214
+ return textResult(ranked.map((hit) => hit.line).join('\n') || `No history matches "${query}".`)
215
+ }
216
+
217
+ const id = params.id?.trim()
218
+ if (!id) return textResult('id is required for op "read".')
219
+ const maxChars = pageChars(ctx.getContextUsage())
220
+ const slice = (entry: FlatEntry, source: string) => {
221
+ const offset = Math.min(params.offset ?? 0, entry.text.length)
222
+ const end = Math.min(entry.text.length, offset + maxChars)
223
+ const more = end < entry.text.length ? `\nMore remains; read id "${id}" again with offset ${end}.` : ''
224
+ return textResult(
225
+ `${source ? `${source} ` : ''}${entry.timestamp} [${id}] [chars ${offset}-${end} of ${entry.text.length}]\n${entry.text.slice(offset, end)}${more}`
226
+ )
227
+ }
228
+
229
+ for (const raw of manager.getBranch() as SessionEntry[]) {
230
+ const entry = raw.id === id ? flatten(raw) : undefined
231
+ if (entry) return slice(entry, '')
232
+ }
233
+ for (const file of sessionFiles(dir)) {
234
+ for await (const entry of fileEntries(file, signal)) {
235
+ if (entry.id === id) return slice(entry, relative(dir, file))
236
+ }
237
+ }
238
+ return textResult(`No history entry with id "${id}".`)
239
+ }
240
+ })
241
+ }
package/src/summary.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { Validator } from 'ata-validator'
2
+ import { t } from 'ata-validator/t'
3
+
4
+ import type { CompactionEntry, FileOperations } from '@earendil-works/pi-coding-agent'
5
+
6
+ const FileListsSchema = t.object({
7
+ readFiles: t.optional(t.array(t.string())),
8
+ modifiedFiles: t.optional(t.array(t.string()))
9
+ })
10
+ const isFileListsSchema = new Validator(FileListsSchema).isValidObject
11
+
12
+ const FileBlock = /^<(read-files|modified-files)>[\s\S]*?<\/\1>\n?/gm
13
+
14
+ interface FileLists {
15
+ read: string[]
16
+ modified: string[]
17
+ }
18
+
19
+ function listsFrom(details: CompactionEntry['details']): FileLists {
20
+ if (!isFileListsSchema(details)) return { read: [], modified: [] }
21
+ return { read: details.readFiles ?? [], modified: details.modifiedFiles ?? [] }
22
+ }
23
+
24
+ function merge(...lists: string[][]): string[] {
25
+ return [...new Set(lists.flat())].toSorted()
26
+ }
27
+
28
+ /**
29
+ * The summary the model wrote, with pi's own file lists appended. The model may have copied
30
+ * a previous compaction's lists into the text, so those blocks are stripped first. Lists
31
+ * from the previous compaction ride along, because pi carries them forward only from its
32
+ * own compactions.
33
+ */
34
+ export interface Checkpoint {
35
+ summary: string
36
+ details: { readFiles: string[]; modifiedFiles: string[] }
37
+ }
38
+
39
+ export function checkpointSummary(
40
+ raw: string,
41
+ fileOps: FileOperations,
42
+ previousDetails: CompactionEntry['details']
43
+ ): Checkpoint {
44
+ const previous = listsFrom(previousDetails)
45
+ const read = merge(previous.read, [...fileOps.read])
46
+ const modified = merge(previous.modified, [...fileOps.written, ...fileOps.edited])
47
+ const body = raw.replace(FileBlock, '').trimEnd()
48
+ return {
49
+ summary:
50
+ `${body}\n\n` +
51
+ `<read-files>\n${read.join('\n')}\n</read-files>\n` +
52
+ `<modified-files>\n${modified.join('\n')}\n</modified-files>`,
53
+ details: { readFiles: read, modifiedFiles: modified }
54
+ }
55
+ }