pi-tuck 1.0.25 → 1.0.26

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,24 @@
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.26] - 2026-11-10
7
+
8
+ ### Fixed
9
+
10
+ - **1.0.25 failed to load in an installed Pi** ("Cannot find module '@earendil-works/pi-ai/utils/estimate'"). Pi's extension loader resolves
11
+ only a few pi-ai entry points for an installed package, and 1.0.25 imported another one by name.
12
+ - **Since 1.0.23, the check of Pi's own reply cap never ran in an installed Pi**, for the same reason. It loaded only in tests and the eval
13
+ harness, where pi-ai resolves by name; the installed package silently fell back without it. The 4,096-token margin in the hard line held,
14
+ because it fell back to the same value.
15
+ - pi-tuck now loads Pi's reply clamp and estimator from the running Pi's own install. If it can't find them (a host embedding Pi), it
16
+ keeps the fallback as before.
17
+
18
+ ### Release gates
19
+
20
+ - **New gate:** the packed tarball was loaded in the real installed Pi before publishing. It loaded, and Pi's clamp resolved from Pi's own
21
+ install.
22
+ - Requests are unchanged from 1.0.25 wherever pi-ai resolves by name, which covers replay, stress and tests. Tests and CI pass.
23
+
6
24
  ## [1.0.25] - 2026-11-10
7
25
 
8
26
  ### Fixed
@@ -1 +1 @@
1
- export declare const BUILD_ID = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
1
+ export declare const BUILD_ID = "e95edde95c614d01e1cbf6489134676fac0c1bbf6aa57ef639ffac1e7e105041";
@@ -1 +1 @@
1
- export const BUILD_ID = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
1
+ export const BUILD_ID = "e95edde95c614d01e1cbf6489134676fac0c1bbf6aa57ef639ffac1e7e105041";
@@ -1,5 +1,5 @@
1
1
  import { textOf } from "./tuck/core/content.js";
2
- import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin } from "./request-capacity.js";
2
+ import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath } from "./request-capacity.js";
3
3
  import { BUILD_ID } from "./build-id.js";
4
4
  import { createSemanticRecall, budgetSemantic } from "./semantic-recall.js";
5
5
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry } from "./diagnostics.js";
@@ -11,7 +11,6 @@ import { projectedMessages } from "./omissions.js";
11
11
  import { applyClips, clipBudgets, clipFor } from "./clip.js";
12
12
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt } from "./budget.js";
13
13
  import { getCurrentSystemMessage, isContextOverflow, normalizeContext } from "@earendil-works/pi-ai";
14
- import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
15
14
  import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager } from "@earendil-works/pi-coding-agent";
16
15
  import { Type } from "typebox";
17
16
  import { Check } from "typebox/value";
@@ -105,9 +104,9 @@ export function seedWindow(branch, key) {
105
104
  return initialWindow(key);
106
105
  }
107
106
  export async function installCompaction(pi, options = {}) {
108
- const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
109
- const piClamp = await import('@earendil-works/pi-ai/api/simple-options')
110
- .then(module => module.clampMaxTokensToContext).catch(() => undefined);
107
+ const piMath = await loadPiRequestMath();
108
+ const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin(piMath);
109
+ const piClamp = piMath?.clampMaxTokensToContext;
111
110
  const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
112
111
  if (!validCutAt(cutAt))
113
112
  throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
@@ -367,7 +366,7 @@ export async function installCompaction(pi, options = {}) {
367
366
  const context = normalizeContext({ messages: convertToLlm(messages) });
368
367
  const cap = piClamp?.(ctx.model, context, ctx.model.maxTokens);
369
368
  return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
370
- estimate: estimateContextTokens(context).tokens };
369
+ estimate: piMath ? piMath.estimateContextTokens(context).tokens : 0 };
371
370
  };
372
371
  const projectedRequest = (messages, ctx, host, incoming, prompt, tools, cut) => {
373
372
  const enabled = cut || historyToolsEnabled(ctx), shapes = toolShapes(historyToolNames(pi, tools, enabled));
@@ -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 = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
3
+ export declare const DIAGNOSTIC_BUILD = "e95edde95c614d01e1cbf6489134676fac0c1bbf6aa57ef639ffac1e7e105041";
4
4
  export interface Receipt {
5
5
  id: string;
6
6
  build: string;
@@ -1,9 +1,21 @@
1
1
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
2
2
  import type { AgentMessage, Messages } from './budget.js';
3
+ type SimpleOptions = typeof import('@earendil-works/pi-ai/api/simple-options');
4
+ type Estimate = typeof import('@earendil-works/pi-ai/utils/estimate');
5
+ export type PiRequestMath = {
6
+ clampMaxTokensToContext: SimpleOptions['clampMaxTokensToContext'];
7
+ estimateContextTokens: Estimate['estimateContextTokens'];
8
+ };
9
+ /** Pi's own request arithmetic: its reply clamp and the estimator behind it, as the running Pi has them.
10
+ * An installed extension cannot import these subpaths by name: Pi's extension loader aliases only pi-ai's root, compat, models, oauth and
11
+ * providers/all, and an installed package has no node_modules of its own. So resolve them first by name (tests, the eval harness, a dev
12
+ * checkout), then from the running Pi's own install, beside its executable. If neither works (an SDK host), there is none, and callers
13
+ * keep their fallbacks. */
14
+ export declare function loadPiRequestMath(): Promise<PiRequestMath | undefined>;
3
15
  /** Read Pi's private safety allowance through its public clamp, rather than copying it.
4
- * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
16
+ * Without Pi's request arithmetic, fall back to Pi 1.2–1.5's documented 4,096-token allowance.
5
17
  */
6
- export declare function piContextSafetyMargin(): Promise<number>;
18
+ export declare function piContextSafetyMargin(math?: PiRequestMath): Promise<number>;
7
19
  /** The smallest reply room a request above the room must leave after Pi's own clamp: above the user's p99 reply (3,270 tokens). */
8
20
  export declare const REPLY_FLOOR_MIN = 3584;
9
21
  /** A spent thinking budget that produced neither a visible answer nor work. Empty/text/tool replies keep the existing length recovery. */
@@ -16,3 +28,4 @@ export declare function branchReplyFloor(branch: readonly SessionEntry[], reserv
16
28
  export declare function requestReplyFloor(branch: readonly SessionEntry[], reserve: number, maxTokens?: number): number;
17
29
  /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
18
30
  export declare function carriesPrompt(messages: Messages, prompt: string): boolean;
31
+ export {};
@@ -1,13 +1,51 @@
1
1
  import { isContextOverflow, normalizeContext } from '@earendil-works/pi-ai';
2
2
  import { parseRecords, splitSummary } from './tuck/core/transcript.js';
3
+ /** Pi's own request arithmetic: its reply clamp and the estimator behind it, as the running Pi has them.
4
+ * An installed extension cannot import these subpaths by name: Pi's extension loader aliases only pi-ai's root, compat, models, oauth and
5
+ * providers/all, and an installed package has no node_modules of its own. So resolve them first by name (tests, the eval harness, a dev
6
+ * checkout), then from the running Pi's own install, beside its executable. If neither works (an SDK host), there is none, and callers
7
+ * keep their fallbacks. */
8
+ export async function loadPiRequestMath() {
9
+ try {
10
+ const [simple, estimate] = await Promise.all([import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate')]);
11
+ return { clampMaxTokensToContext: simple.clampMaxTokensToContext, estimateContextTokens: estimate.estimateContextTokens };
12
+ }
13
+ catch { /* not resolvable by name: an installed extension */ }
14
+ try {
15
+ // pi-ai's exports map these subpaths for `import` only (./api/* -> ./dist/api/*.js), so find the package beside Pi's executable and
16
+ // import the files that map names.
17
+ const [{ existsSync, realpathSync }, { dirname, join }, { pathToFileURL }] = await Promise.all([import('node:fs'), import('node:path'), import('node:url')]);
18
+ const entry = process.argv[1];
19
+ if (!entry)
20
+ return undefined;
21
+ let dir = dirname(realpathSync(entry)), root;
22
+ for (let up = 0; up < 12 && !root; up++, dir = dirname(dir)) {
23
+ const candidate = join(dir, 'node_modules', '@earendil-works', 'pi-ai');
24
+ if (existsSync(join(candidate, 'dist', 'api', 'simple-options.js')))
25
+ root = candidate;
26
+ if (dirname(dir) === dir)
27
+ break;
28
+ }
29
+ if (!root)
30
+ return undefined;
31
+ const [simple, estimate] = await Promise.all([
32
+ import(pathToFileURL(join(root, 'dist', 'api', 'simple-options.js')).href),
33
+ import(pathToFileURL(join(root, 'dist', 'utils', 'estimate.js')).href)
34
+ ]);
35
+ if (typeof simple.clampMaxTokensToContext !== 'function' || typeof estimate.estimateContextTokens !== 'function')
36
+ return undefined;
37
+ return { clampMaxTokensToContext: simple.clampMaxTokensToContext, estimateContextTokens: estimate.estimateContextTokens };
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
3
43
  /** Read Pi's private safety allowance through its public clamp, rather than copying it.
4
- * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
44
+ * Without Pi's request arithmetic, fall back to Pi 1.2–1.5's documented 4,096-token allowance.
5
45
  */
6
- export async function piContextSafetyMargin() {
46
+ export async function piContextSafetyMargin(math) {
7
47
  try {
8
- const [{ clampMaxTokensToContext }, { estimateContextTokens }] = await Promise.all([
9
- import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate'),
10
- ]);
48
+ const { clampMaxTokensToContext, estimateContextTokens } = math ?? await loadPiRequestMath() ?? (() => { throw new Error('No Pi request math'); })();
11
49
  const window = 1_000_000, context = normalizeContext({ messages: [] });
12
50
  const margin = window - estimateContextTokens(context).tokens - clampMaxTokensToContext({ contextWindow: window, maxTokens: window }, context, window);
13
51
  if (!Number.isSafeInteger(margin) || margin < 0 || margin >= window)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.25",
3
+ "version": "1.0.26",
4
4
  "description": "Deterministic compaction for Pi with no model call, plus exact recall of earlier history. Search by meaning on Apple silicon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "48d3f777f203edb56b29154215540eb8245182237d18cdc50c6cbe704ea28e7e";
1
+ export const BUILD_ID = "e95edde95c614d01e1cbf6489134676fac0c1bbf6aa57ef639ffac1e7e105041";
package/src/compaction.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { textOf } from "./tuck/core/content.js";
2
- import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin } from "./request-capacity.js";
2
+ import { requestReplyFloor, carriesPrompt, needsFullReplyReserve, thinkingOnlyLength, piContextSafetyMargin, loadPiRequestMath } from "./request-capacity.js";
3
3
  import { BUILD_ID } from "./build-id.js";
4
4
  import { createSemanticRecall, budgetSemantic, type SemanticRecallController } from "./semantic-recall";
5
5
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry, type Receipt, type RecorderOptions } from "./diagnostics.js";
@@ -12,7 +12,6 @@ import { applyClips, clipBudgets, clipFor, type Clip } from "./clip.js";
12
12
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt,
13
13
  type AgentMessage, type Decision, type Messages, type Plan, type Target } from "./budget.js";
14
14
  import { getCurrentSystemMessage, isContextOverflow, normalizeContext, type ImageContent, type ToolResultMessage } from "@earendil-works/pi-ai";
15
- import { estimateContextTokens } from "@earendil-works/pi-ai/utils/estimate";
16
15
  import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry, type SessionBoundaryDraft, type TurnEndEvent } from "@earendil-works/pi-coding-agent";
17
16
  import { Type } from "typebox";
18
17
  import { Check } from "typebox/value";
@@ -135,9 +134,9 @@ export interface CompactionOptions {
135
134
  }
136
135
 
137
136
  export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
138
- const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
139
- const piClamp = await import('@earendil-works/pi-ai/api/simple-options')
140
- .then(module => module.clampMaxTokensToContext).catch(() => undefined);
137
+ const piMath = await loadPiRequestMath();
138
+ const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin(piMath);
139
+ const piClamp = piMath?.clampMaxTokensToContext;
141
140
  const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
142
141
  if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
143
142
  const semantic = options.semanticRecall ?? createSemanticRecall(), search = options.search ?? true;
@@ -350,7 +349,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
350
349
  const context = normalizeContext({ messages: convertToLlm(messages) });
351
350
  const cap = piClamp?.(ctx.model!, context, ctx.model!.maxTokens);
352
351
  return { cap, leavesFloor: cap === undefined || cap >= limits(ctx).replyFloor,
353
- estimate: estimateContextTokens(context).tokens };
352
+ estimate: piMath ? piMath.estimateContextTokens(context).tokens : 0 };
354
353
  };
355
354
  const projectedRequest = (messages: Messages, ctx: ExtensionContext, host: Messages, incoming: Messages, prompt: string, tools: readonly string[], cut: boolean) => {
356
355
  const enabled = cut || historyToolsEnabled(ctx), shapes = toolShapes(historyToolNames(pi, tools, enabled));
@@ -3,14 +3,47 @@ import type { SessionEntry } from '@earendil-works/pi-coding-agent';
3
3
  import type { AgentMessage, Messages } from './budget.js';
4
4
  import { parseRecords, splitSummary } from './tuck/core/transcript.js';
5
5
 
6
+ type SimpleOptions = typeof import('@earendil-works/pi-ai/api/simple-options');
7
+ type Estimate = typeof import('@earendil-works/pi-ai/utils/estimate');
8
+ export type PiRequestMath = { clampMaxTokensToContext: SimpleOptions['clampMaxTokensToContext']; estimateContextTokens: Estimate['estimateContextTokens'] };
9
+
10
+ /** Pi's own request arithmetic: its reply clamp and the estimator behind it, as the running Pi has them.
11
+ * An installed extension cannot import these subpaths by name: Pi's extension loader aliases only pi-ai's root, compat, models, oauth and
12
+ * providers/all, and an installed package has no node_modules of its own. So resolve them first by name (tests, the eval harness, a dev
13
+ * checkout), then from the running Pi's own install, beside its executable. If neither works (an SDK host), there is none, and callers
14
+ * keep their fallbacks. */
15
+ export async function loadPiRequestMath(): Promise<PiRequestMath | undefined> {
16
+ try {
17
+ const [simple, estimate] = await Promise.all([import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate')]);
18
+ return { clampMaxTokensToContext: simple.clampMaxTokensToContext, estimateContextTokens: estimate.estimateContextTokens };
19
+ } catch { /* not resolvable by name: an installed extension */ }
20
+ try {
21
+ // pi-ai's exports map these subpaths for `import` only (./api/* -> ./dist/api/*.js), so find the package beside Pi's executable and
22
+ // import the files that map names.
23
+ const [{ existsSync, realpathSync }, { dirname, join }, { pathToFileURL }] = await Promise.all([import('node:fs'), import('node:path'), import('node:url')]);
24
+ const entry = process.argv[1];
25
+ if (!entry) return undefined;
26
+ let dir = dirname(realpathSync(entry)), root: string | undefined;
27
+ for (let up = 0; up < 12 && !root; up++, dir = dirname(dir)) {
28
+ const candidate = join(dir, 'node_modules', '@earendil-works', 'pi-ai');
29
+ if (existsSync(join(candidate, 'dist', 'api', 'simple-options.js'))) root = candidate;
30
+ if (dirname(dir) === dir) break;
31
+ }
32
+ if (!root) return undefined;
33
+ const [simple, estimate] = await Promise.all([
34
+ import(pathToFileURL(join(root, 'dist', 'api', 'simple-options.js')).href) as Promise<SimpleOptions>,
35
+ import(pathToFileURL(join(root, 'dist', 'utils', 'estimate.js')).href) as Promise<Estimate>]);
36
+ if (typeof simple.clampMaxTokensToContext !== 'function' || typeof estimate.estimateContextTokens !== 'function') return undefined;
37
+ return { clampMaxTokensToContext: simple.clampMaxTokensToContext, estimateContextTokens: estimate.estimateContextTokens };
38
+ } catch { return undefined; }
39
+ }
40
+
6
41
  /** Read Pi's private safety allowance through its public clamp, rather than copying it.
7
- * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
42
+ * Without Pi's request arithmetic, fall back to Pi 1.2–1.5's documented 4,096-token allowance.
8
43
  */
9
- export async function piContextSafetyMargin(): Promise<number> {
44
+ export async function piContextSafetyMargin(math?: PiRequestMath): Promise<number> {
10
45
  try {
11
- const [{ clampMaxTokensToContext }, { estimateContextTokens }] = await Promise.all([
12
- import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate'),
13
- ]);
46
+ const { clampMaxTokensToContext, estimateContextTokens } = math ?? await loadPiRequestMath() ?? (() => { throw new Error('No Pi request math'); })();
14
47
  const window = 1_000_000, context = normalizeContext({ messages: [] });
15
48
  const margin = window - estimateContextTokens(context).tokens - clampMaxTokensToContext({ contextWindow: window, maxTokens: window } as never, context, window);
16
49
  if (!Number.isSafeInteger(margin) || margin < 0 || margin >= window) throw new Error('Invalid Pi context margin');