@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 +15 -0
- package/dist/types/compaction/branch-summarization.d.ts +2 -3
- package/dist/types/compaction/compaction.d.ts +2 -2
- package/dist/types/compaction/utils.d.ts +6 -22
- package/dist/types/tokenizer.d.ts +9 -9
- package/package.json +8 -8
- package/src/agent-loop.ts +2 -2
- package/src/compaction/branch-summarization.ts +4 -10
- package/src/compaction/compaction-v2-streaming.ts +2 -2
- package/src/compaction/compaction.ts +6 -10
- package/src/compaction/utils.ts +19 -98
- package/src/tokenizer.ts +53 -24
- package/src/compaction/prompts/file-operations.md +0 -5
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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
*
|
|
28
|
-
* when known, o200k_base otherwise
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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;
|
|
73
|
-
*
|
|
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.
|
|
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.
|
|
42
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
43
|
-
"@oh-my-pi/pi-natives": "18.
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.
|
|
45
|
-
"@oh-my-pi/pi-wire": "18.
|
|
46
|
-
"@oh-my-pi/snapcompact": "18.
|
|
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.
|
|
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,
|
|
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 =
|
|
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,
|
package/src/compaction/utils.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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 & {
|
|
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
|
-
*
|
|
81
|
-
* when known, o200k_base otherwise
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
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;
|
|
209
|
-
*
|
|
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
|
|
273
|
-
|
|
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)
|
|
472
|
-
// `
|
|
473
|
-
//
|
|
474
|
-
//
|
|
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
|
|
489
|
-
// excluded from the compaction floor for
|
|
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
|
|
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
|