@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.
package/LICENSE ADDED
@@ -0,0 +1,178 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ Use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ Copyright 2026 tduyng
package/README.md CHANGED
@@ -1,3 +1,121 @@
1
- # Temporary Holding Version
1
+ # @tduyng/pi-context
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ See what fills the context window, summarize it from the warm prompt cache, retire a finished task, and recover what compaction dropped.
4
+
5
+ ## Commands and tools
6
+
7
+ ### /context
8
+
9
+ Opens a TUI panel with a colored pixel map and a dotted-leader category list. Every context consumer 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. Child items within a category are capped at 30 shown. The rest are summarized as a "+N more" line with their combined token count.
10
+
11
+ The panel fits the window it opens in. Its body takes at most 70% of the terminal 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 for all categories says how many it hid. The map is hidden when the window is shorter than the map's six rows or narrower than 86 columns, so a small window keeps the numbers instead of a map no one can read. The pixel map uses proportional colored squares across 24 columns.
12
+
13
+ ### Cached compaction
14
+
15
+ 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 the full tool output instead of pi's 2,000-character head cuts.
16
+
17
+ The fallback. 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.
18
+
19
+ 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 state now: the declared lifetime, the last refresh and the warm capture. Idle cache warming is pi's own job (`cacheWarming` in settings), so this extension starts no compaction of its own.
20
+
21
+ Every decision lands in `~/.pi/agent/cached-compaction.jsonl` with the reason it took.
22
+
23
+ ### history
24
+
25
+ Search or read earlier conversation that left the context window: pruned results, compacted spans, or earlier sessions of the project.
26
+
27
+ `search` walks the current branch newest-first. With `all: true`, it searches every `.jsonl` 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. Default limit is 10, max 50. The ranking is lexical: a model rerank of the top eight was deleted, see the removal note below.
28
+
29
+ `read` returns one entry in full, sliced to fit the remaining context. Page size: min 2,000, max 20,000 chars, sized to leave room for the model's reply. Pass `offset` for pagination.
30
+
31
+ ### Compaction input record
32
+
33
+ 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. Total cap: 20,000 chars. The first ask, the latest ask, and the latest input always survive. The rest fill newest-first. Anything omitted is recoverable with the `history` tool.
34
+
35
+
36
+ ### /collapse
37
+
38
+ `/collapse [turns]` replaces finished turns with one branch summary. The default is one turn. The anchor is the entry before the oldest user prompt in the span, so nothing before the task moves.
39
+
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. This one costs nothing and it leaves the cache alone.
41
+
42
+ Two cases refuse. Nothing sits before the first prompt of a session, so there is no entry to anchor to, and a prompt with no assistant turn after it is work in flight rather than a finished turn.
43
+
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 from, so `/cost` still counts what those turns cost.
45
+
46
+ 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
+ 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
+ ## Reads a later edit made wrong
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
59
+
60
+ When the agent dir and the working directory resolve to the same file through different paths (hardlinks, bind mounts, a symlinked ancestor), pi adds the same `<project_instructions>` block twice. A `before_agent_start` handler strips the second copy, matching on content, so two identical blocks are always redundant regardless of path. Blocks with different content stay.
61
+
62
+ ## Tool-result views
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.
71
+
72
+ The views and the benchmark are deleted. This section is what is left of them, so the next person does not build it again.
73
+
74
+ ## What actually reclaims context, measured
75
+
76
+ Over 358 recorded sessions, pi's own compaction does the work. One session is enough to show
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.
80
+
81
+ Nothing in this package rewrites context between compactions. The panel, `history` and dedupe
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.
84
+
85
+ Two local model gates are gone from this package, both because nothing measured them.
86
+
87
+ The compaction deferral asked whether summarizing the older messages would lose something the
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.
94
+
95
+ The history rerank asked which of the top eight search hits answers the question. Over 17
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.
99
+
100
+ ## Compaction timing
101
+
102
+ Pi compacts at `contextWindow - compaction.reserveTokens`. Fixed line. It summarizes whole spans whether or not the work in flight is finished, and a half-applied edit survives that summary by luck.
103
+
104
+ The deferral that used to sit here asked the model at that moment whether the summary would lose something the remaining work still needs, and let the space be reclaimed in place instead. It is gone, for the reasons above. What is left is pi's own timing, and cached compaction, which pays for a rewrite once instead of once per edit.
105
+
106
+ ## Design
107
+
108
+ - `history` registers and unregisters dynamically, so the tool appears only when there is an earlier transcript to search.
109
+ - Session files are JSONL, append-only. `history` streams them line by line so large transcripts never land in memory.
110
+
111
+ ## Install
112
+
113
+ ```bash
114
+ pi install npm:@tduyng/pi-context
115
+ ```
116
+
117
+ Local checkout: `pi install /path/to/dotfiles/pi/agent/extensions/pi-context`
118
+
119
+ ## Configure
120
+
121
+ None.
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "@tduyng/pi-context",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.0.1",
4
+ "description": "Context-window suite: see what fills it, prune spent tool results, retire reads an edit made wrong, and recover what compaction dropped.",
5
+ "keywords": [
6
+ "pi",
7
+ "pi-package",
8
+ "pi-extension",
9
+ "pi-coding-agent",
10
+ "context",
11
+ "compaction",
12
+ "tokens"
13
+ ],
14
+ "homepage": "https://gitlab.com/tduyng/pi-agent/-/tree/main/extensions/pi-context#readme",
15
+ "bugs": "https://gitlab.com/tduyng/pi-agent/-/issues",
16
+ "license": "Apache-2.0",
17
+ "author": "tduyng",
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://gitlab.com/tduyng/pi-agent.git",
21
+ "directory": "extensions/pi-context"
22
+ },
23
+ "type": "module",
24
+ "files": [
25
+ "src",
26
+ "!src/**/*.test.ts",
27
+ "README.md",
28
+ "LICENSE"
29
+ ],
30
+ "peerDependencies": {
31
+ "@earendil-works/pi-coding-agent": "*",
32
+ "@earendil-works/pi-tui": "*"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "pi": {
38
+ "extensions": [
39
+ "./src/context.ts",
40
+ "./src/history.ts",
41
+ "./src/compaction-handoff.ts",
42
+ "./src/context-dedupe.ts",
43
+ "./src/collapse.ts",
44
+ "./src/cached-compaction.ts"
45
+ ]
46
+ },
47
+ "dependencies": {
48
+ "ata-validator": "^1.62.1"
49
+ }
50
+ }
@@ -0,0 +1,100 @@
1
+ import { Validator } from 'ata-validator'
2
+ import { t } from 'ata-validator/t'
3
+
4
+ import type { SessionEntry } from '@earendil-works/pi-coding-agent'
5
+ import type { Api, Model } from '@earendil-works/pi-ai'
6
+
7
+ const PromptCacheSchema = t.object({
8
+ long: t.optional(t.number()),
9
+ short: t.optional(t.number())
10
+ })
11
+
12
+ const ModelCacheSchema = t.object({
13
+ promptCache: t.optional(PromptCacheSchema)
14
+ })
15
+ const isModelCacheSchema = new Validator(ModelCacheSchema).isValidObject
16
+
17
+ export type Retention = 'short' | 'long'
18
+
19
+ export function resolveRetention(env: Record<string, string | undefined>): Retention {
20
+ return env['PI_CACHE_RETENTION'] === 'long' ? 'long' : 'short'
21
+ }
22
+
23
+ /**
24
+ * Lifetime of the cache entry a request writes, from the tier the model declares for the
25
+ * retention in force. Null when the model declares no lifetime: a countdown would be a
26
+ * guess, so none is shown and no expiry compaction is armed.
27
+ */
28
+ export function promptCacheTtlMs(model: Model<Api> | undefined, retention: Retention): number | null {
29
+ if (!isModelCacheSchema(model)) return null
30
+ const seconds = retention === 'long' ? model.promptCache?.long : model.promptCache?.short
31
+ return seconds === undefined ? null : seconds * 1000
32
+ }
33
+
34
+ export interface CacheState {
35
+ /** When the warm entry dies, or null with no declared lifetime. */
36
+ expiresAt: number | null
37
+ /** Last activity that refreshed the entry: a cached reply or a warm replay. */
38
+ lastRefreshAt: number | null
39
+ /** The last reply itself was served from cache. */
40
+ replyIsWarm: boolean
41
+ lifetimeMs: number | null
42
+ }
43
+
44
+ const WarmEntrySchema = t.object({
45
+ type: t.literal('usage'),
46
+ kind: t.string(),
47
+ timestamp: t.string()
48
+ })
49
+ const isWarmEntrySchema = new Validator(WarmEntrySchema).isValidObject
50
+
51
+ function refreshAt(entry: SessionEntry): number | null {
52
+ if (isWarmEntrySchema(entry)) {
53
+ return entry.kind === 'cache_warm' ? Date.parse(entry.timestamp) : null
54
+ }
55
+ if (entry.type !== 'message') return null
56
+ if (entry.message.role !== 'assistant') return null
57
+ const usage = entry.message.usage
58
+ const read = usage.cacheRead ?? 0
59
+ const write = usage.cacheWrite ?? 0
60
+ const at = Date.parse(entry.timestamp)
61
+ return read + write > 0 ? at : null
62
+ }
63
+
64
+ /**
65
+ * The age of the warm entry, read from what actually touched the cache: a reply the
66
+ * provider served from cache, or a warm replay pi recorded. Error and aborted replies do
67
+ * not count; a failed request may never have reached the cache.
68
+ */
69
+ export function cacheState(entries: SessionEntry[], model: Model<Api> | undefined, retention: Retention): CacheState {
70
+ const lifetimeMs = promptCacheTtlMs(model, retention)
71
+ let lastRefreshAt: number | null = null
72
+ let replyIsWarm = false
73
+ for (let index = entries.length - 1; index >= 0; index--) {
74
+ const entry = entries[index]
75
+ if (entry === undefined) break
76
+ const at = refreshAt(entry)
77
+ if (at === null) continue
78
+ if (lastRefreshAt === null) {
79
+ lastRefreshAt = at
80
+ replyIsWarm = entry.type === 'message'
81
+ }
82
+ if (entry.type === 'message') break
83
+ }
84
+ if (lastRefreshAt === null) return { expiresAt: null, lastRefreshAt: null, replyIsWarm: false, lifetimeMs }
85
+ return {
86
+ expiresAt: lifetimeMs === null ? null : lastRefreshAt + lifetimeMs,
87
+ lastRefreshAt,
88
+ replyIsWarm,
89
+ lifetimeMs
90
+ }
91
+ }
92
+
93
+ export function formatExpiry(expiresAt: number | null, now: number): string {
94
+ if (expiresAt === null) return ''
95
+ const remainingMs = expiresAt - now
96
+ if (remainingMs <= 0) return 'cache cold'
97
+ const totalSeconds = Math.ceil(remainingMs / 1000)
98
+ if (totalSeconds >= 60) return `${Math.floor(totalSeconds / 60)}m`
99
+ return `${totalSeconds}s`
100
+ }
@@ -0,0 +1,170 @@
1
+ import { appendFileSync, mkdirSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+
4
+ import {
5
+ convertToLlm,
6
+ getAgentDir,
7
+ getLatestCompactionEntry,
8
+ type CompactionEntry,
9
+ type ExtensionAPI,
10
+ type ExtensionContext,
11
+ type SessionBeforeCompactEvent,
12
+ type SessionBeforeCompactResult
13
+ } from '@earendil-works/pi-coding-agent'
14
+ import type { Usage } from '@earendil-works/pi-ai'
15
+
16
+ import { cacheState, formatExpiry, resolveRetention } from './cache-state.js'
17
+ import { assistantText, forget, keptStartQuote, summaryInstruction, walkTrailing, type CapturedRequest, type CaptureStore, type TranscriptMessage } from './cached-request.js'
18
+ import { checkpointSummary } from './summary.js'
19
+
20
+ const MAX_TOKENS_SHARE = 0.8
21
+
22
+ type Preparation = SessionBeforeCompactEvent['preparation']
23
+
24
+ type Decision = { kind: 'cached'; summary: string; tokens: number } | { kind: 'fallback'; reason: string }
25
+
26
+ function traceFile(): string {
27
+ return join(getAgentDir(), 'cached-compaction.jsonl')
28
+ }
29
+
30
+ type TraceRecord = { decision: 'cached' | 'pi'; reason?: string; tokens?: number }
31
+
32
+ function trace(record: TraceRecord): void {
33
+ mkdirSync(getAgentDir(), { recursive: true })
34
+ appendFileSync(traceFile(), `${JSON.stringify({ at: Date.now(), ...record })}\n`, 'utf8')
35
+ }
36
+
37
+ function sameModel(a: CapturedRequest, b: { id?: string; provider?: string } | undefined): boolean {
38
+ if (b === undefined) return false
39
+ return a.model.id === b.id && a.model.provider === b.provider
40
+ }
41
+
42
+ async function cachedSummary(
43
+ captured: CapturedRequest,
44
+ event: SessionBeforeCompactEvent,
45
+ ctx: ExtensionContext
46
+ ): Promise<Decision> {
47
+ const trailing = walkTrailing(captured, event.branchEntries)
48
+ if (trailing === null) {
49
+ return { kind: 'fallback', reason: 'the branch changed shape since the last request' }
50
+ }
51
+ const keptStart = keptStartQuote(event.branchEntries, event.preparation.firstKeptEntryId)
52
+ const instruction = summaryInstruction(keptStart, event.customInstructions)
53
+ const transcript = convertToLlm([...captured.transcript, ...trailing, instruction])
54
+ const stream = ctx.modelRegistry.streamSimple(captured.model, { messages: transcript }, {
55
+ maxTokens: Math.min(Math.floor(event.preparation.settings.reserveTokens * MAX_TOKENS_SHARE), 32_000),
56
+ sessionId: ctx.sessionManager.getSessionId(),
57
+ cacheRetention: resolveRetention(process.env)
58
+ })
59
+ for await (const _ of stream) {
60
+ // drain: the final assistant message carries text, usage and the stop reason
61
+ }
62
+ const assistant = await stream.result()
63
+ if (assistant.stopReason !== 'stop') {
64
+ return { kind: 'fallback', reason: `the summary stopped on ${assistant.stopReason}` }
65
+ }
66
+ const summary = assistantText(assistant)
67
+ if (summary.trim() === '') return { kind: 'fallback', reason: 'the summary came back empty' }
68
+ return { kind: 'cached', summary, tokens: assistant.usage.input }
69
+ }
70
+
71
+ function decideFallback(reason: string): SessionBeforeCompactResult {
72
+ trace({ decision: 'pi', reason })
73
+ return {}
74
+ }
75
+
76
+ function runSummary(
77
+ preparation: Preparation,
78
+ summary: string,
79
+ details: CompactionEntry['details'],
80
+ usage: Usage | undefined
81
+ ): SessionBeforeCompactResult {
82
+ return {
83
+ compaction: {
84
+ summary,
85
+ firstKeptEntryId: preparation.firstKeptEntryId,
86
+ tokensBefore: preparation.tokensBefore,
87
+ usage,
88
+ details
89
+ }
90
+ }
91
+ }
92
+
93
+ let pendingTranscript: TranscriptMessage[] = []
94
+
95
+ export default function cachedCompactionExtension(pi: ExtensionAPI): void {
96
+ const store: CaptureStore = { captured: null }
97
+ let retentionApplied = false
98
+
99
+ pi.on('context_with_system', (event) => {
100
+ pendingTranscript = event.messages
101
+ })
102
+
103
+ pi.on('before_provider_request', (_event, ctx) => {
104
+ const model = ctx.model
105
+ if (model !== undefined) {
106
+ store.captured = {
107
+ transcript: pendingTranscript,
108
+ model,
109
+ at: Date.now()
110
+ }
111
+ }
112
+ if (!retentionApplied && ctx.hasUI && process.env['PI_CACHE_RETENTION'] === undefined) {
113
+ retentionApplied = true
114
+ process.env['PI_CACHE_RETENTION'] = 'long'
115
+ }
116
+ })
117
+
118
+ pi.on('model_select', () => {
119
+ forget(store)
120
+ })
121
+
122
+ pi.on('session_tree', () => {
123
+ forget(store)
124
+ })
125
+
126
+ pi.on('session_before_compact', async (event, ctx) => {
127
+ const captured = store.captured
128
+ if (captured === null || !sameModel(captured, ctx.model)) {
129
+ return decideFallback('no warm capture of this model')
130
+ }
131
+ const now = Date.now()
132
+ const state = cacheState(event.branchEntries, ctx.model, resolveRetention(process.env))
133
+ if (state.lastRefreshAt === null) {
134
+ return decideFallback('the cache holds no warm reply')
135
+ }
136
+ if (state.expiresAt !== null && state.expiresAt <= now) {
137
+ return decideFallback('the warm entry is cold')
138
+ }
139
+ try {
140
+ const decision = await cachedSummary(captured, event, ctx)
141
+ if (decision.kind === 'fallback') return decideFallback(decision.reason)
142
+ const previous = getLatestCompactionEntry(event.branchEntries)
143
+ const prepared = checkpointSummary(
144
+ decision.summary,
145
+ event.preparation.fileOps,
146
+ previous?.details
147
+ )
148
+ trace({ decision: 'cached', tokens: decision.tokens })
149
+ return runSummary(event.preparation, prepared.summary, prepared.details, undefined)
150
+ } catch (error) {
151
+ const reason = error instanceof Error ? error.message : String(error)
152
+ return decideFallback(reason)
153
+ }
154
+ })
155
+
156
+ pi.registerCommand('context-cache', {
157
+ description: 'The warm cache this session holds and what the next compaction would cost',
158
+ handler: async (_args, ctx) => {
159
+ const model = ctx.model
160
+ const state = cacheState(ctx.sessionManager.getEntries(), model, resolveRetention(process.env))
161
+ const lines = [
162
+ `cache: ${formatExpiry(state.expiresAt, Date.now()) || 'no declared lifetime, no countdown'}`,
163
+ `lifetime: ${state.lifetimeMs === null ? 'undeclared' : `${Math.round(state.lifetimeMs / 60_000)}m`}`,
164
+ `last refresh: ${state.lastRefreshAt === null ? 'none on record' : new Date(state.lastRefreshAt).toISOString()}`,
165
+ `warm capture: ${store.captured === null ? 'none' : new Date(store.captured.at).toISOString()}`
166
+ ]
167
+ ctx.ui.notify(lines.join('\n'), 'info')
168
+ }
169
+ })
170
+ }