pi-tuck 1.0.5 → 1.0.7
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 +27 -0
- package/README.md +4 -4
- 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/compaction.d.ts +2 -0
- package/dist/src/compaction.js +28 -27
- package/dist/src/diagnostics.d.ts +1 -1
- package/dist/src/semantic-recall.d.ts +15 -0
- package/dist/src/semantic-recall.js +20 -4
- package/index.ts +5 -3
- package/package.json +1 -1
- package/scripts/semantic-worker.py +26 -16
- package/src/build-id.ts +1 -1
- package/src/compaction.ts +5 -3
- package/src/semantic-recall.ts +18 -6
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,33 @@
|
|
|
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.7] - 2026-11-01
|
|
7
|
+
|
|
8
|
+
### Changed
|
|
9
|
+
|
|
10
|
+
- 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
|
|
11
|
+
VM, a Mac without a GPU), semantic recall goes off with "Semantic recall needs Metal; word recall still works", and word recall keeps
|
|
12
|
+
answering.
|
|
13
|
+
- Off Apple silicon (Linux, Intel Macs), pi-tuck compacts and offers word recall only: `tuck_search` is no longer registered, `tuck_recall`'s
|
|
14
|
+
description no longer points to it, and nothing is downloaded. On Apple silicon both tools are as before.
|
|
15
|
+
|
|
16
|
+
## [1.0.6] - 2026-11-01
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- 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
|
|
21
|
+
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,
|
|
22
|
+
56 times faster, with nearly the same vectors (97.9% top-10 overlap with the CPU's). Indexing therefore finishes, and `tuck_search` ranks
|
|
23
|
+
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
|
|
24
|
+
installed runtime is kept; each session's index is rebuilt once.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Semantic recall no longer switches itself off on a busy machine. The worker's start (Python, the runtime check, the model load) now has
|
|
29
|
+
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
|
|
30
|
+
machine slows indexing down instead of making it fail. A worker that still fails is retried in the same session and recovers once the
|
|
31
|
+
machine is free.
|
|
32
|
+
|
|
6
33
|
## [1.0.5] - 2026-10-31
|
|
7
34
|
|
|
8
35
|
### Fixed
|
package/README.md
CHANGED
|
@@ -58,8 +58,8 @@ Then start a new Pi session. That is all. pi-tuck takes over Pi's compaction, bo
|
|
|
58
58
|
|
|
59
59
|
- Needs Pi 1.1 or later (tested on 1.1.0 and 1.4.0), on macOS or Linux.
|
|
60
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).
|
|
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.
|
|
61
|
+
- Prefer GitHub? `pi install git:github.com/galyavin/pi-tuck@v1.0.7`.
|
|
62
|
+
- To pin a version, add it: `pi install npm:pi-tuck@1.0.7`.
|
|
63
63
|
|
|
64
64
|
## How it works
|
|
65
65
|
|
|
@@ -120,7 +120,7 @@ Each row says what was measured, under which conditions, and where the data is.
|
|
|
120
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) |
|
|
121
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) |
|
|
122
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 |
|
|
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 | [`test/cut-yield.test.ts`](test/cut-yield.test.ts), which pins that every cut frees at least a tenth of the room |
|
|
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 |
|
|
124
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 |
|
|
125
125
|
|
|
126
126
|
Notes:
|
|
@@ -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 the CPU. No text leaves your machine. Elsewhere, `tuck_search` uses keywords, and everything else works.
|
|
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), or on the CPU where Metal cannot start. No text leaves your machine. Elsewhere, `tuck_search` uses keywords, and everything else 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 = "3ef35608c93cc69dbe42be20019d79b65f1a1e047e532fcd5efab4ab939b9150";
|
package/dist/src/build-id.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "3ef35608c93cc69dbe42be20019d79b65f1a1e047e532fcd5efab4ab939b9150";
|
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
|
@@ -97,7 +97,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
97
97
|
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
98
98
|
if (!validCutAt(cutAt))
|
|
99
99
|
throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
|
|
100
|
-
const semantic = options.semanticRecall ?? createSemanticRecall();
|
|
100
|
+
const semantic = options.semanticRecall ?? createSemanticRecall(), search = options.search ?? true;
|
|
101
101
|
const hostAccess = () => {
|
|
102
102
|
if (!active())
|
|
103
103
|
throw new Error("Compaction host is inactive");
|
|
@@ -364,7 +364,7 @@ export async function installCompaction(pi, options = {}) {
|
|
|
364
364
|
});
|
|
365
365
|
pi.registerTool({
|
|
366
366
|
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.",
|
|
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. " + (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
368
|
async execute(_id, raw, signal, _update, ctx) {
|
|
369
369
|
access();
|
|
370
370
|
const input = checkedInput(raw);
|
|
@@ -391,31 +391,32 @@ export async function installCompaction(pi, options = {}) {
|
|
|
391
391
|
return reply(tuckRecall(tuck, ctx.sessionManager.getEntries(), branch, input, options.recallPolicy));
|
|
392
392
|
},
|
|
393
393
|
});
|
|
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
|
-
|
|
394
|
+
if (search)
|
|
395
|
+
pi.registerTool({ name: "tuck_search", label: "History search", parameters: SearchInput,
|
|
396
|
+
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.",
|
|
397
|
+
async execute(_id, raw, signal, _update, ctx) {
|
|
398
|
+
access();
|
|
399
|
+
if (!Check(SearchInput, raw))
|
|
400
|
+
throw new Error("Invalid broad search input");
|
|
401
|
+
const input = structuredClone(raw);
|
|
402
|
+
recorder.capture("recall-action", "authorized-native-tool-invocation-no-result-copy", input);
|
|
403
|
+
// Bind the actual independent lexical offer, including attribution derived
|
|
404
|
+
// from custom markers and receipts excluded from semantic indexing. Rebuild
|
|
405
|
+
// under current caller policy after await, when the session moved; never send this block to the encoder.
|
|
406
|
+
const wordOffer = () => tuckRecall(tuck, ctx.sessionManager.getEntries(), ctx.sessionManager.getBranch(), { query: input.query }, options.recallPolicy);
|
|
407
|
+
// 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.
|
|
408
|
+
// 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.
|
|
409
|
+
const provider = semanticProvider(ctx), initialSnapshot = provider(), initialKey = eligibilityKey(ctx), words = wordOffer(), wordText = JSON.stringify(words);
|
|
410
|
+
const related = await semantic.search(input.query, provider, signal, input.priorResults ?? [], words);
|
|
411
|
+
access();
|
|
412
|
+
if (signal?.aborted)
|
|
413
|
+
throw Object.assign(new Error('Semantic recall cancelled'), { name: 'AbortError' });
|
|
414
|
+
const now = provider();
|
|
415
|
+
if (initialSnapshot.id !== now.id || (eligibilityKeyMoved(ctx, initialKey) && wordText !== JSON.stringify(wordOffer())))
|
|
416
|
+
return { ...reply({ status: "snapshot_invalidated", notice: "Search source eligibility or version changed; no earlier evidence delivered. Request a fresh query." }), isError: true };
|
|
417
|
+
const page = budgetSemantic(related, 20000 - wordText.length - 1);
|
|
418
|
+
return { content: [{ type: "text", text: wordText }, { type: "text", text: JSON.stringify(page) }], details: { semantic: { status: page.status, snapshotId: page.coverage.snapshotId } } };
|
|
419
|
+
} });
|
|
419
420
|
pi.on("session_before_compact", (event, ctx) => {
|
|
420
421
|
try {
|
|
421
422
|
if (!active())
|
|
@@ -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 = "3ef35608c93cc69dbe42be20019d79b65f1a1e047e532fcd5efab4ab939b9150";
|
|
4
4
|
export interface Receipt {
|
|
5
5
|
id: string;
|
|
6
6
|
build: string;
|
|
@@ -110,6 +110,18 @@ 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
|
+
/** 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";
|
|
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
|
|
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
|
|
119
|
+
* scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
|
|
120
|
+
export declare const EMBED_TEXT_MS = 30000;
|
|
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
|
|
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
|
|
123
|
+
* semantic recall went unavailable. It stays above the worker's own native start limit (540 s, in semantic-worker.py). */
|
|
124
|
+
export declare const START_DEADLINE_MS = 600000;
|
|
113
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. */
|
|
114
126
|
export declare const MAX_INDEX_UNITS = 8000;
|
|
115
127
|
/** The wait before the next automatic attempt, given how many were already armed since the last success. */
|
|
@@ -168,6 +180,9 @@ export declare class SemanticRecallController {
|
|
|
168
180
|
healDelaysMs?: readonly number[];
|
|
169
181
|
healSteadyMs?: number;
|
|
170
182
|
idleStopMs?: number;
|
|
183
|
+
startDeadlineMs?: number;
|
|
184
|
+
callDeadlineMs?: number;
|
|
185
|
+
embedTextMs?: number;
|
|
171
186
|
});
|
|
172
187
|
/** 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
188
|
onStateChange(listener: (state: SemanticState, reason: string | null) => void): () => void;
|
|
@@ -169,6 +169,18 @@ 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
|
+
/** 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';
|
|
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
|
|
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
|
|
178
|
+
* scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
|
|
179
|
+
export const EMBED_TEXT_MS = 30000;
|
|
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
|
|
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
|
|
182
|
+
* semantic recall went unavailable. It stays above the worker's own native start limit (540 s, in semantic-worker.py). */
|
|
183
|
+
export const START_DEADLINE_MS = 600000;
|
|
172
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. */
|
|
173
185
|
export const MAX_INDEX_UNITS = 8000;
|
|
174
186
|
/** 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 +195,7 @@ class LocalWorker {
|
|
|
183
195
|
project;
|
|
184
196
|
cleanupFailure;
|
|
185
197
|
onLoss;
|
|
198
|
+
deadlines;
|
|
186
199
|
nativeHelper = false;
|
|
187
200
|
jobId;
|
|
188
201
|
helperReceipt;
|
|
@@ -202,11 +215,13 @@ class LocalWorker {
|
|
|
202
215
|
stopped = false;
|
|
203
216
|
lost = false;
|
|
204
217
|
closing;
|
|
205
|
-
|
|
218
|
+
loaded = false;
|
|
219
|
+
constructor(config, project, cleanupFailure, onLoss = () => { }, deadlines = { startMs: START_DEADLINE_MS, callMs: CALL_DEADLINE_MS, embedTextMs: EMBED_TEXT_MS }) {
|
|
206
220
|
this.config = config;
|
|
207
221
|
this.project = project;
|
|
208
222
|
this.cleanupFailure = cleanupFailure;
|
|
209
223
|
this.onLoss = onLoss;
|
|
224
|
+
this.deadlines = deadlines;
|
|
210
225
|
}
|
|
211
226
|
closeInBackground() { void this.close().catch(error => this.cleanupFailure(error)); }
|
|
212
227
|
// The worker stopped without the controller asking: report it once, then join it like any other background close.
|
|
@@ -261,7 +276,7 @@ class LocalWorker {
|
|
|
261
276
|
if (pending) {
|
|
262
277
|
clearTimeout(pending.timer);
|
|
263
278
|
this.pending.delete(reply.id);
|
|
264
|
-
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));
|
|
265
280
|
if (this.nativeHelper && this.nativeInputId === reply.id) {
|
|
266
281
|
this.nativeInputId = undefined;
|
|
267
282
|
this.pumpNativeInput();
|
|
@@ -303,7 +318,8 @@ class LocalWorker {
|
|
|
303
318
|
const id = ++this.next, body = JSON.stringify({ id, op, ...input }) + '\n';
|
|
304
319
|
if (Buffer.byteLength(body) > 1048576)
|
|
305
320
|
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')); },
|
|
321
|
+
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')
|
|
322
|
+
this.loaded = true; finish(resolveReply, value); }, reject: error => finish(reject, error), timer }); if (this.nativeHelper) {
|
|
307
323
|
const control = { op: 'input', input: Buffer.from(body).toString('base64') };
|
|
308
324
|
this.nativeInputs.push({ id, control: JSON.stringify(control) + '\n' });
|
|
309
325
|
this.pumpNativeInput();
|
|
@@ -620,7 +636,7 @@ export class SemanticRecallController {
|
|
|
620
636
|
if (!this.worker) {
|
|
621
637
|
const worker = new LocalWorker(this.config, project, error => { if (this.worker !== worker)
|
|
622
638
|
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)); });
|
|
639
|
+
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
640
|
this.worker = worker;
|
|
625
641
|
}
|
|
626
642
|
if (!this.identity) {
|
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.7",
|
|
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,26 @@ 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(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','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
|
+
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
|
+
# 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')
|
|
105
115
|
return server
|
|
106
116
|
def run(req):
|
|
107
117
|
op=req['op']
|
|
@@ -132,7 +142,7 @@ def run(req):
|
|
|
132
142
|
if len(a)>8:raise Refusal('batch_limit')
|
|
133
143
|
ids=[enc(framed(t,kind)) for t in a]
|
|
134
144
|
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]
|
|
145
|
+
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
146
|
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
147
|
return {'vectors':vectors,'identity':IDENTITY,'nativePID':server.pid}
|
|
138
148
|
if op=='index':return index(req)
|
package/src/build-id.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export const BUILD_ID = "
|
|
1
|
+
export const BUILD_ID = "3ef35608c93cc69dbe42be20019d79b65f1a1e047e532fcd5efab4ab939b9150";
|
package/src/compaction.ts
CHANGED
|
@@ -88,6 +88,8 @@ export function seedWindow(branch: readonly SessionEntry[], key: string): number
|
|
|
88
88
|
}
|
|
89
89
|
export interface CompactionOptions {
|
|
90
90
|
semanticRecall?: SemanticRecallController;
|
|
91
|
+
/** Whether `tuck_search` exists: false where semantic recall cannot run (off Apple silicon), so the model is offered word recall only. */
|
|
92
|
+
search?: boolean;
|
|
91
93
|
/** Optional host-owned transient operator preference; handle operations never call back. */
|
|
92
94
|
onSemanticSetting?: (enabled: boolean) => Promise<unknown>;
|
|
93
95
|
semanticStatus?: () => unknown;
|
|
@@ -119,7 +121,7 @@ export interface CompactionOptions {
|
|
|
119
121
|
export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
|
|
120
122
|
const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
|
|
121
123
|
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();
|
|
124
|
+
const semantic = options.semanticRecall ?? createSemanticRecall(), search = options.search ?? true;
|
|
123
125
|
const hostAccess = () => {
|
|
124
126
|
if (!active()) throw new Error("Compaction host is inactive");
|
|
125
127
|
options.assertAccess?.();
|
|
@@ -318,7 +320,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
318
320
|
});
|
|
319
321
|
pi.registerTool({
|
|
320
322
|
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.",
|
|
323
|
+
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
324
|
async execute(_id, raw, signal, _update, ctx) {
|
|
323
325
|
access();
|
|
324
326
|
const input = checkedInput(raw);
|
|
@@ -341,7 +343,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
|
|
|
341
343
|
return reply(tuckRecall(tuck, ctx.sessionManager.getEntries(), branch, input, options.recallPolicy));
|
|
342
344
|
},
|
|
343
345
|
});
|
|
344
|
-
pi.registerTool({name:"tuck_search",label:"History search",parameters:SearchInput,
|
|
346
|
+
if (search) pi.registerTool({name:"tuck_search",label:"History search",parameters:SearchInput,
|
|
345
347
|
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
348
|
async execute(_id,raw,signal,_update,ctx){
|
|
347
349
|
access();if(!Check(SearchInput,raw))throw new Error("Invalid broad search input");const input=structuredClone(raw);
|
package/src/semantic-recall.ts
CHANGED
|
@@ -102,6 +102,18 @@ 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
|
+
/** 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';
|
|
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
|
|
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
|
|
111
|
+
* scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
|
|
112
|
+
export const EMBED_TEXT_MS=30000;
|
|
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
|
|
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
|
|
115
|
+
* semantic recall went unavailable. It stays above the worker's own native start limit (540 s, in semantic-worker.py). */
|
|
116
|
+
export const START_DEADLINE_MS=600000;
|
|
105
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. */
|
|
106
118
|
export const MAX_INDEX_UNITS=8000;
|
|
107
119
|
/** 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 +130,8 @@ class LocalWorker {
|
|
|
118
130
|
// This leaves room for the previous native write to finish plus one queued
|
|
119
131
|
// control, even when the child replies before that writer goroutine retires.
|
|
120
132
|
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=()=>{}){}
|
|
133
|
+
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;
|
|
134
|
+
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
135
|
private closeInBackground(){void this.close().catch(error=>this.cleanupFailure(error));}
|
|
124
136
|
// The worker stopped without the controller asking: report it once, then join it like any other background close.
|
|
125
137
|
private lose(reason:string){if(!this.stopped&&!this.lost){this.lost=true;this.onLoss(reason);}this.closeInBackground();}
|
|
@@ -139,7 +151,7 @@ class LocalWorker {
|
|
|
139
151
|
const launchLine=JSON.stringify(launch)+'\n';if(this.nativeHelper&&Buffer.byteLength(launchLine)>2*1048576)throw new Error('Native owner launch bound');
|
|
140
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});
|
|
141
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');}));
|
|
142
|
-
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');}}});
|
|
143
155
|
child.stdout.on('error',()=>this.lose('Local worker output'));
|
|
144
156
|
if(this.nativeHelper)this.helperReceipt=observeNativeFrames(child.stderr,()=>{},()=>this.closeInBackground());
|
|
145
157
|
else child.stderr.resume();
|
|
@@ -152,7 +164,7 @@ class LocalWorker {
|
|
|
152
164
|
if(signal?.aborted)throw abort();if(this.stopped)throw new WorkerFailure('Local worker closed');
|
|
153
165
|
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
166
|
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'));},
|
|
167
|
+
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
168
|
}
|
|
157
169
|
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
170
|
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 +247,7 @@ export class SemanticRecallController {
|
|
|
235
247
|
// 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
248
|
private searched=false;
|
|
237
249
|
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);}
|
|
250
|
+
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
251
|
/** 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
252
|
onStateChange(listener:(state:SemanticState,reason:string|null)=>void):()=>void{this.listeners.add(listener);return()=>{this.listeners.delete(listener);};}
|
|
241
253
|
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 +266,7 @@ export class SemanticRecallController {
|
|
|
254
266
|
* 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
267
|
indexAtCut(provider:()=>Snapshot){if(this.enabled&&!this.healTimer&&this.searched&&!this.active){this.active=true;this.armIdle();}this.schedule(provider);}
|
|
256
268
|
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;}
|
|
269
|
+
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
270
|
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
271
|
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
272
|
private async prepare(provider:()=>Snapshot,full=true){
|