@oh-my-pi/pi-coding-agent 14.8.1 → 14.9.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/package.json +16 -7
  3. package/src/config/model-resolver.ts +92 -35
  4. package/src/config/prompt-templates.ts +1 -1
  5. package/src/debug/index.ts +21 -0
  6. package/src/debug/raw-sse-buffer.ts +229 -0
  7. package/src/debug/raw-sse.ts +213 -0
  8. package/src/edit/index.ts +9 -10
  9. package/src/edit/streaming.ts +6 -5
  10. package/src/eval/js/context-manager.ts +91 -47
  11. package/src/extensibility/extensions/loader.ts +9 -3
  12. package/src/extensibility/plugins/legacy-pi-compat.ts +99 -20
  13. package/src/hashline/anchors.ts +113 -0
  14. package/src/hashline/apply.ts +732 -0
  15. package/src/hashline/bigrams.json +649 -0
  16. package/src/hashline/constants.ts +8 -0
  17. package/src/hashline/diff-preview.ts +43 -0
  18. package/src/hashline/diff.ts +56 -0
  19. package/src/hashline/execute.ts +268 -0
  20. package/src/{edit/modes/hashline.lark → hashline/grammar.lark} +1 -1
  21. package/src/{edit/line-hash.ts → hashline/hash.ts} +5 -651
  22. package/src/hashline/index.ts +14 -0
  23. package/src/hashline/input.ts +110 -0
  24. package/src/hashline/parser.ts +220 -0
  25. package/src/hashline/prefixes.ts +101 -0
  26. package/src/hashline/recovery.ts +72 -0
  27. package/src/hashline/stream.ts +123 -0
  28. package/src/hashline/types.ts +69 -0
  29. package/src/hashline/utils.ts +3 -0
  30. package/src/index.ts +1 -1
  31. package/src/lsp/index.ts +1 -1
  32. package/src/lsp/render.ts +4 -0
  33. package/src/memories/index.ts +13 -4
  34. package/src/modes/components/assistant-message.ts +55 -9
  35. package/src/modes/components/welcome.ts +114 -38
  36. package/src/modes/controllers/event-controller.ts +3 -1
  37. package/src/modes/controllers/input-controller.ts +8 -1
  38. package/src/modes/interactive-mode.ts +9 -9
  39. package/src/modes/rpc/rpc-client.ts +53 -2
  40. package/src/modes/rpc/rpc-mode.ts +67 -1
  41. package/src/modes/rpc/rpc-types.ts +17 -2
  42. package/src/modes/utils/ui-helpers.ts +3 -1
  43. package/src/prompts/agents/reviewer.md +14 -0
  44. package/src/prompts/tools/hashline.md +57 -10
  45. package/src/sdk.ts +4 -3
  46. package/src/session/agent-session.ts +195 -30
  47. package/src/session/compaction/branch-summarization.ts +4 -2
  48. package/src/session/compaction/compaction.ts +22 -3
  49. package/src/task/executor.ts +21 -2
  50. package/src/task/index.ts +4 -1
  51. package/src/tools/ast-edit.ts +1 -1
  52. package/src/tools/match-line-format.ts +1 -1
  53. package/src/tools/read.ts +1 -1
  54. package/src/utils/file-mentions.ts +1 -1
  55. package/src/utils/title-generator.ts +11 -0
  56. package/src/edit/modes/hashline.ts +0 -2039
package/CHANGELOG.md CHANGED
@@ -2,6 +2,44 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [14.9.0] - 2026-05-10
6
+ ### Breaking Changes
7
+
8
+ - Moved hashline APIs to the dedicated `@oh-my-pi/pi-coding-agent/hashline` module, moved hash helpers to `@oh-my-pi/pi-coding-agent/hashline/hash`, and removed the legacy `edit/modes/hashline` and `edit/line-hash` source subpaths.
9
+
10
+ ### Removed
11
+
12
+ - Removed hashline auto-rebase. Anchor mismatches now reject immediately so the model re-reads instead of silently relocating an edit to a hash-collision within ±5 lines, which could otherwise apply the change to the wrong region. Stale-anchor recovery via the cached read snapshot is unaffected.
13
+
14
+ ### Fixed
15
+
16
+ - Fixed compaction crashing with `auth_unavailable` when the current model's provider has no credentials configured; compaction now falls back to an available model role (or fails fast with a clear error) instead of attempting a doomed provider call ([#986](https://github.com/can1357/oh-my-pi/issues/986)).
17
+ - Fixed top-level static import rewriting in JS evaluation to use parser-based detection so only real import declarations are rewritten and `import` text inside strings, comments, or template literals is preserved
18
+ - Fixed `import ... with` attribute handling in rewritten ESM imports so static imports with module attributes now become dynamic imports with matching `with` options
19
+ - Fixed model resolution silently falling back to a different provider (e.g. Amazon Bedrock) when `modelRoles` specified a fully-qualified `<provider>/<id>` whose exact pair was not in the bundled catalog. Explicit provider prefixes are now honored or surface a clear error ([#980](https://github.com/can1357/oh-my-pi/issues/980)).
20
+ - Fixed session count inflation on Anthropic backend caused by a fresh random `metadata.user_id` being generated on every API request; all requests within one conversation now share a stable `metadata.user_id` derived from the session ID, matching the expected one-session-per-conversation counting
21
+ - Fixed plan mode review resubmits to append each refreshed `local://PLAN.md` preview to the chat history, preserving the full refined plan in terminal scrollback.
22
+ - Fixed compaction requests (manual and auto) not carrying `metadata.user_id`, leaving them unattributed on the backend
23
+ - Fixed direct session-bound LLM calls (`/btw` ephemeral turns via `runEphemeralTurn`, branch summarization, session title generation) bypassing the agent and emitting a fresh random `metadata.user_id` per request on Anthropic OAuth: the session-level `prepareSimpleStreamOptions` helper now stamps the agent's session metadata onto direct calls, and `generateBranchSummary` plus `generateSessionTitle` accept and forward an explicit `metadata` option from the call site
24
+ - Fixed `metadata.user_id` lacking the authenticated `account_uuid` on Anthropic OAuth requests; sessions now install a dynamic resolver via `Agent#setMetadataResolver` that builds `{ session_id, account_uuid? }` per request, looking the live OAuth account UUID up from `AuthStorage` so it stays in sync with token refreshes and login/logout transitions instead of stranding a stale value
25
+ - Fixed multi-file legacy Pi extensions failing to load when sibling `.ts` files import each other via relative paths ([#983](https://github.com/can1357/oh-my-pi/issues/983)).
26
+ - Fixed sub-agent dispatch silently routing to a model whose provider has no working credentials (e.g. an unqualified `modelRoles.task` id like `qwen3.6-plus-free` resolving to a provider the user is not authenticated against). Task dispatch now falls back to the parent session's active model — which by definition has working auth — when the resolved subagent model has none ([#985](https://github.com/can1357/oh-my-pi/issues/985)).
27
+
28
+ ### Added
29
+
30
+ - Added a debug-panel raw SSE stream viewer so stuck model/tool-call streams can be inspected live from the TUI.
31
+
32
+ ### Fixed
33
+
34
+ - Fixed legacy Pi plugin extensions failing to load on Windows when their entry path contains a drive letter ([#990](https://github.com/can1357/oh-my-pi/pull/990) by [@jiwangyihao](https://github.com/jiwangyihao)).
35
+
36
+ ### Added
37
+
38
+ - Added `get_login_providers` RPC command to list registered OAuth providers with their current authentication status (`id`, `name`, `available`, `authenticated`)
39
+ - Added `login` RPC command to trigger OAuth login for a given provider; emits an `open_url` extension UI event (fire-and-forget) carrying the auth URL and optional instructions so headless clients can open the browser, then resolves when the callback-server flow completes
40
+ - Added `open_url` variant to `RpcExtensionUIRequest` for the above
41
+ - Added `getLoginProviders()` and `login(providerId)` methods to `RpcClient`
42
+
5
43
  ## [14.8.0] - 2026-05-09
6
44
  ### Added
7
45
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "14.8.1",
4
+ "version": "14.9.0",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/can1357/oh-my-pi",
7
7
  "author": "Can Boluk",
@@ -45,13 +45,14 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@agentclientprotocol/sdk": "0.21.0",
48
+ "@babel/parser": "^7.29.3",
48
49
  "@mozilla/readability": "^0.6.0",
49
- "@oh-my-pi/omp-stats": "14.8.1",
50
- "@oh-my-pi/pi-agent-core": "14.8.1",
51
- "@oh-my-pi/pi-ai": "14.8.1",
52
- "@oh-my-pi/pi-natives": "14.8.1",
53
- "@oh-my-pi/pi-tui": "14.8.1",
54
- "@oh-my-pi/pi-utils": "14.8.1",
50
+ "@oh-my-pi/omp-stats": "14.9.0",
51
+ "@oh-my-pi/pi-agent-core": "14.9.0",
52
+ "@oh-my-pi/pi-ai": "14.9.0",
53
+ "@oh-my-pi/pi-natives": "14.9.0",
54
+ "@oh-my-pi/pi-tui": "14.9.0",
55
+ "@oh-my-pi/pi-utils": "14.9.0",
55
56
  "@puppeteer/browsers": "^2.13.0",
56
57
  "@sinclair/typebox": "^0.34.49",
57
58
  "@types/turndown": "5.0.6",
@@ -227,6 +228,14 @@
227
228
  "types": "./src/edit/modes/*.ts",
228
229
  "import": "./src/edit/modes/*.ts"
229
230
  },
231
+ "./hashline": {
232
+ "types": "./src/hashline/index.ts",
233
+ "import": "./src/hashline/index.ts"
234
+ },
235
+ "./hashline/*": {
236
+ "types": "./src/hashline/*.ts",
237
+ "import": "./src/hashline/*.ts"
238
+ },
230
239
  "./exa": {
231
240
  "types": "./src/exa/index.ts",
232
241
  "import": "./src/exa/index.ts"
@@ -16,7 +16,7 @@ import chalk from "chalk";
16
16
  import MODEL_PRIO from "../priority.json" with { type: "json" };
17
17
  import { parseThinkingLevel, resolveThinkingLevelForModel } from "../thinking";
18
18
  import { fuzzyMatch } from "../utils/fuzzy";
19
- import { MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
19
+ import { isAuthenticated, MODEL_ROLE_IDS, type ModelRegistry, type ModelRole } from "./model-registry";
20
20
  import type { Settings } from "./settings";
21
21
 
22
22
  /** Default model IDs for each known provider */
@@ -326,43 +326,49 @@ function tryMatchModel(
326
326
  return exactCanonicalMatch;
327
327
  }
328
328
 
329
- // Check for provider/modelId formatfuzzy match within provider
329
+ // Exact ID match (case-insensitive)this must happen before provider-scoped
330
+ // fuzzy matching so raw IDs that contain slashes (for example OpenRouter model
331
+ // IDs like "openai/gpt-4o:extended") still resolve as IDs instead of being
332
+ // misread as a provider-qualified selector.
333
+ const exactMatches = availableModels.filter(m => m.id.toLowerCase() === modelPattern.toLowerCase());
334
+ if (exactMatches.length > 0) {
335
+ return pickPreferredModel(exactMatches, context);
336
+ }
337
+ // Check for provider/modelId format — fuzzy match within provider only.
330
338
  const slashIndex = modelPattern.indexOf("/");
331
339
  if (slashIndex !== -1) {
332
340
  const provider = modelPattern.substring(0, slashIndex);
333
341
  const modelId = modelPattern.substring(slashIndex + 1);
334
-
335
342
  const providerModels = availableModels.filter(m => m.provider.toLowerCase() === provider.toLowerCase());
336
- if (providerModels.length > 0) {
343
+ if (providerModels.length === 0) {
344
+ // The prefix is not a known provider in this candidate set, so treat the
345
+ // slash as part of the raw model ID and continue with generic matching.
346
+ } else {
337
347
  const scored = providerModels
338
348
  .map(model => ({ model, match: fuzzyMatch(modelId, model.id) }))
339
349
  .filter(entry => entry.match.matches);
340
- if (scored.length > 0) {
341
- scored.sort((a, b) => {
342
- if (a.match.score !== b.match.score) return a.match.score - b.match.score;
343
- const aKey = formatModelString(a.model);
344
- const bKey = formatModelString(b.model);
345
- const aUsage = context.modelUsageRank.get(aKey) ?? Number.POSITIVE_INFINITY;
346
- const bUsage = context.modelUsageRank.get(bKey) ?? Number.POSITIVE_INFINITY;
347
- if (aUsage !== bUsage) return aUsage - bUsage;
348
-
349
- const aProviderUsage = context.providerUsageRank.get(a.model.provider) ?? Number.POSITIVE_INFINITY;
350
- const bProviderUsage = context.providerUsageRank.get(b.model.provider) ?? Number.POSITIVE_INFINITY;
351
- if (aProviderUsage !== bProviderUsage) return aProviderUsage - bProviderUsage;
352
-
353
- const aOrder = context.modelOrder.get(aKey) ?? 0;
354
- const bOrder = context.modelOrder.get(bKey) ?? 0;
355
- return aOrder - bOrder;
356
- });
357
- return scored[0]?.model;
350
+ if (scored.length === 0) {
351
+ return undefined;
358
352
  }
359
- }
360
- }
361
353
 
362
- // Exact ID match (case-insensitive) with ambiguity across providers handled by preference
363
- const exactMatches = availableModels.filter(m => m.id.toLowerCase() === modelPattern.toLowerCase());
364
- if (exactMatches.length > 0) {
365
- return pickPreferredModel(exactMatches, context);
354
+ scored.sort((a, b) => {
355
+ if (a.match.score !== b.match.score) return a.match.score - b.match.score;
356
+ const aKey = formatModelString(a.model);
357
+ const bKey = formatModelString(b.model);
358
+ const aUsage = context.modelUsageRank.get(aKey) ?? Number.POSITIVE_INFINITY;
359
+ const bUsage = context.modelUsageRank.get(bKey) ?? Number.POSITIVE_INFINITY;
360
+ if (aUsage !== bUsage) return aUsage - bUsage;
361
+
362
+ const aProviderUsage = context.providerUsageRank.get(a.model.provider) ?? Number.POSITIVE_INFINITY;
363
+ const bProviderUsage = context.providerUsageRank.get(b.model.provider) ?? Number.POSITIVE_INFINITY;
364
+ if (aProviderUsage !== bProviderUsage) return aProviderUsage - bProviderUsage;
365
+
366
+ const aOrder = context.modelOrder.get(aKey) ?? 0;
367
+ const bOrder = context.modelOrder.get(bKey) ?? 0;
368
+ return aOrder - bOrder;
369
+ });
370
+ return scored[0]?.model;
371
+ }
366
372
  }
367
373
 
368
374
  // No exact match - fall back to partial matching
@@ -688,18 +694,18 @@ export function resolveModelFromSettings(options: {
688
694
  }): Model<Api> | undefined {
689
695
  const { settings, availableModels, matchPreferences, roleOrder, modelRegistry } = options;
690
696
  const roles = roleOrder ?? MODEL_ROLE_IDS;
697
+ let sawConfiguredProviderQualifiedRole = false;
691
698
  for (const role of roles) {
692
699
  const configured = settings.getModelRole(role);
693
700
  if (!configured) continue;
694
- const resolved = resolveModelFromString(
695
- expandRoleAlias(configured, settings),
696
- availableModels,
697
- matchPreferences,
698
- modelRegistry,
699
- );
701
+ const expanded = expandRoleAlias(configured, settings).trim();
702
+ if (expanded.includes("/")) {
703
+ sawConfiguredProviderQualifiedRole = true;
704
+ }
705
+ const resolved = resolveModelFromString(expanded, availableModels, matchPreferences, modelRegistry);
700
706
  if (resolved) return resolved;
701
707
  }
702
- return availableModels[0];
708
+ return sawConfiguredProviderQualifiedRole ? undefined : availableModels[0];
703
709
  }
704
710
 
705
711
  /**
@@ -726,6 +732,57 @@ export function resolveModelOverride(
726
732
  return { explicitThinkingLevel: false };
727
733
  }
728
734
 
735
+ /**
736
+ * Resolve a list of override patterns to the first matching model, with an
737
+ * auth-aware fallback to the parent session's active model.
738
+ *
739
+ * If the resolved subagent model has no working credentials (provider has no
740
+ * usable auth), and the parent's active model resolves with working auth,
741
+ * use the parent's model instead. This prevents subagent dispatch from
742
+ * silently routing to a provider the user can't actually call (e.g.
743
+ * `modelRoles.task` pointing at an unqualified id whose only available
744
+ * provider variant has no configured credentials — see #985).
745
+ *
746
+ * If neither the subagent nor the parent has working auth, returns the
747
+ * primary resolution unchanged so the existing error path still surfaces
748
+ * a meaningful failure downstream.
749
+ */
750
+ export async function resolveModelOverrideWithAuthFallback(
751
+ modelPatterns: string[],
752
+ parentActiveModelPattern: string | undefined,
753
+ modelRegistry: ModelLookupRegistry & Pick<ModelRegistry, "getApiKey">,
754
+ settings?: Settings,
755
+ ): Promise<{
756
+ model?: Model<Api>;
757
+ thinkingLevel?: ThinkingLevel;
758
+ explicitThinkingLevel: boolean;
759
+ authFallbackUsed: boolean;
760
+ }> {
761
+ const primary = resolveModelOverride(modelPatterns, modelRegistry, settings);
762
+ if (!primary.model || !parentActiveModelPattern) {
763
+ return { ...primary, authFallbackUsed: false };
764
+ }
765
+
766
+ const primaryKey = await modelRegistry.getApiKey(primary.model);
767
+ if (isAuthenticated(primaryKey)) {
768
+ return { ...primary, authFallbackUsed: false };
769
+ }
770
+
771
+ const fallback = resolveModelOverride([parentActiveModelPattern], modelRegistry, settings);
772
+ if (!fallback.model) {
773
+ return { ...primary, authFallbackUsed: false };
774
+ }
775
+ if (modelsAreEqual(fallback.model, primary.model)) {
776
+ return { ...primary, authFallbackUsed: false };
777
+ }
778
+ const fallbackKey = await modelRegistry.getApiKey(fallback.model);
779
+ if (!isAuthenticated(fallbackKey)) {
780
+ return { ...primary, authFallbackUsed: false };
781
+ }
782
+
783
+ return { ...fallback, authFallbackUsed: true };
784
+ }
785
+
729
786
  /**
730
787
  * Resolve a list of role patterns to the first matching model.
731
788
  */
@@ -8,7 +8,7 @@ import {
8
8
  parseFrontmatter,
9
9
  prompt,
10
10
  } from "@oh-my-pi/pi-utils";
11
- import { computeLineHash, HL_BODY_SEP, HL_EDIT_SEP } from "../edit/line-hash";
11
+ import { computeLineHash, HL_BODY_SEP, HL_EDIT_SEP } from "../hashline/hash";
12
12
  import { jtdToTypeScript } from "../tools/jtd-to-typescript";
13
13
  import { parseCommandArgs, substituteArgs } from "../utils/command-args";
14
14
 
@@ -15,6 +15,8 @@ import { formatBytes } from "../tools/render-utils";
15
15
  import { openPath } from "../utils/open";
16
16
  import { DebugLogViewerComponent } from "./log-viewer";
17
17
  import { generateHeapSnapshotData, type ProfilerSession, startCpuProfile } from "./profiler";
18
+ import { RawSseViewerComponent } from "./raw-sse";
19
+ import { resolveRawSseDebugBuffer } from "./raw-sse-buffer";
18
20
  import { clearArtifactCache, createDebugLogSource, createReportBundle, getArtifactCacheStats } from "./report-bundle";
19
21
  import { collectSystemInfo, formatSystemInfo } from "./system-info";
20
22
 
@@ -27,6 +29,7 @@ const DEBUG_MENU_ITEMS: SelectItem[] = [
27
29
  { value: "memory", label: "Report: memory issue", description: "Heap snapshot + bundle" },
28
30
  { value: "logs", label: "View: recent logs", description: "Show last 50 log entries" },
29
31
  { value: "system", label: "View: system info", description: "Show environment details" },
32
+ { value: "raw-sse", label: "View: raw SSE stream", description: "Show live provider SSE frames" },
30
33
  {
31
34
  value: "transcript",
32
35
  label: "Export: TUI transcript",
@@ -97,6 +100,9 @@ export class DebugSelectorComponent extends Container {
97
100
  case "logs":
98
101
  await this.#handleViewLogs();
99
102
  break;
103
+ case "raw-sse":
104
+ await this.#handleViewRawSse();
105
+ break;
100
106
  case "system":
101
107
  await this.#handleViewSystemInfo();
102
108
  break;
@@ -315,6 +321,21 @@ export class DebugSelectorComponent extends Container {
315
321
  this.ctx.ui.requestRender();
316
322
  }
317
323
 
324
+ async #handleViewRawSse(): Promise<void> {
325
+ const viewer = new RawSseViewerComponent({
326
+ buffer: resolveRawSseDebugBuffer(this.ctx.session),
327
+ terminalRows: this.ctx.ui.terminal.rows,
328
+ onExit: () => this.ctx.showDebugSelector(),
329
+ onStatus: message => this.ctx.showStatus(message, { dim: true }),
330
+ onUpdate: () => this.ctx.ui.requestRender(),
331
+ });
332
+
333
+ this.ctx.editorContainer.clear();
334
+ this.ctx.editorContainer.addChild(viewer);
335
+ this.ctx.ui.setFocus(viewer);
336
+ this.ctx.ui.requestRender();
337
+ }
338
+
318
339
  async #handleViewSystemInfo(): Promise<void> {
319
340
  try {
320
341
  const info = await collectSystemInfo();
@@ -0,0 +1,229 @@
1
+ import type { Model, ProviderResponseMetadata, RawSseEvent } from "@oh-my-pi/pi-ai";
2
+
3
+ const MAX_RAW_SSE_EVENTS = 1_000;
4
+ const MAX_RAW_SSE_CHARS = 512_000;
5
+ const MAX_RAW_SSE_EVENT_CHARS = 64_000;
6
+
7
+ export type RawSseDebugRecord =
8
+ | {
9
+ kind: "response";
10
+ sequence: number;
11
+ timestamp: number;
12
+ provider?: string;
13
+ model?: string;
14
+ api?: string;
15
+ status: number;
16
+ requestId?: string | null;
17
+ transport?: string;
18
+ }
19
+ | {
20
+ kind: "event";
21
+ sequence: number;
22
+ timestamp: number;
23
+ provider?: string;
24
+ model?: string;
25
+ api?: string;
26
+ event: string | null;
27
+ raw: string[];
28
+ truncated: boolean;
29
+ originalChars: number;
30
+ };
31
+
32
+ export interface RawSseDebugSnapshot {
33
+ records: readonly RawSseDebugRecord[];
34
+ droppedRecords: number;
35
+ droppedChars: number;
36
+ totalEvents: number;
37
+ lastUpdatedAt?: number;
38
+ }
39
+
40
+ function modelProvider(model: Model | undefined): string | undefined {
41
+ return model?.provider;
42
+ }
43
+
44
+ function modelId(model: Model | undefined): string | undefined {
45
+ return model?.id;
46
+ }
47
+
48
+ function modelApi(model: Model | undefined): string | undefined {
49
+ return model?.api;
50
+ }
51
+
52
+ function countRecordChars(record: RawSseDebugRecord): number {
53
+ if (record.kind === "response") return formatRawSseResponseComment(record).length + 1;
54
+ return record.raw.reduce((sum, line) => sum + line.length + 1, 1);
55
+ }
56
+
57
+ function trimRawLines(raw: string[]): { raw: string[]; truncated: boolean; originalChars: number } {
58
+ const originalChars = raw.reduce((sum, line) => sum + line.length + 1, 0);
59
+ if (originalChars <= MAX_RAW_SSE_EVENT_CHARS) {
60
+ return { raw: [...raw], truncated: false, originalChars };
61
+ }
62
+
63
+ const trimmed: string[] = [];
64
+ let remaining = MAX_RAW_SSE_EVENT_CHARS;
65
+ for (const line of raw) {
66
+ if (remaining <= 0) break;
67
+ if (line.length + 1 <= remaining) {
68
+ trimmed.push(line);
69
+ remaining -= line.length + 1;
70
+ continue;
71
+ }
72
+ trimmed.push(line.slice(0, Math.max(0, remaining)));
73
+ remaining = 0;
74
+ }
75
+ trimmed.push(`: omp-debug-truncated originalChars=${originalChars}`);
76
+ return { raw: trimmed, truncated: true, originalChars };
77
+ }
78
+
79
+ export function formatRawSseIsoTime(timestamp: number): string {
80
+ return new Date(timestamp).toISOString();
81
+ }
82
+
83
+ export function formatRawSseResponseComment(record: Extract<RawSseDebugRecord, { kind: "response" }>): string {
84
+ const fields = [
85
+ "omp-response",
86
+ `ts=${formatRawSseIsoTime(record.timestamp)}`,
87
+ `status=${record.status}`,
88
+ record.provider ? `provider=${record.provider}` : undefined,
89
+ record.model ? `model=${record.model}` : undefined,
90
+ record.api ? `api=${record.api}` : undefined,
91
+ record.requestId ? `requestId=${record.requestId}` : undefined,
92
+ record.transport ? `transport=${record.transport}` : undefined,
93
+ ].filter((field): field is string => field !== undefined);
94
+ return `: ${fields.join(" ")}`;
95
+ }
96
+
97
+ export function rawSseRecordLines(record: RawSseDebugRecord): string[] {
98
+ if (record.kind === "response") return [formatRawSseResponseComment(record)];
99
+ return record.raw;
100
+ }
101
+
102
+ function rawRecordText(record: RawSseDebugRecord): string {
103
+ return `${rawSseRecordLines(record).join("\n")}\n`;
104
+ }
105
+
106
+ function metadataTransport(response: ProviderResponseMetadata): string | undefined {
107
+ const value = response.metadata?.lastTransport;
108
+ return typeof value === "string" ? value : undefined;
109
+ }
110
+
111
+ export class RawSseDebugBuffer {
112
+ #records: RawSseDebugRecord[] = [];
113
+ #totalChars = 0;
114
+ #droppedRecords = 0;
115
+ #droppedChars = 0;
116
+ #totalEvents = 0;
117
+ #lastUpdatedAt: number | undefined;
118
+ #nextSequence = 1;
119
+ #listeners = new Set<() => void>();
120
+
121
+ subscribe(listener: () => void): () => void {
122
+ this.#listeners.add(listener);
123
+ return () => this.#listeners.delete(listener);
124
+ }
125
+
126
+ recordResponse(response: ProviderResponseMetadata, model?: Model): void {
127
+ this.#append({
128
+ kind: "response",
129
+ sequence: this.#nextSequence++,
130
+ timestamp: Date.now(),
131
+ provider: modelProvider(model),
132
+ model: modelId(model),
133
+ api: modelApi(model),
134
+ status: response.status,
135
+ requestId: response.requestId,
136
+ transport: metadataTransport(response),
137
+ });
138
+ }
139
+
140
+ recordEvent(event: RawSseEvent, model?: Model): void {
141
+ const trimmed = trimRawLines(event.raw);
142
+ this.#totalEvents += 1;
143
+ this.#append({
144
+ kind: "event",
145
+ sequence: this.#nextSequence++,
146
+ timestamp: Date.now(),
147
+ provider: modelProvider(model),
148
+ model: modelId(model),
149
+ api: modelApi(model),
150
+ event: event.event,
151
+ raw: trimmed.raw,
152
+ truncated: trimmed.truncated,
153
+ originalChars: trimmed.originalChars,
154
+ });
155
+ }
156
+
157
+ snapshot(): RawSseDebugSnapshot {
158
+ return {
159
+ records: [...this.#records],
160
+ droppedRecords: this.#droppedRecords,
161
+ droppedChars: this.#droppedChars,
162
+ totalEvents: this.#totalEvents,
163
+ lastUpdatedAt: this.#lastUpdatedAt,
164
+ };
165
+ }
166
+
167
+ toRawText(): string {
168
+ return this.#records.map(rawRecordText).join("\n");
169
+ }
170
+
171
+ #append(record: RawSseDebugRecord): void {
172
+ const chars = countRecordChars(record);
173
+ this.#records.push(record);
174
+ this.#totalChars += chars;
175
+ this.#lastUpdatedAt = record.timestamp;
176
+ this.#enforceLimits();
177
+ this.#emit();
178
+ }
179
+
180
+ #enforceLimits(): void {
181
+ while (this.#records.length > MAX_RAW_SSE_EVENTS || this.#totalChars > MAX_RAW_SSE_CHARS) {
182
+ const dropped = this.#records.shift();
183
+ if (!dropped) return;
184
+ const chars = countRecordChars(dropped);
185
+ this.#totalChars = Math.max(0, this.#totalChars - chars);
186
+ this.#droppedRecords += 1;
187
+ this.#droppedChars += chars;
188
+ }
189
+ }
190
+
191
+ #emit(): void {
192
+ for (const listener of this.#listeners) {
193
+ try {
194
+ listener();
195
+ } catch {
196
+ // Debug viewers must not be able to break stream capture.
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ const fallbackBuffers = new WeakMap<object, RawSseDebugBuffer>();
203
+ const globalFallbackBuffer = new RawSseDebugBuffer();
204
+
205
+ export function resolveRawSseDebugBuffer(owner?: object): RawSseDebugBuffer {
206
+ if (!owner) return globalFallbackBuffer;
207
+
208
+ const candidate = (owner as { rawSseDebugBuffer?: unknown }).rawSseDebugBuffer;
209
+ if (candidate instanceof RawSseDebugBuffer) return candidate;
210
+
211
+ const existing = fallbackBuffers.get(owner);
212
+ if (existing) return existing;
213
+
214
+ const buffer = new RawSseDebugBuffer();
215
+ fallbackBuffers.set(owner, buffer);
216
+ if (Object.isExtensible(owner)) {
217
+ try {
218
+ Object.defineProperty(owner, "rawSseDebugBuffer", {
219
+ value: buffer,
220
+ configurable: true,
221
+ enumerable: false,
222
+ writable: true,
223
+ });
224
+ } catch {
225
+ // The WeakMap fallback remains usable if the session object rejects extension.
226
+ }
227
+ }
228
+ return buffer;
229
+ }