pi-shake 0.0.0-stage → 0.1.0
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 +21 -0
- package/README.md +79 -2
- package/docs/explanation.md +210 -0
- package/index.ts +6 -0
- package/package.json +20 -4
- package/src/cli.ts +358 -0
- package/src/constants.ts +22 -0
- package/src/plans.ts +267 -0
- package/src/scanner.ts +163 -0
- package/src/settings.ts +90 -0
- package/src/types.ts +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 K2
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,80 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pi-shake, the `/shake` extension for Pi
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> Fold heavy context out of the model's view; undo restores every byte.
|
|
4
|
+
|
|
5
|
+
`/shake` shortens old tool output, fenced code blocks, top-level XML blocks,
|
|
6
|
+
images, and assistant reasoning into brief placeholders in the model's
|
|
7
|
+
context. Everything stays in the session file: fold works through pi's
|
|
8
|
+
`context_edit` overlays, so nothing is ever deleted and fold is always
|
|
9
|
+
reversible.
|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- One command family for the three kinds of bulk: `elide` (default), `images`,
|
|
14
|
+
`thinking`
|
|
15
|
+
- Undo restores everything that was folded
|
|
16
|
+
- Dry-run previews a fold without applying it
|
|
17
|
+
- A protected recent tail keeps the working set intact; three profiles tune it
|
|
18
|
+
|
|
19
|
+
## Quick start
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
just install # copies the extension into ~/.pi/agent/extensions/shake/
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Run `/reload` in a session (or restart Pi), then:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
/shake # fold old tool results and fenced/XML blocks
|
|
29
|
+
/shake status # what is folded, profile, context usage
|
|
30
|
+
/shake undo # restore everything
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Installation via the pi package manager and from git is planned.
|
|
34
|
+
|
|
35
|
+
## Usage
|
|
36
|
+
|
|
37
|
+
| Command | What it does |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `/shake` | Fold tool results and fenced/XML blocks (also written `/shake elide`) |
|
|
40
|
+
| `/shake images` | Remove all images from the session |
|
|
41
|
+
| `/shake thinking` | Remove all assistant reasoning blocks |
|
|
42
|
+
| `/shake dry-run [elide\|thinking\|images]` | Preview what that scope would do, without doing it |
|
|
43
|
+
| `/shake undo` | Restore everything |
|
|
44
|
+
| `/shake profile` | Choose a profile: default / conservative / aggressive |
|
|
45
|
+
| `/shake status` | Current fold count, profile, context usage, last run |
|
|
46
|
+
|
|
47
|
+
Fenced blocks are the sections between three backticks or three tildes
|
|
48
|
+
(```` ``` ```` / `~~~`); XML blocks are top-level spans such as
|
|
49
|
+
`<output>...</output>`.
|
|
50
|
+
|
|
51
|
+
## Safety
|
|
52
|
+
|
|
53
|
+
- Does not run while the agent is streaming.
|
|
54
|
+
- The most recent ~4,000 tokens stay untouched (2,000 with `aggressive`,
|
|
55
|
+
8,000 with `conservative`).
|
|
56
|
+
- Already-folded entries are left alone.
|
|
57
|
+
- Entries summarized away by `/compact` are left alone.
|
|
58
|
+
- Tool results from `skill` tools are left alone.
|
|
59
|
+
|
|
60
|
+
## Profiles
|
|
61
|
+
|
|
62
|
+
The profile steers elide's two thresholds: the protected tail and the minimum
|
|
63
|
+
size of a fenced/XML block worth folding. `images` and `thinking` are not
|
|
64
|
+
affected.
|
|
65
|
+
|
|
66
|
+
| Profile | Protected tail | Block minimum |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `default` | 4,000 tokens | ~400 tokens |
|
|
69
|
+
| `conservative` | 8,000 tokens | ~800 tokens |
|
|
70
|
+
| `aggressive` | 2,000 tokens | ~200 tokens |
|
|
71
|
+
|
|
72
|
+
Set it with `/shake profile` (writes `shake.profile` to `.pi/settings.json`
|
|
73
|
+
if present, otherwise `~/.pi/agent/settings.json`) or by editing that file.
|
|
74
|
+
Resolution: project settings > agent-directory settings > `default`.
|
|
75
|
+
|
|
76
|
+
## Design
|
|
77
|
+
|
|
78
|
+
The reasoning behind the design (overlays, thresholds, compaction ordering,
|
|
79
|
+
caching, and fork/rewind behavior) is in
|
|
80
|
+
[How `/shake` works](docs/explanation.md).
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# How `/shake` works
|
|
2
|
+
|
|
3
|
+
Why the extension is built the way it is, and what each design choice trades
|
|
4
|
+
off.
|
|
5
|
+
|
|
6
|
+
## Why overlays instead of rewrites
|
|
7
|
+
|
|
8
|
+
In OMP, `/shake` rewrites the transcript file in place: it deletes the
|
|
9
|
+
folded content, offloads the originals to a recovery artifact, rebuilds the
|
|
10
|
+
session context, and tears down provider sessions that cache message
|
|
11
|
+
identity. Fold becomes destructive and expensive, and undo is hard.
|
|
12
|
+
|
|
13
|
+
Pi sessions are append-only. The equivalent of a rewrite is the
|
|
14
|
+
`context_edit` entry, which pi itself uses for overflow recovery:
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
{ type: "context_edit", targetId, replacement: { content } | null }
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The session file is the transcript; it is never touched. What the model
|
|
21
|
+
sees on each request is a **projection**: the stored entries after every
|
|
22
|
+
overlay is applied. For any entry, the most recent overlay wins. A fold is
|
|
23
|
+
therefore not an edit of the file, it is one more overlay stacked on top.
|
|
24
|
+
|
|
25
|
+
## The placeholders
|
|
26
|
+
|
|
27
|
+
A folded block becomes a short text replacement such as
|
|
28
|
+
`[bash output omitted (~9,045 tokens)]`.
|
|
29
|
+
|
|
30
|
+
## The two thresholds and why only elide has them
|
|
31
|
+
|
|
32
|
+
Fold has two thresholds, steered by three profiles:
|
|
33
|
+
|
|
34
|
+
| Profile | Tail | Block minimum |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| default | 4,000 tokens | ~400 tokens |
|
|
37
|
+
| conservative | 8,000 tokens | ~800 tokens |
|
|
38
|
+
| aggressive | 2,000 tokens | ~200 tokens |
|
|
39
|
+
|
|
40
|
+
- The **tail** is measured from the newest entry backward. Whatever falls
|
|
41
|
+
inside it is the model's working set, often the exact tool output it is
|
|
42
|
+
reasoning with, so it stays untouched. The tail advances as the
|
|
43
|
+
conversation grows: each new turn pushes the boundary forward and exposes
|
|
44
|
+
the entries above it to the next fold. Already-folded entries are skipped,
|
|
45
|
+
so a fold never re-does work.
|
|
46
|
+
- The **block minimum** applies to fenced blocks (the sections between three
|
|
47
|
+
backticks or three tildes inside a message) and top-level XML spans. Only
|
|
48
|
+
blocks large enough to be worth it are folded; medium-sized ones may still
|
|
49
|
+
be load-bearing.
|
|
50
|
+
|
|
51
|
+
`images` and `thinking` are all-or-nothing, with no tail and no minimum:
|
|
52
|
+
|
|
53
|
+
- **Thinking has little downstream value.** A reasoning block's main job is
|
|
54
|
+
producing the answer that follows it. Once the answer lands, recent
|
|
55
|
+
reasoning is near disposable as old reasoning: the model mostly
|
|
56
|
+
continues the next turns from conclusions, not chains.
|
|
57
|
+
- **Images are priced high and used once.** Each image costs roughly 1,200
|
|
58
|
+
tokens by pi's estimator, and a screenshot is either the thing being worked
|
|
59
|
+
on or dead weight. You run `/shake images` as a purge after the fact.
|
|
60
|
+
|
|
61
|
+
## The wall: what a fold tracks
|
|
62
|
+
|
|
63
|
+
Every run appends a **log entry to the session file**: a custom entry of type
|
|
64
|
+
`shake` (context-invisible, never sent to the model) listing the targets it
|
|
65
|
+
folded. From these logs the extension derives two things:
|
|
66
|
+
|
|
67
|
+
- **The wall** is the set of entries currently folded. Chronological, last
|
|
68
|
+
operation wins: shake adds, undo removes. It counts entries, never raw
|
|
69
|
+
`context_edit` rows, because the file accumulates superseded overlays and
|
|
70
|
+
pi's own overflow-recovery omissions. Counting rows instead of entries
|
|
71
|
+
produced phantom numbers early on.
|
|
72
|
+
- **The skip-set** is which targets a shake must skip as of now, so the fold
|
|
73
|
+
frontier is monotonic: entries restored and re-folded are folded once, and
|
|
74
|
+
a repeat shake with nothing new appends zero edits.
|
|
75
|
+
|
|
76
|
+
`/shake status` reads the wall; `/shake undo` reads the same logs to decide
|
|
77
|
+
what to restore.
|
|
78
|
+
|
|
79
|
+
## Compaction ordering: compact first, shake second
|
|
80
|
+
|
|
81
|
+
`/compact` squeezes old messages into one summary and stops showing the
|
|
82
|
+
model the originals. The summary is written from what the model currently
|
|
83
|
+
sees (the folded view), and it becomes the model's permanent memory of that
|
|
84
|
+
period: later compactions build on earlier summaries, so that memory is
|
|
85
|
+
carried forward.
|
|
86
|
+
|
|
87
|
+
That makes the order of operations matter. If you shake first and compact
|
|
88
|
+
second, the summarizer reads placeholders like
|
|
89
|
+
`[bash output omitted (~9,000 tokens)]` and can only record "outputs were
|
|
90
|
+
omitted". The full text is still in the file, but the model only reads the
|
|
91
|
+
summary now, so that content is gone from its memory, permanently.
|
|
92
|
+
|
|
93
|
+
If you compact first and shake second, the summary is written from the full
|
|
94
|
+
text and nothing is lost. The raw text that compaction stopped showing is
|
|
95
|
+
now dead weight: shaking it afterwards folds it away with no loss to
|
|
96
|
+
anything the model can still see.
|
|
97
|
+
|
|
98
|
+
So the rule: compact while the content is still visible, then shake the
|
|
99
|
+
leftovers. When compacting, pick a cheap compaction model, then `/shake
|
|
100
|
+
undo`, then `/compact`; in the turns after that, `/shake` again.
|
|
101
|
+
|
|
102
|
+
After a compaction, entries before the summary's `firstKeptEntryId` are no
|
|
103
|
+
longer projected, so all three scopes skip them. Edits there would be inert
|
|
104
|
+
and would inflate the counts dry-run and notifications report.
|
|
105
|
+
|
|
106
|
+
## Caching economics
|
|
107
|
+
|
|
108
|
+
Every turn, pi sends the whole conversation to the provider, and providers
|
|
109
|
+
discount the part of the prompt that is byte-identical to the previous
|
|
110
|
+
request: the unchanged prefix is served from cache at a lower price. The
|
|
111
|
+
moment any byte in the middle differs, everything from that byte to the end
|
|
112
|
+
must be re-encoded at full price, and the cache re-warms from there.
|
|
113
|
+
|
|
114
|
+
That is where a fold's cost lands. A fold edits an old entry in the middle
|
|
115
|
+
of the conversation, so the request right after the fold re-encodes from
|
|
116
|
+
that entry to the end: one full-price turn. Then the conversation is stable
|
|
117
|
+
again, and every later turn reads cache at the smaller size. One cold
|
|
118
|
+
request per fold that applied, and it typically pays for itself within a
|
|
119
|
+
few turns.
|
|
120
|
+
|
|
121
|
+
The same applies to the other mutations: an undo or a compact also changes
|
|
122
|
+
the middle of the conversation, so each costs its own single cold request.
|
|
123
|
+
A no-op fold changes nothing and costs nothing; `/shake status` only reads;
|
|
124
|
+
ordinary turns keep the prefix warm.
|
|
125
|
+
|
|
126
|
+
Fork and rewind change the conversation too, so the first request after
|
|
127
|
+
them is cold regardless of shake. Shaking before that first request means
|
|
128
|
+
the cold re-encode happens once, not twice.
|
|
129
|
+
|
|
130
|
+
## Lifecycle: folds across fork, rewind, import
|
|
131
|
+
|
|
132
|
+
The session file keeps the whole conversation history, and that history is
|
|
133
|
+
a tree: forking or rewinding creates new branches from a chosen point. A
|
|
134
|
+
fold is just two things in that history, a placeholder entry and a log
|
|
135
|
+
entry saying what was folded, both sitting on the branch where the shake
|
|
136
|
+
ran. To pi they are ordinary history, so session operations handle them
|
|
137
|
+
exactly like any other entry:
|
|
138
|
+
|
|
139
|
+
| Operation | What happens to the folds |
|
|
140
|
+
|---|---|
|
|
141
|
+
| `/fork` at the current state | They are copied with the rest of the file. `/shake undo` and `/shake status` still work in the new file. |
|
|
142
|
+
| `/fork` at an earlier message | Everything after that message is left behind, folds included. The new file starts from raw, unshaken context. |
|
|
143
|
+
| `/tree` rewind | The conversation moves back to an earlier message, and the folds that came after it are simply not shown anymore: the raw text is back. The fold data stays in the file under the old branch; switch back and the folds return. |
|
|
144
|
+
| Export / import | The whole file is copied verbatim, folds included. |
|
|
145
|
+
|
|
146
|
+
Two things follow from folds being ordinary history. First, no orphaned
|
|
147
|
+
overlays: pi copies or drops whole subtrees, so a fold and the entry it
|
|
148
|
+
overlays always travel together. Second, a rewound timeline shows raw
|
|
149
|
+
content by construction, because the timeline you rewind to never made the
|
|
150
|
+
fold decision in the first place. Nothing has to be restored or cleaned
|
|
151
|
+
up. The `session-manager` test suite verifies this: it projects the same
|
|
152
|
+
session at an earlier leaf and checks that the raw text returns.
|
|
153
|
+
|
|
154
|
+
## Why undo is all-or-nothing
|
|
155
|
+
|
|
156
|
+
Undo restores every folded entry at once. Scoped undo (restoring one entry
|
|
157
|
+
or a few) was considered and deliberately left out, for three reasons.
|
|
158
|
+
|
|
159
|
+
First, it would save nothing. Restoring any entry changes bytes in the
|
|
160
|
+
middle of the conversation, and the cache re-encodes everything from that
|
|
161
|
+
point to the end. Restoring one entry costs the same single cold request
|
|
162
|
+
as restoring all of them.
|
|
163
|
+
|
|
164
|
+
Second, the real use cases are all-or-nothing anyway: before a compaction
|
|
165
|
+
you want the full text back so the summary is written from real content,
|
|
166
|
+
and after a fold that was too aggressive you want everything back at once.
|
|
167
|
+
|
|
168
|
+
Third, when a single entry matters, undo is the wrong tool. A fold never
|
|
169
|
+
deletes anything: the full original text is still stored in the session
|
|
170
|
+
file, and the model can re-read it from there with the `read` tool.
|
|
171
|
+
|
|
172
|
+
Undo is idempotent: a repeat `/shake undo` is a no-op.
|
|
173
|
+
|
|
174
|
+
## What the dry-run is for
|
|
175
|
+
|
|
176
|
+
`/shake dry-run` opens a scope popup (elide / thinking / images) and prints
|
|
177
|
+
what that scope would fold, in the same wording as the real run, prefixed
|
|
178
|
+
with `[dry-run]`:
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
[dry-run] · Shook 46 tool results (~7,215 tokens). Protects the last 2,000 tokens. Profile: aggressive (settings.json (agent-directory)).
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
It costs nothing (no edits are applied, so there is no cache miss) and it
|
|
185
|
+
answers the question before you commit to a re-warm: how much would this
|
|
186
|
+
scope actually reclaim, and is the one-time re-encode worth it. That matters
|
|
187
|
+
most for `thinking`, which can strip tens of thousands of tokens in one
|
|
188
|
+
strike. The explicit form `/shake dry-run thinking` skips the popup.
|
|
189
|
+
|
|
190
|
+
## The rejected alternative
|
|
191
|
+
|
|
192
|
+
There was a second way shake could have been built, and it was considered
|
|
193
|
+
and rejected. Pi lets an extension register a `context` event that rewrites
|
|
194
|
+
the message list handed to the model on each request, restoring the list
|
|
195
|
+
afterward. A shake built on that would hide heavy content without ever
|
|
196
|
+
touching the session file: no overlays, no undo needed, since nothing was
|
|
197
|
+
changed.
|
|
198
|
+
|
|
199
|
+
It was rejected because the hiding is request-local and ephemeral. The
|
|
200
|
+
folded state lives in the extension's own code, not in the session: the
|
|
201
|
+
moment the event stops running the content is back, and rewinding the tree
|
|
202
|
+
or exporting the session shows the full raw text, because no fold was ever
|
|
203
|
+
recorded. There is also nothing to undo, in the file's sense, since the
|
|
204
|
+
file never changed.
|
|
205
|
+
|
|
206
|
+
The chosen approach, `context_edit` overlays, persists instead: the folds
|
|
207
|
+
are entries in the session file, they survive rewind and travel with
|
|
208
|
+
exports, and `/shake undo` restores real stored content byte for byte. It
|
|
209
|
+
also routes through the same mechanism pi itself uses for overflow
|
|
210
|
+
recovery, so pi's own machinery already knows how to project it.
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,22 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
"name": "pi-shake",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Elide heavy context (tool results, large blocks, images, thinking) via pi context_edit overlays",
|
|
6
|
+
"keywords": ["pi-package"],
|
|
7
|
+
"pi": {
|
|
8
|
+
"extensions": ["./index.ts"]
|
|
9
|
+
},
|
|
10
|
+
"peerDependencies": {
|
|
11
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
12
|
+
},
|
|
13
|
+
"files": ["index.ts", "src", "docs", "README.md"],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"fast-check": "^4.10.2",
|
|
17
|
+
"oxfmt": "0.71.0",
|
|
18
|
+
"oxlint": "1.86.0",
|
|
19
|
+
"publint": "^0.3.25",
|
|
20
|
+
"typescript": "7.0.2"
|
|
21
|
+
}
|
|
6
22
|
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { LOG_TYPE, MODES, PROFILES, SCOPE_NAMES, CHARS_PER_TOKEN } from './constants.ts';
|
|
3
|
+
import type { Mode, ProfileName, ScopeName } from './constants.ts';
|
|
4
|
+
import {
|
|
5
|
+
activeEditTargets,
|
|
6
|
+
describePlan,
|
|
7
|
+
planElide,
|
|
8
|
+
planImages,
|
|
9
|
+
planThinking,
|
|
10
|
+
rawContent,
|
|
11
|
+
shakeLogs,
|
|
12
|
+
shakeWall,
|
|
13
|
+
} from './plans.ts';
|
|
14
|
+
import { resolveProfileFromFiles, writeProfileToSettings } from './settings.ts';
|
|
15
|
+
import type { Entry, PlanEdit } from './types.ts';
|
|
16
|
+
|
|
17
|
+
interface SessionManagerWithEdits {
|
|
18
|
+
appendContextEdit(targetId: string, replacement: { content: unknown } | null): string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface ShakeLog {
|
|
22
|
+
action: string;
|
|
23
|
+
mode?: string;
|
|
24
|
+
targets?: string[];
|
|
25
|
+
freed?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type ShakeCommand =
|
|
29
|
+
| {
|
|
30
|
+
mode: 'elide' | Mode;
|
|
31
|
+
dryRun: boolean;
|
|
32
|
+
allScopes?: boolean;
|
|
33
|
+
}
|
|
34
|
+
| string;
|
|
35
|
+
|
|
36
|
+
function parseRequest(args: string): ShakeCommand {
|
|
37
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
38
|
+
const dryRun = tokens.includes('dry-run');
|
|
39
|
+
const verb = tokens.find((t) => t !== 'dry-run');
|
|
40
|
+
const sub = tokens.find((t) => t !== verb && t !== 'dry-run');
|
|
41
|
+
if (verb === undefined) return { mode: 'elide', dryRun, allScopes: true };
|
|
42
|
+
if (dryRun && (verb === 'undo' || verb === 'profile' || verb === 'status')) {
|
|
43
|
+
return `dry-run applies only to elide, thinking, or images, not ${verb}.`;
|
|
44
|
+
}
|
|
45
|
+
if (verb === 'undo') {
|
|
46
|
+
if (sub !== undefined) return `Unknown /shake undo argument "${sub}". /shake undo restores everything.`;
|
|
47
|
+
return { mode: 'undo', dryRun };
|
|
48
|
+
}
|
|
49
|
+
if ((MODES as readonly string[]).includes(verb)) {
|
|
50
|
+
if (sub !== undefined) return `Unknown /shake argument "${sub}" after ${verb}.`;
|
|
51
|
+
return { mode: verb as Mode, dryRun };
|
|
52
|
+
}
|
|
53
|
+
return `Unknown /shake mode "${verb}". Modes: ${[...MODES, 'dry-run'].join(', ')}; plain /shake elides.`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Bare dry-run popup; undefined = cancelled (already notified). */
|
|
57
|
+
async function pickDryRunScope(ctx: ExtensionCommandContext): Promise<ScopeName | undefined> {
|
|
58
|
+
const choice = await ctx.ui.select('Shake dry-run', [...SCOPE_NAMES]);
|
|
59
|
+
if (choice === undefined) {
|
|
60
|
+
ctx.ui.notify('Preview cancelled.', 'info');
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
if ((SCOPE_NAMES as readonly string[]).includes(choice)) return choice as ScopeName;
|
|
64
|
+
ctx.ui.notify('Preview cancelled.', 'info');
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** A single throw (pi rejects non-editable targets) must not strand the rest outside undo's reach. */
|
|
69
|
+
function applyEdits(
|
|
70
|
+
ctx: ExtensionCommandContext,
|
|
71
|
+
edits: PlanEdit[],
|
|
72
|
+
pi: ExtensionAPI,
|
|
73
|
+
): {
|
|
74
|
+
appliedIds: string[];
|
|
75
|
+
skipped: number;
|
|
76
|
+
logFailed: boolean;
|
|
77
|
+
} {
|
|
78
|
+
const sm = ctx.sessionManager as unknown as SessionManagerWithEdits;
|
|
79
|
+
const appliedIds: string[] = [];
|
|
80
|
+
let freed = 0;
|
|
81
|
+
let skipped = 0;
|
|
82
|
+
let logFailed = false;
|
|
83
|
+
for (const edit of edits) {
|
|
84
|
+
try {
|
|
85
|
+
sm.appendContextEdit(edit.targetId, { content: edit.content });
|
|
86
|
+
appliedIds.push(edit.targetId);
|
|
87
|
+
freed += edit.freed;
|
|
88
|
+
} catch {
|
|
89
|
+
skipped++;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (appliedIds.length > 0) {
|
|
93
|
+
try {
|
|
94
|
+
pi.appendEntry(LOG_TYPE, {
|
|
95
|
+
action: 'shake',
|
|
96
|
+
mode: edits[0]?.kind === 'image' ? 'images' : edits[0]?.kind === 'thinking' ? 'thinking' : 'elide',
|
|
97
|
+
targets: appliedIds,
|
|
98
|
+
freed,
|
|
99
|
+
at: Date.now(),
|
|
100
|
+
});
|
|
101
|
+
} catch {
|
|
102
|
+
logFailed = true; // applied but undo cannot reach them
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { appliedIds, skipped, logFailed };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildStatusLines(input: {
|
|
109
|
+
wallSize: number;
|
|
110
|
+
profile: string;
|
|
111
|
+
source: string;
|
|
112
|
+
usage: { tokens: number; contextWindow: number; percent: number } | undefined;
|
|
113
|
+
last: ShakeLog | undefined;
|
|
114
|
+
}): string[] {
|
|
115
|
+
const { wallSize, profile, source, usage, last } = input;
|
|
116
|
+
let lastLine = 'last: no runs yet';
|
|
117
|
+
if (last) {
|
|
118
|
+
const kind = last.action === 'undo' ? 'undo' : last.mode === 'elide' ? 'elide' : (last.mode ?? 'shake');
|
|
119
|
+
const count = last.targets?.length ?? 0;
|
|
120
|
+
const noun = `${count} entr${count === 1 ? 'y' : 'ies'}`;
|
|
121
|
+
const rest = last.action === 'undo' ? 'restored' : `~${(last.freed ?? 0).toLocaleString()} tokens freed`;
|
|
122
|
+
lastLine = `last: ${kind} · ${noun} · ${rest}`;
|
|
123
|
+
}
|
|
124
|
+
return [
|
|
125
|
+
`wall: ${wallSize} entr${wallSize === 1 ? 'y' : 'ies'} stuck`,
|
|
126
|
+
`profile: ${profile} (${source})`,
|
|
127
|
+
`context: ${usage != null ? `${usage.tokens.toLocaleString()} / ${usage.contextWindow.toLocaleString()} tokens (${Math.round(usage.percent)}%)` : 'unknown'}`,
|
|
128
|
+
lastLine,
|
|
129
|
+
];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function createShakeCommandSpec(pi: ExtensionAPI): {
|
|
133
|
+
description: string;
|
|
134
|
+
getArgumentCompletions: (prefix: string) => Array<{ value: string; label: string }> | null;
|
|
135
|
+
handler: (args: string, ctx: ExtensionCommandContext) => Promise<void>;
|
|
136
|
+
} {
|
|
137
|
+
return {
|
|
138
|
+
description: 'Drop heavy content from context (tool results, large blocks, images, thinking)',
|
|
139
|
+
getArgumentCompletions: (prefix) => {
|
|
140
|
+
const options = [...(MODES as readonly string[]), 'dry-run'];
|
|
141
|
+
const filtered = options.filter((m) => m.startsWith(prefix.toLowerCase()));
|
|
142
|
+
return filtered.length > 0 ? filtered.map((m) => ({ value: m, label: m })) : null;
|
|
143
|
+
},
|
|
144
|
+
handler: async (args, ctx) => {
|
|
145
|
+
if (!ctx.isIdle()) {
|
|
146
|
+
ctx.ui.notify('/shake is unavailable while streaming. Wait or abort first.', 'warning');
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const parsed = parseRequest(args);
|
|
151
|
+
if (typeof parsed === 'string') {
|
|
152
|
+
ctx.ui.notify(parsed, 'warning');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const { mode, dryRun, allScopes } = parsed;
|
|
156
|
+
|
|
157
|
+
const branch = ctx.sessionManager.getBranch() as unknown as Entry[];
|
|
158
|
+
const editedTargets = activeEditTargets(branch);
|
|
159
|
+
const { profile, source } = resolveProfileFromFiles();
|
|
160
|
+
const config = PROFILES[profile];
|
|
161
|
+
|
|
162
|
+
if (mode === 'profile') {
|
|
163
|
+
const names = Object.keys(PROFILES) as ProfileName[];
|
|
164
|
+
const valueColumn = Math.max(...names.map((n) => n.length)) + 4;
|
|
165
|
+
const options = names.map((name) => {
|
|
166
|
+
const tailTokens = PROFILES[name].protectTokens.toLocaleString('en-US');
|
|
167
|
+
const blockTokens = (PROFILES[name].fenceMinChars / CHARS_PER_TOKEN).toLocaleString('en-US');
|
|
168
|
+
const label = `${name}:${' '.repeat(valueColumn - name.length - 1)}tail ${tailTokens} tokens · blocks ≥ ~${blockTokens} tokens`;
|
|
169
|
+
return { name, label };
|
|
170
|
+
});
|
|
171
|
+
const choice = await ctx.ui.select(
|
|
172
|
+
`Shake profile (current: ${profile})`,
|
|
173
|
+
options.map((o) => o.label),
|
|
174
|
+
);
|
|
175
|
+
if (choice === undefined) {
|
|
176
|
+
ctx.ui.notify('Profile unchanged.', 'info');
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
const picked = options.find((o) => o.label === choice)?.name;
|
|
180
|
+
if (picked === undefined) {
|
|
181
|
+
ctx.ui.notify('Profile unchanged.', 'info');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const written = writeProfileToSettings(picked);
|
|
185
|
+
if (written === undefined) {
|
|
186
|
+
ctx.ui.notify('Could not write settings.json. Profile not saved.', 'warning');
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
ctx.ui.notify(`Shake profile → ${picked} (wrote ${written.tier} settings.json: ${written.path}).`, 'info');
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (mode === 'status') {
|
|
194
|
+
const usageRaw = ctx.getContextUsage();
|
|
195
|
+
// pi may report null usage (e.g. post-compaction); print "unknown", never a fake 0%.
|
|
196
|
+
const usage =
|
|
197
|
+
usageRaw == null || usageRaw.tokens === null || usageRaw.percent === null
|
|
198
|
+
? undefined
|
|
199
|
+
: {
|
|
200
|
+
tokens: usageRaw.tokens,
|
|
201
|
+
contextWindow: usageRaw.contextWindow,
|
|
202
|
+
percent: usageRaw.percent,
|
|
203
|
+
};
|
|
204
|
+
const logs = shakeLogs(branch);
|
|
205
|
+
ctx.ui.notify(
|
|
206
|
+
buildStatusLines({
|
|
207
|
+
wallSize: shakeWall(branch).size,
|
|
208
|
+
profile,
|
|
209
|
+
source,
|
|
210
|
+
usage,
|
|
211
|
+
last: logs[logs.length - 1],
|
|
212
|
+
}).join('\n'),
|
|
213
|
+
'info',
|
|
214
|
+
);
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (mode === 'undo') {
|
|
219
|
+
const wall = shakeWall(branch);
|
|
220
|
+
const logs = shakeLogs(branch);
|
|
221
|
+
const targets = new Set<string>();
|
|
222
|
+
for (const log of logs) {
|
|
223
|
+
if (log.action !== 'shake') continue;
|
|
224
|
+
for (const t of log.targets ?? []) if (wall.has(String(t))) targets.add(String(t));
|
|
225
|
+
}
|
|
226
|
+
let skipped = 0;
|
|
227
|
+
const restored: string[] = [];
|
|
228
|
+
const sm = ctx.sessionManager as unknown as SessionManagerWithEdits;
|
|
229
|
+
// Snapshot: appendContextEdit mutates the branch while we iterate.
|
|
230
|
+
for (const entry of branch.slice()) {
|
|
231
|
+
if (!targets.has(entry.id)) continue;
|
|
232
|
+
const content = rawContent(entry);
|
|
233
|
+
if (content === undefined) {
|
|
234
|
+
skipped++;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
sm.appendContextEdit(entry.id, { content }); // last-wins overlay → exact restore
|
|
238
|
+
restored.push(entry.id);
|
|
239
|
+
}
|
|
240
|
+
if (restored.length === 0) {
|
|
241
|
+
const missing = targets.size - skipped;
|
|
242
|
+
const divergence =
|
|
243
|
+
missing > 0 ? ` (${missing} entr${missing === 1 ? 'y' : 'ies'} on the wall missing from this branch)` : '';
|
|
244
|
+
ctx.ui.notify(
|
|
245
|
+
skipped > 0
|
|
246
|
+
? `Nothing to undo (${skipped} entr${skipped === 1 ? 'y' : 'ies'} with no stored content)${divergence}.`
|
|
247
|
+
: `Nothing to undo${divergence}.`,
|
|
248
|
+
'info',
|
|
249
|
+
);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
pi.appendEntry(LOG_TYPE, { action: 'undo', targets: restored, at: Date.now() });
|
|
254
|
+
} catch {
|
|
255
|
+
ctx.ui.notify(
|
|
256
|
+
`Undid ${restored.length} context edit${restored.length === 1 ? '' : 's'}, restoring original content. WARNING: could not log the undo.`,
|
|
257
|
+
'warning',
|
|
258
|
+
);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
const skipClause =
|
|
262
|
+
skipped > 0 ? ` Skipped ${skipped} entr${skipped === 1 ? 'y' : 'ies'} with no stored content.` : '';
|
|
263
|
+
ctx.ui.notify(
|
|
264
|
+
`Undid ${restored.length} context edit${restored.length === 1 ? '' : 's'}, restoring original content.${skipClause}`,
|
|
265
|
+
'info',
|
|
266
|
+
);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const plan =
|
|
271
|
+
mode === 'elide'
|
|
272
|
+
? planElide(branch, editedTargets, config)
|
|
273
|
+
: mode === 'images'
|
|
274
|
+
? planImages(branch, editedTargets)
|
|
275
|
+
: planThinking(branch, editedTargets);
|
|
276
|
+
|
|
277
|
+
if (dryRun) {
|
|
278
|
+
const target = allScopes ? await pickDryRunScope(ctx) : mode;
|
|
279
|
+
if (target === undefined) return;
|
|
280
|
+
|
|
281
|
+
// Rebuild for the chosen scope (bare form's `plan` is elide's).
|
|
282
|
+
const scopedPlan =
|
|
283
|
+
target === 'elide'
|
|
284
|
+
? planElide(branch, editedTargets, config)
|
|
285
|
+
: target === 'images'
|
|
286
|
+
? planImages(branch, editedTargets)
|
|
287
|
+
: planThinking(branch, editedTargets);
|
|
288
|
+
|
|
289
|
+
if (scopedPlan.length === 0) {
|
|
290
|
+
const nothing =
|
|
291
|
+
target === 'images'
|
|
292
|
+
? 'No images found in this session.'
|
|
293
|
+
: target === 'thinking'
|
|
294
|
+
? 'No thinking blocks found in this session.'
|
|
295
|
+
: 'Nothing to shake.';
|
|
296
|
+
ctx.ui.notify(nothing, 'info');
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const described = describePlan(target, scopedPlan);
|
|
301
|
+
const protectNote =
|
|
302
|
+
target === 'elide' ? ` Protects the last ${config.protectTokens.toLocaleString()} tokens.` : '';
|
|
303
|
+
const dryVerb =
|
|
304
|
+
target === 'elide'
|
|
305
|
+
? `Shook ${described.parts.join(' + ')} (~${described.freed.toLocaleString()} tokens).`
|
|
306
|
+
: target === 'thinking'
|
|
307
|
+
? `Dropped ${described.parts.join(' ')} (~${described.freed.toLocaleString()} tokens).`
|
|
308
|
+
: `Dropped ${described.parts.join(' ')} from this session.`;
|
|
309
|
+
ctx.ui.notify(`[dry-run] · ${dryVerb}${protectNote} Profile: ${profile} (${source}).`, 'info');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (plan.length === 0) {
|
|
314
|
+
const nothing =
|
|
315
|
+
mode === 'images'
|
|
316
|
+
? 'No images found in this session.'
|
|
317
|
+
: mode === 'thinking'
|
|
318
|
+
? 'No thinking blocks found in this session.'
|
|
319
|
+
: 'Nothing to shake.';
|
|
320
|
+
ctx.ui.notify(nothing, 'info');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Wall size before this run (distinct stuck entries, not raw context_edit rows).
|
|
325
|
+
const wallBeforeSize = shakeWall(branch).size;
|
|
326
|
+
const { appliedIds, skipped, logFailed } = applyEdits(ctx, plan, pi);
|
|
327
|
+
const wallAfterSize = new Set([...shakeWall(branch), ...appliedIds]).size;
|
|
328
|
+
|
|
329
|
+
// Summaries count what actually applied, not what was planned.
|
|
330
|
+
const appliedSet = new Set(appliedIds);
|
|
331
|
+
const appliedDescribed = describePlan(
|
|
332
|
+
mode,
|
|
333
|
+
plan.filter((e) => appliedSet.has(e.targetId)),
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
let summary: string;
|
|
337
|
+
if (appliedIds.length === 0) {
|
|
338
|
+
summary = `Nothing applied${skipped > 0 ? ` (${skipped} skipped, not editable)` : ''}.`;
|
|
339
|
+
} else if (mode === 'images') {
|
|
340
|
+
summary = `Dropped ${appliedDescribed.parts.join(' ')} from this session.`;
|
|
341
|
+
} else if (mode === 'thinking') {
|
|
342
|
+
summary = `Dropped ${appliedDescribed.parts.join(' ')}${appliedDescribed.freed > 0 ? ` (~${appliedDescribed.freed.toLocaleString()} tokens freed)` : ''}.`;
|
|
343
|
+
} else {
|
|
344
|
+
summary = `Shook ${appliedDescribed.parts.join(' + ')} (~${appliedDescribed.freed.toLocaleString()} tokens freed).`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const appendClause =
|
|
348
|
+
wallBeforeSize > 0 && appliedIds.length > 0
|
|
349
|
+
? ` Appended ${appliedIds.length} new context edit${appliedIds.length === 1 ? '' : 's'} to ${wallBeforeSize} shaken entr${wallBeforeSize === 1 ? 'y' : 'ies'} (${wallAfterSize} total in the wall).`
|
|
350
|
+
: '';
|
|
351
|
+
const skipClause = skipped > 0 ? ` ${skipped} skipped (not editable).` : '';
|
|
352
|
+
const logNote = logFailed ? ' WARNING: could not log this run; undo may not cover it.' : '';
|
|
353
|
+
// Teach the escape hatch once per branch (first shake only).
|
|
354
|
+
const undoHint = wallBeforeSize === 0 && mode === 'elide' ? ' Run /shake undo to restore.' : '';
|
|
355
|
+
ctx.ui.notify(`${summary}${appendClause}${skipClause}${logNote}${undoHint}`, 'info');
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** pi's chars-per-token heuristic; kept single-sourced so displayed estimates never drift. */
|
|
2
|
+
export const CHARS_PER_TOKEN = 4;
|
|
3
|
+
|
|
4
|
+
export const PROFILES = {
|
|
5
|
+
default: { protectTokens: 4_000, fenceMinChars: 400 * CHARS_PER_TOKEN },
|
|
6
|
+
conservative: { protectTokens: 8_000, fenceMinChars: 800 * CHARS_PER_TOKEN },
|
|
7
|
+
aggressive: { protectTokens: 2_000, fenceMinChars: 200 * CHARS_PER_TOKEN },
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export type ProfileName = keyof typeof PROFILES;
|
|
11
|
+
|
|
12
|
+
/** Guardrail: never user-configurable. */
|
|
13
|
+
export const PROTECTED_TOOL_PREFIXES = ['skill'];
|
|
14
|
+
|
|
15
|
+
export const MODES = ['elide', 'images', 'thinking', 'undo', 'status', 'profile'] as const;
|
|
16
|
+
export type Mode = (typeof MODES)[number];
|
|
17
|
+
|
|
18
|
+
export const SCOPE_NAMES = ['elide', 'thinking', 'images'] as const;
|
|
19
|
+
export type ScopeName = (typeof SCOPE_NAMES)[number];
|
|
20
|
+
|
|
21
|
+
/** Logs shake runs (context-invisible); drives wall/undo/status. */
|
|
22
|
+
export const LOG_TYPE = 'shake';
|
package/src/plans.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { getLatestCompactionEntry } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { LOG_TYPE, PROFILES } from './constants.ts';
|
|
3
|
+
import {
|
|
4
|
+
contentTokens,
|
|
5
|
+
entryTokens,
|
|
6
|
+
fmtTokens,
|
|
7
|
+
isProtectedTool,
|
|
8
|
+
newPlaceholder,
|
|
9
|
+
scanTextForBlockRanges,
|
|
10
|
+
textTokens,
|
|
11
|
+
toolResultText,
|
|
12
|
+
} from './scanner.ts';
|
|
13
|
+
import type { Block, BlockRange, Entry, PlanEdit, ShakeConfig, ThinkingBlock } from './types.ts';
|
|
14
|
+
|
|
15
|
+
/** Last-op-wins over our logs; pi's own recovery targets stay off-limits forever. */
|
|
16
|
+
export function activeEditTargets(branch: Entry[]): Set<string> {
|
|
17
|
+
const { stuck, shook } = shakeState(branch);
|
|
18
|
+
for (const entry of branch) {
|
|
19
|
+
if (entry.type === 'context_edit' && entry.targetId && !shook.has(entry.targetId)) {
|
|
20
|
+
stuck.add(entry.targetId);
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return stuck;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Roles whose content pi allows us to overlay (`appendContextEdit` rejects others). */
|
|
27
|
+
const EDITABLE_ROLES = new Set(['user', 'assistant', 'toolResult']);
|
|
28
|
+
|
|
29
|
+
export function isEditableEntry(entry: Entry): boolean {
|
|
30
|
+
return entry.type === 'custom_message' || (entry.type === 'message' && EDITABLE_ROLES.has(entry.message?.role ?? ''));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function rawContent(entry: Entry): string | Block[] | undefined {
|
|
34
|
+
if (entry.type === 'message' && entry.message) return entry.message.content as string | Block[];
|
|
35
|
+
if (entry.type === 'custom_message') return entry.content as string | Block[] | undefined;
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** First entry the model sees after compaction; missing kept-id → boundary = compaction index+1. */
|
|
40
|
+
export function compactionBoundaryIndex(branch: Entry[]): number {
|
|
41
|
+
const latestCompaction = getLatestCompactionEntry(branch as never);
|
|
42
|
+
if (!latestCompaction) return 0;
|
|
43
|
+
const keptIndex = latestCompaction.firstKeptEntryId
|
|
44
|
+
? branch.findIndex((e) => e.id === latestCompaction.firstKeptEntryId)
|
|
45
|
+
: -1;
|
|
46
|
+
if (keptIndex >= 0) return keptIndex;
|
|
47
|
+
const compIndex = branch.findIndex((e) => e.id === latestCompaction.id);
|
|
48
|
+
return compIndex >= 0 ? compIndex + 1 : 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function planElide(
|
|
52
|
+
branch: Entry[],
|
|
53
|
+
editedTargets: Set<string>,
|
|
54
|
+
config: ShakeConfig = PROFILES.default,
|
|
55
|
+
): PlanEdit[] {
|
|
56
|
+
const n = branch.length;
|
|
57
|
+
if (n === 0) return [];
|
|
58
|
+
const accumulatedAfter = Array.from({ length: n }, () => 0);
|
|
59
|
+
let acc = 0;
|
|
60
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
61
|
+
accumulatedAfter[i] = acc;
|
|
62
|
+
acc += entryTokens(branch[i]);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const boundaryIndex = compactionBoundaryIndex(branch);
|
|
66
|
+
|
|
67
|
+
const edits: PlanEdit[] = [];
|
|
68
|
+
|
|
69
|
+
for (let i = 0; i < n; i++) {
|
|
70
|
+
if (i < boundaryIndex) continue;
|
|
71
|
+
if (accumulatedAfter[i] < config.protectTokens) continue;
|
|
72
|
+
const entry = branch[i];
|
|
73
|
+
if (editedTargets.has(entry.id)) continue;
|
|
74
|
+
|
|
75
|
+
if (entry.type === 'message' && entry.message?.role === 'toolResult') {
|
|
76
|
+
const message = entry.message;
|
|
77
|
+
const toolName = typeof message.toolName === 'string' ? message.toolName : 'tool';
|
|
78
|
+
if (isProtectedTool(toolName)) continue;
|
|
79
|
+
const text = toolResultText(message);
|
|
80
|
+
if (!text) continue;
|
|
81
|
+
const blocksArr = Array.isArray(message.content) ? (message.content as Block[]) : [];
|
|
82
|
+
const nonText = blocksArr.filter((b) => b.type !== 'text');
|
|
83
|
+
const label = `tool result(${toolName})`;
|
|
84
|
+
const placeholder = `[${toolName} output omitted (~${fmtTokens(text.tokens)} tokens)]`;
|
|
85
|
+
const content: Block[] = [{ type: 'text', text: placeholder }, ...nonText];
|
|
86
|
+
const freed = text.tokens - textTokens(placeholder);
|
|
87
|
+
if (freed <= 0) continue;
|
|
88
|
+
edits.push({ targetId: entry.id, kind: 'toolResult', label, content, freed, count: 1 });
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (entry.type === 'message' || entry.type === 'custom_message') {
|
|
93
|
+
if (!isEditableEntry(entry)) continue;
|
|
94
|
+
const content = entry.type === 'message' ? (entry.message?.content as string | Block[]) : entry.content;
|
|
95
|
+
if (content === undefined) continue;
|
|
96
|
+
const label = entry.type === 'message' ? (entry.message?.role ?? 'message') : (entry.customType ?? 'custom');
|
|
97
|
+
const perBlock: Array<{ blockIndex: number; ranges: BlockRange[] }> = [];
|
|
98
|
+
|
|
99
|
+
if (typeof content === 'string') {
|
|
100
|
+
const ranges = scanTextForBlockRanges(content).filter((r) => r.end - r.start >= config.fenceMinChars);
|
|
101
|
+
if (ranges.length > 0) perBlock.push({ blockIndex: -1, ranges });
|
|
102
|
+
} else {
|
|
103
|
+
for (let bi = 0; bi < content.length; bi++) {
|
|
104
|
+
const block = content[bi];
|
|
105
|
+
if (block.type !== 'text') continue;
|
|
106
|
+
const ranges = scanTextForBlockRanges(block.text).filter((r) => r.end - r.start >= config.fenceMinChars);
|
|
107
|
+
if (ranges.length > 0) perBlock.push({ blockIndex: bi, ranges });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (perBlock.length === 0) continue;
|
|
111
|
+
|
|
112
|
+
// Splice highest-start-first so earlier offsets stay valid.
|
|
113
|
+
const before = contentTokens(entry, content);
|
|
114
|
+
let rebuilt: string | Block[];
|
|
115
|
+
if (typeof content === 'string') {
|
|
116
|
+
let out = content;
|
|
117
|
+
const ranges = perBlock[0].ranges;
|
|
118
|
+
for (let r = ranges.length - 1; r >= 0; r--) {
|
|
119
|
+
const range = ranges[r];
|
|
120
|
+
out = out.slice(0, range.start) + newPlaceholder(range.kind, range.end - range.start) + out.slice(range.end);
|
|
121
|
+
}
|
|
122
|
+
rebuilt = out;
|
|
123
|
+
} else {
|
|
124
|
+
rebuilt = content.map((block, bi) => {
|
|
125
|
+
if (block.type !== 'text') return block;
|
|
126
|
+
const pb = perBlock.find((p) => p.blockIndex === bi);
|
|
127
|
+
if (!pb) return block;
|
|
128
|
+
let out = block.text;
|
|
129
|
+
for (let r = pb.ranges.length - 1; r >= 0; r--) {
|
|
130
|
+
const range = pb.ranges[r];
|
|
131
|
+
out =
|
|
132
|
+
out.slice(0, range.start) + newPlaceholder(range.kind, range.end - range.start) + out.slice(range.end);
|
|
133
|
+
}
|
|
134
|
+
return { type: 'text' as const, text: out };
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
const after = contentTokens(entry, rebuilt);
|
|
138
|
+
const freed = Math.max(0, before - after);
|
|
139
|
+
if (freed <= 0) continue;
|
|
140
|
+
const blockCount = perBlock.reduce((sum, p) => sum + p.ranges.length, 0);
|
|
141
|
+
edits.push({
|
|
142
|
+
targetId: entry.id,
|
|
143
|
+
kind: 'block',
|
|
144
|
+
label,
|
|
145
|
+
content: rebuilt,
|
|
146
|
+
freed,
|
|
147
|
+
count: blockCount,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return edits;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function planImages(branch: Entry[], editedTargets: Set<string>): PlanEdit[] {
|
|
156
|
+
const edits: PlanEdit[] = [];
|
|
157
|
+
const boundary = compactionBoundaryIndex(branch);
|
|
158
|
+
for (let i = boundary; i < branch.length; i++) {
|
|
159
|
+
const entry = branch[i];
|
|
160
|
+
if (editedTargets.has(entry.id)) continue;
|
|
161
|
+
const content = entry.type === 'message' ? (entry.message?.content as string | Block[] | undefined) : entry.content;
|
|
162
|
+
if (content === undefined || typeof content === 'string') continue;
|
|
163
|
+
if (!isEditableEntry(entry)) continue;
|
|
164
|
+
const blocks = content as Block[];
|
|
165
|
+
const images = blocks.filter((b) => b.type === 'image');
|
|
166
|
+
if (images.length === 0) continue;
|
|
167
|
+
let kept = blocks.filter((b) => b.type !== 'image');
|
|
168
|
+
if (kept.length === 0) kept = [{ type: 'text', text: '[images omitted]' }]; // message must not vanish
|
|
169
|
+
const before = contentTokens(entry, content);
|
|
170
|
+
const after = contentTokens(entry, kept);
|
|
171
|
+
edits.push({
|
|
172
|
+
targetId: entry.id,
|
|
173
|
+
kind: 'image',
|
|
174
|
+
label: `${images.length} image${images.length === 1 ? '' : 's'}`,
|
|
175
|
+
content: kept,
|
|
176
|
+
freed: Math.max(0, before - after),
|
|
177
|
+
count: images.length,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
return edits;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export function planThinking(branch: Entry[], editedTargets: Set<string>): PlanEdit[] {
|
|
184
|
+
const edits: PlanEdit[] = [];
|
|
185
|
+
const boundary = compactionBoundaryIndex(branch);
|
|
186
|
+
for (let i = boundary; i < branch.length; i++) {
|
|
187
|
+
const entry = branch[i];
|
|
188
|
+
if (editedTargets.has(entry.id)) continue;
|
|
189
|
+
if (entry.type !== 'message' || entry.message?.role !== 'assistant') continue;
|
|
190
|
+
const content = entry.message.content;
|
|
191
|
+
if (typeof content === 'string') continue;
|
|
192
|
+
const blocks = content as Block[];
|
|
193
|
+
const thinking = blocks.filter((b) => b.type === 'thinking') as ThinkingBlock[];
|
|
194
|
+
if (thinking.length === 0) continue;
|
|
195
|
+
let kept = blocks.filter((b) => b.type !== 'thinking');
|
|
196
|
+
if (kept.length === 0) {
|
|
197
|
+
const thinkingTokens = thinking.reduce((sum, b) => sum + textTokens(b.thinking), 0);
|
|
198
|
+
kept = [{ type: 'text', text: `[reasoning omitted (~${fmtTokens(thinkingTokens)} tokens)]` }];
|
|
199
|
+
}
|
|
200
|
+
const before = contentTokens(entry, content);
|
|
201
|
+
const after = contentTokens(entry, kept);
|
|
202
|
+
edits.push({
|
|
203
|
+
targetId: entry.id,
|
|
204
|
+
kind: 'thinking',
|
|
205
|
+
label: `${thinking.length} thinking block${thinking.length === 1 ? '' : 's'}`,
|
|
206
|
+
content: kept,
|
|
207
|
+
freed: Math.max(0, before - after),
|
|
208
|
+
count: thinking.length,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
return edits;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export function describePlan(mode: string, plan: PlanEdit[]): { parts: string[]; freed: number } {
|
|
215
|
+
const freed = plan.reduce((sum, e) => sum + e.freed, 0);
|
|
216
|
+
if (mode === 'images') {
|
|
217
|
+
const images = plan.reduce((sum, e) => sum + e.count, 0);
|
|
218
|
+
return { parts: [`${images} image${images === 1 ? '' : 's'}`], freed };
|
|
219
|
+
}
|
|
220
|
+
if (mode === 'thinking') {
|
|
221
|
+
const blocks = plan.reduce((sum, e) => sum + e.count, 0);
|
|
222
|
+
return { parts: [`${blocks} thinking block${blocks === 1 ? '' : 's'}`], freed };
|
|
223
|
+
}
|
|
224
|
+
const toolResults = plan.filter((e) => e.kind === 'toolResult').length;
|
|
225
|
+
const blocks = plan.reduce((sum, e) => (e.kind === 'block' ? sum + e.count : sum), 0);
|
|
226
|
+
const parts: string[] = [];
|
|
227
|
+
if (toolResults > 0) parts.push(`${toolResults} tool result${toolResults === 1 ? '' : 's'}`);
|
|
228
|
+
if (blocks > 0) parts.push(`${blocks} block${blocks === 1 ? '' : 's'}`);
|
|
229
|
+
return { parts, freed };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Targets currently stuck (shake adds, undo removes). */
|
|
233
|
+
export function shakeWall(branch: Entry[]): Set<string> {
|
|
234
|
+
return shakeState(branch).stuck;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function shakeState(branch: Entry[]): { stuck: Set<string>; shook: Set<string> } {
|
|
238
|
+
const stuck = new Set<string>();
|
|
239
|
+
const shook = new Set<string>();
|
|
240
|
+
for (const entry of branch) {
|
|
241
|
+
if (entry.type !== 'custom' || entry.customType !== LOG_TYPE) continue;
|
|
242
|
+
const log = entry.data as { action?: string; targets?: unknown } | undefined;
|
|
243
|
+
if (!log || !Array.isArray(log.targets)) continue;
|
|
244
|
+
for (const t of log.targets) {
|
|
245
|
+
const id = String(t);
|
|
246
|
+
if (log.action === 'shake') {
|
|
247
|
+
stuck.add(id);
|
|
248
|
+
shook.add(id);
|
|
249
|
+
} else if (log.action === 'undo') {
|
|
250
|
+
stuck.delete(id);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { stuck, shook };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function shakeLogs(
|
|
258
|
+
branch: Entry[],
|
|
259
|
+
): Array<{ action: string; mode?: string; targets?: string[]; freed?: number }> {
|
|
260
|
+
const logs: Array<{ action: string; mode?: string; targets?: string[]; freed?: number }> = [];
|
|
261
|
+
for (const entry of branch) {
|
|
262
|
+
if (entry.type === 'custom' && entry.customType === LOG_TYPE && entry.data && typeof entry.data === 'object') {
|
|
263
|
+
logs.push(entry.data as { action: string; mode?: string; targets?: string[]; freed?: number });
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
return logs;
|
|
267
|
+
}
|
package/src/scanner.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { estimateTokens } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { CHARS_PER_TOKEN, PROTECTED_TOOL_PREFIXES } from './constants.ts';
|
|
3
|
+
import type { Block, BlockRange, Entry, Msg } from './types.ts';
|
|
4
|
+
|
|
5
|
+
const OPENING_XML = /^<([a-z_-]+)(?:\s+[^>]*)?>$/;
|
|
6
|
+
const CLOSING_XML = /^<\/([a-z_-]+)>$/;
|
|
7
|
+
|
|
8
|
+
/** Conservative: unterminated fences/tags yield nothing; XML suppressed inside fences. */
|
|
9
|
+
export function scanTextForBlockRanges(text: string): BlockRange[] {
|
|
10
|
+
const ranges: BlockRange[] = [];
|
|
11
|
+
let inFence = false;
|
|
12
|
+
let fenceDelim = '';
|
|
13
|
+
let fenceStart = -1;
|
|
14
|
+
const tagStack: string[] = [];
|
|
15
|
+
let xmlStart = -1;
|
|
16
|
+
|
|
17
|
+
let lineStart = 0;
|
|
18
|
+
for (let i = 0; i <= text.length; i++) {
|
|
19
|
+
if (i !== text.length && text[i] !== '\n') continue;
|
|
20
|
+
// Strip a trailing \r so CRLF text matches the same line grammar.
|
|
21
|
+
const line = text.slice(lineStart, i).replace(/\r$/, '');
|
|
22
|
+
const lineEnd = i;
|
|
23
|
+
const trimmedStart = line.trimStart();
|
|
24
|
+
|
|
25
|
+
if (inFence) {
|
|
26
|
+
// Closing must match the opening delimiter.
|
|
27
|
+
if (trimmedStart.startsWith(fenceDelim)) {
|
|
28
|
+
inFence = false;
|
|
29
|
+
ranges.push({ start: fenceStart, end: lineEnd, kind: 'fence' });
|
|
30
|
+
fenceStart = -1;
|
|
31
|
+
fenceDelim = '';
|
|
32
|
+
}
|
|
33
|
+
lineStart = i + 1;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A fence-like line opens a fence only at top level; inside a top-level
|
|
38
|
+
// XML span it is content, not a fence.
|
|
39
|
+
if (tagStack.length === 0 && (trimmedStart.startsWith('```') || trimmedStart.startsWith('~~~'))) {
|
|
40
|
+
inFence = true;
|
|
41
|
+
fenceDelim = trimmedStart.slice(0, 3);
|
|
42
|
+
fenceStart = lineStart;
|
|
43
|
+
lineStart = i + 1;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const openMatch = xmlOpenMatch(trimmedStart, line);
|
|
48
|
+
if (openMatch) {
|
|
49
|
+
if (tagStack.length === 0) xmlStart = lineStart;
|
|
50
|
+
tagStack.push(openMatch[1]);
|
|
51
|
+
} else {
|
|
52
|
+
const closingMatch = CLOSING_XML.exec(trimmedStart);
|
|
53
|
+
if (closingMatch && tagStack.length > 0 && tagStack[tagStack.length - 1] === closingMatch[1]) {
|
|
54
|
+
tagStack.pop();
|
|
55
|
+
if (tagStack.length === 0 && xmlStart >= 0) {
|
|
56
|
+
ranges.push({ start: xmlStart, end: lineEnd, kind: 'xml' });
|
|
57
|
+
xmlStart = -1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
lineStart = i + 1;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return mergeRanges(ranges);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function mergeRanges(ranges: BlockRange[]): BlockRange[] {
|
|
69
|
+
if (ranges.length <= 1) return ranges;
|
|
70
|
+
const sorted = ranges.toSorted((a, b) => a.start - b.start);
|
|
71
|
+
const kept: BlockRange[] = [];
|
|
72
|
+
let lastEnd = -1;
|
|
73
|
+
for (const range of sorted) {
|
|
74
|
+
if (range.start < lastEnd) continue;
|
|
75
|
+
kept.push(range);
|
|
76
|
+
lastEnd = range.end;
|
|
77
|
+
}
|
|
78
|
+
return kept;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function textTokens(text: string): number {
|
|
82
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function fmtTokens(n: number): string {
|
|
86
|
+
return n.toLocaleString('en-US');
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function entryTokens(entry: Entry): number {
|
|
90
|
+
if (entry.type === 'message' && entry.message) {
|
|
91
|
+
const content = entry.message.content;
|
|
92
|
+
const message =
|
|
93
|
+
typeof content === 'string' ? { ...entry.message, content: [{ type: 'text', text: content }] } : entry.message;
|
|
94
|
+
return estimateTokens(message as never);
|
|
95
|
+
}
|
|
96
|
+
if (entry.type === 'custom_message' && entry.content !== undefined) {
|
|
97
|
+
// pi's estimator weighs images in custom content (4,800 chars each).
|
|
98
|
+
return estimateTokens({ role: 'custom', content: entry.content } as never);
|
|
99
|
+
}
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isProtectedTool(toolName: string): boolean {
|
|
104
|
+
return PROTECTED_TOOL_PREFIXES.some((prefix) => toolName.toLowerCase().startsWith(prefix));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Opening-tag line: column 0, not self-closing, not a void element (so `<br
|
|
109
|
+
* />`, `<hr>`, `<img>` never open a span). Returns the match for the name.
|
|
110
|
+
*/
|
|
111
|
+
function xmlOpenMatch(trimmedStart: string, line: string): RegExpExecArray | null {
|
|
112
|
+
if (line.length !== trimmedStart.length || trimmedStart.endsWith('/>')) return null;
|
|
113
|
+
const match = OPENING_XML.exec(trimmedStart);
|
|
114
|
+
if (!match || VOID_TAGS.has(match[1])) return null;
|
|
115
|
+
return match;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const VOID_TAGS = new Set([
|
|
119
|
+
'area',
|
|
120
|
+
'base',
|
|
121
|
+
'br',
|
|
122
|
+
'col',
|
|
123
|
+
'embed',
|
|
124
|
+
'hr',
|
|
125
|
+
'img',
|
|
126
|
+
'input',
|
|
127
|
+
'link',
|
|
128
|
+
'meta',
|
|
129
|
+
'param',
|
|
130
|
+
'source',
|
|
131
|
+
'track',
|
|
132
|
+
'wbr',
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
function toolResultText(message: Msg): { originalText: string; tokens: number } | undefined {
|
|
136
|
+
const blocks = Array.isArray(message.content) ? (message.content as Block[]) : [];
|
|
137
|
+
const fragments: string[] = [];
|
|
138
|
+
for (const block of blocks) {
|
|
139
|
+
if (block.type === 'text' && block.text.length > 0) fragments.push(block.text);
|
|
140
|
+
}
|
|
141
|
+
if (fragments.length === 0) return undefined;
|
|
142
|
+
const originalText = fragments.join('\n');
|
|
143
|
+
return { originalText, tokens: textTokens(originalText) };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** "omitted" reads as deliberate length-cut, not error/silence; never mentions mechanism. */
|
|
147
|
+
export function newPlaceholder(kind: 'fence' | 'xml', chars: number): string {
|
|
148
|
+
const tokens = Math.max(1, Math.ceil(chars / CHARS_PER_TOKEN));
|
|
149
|
+
const label = kind === 'fence' ? 'code block' : 'XML block';
|
|
150
|
+
return `[${label} omitted (~${fmtTokens(tokens)} tokens)]`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function contentTokens(entry: Entry, content: string | Block[]): number {
|
|
154
|
+
// Normalize string content to blocks: pi's estimator scores assistant
|
|
155
|
+
// strings as 0 tokens, which would zero freed math for legacy rows.
|
|
156
|
+
const normalized: Block[] = typeof content === 'string' ? [{ type: 'text', text: content }] : content;
|
|
157
|
+
if (entry.type === 'custom_message') {
|
|
158
|
+
return estimateTokens({ role: 'custom', content: normalized } as never);
|
|
159
|
+
}
|
|
160
|
+
return estimateTokens({ role: entry.message?.role ?? 'user', content: normalized } as never);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export { isProtectedTool, toolResultText };
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/** No extension settings-write API exists; the popup writes the file, shake reads it per invocation. */
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { PROFILES } from './constants.ts';
|
|
5
|
+
import type { ProfileName } from './constants.ts';
|
|
6
|
+
|
|
7
|
+
/** Test seam: redirect project/agent dirs to temp roots. */
|
|
8
|
+
let settingsRoots: { project: string; agent: string } | undefined;
|
|
9
|
+
export function setSettingsRoots(project: string, agent: string): void {
|
|
10
|
+
settingsRoots = { project, agent };
|
|
11
|
+
}
|
|
12
|
+
export function clearSettingsRoots(): void {
|
|
13
|
+
settingsRoots = undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function settingsPaths(): { projectPath: string; globalPath: string } {
|
|
17
|
+
if (settingsRoots) {
|
|
18
|
+
return {
|
|
19
|
+
projectPath: join(settingsRoots.project, 'settings.json'),
|
|
20
|
+
globalPath: join(settingsRoots.agent, 'settings.json'),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
const cwd = process.cwd();
|
|
24
|
+
const home = process.env.HOME ?? '';
|
|
25
|
+
return {
|
|
26
|
+
projectPath: join(cwd, '.pi', 'settings.json'),
|
|
27
|
+
globalPath: join(home, '.pi', 'agent', 'settings.json'),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Distinguish missing from unparseable so corrupt files surface instead of silently defaulting. */
|
|
32
|
+
function readJsonLoose(path: string): { data?: Record<string, unknown>; corrupt?: boolean } {
|
|
33
|
+
if (!existsSync(path)) return {};
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(readFileSync(path, 'utf-8').replace(/^\uFEFF/, ''));
|
|
36
|
+
return parsed && typeof parsed === 'object' ? { data: parsed as Record<string, unknown> } : { corrupt: true };
|
|
37
|
+
} catch {
|
|
38
|
+
return { corrupt: true };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Accept junk like "aggressive:" (trailing colon/space) as "aggressive". */
|
|
43
|
+
function normalizeProfile(value: unknown): ProfileName | undefined {
|
|
44
|
+
if (typeof value !== 'string') return undefined;
|
|
45
|
+
const cleaned = value.trim().replace(/:+$/, '');
|
|
46
|
+
return cleaned in PROFILES ? (cleaned as ProfileName) : undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveProfileFromFiles(): { profile: ProfileName; source: string } {
|
|
50
|
+
const { projectPath, globalPath } = settingsPaths();
|
|
51
|
+
for (const [path, label] of [
|
|
52
|
+
[projectPath, 'settings.json (project)'],
|
|
53
|
+
[globalPath, 'settings.json (agent-directory)'],
|
|
54
|
+
] as const) {
|
|
55
|
+
const { data, corrupt } = readJsonLoose(path);
|
|
56
|
+
if (corrupt) return { profile: 'default', source: `default (${label} is not parseable JSON)` };
|
|
57
|
+
if (data === undefined) continue;
|
|
58
|
+
const rawProfile = (data as { shake?: { profile?: unknown } }).shake?.profile;
|
|
59
|
+
if (rawProfile === undefined) continue;
|
|
60
|
+
const p = normalizeProfile(rawProfile);
|
|
61
|
+
if (p !== undefined) return { profile: p, source: label };
|
|
62
|
+
return { profile: 'default', source: `default (invalid "${String(rawProfile)}" in ${label})` };
|
|
63
|
+
}
|
|
64
|
+
return { profile: 'default', source: 'default' };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Project settings if present, else agent-directory; merges, never clobbers an unparseable file. */
|
|
68
|
+
export function writeProfileToSettings(
|
|
69
|
+
profile: ProfileName,
|
|
70
|
+
): { tier: 'project' | 'agent-directory'; path: string } | undefined {
|
|
71
|
+
const { projectPath, globalPath } = settingsPaths();
|
|
72
|
+
const target = existsSync(projectPath) ? projectPath : globalPath;
|
|
73
|
+
const targetExists = existsSync(target);
|
|
74
|
+
const { data, corrupt } = targetExists ? readJsonLoose(target) : {};
|
|
75
|
+
if (corrupt || (targetExists && data === undefined)) return undefined;
|
|
76
|
+
try {
|
|
77
|
+
const current = data ?? {};
|
|
78
|
+
const shake =
|
|
79
|
+
typeof current.shake === 'object' && current.shake !== null && !Array.isArray(current.shake)
|
|
80
|
+
? (current.shake as Record<string, unknown>)
|
|
81
|
+
: {};
|
|
82
|
+
current.shake = { ...shake, profile };
|
|
83
|
+
const dir = dirname(target);
|
|
84
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
85
|
+
writeFileSync(target, JSON.stringify(current, null, 2) + '\n', 'utf-8');
|
|
86
|
+
return { tier: target === projectPath ? 'project' : 'agent-directory', path: target };
|
|
87
|
+
} catch {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/** Structural views of pi's session-entry shapes, kept untyped to compose with what pi hands us. */
|
|
2
|
+
|
|
3
|
+
export interface TextBlock {
|
|
4
|
+
type: 'text';
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ImageBlock {
|
|
8
|
+
type: 'image';
|
|
9
|
+
data: string;
|
|
10
|
+
mimeType: string;
|
|
11
|
+
}
|
|
12
|
+
export interface ThinkingBlock {
|
|
13
|
+
type: 'thinking';
|
|
14
|
+
thinking: string;
|
|
15
|
+
thinkingSignature?: string;
|
|
16
|
+
redacted?: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ToolCallBlock {
|
|
19
|
+
type: 'toolCall';
|
|
20
|
+
id: string;
|
|
21
|
+
name: string;
|
|
22
|
+
arguments: Record<string, unknown>;
|
|
23
|
+
}
|
|
24
|
+
export type Block = TextBlock | ImageBlock | ThinkingBlock | ToolCallBlock;
|
|
25
|
+
|
|
26
|
+
export interface Msg {
|
|
27
|
+
role: string;
|
|
28
|
+
content: string | Block[];
|
|
29
|
+
[k: string]: unknown;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface Entry {
|
|
33
|
+
id: string;
|
|
34
|
+
type: string;
|
|
35
|
+
message?: Msg;
|
|
36
|
+
content?: string | (TextBlock | ImageBlock)[];
|
|
37
|
+
customType?: string;
|
|
38
|
+
targetId?: string;
|
|
39
|
+
data?: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface PlanEdit {
|
|
43
|
+
targetId: string;
|
|
44
|
+
kind: 'toolResult' | 'block' | 'image' | 'thinking';
|
|
45
|
+
label: string;
|
|
46
|
+
content: string | Block[];
|
|
47
|
+
freed: number;
|
|
48
|
+
/** Count of dropped items for reporting. */
|
|
49
|
+
count: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ShakeConfig {
|
|
53
|
+
protectTokens: number;
|
|
54
|
+
fenceMinChars: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface BlockRange {
|
|
58
|
+
start: number;
|
|
59
|
+
end: number;
|
|
60
|
+
kind: 'fence' | 'xml';
|
|
61
|
+
}
|