@tduyng/pi-context 0.0.1 → 0.0.3
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/README.md +38 -70
- package/package.json +2 -2
- package/src/cache-state.ts +1 -0
- package/src/cached-compaction.ts +23 -19
- package/src/cached-request.ts +2 -10
package/README.md
CHANGED
|
@@ -1,112 +1,82 @@
|
|
|
1
1
|
# @tduyng/pi-context
|
|
2
2
|
|
|
3
|
-
See what fills the
|
|
3
|
+
See what fills the window, compact from the warm cache instead of a cold one, retire a finished turn, and read back what compaction dropped.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What it costs
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
`history` is a deferred tool at 134 tokens in the sessions that load it and nothing in the rest. `/context`, `/collapse`, and `/context-cache` are commands, so they cost nothing until typed. The dedupe handler is an event hook and adds no schema.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
Nothing here rewrites context between compactions. The panel, `history`, and the dedupe add no request bytes, and collapse and handoff act where a compaction boundary has already rewritten the prefix, so their work is paid for once.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
## /context
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
A TUI panel with a colored pixel map and a category list. Every consumer of the window gets a row: system prompt, tools, user messages, assistant text, tool results, bash executions, compacted history, extension entries, free space. Categories break down by tool or custom type, sorted by token weight, and children are capped at 30 shown with a `+N more` line carrying their combined count.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
The panel fits the window it opens in. Its body takes at most 70% of the rows, each line is truncated to the column width instead of wrapping, and a list too tall for the window keeps every category it can before spending the rest on child rows. A window too short says how many categories it hid. The map is hidden below six rows or 86 columns, so a small window keeps the numbers rather than a map nobody can read.
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
## Cached compaction
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
Compaction replays the conversation the provider already cached and appends one instruction, so the summary request reads almost all of its input from cache at a fraction of the price, and the summarizer sees full tool output rather than pi's 2,000-character head cuts.
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
The fallback is narrow on purpose. The branch is projected through pi's own entry conversion and the captured conversation must still be its prefix. A kept region that moved, a model change, a cold cache, a truncated reply, or a provider error all fall back to pi's own compaction. Nothing is cancelled: a compaction pi asked for always runs, on the cached path when it can and on pi's path otherwise.
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
The capture is the full transcript of the last request, including the leading system message that carries the prompt and the tool declarations, and the summary request replays it unchanged. That is what keeps the prefix cacheable, and the request adds no second system prompt. `/context-cache` shows the declared lifetime, the last refresh, and the warm capture. Idle cache warming is pi's own job, so this extension starts no compaction of its own.
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
Every decision lands in `~/.pi/agent/cached-compaction.jsonl` with the reason it took.
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
## history
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
Search or read earlier conversation that left the window: pruned results, compacted spans, or earlier sessions of the project.
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
`search` walks the current branch newest first. With `all: true` it searches every session file of the project, newest file first. Multi-word queries OR-match. Echoes (compaction summaries, verbatim input records, prior history results) rank below originals, so a copy never displaces what it copied. The default limit is 10, the maximum 50, and the ranking is lexical.
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
`read` returns one entry in full, sliced to fit the remaining context. Page size is min 2,000, max 20,000 characters, sized to leave room for the reply. `offset` pages through.
|
|
34
34
|
|
|
35
|
+
Session files are append-only JSONL, streamed line by line, so a large transcript never lands in memory.
|
|
35
36
|
|
|
36
|
-
|
|
37
|
+
## /collapse
|
|
37
38
|
|
|
38
|
-
`/collapse [turns]` replaces finished turns with one branch summary
|
|
39
|
+
`/collapse [turns]` replaces finished turns with one branch summary, one turn by default. The anchor is the entry before the oldest user prompt in the span, so nothing before the task moves.
|
|
39
40
|
|
|
40
|
-
The summary is built from the entries themselves. One walk collects the modified files, the files that were only read, the command count, the validation runs, and the failures with the command that failed. The last assistant text becomes the outcome. No model reads the span and no summary request goes out, which is where this differs from a compaction
|
|
41
|
+
The summary is built from the entries themselves. One walk collects the modified files, the files that were only read, the command count, the validation runs, and the failures with the command that failed. The last assistant text becomes the outcome. No model reads the span and no summary request goes out, which is where this differs from a compaction: it costs nothing and leaves the cache alone.
|
|
41
42
|
|
|
42
|
-
Two cases refuse. Nothing sits before the first prompt of a session, so there is no
|
|
43
|
+
Two cases refuse. Nothing sits before the first prompt of a session, so there is no anchor, and a prompt with no assistant turn after it is work in flight rather than a finished turn.
|
|
43
44
|
|
|
44
|
-
What it drops is real. The reasoning inside a turn goes, the summary keeps one text block, and a file list is capped at twelve with the remainder counted. The raw entries stay in the session file, so `history` recovers any of them and `/tree` walks back to them. The collapsed messages' usage rides along on the branch summary entry, which is where pi reads totals
|
|
45
|
+
What it drops is real. The reasoning inside a turn goes, the summary keeps one text block, and a file list is capped at twelve with the remainder counted. The raw entries stay in the session file, so `history` recovers any of them and `/tree` walks back to them. The collapsed messages' usage rides along on the branch summary entry, which is where pi reads totals, so `/cost` still counts what those turns cost.
|
|
45
46
|
|
|
46
47
|
A hidden `context-collapse` message carries the summary into the next turn and tells the model not to go looking for the raw turns. It starts no turn of its own.
|
|
47
48
|
|
|
48
49
|
Its reach is not measured yet. What decides whether it is worth running is how much front a finished task leaves behind, and `just retention` prints that for the read side of the same spans.
|
|
49
50
|
|
|
50
|
-
##
|
|
51
|
-
|
|
52
|
-
A `read` puts a file's bytes in the transcript. When an `edit` or `write` then lands on that path, those bytes are stale, and nothing in the transcript says so. Over the recorded sessions the frozen benchmark fixture holds 459 read results, of which 359 are marked by this rule, 66 percent of read bytes, 9.5 percent of all tool-result bytes.
|
|
53
|
-
|
|
54
|
-
Marking them in place costs more than it saves. Replacing a tool result in the `context` event rewrites the cached prefix from that point on, so the next request re-bills everything after it. On a 300k-token session that is one 200k to 300k re-bill against the 8KB the marker reclaims, and the rewrite recurs every time a later edit lands on a path already read. The rewrite was removed; `pi-context` now changes nothing the provider has already cached, and the spending on stale reads is left to compaction, which rewrites the whole prefix once and pays for it once.
|
|
55
|
-
|
|
56
|
-
The session file keeps the original bytes, so `history` still recovers them.
|
|
57
|
-
|
|
58
|
-
### Project context dedupe
|
|
51
|
+
## Compaction input record
|
|
59
52
|
|
|
60
|
-
|
|
53
|
+
After automatic compaction, not manual `/compact`, the extension appends a hidden custom message listing the compacted span's verbatim user inputs, `ask_question` answers, and visible custom messages. The cap is 20,000 characters, and the first ask, the latest ask, and the latest input always survive. The rest fill newest first, and anything omitted is recoverable with `history`.
|
|
61
54
|
|
|
62
|
-
##
|
|
63
|
-
|
|
64
|
-
Not wired, and the reason is worth keeping because it closes the question rather than deferring it.
|
|
65
|
-
|
|
66
|
-
A large tool result is rarely needed whole, so the idea was to build the small views of one: `full`, `head`, `tail`, `signals` (the error and failure lines) and `outline` (headings, definitions and `path:line` references), each built from the result's own lines with no model, and to ask the local model which one to keep.
|
|
67
|
-
|
|
68
|
-
It used to run, on a `tool_execution_end` handler over every result past 2,000 characters. It cost 839 calls and 740,402 tokens, 43 percent of everything this suite sent to the local model, and it changed nothing: the handler returned no hint, nothing was rewritten, and the log entry held a `stateHash` rather than the result, so nothing could be scored from it afterwards. Asking the model directly does not work either, and returns near-uniform distributions over the five views.
|
|
69
|
-
|
|
70
|
-
The offline fallback scored each view against the lines a later message actually quoted from the result. That label cannot fire. Across 1,045 replayed cases, not one tool-result line was ever quoted verbatim, at every bar from 4 to 80 characters. The model reads a result and acts on it rather than repeating it, so there is no signal in these sessions to score a view against, and no comparison can say which view is safe.
|
|
55
|
+
## Reads a later edit made wrong
|
|
71
56
|
|
|
72
|
-
|
|
57
|
+
A `read` puts a file's bytes in the transcript. When an `edit` or `write` then lands on that path, those bytes are stale, and nothing in the transcript says so. Over the recorded sessions the frozen benchmark fixture holds 459 read results, of which 359 are marked by this rule: 66 percent of read bytes, 9.5 percent of all tool-result bytes.
|
|
73
58
|
|
|
74
|
-
|
|
59
|
+
Marking them in place costs more than it saves. Replacing a tool result in the `context` event rewrites the cached prefix from that point on, so the next request re-bills everything after it. On a 300k-token session that is one 200k to 300k re-bill against the 8 KB the marker reclaims, and the rewrite recurs every time a later edit lands on a path already read. The rewrite was removed: `pi-context` now changes nothing the provider has already cached, and the spending on stale reads is left to compaction, which rewrites the whole prefix once and pays for it once.
|
|
75
60
|
|
|
76
|
-
|
|
77
|
-
the ratio: its context climbed to 48 percent, dropped to 7, climbed to 53, and dropped again.
|
|
78
|
-
Both drops are pi's own compaction, `fromHook: False`, and the second reclaimed about 458,000
|
|
79
|
-
tokens in the event.
|
|
61
|
+
The session file keeps the original bytes, so `history` still recovers them.
|
|
80
62
|
|
|
81
|
-
|
|
82
|
-
add no request bytes, and collapse and handoff act where a compaction boundary has already
|
|
83
|
-
rewritten the prefix, so their work is paid for once.
|
|
63
|
+
## Project context dedupe
|
|
84
64
|
|
|
85
|
-
|
|
65
|
+
When the agent dir and the working directory resolve to the same file through different paths, from hardlinks, bind mounts, or a symlinked ancestor, pi adds the same `<project_instructions>` block twice. A `before_agent_start` handler strips the second copy by content, so two identical blocks are always redundant regardless of path, and blocks with different content stay.
|
|
86
66
|
|
|
87
|
-
|
|
88
|
-
work in flight still needs, and it had one live record in the whole history. Pi opens a
|
|
89
|
-
threshold compaction at `contextWindow - reserveTokens`, a line at 1,032,192 tokens for this
|
|
90
|
-
1,048,576-token model, against a session request that carries about 217,000, so the hook
|
|
91
|
-
cannot fire at the mean context. Its label would be the compaction that happened anyway, and
|
|
92
|
-
with no deferral there is no negative class, so no cut could be fitted. Three hand-written
|
|
93
|
-
probes said the answer looked right, and three probes are not a corpus.
|
|
67
|
+
## What reclaims context, measured
|
|
94
68
|
|
|
95
|
-
|
|
96
|
-
searches it promoted twice, and the same shape on file selection read 0.612 to 0.659 AUC
|
|
97
|
-
against a lexical floor at 0.707, so the ranking it would have reordered was already the
|
|
98
|
-
better one. Both are deleted rather than left waiting for a corpus that has not arrived.
|
|
69
|
+
Over 358 recorded sessions, pi's own compaction does the work. One session is enough to show the ratio: its context climbed to 48 percent, dropped to 7, climbed to 53, and dropped again. Both drops are pi's own compaction, `fromHook: False`, and the second reclaimed about 458,000 tokens.
|
|
99
70
|
|
|
100
|
-
|
|
71
|
+
Two local model gates are gone from this package, both because nothing measured them. The compaction deferral had one live record in the whole history: pi opens a threshold compaction at `contextWindow - reserveTokens`, a line at 1,032,192 tokens for a 1,048,576-token model, against a session request carrying about 217,000, so the hook could not fire at the mean context. The history rerank promoted twice in 17 searches, and the same shape on file selection read 0.612 to 0.659 AUC against a lexical floor at 0.707, so the ranking it would have reordered was already the better one.
|
|
101
72
|
|
|
102
|
-
|
|
73
|
+
### Tool-result views, and why they are not here
|
|
103
74
|
|
|
104
|
-
|
|
75
|
+
This one is kept because it closes the question rather than deferring it.
|
|
105
76
|
|
|
106
|
-
|
|
77
|
+
A large tool result is rarely needed whole, so the idea was to build small views of one (`full`, `head`, `tail`, `signals`, `outline`) and ask the local model which to keep. It ran on a `tool_execution_end` handler over every result past 2,000 characters, cost 839 calls and 740,402 tokens, 43 percent of everything this suite sent to the local model, and changed nothing: the handler returned no hint, nothing was rewritten, and the log entry held a `stateHash` rather than the result, so nothing could be scored afterwards. Asking the model directly returns near-uniform distributions over the five views.
|
|
107
78
|
|
|
108
|
-
|
|
109
|
-
- Session files are JSONL, append-only. `history` streams them line by line so large transcripts never land in memory.
|
|
79
|
+
The offline fallback scored each view against the lines a later message quoted from the result, and that label cannot fire: across 1,045 replayed cases, not one tool-result line was ever quoted verbatim, at every bar from 4 to 80 characters. The model reads a result and acts on it rather than repeating it, so these sessions hold no signal to score a view against. The views and the benchmark are deleted.
|
|
110
80
|
|
|
111
81
|
## Install
|
|
112
82
|
|
|
@@ -116,6 +86,4 @@ pi install npm:@tduyng/pi-context
|
|
|
116
86
|
|
|
117
87
|
Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-context`
|
|
118
88
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
None.
|
|
89
|
+
No configuration.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tduyng/pi-context",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.0.3",
|
|
4
|
+
"description": "See what fills the context window, compact from the warm prompt cache, collapse a finished turn, and recover what compaction dropped.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi",
|
|
7
7
|
"pi-package",
|
package/src/cache-state.ts
CHANGED
|
@@ -54,6 +54,7 @@ function refreshAt(entry: SessionEntry): number | null {
|
|
|
54
54
|
}
|
|
55
55
|
if (entry.type !== 'message') return null
|
|
56
56
|
if (entry.message.role !== 'assistant') return null
|
|
57
|
+
if (entry.message.stopReason === 'error' || entry.message.stopReason === 'aborted') return null
|
|
57
58
|
const usage = entry.message.usage
|
|
58
59
|
const read = usage.cacheRead ?? 0
|
|
59
60
|
const write = usage.cacheWrite ?? 0
|
package/src/cached-compaction.ts
CHANGED
|
@@ -14,14 +14,23 @@ import {
|
|
|
14
14
|
import type { Usage } from '@earendil-works/pi-ai'
|
|
15
15
|
|
|
16
16
|
import { cacheState, formatExpiry, resolveRetention } from './cache-state.js'
|
|
17
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
assistantText,
|
|
19
|
+
forget,
|
|
20
|
+
keptStartQuote,
|
|
21
|
+
summaryInstruction,
|
|
22
|
+
walkTrailing,
|
|
23
|
+
type CapturedRequest,
|
|
24
|
+
type CaptureStore,
|
|
25
|
+
type TranscriptMessage
|
|
26
|
+
} from './cached-request.js'
|
|
18
27
|
import { checkpointSummary } from './summary.js'
|
|
19
28
|
|
|
20
29
|
const MAX_TOKENS_SHARE = 0.8
|
|
21
30
|
|
|
22
31
|
type Preparation = SessionBeforeCompactEvent['preparation']
|
|
23
32
|
|
|
24
|
-
type Decision = { kind: 'cached'; summary: string; tokens: number } | { kind: 'fallback'; reason: string }
|
|
33
|
+
type Decision = { kind: 'cached'; summary: string; tokens: number; usage: Usage } | { kind: 'fallback'; reason: string }
|
|
25
34
|
|
|
26
35
|
function traceFile(): string {
|
|
27
36
|
return join(getAgentDir(), 'cached-compaction.jsonl')
|
|
@@ -51,11 +60,15 @@ async function cachedSummary(
|
|
|
51
60
|
const keptStart = keptStartQuote(event.branchEntries, event.preparation.firstKeptEntryId)
|
|
52
61
|
const instruction = summaryInstruction(keptStart, event.customInstructions)
|
|
53
62
|
const transcript = convertToLlm([...captured.transcript, ...trailing, instruction])
|
|
54
|
-
const stream = ctx.modelRegistry.streamSimple(
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
const stream = ctx.modelRegistry.streamSimple(
|
|
64
|
+
captured.model,
|
|
65
|
+
{ messages: transcript },
|
|
66
|
+
{
|
|
67
|
+
maxTokens: Math.min(Math.floor(event.preparation.settings.reserveTokens * MAX_TOKENS_SHARE), 32_000),
|
|
68
|
+
sessionId: ctx.sessionManager.getSessionId(),
|
|
69
|
+
cacheRetention: resolveRetention(process.env)
|
|
70
|
+
}
|
|
71
|
+
)
|
|
59
72
|
for await (const _ of stream) {
|
|
60
73
|
// drain: the final assistant message carries text, usage and the stop reason
|
|
61
74
|
}
|
|
@@ -65,7 +78,7 @@ async function cachedSummary(
|
|
|
65
78
|
}
|
|
66
79
|
const summary = assistantText(assistant)
|
|
67
80
|
if (summary.trim() === '') return { kind: 'fallback', reason: 'the summary came back empty' }
|
|
68
|
-
return { kind: 'cached', summary, tokens: assistant.usage.input }
|
|
81
|
+
return { kind: 'cached', summary, tokens: assistant.usage.input, usage: assistant.usage }
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
function decideFallback(reason: string): SessionBeforeCompactResult {
|
|
@@ -94,7 +107,6 @@ let pendingTranscript: TranscriptMessage[] = []
|
|
|
94
107
|
|
|
95
108
|
export default function cachedCompactionExtension(pi: ExtensionAPI): void {
|
|
96
109
|
const store: CaptureStore = { captured: null }
|
|
97
|
-
let retentionApplied = false
|
|
98
110
|
|
|
99
111
|
pi.on('context_with_system', (event) => {
|
|
100
112
|
pendingTranscript = event.messages
|
|
@@ -109,10 +121,6 @@ export default function cachedCompactionExtension(pi: ExtensionAPI): void {
|
|
|
109
121
|
at: Date.now()
|
|
110
122
|
}
|
|
111
123
|
}
|
|
112
|
-
if (!retentionApplied && ctx.hasUI && process.env['PI_CACHE_RETENTION'] === undefined) {
|
|
113
|
-
retentionApplied = true
|
|
114
|
-
process.env['PI_CACHE_RETENTION'] = 'long'
|
|
115
|
-
}
|
|
116
124
|
})
|
|
117
125
|
|
|
118
126
|
pi.on('model_select', () => {
|
|
@@ -140,13 +148,9 @@ export default function cachedCompactionExtension(pi: ExtensionAPI): void {
|
|
|
140
148
|
const decision = await cachedSummary(captured, event, ctx)
|
|
141
149
|
if (decision.kind === 'fallback') return decideFallback(decision.reason)
|
|
142
150
|
const previous = getLatestCompactionEntry(event.branchEntries)
|
|
143
|
-
const prepared = checkpointSummary(
|
|
144
|
-
decision.summary,
|
|
145
|
-
event.preparation.fileOps,
|
|
146
|
-
previous?.details
|
|
147
|
-
)
|
|
151
|
+
const prepared = checkpointSummary(decision.summary, event.preparation.fileOps, previous?.details)
|
|
148
152
|
trace({ decision: 'cached', tokens: decision.tokens })
|
|
149
|
-
return runSummary(event.preparation, prepared.summary, prepared.details,
|
|
153
|
+
return runSummary(event.preparation, prepared.summary, prepared.details, decision.usage)
|
|
150
154
|
} catch (error) {
|
|
151
155
|
const reason = error instanceof Error ? error.message : String(error)
|
|
152
156
|
return decideFallback(reason)
|
package/src/cached-request.ts
CHANGED
|
@@ -81,15 +81,13 @@ export function walkTrailing(captured: CapturedRequest, branchEntries: SessionEn
|
|
|
81
81
|
|
|
82
82
|
export interface KeptStart {
|
|
83
83
|
text: string
|
|
84
|
-
repeats: number
|
|
85
84
|
}
|
|
86
85
|
|
|
87
86
|
const QUOTE_CHARS = 120
|
|
88
87
|
|
|
89
88
|
/**
|
|
90
89
|
* The opening of the first message pi keeps verbatim, quoted back to the summarizer. The
|
|
91
|
-
* quote marks where the kept window begins.
|
|
92
|
-
* so the quote names the last of them.
|
|
90
|
+
* quote marks where the kept window begins.
|
|
93
91
|
*/
|
|
94
92
|
export function keptStartQuote(branchEntries: SessionEntry[], firstKeptEntryId: string): KeptStart | null {
|
|
95
93
|
const start = branchEntries.findIndex((entry) => entry.id === firstKeptEntryId)
|
|
@@ -100,12 +98,7 @@ export function keptStartQuote(branchEntries: SessionEntry[], firstKeptEntryId:
|
|
|
100
98
|
.filter((text) => text !== '')
|
|
101
99
|
const first = quotes[0]
|
|
102
100
|
if (first === undefined) return null
|
|
103
|
-
|
|
104
|
-
let repeats = 0
|
|
105
|
-
for (const quote of quotes) {
|
|
106
|
-
if (quote.startsWith(text)) repeats++
|
|
107
|
-
}
|
|
108
|
-
return { text, repeats }
|
|
101
|
+
return { text: first.slice(0, QUOTE_CHARS) }
|
|
109
102
|
}
|
|
110
103
|
|
|
111
104
|
/**
|
|
@@ -118,7 +111,6 @@ export function summaryInstruction(keptStart: KeptStart | null, customFocus: str
|
|
|
118
111
|
keptStart === null
|
|
119
112
|
? 'Summarize the conversation so far.'
|
|
120
113
|
: `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
114
|
'Do not include anything after the boundary.'
|
|
123
115
|
const message: BranchMessage = {
|
|
124
116
|
role: 'user',
|