@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 +178 -0
- package/README.md +120 -2
- package/package.json +48 -4
- package/src/cache-state.ts +100 -0
- package/src/cached-compaction.ts +170 -0
- package/src/cached-request.ts +143 -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/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
|
-
#
|
|
1
|
+
# @tduyng/pi-context
|
|
2
2
|
|
|
3
|
-
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|