pi-tuck 1.0.6 → 1.0.8
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 +21 -0
- package/README.md +19 -19
- package/dist/index.js +5 -3
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/clip.d.ts +14 -0
- package/dist/src/clip.js +52 -0
- package/dist/src/compaction.d.ts +2 -0
- package/dist/src/compaction.js +112 -67
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/diagnostics.js +1 -1
- package/dist/src/semantic-recall.d.ts +3 -1
- package/dist/src/semantic-recall.js +4 -2
- package/index.ts +5 -3
- package/package.json +1 -1
- package/scripts/semantic-worker.py +8 -9
- package/src/build-id.ts +1 -1
- package/src/clip.ts +47 -0
- package/src/compaction.ts +89 -51
- package/src/diagnostics.ts +1 -1
- package/src/semantic-recall.ts +4 -2
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,27 @@
|
|
|
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.8] - 2026-11-01
|
|
7
|
+
|
|
8
|
+
### Fixed
|
|
9
|
+
|
|
10
|
+
- A fresh tool result too large for the room no longer refuses the run. The request carries the result's head and tail instead, with a
|
|
11
|
+
`tuck_recall` locator to the rest; a round of parallel results shares one clip budget. The clip ends with its run.
|
|
12
|
+
- A prompt right after a cut is no longer refused when Pi has nothing to compact for it. When a run ends at or above the cut band, pi-tuck
|
|
13
|
+
cuts there itself, so the next prompt starts below the band. A run-end cut that cannot be planned is no fault.
|
|
14
|
+
- Gates: on 29 replayed recordings, no task lost and no request refused, where 1.0.6 refused 8; live at a 32k window (60 tasks), no dead task
|
|
15
|
+
and no mid-run refusal, and 46 resolved, as 1.0.7 ([`benchmark/RESULTS-V108.md`](benchmark/RESULTS-V108.md)).
|
|
16
|
+
|
|
17
|
+
## [1.0.7] - 2026-11-01
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- Semantic recall runs on Metal only, so only on Apple silicon. The CPU fallback is gone. Where the embedding server cannot start on Metal (a
|
|
22
|
+
VM, a Mac without a GPU), semantic recall goes off with "Semantic recall needs Metal; word recall still works", and word recall keeps
|
|
23
|
+
answering.
|
|
24
|
+
- Off Apple silicon (Linux, Intel Macs), pi-tuck compacts and offers word recall only: `tuck_search` is no longer registered, `tuck_recall`'s
|
|
25
|
+
description no longer points to it, and nothing is downloaded. On Apple silicon both tools are as before.
|
|
26
|
+
|
|
6
27
|
## [1.0.6] - 2026-11-01
|
|
7
28
|
|
|
8
29
|
### Changed
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# pi-tuck
|
|
2
2
|
|
|
3
|
-
**Compaction for [Pi](https://pi.dev) with no model call, and a history the agent can search by keyword
|
|
3
|
+
**Compaction for [Pi](https://pi.dev) with no model call, and a history the agent can search by keyword, and on Apple silicon by meaning, all on your machine.** In our live run, a cut took a median 0.09 s. It costs no tokens. The same session always gives the same summary. And nothing is lost: the agent can read back anything earlier, word for word, and on Apple silicon it can find it by meaning.
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
pi install npm:pi-tuck
|
|
@@ -17,14 +17,13 @@ Pi's built-in compaction asks a model to write a summary. pi-tuck builds the sum
|
|
|
17
17
|
| Context carried after a cut | 29,200 tokens | 27,800 tokens (about the same) |
|
|
18
18
|
| Same session, same summary | Not guaranteed, since a model writes it | Yes |
|
|
19
19
|
| Can the agent get dropped detail back | No recall tool in Pi 1.4.0. The full history stays in the session file, but the agent has no tool made to search it | Yes. `tuck_recall` reads any original exactly |
|
|
20
|
-
| Search history by meaning | No | Yes, with `tuck_search`, on macOS with Apple silicon. It runs locally, and no text leaves your machine. Elsewhere
|
|
20
|
+
| Search history by meaning | No | Yes, with `tuck_search`, on macOS with Apple silicon. It runs locally on your Mac's GPU, and no text leaves your machine. Elsewhere there is no `tuck_search`, and `tuck_recall` searches by keyword |
|
|
21
21
|
| Can the summary invent history | It can, since a model writes it | No, it holds only recorded text |
|
|
22
22
|
| What the summary leaves out | Whatever the model dropped | Tool output only; the agent reads it back exactly |
|
|
23
23
|
| Billed input at the default 60% cut point (simulated) | Not measured | 10–18% less than pi-tuck cutting at the full room. This is not a comparison with the built-in |
|
|
24
|
-
|
|
|
25
|
-
| Answer quality against Pi's compaction prompts on a frontier model | | **Untested.** |
|
|
24
|
+
| Tasks resolved at a 32k window (150 SWE-bench Verified tasks, one model, pi-tuck 1.0.5) | 94 of 150 | 120 of 150 (+17.3 points, 95% lower bound +10.7). Limits below the table |
|
|
26
25
|
|
|
27
|
-
The time and context rows are live runs with a 131k window: 27 cuts per arm over 5 chains, interim.
|
|
26
|
+
The time and context rows are live runs with a 131k window: 27 cuts per arm over 5 chains, interim. The 32k row is one model, one window and five chains. Observation masking, a third approach, resolved 117 of the same 150 tasks (pi-tuck +2.0 points, lower bound −2.7: no difference shown). In the 32k run only pi-tuck was held to the 16,384-token room: Pi's built-in compaction sent a request over it in 97 of 150 tasks. Every number comes from a file in this repository. Conditions, sample sizes and the full limits are in [Benchmarks](#benchmarks).
|
|
28
27
|
|
|
29
28
|
## Ask your history
|
|
30
29
|
|
|
@@ -46,7 +45,7 @@ It gets back the original passage, with a pointer such as `#212`. It reads the w
|
|
|
46
45
|
|
|
47
46
|
That is `tuck_recall`. The agent answers from the original text, not from a model's memory of it.
|
|
48
47
|
|
|
49
|
-
*This example is an illustration. It is not a recorded session.* The call shapes are real. **Semantic search has no measured quality numbers yet.** A test of it is pre-registered in [`benchmark/PREREG-RECALL.md`](benchmark/PREREG-RECALL.md); it has no result yet. The benchmarks below measure cut time, cost and answer retention after cuts, not how well `tuck_search` finds a passage.
|
|
48
|
+
*This example is an illustration. It is not a recorded session.* The call shapes are real. **Semantic search has no measured quality numbers yet.** A test of it is pre-registered in [`benchmark/PREREG-RECALL.md`](benchmark/PREREG-RECALL.md); it has no result yet. The benchmarks below measure cut time, cost and answer retention after cuts, not how well `tuck_search` finds a passage. `tuck_search` exists only on macOS with Apple silicon. Elsewhere the agent has `tuck_recall` and its keyword search, and the example above would not find its match.
|
|
50
49
|
|
|
51
50
|
## Install
|
|
52
51
|
|
|
@@ -56,10 +55,10 @@ pi install npm:pi-tuck
|
|
|
56
55
|
|
|
57
56
|
Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, both automatic and `/compact`. You change no setting.
|
|
58
57
|
|
|
59
|
-
- Needs Pi 1.1 or later (tested on 1.1.0 and 1.4.0), on macOS or Linux.
|
|
58
|
+
- Needs Pi 1.1 or later (tested on 1.1.0 and 1.4.0), on macOS or Linux. Full features on macOS with Apple silicon; elsewhere, compaction and word recall.
|
|
60
59
|
- On macOS with Apple silicon, the first session also downloads a local search model in the background (about 390 MB, once). Until it is ready, search works by keyword. See [Semantic search](#semantic-search).
|
|
61
|
-
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.
|
|
62
|
-
- To pin a version, add it: `pi install npm:pi-tuck@1.0.
|
|
60
|
+
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.7`.
|
|
61
|
+
- To pin a version, add it: `pi install npm:pi-tuck@1.0.7`.
|
|
63
62
|
|
|
64
63
|
## How it works
|
|
65
64
|
|
|
@@ -77,9 +76,9 @@ The summary is plain text, one line per record, such as `#12 user @2026-10-30T14
|
|
|
77
76
|
The agent gets two tools to get anything back:
|
|
78
77
|
|
|
79
78
|
- **`tuck_recall`** finds earlier text by keyword. It reads any original message or tool result exactly, by the index shown in a summary. It can also write an original out to a file.
|
|
80
|
-
- **`tuck_search`** finds earlier history related to a question, even when the wording differs. It combines keyword hits with local semantic search and returns the original text with pointers for `tuck_recall`.
|
|
79
|
+
- **`tuck_search`** (macOS with Apple silicon only) finds earlier history related to a question, even when the wording differs. It combines keyword hits with local semantic search and returns the original text with pointers for `tuck_recall`.
|
|
81
80
|
|
|
82
|
-
|
|
81
|
+
On Apple silicon, both tools cost about 1,050 tokens of tool description per request. Elsewhere only `tuck_recall` is offered.
|
|
83
82
|
|
|
84
83
|
| Command | Does |
|
|
85
84
|
|---|---|
|
|
@@ -90,15 +89,15 @@ Both tools cost about 1,050 tokens of tool description per request.
|
|
|
90
89
|
|
|
91
90
|
## What is proven, and what is not
|
|
92
91
|
|
|
93
|
-
**Measured so far
|
|
92
|
+
**Measured so far.** In the live run at a 131k window, a pi-tuck cut took a median 0.09 s, where Pi's built-in compaction with a local model writing the summary took a median 269 s. A cut calls no model, so it costs no tokens. After a cut, pi-tuck carried about as many tokens as the built-in did, not fewer. In simulation, cutting at 60% of the room instead of 100% saved billed input for pi-tuck itself, and answers held in replay. At a 32k window, in one suite of 150 SWE-bench Verified tasks with one model, pi-tuck 1.0.5 resolved 120 tasks and Pi's built-in compaction 94 (+17.3 points, 95% lower bound +10.7). Observation masking resolved 117 (pi-tuck +2.0 points, lower bound −2.7: no difference shown). The limits of that result are in [Benchmarks](#benchmarks).
|
|
94
93
|
|
|
95
|
-
**Not
|
|
94
|
+
**Not claimed.** This README does not say that pi-tuck is as good as, or better than, Pi's built-in compaction at a 128k window or in any other setting than the one above. The tests were pre-registered before any result came in. The plans, and every result, including those that did not favour pi-tuck, are in [`benchmark/`](benchmark).
|
|
96
95
|
|
|
97
|
-
The
|
|
96
|
+
The pre-registered plan has three parts:
|
|
98
97
|
|
|
99
98
|
1. **A live run** at a 128k window on a local model, with pi-tuck 1.0.1. Ten chains of 30 SWE-bench Verified tasks per arm, with Pi's built-in compaction in one arm and pi-tuck in the other. Official grading.
|
|
100
|
-
2. **A paired replay with probe questions.** The same cuts, made by pi-tuck and by Pi's summarize prompts
|
|
101
|
-
3. **A 32k suite**, where tasks are bigger than the room, so cuts must summarise work in progress.
|
|
99
|
+
2. **A paired replay with probe questions.** The same cuts, made by pi-tuck and by Pi's summarize prompts, then questions about facts that fell before the cut. Pre-registered rule: the claim holds only if pi-tuck is no more than 3 points worse than the better compactor, with a 95% bound. The comparators are named in [`benchmark/PINS.md`](benchmark/PINS.md).
|
|
100
|
+
3. **A 32k suite**, where tasks are bigger than the room, so cuts must summarise work in progress. Its result is the 32k row above.
|
|
102
101
|
|
|
103
102
|
The plan: [`benchmark/PREREG.md`](benchmark/PREREG.md), [`benchmark/PREREG-32K.md`](benchmark/PREREG-32K.md), [`benchmark/PREREG-TERRA.md`](benchmark/PREREG-TERRA.md), and every change after the freeze in [`benchmark/DEVIATIONS.md`](benchmark/DEVIATIONS.md).
|
|
104
103
|
|
|
@@ -106,7 +105,7 @@ Known limits today:
|
|
|
106
105
|
|
|
107
106
|
- pi-tuck's summary does not explain why things happened. It holds the words that were said, not a model's reading of them. The agent must look things up. Each look-up costs a tool call.
|
|
108
107
|
- Small windows: an upstream that spends far more context on tool schemas than others (some OpenRouter upstreams do) can leave a small input room too little space, and pi-tuck then refuses to cut rather than overflow. This is what made 1.0.1 refuse at a 32k window with Pi's default 16,384 reserve. Since 1.0.4 the notice says when the provider is counting far above pi-tuck's estimate. In 1.0.4, after an oversize tool result or a "Nothing to compact" error from Pi, a session could keep refusing later prompts. 1.0.5 fixes both. Pin a different upstream, lower the reserve, or use a larger window; see [Small windows](#small-windows).
|
|
109
|
-
- Semantic search
|
|
108
|
+
- Semantic search (`tuck_search`) needs a Mac with Metal (Apple silicon). Elsewhere, compaction and `tuck_recall`'s word search work, there is no `tuck_search`, and nothing is downloaded.
|
|
110
109
|
|
|
111
110
|
## Benchmarks
|
|
112
111
|
|
|
@@ -114,6 +113,7 @@ Each row says what was measured, under which conditions, and where the data is.
|
|
|
114
113
|
|
|
115
114
|
| What | Result | Conditions | Source |
|
|
116
115
|
|---|---|---|---|
|
|
116
|
+
| Tasks resolved, 32k window | Pi's built-in compaction 94 of 150; pi-tuck 120 of 150 (+17.3 points, 95% lower bound +10.7); observation masking 117 of 150 (pi-tuck +2.0, lower bound −2.7: no difference shown). Turns per task: 9.4 (built-in), 24.0 (pi-tuck), 11.9 (masking). Tokens per session: 6.10 M, 6.39 M, 7.02 M. Spend per arm for the five sessions: $0.87, $0.55, $0.94 | pi-tuck 1.0.5 (commit `aa30824`, the 1.0.5 code; 1.0.6 and 1.0.7 are not measured). DeepSeek V4.1 Flash pinned to one upstream, window 32,768, reserve 16,384, 5 chains of 30 SWE-bench Verified tasks, paired by task, one-sided bootstrap over tasks. **Limits:** only pi-tuck is held to the 16,384-token room (the built-in sent a request over it in 97 of 150 tasks, masking in 73, so the window is nominal for them). The difference by chain is +10.0, −3.3, +50.0, +16.7 and +13.3 points; leaving out chain t03 it is +9.2. The bootstrap resamples tasks, not chains. The pi-tuck arm ran at a different time from the others. One model, one window. pi-tuck refused to cut mid-run in 5 tasks, where one fresh tool result was larger than the room; they count as unresolved. In the same setup pi-tuck 1.0.3 resolved 72 of 150, with 52 tasks lost because an earlier refusal carried forward; 1.0.5 fixes that | [`benchmark/RESULTS-32K-ACCEPT.md`](benchmark/RESULTS-32K-ACCEPT.md) |
|
|
117
117
|
| Cut time, live | pi-tuck median 0.087 s (90th percentile 0.205 s); Pi's built-in median 268.8 s (90th percentile 421.6 s). Context carried after a cut, median: 27,751 against 29,241 tokens | **Interim.** pi-tuck 1.0.1, Pi 1.4.0, SWE-bench Verified chains of 30 tasks, 131,072-token window, reply reserve 32,768, local model `mac-studio/main` writing Pi's summaries. 27 cuts per arm, 5 chains. The built-in time depends on that model and on server load. pi-tuck's time is an upper bound read from the session file | [`benchmark/RESULTS-INTERIM.md`](benchmark/RESULTS-INTERIM.md) section 1 |
|
|
118
118
|
| Cut time, paired replay | pi-tuck median 0.062 s (range 0.005 to 0.48 s); local-model summarizer 193.4 s (77.3 to 413.3 s); frontier summarizer 96.1 s (39.1 to 267.8 s; measured on the retired model `openai/gpt-6-astra`; feeds no test). Context carried after a cut, median: 28,006 (pi-tuck), 26,424 (local), 25,555 (frontier) tokens | **Interim.** The same recorded sessions cut at the same positions. pi-tuck: 10 recordings, 52 cuts. Summarizers: 6 recordings, 28 and 29 cuts. pi-tuck's time leaves out the background search index | [`benchmark/RESULTS-INTERIM.md`](benchmark/RESULTS-INTERIM.md) section 2 |
|
|
119
119
|
| Billed input, cutting at 60% of the room instead of 100% | −17.9% at 128k, −14.0% at 32k with automatic prompt caching; −17.0% and −9.6% with explicit cache breakpoints. Cuts go from 6 to 12 (128k) and from 84 to 157 (32k); none frees under a tenth of the room | Simulated billing, not provider bills. Prompt caching priced. Build `1bda64b` (the default shipped in 1.0.3). 5 recorded live sessions. 128k = 131,072 window, reserve 32,768. 32k = 32,768 window, reserve 16,384 | [`rnd/replay32/RESULT-lean.md`](rnd/replay32/RESULT-lean.md) |
|
|
@@ -139,7 +139,7 @@ pi-tuck needs none. Two are optional. Put them in `pi-tuck.json` in Pi's agent f
|
|
|
139
139
|
```
|
|
140
140
|
|
|
141
141
|
- **`cutAt`** (above 0, at most 1; default 0.6) is the part of the input room a request may fill before pi-tuck cuts. At 0.6, each cut leaves room to grow, so fewer tokens are carried and billed per request. `{ "cutAt": 1 }` cuts at the full room. A lower value cuts earlier and smaller. The whole room still decides which request pi-tuck refuses.
|
|
142
|
-
- **`semanticRecall`** (`false` turns semantic search off). Nothing is then downloaded, and `tuck_search` uses keywords only.
|
|
142
|
+
- **`semanticRecall`** (`false` turns semantic search off, on Apple silicon). Nothing is then downloaded, and `tuck_search` uses keywords only.
|
|
143
143
|
|
|
144
144
|
A bad file is reported at session start, and pi-tuck uses the defaults.
|
|
145
145
|
|
|
@@ -161,7 +161,7 @@ If you reach the model through OpenRouter, you can also pin one upstream, so tha
|
|
|
161
161
|
|
|
162
162
|
## Semantic search
|
|
163
163
|
|
|
164
|
-
Semantic search is on by default on macOS with Apple silicon. It runs entirely on your Mac: an embedding model, [EmbeddingGemma-2](https://huggingface.co/google/embeddinggemma-2), served by [llama.cpp](https://github.com/ggml-org/llama.cpp) on
|
|
164
|
+
Semantic search is on by default on macOS with Apple silicon. It runs entirely on your Mac: an embedding model, [EmbeddingGemma-2](https://huggingface.co/google/embeddinggemma-2), served by [llama.cpp](https://github.com/ggml-org/llama.cpp) on your Mac's GPU (Metal). No text leaves your machine. Elsewhere (Linux, Intel Macs) there is no `tuck_search`, and nothing is downloaded; compaction and `tuck_recall` work. On a Mac where Metal fails to start, semantic recall goes off with the reason "Semantic recall needs Metal; word recall still works".
|
|
165
165
|
|
|
166
166
|
The first time a session starts, pi-tuck downloads what it needs, once, about 390 MB, in the background. Keyword search works meanwhile:
|
|
167
167
|
|
package/dist/index.js
CHANGED
|
@@ -18,7 +18,9 @@ export default async function (pi) {
|
|
|
18
18
|
// `/tuck-semantic off` holds for the rest of the session, including over a setup that finishes later.
|
|
19
19
|
let off = false;
|
|
20
20
|
const settings = readSettings(join(getAgentDir(), "pi-tuck.json"));
|
|
21
|
-
|
|
21
|
+
// Semantic recall runs on Metal, so only on Apple silicon; elsewhere the model is not offered tuck_search at all.
|
|
22
|
+
const appleSilicon = process.platform === "darwin" && process.arch === "arm64";
|
|
23
|
+
const handle = await installCompaction(pi, { semanticRecall, search: appleSilicon, cutAt: settings.cutAt, onSemanticSetting: async (enabled) => { off = !enabled; } });
|
|
22
24
|
if (settings.semanticRecall === false)
|
|
23
25
|
return;
|
|
24
26
|
let session = 0, said = "", setup;
|
|
@@ -37,8 +39,8 @@ export default async function (pi) {
|
|
|
37
39
|
off = false;
|
|
38
40
|
if (settings.error)
|
|
39
41
|
once(ctx, `pi-tuck: ${settings.error}; using the defaults.`, "warning");
|
|
40
|
-
if (
|
|
41
|
-
return once(ctx, "pi-tuck: semantic recall needs macOS on Apple silicon
|
|
42
|
+
if (!appleSilicon)
|
|
43
|
+
return once(ctx, "pi-tuck: semantic recall needs macOS on Apple silicon; compaction and word recall work.", "info");
|
|
42
44
|
// Semantic recall keeps its runtime and indexes outside the project it serves, so not when Pi runs in a folder that holds its
|
|
43
45
|
// agent folder, such as the home folder.
|
|
44
46
|
if (within(real(getAgentDir()), real(ctx.cwd)))
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "52dc04a544dfbc3cdf7e9dccd5f192350d56889968cf2bd4bd176405ab65a29a";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "52dc04a544dfbc3cdf7e9dccd5f192350d56889968cf2bd4bd176405ab65a29a";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
/** What a request carries in place of one fresh tool result that the room cannot hold: its head and tail, and where the rest is. */
|
|
3
|
+
export interface Clip {
|
|
4
|
+
toolCallId: string;
|
|
5
|
+
content: ToolResultMessage["content"];
|
|
6
|
+
}
|
|
7
|
+
/** The round's text results within `budget` tokens together: each result smaller than an equal share of what is left stays whole, and the
|
|
8
|
+
* rest are clipped to that share, keeping the most head and tail characters that fit it. None when the round already fits, or when a share
|
|
9
|
+
* cannot hold even the locator. A round of one result gets the whole budget. Deterministic: the same round and budget always clip the same way. */
|
|
10
|
+
export declare function clipFor(results: readonly ToolResultMessage[], entryIdOf: (toolCallId: string) => string | undefined, budget: number): Clip[];
|
|
11
|
+
/** The messages with each clipped result replaced; the same array when nothing is clipped. */
|
|
12
|
+
export declare const applyClips: <M extends {
|
|
13
|
+
role: string;
|
|
14
|
+
}>(messages: M[], clips: ReadonlyMap<string, Clip>) => M[];
|
package/dist/src/clip.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { conversationTokens } from "./budget.js";
|
|
2
|
+
import { MAX_RECALL_CHARACTERS } from "./recall.js";
|
|
3
|
+
/** The text `tuck_recall {entryId, offset}` reads from a text-only result; any other result is not clipped. */
|
|
4
|
+
const textOf = (message) => message.content.every(block => block.type === "text")
|
|
5
|
+
? message.content.map(block => block.type === "text" ? block.text : "").join("\n") : undefined;
|
|
6
|
+
const clipped = (text, keep, entryId) => {
|
|
7
|
+
const head = Math.ceil(keep / 2), tail = keep - head;
|
|
8
|
+
return [{ type: "text", text: text.slice(0, head) +
|
|
9
|
+
`\n[pi-tuck: characters ${head}-${text.length - tail} of ${text.length} are not shown, to fit the context window; read them with ` +
|
|
10
|
+
`tuck_recall {"entryId":"${entryId}","offset":${head}}, up to ${MAX_RECALL_CHARACTERS} characters a call]\n` + text.slice(text.length - tail) }];
|
|
11
|
+
};
|
|
12
|
+
/** The round's text results within `budget` tokens together: each result smaller than an equal share of what is left stays whole, and the
|
|
13
|
+
* rest are clipped to that share, keeping the most head and tail characters that fit it. None when the round already fits, or when a share
|
|
14
|
+
* cannot hold even the locator. A round of one result gets the whole budget. Deterministic: the same round and budget always clip the same way. */
|
|
15
|
+
export function clipFor(results, entryIdOf, budget) {
|
|
16
|
+
const tokens = (message) => conversationTokens([message]);
|
|
17
|
+
const round = results.flatMap(result => {
|
|
18
|
+
const text = textOf(result), entryId = entryIdOf(result.toolCallId);
|
|
19
|
+
return text === undefined || !entryId ? [] : [{ result, text, entryId, size: tokens(result) }];
|
|
20
|
+
})
|
|
21
|
+
.sort((a, b) => a.size - b.size);
|
|
22
|
+
if (round.reduce((sum, item) => sum + item.size, 0) <= budget)
|
|
23
|
+
return [];
|
|
24
|
+
const clips = [];
|
|
25
|
+
let left = budget;
|
|
26
|
+
for (const [at, { result, text, entryId, size }] of round.entries()) {
|
|
27
|
+
const share = Math.floor(left / (round.length - at));
|
|
28
|
+
if (size <= share) {
|
|
29
|
+
left -= size;
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
const fits = (keep) => tokens({ ...result, content: clipped(text, keep, entryId) }) <= share;
|
|
33
|
+
if (!fits(0))
|
|
34
|
+
return [];
|
|
35
|
+
let low = 0, high = text.length;
|
|
36
|
+
while (low < high) {
|
|
37
|
+
const mid = Math.ceil((low + high) / 2);
|
|
38
|
+
if (fits(mid))
|
|
39
|
+
low = mid;
|
|
40
|
+
else
|
|
41
|
+
high = mid - 1;
|
|
42
|
+
}
|
|
43
|
+
const content = clipped(text, low, entryId);
|
|
44
|
+
clips.push({ toolCallId: result.toolCallId, content });
|
|
45
|
+
left -= tokens({ ...result, content });
|
|
46
|
+
}
|
|
47
|
+
return clips;
|
|
48
|
+
}
|
|
49
|
+
/** The messages with each clipped result replaced; the same array when nothing is clipped. */
|
|
50
|
+
export const applyClips = (messages, clips) => !clips.size ? messages
|
|
51
|
+
: messages.map(message => message.role === "toolResult" && clips.has(message.toolCallId)
|
|
52
|
+
? { ...message, content: clips.get(message.toolCallId).content } : message);
|
package/dist/src/compaction.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export declare const MEASURED_CALIBRATION: Readonly<Record<string, number>>;
|
|
|
23
23
|
export declare function seedWindow(branch: readonly SessionEntry[], key: string): number[];
|
|
24
24
|
export interface CompactionOptions {
|
|
25
25
|
semanticRecall?: SemanticRecallController;
|
|
26
|
+
/** Whether `tuck_search` exists: false where semantic recall cannot run (off Apple silicon), so the model is offered word recall only. */
|
|
27
|
+
search?: boolean;
|
|
26
28
|
/** Optional host-owned transient operator preference; handle operations never call back. */
|
|
27
29
|
onSemanticSetting?: (enabled: boolean) => Promise<unknown>;
|
|
28
30
|
semanticStatus?: () => unknown;
|
package/dist/src/compaction.js
CHANGED
|
@@ -5,6 +5,7 @@ import { exportSource } from "./source-export.js";
|
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import { assertNativeToolRounds } from "./protocol.js";
|
|
7
7
|
import { projectedMessages } from "./omissions.js";
|
|
8
|
+
import { applyClips, clipFor } from "./clip.js";
|
|
8
9
|
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart, validCutAt } from "./budget.js";
|
|
9
10
|
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
10
11
|
import { convertToLlm, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
@@ -97,7 +98,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
97
98
|
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
98
99
|
if (!validCutAt(cutAt))
|
|
99
100
|
throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
|
|
100
|
-
const semantic = options.semanticRecall ?? createSemanticRecall();
|
|
101
|
+
const semantic = options.semanticRecall ?? createSemanticRecall(), search = options.search ?? true;
|
|
101
102
|
const hostAccess = () => {
|
|
102
103
|
if (!active())
|
|
103
104
|
throw new Error("Compaction host is inactive");
|
|
@@ -269,7 +270,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
269
270
|
learn(ratio);
|
|
270
271
|
});
|
|
271
272
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
272
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); });
|
|
273
|
+
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); });
|
|
273
274
|
const active = () => options.active?.() ?? true;
|
|
274
275
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
275
276
|
// The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
|
|
@@ -298,10 +299,10 @@ export async function installCompaction(pi, options = {}) {
|
|
|
298
299
|
};
|
|
299
300
|
const fits = (tokens, limit, ctx) => corrected(tokens, ctx) < limit;
|
|
300
301
|
// The saved conversation and a cut's quantities are counted by the one function the guard counts with (strict, as the context hook is).
|
|
301
|
-
const measureFor = (ctx) => (messages) => projectedConversationTokens(messages, ctx, options.recallPolicy);
|
|
302
|
+
const measureFor = (ctx) => (messages) => projectedConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy);
|
|
302
303
|
// The planner counts with the same function, except that it does not fail on a summary in another format: a cut can replace one
|
|
303
304
|
// (an operator's /compact repairs a session whose saved summary is foreign). Identical to the strict one for this compactor's summaries.
|
|
304
|
-
const plannerMeasureFor = (ctx) => (messages) => measurableConversationTokens(messages, ctx, options.recallPolicy);
|
|
305
|
+
const plannerMeasureFor = (ctx) => (messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy);
|
|
305
306
|
const hostTokens = (ctx) => options.hostMessages ? conversationTokens(options.hostMessages(ctx)) : 0;
|
|
306
307
|
const toolShapes = (names) => {
|
|
307
308
|
const selected = new Set(names);
|
|
@@ -315,6 +316,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
315
316
|
let pendingCut;
|
|
316
317
|
let failure;
|
|
317
318
|
let pendingBoundaryCommit = false;
|
|
319
|
+
// Fresh tool results the room cannot hold, by call id: each request of the run carries the clip in their place; the session keeps them
|
|
320
|
+
// whole. A clip ends with its run like any failure would: the next run plans from the session as it is, where the result is no longer fresh.
|
|
321
|
+
const clips = new Map();
|
|
318
322
|
const fail = (error, ctx) => {
|
|
319
323
|
recorder.stop("native-attempt-failed");
|
|
320
324
|
const notify = !failure && error instanceof CapacityError;
|
|
@@ -364,7 +368,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
364
368
|
});
|
|
365
369
|
pi.registerTool({
|
|
366
370
|
name: "tuck_recall", label: "pi-tuck history", parameters: RecallInput,
|
|
367
|
-
description: "Keyword lookup locates particular text in your current branch. {query} returns ranked passages with read coordinates; punctuation separates words. Pass the returned next as {continuation} unchanged. Use tuck_search for historical information relevant to a question, including different wording and qualifications. Knowing an identifier does not itself justify narrowing discovery. {entryId} or {index} (a summary sourceIndex or #N) reads one source, optionally with query, offset and limit in UTF-16 characters (limit defaults to 8000). {} browses history; {around} expands neighbors; {entryId,image} reads an image; outputPath writes an original to a file with the native write tool. Hidden reasoning and system records are excluded.",
|
|
371
|
+
description: "Keyword lookup locates particular text in your current branch. {query} returns ranked passages with read coordinates; punctuation separates words. Pass the returned next as {continuation} unchanged. " + (search ? "Use tuck_search for historical information relevant to a question, including different wording and qualifications. " : "") + "Knowing an identifier does not itself justify narrowing discovery. {entryId} or {index} (a summary sourceIndex or #N) reads one source, optionally with query, offset and limit in UTF-16 characters (limit defaults to 8000). {} browses history; {around} expands neighbors; {entryId,image} reads an image; outputPath writes an original to a file with the native write tool. Hidden reasoning and system records are excluded.",
|
|
368
372
|
async execute(_id, raw, signal, _update, ctx) {
|
|
369
373
|
access();
|
|
370
374
|
const input = checkedInput(raw);
|
|
@@ -391,31 +395,32 @@ export async function installCompaction(pi, options = {}) {
|
|
|
391
395
|
return reply(tuckRecall(tuck, ctx.sessionManager.getEntries(), branch, input, options.recallPolicy));
|
|
392
396
|
},
|
|
393
397
|
});
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
398
|
+
if (search)
|
|
399
|
+
pi.registerTool({ name: "tuck_search", label: "History search", parameters: SearchInput,
|
|
400
|
+
description: "Find historical information relevant to a question, including different wording and related qualifications. Combines keyword and local embedding search. Knowing a name or identifier does not imply keyword-only lookup is sufficient. Returns original evidence up to 20000 characters; not exhaustive. Semantic search may be off or incomplete, which is not the same as no matches. Read exact originals and their context with tuck_recall at the returned locators. priorResults lists source ranges already read.",
|
|
401
|
+
async execute(_id, raw, signal, _update, ctx) {
|
|
402
|
+
access();
|
|
403
|
+
if (!Check(SearchInput, raw))
|
|
404
|
+
throw new Error("Invalid broad search input");
|
|
405
|
+
const input = structuredClone(raw);
|
|
406
|
+
recorder.capture("recall-action", "authorized-native-tool-invocation-no-result-copy", input);
|
|
407
|
+
// Bind the actual independent lexical offer, including attribution derived
|
|
408
|
+
// from custom markers and receipts excluded from semantic indexing. Rebuild
|
|
409
|
+
// under current caller policy after await, when the session moved; never send this block to the encoder.
|
|
410
|
+
const wordOffer = () => tuckRecall(tuck, ctx.sessionManager.getEntries(), ctx.sessionManager.getBranch(), { query: input.query }, options.recallPolicy);
|
|
411
|
+
// The key is taken in the same synchronous step as the first lexical pass. After the await the second pass runs only if it moved (see eligibilityKey); a moved key alone invalidates nothing.
|
|
412
|
+
// The snapshot id check covers every in-contract change (any branch change moves the id); the key-gated second pass is defence in depth for out-of-contract callers.
|
|
413
|
+
const provider = semanticProvider(ctx), initialSnapshot = provider(), initialKey = eligibilityKey(ctx), words = wordOffer(), wordText = JSON.stringify(words);
|
|
414
|
+
const related = await semantic.search(input.query, provider, signal, input.priorResults ?? [], words);
|
|
415
|
+
access();
|
|
416
|
+
if (signal?.aborted)
|
|
417
|
+
throw Object.assign(new Error('Semantic recall cancelled'), { name: 'AbortError' });
|
|
418
|
+
const now = provider();
|
|
419
|
+
if (initialSnapshot.id !== now.id || (eligibilityKeyMoved(ctx, initialKey) && wordText !== JSON.stringify(wordOffer())))
|
|
420
|
+
return { ...reply({ status: "snapshot_invalidated", notice: "Search source eligibility or version changed; no earlier evidence delivered. Request a fresh query." }), isError: true };
|
|
421
|
+
const page = budgetSemantic(related, 20000 - wordText.length - 1);
|
|
422
|
+
return { content: [{ type: "text", text: wordText }, { type: "text", text: JSON.stringify(page) }], details: { semantic: { status: page.status, snapshotId: page.coverage.snapshotId } } };
|
|
423
|
+
} });
|
|
419
424
|
pi.on("session_before_compact", (event, ctx) => {
|
|
420
425
|
try {
|
|
421
426
|
if (!active())
|
|
@@ -494,7 +499,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
494
499
|
}
|
|
495
500
|
});
|
|
496
501
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
497
|
-
pi.on("session_start", () => { recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
502
|
+
pi.on("session_start", () => { clips.clear(); recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
498
503
|
// The cut before a run. Pi 1.4 counts a run as started before before_agent_start, and ctx.compact() first aborts and waits for an
|
|
499
504
|
// idle session, so a cut asked for there would wait on its own run. A prompt's cut is therefore made on `input`, while the session
|
|
500
505
|
// is idle. before_agent_start decides only for a run no input started (one a custom message starts) and cuts only if the session is
|
|
@@ -570,30 +575,31 @@ export async function installCompaction(pi, options = {}) {
|
|
|
570
575
|
fail(error, ctx);
|
|
571
576
|
}
|
|
572
577
|
});
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
578
|
+
// A cut at a public boundary, drafted against the boundary's own preview: "tool-loop" between the rounds of a run, "run-end" once its
|
|
579
|
+
// last reply is in. A run-end cut is made only at or above the band, as a tool-loop one, so the next prompt starts from below it: an
|
|
580
|
+
// input cut is bound by Pi's keepRecentTokens, and a large prompt right after a cut could leave it nothing to cut. A run-end cut that
|
|
581
|
+
// cannot be planned is no fault: the run is over, and the next prompt decides afresh. Only a tool-loop boundary has fresh results to clip.
|
|
582
|
+
const boundaryCut = (event, ctx, reason) => {
|
|
583
|
+
access();
|
|
584
|
+
// Earlier boundary drafts have a public preview but no original-source compiler
|
|
585
|
+
// contract. Preserve them and leave admission to the final guard.
|
|
586
|
+
if (event.entries.length || !isDeepStrictEqual(event.context.contextMessages, ctx.sessionManager.buildSessionProjection().messages))
|
|
587
|
+
return;
|
|
588
|
+
if (!ctx.model)
|
|
589
|
+
return;
|
|
590
|
+
const { response, room, band } = limits(ctx);
|
|
591
|
+
const measure = measureFor(ctx), saved = event.context.contextMessages;
|
|
592
|
+
// One snapshot per decision. Messages queued for the next request (steering, follow-ups) are in that request but not in the
|
|
593
|
+
// saved conversation. Pi's peek returns the steering queue, else the follow-up queue; a follow-up is not in the very next
|
|
594
|
+
// request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
|
|
595
|
+
const system = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt());
|
|
596
|
+
const host = hostTokens(ctx);
|
|
597
|
+
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages) : 0;
|
|
598
|
+
const mandatory = system + host + queued;
|
|
599
|
+
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
600
|
+
const decide = () => {
|
|
594
601
|
const before = measure(saved);
|
|
595
|
-
|
|
596
|
-
const decision = decideCut({
|
|
602
|
+
return decideCut({
|
|
597
603
|
total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
598
604
|
unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
|
|
599
605
|
plan: (target) => {
|
|
@@ -611,14 +617,44 @@ export async function installCompaction(pi, options = {}) {
|
|
|
611
617
|
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
612
618
|
},
|
|
613
619
|
});
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
620
|
+
};
|
|
621
|
+
let decision = decide();
|
|
622
|
+
// Only a request that would be refused is clipped. A fresh result is kept whole by every cut (it has not reached the model yet), so
|
|
623
|
+
// a round larger than the room refuses the run. In their place the request carries the results' heads and tails, together at most
|
|
624
|
+
// half of what the room leaves the conversation, each with a tuck_recall locator to its rest. When even that is refused the refusal stands.
|
|
625
|
+
if (decision.kind === "fault" && event.toolResults) {
|
|
626
|
+
const branch = ctx.sessionManager.getBranch();
|
|
627
|
+
const entryIdOf = (toolCallId) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
|
|
628
|
+
const round = clipFor(event.toolResults, entryIdOf, Math.floor((room - mandatory) / 2));
|
|
629
|
+
if (round.length) {
|
|
630
|
+
for (const clip of round)
|
|
631
|
+
clips.set(clip.toolCallId, clip);
|
|
632
|
+
const clippedDecision = decide();
|
|
633
|
+
if (clippedDecision.kind === "fault")
|
|
634
|
+
for (const clip of round)
|
|
635
|
+
clips.delete(clip.toolCallId);
|
|
636
|
+
else
|
|
637
|
+
decision = clippedDecision;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
const before = measure(saved);
|
|
641
|
+
if (decision.kind === "fault" && reason === "tool-loop")
|
|
642
|
+
throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
643
|
+
if (decision.kind !== "cut")
|
|
644
|
+
return;
|
|
645
|
+
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, reason, before, true, false, previousSummary, { response, host, mandatory, target: decision.target, margin: room - band });
|
|
646
|
+
return { entries: [{ type: "compaction", summary: compaction.summary,
|
|
647
|
+
firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
|
|
648
|
+
};
|
|
649
|
+
pi.on("turn_end", (event, ctx) => {
|
|
650
|
+
try {
|
|
651
|
+
if (!active() || !admittedRun || event.outcome !== "completed" || ctx.signal?.aborted ||
|
|
652
|
+
!event.context.canContinue || !event.toolResults.length || !settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
617
653
|
return;
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
654
|
+
const result = boundaryCut(event, ctx, "tool-loop");
|
|
655
|
+
if (result)
|
|
656
|
+
pendingBoundaryCommit = true;
|
|
657
|
+
return result;
|
|
622
658
|
}
|
|
623
659
|
catch (error) {
|
|
624
660
|
fail(error, ctx);
|
|
@@ -629,7 +665,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
629
665
|
if (!active())
|
|
630
666
|
return;
|
|
631
667
|
access();
|
|
632
|
-
return { messages: projectedMessages(event.messages, ctx, options.recallPolicy) };
|
|
668
|
+
return { messages: applyClips(projectedMessages(event.messages, ctx, options.recallPolicy), clips) };
|
|
633
669
|
}
|
|
634
670
|
catch (error) {
|
|
635
671
|
try {
|
|
@@ -664,12 +700,21 @@ export async function installCompaction(pi, options = {}) {
|
|
|
664
700
|
fail(new (event.fromExtension ? CommitFault : Error)(event.errorMessage ?? "Compaction failed"), ctx);
|
|
665
701
|
});
|
|
666
702
|
pi.on("agent_before_settle", (event, ctx) => {
|
|
667
|
-
if (
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
fail(new CommitFault("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
|
|
671
|
-
else
|
|
703
|
+
if (pendingBoundaryCommit) {
|
|
704
|
+
if (event.outcome === "error")
|
|
705
|
+
return void fail(new CommitFault("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
|
|
672
706
|
pendingBoundaryCommit = false;
|
|
707
|
+
}
|
|
708
|
+
// Pi commits these entries before the run settles.
|
|
709
|
+
try {
|
|
710
|
+
if (!active() || !admittedRun || event.outcome !== "completed" || ctx.signal?.aborted || failure ||
|
|
711
|
+
!settingsFor().getCompactionSettings(ctx.model).enabled)
|
|
712
|
+
return;
|
|
713
|
+
return boundaryCut(event, ctx, "run-end");
|
|
714
|
+
}
|
|
715
|
+
catch (error) {
|
|
716
|
+
fail(error, ctx);
|
|
717
|
+
}
|
|
673
718
|
});
|
|
674
719
|
pi.on("context_with_system", (event, ctx) => {
|
|
675
720
|
// Reaching the next request boundary proves native commit returned, not durable storage.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { type RecallPolicy } from './recall.js';
|
|
2
2
|
import type { SessionEntry } from '@earendil-works/pi-coding-agent';
|
|
3
|
-
export declare const DIAGNOSTIC_BUILD = "
|
|
3
|
+
export declare const DIAGNOSTIC_BUILD = "52dc04a544dfbc3cdf7e9dccd5f192350d56889968cf2bd4bd176405ab65a29a";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
package/dist/src/diagnostics.js
CHANGED
|
@@ -8,7 +8,7 @@ import { createRecall } from './recall.js';
|
|
|
8
8
|
export const DIAGNOSTIC_BUILD = BUILD_ID;
|
|
9
9
|
export function attachReceipt(draft, reason, reserveTokens, contextWindow, resolved = {}) {
|
|
10
10
|
const details = draft.details && typeof draft.details === 'object' ? draft.details : {};
|
|
11
|
-
const receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
|
11
|
+
const receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' || reason === 'run-end' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
|
12
12
|
return { ...draft, details: { ...details, diagnosticReceipt: receipt } };
|
|
13
13
|
}
|
|
14
14
|
export function receiptId(entry) {
|
|
@@ -112,13 +112,15 @@ export declare const HEAL_STEADY_MS = 600000;
|
|
|
112
112
|
export declare const IDLE_STOP_MS = 600000;
|
|
113
113
|
/** A worker call's deadline once the worker has answered a call after `ready`: index, embed and tokenize on a loaded model. */
|
|
114
114
|
export declare const CALL_DEADLINE_MS = 60000;
|
|
115
|
+
/** Why semantic recall is off where the embedding server cannot start on Metal (a VM, a Mac without a GPU): it runs on Metal only. */
|
|
116
|
+
export declare const METAL_UNAVAILABLE = "Semantic recall needs Metal; word recall still works";
|
|
115
117
|
/** What an `embed` call adds to its deadline for each text it carries. A batch of 8 passages on a starved CPU took longer than the per-call
|
|
116
118
|
* deadline, and the worker failed when it should have slowed down; a query (one text) still fails fast when the server hangs. The worker
|
|
117
119
|
* scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
|
|
118
120
|
export declare const EMBED_TEXT_MS = 30000;
|
|
119
121
|
/** The deadline for a worker's start: Python start, `ready` (which verifies every runtime file) and the first call after it, which loads the
|
|
120
122
|
* model and starts its native server. On a busy machine that takes minutes; held to the per-call deadline, the worker never started and
|
|
121
|
-
* semantic recall went unavailable. It stays above the worker's own native start
|
|
123
|
+
* semantic recall went unavailable. It stays above the worker's own native start limit (540 s, in semantic-worker.py). */
|
|
122
124
|
export declare const START_DEADLINE_MS = 600000;
|
|
123
125
|
/** The most units one index window holds. The worker's SQLite store stops at 8,008 rows (`PRAGMA max_page_count=8192` in scripts/semantic-worker.py), so a cap below that keeps a window of short units from filling the store and sticking in `unavailable`. Raise it only together with the store. */
|
|
124
126
|
export declare const MAX_INDEX_UNITS = 8000;
|
|
@@ -171,13 +171,15 @@ export const HEAL_STEADY_MS = 600000;
|
|
|
171
171
|
export const IDLE_STOP_MS = 600000;
|
|
172
172
|
/** A worker call's deadline once the worker has answered a call after `ready`: index, embed and tokenize on a loaded model. */
|
|
173
173
|
export const CALL_DEADLINE_MS = 60000;
|
|
174
|
+
/** Why semantic recall is off where the embedding server cannot start on Metal (a VM, a Mac without a GPU): it runs on Metal only. */
|
|
175
|
+
export const METAL_UNAVAILABLE = 'Semantic recall needs Metal; word recall still works';
|
|
174
176
|
/** What an `embed` call adds to its deadline for each text it carries. A batch of 8 passages on a starved CPU took longer than the per-call
|
|
175
177
|
* deadline, and the worker failed when it should have slowed down; a query (one text) still fails fast when the server hangs. The worker
|
|
176
178
|
* scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
|
|
177
179
|
export const EMBED_TEXT_MS = 30000;
|
|
178
180
|
/** The deadline for a worker's start: Python start, `ready` (which verifies every runtime file) and the first call after it, which loads the
|
|
179
181
|
* model and starts its native server. On a busy machine that takes minutes; held to the per-call deadline, the worker never started and
|
|
180
|
-
* semantic recall went unavailable. It stays above the worker's own native start
|
|
182
|
+
* semantic recall went unavailable. It stays above the worker's own native start limit (540 s, in semantic-worker.py). */
|
|
181
183
|
export const START_DEADLINE_MS = 600000;
|
|
182
184
|
/** The most units one index window holds. The worker's SQLite store stops at 8,008 rows (`PRAGMA max_page_count=8192` in scripts/semantic-worker.py), so a cap below that keeps a window of short units from filling the store and sticking in `unavailable`. Raise it only together with the store. */
|
|
183
185
|
export const MAX_INDEX_UNITS = 8000;
|
|
@@ -274,7 +276,7 @@ class LocalWorker {
|
|
|
274
276
|
if (pending) {
|
|
275
277
|
clearTimeout(pending.timer);
|
|
276
278
|
this.pending.delete(reply.id);
|
|
277
|
-
reply.ok ? pending.resolve(reply.result) : pending.reject(new WorkerFailure('Local semantic runtime: ' + reply.error?.code, reply.error?.code ?? null));
|
|
279
|
+
reply.ok ? pending.resolve(reply.result) : pending.reject(new WorkerFailure(reply.error?.code === 'metal_unavailable' ? METAL_UNAVAILABLE : 'Local semantic runtime: ' + reply.error?.code, reply.error?.code ?? null));
|
|
278
280
|
if (this.nativeHelper && this.nativeInputId === reply.id) {
|
|
279
281
|
this.nativeInputId = undefined;
|
|
280
282
|
this.pumpNativeInput();
|
package/index.ts
CHANGED
|
@@ -22,7 +22,9 @@ export default async function (pi: ExtensionAPI) {
|
|
|
22
22
|
// `/tuck-semantic off` holds for the rest of the session, including over a setup that finishes later.
|
|
23
23
|
let off = false;
|
|
24
24
|
const settings = readSettings(join(getAgentDir(), "pi-tuck.json"));
|
|
25
|
-
|
|
25
|
+
// Semantic recall runs on Metal, so only on Apple silicon; elsewhere the model is not offered tuck_search at all.
|
|
26
|
+
const appleSilicon = process.platform === "darwin" && process.arch === "arm64";
|
|
27
|
+
const handle = await installCompaction(pi, { semanticRecall, search: appleSilicon, cutAt: settings.cutAt, onSemanticSetting: async enabled => { off = !enabled; } });
|
|
26
28
|
if (settings.semanticRecall === false) return;
|
|
27
29
|
let session = 0, said = "", setup: Promise<unknown> | undefined;
|
|
28
30
|
// A context outlives its session (a setup can finish after /new); a notice to an ended session is dropped.
|
|
@@ -33,8 +35,8 @@ export default async function (pi: ExtensionAPI) {
|
|
|
33
35
|
const current = ++session;
|
|
34
36
|
off = false;
|
|
35
37
|
if (settings.error) once(ctx, `pi-tuck: ${settings.error}; using the defaults.`, "warning");
|
|
36
|
-
if (
|
|
37
|
-
return once(ctx, "pi-tuck: semantic recall needs macOS on Apple silicon
|
|
38
|
+
if (!appleSilicon)
|
|
39
|
+
return once(ctx, "pi-tuck: semantic recall needs macOS on Apple silicon; compaction and word recall work.", "info");
|
|
38
40
|
// Semantic recall keeps its runtime and indexes outside the project it serves, so not when Pi runs in a folder that holds its
|
|
39
41
|
// agent folder, such as the home folder.
|
|
40
42
|
if (within(real(getAgentDir()), real(ctx.cwd)))
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tuck",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.8",
|
|
4
4
|
"description": "Deterministic compaction for Pi: compact summaries that keep the dialogue and the files touched, plus history recall by keyword and by meaning, all local.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -92,9 +92,9 @@ def load():
|
|
|
92
92
|
# Pinned common/arg.cpp supports LLAMA_API_KEY; server-http.cpp requires Bearer
|
|
93
93
|
# on /props and embeddings, but /health is public. No token in process argv.
|
|
94
94
|
env['LLAMA_API_KEY']=encoder_token
|
|
95
|
-
def launch(
|
|
95
|
+
def launch(limit):
|
|
96
96
|
"""The server, or None when it exits or is not healthy within `limit` seconds (it is then stopped)."""
|
|
97
|
-
child=subprocess.Popen([str(executable),'-m',str(ROOT/'model.gguf'),'--embedding','--pooling','mean','--embd-normalize','2','--ctx-size','8192','--batch-size','8192','--ubatch-size','8192','--parallel','1','--n-gpu-layers',
|
|
97
|
+
child=subprocess.Popen([str(executable),'-m',str(ROOT/'model.gguf'),'--embedding','--pooling','mean','--embd-normalize','2','--ctx-size','8192','--batch-size','8192','--ubatch-size','8192','--parallel','1','--n-gpu-layers','99','--cache-type-k','f32','--cache-type-v','f32','--flash-attn','off','--threads','4',*transport,'--no-webui','--log-disable'],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env=env)
|
|
98
98
|
started=time.monotonic()
|
|
99
99
|
while child.poll() is None and time.monotonic()-started<=limit:
|
|
100
100
|
try:
|
|
@@ -106,13 +106,12 @@ def load():
|
|
|
106
106
|
if child.poll() is None:
|
|
107
107
|
child.kill();child.wait()
|
|
108
108
|
return None
|
|
109
|
-
# Metal
|
|
110
|
-
#
|
|
111
|
-
# healthy, and
|
|
112
|
-
# deadline (START_DEADLINE_MS, 600 s).
|
|
113
|
-
server=
|
|
114
|
-
if server is None:
|
|
115
|
-
if server is None:raise Refusal('native_start_failure')
|
|
109
|
+
# Semantic recall runs on Metal only (the project's platform choice): on Apple silicon it embeds a batch about 56 times faster than the
|
|
110
|
+
# CPU did (0.7 s against 39 s for 8 passages at load 44-76). Where Metal cannot start (a VM, no GPU) the server exits or never becomes
|
|
111
|
+
# healthy, and semantic recall goes off with that reason; word recall keeps working. A busy machine can take minutes to start it; the
|
|
112
|
+
# limit stays under the host's start deadline (START_DEADLINE_MS, 600 s).
|
|
113
|
+
server=launch(540)
|
|
114
|
+
if server is None:raise Refusal('metal_unavailable')
|
|
116
115
|
return server
|
|
117
116
|
def run(req):
|
|
118
117
|
op=req['op']
|
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "52dc04a544dfbc3cdf7e9dccd5f192350d56889968cf2bd4bd176405ab65a29a";
|
package/src/clip.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
|
2
|
+
import { conversationTokens, type Messages } from "./budget.js";
|
|
3
|
+
import { MAX_RECALL_CHARACTERS } from "./recall.js";
|
|
4
|
+
|
|
5
|
+
/** What a request carries in place of one fresh tool result that the room cannot hold: its head and tail, and where the rest is. */
|
|
6
|
+
export interface Clip { toolCallId: string; content: ToolResultMessage["content"] }
|
|
7
|
+
|
|
8
|
+
/** The text `tuck_recall {entryId, offset}` reads from a text-only result; any other result is not clipped. */
|
|
9
|
+
const textOf = (message: ToolResultMessage) => message.content.every(block => block.type === "text")
|
|
10
|
+
? message.content.map(block => block.type === "text" ? block.text : "").join("\n") : undefined;
|
|
11
|
+
|
|
12
|
+
const clipped = (text: string, keep: number, entryId: string): ToolResultMessage["content"] => {
|
|
13
|
+
const head = Math.ceil(keep / 2), tail = keep - head;
|
|
14
|
+
return [{ type: "text", text: text.slice(0, head) +
|
|
15
|
+
`\n[pi-tuck: characters ${head}-${text.length - tail} of ${text.length} are not shown, to fit the context window; read them with ` +
|
|
16
|
+
`tuck_recall {"entryId":"${entryId}","offset":${head}}, up to ${MAX_RECALL_CHARACTERS} characters a call]\n` + text.slice(text.length - tail) }];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** The round's text results within `budget` tokens together: each result smaller than an equal share of what is left stays whole, and the
|
|
20
|
+
* rest are clipped to that share, keeping the most head and tail characters that fit it. None when the round already fits, or when a share
|
|
21
|
+
* cannot hold even the locator. A round of one result gets the whole budget. Deterministic: the same round and budget always clip the same way. */
|
|
22
|
+
export function clipFor(results: readonly ToolResultMessage[], entryIdOf: (toolCallId: string) => string | undefined, budget: number): Clip[] {
|
|
23
|
+
const tokens = (message: ToolResultMessage) => conversationTokens([message] as Messages);
|
|
24
|
+
const round = results.flatMap(result => { const text = textOf(result), entryId = entryIdOf(result.toolCallId);
|
|
25
|
+
return text === undefined || !entryId ? [] : [{ result, text, entryId, size: tokens(result) }]; })
|
|
26
|
+
.sort((a, b) => a.size - b.size);
|
|
27
|
+
if (round.reduce((sum, item) => sum + item.size, 0) <= budget) return [];
|
|
28
|
+
const clips: Clip[] = [];
|
|
29
|
+
let left = budget;
|
|
30
|
+
for (const [at, { result, text, entryId, size }] of round.entries()) {
|
|
31
|
+
const share = Math.floor(left / (round.length - at));
|
|
32
|
+
if (size <= share) { left -= size; continue; }
|
|
33
|
+
const fits = (keep: number) => tokens({ ...result, content: clipped(text, keep, entryId) }) <= share;
|
|
34
|
+
if (!fits(0)) return [];
|
|
35
|
+
let low = 0, high = text.length;
|
|
36
|
+
while (low < high) { const mid = Math.ceil((low + high) / 2); if (fits(mid)) low = mid; else high = mid - 1; }
|
|
37
|
+
const content = clipped(text, low, entryId);
|
|
38
|
+
clips.push({ toolCallId: result.toolCallId, content });
|
|
39
|
+
left -= tokens({ ...result, content });
|
|
40
|
+
}
|
|
41
|
+
return clips;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The messages with each clipped result replaced; the same array when nothing is clipped. */
|
|
45
|
+
export const applyClips = <M extends { role: string }>(messages: M[], clips: ReadonlyMap<string, Clip>): M[] => !clips.size ? messages
|
|
46
|
+
: messages.map(message => message.role === "toolResult" && clips.has((message as unknown as ToolResultMessage).toolCallId)
|
|
47
|
+
? { ...message, content: clips.get((message as unknown as ToolResultMessage).toolCallId)!.content } : message);
|
package/src/compaction.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { exportSource } from "./source-export.js";
|
|
|
5
5
|
import { isDeepStrictEqual } from "node:util";
|
|
6
6
|
import { assertNativeToolRounds } from "./protocol.js";
|
|
7
7
|
import { projectedMessages } from "./omissions.js";
|
|
8
|
+
import { applyClips, clipFor, type Clip } from "./clip.js";
|
|
8
9
|
import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart, validCutAt,
|
|
9
10
|
type AgentMessage, type Messages, type Plan, type Target, type ToolShape } from "./budget.js";
|
|
10
11
|
import { isContextOverflow } from "@earendil-works/pi-ai";
|
|
@@ -88,6 +89,8 @@ export function seedWindow(branch: readonly SessionEntry[], key: string): number
|
|
|
88
89
|
}
|
|
89
90
|
export interface CompactionOptions {
|
|
90
91
|
semanticRecall?: SemanticRecallController;
|
|
92
|
+
/** Whether `tuck_search` exists: false where semantic recall cannot run (off Apple silicon), so the model is offered word recall only. */
|
|
93
|
+
search?: boolean;
|
|
91
94
|
/** Optional host-owned transient operator preference; handle operations never call back. */
|
|
92
95
|
onSemanticSetting?: (enabled: boolean) => Promise<unknown>;
|
|
93
96
|
semanticStatus?: () => unknown;
|
|
@@ -119,7 +122,7 @@ export interface CompactionOptions {
|
|
|
119
122
|
export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
|
|
120
123
|
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
121
124
|
if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
|
|
122
|
-
const semantic = options.semanticRecall ?? createSemanticRecall();
|
|
125
|
+
const semantic = options.semanticRecall ?? createSemanticRecall(), search = options.search ?? true;
|
|
123
126
|
const hostAccess = () => {
|
|
124
127
|
if (!active()) throw new Error("Compaction host is inactive");
|
|
125
128
|
options.assertAccess?.();
|
|
@@ -238,7 +241,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
238
241
|
if (input > 0 && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
|
|
239
242
|
});
|
|
240
243
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
241
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); });
|
|
244
|
+
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); });
|
|
242
245
|
const active = () => options.active?.() ?? true;
|
|
243
246
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
244
247
|
// The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
|
|
@@ -268,10 +271,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
268
271
|
};
|
|
269
272
|
const fits = (tokens: number, limit: number, ctx: ExtensionContext) => corrected(tokens, ctx) < limit;
|
|
270
273
|
// The saved conversation and a cut's quantities are counted by the one function the guard counts with (strict, as the context hook is).
|
|
271
|
-
const measureFor = (ctx: ExtensionContext) => (messages: Messages) => projectedConversationTokens(messages, ctx, options.recallPolicy);
|
|
274
|
+
const measureFor = (ctx: ExtensionContext) => (messages: Messages) => projectedConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy);
|
|
272
275
|
// The planner counts with the same function, except that it does not fail on a summary in another format: a cut can replace one
|
|
273
276
|
// (an operator's /compact repairs a session whose saved summary is foreign). Identical to the strict one for this compactor's summaries.
|
|
274
|
-
const plannerMeasureFor = (ctx: ExtensionContext) => (messages: Messages) => measurableConversationTokens(messages, ctx, options.recallPolicy);
|
|
277
|
+
const plannerMeasureFor = (ctx: ExtensionContext) => (messages: Messages) => measurableConversationTokens(applyClips(messages, clips), ctx, options.recallPolicy);
|
|
275
278
|
const hostTokens = (ctx: ExtensionContext) => options.hostMessages ? conversationTokens(options.hostMessages(ctx)) : 0;
|
|
276
279
|
const toolShapes = (names: Iterable<string>): ToolShape[] => {
|
|
277
280
|
const selected = new Set(names);
|
|
@@ -285,6 +288,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
285
288
|
let pendingCut: { mandatory: number; before: number; host: number; opportunistic: boolean; proposed: boolean } | undefined;
|
|
286
289
|
let failure: Error | undefined;
|
|
287
290
|
let pendingBoundaryCommit = false;
|
|
291
|
+
// Fresh tool results the room cannot hold, by call id: each request of the run carries the clip in their place; the session keeps them
|
|
292
|
+
// whole. A clip ends with its run like any failure would: the next run plans from the session as it is, where the result is no longer fresh.
|
|
293
|
+
const clips = new Map<string, Clip>();
|
|
288
294
|
const fail = (error: unknown, ctx: ExtensionContext) => {
|
|
289
295
|
recorder.stop("native-attempt-failed");
|
|
290
296
|
const notify = !failure && error instanceof CapacityError;
|
|
@@ -318,7 +324,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
318
324
|
});
|
|
319
325
|
pi.registerTool({
|
|
320
326
|
name: "tuck_recall", label: "pi-tuck history", parameters: RecallInput,
|
|
321
|
-
description: "Keyword lookup locates particular text in your current branch. {query} returns ranked passages with read coordinates; punctuation separates words. Pass the returned next as {continuation} unchanged. Use tuck_search for historical information relevant to a question, including different wording and qualifications. Knowing an identifier does not itself justify narrowing discovery. {entryId} or {index} (a summary sourceIndex or #N) reads one source, optionally with query, offset and limit in UTF-16 characters (limit defaults to 8000). {} browses history; {around} expands neighbors; {entryId,image} reads an image; outputPath writes an original to a file with the native write tool. Hidden reasoning and system records are excluded.",
|
|
327
|
+
description: "Keyword lookup locates particular text in your current branch. {query} returns ranked passages with read coordinates; punctuation separates words. Pass the returned next as {continuation} unchanged. " + (search ? "Use tuck_search for historical information relevant to a question, including different wording and qualifications. " : "") + "Knowing an identifier does not itself justify narrowing discovery. {entryId} or {index} (a summary sourceIndex or #N) reads one source, optionally with query, offset and limit in UTF-16 characters (limit defaults to 8000). {} browses history; {around} expands neighbors; {entryId,image} reads an image; outputPath writes an original to a file with the native write tool. Hidden reasoning and system records are excluded.",
|
|
322
328
|
async execute(_id, raw, signal, _update, ctx) {
|
|
323
329
|
access();
|
|
324
330
|
const input = checkedInput(raw);
|
|
@@ -341,7 +347,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
341
347
|
return reply(tuckRecall(tuck, ctx.sessionManager.getEntries(), branch, input, options.recallPolicy));
|
|
342
348
|
},
|
|
343
349
|
});
|
|
344
|
-
pi.registerTool({name:"tuck_search",label:"History search",parameters:SearchInput,
|
|
350
|
+
if (search) pi.registerTool({name:"tuck_search",label:"History search",parameters:SearchInput,
|
|
345
351
|
description:"Find historical information relevant to a question, including different wording and related qualifications. Combines keyword and local embedding search. Knowing a name or identifier does not imply keyword-only lookup is sufficient. Returns original evidence up to 20000 characters; not exhaustive. Semantic search may be off or incomplete, which is not the same as no matches. Read exact originals and their context with tuck_recall at the returned locators. priorResults lists source ranges already read.",
|
|
346
352
|
async execute(_id,raw,signal,_update,ctx){
|
|
347
353
|
access();if(!Check(SearchInput,raw))throw new Error("Invalid broad search input");const input=structuredClone(raw);
|
|
@@ -421,7 +427,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
421
427
|
}
|
|
422
428
|
});
|
|
423
429
|
pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
|
|
424
|
-
pi.on("session_start", () => { recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
430
|
+
pi.on("session_start", () => { clips.clear(); recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
|
|
425
431
|
// The cut before a run. Pi 1.4 counts a run as started before before_agent_start, and ctx.compact() first aborts and waits for an
|
|
426
432
|
// idle session, so a cut asked for there would wait on its own run. A prompt's cut is therefore made on `input`, while the session
|
|
427
433
|
// is idle. before_agent_start decides only for a run no input started (one a custom message starts) and cuts only if the session is
|
|
@@ -481,57 +487,82 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
481
487
|
else await preflight(ctx, incomingEventTokens(event), event.systemPrompt, event.systemPromptOptions.selectedTools);
|
|
482
488
|
} catch (error) { fail(error, ctx); }
|
|
483
489
|
});
|
|
490
|
+
// A cut at a public boundary, drafted against the boundary's own preview: "tool-loop" between the rounds of a run, "run-end" once its
|
|
491
|
+
// last reply is in. A run-end cut is made only at or above the band, as a tool-loop one, so the next prompt starts from below it: an
|
|
492
|
+
// input cut is bound by Pi's keepRecentTokens, and a large prompt right after a cut could leave it nothing to cut. A run-end cut that
|
|
493
|
+
// cannot be planned is no fault: the run is over, and the next prompt decides afresh. Only a tool-loop boundary has fresh results to clip.
|
|
494
|
+
const boundaryCut = (event: { entries: readonly unknown[]; context: { contextMessages: Messages; pendingMessages: readonly unknown[] }; toolResults?: Parameters<typeof clipFor>[0] }, ctx: ExtensionContext, reason: "tool-loop" | "run-end") => {
|
|
495
|
+
access();
|
|
496
|
+
// Earlier boundary drafts have a public preview but no original-source compiler
|
|
497
|
+
// contract. Preserve them and leave admission to the final guard.
|
|
498
|
+
if (event.entries.length || !isDeepStrictEqual(event.context.contextMessages,
|
|
499
|
+
ctx.sessionManager.buildSessionProjection().messages)) return;
|
|
500
|
+
if (!ctx.model) return;
|
|
501
|
+
const { response, room, band } = limits(ctx);
|
|
502
|
+
const measure = measureFor(ctx), saved = event.context.contextMessages;
|
|
503
|
+
// One snapshot per decision. Messages queued for the next request (steering, follow-ups) are in that request but not in the
|
|
504
|
+
// saved conversation. Pi's peek returns the steering queue, else the follow-up queue; a follow-up is not in the very next
|
|
505
|
+
// request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
|
|
506
|
+
const system = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt());
|
|
507
|
+
const host = hostTokens(ctx);
|
|
508
|
+
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages as Messages) : 0;
|
|
509
|
+
const mandatory = system + host + queued;
|
|
510
|
+
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
511
|
+
const decide = () => {
|
|
512
|
+
const before = measure(saved);
|
|
513
|
+
return decideCut({
|
|
514
|
+
total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
515
|
+
unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
|
|
516
|
+
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
|
|
517
|
+
const reserve = planReserve(target, room, mandatory, ctx);
|
|
518
|
+
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
519
|
+
if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
520
|
+
const header = ctx.sessionManager.getHeader();
|
|
521
|
+
if (!header) throw new Error("Missing native session header at tool-loop boundary");
|
|
522
|
+
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
523
|
+
preview.appendCompaction(draft.compaction.summary, draft.compaction.firstKeptEntryId, before, draft.compaction.details, true);
|
|
524
|
+
const messages = preview.buildSessionProjection().messages;
|
|
525
|
+
assertNativeToolRounds(messages);
|
|
526
|
+
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
527
|
+
},
|
|
528
|
+
});
|
|
529
|
+
};
|
|
530
|
+
let decision = decide();
|
|
531
|
+
// Only a request that would be refused is clipped. A fresh result is kept whole by every cut (it has not reached the model yet), so
|
|
532
|
+
// a round larger than the room refuses the run. In their place the request carries the results' heads and tails, together at most
|
|
533
|
+
// half of what the room leaves the conversation, each with a tuck_recall locator to its rest. When even that is refused the refusal stands.
|
|
534
|
+
if (decision.kind === "fault" && event.toolResults) {
|
|
535
|
+
const branch = ctx.sessionManager.getBranch();
|
|
536
|
+
const entryIdOf = (toolCallId: string) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
|
|
537
|
+
const round = clipFor(event.toolResults, entryIdOf, Math.floor((room - mandatory) / 2));
|
|
538
|
+
if (round.length) {
|
|
539
|
+
for (const clip of round) clips.set(clip.toolCallId, clip);
|
|
540
|
+
const clippedDecision = decide();
|
|
541
|
+
if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId); else decision = clippedDecision;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const before = measure(saved);
|
|
545
|
+
if (decision.kind === "fault" && reason === "tool-loop") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
546
|
+
if (decision.kind !== "cut") return;
|
|
547
|
+
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, reason, before, true, false, previousSummary,
|
|
548
|
+
{ response, host, mandatory, target: decision.target, margin: room - band });
|
|
549
|
+
return { entries: [{ type: "compaction" as const, summary: compaction.summary,
|
|
550
|
+
firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
|
|
551
|
+
};
|
|
484
552
|
pi.on("turn_end", (event, ctx) => {
|
|
485
553
|
try {
|
|
486
554
|
if (!active() || !admittedRun || event.outcome !== "completed" || ctx.signal?.aborted ||
|
|
487
555
|
!event.context.canContinue || !event.toolResults.length || !settingsFor().getCompactionSettings(ctx.model).enabled) return;
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
if (event.entries.length || !isDeepStrictEqual(event.context.contextMessages,
|
|
492
|
-
ctx.sessionManager.buildSessionProjection().messages)) return;
|
|
493
|
-
if (!ctx.model) return;
|
|
494
|
-
const { response, room, band } = limits(ctx);
|
|
495
|
-
const measure = measureFor(ctx), saved = event.context.contextMessages;
|
|
496
|
-
// One snapshot per decision. Messages queued for the next request (steering, follow-ups) are in that request but not in the
|
|
497
|
-
// saved conversation. Pi's peek returns the steering queue, else the follow-up queue; a follow-up is not in the very next
|
|
498
|
-
// request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
|
|
499
|
-
const system = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt());
|
|
500
|
-
const host = hostTokens(ctx);
|
|
501
|
-
const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages as Messages) : 0;
|
|
502
|
-
const mandatory = system + host + queued;
|
|
503
|
-
const before = measure(saved);
|
|
504
|
-
const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
|
|
505
|
-
const decision = decideCut({
|
|
506
|
-
total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
|
|
507
|
-
unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
|
|
508
|
-
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
|
|
509
|
-
const reserve = planReserve(target, room, mandatory, ctx);
|
|
510
|
-
const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
|
|
511
|
-
if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
512
|
-
const header = ctx.sessionManager.getHeader();
|
|
513
|
-
if (!header) throw new Error("Missing native session header at tool-loop boundary");
|
|
514
|
-
const preview = SessionManager.inMemory(ctx.cwd, undefined, [header, ...ctx.sessionManager.getBranch()]);
|
|
515
|
-
preview.appendCompaction(draft.compaction.summary, draft.compaction.firstKeptEntryId, before, draft.compaction.details, true);
|
|
516
|
-
const messages = preview.buildSessionProjection().messages;
|
|
517
|
-
assertNativeToolRounds(messages);
|
|
518
|
-
return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
|
|
519
|
-
},
|
|
520
|
-
});
|
|
521
|
-
if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
|
|
522
|
-
if (decision.kind !== "cut") return;
|
|
523
|
-
const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "tool-loop", before, true, false, previousSummary,
|
|
524
|
-
{ response, host, mandatory, target: decision.target, margin: room - band });
|
|
525
|
-
pendingBoundaryCommit = true;
|
|
526
|
-
return { entries: [{ type: "compaction" as const, summary: compaction.summary,
|
|
527
|
-
firstKeptEntryId: compaction.firstKeptEntryId, details: compaction.details }] };
|
|
556
|
+
const result = boundaryCut(event, ctx, "tool-loop");
|
|
557
|
+
if (result) pendingBoundaryCommit = true;
|
|
558
|
+
return result;
|
|
528
559
|
} catch (error) { fail(error, ctx); }
|
|
529
560
|
});
|
|
530
561
|
pi.on("context", (event, ctx) => {
|
|
531
562
|
try {
|
|
532
563
|
if (!active()) return;
|
|
533
564
|
access();
|
|
534
|
-
return { messages: projectedMessages(event.messages, ctx, options.recallPolicy) };
|
|
565
|
+
return { messages: applyClips(projectedMessages(event.messages, ctx, options.recallPolicy), clips) };
|
|
535
566
|
} catch (error) { try { fail(error, ctx); } finally { return { messages: [] }; } }
|
|
536
567
|
});
|
|
537
568
|
pi.on("session_compact_failed", (event, ctx) => {
|
|
@@ -552,9 +583,16 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
552
583
|
if (active() && !event.aborted) fail(new (event.fromExtension ? CommitFault : Error)(event.errorMessage ?? "Compaction failed"), ctx);
|
|
553
584
|
});
|
|
554
585
|
pi.on("agent_before_settle", (event, ctx) => {
|
|
555
|
-
if (
|
|
556
|
-
|
|
557
|
-
|
|
586
|
+
if (pendingBoundaryCommit) {
|
|
587
|
+
if (event.outcome === "error") return void fail(new CommitFault("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
|
|
588
|
+
pendingBoundaryCommit = false;
|
|
589
|
+
}
|
|
590
|
+
// Pi commits these entries before the run settles.
|
|
591
|
+
try {
|
|
592
|
+
if (!active() || !admittedRun || event.outcome !== "completed" || ctx.signal?.aborted || failure ||
|
|
593
|
+
!settingsFor().getCompactionSettings(ctx.model).enabled) return;
|
|
594
|
+
return boundaryCut(event, ctx, "run-end");
|
|
595
|
+
} catch (error) { fail(error, ctx); }
|
|
558
596
|
});
|
|
559
597
|
pi.on("context_with_system", (event, ctx) => {
|
|
560
598
|
// Reaching the next request boundary proves native commit returned, not durable storage.
|
package/src/diagnostics.ts
CHANGED
|
@@ -10,7 +10,7 @@ export const DIAGNOSTIC_BUILD = BUILD_ID;
|
|
|
10
10
|
export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; cutAt?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
|
|
11
11
|
export function attachReceipt<T extends { details?: unknown; firstKeptEntryId: string; tokensBefore: number }>(draft: T, reason: string, reserveTokens: number, contextWindow: number, resolved: Partial<Receipt['settings']> = {}): T & { details: Record<string, unknown> & { diagnosticReceipt: Receipt } } {
|
|
12
12
|
const details = draft.details && typeof draft.details === 'object' ? draft.details as Record<string, unknown> : {};
|
|
13
|
-
const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
|
13
|
+
const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' || reason === 'run-end' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
|
|
14
14
|
return { ...draft, details: { ...details, diagnosticReceipt: receipt } };
|
|
15
15
|
}
|
|
16
16
|
export function receiptId(entry: { details?: unknown }): string | undefined {
|
package/src/semantic-recall.ts
CHANGED
|
@@ -104,13 +104,15 @@ export const HEAL_STEADY_MS=600000;
|
|
|
104
104
|
export const IDLE_STOP_MS=600000;
|
|
105
105
|
/** A worker call's deadline once the worker has answered a call after `ready`: index, embed and tokenize on a loaded model. */
|
|
106
106
|
export const CALL_DEADLINE_MS=60000;
|
|
107
|
+
/** Why semantic recall is off where the embedding server cannot start on Metal (a VM, a Mac without a GPU): it runs on Metal only. */
|
|
108
|
+
export const METAL_UNAVAILABLE='Semantic recall needs Metal; word recall still works';
|
|
107
109
|
/** What an `embed` call adds to its deadline for each text it carries. A batch of 8 passages on a starved CPU took longer than the per-call
|
|
108
110
|
* deadline, and the worker failed when it should have slowed down; a query (one text) still fails fast when the server hangs. The worker
|
|
109
111
|
* scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
|
|
110
112
|
export const EMBED_TEXT_MS=30000;
|
|
111
113
|
/** The deadline for a worker's start: Python start, `ready` (which verifies every runtime file) and the first call after it, which loads the
|
|
112
114
|
* model and starts its native server. On a busy machine that takes minutes; held to the per-call deadline, the worker never started and
|
|
113
|
-
* semantic recall went unavailable. It stays above the worker's own native start
|
|
115
|
+
* semantic recall went unavailable. It stays above the worker's own native start limit (540 s, in semantic-worker.py). */
|
|
114
116
|
export const START_DEADLINE_MS=600000;
|
|
115
117
|
/** The most units one index window holds. The worker's SQLite store stops at 8,008 rows (`PRAGMA max_page_count=8192` in scripts/semantic-worker.py), so a cap below that keeps a window of short units from filling the store and sticking in `unavailable`. Raise it only together with the store. */
|
|
116
118
|
export const MAX_INDEX_UNITS=8000;
|
|
@@ -149,7 +151,7 @@ class LocalWorker {
|
|
|
149
151
|
const launchLine=JSON.stringify(launch)+'\n';if(this.nativeHelper&&Buffer.byteLength(launchLine)>2*1048576)throw new Error('Native owner launch bound');
|
|
150
152
|
const child=spawn(this.nativeHelper?this.config.nativeOwnerHelperPath!:this.config.pythonPath,this.nativeHelper?['--run']:args,{cwd:this.scratch,env:this.nativeHelper?osEnvironment:env,stdio:'pipe',detached:!this.nativeHelper});
|
|
151
153
|
this.child=child;this.exited=new Promise<void>(done=>child.once('close',(code,signal)=>{this.nativeExit={code,signal};this.rejectAll(new WorkerFailure('Local worker stopped'));done();this.lose('Local worker stopped');}));
|
|
152
|
-
child.stdout.on('data',data=>{this.buffer+=data.toString();if(this.buffer.length>16*1024*1024){this.lose('Local worker output bound');return;}let end;while((end=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,end);this.buffer=this.buffer.slice(end+1);try{const reply=JSON.parse(line),pending=this.pending.get(reply.id);if(pending){clearTimeout(pending.timer);this.pending.delete(reply.id);reply.ok?pending.resolve(reply.result):pending.reject(new WorkerFailure('Local semantic runtime: '+reply.error?.code,reply.error?.code??null));if(this.nativeHelper&&this.nativeInputId===reply.id){this.nativeInputId=undefined;this.pumpNativeInput();}}}catch{this.lose('Local worker framing');}}});
|
|
154
|
+
child.stdout.on('data',data=>{this.buffer+=data.toString();if(this.buffer.length>16*1024*1024){this.lose('Local worker output bound');return;}let end;while((end=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,end);this.buffer=this.buffer.slice(end+1);try{const reply=JSON.parse(line),pending=this.pending.get(reply.id);if(pending){clearTimeout(pending.timer);this.pending.delete(reply.id);reply.ok?pending.resolve(reply.result):pending.reject(new WorkerFailure(reply.error?.code==='metal_unavailable'?METAL_UNAVAILABLE:'Local semantic runtime: '+reply.error?.code,reply.error?.code??null));if(this.nativeHelper&&this.nativeInputId===reply.id){this.nativeInputId=undefined;this.pumpNativeInput();}}}catch{this.lose('Local worker framing');}}});
|
|
153
155
|
child.stdout.on('error',()=>this.lose('Local worker output'));
|
|
154
156
|
if(this.nativeHelper)this.helperReceipt=observeNativeFrames(child.stderr,()=>{},()=>this.closeInBackground());
|
|
155
157
|
else child.stderr.resume();
|