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 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
- # Temporary Holding Version
1
+ # pi-shake, the `/shake` extension for Pi
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > 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
@@ -0,0 +1,6 @@
1
+ import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
+ import { createShakeCommandSpec } from './src/cli.ts';
3
+
4
+ export default function shakeExtension(pi: ExtensionAPI): void {
5
+ pi.registerCommand('shake', createShakeCommandSpec(pi));
6
+ }
package/package.json CHANGED
@@ -1,6 +1,22 @@
1
1
  {
2
- "name": "pi-shake",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
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
+ }
@@ -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 };
@@ -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
+ }