@oh-my-pi/pi-agent-core 18.15.0 → 18.16.0

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
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.16.0] - 2026-11-02
6
+
7
+ ### Changed
8
+
9
+ - Deprecated `createFileOps`, `computeFileLists`, `formatFileOperations`, `upsertFileOperations`, `isUrlSchemePath` and `FileOperations` in `@oh-my-pi/pi-agent-core/compaction`; they now re-export `@oh-my-pi/snapcompact`, which callers should import directly
10
+
11
+ ### Fixed
12
+
13
+ - Context estimates for a model without a known tokenizer family (Gemini, Grok and the like), and token counts made without a model (compress metrics, the legacy `estimateTokens` export), count text in o200k_base instead of guessing 4 bytes per token, which undercounted what those providers bill by 11–48% on code-heavy sessions and let compaction fire late after a switch to them ([#17607](https://github.com/can1357/oh-my-pi/pull/17607) by [@will-bogusz](https://github.com/will-bogusz))
14
+ - Context estimates no longer charge a reader another model's thinking signatures or redacted thinking, which the request strips: a Claude session switched to Grok or Gemini, including Gemini through Factory, counted them as text ([#17607](https://github.com/can1357/oh-my-pi/pull/17607) by [@will-bogusz](https://github.com/will-bogusz))
15
+
16
+ ### Removed
17
+
18
+ - Removed `PI_TOKENIZER_ACCURATE`: every model is now counted with a native tokenizer ([#17607](https://github.com/can1357/oh-my-pi/pull/17607) by [@will-bogusz](https://github.com/will-bogusz))
19
+
5
20
  ## [18.15.0] - 2026-11-01
6
21
 
7
22
  ### Fixed
@@ -5,12 +5,12 @@
5
5
  * a summary of the branch being left so context isn't lost.
6
6
  */
7
7
  import type { Api, ApiKey, AssistantMessage, Context, Model, SimpleStreamOptions } from "@oh-my-pi/pi-ai";
8
+ import * as snapcompact from "@oh-my-pi/snapcompact";
8
9
  import { type AgentTelemetry } from "../telemetry.js";
9
10
  import { Tokenizer } from "../tokenizer.js";
10
11
  import type { AgentMessage } from "../types.js";
11
12
  import type { ReadonlySessionManager, SessionEntry } from "./entries.js";
12
13
  import { type ConvertToLlm } from "./messages.js";
13
- import { type FileOperations } from "./utils.js";
14
14
  export interface BranchSummaryResult {
15
15
  summary?: string;
16
16
  readFiles?: string[];
@@ -23,12 +23,11 @@ export interface BranchSummaryDetails {
23
23
  readFiles: string[];
24
24
  modifiedFiles: string[];
25
25
  }
26
- export type { FileOperations } from "./utils.js";
27
26
  export interface BranchPreparation {
28
27
  /** Messages extracted for summarization, in chronological order */
29
28
  messages: AgentMessage[];
30
29
  /** File operations extracted from tool calls */
31
- fileOps: FileOperations;
30
+ fileOps: snapcompact.FileOperations;
32
31
  /** Total estimated tokens in messages */
33
32
  totalTokens: number;
34
33
  }
@@ -5,13 +5,13 @@
5
5
  * and after compaction the session is reloaded.
6
6
  */
7
7
  import { type Api, type ApiKey, type AssistantMessage, type CodexCompactionContext, type Context, type FetchImpl, type MessageAttribution, type Model, type OneshotRetryOptions, type ProviderSessionState, type SimpleStreamOptions, type Tool, type Usage } from "@oh-my-pi/pi-ai";
8
+ import * as snapcompact from "@oh-my-pi/snapcompact";
8
9
  import { type AgentTelemetry } from "../telemetry.js";
9
10
  import { ThinkingLevel } from "../thinking.js";
10
11
  import { Tokenizer } from "../tokenizer.js";
11
12
  import type { AgentMessage } from "../types.js";
12
13
  import type { SessionEntry } from "./entries.js";
13
14
  import { type ConvertToLlm } from "./messages.js";
14
- import { type FileOperations } from "./utils.js";
15
15
  /** Details stored in CompactionEntry.details for file tracking */
16
16
  export interface CompactionDetails {
17
17
  readFiles: string[];
@@ -368,7 +368,7 @@ export interface CompactionPreparation {
368
368
  /** Preserved opaque compaction payload from the previous compaction, if any. */
369
369
  previousPreserveData?: Record<string, unknown>;
370
370
  /** File operations extracted from messagesToSummarize */
371
- fileOps: FileOperations;
371
+ fileOps: snapcompact.FileOperations;
372
372
  /** Compaction settions from settings.jsonl */
373
373
  settings: CompactionSettings;
374
374
  }
@@ -3,13 +3,12 @@
3
3
  */
4
4
  import type { Message } from "@oh-my-pi/pi-ai";
5
5
  import { type Dialect } from "@oh-my-pi/pi-ai/dialect";
6
+ import * as snapcompact from "@oh-my-pi/snapcompact";
6
7
  import type { AgentMessage } from "../types.js";
7
- export interface FileOperations {
8
- read: Set<string>;
9
- written: Set<string>;
10
- edited: Set<string>;
11
- }
12
- export declare function createFileOps(): FileOperations;
8
+ /** @deprecated Import these from `@oh-my-pi/snapcompact`. */
9
+ export { computeFileLists, createFileOps, type FileOperations, isUrlSchemePath, upsertFileOperations, } from "@oh-my-pi/snapcompact";
10
+ /** @deprecated Use `upsertFileOperations("", readFiles, modifiedFiles, readSet)` from `@oh-my-pi/snapcompact`. */
11
+ export declare function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
13
12
  /** Error text for a summarization call that hit its output limit (`stopReason: "length"`): partial text is never used. */
14
13
  export declare function incompleteOutputError(prefix: string): string;
15
14
  /**
@@ -33,25 +32,10 @@ export declare function stripReadSelector(path: string): string;
33
32
  * than its plain lines: verbatim `raw` bytes or the merge `conflicts` view.
34
33
  */
35
34
  export declare function isAlternateFormReadSelector(sel: string): boolean;
36
- /**
37
- * Whether `path` references a `scheme://` URL (internal URI or web URL) rather
38
- * than a filesystem path that belongs in the compaction `<files>` summary.
39
- */
40
- export declare function isUrlSchemePath(path: string): boolean;
41
35
  /**
42
36
  * Extract file operations from tool calls in an assistant message.
43
37
  */
44
- export declare function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void;
45
- /**
46
- * Compute final file lists from file operations.
47
- * Returns readFiles (files only read, not modified) and modifiedFiles.
48
- */
49
- export declare function computeFileLists(fileOps: FileOperations): {
50
- readFiles: string[];
51
- modifiedFiles: string[];
52
- };
53
- export declare function formatFileOperations(readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
54
- export declare function upsertFileOperations(summary: string, readFiles: string[], modifiedFiles: string[], readSet?: ReadonlySet<string>): string;
38
+ export declare function extractFileOpsFromMessage(message: AgentMessage, fileOps: snapcompact.FileOperations): void;
55
39
  /**
56
40
  * Truncate tool results to the same representation used in summarization prompts.
57
41
  */
@@ -10,6 +10,7 @@ type TokenizerModel = Pick<Model, "tokenizer"> & snapcompact.ShapeTarget & {
10
10
  compat?: Model["compat"];
11
11
  thinking?: Model["thinking"];
12
12
  reasoning?: boolean;
13
+ requestModelId?: string;
13
14
  };
14
15
  /** Identity of the reader whose replayed reasoning a {@link Tokenizer} counts; equal keys count it alike. */
15
16
  export declare function reasoningReplayKey(model: TokenizerModel | null | undefined): string;
@@ -24,13 +25,12 @@ export interface TokenizerOptions {
24
25
  nativeHistoryReplay?: boolean;
25
26
  }
26
27
  /**
27
- * `strict` always tries an exact native count (the catalog-resolved tokenizer
28
- * when known, o200k_base otherwise), falling back to the byte upper bound when
29
- * the loaded addon does not recognize that encoding. `approximate` and
30
- * `upperbound` prefer the same exact count for known tokenizer families or
31
- * when `PI_TOKENIZER_ACCURATE=1` is set; otherwise they use a cheap heuristic:
32
- * `approximate` a bytes/4 guess, `upperbound` the raw byte length (never
33
- * undercounts).
28
+ * Every mode counts with the native tokenizer: the catalog-resolved family
29
+ * when known, o200k_base otherwise. They differ only in the fallback when the
30
+ * loaded addon does not recognize that encoding: `strict` and `upperbound`
31
+ * the raw byte length (never undercounts), `approximate` a bytes/4 guess.
32
+ * Tests (`NODE_ENV=test`) skip the native count outside `strict` and use that
33
+ * fallback.
34
34
  */
35
35
  export type TokenCountMode = "strict" | "approximate" | "upperbound";
36
36
  /** Options for {@link Tokenizer.countMessage} / {@link Tokenizer.countMessages}. */
@@ -69,8 +69,8 @@ export interface TokenBudgetCheck {
69
69
  * when the model's encoding, image or thinking-replay pricing, replay
70
70
  * identity or native-history replay state changes); one-shot flows construct
71
71
  * their own for the model that will be billed. Known tokenizer families use
72
- * exact native counts; unknown models keep the fast byte estimate (or o200k
73
- * when `PI_TOKENIZER_ACCURATE=1`).
72
+ * exact native counts; other models are counted in o200k_base, which bills
73
+ * unknown readers closer than a bytes/4 guess.
74
74
  */
75
75
  export declare class Tokenizer {
76
76
  #private;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "18.15.0",
4
+ "version": "18.16.0",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -38,16 +38,16 @@
38
38
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
39
39
  },
40
40
  "dependencies": {
41
- "@oh-my-pi/pi-ai": "18.15.0",
42
- "@oh-my-pi/pi-catalog": "18.15.0",
43
- "@oh-my-pi/pi-natives": "18.15.0",
44
- "@oh-my-pi/pi-utils": "18.15.0",
45
- "@oh-my-pi/pi-wire": "18.15.0",
46
- "@oh-my-pi/snapcompact": "18.15.0",
41
+ "@oh-my-pi/pi-ai": "18.16.0",
42
+ "@oh-my-pi/pi-catalog": "18.16.0",
43
+ "@oh-my-pi/pi-natives": "18.16.0",
44
+ "@oh-my-pi/pi-utils": "18.16.0",
45
+ "@oh-my-pi/pi-wire": "18.16.0",
46
+ "@oh-my-pi/snapcompact": "18.16.0",
47
47
  "@opentelemetry/api": "^1.9.1"
48
48
  },
49
49
  "devDependencies": {
50
- "@oh-my-pi/omptype": "18.15.0",
50
+ "@oh-my-pi/omptype": "18.16.0",
51
51
  "@opentelemetry/context-async-hooks": "^2.9.0",
52
52
  "@opentelemetry/sdk-trace-base": "^2.9.0",
53
53
  "@types/bun": "^1.3.14"
package/src/agent-loop.ts CHANGED
@@ -53,7 +53,7 @@ import {
53
53
  signalListLabel,
54
54
  } from "@oh-my-pi/pi-ai/utils/harmony-leak";
55
55
  import { isDsmlLeakRecoveryTarget, removeDsmlToolMarkupLeak } from "@oh-my-pi/pi-ai/utils/dsml-leak";
56
- import { cloneJsonTree, logger, sanitizeText, structuredCloneJSON } from "@oh-my-pi/pi-utils";
56
+ import { cloneJsonTree, logger, sanitizeText, structuredCloneJSON, toError } from "@oh-my-pi/pi-utils";
57
57
  import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
58
58
  import { LiveSteeringChannel } from "./live-steering";
59
59
  import { agentPauseGate } from "./pause";
@@ -1188,7 +1188,7 @@ function resolveAsides(entries: AsideMessage[] | undefined): AgentMessage[] {
1188
1188
  if (message) out.push(message);
1189
1189
  }
1190
1190
  } catch (error) {
1191
- discardAsides(out, error instanceof Error ? error : new Error(String(error)));
1191
+ discardAsides(out, toError(error));
1192
1192
  throw error;
1193
1193
  }
1194
1194
  return out;
@@ -24,16 +24,12 @@ import {
24
24
  import branchSummaryPrompt from "./prompts/branch-summary.md" with { type: "text" };
25
25
  import branchSummaryPreamble from "./prompts/branch-summary-preamble.md" with { type: "text" };
26
26
  import {
27
- computeFileLists,
28
- createFileOps,
29
27
  extractFileOpsFromMessage,
30
- type FileOperations,
31
28
  incompleteOutputError,
32
29
  SUMMARIZATION_SYSTEM_PROMPT,
33
30
  serializeConversationForSummary,
34
31
  stripReadSelector,
35
32
  truncateToolResultForSummary,
36
- upsertFileOperations,
37
33
  } from "./utils";
38
34
 
39
35
  // ============================================================================
@@ -54,13 +50,11 @@ export interface BranchSummaryDetails {
54
50
  modifiedFiles: string[];
55
51
  }
56
52
 
57
- export type { FileOperations } from "./utils";
58
-
59
53
  export interface BranchPreparation {
60
54
  /** Messages extracted for summarization, in chronological order */
61
55
  messages: AgentMessage[];
62
56
  /** File operations extracted from tool calls */
63
- fileOps: FileOperations;
57
+ fileOps: snapcompact.FileOperations;
64
58
  /** Total estimated tokens in messages */
65
59
  totalTokens: number;
66
60
  }
@@ -252,7 +246,7 @@ export function prepareBranchEntries(
252
246
  tokenBudget: number = 0,
253
247
  ): BranchPreparation {
254
248
  const messages: AgentMessage[] = [];
255
- const fileOps = createFileOps();
249
+ const fileOps = snapcompact.createFileOps();
256
250
  let totalTokens = 0;
257
251
 
258
252
  // First pass: collect file ops from ALL entries (even if they don't fit in token budget)
@@ -400,8 +394,8 @@ export async function generateBranchSummary(
400
394
  summary = BRANCH_SUMMARY_PREAMBLE + summary;
401
395
 
402
396
  // Compute file lists and append to summary
403
- const { readFiles, modifiedFiles } = computeFileLists(fileOps);
404
- summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
397
+ const { readFiles, modifiedFiles } = snapcompact.computeFileLists(fileOps);
398
+ summary = snapcompact.upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
405
399
 
406
400
  return {
407
401
  summary: summary || "No summary generated",
@@ -32,7 +32,7 @@ import {
32
32
  OPENAI_HEADER_VALUES,
33
33
  OPENAI_HEADERS,
34
34
  } from "@oh-my-pi/pi-catalog/wire/codex";
35
- import { $env, isUnexpectedSocketCloseMessage, logger, ptree, stringifyJson } from "@oh-my-pi/pi-utils";
35
+ import { $env, isUnexpectedSocketCloseMessage, logger, ptree, stringifyJson, toError } from "@oh-my-pi/pi-utils";
36
36
  import { appendAzureApiVersion, resolveAzureOpenAiBaseUrl } from "./azure-openai-endpoint";
37
37
  import { prepareBedrockCompactionRequest } from "./bedrock";
38
38
 
@@ -320,7 +320,7 @@ export async function requestCompactionV2Streaming(
320
320
  codexAccessPrograms: options?.codexAccessPrograms,
321
321
  });
322
322
  } catch (err) {
323
- const error = err instanceof Error ? err : new Error(String(err));
323
+ const error = toError(err);
324
324
  if (signal?.aborted) throw error;
325
325
 
326
326
  if (isRetryableCompactionError(error) && attempt < V2_COMPACTION_MAX_RETRIES) {
@@ -86,16 +86,12 @@ import handoffDocumentPrompt from "./prompts/handoff-document.md" with { type: "
86
86
  import snapcompactArchiveContextPrompt from "./prompts/snapcompact-archive-context.md" with { type: "text" };
87
87
 
88
88
  import {
89
- computeFileLists,
90
- createFileOps,
91
89
  escapeSummaryBoundaryTags,
92
90
  extractFileOpsFromMessage,
93
- type FileOperations,
94
91
  incompleteOutputError,
95
92
  SUMMARIZATION_SYSTEM_PROMPT,
96
93
  serializeConversationForSummary,
97
94
  stripReadSelector,
98
- upsertFileOperations,
99
95
  } from "./utils";
100
96
 
101
97
  // ============================================================================
@@ -115,8 +111,8 @@ function extractFileOperations(
115
111
  messages: AgentMessage[],
116
112
  entries: SessionEntry[],
117
113
  prevCompactionIndex: number,
118
- ): FileOperations {
119
- const fileOps = createFileOps();
114
+ ): snapcompact.FileOperations {
115
+ const fileOps = snapcompact.createFileOps();
120
116
 
121
117
  // Collect from previous compaction's details (if pi-generated)
122
118
  if (prevCompactionIndex >= 0) {
@@ -1384,7 +1380,7 @@ export interface CompactionPreparation {
1384
1380
  /** Preserved opaque compaction payload from the previous compaction, if any. */
1385
1381
  previousPreserveData?: Record<string, unknown>;
1386
1382
  /** File operations extracted from messagesToSummarize */
1387
- fileOps: FileOperations;
1383
+ fileOps: snapcompact.FileOperations;
1388
1384
  /** Compaction settions from settings.jsonl */
1389
1385
  settings: CompactionSettings;
1390
1386
  }
@@ -2343,8 +2339,8 @@ export async function compact(
2343
2339
  });
2344
2340
 
2345
2341
  // Compute file lists and append to summary
2346
- const { readFiles, modifiedFiles } = computeFileLists(fileOps);
2347
- summary = upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
2342
+ const { readFiles, modifiedFiles } = snapcompact.computeFileLists(fileOps);
2343
+ summary = snapcompact.upsertFileOperations(summary, readFiles, modifiedFiles, fileOps.read);
2348
2344
  if (nativeSummary !== undefined) {
2349
2345
  // The replayed block (summary and any `tool_changes`) stays
2350
2346
  // byte-identical to the API's output so it matches its signature. The
@@ -2352,7 +2348,7 @@ export async function compact(
2352
2348
  // summary message with the block and skips its text, so they would
2353
2349
  // otherwise be invisible to this provider. Every other provider keeps
2354
2350
  // reading the entry text.
2355
- const filesText = upsertFileOperations("", readFiles, modifiedFiles, fileOps.read) || undefined;
2351
+ const filesText = snapcompact.upsertFileOperations("", readFiles, modifiedFiles, fileOps.read) || undefined;
2356
2352
  preserveData = withAnthropicCompactionPreserveData(preserveData, {
2357
2353
  provider: model.provider,
2358
2354
  content: nativeSummary,
@@ -5,27 +5,27 @@
5
5
  import type { Message, ToolCall } from "@oh-my-pi/pi-ai";
6
6
  import { type Dialect, getDialectDefinition } from "@oh-my-pi/pi-ai/dialect";
7
7
  import { escapeHarmonyControlTokens } from "@oh-my-pi/pi-ai/utils/harmony-leak";
8
- import { formatGroupedPaths, prompt, stringifyJson } from "@oh-my-pi/pi-utils";
8
+ import { prompt, stringifyJson } from "@oh-my-pi/pi-utils";
9
+ import * as snapcompact from "@oh-my-pi/snapcompact";
9
10
  import type { AgentMessage } from "../types";
10
- import fileOperationsTemplate from "./prompts/file-operations.md" with { type: "text" };
11
11
  import summarizationSystemPrompt from "./prompts/summarization-system.md" with { type: "text" };
12
12
 
13
- // ============================================================================
14
- // File Operation Tracking
15
- // ============================================================================
13
+ /** @deprecated Import these from `@oh-my-pi/snapcompact`. */
14
+ export {
15
+ computeFileLists,
16
+ createFileOps,
17
+ type FileOperations,
18
+ isUrlSchemePath,
19
+ upsertFileOperations,
20
+ } from "@oh-my-pi/snapcompact";
16
21
 
17
- export interface FileOperations {
18
- read: Set<string>;
19
- written: Set<string>;
20
- edited: Set<string>;
21
- }
22
-
23
- export function createFileOps(): FileOperations {
24
- return {
25
- read: new Set(),
26
- written: new Set(),
27
- edited: new Set(),
28
- };
22
+ /** @deprecated Use `upsertFileOperations("", readFiles, modifiedFiles, readSet)` from `@oh-my-pi/snapcompact`. */
23
+ export function formatFileOperations(
24
+ readFiles: string[],
25
+ modifiedFiles: string[],
26
+ readSet?: ReadonlySet<string>,
27
+ ): string {
28
+ return snapcompact.upsertFileOperations("", readFiles, modifiedFiles, readSet);
29
29
  }
30
30
 
31
31
  /** Error text for a summarization call that hit its output limit (`stopReason: "length"`): partial text is never used. */
@@ -91,27 +91,10 @@ export function isAlternateFormReadSelector(sel: string): boolean {
91
91
  return sel.split(":").some(part => READ_ALTERNATE_FORM_RE.test(part));
92
92
  }
93
93
 
94
- /**
95
- * A real filesystem path never contains a `scheme://` URL. Tool-call paths that
96
- * do — `conflict://1`, `artifact://3`, `local://ctx.md`, `history://…`,
97
- * `issue://12`, `https://…`, and the tolerated `file.ts:conflict://1` prefix
98
- * form — are session-scoped or remote resources, not files the post-compaction
99
- * agent can re-ground on. Keep them out of the `<files>` summary.
100
- */
101
- const URL_SCHEME_RE = /[a-z][a-z0-9+.-]*:\/\//i;
102
-
103
- /**
104
- * Whether `path` references a `scheme://` URL (internal URI or web URL) rather
105
- * than a filesystem path that belongs in the compaction `<files>` summary.
106
- */
107
- export function isUrlSchemePath(path: string): boolean {
108
- return URL_SCHEME_RE.test(path);
109
- }
110
-
111
94
  /**
112
95
  * Extract file operations from tool calls in an assistant message.
113
96
  */
114
- export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOperations): void {
97
+ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: snapcompact.FileOperations): void {
115
98
  if (message.role !== "assistant") return;
116
99
  if (!("content" in message) || !Array.isArray(message.content)) return;
117
100
 
@@ -128,7 +111,7 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
128
111
 
129
112
  // Internal URIs (conflict://, artifact://, local://, history://, …) and
130
113
  // web URLs are not re-groundable files — keep them out of `<files>`.
131
- if (isUrlSchemePath(path)) continue;
114
+ if (snapcompact.isUrlSchemePath(path)) continue;
132
115
 
133
116
  switch (block.name) {
134
117
  case "read":
@@ -144,68 +127,6 @@ export function extractFileOpsFromMessage(message: AgentMessage, fileOps: FileOp
144
127
  }
145
128
  }
146
129
 
147
- /**
148
- * Compute final file lists from file operations.
149
- * Returns readFiles (files only read, not modified) and modifiedFiles.
150
- */
151
- export function computeFileLists(fileOps: FileOperations): { readFiles: string[]; modifiedFiles: string[] } {
152
- // Drop any `scheme://` URLs (e.g. legacy `conflict://`/`artifact://` entries
153
- // rehydrated straight into `fileOps` from a pre-fix compaction summary) — only
154
- // real files belong in `<files>`. New tool-call scans are already filtered.
155
- const modified = new Set([...fileOps.edited, ...fileOps.written].filter(f => !isUrlSchemePath(f)));
156
- const readOnly = [...fileOps.read].filter(f => !isUrlSchemePath(f) && !modified.has(f)).sort();
157
- const modifiedFiles = [...modified].sort();
158
- return { readFiles: readOnly, modifiedFiles };
159
- }
160
-
161
- /**
162
- * Format file operations as one `<files>` tag: a grouped, prefix-folded
163
- * directory tree (find-tool shape — `# dir/` headers, bare basenames) with a
164
- * ` (Read)` / ` (Write)` / ` (RW)` marker per file instead of separate
165
- * read/modified lists. `readSet` is the cumulative read set (`fileOps.read`),
166
- * used to tell modified files that were also read (RW) from blind writes.
167
- */
168
- const FILE_OPERATION_SUMMARY_LIMIT = 20;
169
-
170
- function stripFileOperationTags(summary: string): string {
171
- // Legacy <read-files>/<modified-files> tags are still stripped so summaries
172
- // written before the combined <files> tag self-heal on the next compaction.
173
- return summary
174
- .replace(/<files>[\s\S]*?<\/files>\s*/g, "")
175
- .replace(/<read-files>[\s\S]*?<\/read-files>\s*/g, "")
176
- .replace(/<modified-files>[\s\S]*?<\/modified-files>\s*/g, "")
177
- .trimEnd();
178
- }
179
- export function formatFileOperations(
180
- readFiles: string[],
181
- modifiedFiles: string[],
182
- readSet?: ReadonlySet<string>,
183
- ): string {
184
- if (readFiles.length === 0 && modifiedFiles.length === 0) return "";
185
- const mode = new Map<string, "Read" | "Write" | "RW">();
186
- for (const file of readFiles) mode.set(file, "Read");
187
- for (const file of modifiedFiles) mode.set(file, readSet?.has(file) ? "RW" : "Write");
188
- const all = [...mode.keys()].sort();
189
- let files = formatGroupedPaths(all.slice(0, FILE_OPERATION_SUMMARY_LIMIT), path => ` (${mode.get(path)})`);
190
- if (all.length > FILE_OPERATION_SUMMARY_LIMIT) {
191
- files += `\n[…${all.length - FILE_OPERATION_SUMMARY_LIMIT} files elided…]`;
192
- }
193
- return prompt.render(fileOperationsTemplate, { files });
194
- }
195
-
196
- export function upsertFileOperations(
197
- summary: string,
198
- readFiles: string[],
199
- modifiedFiles: string[],
200
- readSet?: ReadonlySet<string>,
201
- ): string {
202
- const baseSummary = stripFileOperationTags(summary);
203
- const fileOperations = formatFileOperations(readFiles, modifiedFiles, readSet);
204
- if (!fileOperations) return baseSummary;
205
- if (!baseSummary) return fileOperations;
206
- return `${baseSummary}\n\n${fileOperations}`;
207
- }
208
-
209
130
  // ============================================================================
210
131
  // Message Serialization
211
132
  // ============================================================================
package/src/tokenizer.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  import type { AssistantMessage, Model } from "@oh-my-pi/pi-ai";
2
2
  import { renderDemotedThinking } from "@oh-my-pi/pi-ai/dialect/demotion";
3
+ import { isFactoryDroidGeminiWire, keepsFactoryGeminiReasoning } from "@oh-my-pi/pi-ai/providers/factory-droid/gemini";
3
4
  import { ollamaChatDropsOwnThinking } from "@oh-my-pi/pi-ai/providers/ollama";
4
5
  import { completionsDropsOwnThinking, isLiteLLMThinkingSignature } from "@oh-my-pi/pi-ai/providers/openai-completions";
5
6
  import { isColdReplayableResponsesTurn } from "@oh-my-pi/pi-ai/providers/openai-shared";
6
7
  import {
8
+ dropsForeignReasoningPayload,
7
9
  isSameModelTurn,
8
10
  shouldDropTruncatedThinkingOnlyAssistant,
9
11
  } from "@oh-my-pi/pi-ai/providers/transform-messages";
@@ -22,7 +24,6 @@ import { base64ImageSize, estimateImageTokens, type ImageDetail, type ImageSize
22
24
  import type { AgentMessage } from "./types";
23
25
 
24
26
  const testEnv = Bun.env.NODE_ENV === "test";
25
- const accurate = process.env.PI_TOKENIZER_ACCURATE === "1" && !testEnv;
26
27
 
27
28
  const NATIVE_ENCODING: Record<ModelTokenizer, natives.Encoding> = {
28
29
  "claude-v3": natives.Encoding.ClaudeV3,
@@ -33,6 +34,7 @@ const NATIVE_ENCODING: Record<ModelTokenizer, natives.Encoding> = {
33
34
  "deepseek-v3": natives.Encoding.DeepSeekV3,
34
35
  "kimi-k2": natives.Encoding.KimiK2,
35
36
  glm5: natives.Encoding.Glm5,
37
+ cl100k: natives.Encoding.Cl100kBase,
36
38
  o200k: natives.Encoding.O200kBase,
37
39
  };
38
40
 
@@ -42,7 +44,12 @@ export function tokenizerEncodingForModel(model: Pick<Model, "tokenizer"> | null
42
44
  }
43
45
 
44
46
  type TokenizerModel = Pick<Model, "tokenizer"> &
45
- snapcompact.ShapeTarget & { compat?: Model["compat"]; thinking?: Model["thinking"]; reasoning?: boolean };
47
+ snapcompact.ShapeTarget & {
48
+ compat?: Model["compat"];
49
+ thinking?: Model["thinking"];
50
+ reasoning?: boolean;
51
+ requestModelId?: string;
52
+ };
46
53
 
47
54
  /** The model an assistant turn's native reasoning (signed thinking, Responses history) is replayed to. */
48
55
  interface ReasoningReplayReader {
@@ -77,13 +84,12 @@ export interface TokenizerOptions {
77
84
  }
78
85
 
79
86
  /**
80
- * `strict` always tries an exact native count (the catalog-resolved tokenizer
81
- * when known, o200k_base otherwise), falling back to the byte upper bound when
82
- * the loaded addon does not recognize that encoding. `approximate` and
83
- * `upperbound` prefer the same exact count for known tokenizer families or
84
- * when `PI_TOKENIZER_ACCURATE=1` is set; otherwise they use a cheap heuristic:
85
- * `approximate` a bytes/4 guess, `upperbound` the raw byte length (never
86
- * undercounts).
87
+ * Every mode counts with the native tokenizer: the catalog-resolved family
88
+ * when known, o200k_base otherwise. They differ only in the fallback when the
89
+ * loaded addon does not recognize that encoding: `strict` and `upperbound`
90
+ * the raw byte length (never undercounts), `approximate` a bytes/4 guess.
91
+ * Tests (`NODE_ENV=test`) skip the native count outside `strict` and use that
92
+ * fallback.
87
93
  */
88
94
  export type TokenCountMode = "strict" | "approximate" | "upperbound";
89
95
 
@@ -205,8 +211,8 @@ interface MessageEstimate {
205
211
  * when the model's encoding, image or thinking-replay pricing, replay
206
212
  * identity or native-history replay state changes); one-shot flows construct
207
213
  * their own for the model that will be billed. Known tokenizer families use
208
- * exact native counts; unknown models keep the fast byte estimate (or o200k
209
- * when `PI_TOKENIZER_ACCURATE=1`).
214
+ * exact native counts; other models are counted in o200k_base, which bills
215
+ * unknown readers closer than a bytes/4 guess.
210
216
  */
211
217
  export class Tokenizer {
212
218
  readonly #encoding: natives.Encoding | null;
@@ -217,6 +223,8 @@ export class Tokenizer {
217
223
  readonly #dropsCompletionsThinking: boolean;
218
224
  /** Claude's own API and prefix-bound hosts keep another model's signature from their deployment. */
219
225
  readonly #readsDeploymentSignatures: boolean;
226
+ /** Factory's Gemini generate wire, whose replay pass keeps only Google-origin reasoning. */
227
+ readonly #factoryGeminiWire: boolean;
220
228
  readonly reasoningReplayKey: string;
221
229
  /** How the reader bills replayed signed thinking (catalog `thinking-replay-tokens`). */
222
230
  readonly thinkingReplayTokens: ThinkingReplayTokens;
@@ -259,6 +267,10 @@ export class Tokenizer {
259
267
  model?.api === "anthropic-messages" &&
260
268
  ((compat !== undefined && "officialEndpoint" in compat && compat.officialEndpoint) ||
261
269
  model.thinking?.prefixBinding === true);
270
+ this.#factoryGeminiWire =
271
+ model?.api === "factory-droid-agent" &&
272
+ model.id !== undefined &&
273
+ isFactoryDroidGeminiWire({ id: model.id, requestModelId: model.requestModelId });
262
274
  this.reasoningReplayKey = reasoningReplayKey(model);
263
275
  this.thinkingReplayTokens = resolveThinkingReplayTokens(model ?? {});
264
276
  this.nativeHistoryReplay = options?.nativeHistoryReplay ?? true;
@@ -269,11 +281,8 @@ export class Tokenizer {
269
281
  }
270
282
 
271
283
  countTokens(text: string | string[], mode: TokenCountMode = "approximate"): number {
272
- if (mode === "strict") return countTokensNat(text, this.#encoding, mode, this.#nativeCounts).tokens;
273
- if (!testEnv && this.#encoding !== null)
274
- return countTokensNat(text, this.#encoding, mode, this.#nativeCounts).tokens;
275
- if (accurate) return countTokensNat(text, undefined, mode, this.#nativeCounts).tokens;
276
- return sumFragments(text, mode === "upperbound" ? byteLength : byteEstimate);
284
+ if (testEnv && mode !== "strict") return sumFragments(text, mode === "upperbound" ? byteLength : byteEstimate);
285
+ return countTokensNat(text, this.#encoding, mode, this.#nativeCounts).tokens;
277
286
  }
278
287
 
279
288
  /**
@@ -365,6 +374,21 @@ export class Tokenizer {
365
374
  );
366
375
  }
367
376
 
377
+ /**
378
+ * Whether `message`'s opaque reasoning payload (thinking signatures, redacted
379
+ * thinking) may reach this reader: everything except what `transformMessages`
380
+ * certainly strips from another model's turn, or what Factory's Gemini wire
381
+ * drops from a turn not of Google origin. Without a replay reader (no
382
+ * model, or a `filterReasoningHistory` target) every payload is charged,
383
+ * which overcounts a filtering reader's foreign turns.
384
+ */
385
+ #receivesReasoningPayload(message: AssistantMessage): boolean {
386
+ const reader = this.#reasoningReader;
387
+ if (reader === undefined) return true;
388
+ if (this.#factoryGeminiWire) return keepsFactoryGeminiReasoning(message);
389
+ return !dropsForeignReasoningPayload(message, reader);
390
+ }
391
+
368
392
  /**
369
393
  * The replayed native Responses reasoning {@link countMessage} charges
370
394
  * across `messages`: the part of a count that changes with the reading
@@ -468,15 +492,17 @@ export class Tokenizer {
468
492
  fragments.push(block.thinking);
469
493
  // Providers charge for the opaque signature/reasoning payload that
470
494
  // rides alongside the thinking text (OpenAI Responses encrypted
471
- // reasoning items, Gemini thought signatures). A reader billing at
472
- // `output` size (Claude) charges the reasoning, priced below, not
473
- // these bytes. The compaction floor excludes it (its local byte size
474
- // diverges from provider billing).
495
+ // reasoning items, Gemini thought signatures) wherever it survives
496
+ // replay; `transformMessages` strips another model's on most wires.
497
+ // A reader billing at `output` size (Claude) charges the reasoning,
498
+ // priced below, not these bytes. The compaction floor excludes it
499
+ // (its local byte size diverges from provider billing).
475
500
  if (
476
501
  block.thinkingSignature &&
477
502
  !excludeEncryptedReasoning &&
478
503
  signaturesPriced &&
479
- typeof replayedReasoning !== "number"
504
+ typeof replayedReasoning !== "number" &&
505
+ this.#receivesReasoningPayload(message)
480
506
  ) {
481
507
  fragments.push(block.thinkingSignature);
482
508
  }
@@ -485,10 +511,13 @@ export class Tokenizer {
485
511
  fragments.push(block.name);
486
512
  fragments.push(stringifyJson(block.arguments) ?? "null");
487
513
  } else if (block.type === "redactedThinking") {
488
- // Encrypted reasoning blob the provider still bills for on replay;
489
- // excluded from the compaction floor for the same reason as above.
514
+ // Encrypted reasoning blob the provider still bills for wherever it
515
+ // survives replay, as above; excluded from the compaction floor for
516
+ // the same reason.
490
517
  signedReasoning = true;
491
- if (!excludeEncryptedReasoning && signaturesPriced) fragments.push(block.data);
518
+ if (!excludeEncryptedReasoning && signaturesPriced && this.#receivesReasoningPayload(message)) {
519
+ fragments.push(block.data);
520
+ }
492
521
  } else if (block.type === "anthropicServerTool") {
493
522
  // Native Anthropic server-tool call/result replayed verbatim on the
494
523
  // wire (server_tool_use input and opaque result content). The provider
@@ -1,5 +0,0 @@
1
- {{#if files}}
2
- {{#xml "files"}}
3
- {{files}}
4
- {{/xml}}
5
- {{/if}}