@oh-my-pi/pi-agent-core 16.2.1 → 16.2.3

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.
@@ -14,12 +14,14 @@ import {
14
14
  type Message,
15
15
  type MessageAttribution,
16
16
  type Model,
17
- ProviderHttpError,
18
17
  type SimpleStreamOptions,
19
18
  type Tool,
20
19
  type Usage,
21
20
  withAuth,
22
21
  } from "@oh-my-pi/pi-ai";
22
+ import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
23
+ import { convertTools } from "@oh-my-pi/pi-ai/providers/openai-responses";
24
+ import { buildResponsesInput, resolveOpenAICompatPolicy } from "@oh-my-pi/pi-ai/providers/openai-shared";
23
25
  import { preferredDialect } from "@oh-my-pi/pi-catalog/identity";
24
26
  import { clampThinkingLevelForModel } from "@oh-my-pi/pi-catalog/model-thinking";
25
27
  import { logger, prompt } from "@oh-my-pi/pi-utils";
@@ -28,6 +30,14 @@ import { type AgentTelemetry, instrumentedCompleteSimple } from "../telemetry";
28
30
  import { ThinkingLevel } from "../thinking";
29
31
  import { countTokens } from "../tokenizer";
30
32
  import type { AgentMessage } from "../types";
33
+ import {
34
+ buildCompactionV2Request,
35
+ getCompactionV2PreserveData,
36
+ requestCompactionV2Streaming,
37
+ shouldUseCompactionV2Streaming,
38
+ storeCompactionV2PreserveData,
39
+ V2_RETAINED_MESSAGE_TOKEN_BUDGET,
40
+ } from "./compaction-v2-streaming";
31
41
  import type { CompactionEntry, SessionEntry } from "./entries";
32
42
  import { type ConvertToLlm, createBranchSummaryMessage, createCustomMessage, defaultConvertToLlm } from "./messages";
33
43
  import {
@@ -155,6 +165,8 @@ export interface CompactionSettings {
155
165
  autoContinue?: boolean;
156
166
  remoteEnabled?: boolean;
157
167
  remoteEndpoint?: string;
168
+ remoteStreamingV2Enabled?: boolean;
169
+ v2RetainedMessageBudget?: number;
158
170
  }
159
171
 
160
172
  export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
@@ -167,6 +179,8 @@ export const DEFAULT_COMPACTION_SETTINGS: CompactionSettings = {
167
179
  keepRecentTokens: 20000,
168
180
  autoContinue: true,
169
181
  remoteEnabled: true,
182
+ remoteStreamingV2Enabled: true,
183
+ v2RetainedMessageBudget: V2_RETAINED_MESSAGE_TOKEN_BUDGET,
170
184
  };
171
185
 
172
186
  // ============================================================================
@@ -657,6 +671,12 @@ export interface SummaryOptions {
657
671
  * `resolveCompactionEffort` for the conversion contract.
658
672
  */
659
673
  thinkingLevel?: ThinkingLevel;
674
+ /** Session routing key for remote compaction transports with sticky provider sessions. */
675
+ sessionId?: string;
676
+ /** Prompt-cache key for remote compaction transports that support provider prefix caching. */
677
+ promptCacheKey?: string;
678
+ /** Provider-visible tools for remote compaction transports that replay native tool history. */
679
+ tools?: Tool[];
660
680
  /** Optional fetch implementation threaded into remote compaction calls. */
661
681
  fetch?: FetchImpl;
662
682
  }
@@ -972,9 +992,34 @@ export interface CompactionPreparation {
972
992
  settings: CompactionSettings;
973
993
  }
974
994
 
995
+ /**
996
+ * Whether a prior compaction's preserve data can be carried forward by the
997
+ * upcoming compaction. A local compaction (no remote preserve) always can — it
998
+ * holds a real textual summary. A remote compaction (V2 or V1) only can when
999
+ * some candidate model shares its provider AND remote replay is still enabled;
1000
+ * otherwise its provider-native replay is dead weight and only the opaque
1001
+ * placeholder summary survives, so the caller must re-expand the originals.
1002
+ */
1003
+ function remotePreserveReusableByAny(
1004
+ preserveData: Record<string, unknown> | undefined,
1005
+ models: readonly Model[],
1006
+ settings: CompactionSettings,
1007
+ ): boolean {
1008
+ const remote = getCompactionV2PreserveData(preserveData) ?? getPreservedOpenAiRemoteCompactionData(preserveData);
1009
+ if (!remote) return true;
1010
+ if (settings.remoteEnabled === false) return false;
1011
+ for (const model of models) {
1012
+ if (remote.provider !== model.provider) continue;
1013
+ const v2Ok = settings.remoteStreamingV2Enabled !== false && shouldUseCompactionV2Streaming(model);
1014
+ if (v2Ok || shouldUseOpenAiRemoteCompaction(model)) return true;
1015
+ }
1016
+ return false;
1017
+ }
1018
+
975
1019
  export function prepareCompaction(
976
1020
  pathEntries: SessionEntry[],
977
1021
  settings: CompactionSettings,
1022
+ compactionModels: readonly Model[] = [],
978
1023
  ): CompactionPreparation | undefined {
979
1024
  if (pathEntries.length > 0 && pathEntries[pathEntries.length - 1].type === "compaction") {
980
1025
  return undefined;
@@ -982,10 +1027,18 @@ export function prepareCompaction(
982
1027
 
983
1028
  let prevCompactionIndex = -1;
984
1029
  for (let i = pathEntries.length - 1; i >= 0; i--) {
985
- if (pathEntries[i].type === "compaction") {
986
- prevCompactionIndex = i;
987
- break;
1030
+ if (pathEntries[i].type !== "compaction") continue;
1031
+ // Skip a prior remote compaction (V2 or V1) whose provider-native replay
1032
+ // none of the upcoming compaction candidates can reuse: its summary is only
1033
+ // an opaque placeholder, so re-expand its original messages and summarize
1034
+ // them locally rather than stranding that history. compact() still reuses it
1035
+ // when a candidate can (same provider, remote enabled).
1036
+ const entry = pathEntries[i] as CompactionEntry;
1037
+ if (compactionModels.length > 0 && !remotePreserveReusableByAny(entry.preserveData, compactionModels, settings)) {
1038
+ continue;
988
1039
  }
1040
+ prevCompactionIndex = i;
1041
+ break;
989
1042
  }
990
1043
  const boundaryStart = prevCompactionIndex + 1;
991
1044
  const boundaryEnd = pathEntries.length;
@@ -1079,6 +1132,49 @@ export function prepareCompaction(
1079
1132
 
1080
1133
  const TURN_PREFIX_SUMMARIZATION_PROMPT = prompt.render(compactionTurnPrefixPrompt);
1081
1134
 
1135
+ function openAiCompatSupportsImageDetailOriginal(model: Model): boolean {
1136
+ const compat = model.compat;
1137
+ return !!compat && "supportsImageDetailOriginal" in compat && compat.supportsImageDetailOriginal === true;
1138
+ }
1139
+
1140
+ function buildOpenAiResponsesCompactionInput(
1141
+ messages: Message[],
1142
+ model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
1143
+ previousReplacementHistory: Array<Record<string, unknown>> | undefined,
1144
+ ): unknown[] {
1145
+ const input = buildResponsesInput({
1146
+ model,
1147
+ context: { messages },
1148
+ strictResponsesPairing: model.compat.strictResponsesPairing,
1149
+ supportsImageDetailOriginal: openAiCompatSupportsImageDetailOriginal(model),
1150
+ nativeHistory: { replay: true, filterReasoning: false },
1151
+ includeThinkingSignatures: true,
1152
+ repairOrphanOutputs: true,
1153
+ });
1154
+ return previousReplacementHistory ? [...previousReplacementHistory, ...input] : input;
1155
+ }
1156
+
1157
+ /**
1158
+ * Resolve the Responses `reasoning` param for a V2 compaction request the same
1159
+ * way a normal turn does — through {@link resolveOpenAICompatPolicy}, so it
1160
+ * honors per-model effort support, `omitReasoningEffort`, disable modes, and the
1161
+ * wire-effort mapping. Returns `undefined` for non-reasoning models or when the
1162
+ * user selected `Off` (matching the normal-turn omission, not a fabricated shape).
1163
+ */
1164
+ function buildCompactionV2Reasoning(
1165
+ model: Model<"openai-responses" | "azure-openai-responses" | "openai-codex-responses">,
1166
+ thinkingLevel: ThinkingLevel | undefined,
1167
+ ): { effort: string; summary: string } | undefined {
1168
+ const policy = resolveOpenAICompatPolicy(model, {
1169
+ endpoint: "responses",
1170
+ reasoning: resolveCompactionEffort(model, thinkingLevel),
1171
+ });
1172
+ const reasoning = policy.reasoning;
1173
+ if (!reasoning.modelSupported || reasoning.disabled || reasoning.omitReasoningEffort) return undefined;
1174
+ if (reasoning.requestedEffort === undefined) return undefined;
1175
+ return { effort: reasoning.wireEffort ?? reasoning.requestedEffort, summary: "auto" };
1176
+ }
1177
+
1082
1178
  /**
1083
1179
  * Generate summaries for compaction using prepared data.
1084
1180
  * Returns CompactionResult - SessionManager adds id/parentId when saving.
@@ -1122,6 +1218,9 @@ export async function compact(
1122
1218
  // silently falls back to Effort.High — the same defect e07b47ee4 fixed
1123
1219
  // at the call sites, leaked back in here. See resolveCompactionEffort.
1124
1220
  thinkingLevel: options?.thinkingLevel,
1221
+ sessionId: options?.sessionId,
1222
+ promptCacheKey: options?.promptCacheKey,
1223
+ tools: options?.tools,
1125
1224
  fetch: options?.fetch,
1126
1225
  };
1127
1226
 
@@ -1138,18 +1237,74 @@ export async function compact(
1138
1237
  : undefined;
1139
1238
 
1140
1239
  let preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, undefined);
1141
- if (settings.remoteEnabled !== false && shouldUseOpenAiRemoteCompaction(model)) {
1142
- const previousRemoteCompaction = getPreservedOpenAiRemoteCompactionData(previousPreserveData);
1143
- const remoteMessages: AgentMessage[] = [
1144
- ...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
1145
- ...messagesToSummarize,
1146
- ...turnPrefixMessages,
1147
- ...recentMessages,
1148
- ];
1240
+ const remoteMessages: AgentMessage[] = [
1241
+ ...(snapcompactArchiveMigrationMessage ? [snapcompactArchiveMigrationMessage] : []),
1242
+ ...messagesToSummarize,
1243
+ ...turnPrefixMessages,
1244
+ ...recentMessages,
1245
+ ];
1246
+ let usedRemoteCompaction = false;
1247
+ if (
1248
+ settings.remoteEnabled !== false &&
1249
+ settings.remoteStreamingV2Enabled !== false &&
1250
+ shouldUseCompactionV2Streaming(model)
1251
+ ) {
1252
+ const previousRemoteCompaction = getCompactionV2PreserveData(previousPreserveData);
1149
1253
  const previousReplacementHistory =
1150
1254
  previousRemoteCompaction?.provider === model.provider
1151
1255
  ? previousRemoteCompaction.replacementHistory
1152
1256
  : undefined;
1257
+ const remoteHistory = buildOpenAiResponsesCompactionInput(
1258
+ (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
1259
+ model,
1260
+ previousReplacementHistory,
1261
+ );
1262
+ if (remoteHistory.length > 0) {
1263
+ try {
1264
+ const request = buildCompactionV2Request(
1265
+ model,
1266
+ remoteHistory,
1267
+ summaryOptions.remoteInstructions ?? SUMMARIZATION_SYSTEM_PROMPT,
1268
+ {
1269
+ tools: summaryOptions.tools
1270
+ ? convertTools(summaryOptions.tools, model.compat.supportsStrictMode, model)
1271
+ : undefined,
1272
+ reasoning: buildCompactionV2Reasoning(model, summaryOptions.thinkingLevel),
1273
+ sessionId: summaryOptions.sessionId,
1274
+ promptCacheKey: summaryOptions.promptCacheKey,
1275
+ retainedMessageBudget: settings.v2RetainedMessageBudget,
1276
+ },
1277
+ );
1278
+ const remote = await withAuth(
1279
+ apiKey,
1280
+ key => requestCompactionV2Streaming(model, key, request, signal, { fetch: summaryOptions.fetch }),
1281
+ { signal },
1282
+ );
1283
+ preserveData = { ...(preserveData ?? {}), ...storeCompactionV2PreserveData(remote, model) };
1284
+ usedRemoteCompaction = true;
1285
+ } catch (err) {
1286
+ // A user/session abort is a cancellation, not a remote failure —
1287
+ // swallowing it here would downgrade Esc into "fall back to local
1288
+ // summarization" and keep compaction running on an aborted signal.
1289
+ if (signal?.aborted) throw err;
1290
+ logger.warn("OpenAI V2 remote compaction failed, falling back to V1/local summarization", {
1291
+ error: err instanceof Error ? err.message : String(err),
1292
+ model: model.id,
1293
+ provider: model.provider,
1294
+ });
1295
+ }
1296
+ }
1297
+ }
1298
+
1299
+ if (!usedRemoteCompaction && settings.remoteEnabled !== false && shouldUseOpenAiRemoteCompaction(model)) {
1300
+ const previousRemoteCompaction = getPreservedOpenAiRemoteCompactionData(previousPreserveData);
1301
+ const previousV2Compaction = getCompactionV2PreserveData(previousPreserveData);
1302
+ const previousReplacementHistory =
1303
+ previousRemoteCompaction?.provider === model.provider
1304
+ ? previousRemoteCompaction.replacementHistory
1305
+ : previousV2Compaction?.provider === model.provider
1306
+ ? previousV2Compaction.replacementHistory
1307
+ : undefined;
1153
1308
  const remoteHistory = buildOpenAiNativeHistory(
1154
1309
  (summaryOptions.convertToLlm ?? defaultConvertToLlm)(remoteMessages),
1155
1310
  model,
@@ -1171,6 +1326,7 @@ export async function compact(
1171
1326
  { signal },
1172
1327
  );
1173
1328
  preserveData = withOpenAiRemoteCompactionPreserveData(previousPreserveData, remote);
1329
+ usedRemoteCompaction = true;
1174
1330
  } catch (err) {
1175
1331
  // A user/session abort is a cancellation, not a remote failure —
1176
1332
  // swallowing it here would downgrade Esc into "fall back to local
@@ -1188,7 +1344,18 @@ export async function compact(
1188
1344
  // Generate summaries (can be parallel if both needed) and merge into one
1189
1345
  let summary: string;
1190
1346
 
1191
- if (isSplitTurn && turnPrefixMessages.length > 0) {
1347
+ if (usedRemoteCompaction) {
1348
+ // Remote compaction (V2 or V1) already compacted remotely; the durable
1349
+ // history lives in the provider replay payload (preserveData). Skip local
1350
+ // summarization so a successful remote compaction never pays for a second,
1351
+ // redundant LLM round. If a LATER compaction cannot reuse this payload,
1352
+ // prepareCompaction re-expands the original messages and summarizes them
1353
+ // locally then (see remotePreserveReusableByAny).
1354
+ const usedTokens = getCompactionV2PreserveData(preserveData)?.usedTokens ?? 0;
1355
+ summary =
1356
+ "Remote compaction preserved provider-native history for this session." +
1357
+ (usedTokens > 0 ? ` Retained ${usedTokens} tokens in the provider replay payload.` : "");
1358
+ } else if (isSplitTurn && turnPrefixMessages.length > 0) {
1192
1359
  // Generate both summaries in parallel
1193
1360
  const [historyResult, turnPrefixResult] = await Promise.all([
1194
1361
  messagesToSummarize.length > 0 || previousSummaryForCompaction
@@ -1227,25 +1394,19 @@ export async function compact(
1227
1394
  summary = "No prior history.";
1228
1395
  }
1229
1396
 
1230
- const shortSummary = await generateShortSummary(
1231
- recentMessages,
1232
- summary,
1233
- model,
1234
- settings.reserveTokens,
1235
- apiKey,
1236
- signal,
1237
- {
1238
- extraContext: options?.extraContext,
1239
- remoteEndpoint: summaryOptions.remoteEndpoint,
1240
- initiatorOverride: summaryOptions.initiatorOverride,
1241
- metadata: summaryOptions.metadata,
1242
- telemetry: summaryOptions.telemetry,
1243
- // Same propagation as summaryOptions above — generateShortSummary
1244
- // resolves its own reasoning via resolveCompactionEffort.
1245
- thinkingLevel: options?.thinkingLevel,
1246
- fetch: summaryOptions.fetch,
1247
- },
1248
- );
1397
+ const shortSummary = usedRemoteCompaction
1398
+ ? "Remote compaction"
1399
+ : await generateShortSummary(recentMessages, summary, model, settings.reserveTokens, apiKey, signal, {
1400
+ extraContext: options?.extraContext,
1401
+ remoteEndpoint: summaryOptions.remoteEndpoint,
1402
+ initiatorOverride: summaryOptions.initiatorOverride,
1403
+ metadata: summaryOptions.metadata,
1404
+ telemetry: summaryOptions.telemetry,
1405
+ // Same propagation as summaryOptions above — generateShortSummary
1406
+ // resolves its own reasoning via resolveCompactionEffort.
1407
+ thinkingLevel: options?.thinkingLevel,
1408
+ fetch: summaryOptions.fetch,
1409
+ });
1249
1410
 
1250
1411
  // Compute file lists and append to summary
1251
1412
  const { readFiles, modifiedFiles } = computeFileLists(fileOps);
@@ -77,6 +77,14 @@ export interface LabelEntry extends SessionEntryBase {
77
77
  label: string | undefined;
78
78
  }
79
79
 
80
+ export interface TitleChangeEntry extends SessionEntryBase {
81
+ type: "title_change";
82
+ title: string;
83
+ previousTitle?: string;
84
+ source: "auto" | "user";
85
+ trigger?: string;
86
+ }
87
+
80
88
  export interface TtsrInjectionEntry extends SessionEntryBase {
81
89
  type: "ttsr_injection";
82
90
  /** Names of rules that were injected */
@@ -121,6 +129,7 @@ export type SessionEntry =
121
129
  | CustomEntry
122
130
  | CustomMessageEntry
123
131
  | LabelEntry
132
+ | TitleChangeEntry
124
133
  | TtsrInjectionEntry
125
134
  | MCPToolSelectionEntry
126
135
  | SessionInitEntry
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Remote compaction utilities.
3
3
  *
4
- * Provider-side conversation summarization endpoints. Two flavors:
4
+ * Provider-side conversation summarization endpoints. Three flavors:
5
5
  *
6
- * - **OpenAI remote compaction** (`/responses/compact`): preserves encrypted
6
+ * - **OpenAI remote compaction V2** (Responses streaming): appends a
7
+ * `compaction_trigger` input item to the normal stream and stores the returned
8
+ * `compaction` item with retained real user messages in `preserveData`.
9
+ * - **OpenAI remote compaction V1** (`/responses/compact`): preserves encrypted
7
10
  * reasoning across compactions by submitting the full responses-API native
8
11
  * history and storing the returned `compaction` / `compaction_summary`
9
12
  * item in `preserveData` so future turns can replay the encrypted state.
@@ -12,7 +15,7 @@
12
15
  * with `{ summary, shortSummary? }`.
13
16
  */
14
17
 
15
- import { ProviderHttpError } from "@oh-my-pi/pi-ai/errors";
18
+ import { ProviderHttpError } from "@oh-my-pi/pi-ai/error";
16
19
  import { parseAzureDeploymentNameMap, parseTextSignature } from "@oh-my-pi/pi-ai/providers/openai-shared";
17
20
  import { transformMessages } from "@oh-my-pi/pi-ai/providers/transform-messages";
18
21
  import type { Api, AssistantMessage, FetchImpl, Message, Model } from "@oh-my-pi/pi-ai/types";
@@ -29,6 +32,8 @@ import {
29
32
  } from "@oh-my-pi/pi-catalog/wire/codex";
30
33
  import { $env, logger } from "@oh-my-pi/pi-utils";
31
34
 
35
+ export * from "./compaction-v2-streaming";
36
+
32
37
  // ============================================================================
33
38
  // Public types
34
39
  // ============================================================================
@@ -214,57 +219,12 @@ export function withOpenAiRemoteCompactionPreserveData(
214
219
  // Input/output filtering for OpenAI compact endpoint
215
220
  // ============================================================================
216
221
 
217
- function shouldTrimOpenAiCompactInputItem(item: Record<string, unknown>): boolean {
218
- return item.type === "function_call_output" || (item.type === "message" && item.role === "developer");
219
- }
220
-
221
222
  function shouldKeepOpenAiCompactOutputItem(item: Record<string, unknown>): boolean {
222
223
  if (item.type === "compaction" || item.type === "compaction_summary") return true;
223
224
  if (item.type !== "message") return false;
224
225
  return item.role === "assistant" || item.role === "user";
225
226
  }
226
227
 
227
- function trimOpenAiCompactInput(
228
- input: Array<Record<string, unknown>>,
229
- contextWindow: number,
230
- instructions: string,
231
- ): Array<Record<string, unknown>> {
232
- const trimmed = [...input];
233
- // Per-item serialized sizes are cached and decremented on removal.
234
- // Re-stringifying the whole input per popped item was O(N²) in total chars
235
- // — hundreds of MB of stringify churn on a 200k-token codex history,
236
- // blocking the event loop for seconds (same class as the addOpenAiCallIds
237
- // fix above).
238
- const sizes = trimmed.map(item => JSON.stringify(item).length);
239
- let chars = instructions.length;
240
- for (const size of sizes) chars += size;
241
- const removeAt = (index: number): void => {
242
- chars -= sizes[index] ?? 0;
243
- trimmed.splice(index, 1);
244
- sizes.splice(index, 1);
245
- };
246
- while (trimmed.length > 0 && Math.ceil(chars / 4) > contextWindow) {
247
- const last = trimmed[trimmed.length - 1];
248
- if (last?.type === "function_call_output" || last?.type === "custom_tool_call_output") {
249
- const callId = typeof last.call_id === "string" ? last.call_id : undefined;
250
- const callType = last.type === "custom_tool_call_output" ? "custom_tool_call" : "function_call";
251
- removeAt(trimmed.length - 1);
252
- if (callId) {
253
- const matchingCallIndex = trimmed.findLastIndex(item => item.type === callType && item.call_id === callId);
254
- if (matchingCallIndex >= 0) {
255
- removeAt(matchingCallIndex);
256
- }
257
- }
258
- continue;
259
- }
260
- if (!last || !shouldTrimOpenAiCompactInputItem(last)) {
261
- break;
262
- }
263
- removeAt(trimmed.length - 1);
264
- }
265
- return trimmed;
266
- }
267
-
268
228
  // Register every tool-call id in `items` (and the subset using the custom-tool
269
229
  // wire shape) into the running sets. The history builder maintains both sets
270
230
  // incrementally as native history is appended, so this only scans the
@@ -506,7 +466,10 @@ export async function requestOpenAiRemoteCompaction(
506
466
  const requestModel = resolveOpenAiCompactModel(model);
507
467
  const request: OpenAiRemoteCompactionRequest = {
508
468
  model: requestModel,
509
- input: trimOpenAiCompactInput(compactInput, model.contextWindow ?? Number.POSITIVE_INFINITY, instructions),
469
+ // Send full history to the endpoint - don't trim locally.
470
+ // The provider handles compression via the compaction endpoint.
471
+ // Trimming before sending loses assistant messages and thinking blocks.
472
+ input: compactInput,
510
473
  instructions,
511
474
  };
512
475
  const isAzureOpenAiResponses = (model.remoteCompaction?.api ?? model.api) === "azure-openai-responses";
package/src/proxy.ts CHANGED
@@ -13,6 +13,12 @@ import {
13
13
  type StopReason,
14
14
  type ToolCall,
15
15
  } from "@oh-my-pi/pi-ai";
16
+ import {
17
+ clearStreamingPartialJson,
18
+ kStreamingPartialJson,
19
+ type StreamingPartialJsonCarrier,
20
+ setStreamingPartialJson,
21
+ } from "@oh-my-pi/pi-ai/utils/block-symbols";
16
22
  import { calculateCost } from "@oh-my-pi/pi-catalog/models";
17
23
  import { parseStreamingJson, readSseJson } from "@oh-my-pi/pi-utils";
18
24
 
@@ -183,8 +189,8 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
183
189
  const errorMessage = error instanceof Error ? error.message : String(error);
184
190
  const reason = options.signal?.aborted ? "aborted" : "error";
185
191
  partial.stopReason = reason;
186
- scrubPartialJson(partial);
187
192
  partial.errorMessage = errorMessage;
193
+ scrubPartialJson(partial);
188
194
  stream.push({
189
195
  type: "error",
190
196
  reason,
@@ -202,14 +208,13 @@ export function streamProxy(model: Model, context: Context, options: ProxyStream
202
208
  }
203
209
 
204
210
  /**
205
- * Remove the `partialJson` streaming field from any tool-call content blocks
206
- * that still carry it (e.g. when the stream ended without a `toolcall_end`).
211
+ * Clear the `partialJson` streaming symbol from any tool-call content blocks
212
+ * that still carry it (e.g. when the stream ended without a `toolcall_end`), so
213
+ * the finalized `AssistantMessage` no longer reads as still-streaming.
207
214
  */
208
215
  function scrubPartialJson(partial: AssistantMessage): void {
209
216
  for (const block of partial.content) {
210
- if (block?.type === "toolCall") {
211
- delete (block as ToolCall & { partialJson?: string }).partialJson;
212
- }
217
+ if (block?.type === "toolCall") clearStreamingPartialJson(block);
213
218
  }
214
219
  }
215
220
 
@@ -218,10 +223,10 @@ function scrubPartialJson(partial: AssistantMessage): void {
218
223
  *
219
224
  * Streaming `partialJson` for in-progress tool calls is accumulated in a
220
225
  * side-channel map keyed by `contentIndex` and also written onto the content
221
- * object (as a typed intersection field) so downstream renderers can read it
222
- * during streaming. The field is deleted at `toolcall_end` and scrubbed from
223
- * any remaining blocks at `done`/`error` to guarantee it never leaks into the
224
- * final `AssistantMessage`.
226
+ * object as a symbol-keyed field so downstream renderers can read it
227
+ * during streaming. The field is cleared at `toolcall_end` and scrubbed from any
228
+ * remaining blocks at `done`/`error` so the finalized `AssistantMessage` never
229
+ * reads as still-streaming.
225
230
  */
226
231
  function processProxyEvent(
227
232
  model: Model,
@@ -240,9 +245,10 @@ function processProxyEvent(
240
245
  totalTokens: 0,
241
246
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
242
247
  };
243
- delete (partial as { stopReason?: string }).stopReason;
244
- delete (partial as { errorMessage?: string }).errorMessage;
245
- delete (partial as { duration?: number }).duration;
248
+ partial.errorMessage = undefined;
249
+ partial.errorId = undefined;
250
+ partial.duration = undefined;
251
+ (partial as { stopReason?: string }).stopReason = undefined;
246
252
  return { type: "start", partial };
247
253
 
248
254
  case "text_start":
@@ -315,8 +321,8 @@ function processProxyEvent(
315
321
  id: proxyEvent.id,
316
322
  name: proxyEvent.toolName,
317
323
  arguments: {},
318
- partialJson: "",
319
- } as ToolCall & { partialJson: string };
324
+ [kStreamingPartialJson]: "",
325
+ } as ToolCall & StreamingPartialJsonCarrier;
320
326
  partialJsonByIndex.set(proxyEvent.contentIndex, "");
321
327
  return { type: "toolcall_start", contentIndex: proxyEvent.contentIndex, partial };
322
328
  case "toolcall_delta": {
@@ -325,7 +331,7 @@ function processProxyEvent(
325
331
  const acc = (partialJsonByIndex.get(proxyEvent.contentIndex) ?? "") + proxyEvent.delta;
326
332
  partialJsonByIndex.set(proxyEvent.contentIndex, acc);
327
333
  content.arguments = parseStreamingJson(acc) || {};
328
- (content as ToolCall & { partialJson: string }).partialJson = acc;
334
+ setStreamingPartialJson(content, acc);
329
335
  partial.content[proxyEvent.contentIndex] = { ...content }; // Trigger reactivity
330
336
  return {
331
337
  type: "toolcall_delta",
@@ -341,7 +347,7 @@ function processProxyEvent(
341
347
  const content = partial.content[proxyEvent.contentIndex];
342
348
  if (content?.type === "toolCall") {
343
349
  partialJsonByIndex.delete(proxyEvent.contentIndex);
344
- delete (content as ToolCall & { partialJson?: string }).partialJson;
350
+ clearStreamingPartialJson(content);
345
351
  return {
346
352
  type: "toolcall_end",
347
353
  contentIndex: proxyEvent.contentIndex,
package/src/types.ts CHANGED
@@ -633,6 +633,27 @@ export interface AgentTool<TParameters extends TSchema = TSchema, TDetails = any
633
633
  */
634
634
  matcherDigest?: (args: unknown) => string | undefined;
635
635
 
636
+ /**
637
+ * Surface the target file paths a (potentially partial) streamed call would
638
+ * touch, so path-scoped stream matchers (e.g. TTSR `tool:edit(*.ts)` globs)
639
+ * can match without a top-level `path`/`paths` argument. Used for tools whose
640
+ * wire grammar embeds paths inside the streamed payload (hashline section
641
+ * headers, apply_patch envelope markers). Return `undefined` (or an empty
642
+ * array) to fall back to the caller's top-level argument scan.
643
+ */
644
+ matcherPaths?: (args: unknown) => readonly string[] | undefined;
645
+
646
+ /**
647
+ * Per-file projection of a (potentially partial) streamed call, pairing each
648
+ * touched file path with the digest of only the lines added to that file.
649
+ * Path-scoped stream matchers (TTSR) evaluate each entry in isolation, so a
650
+ * scoped rule like `tool:edit(*.ts)` never fires on text that actually
651
+ * belongs to a sibling Markdown hunk in a multi-file payload. Takes
652
+ * precedence over {@link matcherDigest} + {@link matcherPaths} when present;
653
+ * returns `undefined` (or empty) to fall back to the combined hooks.
654
+ */
655
+ matcherEntries?: (args: unknown) => readonly { path: string; digest: string }[] | undefined;
656
+
636
657
  /** Capability tier declaration used by approval gates. Omitted means "exec". */
637
658
  approval?: ToolApproval;
638
659