pi-tuck 1.0.6 → 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 CHANGED
@@ -3,6 +3,16 @@
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
+
6
16
  ## [1.0.6] - 2026-11-01
7
17
 
8
18
  ### Changed
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.6`.
62
- - To pin a version, add it: `pi install npm:pi-tuck@1.0.6`.
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
 
@@ -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
- const handle = await installCompaction(pi, { semanticRecall, cutAt: settings.cutAt, onSemanticSetting: async (enabled) => { off = !enabled; } });
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 (process.platform !== "darwin" || process.arch !== "arm64")
41
- return once(ctx, "pi-tuck: semantic recall needs macOS on Apple silicon for now; compaction and word recall work.", "info");
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)))
@@ -1 +1 @@
1
- export declare const BUILD_ID = "d2b2e6b0e594b5dd41e9771c43f4ed0355d8fd048c4e247541199f3b812751d3";
1
+ export declare const BUILD_ID = "3ef35608c93cc69dbe42be20019d79b65f1a1e047e532fcd5efab4ab939b9150";
@@ -1 +1 @@
1
- export const BUILD_ID = "d2b2e6b0e594b5dd41e9771c43f4ed0355d8fd048c4e247541199f3b812751d3";
1
+ export const BUILD_ID = "3ef35608c93cc69dbe42be20019d79b65f1a1e047e532fcd5efab4ab939b9150";
@@ -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;
@@ -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
- pi.registerTool({ name: "tuck_search", label: "History search", parameters: SearchInput,
395
- 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.",
396
- async execute(_id, raw, signal, _update, ctx) {
397
- access();
398
- if (!Check(SearchInput, raw))
399
- throw new Error("Invalid broad search input");
400
- const input = structuredClone(raw);
401
- recorder.capture("recall-action", "authorized-native-tool-invocation-no-result-copy", input);
402
- // Bind the actual independent lexical offer, including attribution derived
403
- // from custom markers and receipts excluded from semantic indexing. Rebuild
404
- // under current caller policy after await, when the session moved; never send this block to the encoder.
405
- const wordOffer = () => tuckRecall(tuck, ctx.sessionManager.getEntries(), ctx.sessionManager.getBranch(), { query: input.query }, options.recallPolicy);
406
- // 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.
407
- // 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.
408
- const provider = semanticProvider(ctx), initialSnapshot = provider(), initialKey = eligibilityKey(ctx), words = wordOffer(), wordText = JSON.stringify(words);
409
- const related = await semantic.search(input.query, provider, signal, input.priorResults ?? [], words);
410
- access();
411
- if (signal?.aborted)
412
- throw Object.assign(new Error('Semantic recall cancelled'), { name: 'AbortError' });
413
- const now = provider();
414
- if (initialSnapshot.id !== now.id || (eligibilityKeyMoved(ctx, initialKey) && wordText !== JSON.stringify(wordOffer())))
415
- return { ...reply({ status: "snapshot_invalidated", notice: "Search source eligibility or version changed; no earlier evidence delivered. Request a fresh query." }), isError: true };
416
- const page = budgetSemantic(related, 20000 - wordText.length - 1);
417
- return { content: [{ type: "text", text: wordText }, { type: "text", text: JSON.stringify(page) }], details: { semantic: { status: page.status, snapshotId: page.coverage.snapshotId } } };
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 = "d2b2e6b0e594b5dd41e9771c43f4ed0355d8fd048c4e247541199f3b812751d3";
3
+ export declare const DIAGNOSTIC_BUILD = "3ef35608c93cc69dbe42be20019d79b65f1a1e047e532fcd5efab4ab939b9150";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -112,13 +112,15 @@ export declare const HEAL_STEADY_MS = 600000;
112
112
  export declare const IDLE_STOP_MS = 600000;
113
113
  /** A worker call's deadline once the worker has answered a call after `ready`: index, embed and tokenize on a loaded model. */
114
114
  export declare const CALL_DEADLINE_MS = 60000;
115
+ /** Why semantic recall is off where the embedding server cannot start on Metal (a VM, a Mac without a GPU): it runs on Metal only. */
116
+ export declare const METAL_UNAVAILABLE = "Semantic recall needs Metal; word recall still works";
115
117
  /** What an `embed` call adds to its deadline for each text it carries. A batch of 8 passages on a starved CPU took longer than the per-call
116
118
  * deadline, and the worker failed when it should have slowed down; a query (one text) still fails fast when the server hangs. The worker
117
119
  * scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
118
120
  export declare const EMBED_TEXT_MS = 30000;
119
121
  /** The deadline for a worker's start: Python start, `ready` (which verifies every runtime file) and the first call after it, which loads the
120
122
  * model and starts its native server. On a busy machine that takes minutes; held to the per-call deadline, the worker never started and
121
- * semantic recall went unavailable. It stays above the worker's own native start limits (120 s on Metal, then 400 s on the CPU, in semantic-worker.py). */
123
+ * semantic recall went unavailable. It stays above the worker's own native start limit (540 s, in semantic-worker.py). */
122
124
  export declare const START_DEADLINE_MS = 600000;
123
125
  /** The most units one index window holds. The worker's SQLite store stops at 8,008 rows (`PRAGMA max_page_count=8192` in scripts/semantic-worker.py), so a cap below that keeps a window of short units from filling the store and sticking in `unavailable`. Raise it only together with the store. */
124
126
  export declare const MAX_INDEX_UNITS = 8000;
@@ -171,13 +171,15 @@ export const HEAL_STEADY_MS = 600000;
171
171
  export const IDLE_STOP_MS = 600000;
172
172
  /** A worker call's deadline once the worker has answered a call after `ready`: index, embed and tokenize on a loaded model. */
173
173
  export const CALL_DEADLINE_MS = 60000;
174
+ /** Why semantic recall is off where the embedding server cannot start on Metal (a VM, a Mac without a GPU): it runs on Metal only. */
175
+ export const METAL_UNAVAILABLE = 'Semantic recall needs Metal; word recall still works';
174
176
  /** What an `embed` call adds to its deadline for each text it carries. A batch of 8 passages on a starved CPU took longer than the per-call
175
177
  * deadline, and the worker failed when it should have slowed down; a query (one text) still fails fast when the server hangs. The worker
176
178
  * scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
177
179
  export const EMBED_TEXT_MS = 30000;
178
180
  /** The deadline for a worker's start: Python start, `ready` (which verifies every runtime file) and the first call after it, which loads the
179
181
  * model and starts its native server. On a busy machine that takes minutes; held to the per-call deadline, the worker never started and
180
- * semantic recall went unavailable. It stays above the worker's own native start limits (120 s on Metal, then 400 s on the CPU, in semantic-worker.py). */
182
+ * semantic recall went unavailable. It stays above the worker's own native start limit (540 s, in semantic-worker.py). */
181
183
  export const START_DEADLINE_MS = 600000;
182
184
  /** The most units one index window holds. The worker's SQLite store stops at 8,008 rows (`PRAGMA max_page_count=8192` in scripts/semantic-worker.py), so a cap below that keeps a window of short units from filling the store and sticking in `unavailable`. Raise it only together with the store. */
183
185
  export const MAX_INDEX_UNITS = 8000;
@@ -274,7 +276,7 @@ class LocalWorker {
274
276
  if (pending) {
275
277
  clearTimeout(pending.timer);
276
278
  this.pending.delete(reply.id);
277
- reply.ok ? pending.resolve(reply.result) : pending.reject(new WorkerFailure('Local semantic runtime: ' + reply.error?.code, reply.error?.code ?? null));
279
+ reply.ok ? pending.resolve(reply.result) : pending.reject(new WorkerFailure(reply.error?.code === 'metal_unavailable' ? METAL_UNAVAILABLE : 'Local semantic runtime: ' + reply.error?.code, reply.error?.code ?? null));
278
280
  if (this.nativeHelper && this.nativeInputId === reply.id) {
279
281
  this.nativeInputId = undefined;
280
282
  this.pumpNativeInput();
package/index.ts CHANGED
@@ -22,7 +22,9 @@ export default async function (pi: ExtensionAPI) {
22
22
  // `/tuck-semantic off` holds for the rest of the session, including over a setup that finishes later.
23
23
  let off = false;
24
24
  const settings = readSettings(join(getAgentDir(), "pi-tuck.json"));
25
- const handle = await installCompaction(pi, { semanticRecall, cutAt: settings.cutAt, onSemanticSetting: async enabled => { off = !enabled; } });
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 (process.platform !== "darwin" || process.arch !== "arm64")
37
- return once(ctx, "pi-tuck: semantic recall needs macOS on Apple silicon for now; compaction and word recall work.", "info");
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.6",
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",
@@ -92,9 +92,9 @@ def load():
92
92
  # Pinned common/arg.cpp supports LLAMA_API_KEY; server-http.cpp requires Bearer
93
93
  # on /props and embeddings, but /health is public. No token in process argv.
94
94
  env['LLAMA_API_KEY']=encoder_token
95
- def launch(gpu_layers,limit):
95
+ def launch(limit):
96
96
  """The server, or None when it exits or is not healthy within `limit` seconds (it is then stopped)."""
97
- child=subprocess.Popen([str(executable),'-m',str(ROOT/'model.gguf'),'--embedding','--pooling','mean','--embd-normalize','2','--ctx-size','8192','--batch-size','8192','--ubatch-size','8192','--parallel','1','--n-gpu-layers',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)
97
+ child=subprocess.Popen([str(executable),'-m',str(ROOT/'model.gguf'),'--embedding','--pooling','mean','--embd-normalize','2','--ctx-size','8192','--batch-size','8192','--ubatch-size','8192','--parallel','1','--n-gpu-layers','99','--cache-type-k','f32','--cache-type-v','f32','--flash-attn','off','--threads','4',*transport,'--no-webui','--log-disable'],stdin=subprocess.DEVNULL,stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,env=env)
98
98
  started=time.monotonic()
99
99
  while child.poll() is None and time.monotonic()-started<=limit:
100
100
  try:
@@ -106,13 +106,12 @@ def load():
106
106
  if child.poll() is None:
107
107
  child.kill();child.wait()
108
108
  return None
109
- # Metal 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')
109
+ # Semantic recall runs on Metal only (the project's platform choice): on Apple silicon it embeds a batch about 56 times faster than the
110
+ # CPU did (0.7 s against 39 s for 8 passages at load 44-76). Where Metal cannot start (a VM, no GPU) the server exits or never becomes
111
+ # healthy, and semantic recall goes off with that reason; word recall keeps working. A busy machine can take minutes to start it; the
112
+ # limit stays under the host's start deadline (START_DEADLINE_MS, 600 s).
113
+ server=launch(540)
114
+ if server is None:raise Refusal('metal_unavailable')
116
115
  return server
117
116
  def run(req):
118
117
  op=req['op']
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "d2b2e6b0e594b5dd41e9771c43f4ed0355d8fd048c4e247541199f3b812751d3";
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);
@@ -104,13 +104,15 @@ export const HEAL_STEADY_MS=600000;
104
104
  export const IDLE_STOP_MS=600000;
105
105
  /** A worker call's deadline once the worker has answered a call after `ready`: index, embed and tokenize on a loaded model. */
106
106
  export const CALL_DEADLINE_MS=60000;
107
+ /** Why semantic recall is off where the embedding server cannot start on Metal (a VM, a Mac without a GPU): it runs on Metal only. */
108
+ export const METAL_UNAVAILABLE='Semantic recall needs Metal; word recall still works';
107
109
  /** What an `embed` call adds to its deadline for each text it carries. A batch of 8 passages on a starved CPU took longer than the per-call
108
110
  * deadline, and the worker failed when it should have slowed down; a query (one text) still fails fast when the server hangs. The worker
109
111
  * scales its native request timeout by the same amount (semantic-worker.py), so that limit is not reached first. */
110
112
  export const EMBED_TEXT_MS=30000;
111
113
  /** The deadline for a worker's start: Python start, `ready` (which verifies every runtime file) and the first call after it, which loads the
112
114
  * model and starts its native server. On a busy machine that takes minutes; held to the per-call deadline, the worker never started and
113
- * semantic recall went unavailable. It stays above the worker's own native start limits (120 s on Metal, then 400 s on the CPU, in semantic-worker.py). */
115
+ * semantic recall went unavailable. It stays above the worker's own native start limit (540 s, in semantic-worker.py). */
114
116
  export const START_DEADLINE_MS=600000;
115
117
  /** The most units one index window holds. The worker's SQLite store stops at 8,008 rows (`PRAGMA max_page_count=8192` in scripts/semantic-worker.py), so a cap below that keeps a window of short units from filling the store and sticking in `unavailable`. Raise it only together with the store. */
116
118
  export const MAX_INDEX_UNITS=8000;
@@ -149,7 +151,7 @@ class LocalWorker {
149
151
  const launchLine=JSON.stringify(launch)+'\n';if(this.nativeHelper&&Buffer.byteLength(launchLine)>2*1048576)throw new Error('Native owner launch bound');
150
152
  const child=spawn(this.nativeHelper?this.config.nativeOwnerHelperPath!:this.config.pythonPath,this.nativeHelper?['--run']:args,{cwd:this.scratch,env:this.nativeHelper?osEnvironment:env,stdio:'pipe',detached:!this.nativeHelper});
151
153
  this.child=child;this.exited=new Promise<void>(done=>child.once('close',(code,signal)=>{this.nativeExit={code,signal};this.rejectAll(new WorkerFailure('Local worker stopped'));done();this.lose('Local worker stopped');}));
152
- child.stdout.on('data',data=>{this.buffer+=data.toString();if(this.buffer.length>16*1024*1024){this.lose('Local worker output bound');return;}let end;while((end=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,end);this.buffer=this.buffer.slice(end+1);try{const reply=JSON.parse(line),pending=this.pending.get(reply.id);if(pending){clearTimeout(pending.timer);this.pending.delete(reply.id);reply.ok?pending.resolve(reply.result):pending.reject(new WorkerFailure('Local semantic runtime: '+reply.error?.code,reply.error?.code??null));if(this.nativeHelper&&this.nativeInputId===reply.id){this.nativeInputId=undefined;this.pumpNativeInput();}}}catch{this.lose('Local worker framing');}}});
154
+ child.stdout.on('data',data=>{this.buffer+=data.toString();if(this.buffer.length>16*1024*1024){this.lose('Local worker output bound');return;}let end;while((end=this.buffer.indexOf('\n'))>=0){const line=this.buffer.slice(0,end);this.buffer=this.buffer.slice(end+1);try{const reply=JSON.parse(line),pending=this.pending.get(reply.id);if(pending){clearTimeout(pending.timer);this.pending.delete(reply.id);reply.ok?pending.resolve(reply.result):pending.reject(new WorkerFailure(reply.error?.code==='metal_unavailable'?METAL_UNAVAILABLE:'Local semantic runtime: '+reply.error?.code,reply.error?.code??null));if(this.nativeHelper&&this.nativeInputId===reply.id){this.nativeInputId=undefined;this.pumpNativeInput();}}}catch{this.lose('Local worker framing');}}});
153
155
  child.stdout.on('error',()=>this.lose('Local worker output'));
154
156
  if(this.nativeHelper)this.helperReceipt=observeNativeFrames(child.stderr,()=>{},()=>this.closeInBackground());
155
157
  else child.stderr.resume();