@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
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
|
+
}
|