pi-tuck 1.0.26 → 1.0.28

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/CHANGELOG.md CHANGED
@@ -3,6 +3,53 @@
3
3
  All notable changes to pi-tuck. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow
4
4
  [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## [1.0.28] - 2026-11-10
7
+
8
+ ### Changed
9
+
10
+ - **One owner for each piece of state that had several, the user-approved state refactor.** Requests are unchanged.
11
+ - The **pending cut** has one view, which every consumer reads: the request, `tuck_search`, the history tools, Pi's threshold path, the
12
+ band, the boundary save, `/tuck`, and the clamp, deepest-cut and retry previews.
13
+ - The **run** is one record, reset in one place. It holds the starting prompt as an entry, a typed prompt or a custom message.
14
+ - **Reply fit** has one reader behind the reserve and the floor.
15
+ - **Semantic setup** has an explicit state, which `/tuck` and the notices derive from.
16
+
17
+ ### Fixed
18
+
19
+ - **A plan made for a prompt that another extension handled could still answer Pi's own compaction check before a later run.** A plan now
20
+ answers that check only until the next tick, unless its own run starts. The cut it made was valid but deeper than needed.
21
+
22
+ ### Release gates
23
+
24
+ - **Shipped without a live gate, because its replay matches 1.0.27:** 13,946 requests on 29 recordings, 0 lost and 0 dead tasks, clips
25
+ 17 → 17. In the final run, one recording (live32-t02-M) showed 32 differing requests. Two more runs of this build on that recording matched
26
+ 1.0.27 exactly, as did two more runs of 1.0.27, so it was noise.
27
+ - **Strict stress:** 0 findings.
28
+ - **The lifecycle sequence test:** 41 seeds and 1,107 events, clean.
29
+ - **An independent review** found 2 notice-only differences from 1.0.27, both fixed with tests: the length-stop notice after a tree move
30
+ inside another extension's settle, and the "set up" notice for a setup that finishes in a later session.
31
+ - Tests (802) and CI. The packed tarball loaded in the real installed Pi.
32
+
33
+ ## [1.0.27] - 2026-11-10
34
+
35
+ ### Fixed
36
+
37
+ - **A run that a custom message started could lose that message after a cut in the middle of the run.** pi-tuck protected only a prompt the
38
+ user typed. A tool-loop cut in a run that an extension started with a custom message kept the message neither whole nor quoted, so later
39
+ requests lacked the instruction that started the run. Now such a message gets the same protection as a typed prompt: it is kept, or
40
+ quoted whole in the summary, and the guard checks it. Runs started by typed prompts are unchanged. A custom message with no text has
41
+ nothing to quote and stays unprotected, as before.
42
+
43
+ ### Added
44
+
45
+ - A seeded lifecycle sequence test drives real Pi through orders of prompts, handled prompts, custom-message runs, tree moves, new
46
+ sessions, compactions and aborts. It checks invariants read from outputs only. It reproduces 1.0.23's stale-cut bug and found this one.
47
+
48
+ ### Release gates
49
+
50
+ - **Shipped without a live gate, because its replay matches 1.0.26 exactly:** 13,946 requests, 0 different, 0 dead tasks, clips 17 → 17.
51
+ Strict stress: 0 findings. Tests (745) and CI. The packed tarball loaded in the real installed Pi.
52
+
6
53
  ## [1.0.26] - 2026-11-10
7
54
 
8
55
  ### Fixed
package/README.md CHANGED
@@ -64,8 +64,8 @@ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, bo
64
64
 
65
65
  - Needs Pi 1.2.0 or later (tested on 1.2.0 and 1.4.0), on macOS or Linux. On an older Pi, pi-tuck stays off, Pi's own compaction runs, and pi-tuck says so once. Full features on macOS with Apple silicon; elsewhere, compaction and word recall.
66
66
  - On macOS with Apple silicon, the first session also downloads a local search model in the background (about 390 MB, once), and says once when it is set up. Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
67
- - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.24`.
68
- - To pin a version, add it: `pi install npm:pi-tuck@1.0.24`.
67
+ - Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.27`.
68
+ - To pin a version, add it: `pi install npm:pi-tuck@1.0.27`.
69
69
 
70
70
  ## Uninstall
71
71
 
@@ -102,7 +102,7 @@ pi-tuck's tools appear only after its first cut. Before that there is nothing ou
102
102
 
103
103
  | Command | Does |
104
104
  |---|---|
105
- | `/tuck` | Says in words what pi-tuck is doing in this session: the version this session loaded (an open session keeps it until Pi restarts), how many cuts pi-tuck made and the last one's size before and after, whether the cut point adapts or is fixed by `cutAt`, and how search works here. It is for you; the model sees nothing new. |
105
+ | `/tuck` | Says in words what pi-tuck is doing in this session: the version this session loaded (an open session keeps it until Pi restarts), how many cuts and retries pi-tuck made and the last cut's size before and after, whether the cut point adapts or is fixed by `cutAt`, and how search works here. It is for you; the model sees nothing new. |
106
106
  | `/tuck-semantic on\|off\|status` | Turns semantic search on or off for this session, or shows its state. |
107
107
  | `/tuck-incident on\|next\|off\|status` | Records this session's compaction decisions to a file you can attach to a bug report. |
108
108
 
@@ -131,8 +131,11 @@ Known limits today:
131
131
  - A host that patches a large section into the system prompt could get a refusal that a cut would have fit (1.0.0 to 1.0.16; fixed in 1.0.17). pi-tuck plans a prompt's cut when the prompt arrives, before other extensions change the system prompt. A host engine that adds a section of thousands of tokens for one turn could push that turn's first request over the room, and the final guard refused it. Now the guard plans the cut against the system prompt as finally sent, and refuses only if that cut does not fit either ([`CHANGELOG.md`](CHANGELOG.md) 1.0.17).
132
132
  - Semantic recall never started in `pi -p` (1.0.0 to 1.0.20; fixed in 1.0.21). pi-tuck skipped semantic recall whenever Pi ran with no UI, to keep a one-shot run from starting the first-time download, and that also skipped a runtime that was already installed. Jobs run with `pi -p` searched by keyword only, while `tuck_search` said semantic recall was being set up. RPC mode was not affected ([`CHANGELOG.md`](CHANGELOG.md) 1.0.21).
133
133
  - pi-tuck refused requests that fit the window, and could strand a session (1.0.0 to 1.0.21; fixed in 1.0.22). It refused at the room, the window less the full reply reserve, where native Pi still sends. With Pi's defaults and a large setup, a session could be refused at every prompt. The final refusal now sits at the window less a reply floor ([`CHANGELOG.md`](CHANGELOG.md) 1.0.22).
134
+ - pi-tuck 1.0.25 failed to load in an installed Pi ("Cannot find module '@earendil-works/pi-ai/utils/estimate'"); fixed in 1.0.26, and 1.0.25 is deprecated. Since 1.0.23, the check of Pi's own reply cap also never ran in an installed Pi, for the same reason: Pi's extension loader resolves only a few pi-ai entry points for an installed package. It loaded only in tests and the eval harness, and the installed package fell back to the same 4,096-token margin without it. Fixed in 1.0.26, which was loaded in a real installed Pi before publishing ([`CHANGELOG.md`](CHANGELOG.md) 1.0.26).
135
+ - A request above the room could be admitted and then refused, leaving the session stuck where native Pi would have sent (1.0.22 to 1.0.24; fixed in 1.0.25). pi-tuck checked its line in its own corrected token count, while Pi's reply cap uses Pi's own estimate, which is higher when the provider counts fewer tokens than pi-tuck's raw measure ([`CHANGELOG.md`](CHANGELOG.md) 1.0.25).
134
136
  - Near the 1.0.22 refusal line, replies were cut to a few hundred tokens (1.0.22; fixed in 1.0.23). 1.0.22 left only its reply floor and did not count Pi's 4,096-token margin on the reply cap ([`CHANGELOG.md`](CHANGELOG.md) 1.0.23).
135
137
  - Cost of the 1.0.22 change: between the room and the window, the reply reserve no longer cushions a wrong count. At a cold start an undercount can reach the provider. A provider that errors is caught and learned from; a server that truncates silently truncates, as it does for native Pi. With a setup above the room, cuts are forced and frequent, each freeing little, which beats a stranded session ([`CHANGELOG.md`](CHANGELOG.md) 1.0.22).
138
+ - A run that a custom message started could lose that message after a cut in the middle of the run (fixed in 1.0.27). pi-tuck protected only a prompt the user typed, so a tool-loop cut in a run an extension started with a custom message kept the message neither whole nor quoted, and later requests lacked the instruction that started the run. Now such a message gets the same protection as a typed prompt: it is kept, or quoted whole in the summary, and the guard checks it. A custom message with no text has nothing to quote and stays unprotected ([`CHANGELOG.md`](CHANGELOG.md) 1.0.27).
136
139
  - A prompt's cut could reach a run it was not planned for (fixed in 1.0.24). pi-tuck plans a prompt's cut when the prompt arrives. When another extension handled that prompt, no run started, and the plan waited for the next run. A run started by a custom message carried a cut sized for the prompt that never ran, deeper than needed. After a move in the session tree (`/tree`), the plan was applied on the new branch, where its kept entry was not present, so the request lost the custom message that started the run, even on a branch that needed no cut ([`CHANGELOG.md`](CHANGELOG.md) 1.0.24).
137
140
  - Four `tuck_search` faults, fixed in 1.0.23: it said semantic recall was still indexing when the history was fully indexed; in the turn after a prompt's cut it ranked nothing ("nothing is out of view"); a keyword-search timeout on a very long history failed the whole search, where now the semantic results still answer; and the timeout message named a pattern instead of the history's size ([`CHANGELOG.md`](CHANGELOG.md) 1.0.23).
138
141
  - pi-tuck cut earlier than native Pi when a model's reply cap is above Pi's reserve (1.0.0 to 1.0.19; fixed in 1.0.20). It reserved the larger of Pi's `compaction.reserveTokens` and the model's `maxTokens`: with `maxTokens` 32,768 and a reserve of 16,384 on an 81,920 window, its room fell to 49,152 tokens, where native Pi compacts at 65,536 ([`CHANGELOG.md`](CHANGELOG.md) 1.0.20).
@@ -193,7 +196,9 @@ A bad file is reported at session start, and pi-tuck uses the defaults.
193
196
 
194
197
  pi-tuck keeps room for the model's reply. Since 1.0.20 that room is Pi's `compaction.reserveTokens` (default 16,384), the same as native Pi. It rises to the model's `maxTokens` for the rest of the branch only after a reply on that branch did not fit: a reply that stopped for length, or a request refused as a context overflow. A transient error such as a 503 keeps the native room. This is read from the current branch at each decision, so resume, fork and `/tree` follow the branch, and a new session starts at `reserveTokens` again. A host that passes its own reserve keeps its own. Before 1.0.20, pi-tuck always reserved the larger of the two, so with a large `maxTokens` it cut earlier than native Pi.
195
198
 
196
- Since 1.0.22, pi-tuck refuses a request only past a line near the window. Pi sets each request's reply cap to the window less its own estimate of the request less a 4,096-token safety margin, and since 1.0.23 pi-tuck reads that margin from Pi. A request above the room goes out only if Pi's own cap still leaves the reply floor. The floor is the larger of the biggest reply seen on the branch and 3,584 tokens, capped at the model's own reply cap. After a reply that stopped for length or was refused as a context overflow, the floor is the reserve. The line never sits below the room, where 1.0.21 also sends. Before it refuses, pi-tuck cuts to the band, cuts to the room, makes a smaller summary and clips a fresh tool result, in that order. A run's request always carries the whole prompt that started the run: if it cannot fit after every reduction, pi-tuck stops the run with a notice saying to shorten the prompt or use a larger window. Each refusal, and each request sent above the room, leaves one `pi-tuck-capacity` entry in the session; it is never sent to the model, and never appears in summaries or recall.
199
+ Since 1.0.22, pi-tuck refuses a request only past a line near the window. Pi sets each request's reply cap to the window less its own estimate of the request less a 4,096-token safety margin, and since 1.0.26 pi-tuck loads Pi's own reply clamp and estimator from the running Pi's install (a host that embeds Pi falls back to the same 4,096-token margin). Since 1.0.25, each point that sends a request above the room checks Pi's own cap on the request it would send. When that leaves less than the reply floor, pi-tuck cuts deeper, with up to three re-plans and then the deepest cut that still carries the whole prompt, and refuses only if that leaves less than the floor too. The floor is the larger of the biggest reply seen on the branch and 3,584 tokens, capped at the model's own reply cap. After a reply that stopped for length or was refused as a context overflow, the floor is the reserve. The line never sits below the room, where 1.0.21 also sends. Before it refuses, pi-tuck cuts to the band, cuts to the room, makes a smaller summary and clips a fresh tool result, in that order. A run's request always carries the whole prompt that started the run: if it cannot fit after every reduction, pi-tuck stops the run with a notice saying to shorten the prompt or use a larger window. Each refusal, and each request sent above the room, leaves one `pi-tuck-capacity` entry in the session; it is never sent to the model, and never appears in summaries or recall.
200
+
201
+ Since 1.0.25, after a reply that stops for length with thinking only, pi-tuck retries the turn once, leaving the stopped reply out of the request. It uses the deepest cut that still carries the whole prompt when that gives the reply more room, and otherwise retries uncut. A retried reply that stops again ends the run. When a run ends on a reply cut at the output limit, pi-tuck says so ("the last reply stopped at the model's output limit and may be cut off"): in the interface, or on stderr when there is no UI. The notice never enters the model's context.
197
202
 
198
203
  pi-tuck estimates the request's size, and it scales that estimate by the worst gap it has seen between its count and the provider's. In a small window, an upstream that spends far more context on tool schemas (some OpenRouter upstreams do) can leave too little room. If a small window refuses, use a larger window, or pin one upstream as below.
199
204
 
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export type { CompactionOptions } from "./src/compaction.js";
3
3
  export { installCompaction } from "./src/compaction.js";
4
4
  export { inspectIncident, readableIncident } from "./src/diagnostic-replay.js";
5
5
  export { createSemanticRecall, SemanticRecallController } from "./src/semantic-recall.js";
6
- export type { SemanticRecallConfig, PriorResult, SemanticState } from "./src/semantic-recall.js";
6
+ export type { SemanticRecallConfig, PriorResult, SemanticState, SemanticSetup } from "./src/semantic-recall.js";
7
7
  export { ensureSemanticRuntime } from "./src/setup.js";
8
8
  /** pi-tuck as a Pi extension: compaction and history recall always; semantic recall too, unless `pi-tuck.json` in Pi's agent folder
9
9
  * says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts. The same file's `"cutAt"`
package/dist/index.js CHANGED
@@ -43,14 +43,21 @@ export default async function (pi) {
43
43
  }
44
44
  const semanticRecall = createSemanticRecall();
45
45
  // `/tuck-semantic off` holds for the rest of the session, including over a setup that finishes later.
46
- let off = false;
47
46
  const settings = readSettings(join(getAgentDir(), "pi-tuck.json"));
48
47
  // Semantic recall runs on Metal, so only on Apple silicon; elsewhere the model is not offered tuck_search at all.
49
48
  const appleSilicon = process.platform === "darwin" && process.arch === "arm64";
50
- const handle = await installCompaction(pi, { semanticRecall, search: appleSilicon, cutAt: settings.cutAt, onSemanticSetting: async (enabled) => { off = !enabled; } });
51
- if (settings.semanticRecall === false)
49
+ const handle = await installCompaction(pi, { semanticRecall, search: appleSilicon, cutAt: settings.cutAt, onSemanticSetting: async (enabled) => {
50
+ if (!enabled)
51
+ semanticRecall.setSetup("off-user");
52
+ else if (semanticRecall.status().setup === "off-user")
53
+ semanticRecall.setSetup("starting", { installed: existsSync(runtimeConfigPath(defaultRoot())) });
54
+ } });
55
+ if (settings.semanticRecall === false) {
56
+ semanticRecall.setSetup("off-settings");
57
+ pi.on("session_start", () => { semanticRecall.setSetup("off-settings"); });
52
58
  return;
53
- let session = 0, said = "", setup, announced = false;
59
+ }
60
+ let session = 0, said = "", announced = false, setup;
54
61
  // A context outlives its session (a setup can finish after /new); a notice to an ended session is dropped.
55
62
  // With no UI (pi -p), Pi shows no notices, so a settings error or an off or failing semantic recall would go unsaid: write each to stderr
56
63
  // instead, once per session. stderr is the operator's; it never enters the model's context.
@@ -75,49 +82,55 @@ export default async function (pi) {
75
82
  said = message;
76
83
  say(ctx, message, level);
77
84
  } };
85
+ const announce = (ctx, state, detail) => {
86
+ semanticRecall.setSetup(state, detail);
87
+ const notice = semanticRecall.setupNotice();
88
+ if (notice)
89
+ (state === "off-platform" || state === "off-project" ? once : say)(ctx, notice.message, notice.level);
90
+ };
78
91
  pi.on("session_shutdown", () => { session++; });
79
92
  pi.on("session_start", (_event, ctx) => {
80
93
  const current = ++session;
81
- off = false;
82
94
  stderrSaid.clear();
83
95
  if (settings.error)
84
96
  once(ctx, `pi-tuck: ${settings.error}; using the defaults.`, "warning");
85
97
  if (!appleSilicon)
86
- return once(ctx, "pi-tuck: semantic recall needs macOS on Apple silicon; compaction and word recall work.", "info");
98
+ return announce(ctx, "off-platform");
87
99
  // Semantic recall keeps its runtime and indexes outside the project it serves, so not when Pi runs in a folder that holds its
88
100
  // agent folder, such as the home folder.
89
101
  if (within(real(getAgentDir()), real(ctx.cwd)))
90
- return once(ctx, "pi-tuck: semantic recall is off here because Pi's agent folder is inside this folder; open Pi in a project folder to use it.", "info");
102
+ return announce(ctx, "off-project");
91
103
  // A run with no UI (pi -p, RPC, JSON) uses a runtime that is already installed: that needs no download. It does not start a first-time
92
104
  // setup, which would outlast a one-shot run, and it says why, so tuck_search and /tuck name the real state.
93
105
  const installed = existsSync(runtimeConfigPath(defaultRoot()));
94
- if (!ctx.hasUI && !installed) {
95
- semanticRecall.refuse("Semantic recall is not set up yet; start Pi once in an interactive session to set it up. Word recall works meanwhile");
96
- return say(ctx, "pi-tuck: semantic recall is not set up yet; start Pi once in an interactive session to set it up. Word recall works.", "warning");
97
- }
98
- if (!semanticRecall.status().configured)
99
- semanticRecall.refuse(installed ? "Semantic recall is starting; word recall works meanwhile" : "Semantic recall is being set up; word recall works meanwhile");
100
- setup ??= ensureSemanticRuntime(defaultRoot(), message => { announced = true; say(ctx, message, "info"); });
106
+ if (!ctx.hasUI && !installed)
107
+ return announce(ctx, "not-installed");
108
+ // The download's announcement outlives the session that made it: a session that starts while the shared setup is still running
109
+ // owes the "set up" notice once, as before the refactor.
110
+ semanticRecall.setSetup("starting", { installed, announced });
111
+ setup ??= ensureSemanticRuntime(defaultRoot(), message => {
112
+ announced = true;
113
+ if (current === session && semanticRecall.status().setup !== "off-user")
114
+ announce(ctx, "starting", { installed, notice: message, announced: true });
115
+ });
101
116
  setup.then(async () => {
102
- if (current !== session || off)
117
+ if (current !== session || semanticRecall.status().setup === "off-user")
103
118
  return;
104
119
  try {
105
- await handle.setSemanticEnabled(true, ctx); // reads the runtime's configuration through the outside-project check
106
- // The download was announced, so its end is too: once, when semantic recall first starts on it.
107
- if (announced && current === session) {
120
+ await handle.setSemanticEnabled(true, ctx);
121
+ if (current === session && semanticRecall.status().setup !== "off-user") {
122
+ announce(ctx, "ready", { announced });
108
123
  announced = false;
109
- say(ctx, "pi-tuck: semantic recall is set up. Search by meaning works from the first cut on.", "info");
110
124
  }
111
125
  }
112
126
  catch (error) {
113
- if (current === session)
114
- say(ctx, `pi-tuck: semantic recall could not start (${reason(error)}); it tries again when a session starts.`, "warning");
127
+ if (current === session && semanticRecall.status().setup !== "off-user")
128
+ announce(ctx, "failed", { stage: "start", reason: reason(error) });
115
129
  }
116
130
  }, error => {
117
131
  setup = undefined; // the next session start tries again
118
- semanticRecall.refuse(`Semantic recall setup failed: ${reason(error)}`);
119
- if (current === session)
120
- say(ctx, `pi-tuck: semantic recall setup failed (${reason(error)}). Word recall works; setup retries when a session starts.`, "warning");
132
+ if (current === session && semanticRecall.status().setup !== "off-user")
133
+ announce(ctx, "failed", { stage: "setup", reason: reason(error) });
121
134
  });
122
135
  });
123
136
  }
@@ -1 +1 @@
1
- export declare const BUILD_ID = "e95edde95c614d01e1cbf6489134676fac0c1bbf6aa57ef639ffac1e7e105041";
1
+ export declare const BUILD_ID = "e9c750b757776bf44ad79939b633f178ef9a8f14acfab1c8bb6311d692c79e61";
@@ -1 +1 @@
1
- export const BUILD_ID = "e95edde95c614d01e1cbf6489134676fac0c1bbf6aa57ef639ffac1e7e105041";
1
+ export const BUILD_ID = "e9c750b757776bf44ad79939b633f178ef9a8f14acfab1c8bb6311d692c79e61";
@@ -74,6 +74,8 @@ export declare function installCompaction(pi: ExtensionAPI, options?: Compaction
74
74
  close: () => Promise<void>;
75
75
  semanticRecall: SemanticRecallController;
76
76
  setSemanticEnabled: (enabled: boolean, ctx: ExtensionContext) => Promise<{
77
+ setup: import("./semantic-recall.js").SemanticSetup;
78
+ setupMessage: string | null;
77
79
  enabled: boolean;
78
80
  configured: boolean;
79
81
  cleanupError: string | null;
@@ -119,6 +121,8 @@ export declare function installCompaction(pi: ExtensionAPI, options?: Compaction
119
121
  semanticStatus: () => {
120
122
  host?: unknown;
121
123
  local: {
124
+ setup: import("./semantic-recall.js").SemanticSetup;
125
+ setupMessage: string | null;
122
126
  enabled: boolean;
123
127
  configured: boolean;
124
128
  cleanupError: string | null;