@tt-a1i/openpi 0.4.0 → 0.5.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 (109) hide show
  1. package/README.md +102 -40
  2. package/SETUP.md +22 -6
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/background-terminals/index.ts +30 -2
  6. package/extensions/background-terminals/src/domain.ts +2 -0
  7. package/extensions/background-terminals/src/manager.ts +486 -106
  8. package/extensions/background-terminals/src/output.ts +33 -0
  9. package/extensions/background-terminals/src/prompt.ts +13 -5
  10. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  11. package/extensions/clear-context/index.ts +83 -0
  12. package/extensions/context-pivot/index.ts +16 -6
  13. package/extensions/cron/schedule.ts +7 -1
  14. package/extensions/file-mutation-display/render.ts +17 -257
  15. package/extensions/file-search/src/binaries.ts +57 -41
  16. package/extensions/git-read/index.ts +1 -3
  17. package/extensions/model-info/index.ts +21 -33
  18. package/extensions/model-info/session-metrics.ts +96 -0
  19. package/extensions/plan-mode/bash-policy.ts +54 -9
  20. package/extensions/plan-mode/index.ts +7 -2
  21. package/extensions/post-edit/index.ts +16 -6
  22. package/extensions/sessions/git-stats.ts +258 -72
  23. package/extensions/sessions/index.ts +153 -86
  24. package/extensions/sessions/preview-cache.ts +104 -0
  25. package/extensions/sessions/preview-loader.ts +856 -0
  26. package/extensions/sessions/sessions.ts +43 -4
  27. package/extensions/setup/index.ts +123 -127
  28. package/extensions/shared/activity-status.ts +30 -0
  29. package/extensions/shared/agent-session-page.ts +319 -0
  30. package/extensions/shared/agent-tool-renderer.ts +218 -0
  31. package/extensions/shared/agent-transcript.ts +524 -0
  32. package/extensions/shared/capability-intent.ts +1 -1
  33. package/extensions/shared/child-session.ts +437 -21
  34. package/extensions/shared/result-delivery.ts +34 -0
  35. package/extensions/shared/setup-config.ts +73 -33
  36. package/extensions/shared/setup-episode-state.ts +1 -1
  37. package/extensions/shared/terminal-text.ts +110 -23
  38. package/extensions/shared/text-projection.ts +72 -15
  39. package/extensions/shared/tool-activity.ts +382 -0
  40. package/extensions/shared/tool-surface.ts +29 -2
  41. package/extensions/shared/transcript-viewport.ts +46 -0
  42. package/extensions/shared/web-observer-registry.ts +390 -0
  43. package/extensions/shared/worktree.ts +11 -0
  44. package/extensions/subagents/index.ts +270 -59
  45. package/extensions/subagents/navigation.ts +34 -5
  46. package/extensions/subagents/src/backend.ts +12 -1
  47. package/extensions/subagents/src/backends/pi.ts +375 -66
  48. package/extensions/subagents/src/domain.ts +5 -0
  49. package/extensions/subagents/src/manager.ts +34 -2
  50. package/extensions/subagents/src/prompt.ts +32 -4
  51. package/extensions/subagents/src/result-artifact.ts +4 -0
  52. package/extensions/subagents/src/result-delivery.ts +7 -1
  53. package/extensions/subagents/src/runtime.ts +15 -1
  54. package/extensions/subagents/src/ui/takeover.ts +73 -257
  55. package/extensions/subagents/src/ui/transcript.ts +38 -535
  56. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  57. package/extensions/suggestions/src/ui.ts +10 -4
  58. package/extensions/tasks/index.ts +0 -3
  59. package/extensions/ui-customization/footer.ts +0 -40
  60. package/extensions/ui-customization/index.ts +0 -4
  61. package/extensions/user-input-fold/index.ts +1 -1
  62. package/extensions/web/index.ts +234 -0
  63. package/extensions/workflows/artifacts.ts +137 -47
  64. package/extensions/workflows/completion-projection.ts +457 -0
  65. package/extensions/workflows/coordinator.ts +8 -10
  66. package/extensions/workflows/dashboard.ts +167 -228
  67. package/extensions/workflows/handoff.ts +70 -16
  68. package/extensions/workflows/index.ts +488 -198
  69. package/extensions/workflows/journal.ts +148 -13
  70. package/extensions/workflows/model.ts +74 -4
  71. package/extensions/workflows/navigation.ts +32 -8
  72. package/extensions/workflows/progress-projection.ts +306 -0
  73. package/extensions/workflows/prompt.ts +66 -6
  74. package/extensions/workflows/replay-safety.ts +42 -21
  75. package/extensions/workflows/result-delivery.ts +128 -64
  76. package/extensions/workflows/retention.ts +593 -0
  77. package/extensions/workflows/runner.ts +388 -279
  78. package/extensions/workflows/sandbox-child.cjs +25 -3
  79. package/extensions/workflows/sandbox.ts +62 -8
  80. package/extensions/workflows/serialization.ts +325 -17
  81. package/extensions/workflows/tool-renderer.ts +22 -0
  82. package/extensions/workflows/transcript.ts +149 -0
  83. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  84. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  85. package/package.json +28 -8
  86. package/skills/subagents/REFERENCE.md +189 -0
  87. package/skills/subagents/SKILL.md +1 -1
  88. package/skills/workflows/REFERENCE.md +4 -2
  89. package/web/adapter/pi-adapter.ts +661 -0
  90. package/web/host/browser-launcher.ts +20 -0
  91. package/web/host/static-assets.ts +4 -0
  92. package/web/host/terminal-status.ts +38 -0
  93. package/web/host/web-host.ts +789 -0
  94. package/web/http-dispatcher.ts +125 -0
  95. package/web/protocol/types.ts +462 -0
  96. package/web/runtime/pi-runtime.ts +991 -0
  97. package/web/runtime/types.ts +71 -0
  98. package/web/runtime/web-host-lease.ts +497 -0
  99. package/web/trace.ts +18 -0
  100. package/web/ui/app.js +1398 -0
  101. package/web/ui/index.html +139 -0
  102. package/web/ui/styles.css +598 -0
  103. package/web/vite.config.mjs +34 -0
  104. package/extensions/execution-convergence/active-evidence.ts +0 -129
  105. package/extensions/execution-convergence/index.ts +0 -442
  106. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  107. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  108. package/extensions/setup/intercom.ts +0 -603
  109. package/extensions/subagents/src/backends/stub.ts +0 -303
@@ -72,6 +72,22 @@ export const DEFAULT_FOOTER_LINES: FooterLines = [
72
72
  ["model", "context", "flex", "git", "pr", "cwd"],
73
73
  ];
74
74
 
75
+ /** Stable skeleton for the legacy flat-selection adapter. */
76
+ const FLAT_FOOTER_ITEM_LINES: FooterLines = [
77
+ [
78
+ "model",
79
+ "thinking",
80
+ "context",
81
+ "cache",
82
+ "cost",
83
+ "throughput",
84
+ "flex",
85
+ "git",
86
+ "pr",
87
+ "cwd",
88
+ ],
89
+ ];
90
+
75
91
  /** The persisted canonical default before model/context moved to the left. */
76
92
  const LEGACY_DEFAULT_FOOTER_LINES: FooterLines = [
77
93
  ["cwd", "git", "pr", "flex", "model", "context"],
@@ -136,9 +152,7 @@ export interface MyPiSetupConfig {
136
152
  readonly showHeader: boolean;
137
153
  readonly customFooter: boolean;
138
154
  readonly footerStyle: FooterStyle;
139
- /** Canonical footer layout. `footerItems` is always derived from this. */
140
155
  readonly footerLines: FooterLines;
141
- readonly footerItems: readonly FooterItem[];
142
156
  readonly subagentResultDisplay: DetailDisplay;
143
157
  readonly bashToolDisplay: DetailDisplay;
144
158
  readonly fileMutationDisplay: DetailDisplay;
@@ -169,8 +183,7 @@ export const DEFAULT_SETUP_CONFIG: MyPiSetupConfig = {
169
183
  customFooter: true,
170
184
  footerStyle: DEFAULT_FOOTER_STYLE,
171
185
  footerLines: DEFAULT_FOOTER_LINES,
172
- footerItems: DEFAULT_FOOTER_ITEMS,
173
- subagentResultDisplay: "full",
186
+ subagentResultDisplay: "compact",
174
187
  bashToolDisplay: "compact",
175
188
  fileMutationDisplay: "compact",
176
189
  },
@@ -261,13 +274,13 @@ export function normalizeFooterLines(value: unknown): FooterLines {
261
274
  return lines.length > 0 ? lines : DEFAULT_FOOTER_LINES;
262
275
  }
263
276
 
264
- /** Map a flat item list onto the default one-line skeleton (legacy compat). */
277
+ /** Map a flat item list onto the stable one-line skeleton (legacy compat). */
265
278
  export function footerLinesFromItems(
266
279
  items: readonly FooterItem[],
267
280
  ): FooterLines {
268
281
  const selected = new Set(items);
269
282
  return normalizeFooterLines(
270
- DEFAULT_FOOTER_LINES.map((line) =>
283
+ FLAT_FOOTER_ITEM_LINES.map((line) =>
271
284
  line.filter((item) => item === "flex" || selected.has(item)),
272
285
  ),
273
286
  );
@@ -335,7 +348,6 @@ export function applyFooterConfig(
335
348
  return {
336
349
  footerStyle: style,
337
350
  footerLines: normalized,
338
- footerItems: flattenFooterItems(normalized),
339
351
  };
340
352
  }
341
353
 
@@ -358,7 +370,7 @@ function sameFooterLines(left: FooterLines, right: FooterLines) {
358
370
 
359
371
  function parseUiFooter(
360
372
  ui: Record<string, unknown>,
361
- ): Pick<MyPiSetupConfig["ui"], "footerStyle" | "footerLines" | "footerItems"> {
373
+ ): Pick<MyPiSetupConfig["ui"], "footerStyle" | "footerLines"> {
362
374
  const style = isFooterStyle(ui.footerStyle)
363
375
  ? ui.footerStyle
364
376
  : DEFAULT_FOOTER_STYLE;
@@ -371,7 +383,6 @@ function parseUiFooter(
371
383
  return {
372
384
  footerStyle: style,
373
385
  footerLines: lines,
374
- footerItems: flattenFooterItems(lines),
375
386
  };
376
387
  }
377
388
 
@@ -382,14 +393,12 @@ function parseUiFooter(
382
393
  return {
383
394
  footerStyle: style,
384
395
  footerLines: lines,
385
- footerItems: flattenFooterItems(lines),
386
396
  };
387
397
  }
388
398
 
389
399
  return {
390
400
  footerStyle: style,
391
401
  footerLines: DEFAULT_FOOTER_LINES,
392
- footerItems: DEFAULT_FOOTER_ITEMS,
393
402
  };
394
403
  }
395
404
 
@@ -484,7 +493,7 @@ export function parseSetupConfig(value: unknown): MyPiSetupConfig {
484
493
  ui.subagentResultDisplay as DetailDisplay,
485
494
  )
486
495
  ? (ui.subagentResultDisplay as DetailDisplay)
487
- : "full",
496
+ : "compact",
488
497
  bashToolDisplay: DETAIL_DISPLAYS.includes(
489
498
  ui.bashToolDisplay as DetailDisplay,
490
499
  )
@@ -504,9 +513,9 @@ export function parseSetupConfig(value: unknown): MyPiSetupConfig {
504
513
  /** An empty or invalid value disables the post-edit command (the safe default). */
505
514
  function parsePostEditCommand(value: unknown) {
506
515
  if (!isRecord(value)) return "";
507
- return typeof value.command === "string"
508
- ? value.command.trim().slice(0, POST_EDIT_COMMAND_MAX_CHARS)
509
- : "";
516
+ if (typeof value.command !== "string") return "";
517
+ const command = value.command.trim();
518
+ return command.length <= POST_EDIT_COMMAND_MAX_CHARS ? command : "";
510
519
  }
511
520
 
512
521
  export function hasSavedSetupConfig() {
@@ -561,6 +570,9 @@ function replacedFields(
561
570
  ) {
562
571
  paths.push("summaries → suggestions");
563
572
  }
573
+ if (path === "ui" && "footerItems" in raw && "footerLines" in normalized) {
574
+ paths.push("ui.footerItems");
575
+ }
564
576
  for (const [key, value] of Object.entries(normalized)) {
565
577
  if (!(key in raw)) continue;
566
578
  const here = path ? `${path}.${key}` : key;
@@ -627,24 +639,55 @@ const sameOwner = (left: LockOwner, right: LockOwner) =>
627
639
  const sameFile = (left: Stats, right: Stats) =>
628
640
  left.dev === right.dev && left.ino === right.ino;
629
641
 
642
+ const parsePowerShellProcessStartedAt = (stdout: string) => {
643
+ const startedAt = Number(stdout.trim());
644
+ return isPositiveInteger(startedAt) ? startedAt : undefined;
645
+ };
646
+
647
+ const parsePsProcessStartedAt = (stdout: string) => {
648
+ const startedAt = Date.parse(stdout.trim());
649
+ return Number.isFinite(startedAt) ? startedAt : undefined;
650
+ };
651
+
652
+ export function processStartedAtQuery(
653
+ pid: number,
654
+ platform = process.platform,
655
+ ) {
656
+ if (platform === "win32") {
657
+ const script = [
658
+ "$ErrorActionPreference='Stop'",
659
+ `$p=Get-Process -Id ${pid}`,
660
+ "[Console]::Out.Write(([DateTimeOffset]$p.StartTime).ToUnixTimeMilliseconds())",
661
+ ].join("; ");
662
+ return {
663
+ command: "powershell.exe",
664
+ args: ["-NoProfile", "-NonInteractive", "-Command", script],
665
+ env: undefined,
666
+ parseOutput: parsePowerShellProcessStartedAt,
667
+ };
668
+ }
669
+ return {
670
+ command: "ps",
671
+ args: ["-o", "lstart=", "-p", String(pid)],
672
+ env: { LC_ALL: "C" },
673
+ parseOutput: parsePsProcessStartedAt,
674
+ };
675
+ }
676
+
630
677
  const queryProcessStartedAt = (pid: number) =>
631
678
  new Promise<number | undefined>((resolve) => {
632
- if (process.platform === "win32") {
633
- resolve(undefined);
634
- return;
635
- }
679
+ const query = processStartedAtQuery(pid);
636
680
  try {
637
681
  execFile(
638
- "ps",
639
- ["-o", "lstart=", "-p", String(pid)],
682
+ query.command,
683
+ query.args,
640
684
  {
641
685
  encoding: "utf8",
642
- env: { ...process.env, LC_ALL: "C" },
686
+ env: query.env ? { ...process.env, ...query.env } : process.env,
643
687
  timeout: 1_000,
644
688
  },
645
689
  (error, stdout) => {
646
- const startedAt = error ? Number.NaN : Date.parse(stdout.trim());
647
- resolve(Number.isFinite(startedAt) ? startedAt : undefined);
690
+ resolve(error ? undefined : query.parseOutput(stdout));
648
691
  },
649
692
  );
650
693
  } catch {
@@ -938,13 +981,15 @@ async function withSetupConfigLock<A>(action: () => Promise<A>) {
938
981
  }
939
982
 
940
983
  async function writeSetupConfig(config: MyPiSetupConfig) {
984
+ const canonical = parseSetupConfig(config);
941
985
  const tempPath = `${SETUP_CONFIG_PATH}.${process.pid}.${randomUUID()}.tmp`;
942
986
  try {
943
- await writeFile(tempPath, `${JSON.stringify(config, null, 2)}\n`, {
987
+ await writeFile(tempPath, `${JSON.stringify(canonical, null, 2)}\n`, {
944
988
  encoding: "utf8",
945
989
  mode: 0o600,
946
990
  });
947
991
  await rename(tempPath, SETUP_CONFIG_PATH);
992
+ return canonical;
948
993
  } catch (error) {
949
994
  await unlink(tempPath).catch(() => undefined);
950
995
  throw error;
@@ -958,10 +1003,7 @@ export async function saveSetupConfig(config: MyPiSetupConfig) {
958
1003
  });
959
1004
  }
960
1005
 
961
- export function formatSetupConfig(
962
- config = loadSetupConfig(),
963
- integrationLines: readonly string[] = [],
964
- ) {
1006
+ export function formatSetupConfig(config = loadSetupConfig()) {
965
1007
  const suggestionModel = config.suggestions.model;
966
1008
  const suggestions =
967
1009
  !config.suggestions.enabled || !suggestionModel
@@ -978,9 +1020,8 @@ export function formatSetupConfig(
978
1020
  `Subagent results: ${config.ui.subagentResultDisplay === "full" ? "full by default" : "compact status summary (Ctrl+O expands full output)"}`,
979
1021
  `Bash operations: ${config.ui.bashToolDisplay === "full" ? "expanded by default" : "one-line activity summary (Ctrl+O restores native evidence)"}`,
980
1022
  `Write/Edit operations: ${config.ui.fileMutationDisplay === "full" ? "expanded by default" : "one-line activity summary (Ctrl+O restores native evidence)"}`,
981
- `Post-edit command: ${config.postEdit.command ? config.postEdit.command : "off"}`,
1023
+ `Post-edit command: ${config.postEdit.command ? "configured" : "off"}`,
982
1024
  `Agent role models (Subagents + Workflows): ${SUBAGENT_ROLE_NAMES.map((role) => `${role} ${config.subagents.roleModels[role] ? `${config.subagents.roleModels[role].provider}/${config.subagents.roleModels[role].model}` : "inherit"}`).join(" · ")}`,
983
- ...integrationLines,
984
1025
  ].join("\n");
985
1026
  }
986
1027
 
@@ -998,8 +1039,7 @@ export async function updateSetupConfig(
998
1039
  return withSetupConfigLock(async () => {
999
1040
  const raw = readDocumentForWrite();
1000
1041
  const current = parseSetupConfig(raw);
1001
- const config = mutate(current);
1002
- await writeSetupConfig(config);
1042
+ const config = await writeSetupConfig(mutate(current));
1003
1043
  return { config, replaced: replacedFields(raw, current) };
1004
1044
  });
1005
1045
  }
@@ -2,6 +2,6 @@
2
2
  export const OPENPI_SETUP_EPISODE_CHANNEL = "openpi:setup-episode";
3
3
 
4
4
  export interface OpenPiSetupEpisodeState {
5
- /** True for both armed and actively running setup episodes. */
5
+ /** True only while the owned setup writer is active for a delivered request. */
6
6
  readonly active: boolean;
7
7
  }
@@ -1,35 +1,122 @@
1
- // OSC may end in BEL or ST. DCS/SOS/PM/APC end only in ST; treating BEL as
2
- // their terminator would expose the rest of a still-hidden payload. Both
3
- // patterns consume an unterminated string through the bounded input's end.
4
- // eslint-disable-next-line no-control-regex
5
- const OSC_PATTERN =
6
- /(?:\u001b\]|\u009d)(?:[^\u0007\u001b\u009c]|\u001b(?!\\))*(?:\u0007|\u001b\\|\u009c|$)/g;
7
- // eslint-disable-next-line no-control-regex
8
- const ST_STRING_PATTERN =
9
- /(?:\u001b[PX^_]|[\u0090\u0098\u009e\u009f])(?:[^\u001b\u009c]|\u001b(?!\\))*(?:\u001b\\|\u009c|$)/g;
10
- // eslint-disable-next-line no-control-regex
11
- const CSI_PATTERN = /(?:\u001b\[|\u009b)[0-?]*[ -/]*[@-~]/g;
12
- // Remaining two-byte/charset escape forms (for example ESC ( 0).
13
- // eslint-disable-next-line no-control-regex
14
- const ESCAPE_PATTERN = /\u001b(?:[()][0-2A-Z]|[ -/]*[@-~])/g;
15
1
  // Invisible Unicode format controls can reorder or disguise untrusted text.
16
2
  // Keep only ZWNJ/ZWJ (U+200C/U+200D), which carry legitimate shaping and emoji
17
3
  // semantics; variation selectors are marks rather than Cf and remain intact.
18
- const UNSAFE_FORMAT_PATTERN = /(?![\u200c\u200d])\p{Cf}/gu;
4
+ const UNSAFE_FORMAT_CHARACTER = /\p{Cf}/u;
19
5
  // eslint-disable-next-line no-control-regex
20
6
  const TERMINAL_CONTROL_PATTERN =
21
7
  /[\u0000-\u001f\u007f-\u009f]|(?:(?![\u200c\u200d])\p{Cf})/u;
22
8
 
9
+ type SanitizerState =
10
+ | "text"
11
+ | "escape"
12
+ | "escape_intermediate"
13
+ | "csi"
14
+ | "osc"
15
+ | "st_string";
16
+
17
+ /** Stateful terminal-text projection for streams whose retained head can move. */
18
+ export class TerminalTextSanitizer {
19
+ private state: SanitizerState = "text";
20
+ private stringEscape = false;
21
+
22
+ push(text: string) {
23
+ let safe = "";
24
+ for (const character of text) {
25
+ const code = character.codePointAt(0)!;
26
+
27
+ if (this.state === "osc" || this.state === "st_string") {
28
+ if (character === "\u009c") {
29
+ this.state = "text";
30
+ this.stringEscape = false;
31
+ } else if (this.state === "osc" && character === "\u0007") {
32
+ this.state = "text";
33
+ this.stringEscape = false;
34
+ } else if (this.stringEscape && character === "\\") {
35
+ this.state = "text";
36
+ this.stringEscape = false;
37
+ } else {
38
+ this.stringEscape = character === "\u001b";
39
+ }
40
+ continue;
41
+ }
42
+
43
+ if (this.state === "csi") {
44
+ if (code >= 0x40 && code <= 0x7e) this.state = "text";
45
+ else if (code < 0x20 || code > 0x3f) {
46
+ this.state = "text";
47
+ safe += this.safeCharacter(character, code);
48
+ }
49
+ continue;
50
+ }
51
+
52
+ if (this.state === "escape" || this.state === "escape_intermediate") {
53
+ if (this.state === "escape") {
54
+ if (character === "[") {
55
+ this.state = "csi";
56
+ continue;
57
+ }
58
+ if (character === "]") {
59
+ this.state = "osc";
60
+ continue;
61
+ }
62
+ if (
63
+ character === "P" ||
64
+ character === "X" ||
65
+ character === "^" ||
66
+ character === "_"
67
+ ) {
68
+ this.state = "st_string";
69
+ continue;
70
+ }
71
+ }
72
+ if (code >= 0x20 && code <= 0x2f) {
73
+ this.state = "escape_intermediate";
74
+ } else if (code >= 0x30 && code <= 0x7e) {
75
+ this.state = "text";
76
+ } else {
77
+ this.state = "text";
78
+ safe += this.safeCharacter(character, code);
79
+ }
80
+ continue;
81
+ }
82
+
83
+ if (character === "\u001b") {
84
+ this.state = "escape";
85
+ } else if (character === "\u009b") {
86
+ this.state = "csi";
87
+ } else if (character === "\u009d") {
88
+ this.state = "osc";
89
+ } else if (
90
+ character === "\u0090" ||
91
+ character === "\u0098" ||
92
+ character === "\u009e" ||
93
+ character === "\u009f"
94
+ ) {
95
+ this.state = "st_string";
96
+ } else {
97
+ safe += this.safeCharacter(character, code);
98
+ }
99
+ }
100
+ return safe;
101
+ }
102
+
103
+ private safeCharacter(character: string, code: number) {
104
+ if (character === "\t") return " ";
105
+ if (
106
+ character === "\n" ||
107
+ character === "\u200c" ||
108
+ character === "\u200d"
109
+ ) {
110
+ return character;
111
+ }
112
+ if (code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return "";
113
+ return UNSAFE_FORMAT_CHARACTER.test(character) ? "" : character;
114
+ }
115
+ }
116
+
23
117
  /** Strip terminal control sequences and direction-spoofing format controls. */
24
118
  export function sanitizeTerminalText(text: string) {
25
- return text
26
- .replace(OSC_PATTERN, "")
27
- .replace(ST_STRING_PATTERN, "")
28
- .replace(CSI_PATTERN, "")
29
- .replace(ESCAPE_PATTERN, "")
30
- .replace(UNSAFE_FORMAT_PATTERN, "")
31
- .replaceAll("\t", " ")
32
- .replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, "");
119
+ return new TerminalTextSanitizer().push(text);
33
120
  }
34
121
 
35
122
  /** Reject raw terminal or direction-spoofing controls in display patterns. */
@@ -1,6 +1,13 @@
1
- import { formatSize, truncateHead } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ formatSize,
3
+ truncateHead,
4
+ truncateTail,
5
+ } from "@earendil-works/pi-coding-agent";
2
6
 
3
7
  const HEAD_SHARE = 0.75;
8
+ const OMISSION_MARKER = "[... middle omitted ...]";
9
+ // Three framing blanks, the omission marker, and the footer.
10
+ const STATIC_PROJECTION_OVERHEAD_LINES = 5;
4
11
 
5
12
  function utf8Prefix(content: string, maxBytes: number) {
6
13
  const bytes = Buffer.from(content, "utf8");
@@ -14,16 +21,46 @@ function utf8Prefix(content: string, maxBytes: number) {
14
21
  return "";
15
22
  }
16
23
 
17
- function utf8Suffix(content: string, maxBytes: number) {
18
- const bytes = Buffer.from(content, "utf8");
19
- if (bytes.length <= maxBytes) return content;
20
- let start = Math.max(0, bytes.length - maxBytes);
21
- while (start < bytes.length) {
22
- const value = bytes.subarray(start).toString("utf8");
23
- if (!value.startsWith("�")) return value;
24
- start += 1;
25
- }
26
- return "";
24
+ function boundedHead(content: string, maxBytes: number, maxLines: number) {
25
+ const result = truncateHead(content, { maxBytes, maxLines });
26
+ if (result.content || !result.firstLineExceedsLimit) return result.content;
27
+ return utf8Prefix(content, maxBytes);
28
+ }
29
+
30
+ function compactProjection(
31
+ content: string,
32
+ maxBytes: number,
33
+ maxLines: number,
34
+ ) {
35
+ const marker =
36
+ maxBytes >= OMISSION_MARKER.length
37
+ ? OMISSION_MARKER
38
+ : ".".repeat(Math.min(3, maxBytes));
39
+ if (!marker || maxLines <= 0) return "";
40
+ if (maxLines === 1) return marker;
41
+
42
+ const bodyLineBudget = maxLines - 1;
43
+ const keepTail = bodyLineBudget >= 2;
44
+ const separatorBytes = keepTail ? 2 : 1;
45
+ const bodyByteBudget = maxBytes - marker.length - separatorBytes;
46
+ if (bodyByteBudget <= 0 || (keepTail && bodyByteBudget < 2)) return marker;
47
+
48
+ const headLines = keepTail
49
+ ? Math.max(1, Math.floor(bodyLineBudget * HEAD_SHARE))
50
+ : bodyLineBudget;
51
+ const tailLines = keepTail ? Math.max(1, bodyLineBudget - headLines) : 0;
52
+ const headBytes = keepTail
53
+ ? Math.max(1, Math.floor(bodyByteBudget * HEAD_SHARE))
54
+ : bodyByteBudget;
55
+ const tailBytes = keepTail ? Math.max(1, bodyByteBudget - headBytes) : 0;
56
+ const head = boundedHead(content, headBytes, headLines);
57
+ const tail = keepTail
58
+ ? truncateTail(content, {
59
+ maxBytes: tailBytes,
60
+ maxLines: tailLines,
61
+ }).content
62
+ : "";
63
+ return [head, marker, tail].filter(Boolean).join("\n");
27
64
  }
28
65
 
29
66
  /** Keep decision context at the start and verdict/evidence at the end. */
@@ -31,26 +68,46 @@ export function projectText(
31
68
  content: string,
32
69
  options: { maxBytes: number; maxLines: number; recovery: string },
33
70
  ) {
71
+ if (options.maxBytes <= 0 || options.maxLines <= 0) return "";
72
+
34
73
  const probe = truncateHead(content, {
35
74
  maxBytes: options.maxBytes,
36
75
  maxLines: options.maxLines,
37
76
  });
38
77
  if (!probe.truncated) return content;
39
78
 
79
+ const recoveryNewlines = options.recovery.split("\n").length - 1;
80
+ const bodyLineBudget =
81
+ options.maxLines - STATIC_PROJECTION_OVERHEAD_LINES - recoveryNewlines;
82
+ if (bodyLineBudget < 2) {
83
+ return compactProjection(content, options.maxBytes, options.maxLines);
84
+ }
85
+ const headLines = Math.max(1, Math.floor(bodyLineBudget * HEAD_SHARE));
86
+ const tailLines = Math.max(1, bodyLineBudget - headLines);
87
+
40
88
  let bodyBudget = options.maxBytes;
41
89
  let projected = "";
42
90
  for (let attempt = 0; attempt < 8; attempt++) {
43
91
  const headBytes = Math.max(1, Math.floor(bodyBudget * HEAD_SHARE));
44
92
  const tailBytes = Math.max(1, bodyBudget - headBytes);
45
- const head = utf8Prefix(content, headBytes);
46
- const tail = utf8Suffix(content, tailBytes);
93
+ const head = boundedHead(content, headBytes, headLines);
94
+ const tail = truncateTail(content, {
95
+ maxBytes: tailBytes,
96
+ maxLines: tailLines,
97
+ }).content;
47
98
  const shown =
48
99
  Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
49
100
  const footer = `[Projection bounded: showing ${formatSize(shown)} of ${formatSize(probe.totalBytes)} across the head and tail. ${options.recovery}]`;
50
- projected = `${head}\n\n[... middle omitted ...]\n\n${tail}\n\n${footer}`;
101
+ projected = `${head}\n\n${OMISSION_MARKER}\n\n${tail}\n\n${footer}`;
51
102
  const overflow = Buffer.byteLength(projected, "utf8") - options.maxBytes;
52
103
  if (overflow <= 0 || bodyBudget <= overflow + 2) break;
53
104
  bodyBudget -= overflow;
54
105
  }
55
- return projected;
106
+ const projectedProbe = truncateHead(projected, {
107
+ maxBytes: options.maxBytes,
108
+ maxLines: options.maxLines,
109
+ });
110
+ return projectedProbe.truncated
111
+ ? compactProjection(content, options.maxBytes, options.maxLines)
112
+ : projected;
56
113
  }