pi-tuck 1.0.4 → 1.0.6
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 +34 -0
- package/README.md +22 -19
- package/dist/src/build-id.d.ts +1 -1
- package/dist/src/build-id.js +1 -1
- package/dist/src/compaction.js +34 -18
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/semantic-recall.d.ts +13 -0
- package/dist/src/semantic-recall.js +17 -3
- package/dist/src/tuck.d.ts +4 -1
- package/dist/src/tuck.js +10 -4
- package/package.json +1 -1
- package/scripts/semantic-worker.py +27 -16
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +30 -17
- package/src/semantic-recall.ts +15 -5
- package/src/tuck.ts +11 -5
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,40 @@
|
|
|
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.6] - 2026-11-01
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
|
|
10
|
+
- Semantic recall embeds on the GPU (Metal) on Apple silicon. The pinned llama.cpp build already includes Metal, but pi-tuck started it on the
|
|
11
|
+
CPU only. On an Apple M5 under heavy load (load average 44-76), a batch of 8 passages took a median 38.6 s on the CPU and 0.69 s on Metal,
|
|
12
|
+
56 times faster, with nearly the same vectors (97.9% top-10 overlap with the CPU's). Indexing therefore finishes, and `tuck_search` ranks
|
|
13
|
+
by meaning instead of falling back to keywords. Where Metal cannot start (a VM, CI, no GPU), the same server starts on the CPU. The
|
|
14
|
+
installed runtime is kept; each session's index is rebuilt once.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- Semantic recall no longer switches itself off on a busy machine. The worker's start (Python, the runtime check, the model load) now has
|
|
19
|
+
its own deadline instead of the 60 s per-call limit, and an embed call's deadline grows with the number of texts it carries, so a slow
|
|
20
|
+
machine slows indexing down instead of making it fail. A worker that still fails is retried in the same session and recovers once the
|
|
21
|
+
machine is free.
|
|
22
|
+
|
|
23
|
+
## [1.0.5] - 2026-10-31
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- One oversize tool result no longer leaves a session unusable. A fresh tool round larger than the input room is still refused, since it must
|
|
28
|
+
reach the model whole; but after that run ended (its reply aborted, or hidden by Pi when it recovered from an overflow), the result was
|
|
29
|
+
still treated as not yet seen and kept whole, so
|
|
30
|
+
every later prompt was refused, and a manual `/compact` refused too. Once the session moves on (a new prompt, a manual `/compact`, or a
|
|
31
|
+
run a custom message starts) it is now ordinary history, and the next cut summarizes it; `tuck_recall` still reads it in full.
|
|
32
|
+
- A cut that is needed when Pi has nothing to compact (no history older than its `keepRecentTokens` since the last cut) now refuses that
|
|
33
|
+
request only. Before, Pi's "Nothing to compact" or "Already compacted" stayed as a fault, and every later prompt of the session failed on it.
|
|
34
|
+
- No failure outlives its attempt: after any refusal or fault, the next prompt, a manual cut or the end of the run plans afresh from the
|
|
35
|
+
session as it is. Before, a fault other than a refusal stayed until the session restarted. The one exception is a cut pi-tuck supplied that
|
|
36
|
+
Pi then failed to write: the session may hold a cut its file lacks, so that fault still stays until the session restarts.
|
|
37
|
+
- A refusal no longer turns semantic recall off. Background indexing read any compaction failure as the host revoking access, so it disabled
|
|
38
|
+
semantic recall and deleted its index for the rest of the session.
|
|
39
|
+
|
|
6
40
|
## [1.0.4] - 2026-10-31
|
|
7
41
|
|
|
8
42
|
### 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 or by meaning, all on your machine.**
|
|
3
|
+
**Compaction for [Pi](https://pi.dev) with no model call, and a history the agent can search by keyword or 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
|
|
@@ -8,22 +8,23 @@ pi install npm:pi-tuck
|
|
|
8
8
|
|
|
9
9
|
## pi-tuck against Pi's built-in compaction
|
|
10
10
|
|
|
11
|
-
Pi's built-in compaction asks a model to write a summary. pi-tuck builds the summary from the session itself.
|
|
11
|
+
Pi's built-in compaction asks a model to write a summary. pi-tuck builds the summary from the session itself. The two time and context rows compare pi-tuck 1.0.1 with Pi 1.4.0's built-in compaction, where a local model writes the summary. Each measured result in this README names the pi-tuck build or version it was measured on; the release you install may be newer.
|
|
12
12
|
|
|
13
|
-
| | Pi's built-in | pi-tuck |
|
|
13
|
+
| | Pi's built-in (local model writes the summary) | pi-tuck |
|
|
14
14
|
|---|---|---|
|
|
15
15
|
| Model call per cut | One, to a model you pay for or wait on | None |
|
|
16
16
|
| Time per cut | **269 s** (median) | **0.09 s** (median) |
|
|
17
17
|
| Context carried after a cut | 29,200 tokens | 27,800 tokens (about the same) |
|
|
18
|
-
| Same session, same summary |
|
|
19
|
-
| Can the agent get dropped detail back | No recall tool. 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 |
|
|
18
|
+
| Same session, same summary | Not guaranteed, since a model writes it | Yes |
|
|
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
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 it searches by keyword |
|
|
21
|
-
| Can the summary invent history |
|
|
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
|
-
| Billed input
|
|
24
|
-
| Answer quality against
|
|
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
|
+
| Answer quality against Pi's own compaction on a local model (`mac-studio/main`) | | **Being measured. Not claimed yet.** |
|
|
25
|
+
| Answer quality against Pi's compaction prompts on a frontier model | | **Untested.** |
|
|
25
26
|
|
|
26
|
-
The time and context rows are live runs
|
|
27
|
+
The time and context rows are live runs with a 131k window: 27 cuts per arm over 5 chains, interim. Every number comes from a file in this repository. Conditions, sample sizes and links are in [Benchmarks](#benchmarks). The last row is the one that matters most, and it is not finished: see [What is proven, and what is not](#what-is-proven-and-what-is-not).
|
|
27
28
|
|
|
28
29
|
## Ask your history
|
|
29
30
|
|
|
@@ -57,8 +58,8 @@ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, bo
|
|
|
57
58
|
|
|
58
59
|
- Needs Pi 1.1 or later (tested on 1.1.0 and 1.4.0), on macOS or Linux.
|
|
59
60
|
- 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).
|
|
60
|
-
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.
|
|
61
|
-
- To pin a version, add it: `pi install npm:pi-tuck@1.0.
|
|
61
|
+
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.6`.
|
|
62
|
+
- To pin a version, add it: `pi install npm:pi-tuck@1.0.6`.
|
|
62
63
|
|
|
63
64
|
## How it works
|
|
64
65
|
|
|
@@ -89,22 +90,22 @@ Both tools cost about 1,050 tokens of tool description per request.
|
|
|
89
90
|
|
|
90
91
|
## What is proven, and what is not
|
|
91
92
|
|
|
92
|
-
**Measured so far:** a cut
|
|
93
|
+
**Measured so far:** in the live run, 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.
|
|
93
94
|
|
|
94
|
-
**Not proven yet: that the summary is as good as a model-written one.** The claim is that pi-tuck compacts as well as
|
|
95
|
+
**Not proven yet: that the summary is as good as a model-written one.** The claim is that pi-tuck compacts as well as an LLM compactor, in far less time and at no cost. The comparator is Pi's own summarize prompts, run on a local model (`mac-studio/main`). The frontier comparison is untested: the model first named for it (`openai/gpt-6-astra`) was retired on 2026-10-31, and its recordings are a labelled partial view that feeds no test. A replacement, GPT-6.1 Terra, is pre-registered in [`benchmark/PREREG-TERRA.md`](benchmark/PREREG-TERRA.md). See [`benchmark/PINS.md`](benchmark/PINS.md). That claim has a pre-registered test. The test is running now. It was written down before any result came in, and a failed test will be reported as failed. When the result is final, it goes in [`benchmark/`](benchmark) and in this README. Until then, this README does not claim that pi-tuck is as good as, or better than, any LLM compactor.
|
|
95
96
|
|
|
96
97
|
The test has three parts:
|
|
97
98
|
|
|
98
|
-
1. **A live run** at a 128k window on a local model. 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.
|
|
99
|
-
2. **A paired replay with probe questions.** The same cuts, made by pi-tuck and by
|
|
99
|
+
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 on the local model, 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 above and in `benchmark/`.
|
|
100
101
|
3. **A 32k suite**, where tasks are bigger than the room, so cuts must summarise work in progress.
|
|
101
102
|
|
|
102
|
-
The plan: [`benchmark/PREREG.md`](benchmark/PREREG.md), [`benchmark/PREREG-32K.md`](benchmark/PREREG-32K.md), and every change after the freeze in [`benchmark/DEVIATIONS.md`](benchmark/DEVIATIONS.md).
|
|
103
|
+
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).
|
|
103
104
|
|
|
104
105
|
Known limits today:
|
|
105
106
|
|
|
106
107
|
- 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.
|
|
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,
|
|
108
|
+
- 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).
|
|
108
109
|
- Semantic search runs only on macOS with Apple silicon.
|
|
109
110
|
|
|
110
111
|
## Benchmarks
|
|
@@ -114,12 +115,12 @@ Each row says what was measured, under which conditions, and where the data is.
|
|
|
114
115
|
| What | Result | Conditions | Source |
|
|
115
116
|
|---|---|---|---|
|
|
116
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 |
|
|
117
|
-
| 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). 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.
|
|
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 |
|
|
118
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) |
|
|
119
120
|
| Answers after cutting at 60% instead of 100% | No more losses than the limit on all 4 sets. Losses / wins over n questions: 128k shared 3 / 7 of 99; 128k asymmetric 3 / 1 of 67; 32k shared 5 / 3 of 77; 32k asymmetric 9 / 5 of 343. In the 32k asymmetric set the losses lean against 60% (test_result 4 / 2, error_hit 3 / 1); it is the set to watch | Paired replay, same 5 sessions, DeepSeek flash as agent and judge. Limit, set in advance: losses minus wins at most max(2, 5% of n). Asymmetric = evidence the base keeps native and the variant has summarised | [`rnd/replay32/RESULT-lean.md`](rnd/replay32/RESULT-lean.md) |
|
|
120
121
|
| 1.0.2 changes (plain-text summary, shrinking dialogue view, time on user turns only) against 1.0.1 | No more losses than the limit on all 4 sets. Losses / wins: 128k 1 / 2 of 128; 128k time questions 0 / 0 of 48; 32k 4 / 2 of 127; 32k asymmetric 0 / 2 of 56 | Paired replay, 5 recorded live sessions, DeepSeek flash as agent and judge. 11 of 146 answer sessions hit a 15-minute wall limit and drop out of the pairing | [`rnd/replay32/RESULT-lean.md`](rnd/replay32/RESULT-lean.md), [`CHANGELOG.md`](CHANGELOG.md) |
|
|
121
122
|
| Summary size, plain-text format against the 1.0.1 JSON format | About 30% shorter where the agent writes little, about 14% shorter where it writes a lot | Same dialogue | [`CHANGELOG.md`](CHANGELOG.md) 1.0.2 |
|
|
122
|
-
| Small-window fix | A session that writes little prose cut 174 times in 300 tool rounds at 32k with a 16k reserve (1.0.1 code). With the fix, a test pins that no cut frees under a tenth of the room | Real planner on synthetic sessions (
|
|
123
|
+
| Small-window fix | A session that writes little prose cut 174 times in 300 tool rounds at 32k with a 16k reserve (1.0.1 code). With the fix, a test pins that no cut frees under a tenth of the room | Real planner on synthetic sessions | [`CHANGELOG.md`](CHANGELOG.md) 1.0.2 (the 174 cuts); [`test/cut-yield.test.ts`](test/cut-yield.test.ts), which pins that every cut frees at least a tenth of the room |
|
|
123
124
|
| Stall fix: a long session's cut | A cut at 3,888 entries took 72 s in 1.0.0 and takes 174 ms since 1.0.1. Since 1.0.1 the cost grows linearly with the session | One machine, the same synthetic session for both numbers (little prose, 128k window, reserve 16,384, turn 644) | [`CHANGELOG.md`](CHANGELOG.md) 1.0.1 |
|
|
124
125
|
|
|
125
126
|
Notes:
|
|
@@ -150,6 +151,8 @@ pi-tuck keeps room for the model's reply: the larger of the model's output limit
|
|
|
150
151
|
{ "compaction": { "modelOverrides": { "my-provider/my-model": { "reserveTokens": 8192 } } } }
|
|
151
152
|
```
|
|
152
153
|
|
|
154
|
+
The reserve is the larger of `reserveTokens` and the model's `maxTokens`, so lowering `reserveTokens` alone does nothing when `models.json` sets a larger `maxTokens` for that model. Lower both, and keep `maxTokens` above your largest expected reply.
|
|
155
|
+
|
|
153
156
|
If you reach the model through OpenRouter, you can also pin one upstream, so that every request is served by the same one. Put this in `models.json` in Pi's agent folder, and name the upstream you want:
|
|
154
157
|
|
|
155
158
|
```json
|
package/dist/src/build-id.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const BUILD_ID = "
|
|
1
|
+
export declare const BUILD_ID = "d2b2e6b0e594b5dd41e9771c43f4ed0355d8fd048c4e247541199f3b812751d3";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "d2b2e6b0e594b5dd41e9771c43f4ed0355d8fd048c4e247541199f3b812751d3";
|
package/dist/src/compaction.js
CHANGED
|
@@ -36,11 +36,14 @@ const RecallInput = Type.Union([
|
|
|
36
36
|
]);
|
|
37
37
|
const SearchInput = Type.Object({ query, priorResults: Type.Optional(Type.Array(Type.Object({ entryId, sourceSha: Type.String({ minLength: 64, maxLength: 64 }), start: Type.Integer({ minimum: 0 }), end: Type.Integer({ minimum: 0 }) }, { additionalProperties: false }), { maxItems: 20 })) }, { additionalProperties: false });
|
|
38
38
|
const reply = (value) => ({ content: [{ type: "text", text: JSON.stringify(value) }], details: {} });
|
|
39
|
-
/** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit.
|
|
40
|
-
*
|
|
41
|
-
* or its host, and stays until the session restarts. */
|
|
39
|
+
/** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit. Like every failure but
|
|
40
|
+
* a `CommitFault`, it ends with its attempt (`forgetFailure`), and the next attempt plans afresh from the session as it is. */
|
|
42
41
|
class Refusal extends Error {
|
|
43
42
|
}
|
|
43
|
+
/** A cut this compactor supplied that Pi then failed to commit. Pi appends a cut to the session in memory before it writes the file, so the
|
|
44
|
+
* session may hold a cut its file lacks; what is written after would build on it. The one failure that stays until the session restarts. */
|
|
45
|
+
class CommitFault extends Error {
|
|
46
|
+
}
|
|
44
47
|
/** A refusal for want of room, which the user can remedy: it carries advice. */
|
|
45
48
|
class CapacityError extends Refusal {
|
|
46
49
|
}
|
|
@@ -95,10 +98,17 @@ export async function installCompaction(pi, options = {}) {
|
|
|
95
98
|
if (!validCutAt(cutAt))
|
|
96
99
|
throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
|
|
97
100
|
const semantic = options.semanticRecall ?? createSemanticRecall();
|
|
101
|
+
const hostAccess = () => {
|
|
102
|
+
if (!active())
|
|
103
|
+
throw new Error("Compaction host is inactive");
|
|
104
|
+
options.assertAccess?.();
|
|
105
|
+
};
|
|
98
106
|
let semanticTransition = Promise.resolve();
|
|
99
107
|
let semanticRequest = 0;
|
|
100
108
|
const semanticPending = new Set();
|
|
101
|
-
|
|
109
|
+
// Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
|
|
110
|
+
// attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
|
|
111
|
+
const semanticProvider = (ctx) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess);
|
|
102
112
|
const recorder = new IncidentRecorder(options.diagnostics);
|
|
103
113
|
let proposalId;
|
|
104
114
|
const observe = (ctx) => recorder.measureInline(() => {
|
|
@@ -259,7 +269,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
259
269
|
learn(ratio);
|
|
260
270
|
});
|
|
261
271
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
262
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle";
|
|
272
|
+
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); });
|
|
263
273
|
const active = () => options.active?.() ?? true;
|
|
264
274
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
265
275
|
// The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
|
|
@@ -308,7 +318,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
308
318
|
const fail = (error, ctx) => {
|
|
309
319
|
recorder.stop("native-attempt-failed");
|
|
310
320
|
const notify = !failure && error instanceof CapacityError;
|
|
311
|
-
|
|
321
|
+
// A CommitFault stays whatever fails after it.
|
|
322
|
+
if (!(failure instanceof CommitFault))
|
|
323
|
+
failure = error instanceof Error ? error : new Error(String(error));
|
|
312
324
|
pendingBoundaryCommit = false;
|
|
313
325
|
pending = undefined;
|
|
314
326
|
try {
|
|
@@ -324,16 +336,14 @@ export async function installCompaction(pi, options = {}) {
|
|
|
324
336
|
}
|
|
325
337
|
}
|
|
326
338
|
};
|
|
327
|
-
// The one place a
|
|
328
|
-
// input started (a custom message) has only the first of these.
|
|
329
|
-
const
|
|
339
|
+
// The one place a failure is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
|
|
340
|
+
// input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
|
|
341
|
+
const forgetFailure = () => { if (!(failure instanceof CommitFault))
|
|
330
342
|
failure = undefined; };
|
|
331
343
|
const access = () => {
|
|
332
344
|
if (failure)
|
|
333
345
|
throw failure;
|
|
334
|
-
|
|
335
|
-
throw new Error("Compaction host is inactive");
|
|
336
|
-
options.assertAccess?.();
|
|
346
|
+
hostAccess();
|
|
337
347
|
};
|
|
338
348
|
// Abort alone is insufficient: Pi starts a fresh abort controller after
|
|
339
349
|
// before_agent_start. Keep this attempt blocked, then recheck on new idle input.
|
|
@@ -343,7 +353,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
343
353
|
pi.on("input", async (event, ctx) => {
|
|
344
354
|
if (!ctx.isIdle() || phase === "starting")
|
|
345
355
|
return; // a steering or follow-up message joins the running request
|
|
346
|
-
|
|
356
|
+
forgetFailure();
|
|
347
357
|
phase = "decided";
|
|
348
358
|
try {
|
|
349
359
|
await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools());
|
|
@@ -418,7 +428,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
418
428
|
const incoming = pendingCut;
|
|
419
429
|
const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
|
|
420
430
|
if (operatorManual && !admittedRun)
|
|
421
|
-
|
|
431
|
+
forgetFailure();
|
|
422
432
|
access();
|
|
423
433
|
if (event.customInstructions?.trim()) {
|
|
424
434
|
recorder.stop("no-cut-custom-instructions");
|
|
@@ -453,7 +463,9 @@ export async function installCompaction(pi, options = {}) {
|
|
|
453
463
|
: "Compaction did not reduce context within the estimated request budget",
|
|
454
464
|
plan: (target) => {
|
|
455
465
|
const reserve = planReserve(target, room, mandatory, ctx);
|
|
456
|
-
|
|
466
|
+
// Every cut asked while no run is admitted (a prompt's preflight, an operator's cut, Pi's own cut before a run a custom message
|
|
467
|
+
// starts) is planned for a run that has not begun: the session has moved on from any run that ended. A retry is inside its run.
|
|
468
|
+
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, Boolean(incoming) || operatorManual || !admittedRun);
|
|
457
469
|
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
|
|
458
470
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
459
471
|
},
|
|
@@ -526,10 +538,14 @@ export async function installCompaction(pi, options = {}) {
|
|
|
526
538
|
// An opportunistic cut is not needed to send the request, which is below the room: the guard decides. However it ends (the
|
|
527
539
|
// planner declined it, Pi had nothing to compact, an earlier handler cancelled it) it is no fault, unless a failure is latched
|
|
528
540
|
// or the compactor proposed a cut that then failed to commit (the session may hold what the file lacks).
|
|
541
|
+
// A needed cut that Pi has nothing to make (no history older than its keepRecentTokens since the last cut) is a refusal of
|
|
542
|
+
// this request, which ends with it: as a fault it stayed, and every later prompt of the session failed on it.
|
|
529
543
|
if (cut.opportunistic && !failure && !cut.proposed)
|
|
530
544
|
resolve();
|
|
545
|
+
else if (!cut.proposed && isNoWork(error.message))
|
|
546
|
+
reject(new CapacityError(`The request needs a cut and Pi has none to make (${error.message})`));
|
|
531
547
|
else
|
|
532
|
-
reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : error);
|
|
548
|
+
reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : cut.proposed ? new CommitFault(error.message) : error);
|
|
533
549
|
} }));
|
|
534
550
|
}
|
|
535
551
|
finally {
|
|
@@ -645,13 +661,13 @@ export async function installCompaction(pi, options = {}) {
|
|
|
645
661
|
if (event.reason === "manual" && !event.fromExtension && !event.aborted && isNoWork(event.errorMessage))
|
|
646
662
|
return;
|
|
647
663
|
if (active() && !event.aborted)
|
|
648
|
-
fail(new Error(event.errorMessage ?? "Compaction failed"), ctx);
|
|
664
|
+
fail(new (event.fromExtension ? CommitFault : Error)(event.errorMessage ?? "Compaction failed"), ctx);
|
|
649
665
|
});
|
|
650
666
|
pi.on("agent_before_settle", (event, ctx) => {
|
|
651
667
|
if (!pendingBoundaryCommit)
|
|
652
668
|
return;
|
|
653
669
|
if (event.outcome === "error")
|
|
654
|
-
fail(new
|
|
670
|
+
fail(new CommitFault("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
|
|
655
671
|
else
|
|
656
672
|
pendingBoundaryCommit = false;
|
|
657
673
|
});
|
|
@@ -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 = "d2b2e6b0e594b5dd41e9771c43f4ed0355d8fd048c4e247541199f3b812751d3";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
|
@@ -110,6 +110,16 @@ export declare const HEAL_DELAYS_MS: readonly number[];
|
|
|
110
110
|
export declare const HEAL_STEADY_MS = 600000;
|
|
111
111
|
/** A session that has not searched for this long gives its worker back (the index stays on disk). Enabling starts nothing; a search starts the worker again. */
|
|
112
112
|
export declare const IDLE_STOP_MS = 600000;
|
|
113
|
+
/** A worker call's deadline once the worker has answered a call after `ready`: index, embed and tokenize on a loaded model. */
|
|
114
|
+
export declare const CALL_DEADLINE_MS = 60000;
|
|
115
|
+
/** 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
|
+
* 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
|
+
* scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
|
|
118
|
+
export declare const EMBED_TEXT_MS = 30000;
|
|
119
|
+
/** 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
|
+
* 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 limits (120 s on Metal, then 400 s on the CPU, in semantic-worker.py). */
|
|
122
|
+
export declare const START_DEADLINE_MS = 600000;
|
|
113
123
|
/** 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. */
|
|
114
124
|
export declare const MAX_INDEX_UNITS = 8000;
|
|
115
125
|
/** The wait before the next automatic attempt, given how many were already armed since the last success. */
|
|
@@ -168,6 +178,9 @@ export declare class SemanticRecallController {
|
|
|
168
178
|
healDelaysMs?: readonly number[];
|
|
169
179
|
healSteadyMs?: number;
|
|
170
180
|
idleStopMs?: number;
|
|
181
|
+
startDeadlineMs?: number;
|
|
182
|
+
callDeadlineMs?: number;
|
|
183
|
+
embedTextMs?: number;
|
|
171
184
|
});
|
|
172
185
|
/** Be told when `status().state` or `lastError` changes: once per change, after the synchronous work that caused it. Returns the unsubscribe. A listener that throws is ignored. */
|
|
173
186
|
onStateChange(listener: (state: SemanticState, reason: string | null) => void): () => void;
|
|
@@ -169,6 +169,16 @@ export const HEAL_DELAYS_MS = [5000, 30000, 120000];
|
|
|
169
169
|
export const HEAL_STEADY_MS = 600000;
|
|
170
170
|
/** A session that has not searched for this long gives its worker back (the index stays on disk). Enabling starts nothing; a search starts the worker again. */
|
|
171
171
|
export const IDLE_STOP_MS = 600000;
|
|
172
|
+
/** A worker call's deadline once the worker has answered a call after `ready`: index, embed and tokenize on a loaded model. */
|
|
173
|
+
export const CALL_DEADLINE_MS = 60000;
|
|
174
|
+
/** 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
|
+
* 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
|
+
* scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
|
|
177
|
+
export const EMBED_TEXT_MS = 30000;
|
|
178
|
+
/** 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
|
+
* 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 limits (120 s on Metal, then 400 s on the CPU, in semantic-worker.py). */
|
|
181
|
+
export const START_DEADLINE_MS = 600000;
|
|
172
182
|
/** 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. */
|
|
173
183
|
export const MAX_INDEX_UNITS = 8000;
|
|
174
184
|
/** Keys per worker `score` request: a multiple of the worker's 128-key sub-batches, aligned from the start of the covered prefix. A key is a SHA-256 hex digest (64 characters, see `hash`), so 1024 keys and the 768-float query make a request of about 90 KiB, far under both the 512 KiB design bound and the worker's 1 MiB line limit. */
|
|
@@ -183,6 +193,7 @@ class LocalWorker {
|
|
|
183
193
|
project;
|
|
184
194
|
cleanupFailure;
|
|
185
195
|
onLoss;
|
|
196
|
+
deadlines;
|
|
186
197
|
nativeHelper = false;
|
|
187
198
|
jobId;
|
|
188
199
|
helperReceipt;
|
|
@@ -202,11 +213,13 @@ class LocalWorker {
|
|
|
202
213
|
stopped = false;
|
|
203
214
|
lost = false;
|
|
204
215
|
closing;
|
|
205
|
-
|
|
216
|
+
loaded = false;
|
|
217
|
+
constructor(config, project, cleanupFailure, onLoss = () => { }, deadlines = { startMs: START_DEADLINE_MS, callMs: CALL_DEADLINE_MS, embedTextMs: EMBED_TEXT_MS }) {
|
|
206
218
|
this.config = config;
|
|
207
219
|
this.project = project;
|
|
208
220
|
this.cleanupFailure = cleanupFailure;
|
|
209
221
|
this.onLoss = onLoss;
|
|
222
|
+
this.deadlines = deadlines;
|
|
210
223
|
}
|
|
211
224
|
closeInBackground() { void this.close().catch(error => this.cleanupFailure(error)); }
|
|
212
225
|
// The worker stopped without the controller asking: report it once, then join it like any other background close.
|
|
@@ -303,7 +316,8 @@ class LocalWorker {
|
|
|
303
316
|
const id = ++this.next, body = JSON.stringify({ id, op, ...input }) + '\n';
|
|
304
317
|
if (Buffer.byteLength(body) > 1048576)
|
|
305
318
|
throw new Error('Local worker input bound');
|
|
306
|
-
return new Promise((resolveReply, reject) => { const onAbort = () => { this.closeInBackground(); reject(abort()); }; signal?.addEventListener('abort', onAbort, { once: true }); const finish = (callback, value) => { signal?.removeEventListener('abort', onAbort); callback(value); }; const timer = setTimeout(() => { this.lose('Local runtime deadline'); finish(reject, new WorkerFailure('Local runtime deadline')); },
|
|
319
|
+
return new Promise((resolveReply, reject) => { const onAbort = () => { this.closeInBackground(); reject(abort()); }; signal?.addEventListener('abort', onAbort, { once: true }); const finish = (callback, value) => { signal?.removeEventListener('abort', onAbort); callback(value); }; const timer = setTimeout(() => { this.lose('Local runtime deadline'); finish(reject, new WorkerFailure('Local runtime deadline')); }, this.loaded ? this.deadlines.callMs + (op === 'embed' ? this.deadlines.embedTextMs * (input.texts?.length ?? 0) : 0) : this.deadlines.startMs); this.pending.set(id, { resolve: value => { if (op !== 'ready')
|
|
320
|
+
this.loaded = true; finish(resolveReply, value); }, reject: error => finish(reject, error), timer }); if (this.nativeHelper) {
|
|
307
321
|
const control = { op: 'input', input: Buffer.from(body).toString('base64') };
|
|
308
322
|
this.nativeInputs.push({ id, control: JSON.stringify(control) + '\n' });
|
|
309
323
|
this.pumpNativeInput();
|
|
@@ -620,7 +634,7 @@ export class SemanticRecallController {
|
|
|
620
634
|
if (!this.worker) {
|
|
621
635
|
const worker = new LocalWorker(this.config, project, error => { if (this.worker !== worker)
|
|
622
636
|
return; this.cleanupError = error instanceof Error ? error.message : 'Semantic cleanup failed'; this.error = this.cleanupError; this.enabled = false; this.generation++; }, reason => { if (this.worker === worker)
|
|
623
|
-
this.heal(new WorkerFailure(reason)); });
|
|
637
|
+
this.heal(new WorkerFailure(reason)); }, { startMs: this.timing.startDeadlineMs ?? START_DEADLINE_MS, callMs: this.timing.callDeadlineMs ?? CALL_DEADLINE_MS, embedTextMs: this.timing.embedTextMs ?? EMBED_TEXT_MS });
|
|
624
638
|
this.worker = worker;
|
|
625
639
|
}
|
|
626
640
|
if (!this.identity) {
|
package/dist/src/tuck.d.ts
CHANGED
|
@@ -81,13 +81,16 @@ export declare const tuck: Tuck;
|
|
|
81
81
|
* initiating prompt, file ledger) are already charged in base. */
|
|
82
82
|
export declare function historicalAllowance(window: number, reserve: number, base: number, before: number): number;
|
|
83
83
|
/** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
|
|
84
|
-
export declare function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean, operatorManual?: boolean): Draft;
|
|
84
|
+
export declare function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean, operatorManual?: boolean, newPrompt?: boolean): Draft;
|
|
85
85
|
/** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
|
|
86
|
+
/** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
|
|
87
|
+
* a run a custom message starts); the session has moved on from an ended run. */
|
|
86
88
|
export declare function tuckObservedDraft(tuck: Tuck, input: {
|
|
87
89
|
branchEntries: SessionEntry[];
|
|
88
90
|
tokensBefore: number;
|
|
89
91
|
previousSummary?: string;
|
|
90
92
|
operatorManual?: boolean;
|
|
93
|
+
newPrompt?: boolean;
|
|
91
94
|
}, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy?: RecallPolicy, retainCurrentInput?: boolean): Draft;
|
|
92
95
|
/** Numeric references use the original session index, then intersect with readable current-branch entries. */
|
|
93
96
|
export declare function tuckRecall(tuck: Tuck, entries: readonly SessionEntry[], branch: readonly SessionEntry[], input: RecallOptions & {
|
package/dist/src/tuck.js
CHANGED
|
@@ -26,10 +26,12 @@ export function historicalAllowance(window, reserve, base, before) {
|
|
|
26
26
|
return Math.max(0, Math.min(Math.floor(free / 4), before - base - 1)) * 4;
|
|
27
27
|
}
|
|
28
28
|
/** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
|
|
29
|
-
export function tuckDraft(tuck, event, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true, operatorManual = false) {
|
|
30
|
-
return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual }, ctx, reserveTokens, measure, policy, retainCurrentInput);
|
|
29
|
+
export function tuckDraft(tuck, event, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false) {
|
|
30
|
+
return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt }, ctx, reserveTokens, measure, policy, retainCurrentInput);
|
|
31
31
|
}
|
|
32
32
|
/** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
|
|
33
|
+
/** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
|
|
34
|
+
* a run a custom message starts); the session has moved on from an ended run. */
|
|
33
35
|
export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, policy = {}, retainCurrentInput = true) {
|
|
34
36
|
const { isHistorySource, readable } = createRecall(policy);
|
|
35
37
|
const projected = ctx.sessionManager.buildSessionProjection();
|
|
@@ -73,10 +75,14 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
|
|
|
73
75
|
const calls = new Set(response.message.content.flatMap(block => block.type === "toolCall" ? [block.id] : []));
|
|
74
76
|
const hasFreshReceipt = entries.slice(lastResponse + 1).some(entry => entry.type === "message" &&
|
|
75
77
|
entry.message.role === "toolResult" && calls.has(entry.message.toolCallId));
|
|
76
|
-
|
|
78
|
+
// Fresh means the receipts are the answer the next request of this run carries. Once the session has moved on (the cut is planned for a
|
|
79
|
+
// run that has not begun, or a prompt has arrived since them) they are ordinary history, whatever ended their run: a reply discarded
|
|
80
|
+
// or one Pi hid when it recovered. Kept native, a receipt that never fits would refuse every later prompt. A retry is inside its run.
|
|
81
|
+
const superseded = input.newPrompt || entries.slice(lastResponse + 1).some(entry => entry.type === "message" && entry.message.role === "user");
|
|
82
|
+
freshRound = hasFreshReceipt && !superseded;
|
|
77
83
|
const keptId = cut.firstKeptEntryId;
|
|
78
84
|
const chosenBoundary = cut.compactAll ? entries.length : entries.findIndex(entry => entry.id === keptId);
|
|
79
|
-
if (
|
|
85
|
+
if (freshRound && lastResponse < chosenBoundary) {
|
|
80
86
|
const prefix = tuck.buildOwnCut(entries.slice(0, lastResponse), 0);
|
|
81
87
|
if (!prefix.ok)
|
|
82
88
|
return { unavailable: "No reducible history before the fresh tool round" };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-tuck",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.6",
|
|
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",
|
|
@@ -12,12 +12,13 @@ WINDOWS=os.name=='nt';encoder_port=None;encoder_token=None
|
|
|
12
12
|
class LocalHTTP(http.client.HTTPConnection):
|
|
13
13
|
def connect(self):
|
|
14
14
|
self.sock=socket.socket(socket.AF_UNIX,socket.SOCK_STREAM);self.sock.settimeout(self.timeout);self.sock.connect('encoder.sock')
|
|
15
|
-
|
|
15
|
+
# A native request's timeout: 55 s, plus 30 s per text it embeds (the host's EMBED_TEXT_MS), so a starved CPU slows a batch down and does not fail it.
|
|
16
|
+
def request(path,data=None,texts=0):
|
|
16
17
|
if path not in ('/health','/props','/v1/embeddings'):raise Refusal('native_path_failure')
|
|
17
18
|
if WINDOWS:
|
|
18
19
|
if encoder_port is None or not encoder_token:raise Refusal('native_transport_unavailable')
|
|
19
|
-
connection=http.client.HTTPConnection('127.0.0.1',encoder_port,timeout=55)
|
|
20
|
-
else:connection=LocalHTTP('localhost',timeout=55)
|
|
20
|
+
connection=http.client.HTTPConnection('127.0.0.1',encoder_port,timeout=55+30*texts)
|
|
21
|
+
else:connection=LocalHTTP('localhost',timeout=55+30*texts)
|
|
21
22
|
try:
|
|
22
23
|
headers={'Content-Type':'application/json'}
|
|
23
24
|
if WINDOWS:headers['Authorization']='Bearer '+encoder_token
|
|
@@ -26,6 +27,7 @@ def request(path,data=None):
|
|
|
26
27
|
if len(payload)>16*1024*1024:raise Refusal('native_response_bound')
|
|
27
28
|
if response.status!=200:raise Refusal('native_request_failure')
|
|
28
29
|
return json.loads(payload)
|
|
30
|
+
except TimeoutError:raise Refusal('native_request_failure')
|
|
29
31
|
finally:connection.close()
|
|
30
32
|
def native_executable():
|
|
31
33
|
executable=ROOT/('native/llama-server.exe' if WINDOWS else 'native/llama-server')
|
|
@@ -90,18 +92,27 @@ def load():
|
|
|
90
92
|
# Pinned common/arg.cpp supports LLAMA_API_KEY; server-http.cpp requires Bearer
|
|
91
93
|
# on /props and embeddings, but /health is public. No token in process argv.
|
|
92
94
|
env['LLAMA_API_KEY']=encoder_token
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
if
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
95
|
+
def launch(gpu_layers,limit):
|
|
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',str(gpu_layers),'--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
|
+
started=time.monotonic()
|
|
99
|
+
while child.poll() is None and time.monotonic()-started<=limit:
|
|
100
|
+
try:
|
|
101
|
+
if request('/health').get('status')=='ok':
|
|
102
|
+
if WINDOWS:request('/props') # Public health alone is not authenticated readiness.
|
|
103
|
+
if child.poll() is None:return child
|
|
104
|
+
except (OSError,Refusal):pass
|
|
105
|
+
time.sleep(.02)
|
|
106
|
+
if child.poll() is None:
|
|
107
|
+
child.kill();child.wait()
|
|
108
|
+
return None
|
|
109
|
+
# Metal first: on Apple silicon it embeds a batch about 56 times faster than the CPU (0.7 s against 39 s for 8 passages at load 44-76),
|
|
110
|
+
# with vectors of cosine 0.9998 or more to the CPU's. Where Metal cannot start (a VM, CI, no GPU) the server exits or never becomes
|
|
111
|
+
# healthy, and the same server starts on the CPU. A busy machine can take minutes to start it; both limits stay under the host's start
|
|
112
|
+
# deadline (START_DEADLINE_MS, 600 s). Windows has no Metal.
|
|
113
|
+
server=None if WINDOWS else launch(99,120)
|
|
114
|
+
if server is None:server=launch(0,400)
|
|
115
|
+
if server is None:raise Refusal('native_start_failure')
|
|
105
116
|
return server
|
|
106
117
|
def run(req):
|
|
107
118
|
op=req['op']
|
|
@@ -132,7 +143,7 @@ def run(req):
|
|
|
132
143
|
if len(a)>8:raise Refusal('batch_limit')
|
|
133
144
|
ids=[enc(framed(t,kind)) for t in a]
|
|
134
145
|
if not a:return {'vectors':[],'identity':IDENTITY}
|
|
135
|
-
load();out=request('/v1/embeddings',{'input':ids,'encoding_format':'float','truncate':False})['data'];out.sort(key=lambda row:row['index']);vectors=[row['embedding'] for row in out]
|
|
146
|
+
load();out=request('/v1/embeddings',{'input':ids,'encoding_format':'float','truncate':False},len(ids))['data'];out.sort(key=lambda row:row['index']);vectors=[row['embedding'] for row in out]
|
|
136
147
|
if len(vectors)!=len(a) or any(len(v)!=768 or any(not math.isfinite(x) for x in v) or abs(sum(x*x for x in v)**.5-1)>1e-5 for v in vectors):raise Refusal('invalid_vector')
|
|
137
148
|
return {'vectors':vectors,'identity':IDENTITY,'nativePID':server.pid}
|
|
138
149
|
if op=='index':return index(req)
|
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "d2b2e6b0e594b5dd41e9771c43f4ed0355d8fd048c4e247541199f3b812751d3";
|
package/src/compaction.ts
CHANGED
|
@@ -40,10 +40,12 @@ const RecallInput = Type.Union([
|
|
|
40
40
|
]);
|
|
41
41
|
const SearchInput = Type.Object({query,priorResults:Type.Optional(Type.Array(Type.Object({entryId,sourceSha:Type.String({minLength:64,maxLength:64}),start:Type.Integer({minimum:0}),end:Type.Integer({minimum:0})},{additionalProperties:false}),{maxItems:20}))},{additionalProperties:false});
|
|
42
42
|
const reply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }], details: {} });
|
|
43
|
-
/** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit.
|
|
44
|
-
*
|
|
45
|
-
* or its host, and stays until the session restarts. */
|
|
43
|
+
/** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit. Like every failure but
|
|
44
|
+
* a `CommitFault`, it ends with its attempt (`forgetFailure`), and the next attempt plans afresh from the session as it is. */
|
|
46
45
|
class Refusal extends Error {}
|
|
46
|
+
/** A cut this compactor supplied that Pi then failed to commit. Pi appends a cut to the session in memory before it writes the file, so the
|
|
47
|
+
* session may hold a cut its file lacks; what is written after would build on it. The one failure that stays until the session restarts. */
|
|
48
|
+
class CommitFault extends Error {}
|
|
47
49
|
/** A refusal for want of room, which the user can remedy: it carries advice. */
|
|
48
50
|
class CapacityError extends Refusal {}
|
|
49
51
|
/** What a capacity refusal tells the operator. When the provider has counted far more input than pi-tuck's estimate, the refusal most
|
|
@@ -118,10 +120,16 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
118
120
|
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
119
121
|
if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
|
|
120
122
|
const semantic = options.semanticRecall ?? createSemanticRecall();
|
|
123
|
+
const hostAccess = () => {
|
|
124
|
+
if (!active()) throw new Error("Compaction host is inactive");
|
|
125
|
+
options.assertAccess?.();
|
|
126
|
+
};
|
|
121
127
|
let semanticTransition: Promise<void> = Promise.resolve();
|
|
122
128
|
let semanticRequest = 0;
|
|
123
129
|
const semanticPending = new Set<Promise<unknown>>();
|
|
124
|
-
|
|
130
|
+
// Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
|
|
131
|
+
// attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
|
|
132
|
+
const semanticProvider = (ctx: ExtensionContext) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess);
|
|
125
133
|
const recorder = new IncidentRecorder(options.diagnostics);
|
|
126
134
|
let proposalId: string | undefined;
|
|
127
135
|
const observe = (ctx: ExtensionContext) => recorder.measureInline(() => {
|
|
@@ -230,7 +238,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
230
238
|
if (input > 0 && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
|
|
231
239
|
});
|
|
232
240
|
pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
|
|
233
|
-
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle";
|
|
241
|
+
pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); });
|
|
234
242
|
const active = () => options.active?.() ?? true;
|
|
235
243
|
const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
|
|
236
244
|
// The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
|
|
@@ -280,7 +288,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
280
288
|
const fail = (error: unknown, ctx: ExtensionContext) => {
|
|
281
289
|
recorder.stop("native-attempt-failed");
|
|
282
290
|
const notify = !failure && error instanceof CapacityError;
|
|
283
|
-
|
|
291
|
+
// A CommitFault stays whatever fails after it.
|
|
292
|
+
if (!(failure instanceof CommitFault)) failure = error instanceof Error ? error : new Error(String(error));
|
|
284
293
|
pendingBoundaryCommit = false;
|
|
285
294
|
pending = undefined;
|
|
286
295
|
try { options.fail?.(error); } finally {
|
|
@@ -288,13 +297,12 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
288
297
|
finally { ctx.abort(); }
|
|
289
298
|
}
|
|
290
299
|
};
|
|
291
|
-
// The one place a
|
|
292
|
-
// input started (a custom message) has only the first of these.
|
|
293
|
-
const
|
|
300
|
+
// The one place a failure is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
|
|
301
|
+
// input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
|
|
302
|
+
const forgetFailure = () => { if (!(failure instanceof CommitFault)) failure = undefined; };
|
|
294
303
|
const access = () => {
|
|
295
304
|
if (failure) throw failure;
|
|
296
|
-
|
|
297
|
-
options.assertAccess?.();
|
|
305
|
+
hostAccess();
|
|
298
306
|
};
|
|
299
307
|
// Abort alone is insufficient: Pi starts a fresh abort controller after
|
|
300
308
|
// before_agent_start. Keep this attempt blocked, then recheck on new idle input.
|
|
@@ -303,7 +311,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
303
311
|
let phase: "idle" | "decided" | "starting" = "idle";
|
|
304
312
|
pi.on("input", async (event, ctx) => {
|
|
305
313
|
if (!ctx.isIdle() || phase === "starting") return; // a steering or follow-up message joins the running request
|
|
306
|
-
|
|
314
|
+
forgetFailure();
|
|
307
315
|
phase = "decided";
|
|
308
316
|
try { await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools()); }
|
|
309
317
|
catch (error) { fail(error, ctx); }
|
|
@@ -359,7 +367,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
359
367
|
// Pi also labels the awaited idle incoming preflight "manual".
|
|
360
368
|
const incoming = pendingCut;
|
|
361
369
|
const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
|
|
362
|
-
if (operatorManual && !admittedRun)
|
|
370
|
+
if (operatorManual && !admittedRun) forgetFailure();
|
|
363
371
|
access();
|
|
364
372
|
if (event.customInstructions?.trim()) {
|
|
365
373
|
recorder.stop("no-cut-custom-instructions");
|
|
@@ -393,7 +401,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
393
401
|
: "Compaction did not reduce context within the estimated request budget",
|
|
394
402
|
plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
|
|
395
403
|
const reserve = planReserve(target, room, mandatory, ctx);
|
|
396
|
-
|
|
404
|
+
// Every cut asked while no run is admitted (a prompt's preflight, an operator's cut, Pi's own cut before a run a custom message
|
|
405
|
+
// starts) is planned for a run that has not begun: the session has moved on from any run that ended. A retry is inside its run.
|
|
406
|
+
const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, Boolean(incoming) || operatorManual || !admittedRun);
|
|
397
407
|
return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
|
|
398
408
|
: { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
|
|
399
409
|
},
|
|
@@ -450,8 +460,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
450
460
|
// An opportunistic cut is not needed to send the request, which is below the room: the guard decides. However it ends (the
|
|
451
461
|
// planner declined it, Pi had nothing to compact, an earlier handler cancelled it) it is no fault, unless a failure is latched
|
|
452
462
|
// or the compactor proposed a cut that then failed to commit (the session may hold what the file lacks).
|
|
463
|
+
// A needed cut that Pi has nothing to make (no history older than its keepRecentTokens since the last cut) is a refusal of
|
|
464
|
+
// this request, which ends with it: as a fault it stayed, and every later prompt of the session failed on it.
|
|
453
465
|
if (cut.opportunistic && !failure && !cut.proposed) resolve();
|
|
454
|
-
else
|
|
466
|
+
else if (!cut.proposed && isNoWork(error.message)) reject(new CapacityError(`The request needs a cut and Pi has none to make (${error.message})`));
|
|
467
|
+
else reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : cut.proposed ? new CommitFault(error.message) : error);
|
|
455
468
|
} }));
|
|
456
469
|
} finally { pendingCut = undefined; }
|
|
457
470
|
access();
|
|
@@ -536,11 +549,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
536
549
|
if (exhausted) return;
|
|
537
550
|
// Pi reports manual no-work as failures; an accepted pi-tuck draft cannot be a no-op.
|
|
538
551
|
if (event.reason === "manual" && !event.fromExtension && !event.aborted && isNoWork(event.errorMessage)) return;
|
|
539
|
-
if (active() && !event.aborted) fail(new Error(event.errorMessage ?? "Compaction failed"), ctx);
|
|
552
|
+
if (active() && !event.aborted) fail(new (event.fromExtension ? CommitFault : Error)(event.errorMessage ?? "Compaction failed"), ctx);
|
|
540
553
|
});
|
|
541
554
|
pi.on("agent_before_settle", (event, ctx) => {
|
|
542
555
|
if (!pendingBoundaryCommit) return;
|
|
543
|
-
if (event.outcome === "error") fail(new
|
|
556
|
+
if (event.outcome === "error") fail(new CommitFault("Native tool-loop boundary draft remained unconfirmed at error settlement"), ctx);
|
|
544
557
|
else pendingBoundaryCommit = false;
|
|
545
558
|
});
|
|
546
559
|
pi.on("context_with_system", (event, ctx) => {
|
package/src/semantic-recall.ts
CHANGED
|
@@ -102,6 +102,16 @@ export const HEAL_DELAYS_MS:readonly number[]=[5000,30000,120000];
|
|
|
102
102
|
export const HEAL_STEADY_MS=600000;
|
|
103
103
|
/** A session that has not searched for this long gives its worker back (the index stays on disk). Enabling starts nothing; a search starts the worker again. */
|
|
104
104
|
export const IDLE_STOP_MS=600000;
|
|
105
|
+
/** A worker call's deadline once the worker has answered a call after `ready`: index, embed and tokenize on a loaded model. */
|
|
106
|
+
export const CALL_DEADLINE_MS=60000;
|
|
107
|
+
/** 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
|
+
* 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
|
+
* scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
|
|
110
|
+
export const EMBED_TEXT_MS=30000;
|
|
111
|
+
/** 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
|
+
* 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 limits (120 s on Metal, then 400 s on the CPU, in semantic-worker.py). */
|
|
114
|
+
export const START_DEADLINE_MS=600000;
|
|
105
115
|
/** 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. */
|
|
106
116
|
export const MAX_INDEX_UNITS=8000;
|
|
107
117
|
/** Keys per worker `score` request: a multiple of the worker's 128-key sub-batches, aligned from the start of the covered prefix. A key is a SHA-256 hex digest (64 characters, see `hash`), so 1024 keys and the 768-float query make a request of about 90 KiB, far under both the 512 KiB design bound and the worker's 1 MiB line limit. */
|
|
@@ -118,8 +128,8 @@ class LocalWorker {
|
|
|
118
128
|
// This leaves room for the previous native write to finish plus one queued
|
|
119
129
|
// control, even when the child replies before that writer goroutine retires.
|
|
120
130
|
private nativeInputs:{id:number;control:string}[]=[];private nativeInputId?:number;
|
|
121
|
-
private child?:ChildProcessWithoutNullStreams;private scratch?:string;private starting?:Promise<void>;private exited?:Promise<void>;private buffer='';private next=0;private pending=new Map<number,{resolve:(value:any)=>void;reject:(error:Error)=>void;timer:ReturnType<typeof setTimeout>}>();private stopped=false;private lost=false;private closing?:Promise<void>;
|
|
122
|
-
constructor(private config:SemanticRecallConfig, private project:string, private cleanupFailure:(error:unknown)=>void, private onLoss:(reason:string)=>void=()=>{}){}
|
|
131
|
+
private child?:ChildProcessWithoutNullStreams;private scratch?:string;private starting?:Promise<void>;private exited?:Promise<void>;private buffer='';private next=0;private pending=new Map<number,{resolve:(value:any)=>void;reject:(error:Error)=>void;timer:ReturnType<typeof setTimeout>}>();private stopped=false;private lost=false;private closing?:Promise<void>;private loaded=false;
|
|
132
|
+
constructor(private config:SemanticRecallConfig, private project:string, private cleanupFailure:(error:unknown)=>void, private onLoss:(reason:string)=>void=()=>{}, private deadlines:{startMs:number;callMs:number;embedTextMs:number}={startMs:START_DEADLINE_MS,callMs:CALL_DEADLINE_MS,embedTextMs:EMBED_TEXT_MS}){}
|
|
123
133
|
private closeInBackground(){void this.close().catch(error=>this.cleanupFailure(error));}
|
|
124
134
|
// The worker stopped without the controller asking: report it once, then join it like any other background close.
|
|
125
135
|
private lose(reason:string){if(!this.stopped&&!this.lost){this.lost=true;this.onLoss(reason);}this.closeInBackground();}
|
|
@@ -152,7 +162,7 @@ class LocalWorker {
|
|
|
152
162
|
if(signal?.aborted)throw abort();if(this.stopped)throw new WorkerFailure('Local worker closed');
|
|
153
163
|
await this.start();if(this.stopped||signal?.aborted)throw this.lost&&!signal?.aborted?new WorkerFailure('Local worker stopped'):abort();if(this.pending.size>=2)throw new Error('Local semantic concurrency bound');
|
|
154
164
|
const id=++this.next,body=JSON.stringify({id,op,...input})+'\n';if(Buffer.byteLength(body)>1048576)throw new Error('Local worker input bound');
|
|
155
|
-
return new Promise((resolveReply,reject)=>{const onAbort=()=>{this.closeInBackground();reject(abort());};signal?.addEventListener('abort',onAbort,{once:true});const finish=(callback:(value:any)=>void,value:any)=>{signal?.removeEventListener('abort',onAbort);callback(value);};const timer=setTimeout(()=>{this.lose('Local runtime deadline');finish(reject,new WorkerFailure('Local runtime deadline'));},
|
|
165
|
+
return new Promise((resolveReply,reject)=>{const onAbort=()=>{this.closeInBackground();reject(abort());};signal?.addEventListener('abort',onAbort,{once:true});const finish=(callback:(value:any)=>void,value:any)=>{signal?.removeEventListener('abort',onAbort);callback(value);};const timer=setTimeout(()=>{this.lose('Local runtime deadline');finish(reject,new WorkerFailure('Local runtime deadline'));},this.loaded?this.deadlines.callMs+(op==='embed'?this.deadlines.embedTextMs*((input as {texts?:unknown[]}).texts?.length??0):0):this.deadlines.startMs);this.pending.set(id,{resolve:value=>{if(op!=='ready')this.loaded=true;finish(resolveReply,value);},reject:error=>finish(reject,error),timer});if(this.nativeHelper){const control:NativeOwnerInputControl={op:'input',input:Buffer.from(body).toString('base64')};this.nativeInputs.push({id,control:JSON.stringify(control)+'\n'});this.pumpNativeInput();}else this.child!.stdin.write(body);});
|
|
156
166
|
}
|
|
157
167
|
private pumpNativeInput(){if(this.stopped||this.nativeInputId!==undefined)return;const next=this.nativeInputs.shift();if(next){this.nativeInputId=next.id;this.child!.stdin.write(next.control);}}
|
|
158
168
|
private rejectAll(error:Error){for(const p of this.pending.values()){clearTimeout(p.timer);p.reject(error);}this.pending.clear();this.nativeInputs=[];this.nativeInputId=undefined;}
|
|
@@ -235,7 +245,7 @@ export class SemanticRecallController {
|
|
|
235
245
|
// Whether this session ran a search since the controller was created or last closed; it survives idle stops, so a cut after a long quiet spell still indexes.
|
|
236
246
|
private searched=false;
|
|
237
247
|
private readonly listeners=new Set<(state:SemanticState,reason:string|null)=>void>();private announced='disabled\0';private announcing=false;
|
|
238
|
-
constructor(config?:SemanticRecallConfig,private readonly timing:{healDelaysMs?:readonly number[];healSteadyMs?:number;idleStopMs?:number}={}){if(config)this.configure(config);}
|
|
248
|
+
constructor(config?:SemanticRecallConfig,private readonly timing:{healDelaysMs?:readonly number[];healSteadyMs?:number;idleStopMs?:number;startDeadlineMs?:number;callDeadlineMs?:number;embedTextMs?:number}={}){if(config)this.configure(config);}
|
|
239
249
|
/** Be told when `status().state` or `lastError` changes: once per change, after the synchronous work that caused it. Returns the unsubscribe. A listener that throws is ignored. */
|
|
240
250
|
onStateChange(listener:(state:SemanticState,reason:string|null)=>void):()=>void{this.listeners.add(listener);return()=>{this.listeners.delete(listener);};}
|
|
241
251
|
private touch(){if(this.announcing)return;this.announcing=true;queueMicrotask(()=>{this.announcing=false;const {state,lastError}=this.status(),key=state+'\0'+(lastError??'');if(key===this.announced)return;this.announced=key;for(const listener of [...this.listeners])try{listener(state,lastError);}catch{}});}
|
|
@@ -254,7 +264,7 @@ export class SemanticRecallController {
|
|
|
254
264
|
* It is the conversation that left the view only (kind first, no full pass), a heal backoff in progress is not bypassed, and the idle limit applies afterwards. */
|
|
255
265
|
indexAtCut(provider:()=>Snapshot){if(this.enabled&&!this.healTimer&&this.searched&&!this.active){this.active=true;this.armIdle();}this.schedule(provider);}
|
|
256
266
|
private async runtime(project:string){if(this.idling)await this.idling;if(!this.enabled)throw abort();outsideProject(project,this.config!.runtimeRoot,'runtime');outsideProject(project,scratchParent(this.config!),'scratch');outsideRuntime(this.config!.runtimeRoot,scratchParent(this.config!),'scratch');if(this.worker&&this.recycle){const old=this.worker;await old.close();if(this.worker===old){this.worker=undefined;this.identity='';this.recycle=false;}if(!this.enabled)throw abort();}
|
|
257
|
-
if(!this.worker){const worker=new LocalWorker(this.config!,project,error=>{if(this.worker!==worker)return;this.cleanupError=error instanceof Error?error.message:'Semantic cleanup failed';this.error=this.cleanupError;this.enabled=false;this.generation++;},reason=>{if(this.worker===worker)this.heal(new WorkerFailure(reason));});this.worker=worker;}if(!this.identity){const ready=await this.worker.call('ready');if(ready.dimensions!==768||ready.maxTokens!==8192||!ready.offline)throw new Error('Incompatible semantic model; EG2 768/8192 offline required');this.identity=hash({worker:ready.identity,chunk:'eg2-passage256-focused-disjoint-direct-v3'});}return this.worker;}
|
|
267
|
+
if(!this.worker){const worker=new LocalWorker(this.config!,project,error=>{if(this.worker!==worker)return;this.cleanupError=error instanceof Error?error.message:'Semantic cleanup failed';this.error=this.cleanupError;this.enabled=false;this.generation++;},reason=>{if(this.worker===worker)this.heal(new WorkerFailure(reason));},{startMs:this.timing.startDeadlineMs??START_DEADLINE_MS,callMs:this.timing.callDeadlineMs??CALL_DEADLINE_MS,embedTextMs:this.timing.embedTextMs??EMBED_TEXT_MS});this.worker=worker;}if(!this.identity){const ready=await this.worker.call('ready');if(ready.dimensions!==768||ready.maxTokens!==8192||!ready.offline)throw new Error('Incompatible semantic model; EG2 768/8192 offline required');this.identity=hash({worker:ready.identity,chunk:'eg2-passage256-focused-disjoint-direct-v3'});}return this.worker;}
|
|
258
268
|
private path(snapshot:Snapshot){const directory=this.config!.indexDirectory;outsideProject(snapshot.project,directory,'index');return join(directory,hash({owner:this.ownerId,session:snapshot.session,project:resolve(snapshot.project)})+'.sqlite');}
|
|
259
269
|
private async index(action:string,snapshot:Snapshot,extra:object={}){const path=this.path(snapshot),generation=this.generation,worker=await this.runtime(snapshot.project);if(!this.enabled||generation!==this.generation)throw abort();if(this.provider)this.recheck(this.provider,snapshot);this.indexFiles.add(path);const result=await worker.call('index',{path,identity:this.identity,action,projectDirectory:snapshot.project,...extra});if(!this.enabled||generation!==this.generation)throw abort();if(this.provider)this.recheck(this.provider,snapshot);return result;}
|
|
260
270
|
private async prepare(provider:()=>Snapshot,full=true){
|
package/src/tuck.ts
CHANGED
|
@@ -58,11 +58,13 @@ export function historicalAllowance(window: number, reserve: number, base: numbe
|
|
|
58
58
|
return Math.max(0, Math.min(Math.floor(free / 4), before - base - 1)) * 4;
|
|
59
59
|
}
|
|
60
60
|
/** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
|
|
61
|
-
export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false) {
|
|
62
|
-
return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual }, ctx, reserveTokens, measure, policy, retainCurrentInput);
|
|
61
|
+
export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false) {
|
|
62
|
+
return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt }, ctx, reserveTokens, measure, policy, retainCurrentInput);
|
|
63
63
|
}
|
|
64
64
|
/** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
|
|
65
|
-
|
|
65
|
+
/** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
|
|
66
|
+
* a run a custom message starts); the session has moved on from an ended run. */
|
|
67
|
+
export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean; newPrompt?: boolean }, ctx: ExtensionContext,
|
|
66
68
|
reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true): Draft {
|
|
67
69
|
const { isHistorySource, readable } = createRecall(policy);
|
|
68
70
|
const projected = ctx.sessionManager.buildSessionProjection();
|
|
@@ -102,10 +104,14 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
|
|
|
102
104
|
const calls = new Set(response.message.content.flatMap(block => block.type === "toolCall" ? [block.id] : []));
|
|
103
105
|
const hasFreshReceipt = entries.slice(lastResponse + 1).some(entry => entry.type === "message" &&
|
|
104
106
|
entry.message.role === "toolResult" && calls.has(entry.message.toolCallId));
|
|
105
|
-
|
|
107
|
+
// Fresh means the receipts are the answer the next request of this run carries. Once the session has moved on (the cut is planned for a
|
|
108
|
+
// run that has not begun, or a prompt has arrived since them) they are ordinary history, whatever ended their run: a reply discarded
|
|
109
|
+
// or one Pi hid when it recovered. Kept native, a receipt that never fits would refuse every later prompt. A retry is inside its run.
|
|
110
|
+
const superseded = input.newPrompt || entries.slice(lastResponse + 1).some(entry => entry.type === "message" && entry.message.role === "user");
|
|
111
|
+
freshRound = hasFreshReceipt && !superseded;
|
|
106
112
|
const keptId = cut.firstKeptEntryId;
|
|
107
113
|
const chosenBoundary = cut.compactAll ? entries.length : entries.findIndex(entry => entry.id === keptId);
|
|
108
|
-
if (
|
|
114
|
+
if (freshRound && lastResponse < chosenBoundary) {
|
|
109
115
|
const prefix = tuck.buildOwnCut(entries.slice(0, lastResponse), 0);
|
|
110
116
|
if (!prefix.ok) return { unavailable: "No reducible history before the fresh tool round" };
|
|
111
117
|
cut = { ...cut, messages: prefix.messages, selectedIds: prefix.selectedIds,
|