tau-coding-agent 0.1.5 → 0.2.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 (86) hide show
  1. package/README.md +15 -12
  2. package/extensions/answer.ts +129 -73
  3. package/extensions/branch-term/README.md +7 -0
  4. package/extensions/{branch-term.ts → branch-term/index.ts} +113 -104
  5. package/extensions/btw.ts +90 -4
  6. package/extensions/caffeinate/README.md +5 -0
  7. package/extensions/caffeinate/index.ts +144 -0
  8. package/extensions/fast.ts +292 -0
  9. package/extensions/ghostty.ts +214 -211
  10. package/extensions/git-diff-stats.ts +124 -84
  11. package/extensions/git-pr-status.ts +274 -208
  12. package/extensions/insights.ts +138 -183
  13. package/extensions/loop.ts +150 -135
  14. package/extensions/memory.ts +172 -97
  15. package/extensions/notify.ts +14 -26
  16. package/extensions/openai-verbosity.ts +108 -42
  17. package/extensions/review/fix.ts +15 -6
  18. package/extensions/review/git.ts +93 -103
  19. package/extensions/review/index.ts +60 -62
  20. package/extensions/review/interrupt.ts +117 -0
  21. package/extensions/review/message-queue.ts +45 -12
  22. package/extensions/review/models.ts +79 -3
  23. package/extensions/review/prompts.ts +43 -40
  24. package/extensions/review/review.ts +161 -166
  25. package/extensions/review/runner.ts +139 -147
  26. package/extensions/review/runtime.ts +173 -120
  27. package/extensions/review/submit-review-tool.ts +1 -5
  28. package/extensions/review/submit-triage-tool.ts +1 -0
  29. package/extensions/review/triage.ts +41 -44
  30. package/extensions/sandbox/bash.ts +775 -0
  31. package/extensions/sandbox/command.ts +605 -0
  32. package/extensions/sandbox/config.ts +764 -0
  33. package/extensions/sandbox/index.ts +138 -2850
  34. package/extensions/sandbox/macos-sandbox-shell.mjs +202 -0
  35. package/extensions/sandbox/permissions/dialog.ts +118 -0
  36. package/extensions/sandbox/permissions/filesystem.ts +559 -0
  37. package/extensions/sandbox/permissions/mach-lookup.ts +186 -0
  38. package/extensions/sandbox/permissions/network.ts +164 -0
  39. package/extensions/sandbox/permissions/unsandboxed.ts +270 -0
  40. package/extensions/sandbox/runtime.ts +616 -0
  41. package/extensions/stash.ts +28 -15
  42. package/extensions/subagent/README.md +73 -0
  43. package/extensions/subagent/index.ts +822 -0
  44. package/extensions/subagent/interrupt.ts +117 -0
  45. package/extensions/subagent/permissions.ts +101 -0
  46. package/extensions/subagent/rpc.ts +177 -0
  47. package/extensions/tool-display-mode.ts +267 -64
  48. package/extensions/usage/anthropic.ts +5 -6
  49. package/extensions/usage/github-copilot.ts +30 -9
  50. package/extensions/usage/index.ts +250 -357
  51. package/extensions/usage/openai-codex.ts +2 -2
  52. package/extensions/usage/openrouter.ts +10 -2
  53. package/extensions/websearch/README.md +12 -47
  54. package/extensions/websearch/config.ts +5 -2
  55. package/extensions/websearch/index.ts +94 -109
  56. package/extensions/websearch/output.ts +51 -0
  57. package/extensions/websearch/providers/anthropic.pi.ts +27 -44
  58. package/extensions/websearch/providers/gemini.browser.ts +68 -77
  59. package/extensions/websearch/providers/gemini.pi.ts +17 -17
  60. package/extensions/websearch/providers/openai-codex.pi.ts +145 -7
  61. package/extensions/websearch/providers/pi-model.shared.ts +44 -32
  62. package/extensions/websearch/providers/shared.ts +87 -2
  63. package/extensions/websearch/types.ts +0 -3
  64. package/extensions/worktree.ts +132 -172
  65. package/package.json +10 -5
  66. package/skills/browser-tools/SKILL.md +29 -234
  67. package/skills/browser-tools/references/cookies.md +36 -0
  68. package/skills/browser-tools/references/interaction.md +90 -0
  69. package/skills/browser-tools/references/logging.md +34 -0
  70. package/skills/git-clean-history/SKILL.md +6 -6
  71. package/skills/git-commit/SKILL.md +5 -3
  72. package/skills/github-pull-request/SKILL.md +60 -0
  73. package/skills/github-pull-request/references/create.md +40 -0
  74. package/skills/github-pull-request/references/stewardship.md +60 -0
  75. package/skills/oracle/SKILL.md +4 -4
  76. package/skills/oracle/scripts/oracle +64 -51
  77. package/skills/sentry/SKILL.md +15 -185
  78. package/skills/sentry/references/events.md +79 -0
  79. package/skills/sentry/references/issues.md +62 -0
  80. package/skills/sentry/references/logs.md +46 -0
  81. package/skills/update-changelog/SKILL.md +27 -121
  82. package/skills/web-design/SKILL.md +16 -105
  83. package/themes/tau-dark.json +4 -0
  84. package/extensions/openai-fast.ts +0 -229
  85. package/extensions/websearch/providers/openai-codex.browser.ts +0 -77
  86. package/extensions/websearch/providers/openai-codex.shared.ts +0 -123
@@ -1,4 +1,3 @@
1
- import { complete } from "@earendil-works/pi-ai/compat";
2
1
  import type { Api, Model, UserMessage } from "@earendil-works/pi-ai";
3
2
  import {
4
3
  BorderedLoader,
@@ -7,12 +6,10 @@ import {
7
6
  getAgentDir,
8
7
  getLanguageFromPath,
9
8
  getMarkdownTheme,
10
- migrateSessionEntries,
11
9
  parseSessionEntries,
12
10
  type ExtensionAPI,
13
11
  type ExtensionCommandContext,
14
12
  type SessionEntry,
15
- type SessionHeader,
16
13
  type Theme,
17
14
  } from "@earendil-works/pi-coding-agent";
18
15
  import {
@@ -27,14 +24,15 @@ import {
27
24
  } from "@earendil-works/pi-tui";
28
25
  import { createHash, randomBytes } from "node:crypto";
29
26
  import fs from "node:fs/promises";
27
+ import os from "node:os";
30
28
  import path from "node:path";
31
29
 
32
30
  // --- Constants ---
33
31
 
34
- const INSIGHTS_META_SCHEMA_VERSION = 1;
35
- const SESSION_FINGERPRINT_VERSION = 1;
32
+ const INSIGHTS_META_SCHEMA_VERSION = 2;
33
+ const SESSION_FINGERPRINT_VERSION = 2;
36
34
  const INSIGHTS_FACET_SCHEMA_VERSION = 1;
37
- const FACET_PROMPT_VERSION = 1;
35
+ const FACET_PROMPT_VERSION = 2;
38
36
  const SYNTHESIS_PROMPT_VERSION = 1;
39
37
 
40
38
  const MIN_MEANINGFUL_USER_MESSAGES = 2;
@@ -45,6 +43,8 @@ const REDUCED_TRANSCRIPT_HEAD_CHARS = 12_000;
45
43
  const REDUCED_TRANSCRIPT_TAIL_CHARS = 12_000;
46
44
  const REDUCED_TRANSCRIPT_MAX_CHARS = FULL_TRANSCRIPT_MAX_CHARS;
47
45
 
46
+ const MIN_REPORT_WIDTH = 42; // Existing 40-column box plus its outer margin.
47
+
48
48
  const AGENT_ROOT = getAgentDir();
49
49
  const CACHE_ROOT = path.join(AGENT_ROOT, "insights");
50
50
  const META_CACHE_DIR = path.join(CACHE_ROOT, "session-meta");
@@ -139,8 +139,7 @@ type InsightScope = "current" | "project" | "all";
139
139
 
140
140
  type ConfiguredModelSelection = {
141
141
  model: Model<Api>;
142
- apiKey?: string;
143
- headers?: Record<string, string>;
142
+ modelRegistry: ExtensionCommandContext["modelRegistry"];
144
143
  };
145
144
 
146
145
  type ReadonlySessionManager = Pick<
@@ -309,6 +308,7 @@ class AbortError extends Error {
309
308
  class InsightsReportComponent implements Component {
310
309
  private readonly markdown: Markdown;
311
310
  private scrollOffset = 0;
311
+ private renderWidth?: number;
312
312
  private cachedWidth?: number;
313
313
  private cachedLines?: string[];
314
314
  private cachedBodyWidth?: number;
@@ -342,8 +342,11 @@ class InsightsReportComponent implements Component {
342
342
  return;
343
343
  }
344
344
 
345
+ const width = this.renderWidth ?? this.tui.terminal.columns;
346
+ if (width < MIN_REPORT_WIDTH) return;
347
+
345
348
  const bodyHeight = this.getBodyHeight();
346
- const boxWidth = this.getBoxWidth(this.tui.terminal.columns);
349
+ const boxWidth = this.getBoxWidth(width);
347
350
  const bodyLines = this.getBodyLines(this.getContentWidth(boxWidth));
348
351
  const maxScroll = Math.max(0, bodyLines.length - bodyHeight);
349
352
 
@@ -368,6 +371,19 @@ class InsightsReportComponent implements Component {
368
371
  }
369
372
 
370
373
  render(width: number): string[] {
374
+ this.renderWidth = width;
375
+ if (width < MIN_REPORT_WIDTH) {
376
+ if (width < 1) return [];
377
+ return [
378
+ truncateToWidth(
379
+ this.theme.fg("muted", `Resize to ${MIN_REPORT_WIDTH}+ columns to view insights.`),
380
+ width,
381
+ "",
382
+ ),
383
+ truncateToWidth(this.theme.fg("dim", "Enter/Esc close"), width, ""),
384
+ ];
385
+ }
386
+
371
387
  if (this.cachedWidth === width && this.cachedLines) {
372
388
  return this.cachedLines;
373
389
  }
@@ -435,11 +451,11 @@ class InsightsReportComponent implements Component {
435
451
  }
436
452
 
437
453
  private getBoxWidth(width: number): number {
438
- return Math.max(40, Math.min(width - 2, 140));
454
+ return Math.min(width - 2, 140);
439
455
  }
440
456
 
441
457
  private getContentWidth(boxWidth: number): number {
442
- return Math.max(10, boxWidth - 4);
458
+ return boxWidth - 4;
443
459
  }
444
460
 
445
461
  private borderLine(left: string, right: string, width: number): string {
@@ -451,7 +467,7 @@ class InsightsReportComponent implements Component {
451
467
  }
452
468
 
453
469
  private boxLine(content: string, width: number): string {
454
- const padded = ` ${content}`;
470
+ const padded = ` ${truncateToWidth(content, this.getContentWidth(width))}`;
455
471
  const visible = visibleWidth(padded);
456
472
  const rightPad = Math.max(0, width - 2 - visible);
457
473
  return `${this.theme.fg("borderMuted", "│")}${padded}${" ".repeat(rightPad)}${this.theme.fg("borderMuted", "│")}`;
@@ -562,9 +578,37 @@ async function runInsightsCommand(
562
578
  return;
563
579
  }
564
580
 
581
+ let reportPath: string | undefined;
582
+ try {
583
+ reportPath = await saveInsightsReport(result.reportMarkdown, result.generatedAt);
584
+ } catch (error) {
585
+ const message = error instanceof Error ? error.message : "Unknown error";
586
+ ctx.ui.notify(`Failed to save insights report: ${message}`, "warning");
587
+ }
588
+
565
589
  await ctx.ui.custom<void>((tui, theme, _kb, done) => {
566
590
  return new InsightsReportComponent(result, tui, theme, done);
567
591
  });
592
+
593
+ if (reportPath) {
594
+ ctx.ui.notify(`Insights report saved to ${reportPath}`, "info");
595
+ }
596
+ }
597
+
598
+ async function saveInsightsReport(reportMarkdown: string, generatedAt: string): Promise<string> {
599
+ const timestamp = generatedAt.replace(/[:.]/g, "-");
600
+ const reportPath = path.join(os.tmpdir(), `tau-insights-${timestamp}.md`);
601
+ const reportFile = await fs.open(reportPath, "wx", 0o600);
602
+
603
+ try {
604
+ await reportFile.writeFile(`${reportMarkdown.trimEnd()}\n`, "utf8");
605
+ await reportFile.close();
606
+ return reportPath;
607
+ } catch (error) {
608
+ await reportFile.close().catch(() => undefined);
609
+ await fs.rm(reportPath, { force: true }).catch(() => undefined);
610
+ throw error;
611
+ }
568
612
  }
569
613
 
570
614
  async function runInsightsPipeline(
@@ -749,7 +793,7 @@ async function getConfiguredModelSelection(
749
793
 
750
794
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(ctx.model);
751
795
  if (!auth.ok) return null;
752
- return { model: ctx.model, apiKey: auth.apiKey, headers: auth.headers };
796
+ return { model: ctx.model, modelRegistry: ctx.modelRegistry };
753
797
  }
754
798
 
755
799
  async function listTargets(
@@ -769,32 +813,16 @@ async function listTargets(
769
813
  onProgress({ stage: "resolve", current, total });
770
814
  };
771
815
 
772
- const sessionFiles =
816
+ const cwd = path.resolve(ctx.cwd);
817
+ const sessions =
773
818
  scope === "project"
774
- ? await listProjectSessionFiles(ctx.cwd, ctx.sessionManager.getSessionDir(), progress)
775
- : await listAllSessionFiles(progress);
819
+ ? await SessionManager.list(cwd, ctx.sessionManager.getSessionDir(), progress, signal)
820
+ : await SessionManager.listAll(progress, signal);
776
821
 
777
822
  throwIfAborted(signal);
778
- return sessionFiles.map((sessionFile) => ({ path: sessionFile }));
779
- }
780
-
781
- async function listProjectSessionFiles(
782
- cwd: string,
783
- sessionDir: string,
784
- onProgress: (current: number, total: number) => void,
785
- ): Promise<string[]> {
786
- const currentCwd = path.resolve(cwd);
787
- const sessions = await SessionManager.list(currentCwd, sessionDir, onProgress);
788
823
  return sessions
789
- .filter((session) => path.resolve(session.cwd || currentCwd) === currentCwd)
790
- .map((session) => session.path);
791
- }
792
-
793
- async function listAllSessionFiles(
794
- onProgress: (current: number, total: number) => void,
795
- ): Promise<string[]> {
796
- const sessions = await SessionManager.listAll(onProgress);
797
- return sessions.map((session) => session.path);
824
+ .filter((session) => scope === "all" || path.resolve(session.cwd || cwd) === cwd)
825
+ .map((session) => ({ path: session.path }));
798
826
  }
799
827
 
800
828
  async function loadOrExtractMeta(
@@ -806,7 +834,7 @@ async function loadOrExtractMeta(
806
834
  cacheHit: boolean;
807
835
  runtime?: SessionRuntime;
808
836
  }> {
809
- const cacheInfo = await getCacheInfo(target.path);
837
+ const cacheInfo = await getCacheInfo(target);
810
838
 
811
839
  if (cacheInfo.cacheKey && cacheInfo.fingerprint) {
812
840
  const cached = await readMetaCache(cacheInfo.cacheKey, cacheInfo.fingerprint);
@@ -884,48 +912,12 @@ async function readOnlyOpenSession(sessionFile: string): Promise<ReadonlySession
884
912
  throw new Error(`Session file is empty or invalid: ${sessionFile}`);
885
913
  }
886
914
 
887
- migrateSessionEntries(fileEntries);
888
- const header = fileEntries.find((entry): entry is SessionHeader => entry.type === "session");
915
+ const header = fileEntries.find((entry) => entry.type === "session");
889
916
  if (!header) {
890
917
  throw new Error(`Session file has no header: ${sessionFile}`);
891
918
  }
892
919
 
893
- const entries = fileEntries.filter((entry): entry is SessionEntry => entry.type !== "session");
894
- const byId = new Map(entries.map((entry) => [entry.id, entry]));
895
- const leafId = entries.at(-1)?.id ?? null;
896
- const sessionDir = path.dirname(sessionFile);
897
-
898
- return {
899
- getEntries() {
900
- return entries.slice();
901
- },
902
- getBranch(fromId?: string) {
903
- const startId = fromId ?? leafId;
904
- if (!startId) return [];
905
- const pathEntries: SessionEntry[] = [];
906
- let current = byId.get(startId);
907
- while (current) {
908
- pathEntries.unshift(current);
909
- current = current.parentId ? byId.get(current.parentId) : undefined;
910
- }
911
- return pathEntries;
912
- },
913
- getLeafId() {
914
- return leafId;
915
- },
916
- getHeader() {
917
- return header;
918
- },
919
- getSessionDir() {
920
- return sessionDir;
921
- },
922
- getSessionId() {
923
- return header.id;
924
- },
925
- getSessionFile() {
926
- return sessionFile;
927
- },
928
- } satisfies ReadonlySessionManager;
920
+ return SessionManager.inMemory(header.cwd, undefined, fileEntries);
929
921
  }
930
922
 
931
923
  function extractSessionArtifacts(
@@ -958,11 +950,23 @@ function extractSessionArtifacts(
958
950
  for (const entry of branchEntries) {
959
951
  collectModifiedFilesFromEntry(entry, modifiedFiles);
960
952
 
961
- if (entry.type !== "message") continue;
962
- const message = entry.message as unknown as Record<string, unknown>;
963
- const role = typeof message.role === "string" ? message.role : "";
953
+ const message =
954
+ entry.type === "message" ? (entry.message as unknown as Record<string, unknown>) : undefined;
955
+ const usage =
956
+ entry.type === "usage"
957
+ ? entry.usage
958
+ : message?.role === "assistant"
959
+ ? message.usage
960
+ : undefined;
961
+ if (isRecord(usage)) {
962
+ totalInputTokens +=
963
+ numberValue(usage.input) + numberValue(usage.cacheRead) + numberValue(usage.cacheWrite);
964
+ totalOutputTokens += numberValue(usage.output);
965
+ totalCost += numberValue(isRecord(usage.cost) ? usage.cost.total : undefined);
966
+ }
967
+ if (!message) continue;
964
968
 
965
- if (role === "user") {
969
+ if (message.role === "user") {
966
970
  if (isMeaningfulUserMessage(message)) {
967
971
  meaningfulUserMessages++;
968
972
  if (!firstUserPrompt) {
@@ -972,14 +976,8 @@ function extractSessionArtifacts(
972
976
  continue;
973
977
  }
974
978
 
975
- if (role === "assistant") {
979
+ if (message.role === "assistant") {
976
980
  assistantMessages++;
977
- const usage = isRecord(message.usage) ? message.usage : undefined;
978
- totalInputTokens +=
979
- numberValue(usage?.input) + numberValue(usage?.cacheRead) + numberValue(usage?.cacheWrite);
980
- totalOutputTokens += numberValue(usage?.output);
981
- totalCost += numberValue(isRecord(usage?.cost) ? usage?.cost.total : undefined);
982
-
983
981
  const toolCalls = extractToolCalls(message.content);
984
982
  for (const toolCall of toolCalls) {
985
983
  incrementRecord(toolCounts, toolCall.name);
@@ -988,7 +986,7 @@ function extractSessionArtifacts(
988
986
  continue;
989
987
  }
990
988
 
991
- if (role === "toolResult") {
989
+ if (message.role === "toolResult") {
992
990
  toolResultMessages++;
993
991
  if (message.isError === true && typeof message.toolName === "string") {
994
992
  incrementRecord(toolsWithErrors, message.toolName);
@@ -1200,76 +1198,51 @@ function prepareTranscriptForFacet(transcript: NormalizedTranscript): PreparedTr
1200
1198
  };
1201
1199
  }
1202
1200
 
1203
- const selected = new Set<number>();
1204
- let headChars = 0;
1205
- for (let index = 0; index < transcript.blocks.length; index++) {
1206
- if (headChars >= REDUCED_TRANSCRIPT_HEAD_CHARS) break;
1207
- selected.add(index);
1208
- headChars += transcript.blocks[index]!.text.length + 2;
1209
- }
1201
+ const prefix = [
1202
+ truncateChars(transcript.headerText, 1_000),
1203
+ `[Transcript reduced due to length. Full size: ${formatCount(transcript.charLength)} chars.]`,
1204
+ ].join("\n\n");
1205
+ const gap = (count: number): string => `[... ${count} blocks omitted ...]`;
1206
+ // Charge each selected block for a possible preceding gap and both separators.
1207
+ // Reserve one more gap for the end, so assembly never needs a head-only final cut.
1208
+ const gapAllowance = gap(transcript.blocks.length).length + 4;
1209
+ const maxBodyLength = REDUCED_TRANSCRIPT_MAX_CHARS - prefix.length - 2 - gapAllowance;
1210
+ const selected = new Map<number, string>();
1211
+ const selectBlock = (index: number, budget: number): number => {
1212
+ if (selected.has(index)) return budget;
1213
+ const text = reduceTranscriptBlock(transcript.blocks[index]!.text, budget - gapAllowance);
1214
+ if (!text) return 0;
1215
+ selected.set(index, text);
1216
+ return budget - text.length - gapAllowance;
1217
+ };
1210
1218
 
1211
- let tailChars = 0;
1212
- for (let index = transcript.blocks.length - 1; index >= 0; index--) {
1213
- if (tailChars >= REDUCED_TRANSCRIPT_TAIL_CHARS) break;
1214
- if (selected.has(index)) continue;
1215
- selected.add(index);
1216
- tailChars += transcript.blocks[index]!.text.length + 2;
1219
+ // Independent allocations ensure an oversized opening cannot consume the ending.
1220
+ let headBudget = REDUCED_TRANSCRIPT_HEAD_CHARS;
1221
+ for (let index = 0; index < transcript.blocks.length && headBudget > 0; index++) {
1222
+ headBudget = selectBlock(index, headBudget);
1217
1223
  }
1218
-
1219
- for (let index = 0; index < transcript.blocks.length; index++) {
1224
+ let tailBudget = REDUCED_TRANSCRIPT_TAIL_CHARS;
1225
+ for (let index = transcript.blocks.length - 1; index >= 0 && tailBudget > 0; index--) {
1226
+ tailBudget = selectBlock(index, tailBudget);
1227
+ }
1228
+ let summaryBudget = maxBodyLength - REDUCED_TRANSCRIPT_HEAD_CHARS - REDUCED_TRANSCRIPT_TAIL_CHARS;
1229
+ for (let index = 0; index < transcript.blocks.length && summaryBudget > 0; index++) {
1220
1230
  if (transcript.blocks[index]!.isSummary) {
1221
- selected.add(index);
1231
+ summaryBudget = selectBlock(index, summaryBudget);
1222
1232
  }
1223
1233
  }
1224
1234
 
1225
- const ordered = [...selected].sort((a, b) => a - b);
1226
- const prefix = [
1227
- transcript.headerText,
1228
- `[Transcript reduced due to length. Full size: ${formatCount(transcript.charLength)} chars.]`,
1229
- ]
1230
- .filter(Boolean)
1231
- .join("\n\n")
1232
- .trim();
1233
-
1234
1235
  const reducedBlocks: string[] = [];
1235
1236
  let previous = -1;
1236
- let bodyLength = 0;
1237
- const maxBodyLength = Math.max(0, REDUCED_TRANSCRIPT_MAX_CHARS - prefix.length - 2);
1238
-
1239
- for (const index of ordered) {
1240
- if (previous >= 0 && index > previous + 1) {
1241
- const gapText = `[... ${index - previous - 1} blocks omitted ...]`;
1242
- const appendedGap = appendReducedBlock(reducedBlocks, gapText, maxBodyLength, bodyLength);
1243
- bodyLength = appendedGap.bodyLength;
1244
- if (!appendedGap.didAppend) break;
1245
- }
1246
-
1247
- const appendedBlock = appendReducedBlock(
1248
- reducedBlocks,
1249
- transcript.blocks[index]!.text,
1250
- maxBodyLength,
1251
- bodyLength,
1252
- );
1253
- bodyLength = appendedBlock.bodyLength;
1254
- if (!appendedBlock.didAppend) break;
1237
+ for (const [index, text] of [...selected].sort(([a], [b]) => a - b)) {
1238
+ if (index > previous + 1) reducedBlocks.push(gap(index - previous - 1));
1239
+ reducedBlocks.push(text);
1255
1240
  previous = index;
1256
1241
  }
1257
-
1258
- if (previous >= 0 && previous < transcript.blocks.length - 1) {
1259
- const appendedTailGap = appendReducedBlock(
1260
- reducedBlocks,
1261
- `[... ${transcript.blocks.length - previous - 1} blocks omitted ...]`,
1262
- maxBodyLength,
1263
- bodyLength,
1264
- );
1265
- bodyLength = appendedTailGap.bodyLength;
1242
+ if (previous < transcript.blocks.length - 1) {
1243
+ reducedBlocks.push(gap(transcript.blocks.length - previous - 1));
1266
1244
  }
1267
-
1268
- const reducedText = [prefix, reducedBlocks.join("\n\n")]
1269
- .filter(Boolean)
1270
- .join("\n\n")
1271
- .slice(0, REDUCED_TRANSCRIPT_MAX_CHARS)
1272
- .trim();
1245
+ const reducedText = [prefix, ...reducedBlocks].join("\n\n");
1273
1246
 
1274
1247
  return {
1275
1248
  text: reducedText,
@@ -1278,32 +1251,17 @@ function prepareTranscriptForFacet(transcript: NormalizedTranscript): PreparedTr
1278
1251
  };
1279
1252
  }
1280
1253
 
1281
- function appendReducedBlock(
1282
- blocks: string[],
1283
- block: string,
1284
- maxBodyLength: number,
1285
- currentBodyLength: number,
1286
- ): { bodyLength: number; didAppend: boolean } {
1287
- if (maxBodyLength <= currentBodyLength) {
1288
- return { bodyLength: currentBodyLength, didAppend: false };
1289
- }
1290
-
1291
- const separatorLength = blocks.length > 0 ? 2 : 0;
1292
- const remaining = maxBodyLength - currentBodyLength - separatorLength;
1293
- if (remaining <= 0) {
1294
- return { bodyLength: currentBodyLength, didAppend: false };
1295
- }
1254
+ function reduceTranscriptBlock(text: string, maxChars: number): string | undefined {
1255
+ if (text.length <= maxChars) return text;
1296
1256
 
1297
- const nextBlock = truncateChars(block, remaining);
1298
- if (!nextBlock) {
1299
- return { bodyLength: currentBodyLength, didAppend: false };
1300
- }
1257
+ const label = text.slice(0, text.indexOf("\n") + 1);
1258
+ const omission = "\n[... message text omitted ...]\n";
1259
+ const remaining = maxChars - label.length - omission.length;
1260
+ if (remaining < 2) return undefined;
1301
1261
 
1302
- blocks.push(nextBlock);
1303
- return {
1304
- bodyLength: currentBodyLength + separatorLength + nextBlock.length,
1305
- didAppend: true,
1306
- };
1262
+ const head = Math.ceil(remaining / 2);
1263
+ const tail = Math.floor(remaining / 2);
1264
+ return `${label}${text.slice(label.length, label.length + head)}${omission}${text.slice(-tail)}`;
1307
1265
  }
1308
1266
 
1309
1267
  async function extractFacet(
@@ -1333,17 +1291,13 @@ async function extractFacet(
1333
1291
  timestamp: Date.now(),
1334
1292
  };
1335
1293
 
1336
- const response = await complete(
1294
+ const response = await selection.modelRegistry.complete(
1337
1295
  selection.model,
1338
1296
  {
1339
1297
  systemPrompt: FACET_SYSTEM_PROMPT,
1340
1298
  messages: [userMessage],
1341
1299
  },
1342
- {
1343
- apiKey: selection.apiKey,
1344
- headers: selection.headers,
1345
- signal,
1346
- },
1300
+ { signal },
1347
1301
  );
1348
1302
 
1349
1303
  if (response.stopReason === "aborted") {
@@ -1505,17 +1459,13 @@ async function synthesizeReport(
1505
1459
  timestamp: Date.now(),
1506
1460
  };
1507
1461
 
1508
- const response = await complete(
1462
+ const response = await selection.modelRegistry.complete(
1509
1463
  selection.model,
1510
1464
  {
1511
1465
  systemPrompt: SYNTHESIS_SYSTEM_PROMPT,
1512
1466
  messages: [userMessage],
1513
1467
  },
1514
- {
1515
- apiKey: selection.apiKey,
1516
- headers: selection.headers,
1517
- signal,
1518
- },
1468
+ { signal },
1519
1469
  );
1520
1470
 
1521
1471
  if (response.stopReason === "aborted") {
@@ -1691,15 +1641,20 @@ function setBorderedLoaderMessage(loader: BorderedLoader, message: string): void
1691
1641
  }
1692
1642
  }
1693
1643
 
1694
- async function getCacheInfo(sessionFile: string | undefined): Promise<SessionCacheInfo> {
1644
+ async function getCacheInfo(target: SessionTarget): Promise<SessionCacheInfo> {
1645
+ const sessionFile = target.path;
1695
1646
  if (!sessionFile) return {};
1696
1647
 
1697
- const cacheKey = hashText(sessionFile);
1648
+ // A live selection can differ from the last entry restored from the same file.
1649
+ // Keep each selected view separate, including the empty (null-leaf) branch.
1650
+ const view = target.manager ? { leafId: target.manager.getLeafId() } : "saved";
1651
+ const cacheKey = hashText(JSON.stringify({ sessionFile, view }));
1698
1652
  try {
1699
1653
  const stats = await fs.stat(sessionFile);
1700
1654
  const fingerprint = hashText(
1701
1655
  JSON.stringify({
1702
1656
  sessionFile,
1657
+ view,
1703
1658
  mtimeMs: stats.mtimeMs,
1704
1659
  size: stats.size,
1705
1660
  schemaVersion: SESSION_FINGERPRINT_VERSION,