@shanepadgett/tau-agent 0.33.0 → 0.34.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 (190) hide show
  1. package/docs/context.md +29 -20
  2. package/docs/extending-tau-agent.md +63 -24
  3. package/extensions/appshot/index.ts +112 -111
  4. package/extensions/aside/README.md +7 -0
  5. package/extensions/aside/index.ts +168 -0
  6. package/extensions/aside/panel.ts +83 -0
  7. package/extensions/branch/index.ts +38 -21
  8. package/extensions/cache-diagnostics/index.ts +70 -58
  9. package/extensions/checkpoint/README.md +9 -0
  10. package/extensions/checkpoint/checkpoint-budget.ts +79 -0
  11. package/extensions/checkpoint/checkpoint.ts +277 -0
  12. package/extensions/checkpoint/index.ts +102 -0
  13. package/extensions/checkpoint/messages.ts +169 -0
  14. package/extensions/checkpoint/prompt.ts +24 -0
  15. package/extensions/checkpoint/settings.ts +27 -0
  16. package/extensions/commit/commit-effort.ts +3 -3
  17. package/extensions/context/README.md +12 -8
  18. package/extensions/context/definitions.ts +178 -55
  19. package/extensions/context/index.ts +192 -160
  20. package/extensions/context/panel.ts +57 -37
  21. package/extensions/context/sync.ts +139 -89
  22. package/extensions/context/validation.ts +37 -25
  23. package/extensions/effort/README.md +1 -1
  24. package/extensions/effort/index.ts +3 -3
  25. package/extensions/effort/state.ts +3 -18
  26. package/extensions/explore/guidance.ts +6 -47
  27. package/extensions/explore/index.ts +38 -53
  28. package/extensions/explore/{ast/read → read}/hook.ts +12 -7
  29. package/extensions/explore/{ast/tools → tools}/ast-search.ts +15 -19
  30. package/extensions/explore/{ast/tools → tools}/context.ts +16 -23
  31. package/extensions/explore/{ast/tools → tools}/deps.ts +11 -15
  32. package/extensions/explore/{ast/tools → tools}/discover.ts +15 -17
  33. package/extensions/explore/{ast/tools → tools}/impact.ts +20 -23
  34. package/extensions/explore/{ast/tools → tools}/outline.ts +21 -24
  35. package/extensions/explore/{ast/tools → tools}/relationships.ts +23 -32
  36. package/extensions/explore/{ast/tools → tools}/render.ts +1 -1
  37. package/extensions/explore/{ast/tools → tools}/reverse-deps.ts +11 -15
  38. package/extensions/explore/{ast/tools → tools}/show.ts +16 -19
  39. package/extensions/explore/tools/target-params.ts +9 -0
  40. package/extensions/footer/index.ts +75 -56
  41. package/extensions/handoff/index.ts +8 -30
  42. package/extensions/image-gen/index.ts +46 -40
  43. package/extensions/patch/executor.ts +190 -135
  44. package/extensions/patch/matcher.ts +82 -48
  45. package/extensions/patch/parser.ts +149 -105
  46. package/extensions/patch/render.ts +32 -39
  47. package/extensions/patch/summary.ts +30 -26
  48. package/extensions/ready/README.md +7 -0
  49. package/extensions/ready/index.ts +58 -0
  50. package/extensions/ready/model.ts +128 -0
  51. package/extensions/ready/packs.ts +272 -0
  52. package/extensions/ready/render.ts +445 -0
  53. package/extensions/ready/scan.ts +760 -0
  54. package/extensions/reference/panel.ts +28 -16
  55. package/extensions/review/index.ts +63 -51
  56. package/extensions/review/panel.ts +5 -34
  57. package/extensions/runtime-context/context.ts +30 -26
  58. package/extensions/script-runner/README.md +2 -2
  59. package/extensions/script-runner/index.ts +130 -93
  60. package/extensions/silent-command-runner/index.ts +38 -23
  61. package/extensions/subagent/agents/context-sync.md +185 -33
  62. package/extensions/subagent/agents/scout.md +2 -2
  63. package/extensions/subagent/agents.ts +137 -84
  64. package/extensions/subagent/cmux-dashboard.ts +39 -30
  65. package/extensions/subagent/index.ts +79 -59
  66. package/extensions/subagent/resume.ts +1 -1
  67. package/extensions/subagent/run.ts +304 -193
  68. package/extensions/subagent/runtime.ts +781 -343
  69. package/extensions/tau/index.ts +47 -38
  70. package/extensions/tau-help/help.md +14 -6
  71. package/extensions/tool-loader/README.md +4 -2
  72. package/extensions/tool-loader/index.ts +77 -52
  73. package/extensions/web/index.ts +6 -3
  74. package/extensions/web/webfetch.ts +114 -86
  75. package/package.json +3 -3
  76. package/schemas/tau.schema.json +20 -33
  77. package/shared/bounded-text-result.ts +1 -0
  78. package/shared/checkpoint-visibility.ts +9 -0
  79. package/shared/events.ts +28 -10
  80. package/shared/isolated-session.ts +71 -29
  81. package/shared/model-effort.ts +9 -19
  82. package/shared/model-fallback/index.ts +66 -41
  83. package/shared/settings/load.ts +45 -38
  84. package/shared/temporary-output-store.ts +16 -10
  85. package/{extensions/explore → src}/ast/adapter.ts +11 -2
  86. package/{extensions/explore → src}/ast/engine.ts +54 -38
  87. package/{extensions/explore → src}/ast/format/ast-search.ts +15 -6
  88. package/{extensions/explore → src}/ast/format/context.ts +3 -6
  89. package/src/ast/format/deps.ts +94 -0
  90. package/{extensions/explore → src}/ast/format/discover.ts +2 -2
  91. package/{extensions/explore → src}/ast/format/impact.ts +9 -11
  92. package/{extensions/explore → src}/ast/format/outline.ts +2 -2
  93. package/{extensions/explore → src}/ast/format/relationships.ts +11 -7
  94. package/{extensions/explore → src}/ast/format/show.ts +1 -1
  95. package/src/ast/format/targeted-result.ts +20 -0
  96. package/src/ast/grammars/c_sharp.wasm +0 -0
  97. package/{extensions/explore → src}/ast/grammars/manifest.json +9 -12
  98. package/{extensions/explore → src}/ast/grammars/manifest.ts +0 -4
  99. package/{extensions/explore → src}/ast/graph/file-graph.ts +210 -98
  100. package/{extensions/explore → src}/ast/graph/relationships.ts +285 -72
  101. package/{extensions/explore → src}/ast/ir.ts +6 -0
  102. package/src/ast/languages/csharp-file-deps.ts +90 -0
  103. package/{extensions/explore → src}/ast/languages/csharp.ts +9 -40
  104. package/src/ast/languages/dotted-index.ts +131 -0
  105. package/{extensions/explore → src}/ast/languages/file-dep-util.ts +22 -13
  106. package/src/ast/languages/fixtures/sample.cs +53 -0
  107. package/src/ast/languages/fixtures/sample.cs.expected.json +156 -0
  108. package/src/ast/languages/fixtures/sample.go +37 -0
  109. package/src/ast/languages/fixtures/sample.go.expected.json +96 -0
  110. package/src/ast/languages/fixtures/sample.java +40 -0
  111. package/src/ast/languages/fixtures/sample.java.expected.json +126 -0
  112. package/src/ast/languages/fixtures/sample.kt +31 -0
  113. package/src/ast/languages/fixtures/sample.kt.expected.json +134 -0
  114. package/src/ast/languages/fixtures/sample.md +23 -0
  115. package/src/ast/languages/fixtures/sample.md.expected.json +44 -0
  116. package/src/ast/languages/fixtures/sample.rs +54 -0
  117. package/src/ast/languages/fixtures/sample.rs.expected.json +178 -0
  118. package/src/ast/languages/fixtures/sample.swift +45 -0
  119. package/src/ast/languages/fixtures/sample.swift.expected.json +147 -0
  120. package/src/ast/languages/fixtures/sample.ts +45 -0
  121. package/src/ast/languages/fixtures/sample.ts.expected.json +141 -0
  122. package/src/ast/languages/fixtures/sample.tsx +36 -0
  123. package/src/ast/languages/fixtures/sample.tsx.expected.json +59 -0
  124. package/{extensions/explore → src}/ast/languages/go-file-deps.ts +6 -0
  125. package/{extensions/explore → src}/ast/languages/go.ts +21 -3
  126. package/{extensions/explore → src}/ast/languages/java.ts +5 -38
  127. package/src/ast/languages/jvm-file-deps.ts +135 -0
  128. package/{extensions/explore → src}/ast/languages/kotlin.ts +5 -39
  129. package/{extensions/explore → src}/ast/languages/rust-file-deps.ts +3 -3
  130. package/{extensions/explore → src}/ast/languages/rust.ts +21 -13
  131. package/{extensions/explore → src}/ast/languages/swift-file-deps.ts +22 -15
  132. package/{extensions/explore → src}/ast/languages/swift.ts +9 -39
  133. package/{extensions/explore → src}/ast/languages/tree.ts +47 -0
  134. package/{extensions/explore → src}/ast/languages/typescript-file-deps.ts +108 -0
  135. package/{extensions/explore → src}/ast/languages/typescript-package-surface.ts +4 -3
  136. package/{extensions/explore → src}/ast/languages/typescript.ts +3 -5
  137. package/{extensions/explore → src}/ast/markdown.ts +72 -47
  138. package/{extensions/explore → src}/ast/queries/ast-search.ts +164 -146
  139. package/{extensions/explore → src}/ast/queries/composite-target.ts +1 -1
  140. package/{extensions/explore → src}/ast/queries/context.ts +116 -80
  141. package/{extensions/explore → src}/ast/queries/discover.ts +154 -105
  142. package/src/ast/queries/impact.ts +291 -0
  143. package/{extensions/explore → src}/ast/queries/outline.ts +4 -2
  144. package/{extensions/explore → src}/ast/queries/show.ts +46 -42
  145. package/{extensions/explore → src}/ast/query.ts +1 -1
  146. package/{extensions/explore → src}/ast/registry.ts +1 -2
  147. package/{extensions/explore → src}/ast/scan.ts +1 -1
  148. package/src/ast/session.ts +50 -0
  149. package/{extensions/explore → src}/ast/slice.ts +8 -0
  150. package/{extensions/explore → src/ast}/traverse.ts +32 -20
  151. package/src/file-injection/index.ts +547 -0
  152. package/src/image-generation/client.ts +50 -31
  153. package/src/image-generation/index.ts +177 -134
  154. package/src/index.ts +2 -0
  155. package/src/tool-loading/index.ts +58 -0
  156. package/extensions/context/evidence.ts +0 -517
  157. package/extensions/context/projection.ts +0 -141
  158. package/extensions/context/state.ts +0 -30
  159. package/extensions/context/write-scope.ts +0 -109
  160. package/extensions/explore/ast/format/deps.ts +0 -86
  161. package/extensions/explore/ast/grammars/odin.wasm +0 -0
  162. package/extensions/explore/ast/languages/csharp-file-deps.ts +0 -189
  163. package/extensions/explore/ast/languages/fixtures/sample.cs +0 -17
  164. package/extensions/explore/ast/languages/fixtures/sample.java +0 -21
  165. package/extensions/explore/ast/languages/fixtures/sample.kt +0 -15
  166. package/extensions/explore/ast/languages/fixtures/sample.odin +0 -28
  167. package/extensions/explore/ast/languages/fixtures/sample.rs +0 -31
  168. package/extensions/explore/ast/languages/fixtures/sample.swift +0 -25
  169. package/extensions/explore/ast/languages/jvm-file-deps.ts +0 -217
  170. package/extensions/explore/ast/languages/odin-file-deps.ts +0 -175
  171. package/extensions/explore/ast/languages/odin.ts +0 -407
  172. package/extensions/explore/ast/queries/impact.ts +0 -250
  173. package/extensions/explore/outline-injection.ts +0 -151
  174. package/extensions/explore/read/autoread.ts +0 -118
  175. package/extensions/working-memory/README.md +0 -17
  176. package/extensions/working-memory/checkpoint.ts +0 -242
  177. package/extensions/working-memory/index.ts +0 -341
  178. package/extensions/working-memory/memory.ts +0 -235
  179. package/extensions/working-memory/render.ts +0 -185
  180. package/extensions/working-memory/settings.ts +0 -38
  181. package/extensions/working-memory/state.ts +0 -158
  182. package/shared/autoread.ts +0 -234
  183. package/shared/context-messages.ts +0 -19
  184. package/shared/outline-injection.ts +0 -56
  185. /package/{extensions/explore → src}/ast/format/composite.ts +0 -0
  186. /package/{extensions/explore → src}/ast/grammars/kotlin.wasm +0 -0
  187. /package/{extensions/explore → src}/ast/grammars/swift.wasm +0 -0
  188. /package/{extensions/explore → src}/ast/identity.ts +0 -0
  189. /package/{extensions/explore → src}/ast/package-surface.ts +0 -0
  190. /package/{extensions/explore/ast/read/policy.ts → src/ast/read-policy.ts} +0 -0
@@ -0,0 +1,83 @@
1
+ import { bindingHint, bindingsHint, ScrollableMarkdown, ToolPanel } from "@shanepadgett/tau-tui";
2
+ import type { Theme } from "@earendil-works/pi-coding-agent";
3
+ import {
4
+ type Component,
5
+ type KeybindingsManager,
6
+ truncateToWidth,
7
+ type TUI,
8
+ wrapTextWithAnsi,
9
+ } from "@earendil-works/pi-tui";
10
+
11
+ const PREVIEW_LINES = 5;
12
+ const PREVIEW_CHARACTERS = 2_000;
13
+
14
+ export interface AsideResult {
15
+ question: string;
16
+ answer: string;
17
+ context: string;
18
+ }
19
+
20
+ export class AsideWidget implements Component {
21
+ private readonly panel: ToolPanel;
22
+
23
+ constructor(theme: Theme, question: string) {
24
+ this.panel = new ToolPanel(theme, {
25
+ title: "Aside · Thinking…",
26
+ body: {
27
+ render: (width) =>
28
+ wrapTextWithAnsi(theme.fg("muted", question.slice(0, PREVIEW_CHARACTERS)), width)
29
+ .slice(0, PREVIEW_LINES)
30
+ .map((line) => truncateToWidth(line, width, "")),
31
+ invalidate() {},
32
+ },
33
+ footer: { kind: "hints", hints: [] },
34
+ border: "horizontal",
35
+ });
36
+ }
37
+
38
+ render(width: number): string[] {
39
+ return this.panel.render(width);
40
+ }
41
+
42
+ invalidate(): void {
43
+ this.panel.invalidate();
44
+ }
45
+ }
46
+
47
+ export class AsideResultPanel implements Component {
48
+ private readonly panel: ToolPanel;
49
+ private readonly body: ScrollableMarkdown;
50
+
51
+ constructor(tui: TUI, theme: Theme, keys: KeybindingsManager, result: AsideResult, done: () => void) {
52
+ this.body = new ScrollableMarkdown(tui, result.answer, 8);
53
+ this.panel = new ToolPanel(theme, {
54
+ title: "Aside",
55
+ secondary: `${result.context} · ${result.question.slice(0, 240)}`,
56
+ body: this.body,
57
+ footer: {
58
+ kind: "hints",
59
+ hints: [
60
+ bindingsHint(["tui.select.up", "tui.select.down"], "scroll"),
61
+ bindingHint("tui.select.confirm", "close"),
62
+ bindingHint("tui.select.cancel", "close"),
63
+ ],
64
+ },
65
+ border: "box",
66
+ });
67
+ this.handleInput = (data) => {
68
+ if (keys.matches(data, "tui.select.up")) this.body.scroll(-1);
69
+ else if (keys.matches(data, "tui.select.down")) this.body.scroll(1);
70
+ else if (keys.matches(data, "tui.select.confirm") || keys.matches(data, "tui.select.cancel")) done();
71
+ };
72
+ }
73
+
74
+ handleInput: (data: string) => void;
75
+
76
+ render(width: number): string[] {
77
+ return this.panel.render(width);
78
+ }
79
+
80
+ invalidate(): void {
81
+ this.panel.invalidate();
82
+ }
83
+ }
@@ -14,7 +14,7 @@ const LIST_BRANCH_REFS_ARGS = [
14
14
  "refs/remotes",
15
15
  ];
16
16
 
17
- export function normalizeBranchName(name: string): string {
17
+ function normalizeBranchName(name: string): string {
18
18
  return name
19
19
  .toLowerCase()
20
20
  .replace(/[^a-z0-9]+/g, "-")
@@ -108,38 +108,55 @@ async function loadBranchChoices(git: GitRunner, root: string): Promise<BranchCh
108
108
  return parseBranchChoices(await git.run(LIST_BRANCH_REFS_ARGS, { cwd: root }));
109
109
  }
110
110
 
111
- function parseBranchChoices(output: string): BranchChoice[] {
112
- const refs = output
111
+ interface ParsedRef {
112
+ ref: string;
113
+ updatedAt: number;
114
+ current: boolean;
115
+ symbolic: boolean;
116
+ }
117
+
118
+ function parseRefLines(output: string): ParsedRef[] {
119
+ return output
113
120
  .split("\n")
114
121
  .filter(Boolean)
115
122
  .map((line) => {
116
123
  const [ref = "", seconds = "0", head = "", symref = ""] = line.split("\0");
117
124
  return { ref, updatedAt: Number(seconds) * 1000, current: head.trim() === "*", symbolic: Boolean(symref) };
118
125
  });
119
- const localPrefix = "refs/heads/";
120
- const remotePrefix = "refs/remotes/";
126
+ }
127
+
128
+ function localChoice(ref: ParsedRef): BranchChoice | undefined {
129
+ const name = ref.ref.slice("refs/heads/".length);
130
+ if (!name || ref.current) return undefined;
131
+ return { id: `local:${name}`, kind: "local", label: name, name, updatedAt: ref.updatedAt };
132
+ }
133
+
134
+ function remoteChoice(ref: ParsedRef, localNames: ReadonlySet<string>): BranchChoice | undefined {
135
+ if (ref.symbolic) return undefined;
136
+ const upstream = ref.ref.slice("refs/remotes/".length);
137
+ const separator = upstream.indexOf("/");
138
+ if (separator < 1) return undefined;
139
+ const name = upstream.slice(separator + 1);
140
+ if (!name || name === "HEAD" || localNames.has(name)) return undefined;
141
+ const label = localNames.has(upstream) ? `${upstream} (remote)` : upstream;
142
+ return { id: `remote:${upstream}`, kind: "remote", label, name, upstream, updatedAt: ref.updatedAt };
143
+ }
144
+
145
+ function parseBranchChoices(output: string): BranchChoice[] {
146
+ const refs = parseRefLines(output);
121
147
  const localNames = new Set(
122
- refs.filter(({ ref }) => ref.startsWith(localPrefix)).map(({ ref }) => ref.slice(localPrefix.length)),
148
+ refs.filter(({ ref }) => ref.startsWith("refs/heads/")).map(({ ref }) => ref.slice("refs/heads/".length)),
123
149
  );
124
150
  const choices: BranchChoice[] = [];
125
-
126
151
  for (const ref of refs) {
127
- if (ref.ref.startsWith(localPrefix)) {
128
- const name = ref.ref.slice(localPrefix.length);
129
- if (name && !ref.current)
130
- choices.push({ id: `local:${name}`, kind: "local", label: name, name, updatedAt: ref.updatedAt });
152
+ if (ref.ref.startsWith("refs/heads/")) {
153
+ const choice = localChoice(ref);
154
+ if (choice) choices.push(choice);
131
155
  continue;
132
156
  }
133
- if (!ref.ref.startsWith(remotePrefix) || ref.symbolic) continue;
134
-
135
- const upstream = ref.ref.slice(remotePrefix.length);
136
- const separator = upstream.indexOf("/");
137
- if (separator < 1) continue;
138
- const name = upstream.slice(separator + 1);
139
- if (!name || name === "HEAD" || localNames.has(name)) continue;
140
- const label = localNames.has(upstream) ? `${upstream} (remote)` : upstream;
141
- choices.push({ id: `remote:${upstream}`, kind: "remote", label, name, upstream, updatedAt: ref.updatedAt });
157
+ if (!ref.ref.startsWith("refs/remotes/")) continue;
158
+ const choice = remoteChoice(ref, localNames);
159
+ if (choice) choices.push(choice);
142
160
  }
143
-
144
161
  return choices.sort((left, right) => right.updatedAt - left.updatedAt || left.label.localeCompare(right.label));
145
162
  }
@@ -179,6 +179,72 @@ export default function cacheDiagnosticsExtension(pi: ExtensionAPI): void {
179
179
  cacheActivitySeen = false;
180
180
  };
181
181
 
182
+ const purgeExpiredFiles = async (): Promise<void> => {
183
+ const cutoff = Date.now() - RETENTION_MS;
184
+ for (const parent of [directory, reportsDirectory]) {
185
+ for (const entry of await readdir(parent, { withFileTypes: true })) {
186
+ if (
187
+ !entry.isFile() ||
188
+ (!entry.name.endsWith(".jsonl") && !entry.name.endsWith(".json") && !entry.name.endsWith(".tmp"))
189
+ )
190
+ continue;
191
+ const path = join(parent, entry.name);
192
+ if ((await stat(path)).mtimeMs < cutoff) await unlink(path);
193
+ }
194
+ }
195
+ };
196
+
197
+ const restoreRecordsFromLog = async (path: string): Promise<Array<Record<string, unknown>>> => {
198
+ const persisted = await readRecentLogRecords(path);
199
+ requests = keepRecent(
200
+ persisted
201
+ .filter((record) => record.kind === "request" && record.version === 2)
202
+ .map((record) => record as unknown as RequestRecord),
203
+ );
204
+ responses = keepRecent(
205
+ persisted
206
+ .filter((record) => record.kind === "response" && record.version === 2)
207
+ .map((record) => record as unknown as ResponseRecord),
208
+ );
209
+ results = keepRecent(
210
+ persisted
211
+ .filter((record) => record.kind === "result" && record.version === 2)
212
+ .map((record) => record as unknown as ResultRecord),
213
+ );
214
+ markers = keepRecent(
215
+ persisted
216
+ .filter((record) => record.kind === "marker" && record.version === 2)
217
+ .map((record) => record as unknown as MarkerRecord),
218
+ );
219
+ return persisted;
220
+ };
221
+
222
+ const restoreComparisonBaseline = (persisted: Array<Record<string, unknown>>): void => {
223
+ let latestModelSelectIndex = -1;
224
+ for (let index = persisted.length - 1; index >= 0; index -= 1) {
225
+ const record = persisted[index];
226
+ if (record?.kind !== "marker" || record.version !== 2 || record.name !== "model-select") continue;
227
+ latestModelSelectIndex = index;
228
+ break;
229
+ }
230
+ const comparisonResults = persisted
231
+ .slice(latestModelSelectIndex + 1)
232
+ .filter((record) => record.kind === "result" && record.version === 2)
233
+ .map((record) => record as unknown as ResultRecord);
234
+ const latestPromotedResult = [...comparisonResults].reverse().find((result) => result.baselinePromoted);
235
+ const latestRequest = latestPromotedResult
236
+ ? requests.find((request) => request.id === latestPromotedResult.id)
237
+ : undefined;
238
+ if (!latestPromotedResult || !latestRequest) return;
239
+ previousFingerprint = payloadFingerprintFromRequest(latestRequest);
240
+ previousRequestId = latestRequest.id;
241
+ previousPromptState = latestRequest.promptState;
242
+ previousPromptTokens = latestPromotedResult.usage.promptTokens;
243
+ cacheActivitySeen = comparisonResults.some(
244
+ (result) => result.baselinePromoted && result.usage.cacheRead + result.usage.cacheWrite > 0,
245
+ );
246
+ };
247
+
182
248
  pi.registerCommand("cache-debug", {
183
249
  description: "Write a bounded prompt-cache diagnostic report for this session",
184
250
  async handler(_args, ctx) {
@@ -246,63 +312,9 @@ export default function cacheDiagnosticsExtension(pi: ExtensionAPI): void {
246
312
  resetComparison();
247
313
  await mkdir(reportsDirectory, { recursive: true });
248
314
  logFile = join(directory, `${sessionId.replaceAll(/[^a-zA-Z0-9_-]/g, "_")}.jsonl`);
249
- const cutoff = Date.now() - RETENTION_MS;
250
- for (const parent of [directory, reportsDirectory]) {
251
- for (const entry of await readdir(parent, { withFileTypes: true })) {
252
- if (
253
- !entry.isFile() ||
254
- (!entry.name.endsWith(".jsonl") && !entry.name.endsWith(".json") && !entry.name.endsWith(".tmp"))
255
- )
256
- continue;
257
- const path = join(parent, entry.name);
258
- if ((await stat(path)).mtimeMs < cutoff) await unlink(path);
259
- }
260
- }
261
- const persisted = await readRecentLogRecords(logFile);
262
- requests = keepRecent(
263
- persisted
264
- .filter((record) => record.kind === "request" && record.version === 2)
265
- .map((record) => record as unknown as RequestRecord),
266
- );
267
- responses = keepRecent(
268
- persisted
269
- .filter((record) => record.kind === "response" && record.version === 2)
270
- .map((record) => record as unknown as ResponseRecord),
271
- );
272
- results = keepRecent(
273
- persisted
274
- .filter((record) => record.kind === "result" && record.version === 2)
275
- .map((record) => record as unknown as ResultRecord),
276
- );
277
- markers = keepRecent(
278
- persisted
279
- .filter((record) => record.kind === "marker" && record.version === 2)
280
- .map((record) => record as unknown as MarkerRecord),
281
- );
282
- let latestModelSelectIndex = -1;
283
- for (let index = persisted.length - 1; index >= 0; index -= 1) {
284
- const record = persisted[index];
285
- if (record?.kind !== "marker" || record.version !== 2 || record.name !== "model-select") continue;
286
- latestModelSelectIndex = index;
287
- break;
288
- }
289
- const comparisonRecords = persisted.slice(latestModelSelectIndex + 1);
290
- const comparisonResults = comparisonRecords
291
- .filter((record) => record.kind === "result" && record.version === 2)
292
- .map((record) => record as unknown as ResultRecord);
293
- const latestPromotedResult = [...comparisonResults].reverse().find((result) => result.baselinePromoted);
294
- const latestRequest = latestPromotedResult
295
- ? requests.find((request) => request.id === latestPromotedResult.id)
296
- : undefined;
297
- if (latestPromotedResult && latestRequest) {
298
- previousFingerprint = payloadFingerprintFromRequest(latestRequest);
299
- previousRequestId = latestRequest.id;
300
- previousPromptState = latestRequest.promptState;
301
- previousPromptTokens = latestPromotedResult.usage.promptTokens;
302
- cacheActivitySeen = comparisonResults.some(
303
- (result) => result.baselinePromoted && result.usage.cacheRead + result.usage.cacheWrite > 0,
304
- );
305
- }
315
+ await purgeExpiredFiles();
316
+ const persisted = await restoreRecordsFromLog(logFile);
317
+ restoreComparisonBaseline(persisted);
306
318
  await appendRecord({
307
319
  kind: "runtime",
308
320
  version: 2,
@@ -471,7 +483,7 @@ export default function cacheDiagnosticsExtension(pi: ExtensionAPI): void {
471
483
  );
472
484
  pi.on("session_tree", () => addMarker("session-tree"));
473
485
  pi.on("tool_execution_end", (event) => {
474
- if (event.toolName !== "working_memory" && event.toolName !== "load_tools") return;
486
+ if (event.toolName !== "checkpoint" && event.toolName !== "load_tools") return;
475
487
  return addMarker("cache-affecting-tool", { tool: event.toolName, isError: event.isError });
476
488
  });
477
489
  }
@@ -0,0 +1,9 @@
1
+ # Checkpoint
2
+
3
+ Keeps long-running agent work focused by letting the agent checkpoint durable working context while disposable conversation history is retired. The agent writes a short resume directive that is appended to the hidden continuation message after each checkpoint.
4
+
5
+ Checkpoint rows are hidden by default. Set `extensions.checkpoint.showToolRows` to `true` before `/reload` to watch checkpoint and newly injected-file rows while working. Existing injected-file rows keep their saved display state.
6
+
7
+ Checkpoint nudges the agent at 50% and 75% of `extensions.checkpoint.checkpointTokenLimit`, which defaults to 150,000 context tokens. At the limit, it blocks non-checkpoint tools until the agent checkpoints.
8
+
9
+ After changing this extension, run `/reload` before testing it.
@@ -0,0 +1,79 @@
1
+ export const DEFAULT_CHECKPOINT_TOKEN_LIMIT = 150_000;
2
+
3
+ export type CheckpointBudgetLevel = 0 | 50 | 75 | 100;
4
+ export type CheckpointBudgetNoticeLevel = Exclude<CheckpointBudgetLevel, 0>;
5
+
6
+ export interface CheckpointBudget {
7
+ configure(limit: number): void;
8
+ beginTurn(tokens: number | null): CheckpointBudgetNoticeLevel | undefined;
9
+ finishTurn(tokens: number | null): CheckpointBudgetNoticeLevel | undefined;
10
+ shouldBlockTool(toolName: string, checkpointToolName: string): boolean;
11
+ reset(): void;
12
+ }
13
+
14
+ export function createCheckpointBudget(initialLimit = DEFAULT_CHECKPOINT_TOKEN_LIMIT): CheckpointBudget {
15
+ let limit = validateLimit(initialLimit);
16
+ let highestNoticed: CheckpointBudgetLevel = 0;
17
+ let forced = false;
18
+ let baselineTokens: number | null = 0;
19
+
20
+ return {
21
+ configure(nextLimit: number): void {
22
+ limit = validateLimit(nextLimit);
23
+ highestNoticed = 0;
24
+ forced = false;
25
+ baselineTokens = 0;
26
+ },
27
+
28
+ beginTurn(tokens: number | null): CheckpointBudgetNoticeLevel | undefined {
29
+ return observe(tokens);
30
+ },
31
+
32
+ finishTurn(tokens: number | null): CheckpointBudgetNoticeLevel | undefined {
33
+ return observe(tokens);
34
+ },
35
+
36
+ shouldBlockTool(toolName: string, checkpointToolName: string): boolean {
37
+ return forced && toolName !== checkpointToolName;
38
+ },
39
+
40
+ reset(): void {
41
+ highestNoticed = 0;
42
+ forced = false;
43
+ baselineTokens = null;
44
+ },
45
+ };
46
+
47
+ function observe(tokens: number | null): CheckpointBudgetNoticeLevel | undefined {
48
+ if (tokens === null) return undefined;
49
+ if (baselineTokens === null) {
50
+ baselineTokens = tokens;
51
+ return undefined;
52
+ }
53
+ if (tokens < baselineTokens) {
54
+ baselineTokens = tokens;
55
+ highestNoticed = 0;
56
+ forced = false;
57
+ return undefined;
58
+ }
59
+ const level = levelFor(tokens - baselineTokens, limit);
60
+ if (level === 100) forced = true;
61
+ if (level <= highestNoticed) return undefined;
62
+ highestNoticed = level;
63
+ return level === 0 ? undefined : level;
64
+ }
65
+ }
66
+
67
+ function levelFor(tokens: number, limit: number): CheckpointBudgetLevel {
68
+ if (tokens >= limit) return 100;
69
+ if (tokens >= limit * 0.75) return 75;
70
+ if (tokens >= limit * 0.5) return 50;
71
+ return 0;
72
+ }
73
+
74
+ function validateLimit(limit: number): number {
75
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
76
+ throw new Error("Checkpoint token limit must be a positive safe integer");
77
+ }
78
+ return limit;
79
+ }
@@ -0,0 +1,277 @@
1
+ import {
2
+ defineTool,
3
+ keyText,
4
+ type ExtensionAPI,
5
+ type SessionEntry,
6
+ type SessionMessageEntry,
7
+ type Theme,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { truncateToWidth, type Component, wrapTextWithAnsi } from "@earendil-works/pi-tui";
10
+ import { Type, type Static } from "typebox";
11
+ import { prepareFileInjection } from "@shanepadgett/tau-agent";
12
+ import { areCheckpointRowsVisible } from "../../shared/checkpoint-visibility.ts";
13
+ import type { FileInjectionFile } from "../../src/file-injection/index.ts";
14
+ import { extractConversationText } from "./messages.ts";
15
+ import { formatCheckpointMessage } from "./prompt.ts";
16
+
17
+ export const CHECKPOINT_TOOL = "checkpoint";
18
+ const CONTINUATION_TYPE = "tau.checkpoint";
19
+ const CONTINUATION_PREAMBLE =
20
+ "Continue directly from the checkpoint state and provided files. Trust the provided sources and continue the listed work.";
21
+ const CHECKPOINT_PREVIEW_CHARACTERS = 240;
22
+ const CHECKPOINT_RENDER_CHARACTERS = 24_000;
23
+ const CHECKPOINT_RENDER_LINES = 200;
24
+ const CONVERSATION_MESSAGE_ID_ATTRIBUTE = /^(<(?:user|assistant)-message)\s+id="(?:\\.|[^"\\])*">$/gm;
25
+
26
+ const lineRange = Type.Object(
27
+ {
28
+ startLine: Type.Integer({ minimum: 1 }),
29
+ endLine: Type.Integer({ minimum: 1 }),
30
+ },
31
+ { additionalProperties: false },
32
+ );
33
+
34
+ const checkpointFile = Type.Union([
35
+ Type.Object(
36
+ {
37
+ path: Type.String({ minLength: 1 }),
38
+ mode: Type.Literal("read"),
39
+ ranges: Type.Optional(Type.Array(lineRange)),
40
+ },
41
+ { additionalProperties: false },
42
+ ),
43
+ Type.Object(
44
+ {
45
+ path: Type.String({ minLength: 1 }),
46
+ mode: Type.Literal("outline"),
47
+ },
48
+ { additionalProperties: false },
49
+ ),
50
+ Type.Object(
51
+ {
52
+ path: Type.String({ minLength: 1 }),
53
+ mode: Type.Literal("deferred"),
54
+ when: Type.String({ minLength: 1 }),
55
+ },
56
+ { additionalProperties: false },
57
+ ),
58
+ ]);
59
+
60
+ const checkpointParams = Type.Object(
61
+ {
62
+ keepMessages: Type.Array(Type.String({ minLength: 1 }), {
63
+ description:
64
+ "Exact message-entry IDs from hidden checkpoint metadata or earlier checkpoint conversation entries.",
65
+ }),
66
+ work: Type.Array(Type.String(), { description: "Ordered current work, with the next thing first." }),
67
+ facts: Type.Array(Type.String(), { description: "Concrete findings that must survive context replacement." }),
68
+ decisions: Type.Array(Type.String(), { description: "Choices that continue to govern the work." }),
69
+ continue: Type.String({
70
+ minLength: 1,
71
+ description:
72
+ "Immediate post-checkpoint resume directive. First moves after wake, traps to avoid, what not to re-litigate. Not the full work queue.",
73
+ }),
74
+ files: Type.Array(checkpointFile, {
75
+ description: "Current file reads, outlines, or deferred paths. Active files are injected separately.",
76
+ }),
77
+ },
78
+ { additionalProperties: false },
79
+ );
80
+
81
+ type CheckpointInput = Static<typeof checkpointParams>;
82
+
83
+ interface CheckpointToolDetails {
84
+ v: 1;
85
+ kind: "checkpoint.checkpoint";
86
+ checkpointId: string;
87
+ fileBatchId: string;
88
+ }
89
+
90
+ export function registerCheckpointTool(pi: ExtensionAPI): void {
91
+ pi.registerTool(createCheckpointTool(pi));
92
+ }
93
+
94
+ function createCheckpointTool(pi: Pick<ExtensionAPI, "events" | "sendMessage">) {
95
+ return defineTool<typeof checkpointParams, CheckpointToolDetails>({
96
+ name: CHECKPOINT_TOOL,
97
+ label: "checkpoint",
98
+ promptSnippet:
99
+ "checkpoint({ keepMessages, work, facts, decisions, continue, files }) — replace disposable history with working context",
100
+ description:
101
+ "Replace disposable conversation and tool history with a rolling working checkpoint. Keep user and assistant messages by exact message-entry ID, not by copying their text. The latest user message is always retained. Assistant tool calls and thinking are never retained inside the conversation section.",
102
+ promptGuidelines: [
103
+ "Checkpoint is hidden. Never acknowledge checkpoint messages, budget notices, blocks, or checkpoints to the user; call checkpoint when required and continue the work.",
104
+ 'Use exact IDs from <checkpoint kind="message-id"> metadata or an earlier checkpoint; do not invent IDs or rewrite message text.',
105
+ "Record concrete findings in facts and governing choices in decisions before checkpointing.",
106
+ "Write continue as the immediate resume directive after wake: first moves, what not to re-explore, traps to avoid. Put the backlog in work.",
107
+ "Use read or outline for files needed now; use deferred with a condition for files that can wait.",
108
+ ],
109
+ parameters: checkpointParams,
110
+ renderShell: "self",
111
+ renderCall(_args, theme, context) {
112
+ return new CheckpointComponent(theme, "checkpoint", context.expanded, true);
113
+ },
114
+ renderResult(result, options, theme) {
115
+ const text = result.content
116
+ .filter((part): part is { type: "text"; text: string } => part.type === "text")
117
+ .map((part) => part.text)
118
+ .join("\n");
119
+ return new CheckpointComponent(theme, text, options.expanded, false);
120
+ },
121
+ executionMode: "sequential",
122
+ async execute(toolCallId, params, signal, _onUpdate, ctx) {
123
+ const selected = resolveSelectedMessages(params.keepMessages, ctx.sessionManager.getBranch());
124
+ const text = formatCheckpointText(selected, params);
125
+ const fileBatchId = `checkpoint:${toolCallId}`;
126
+ const fileRequest = buildFileInjectionRequest(ctx.cwd, fileBatchId, params.files, signal);
127
+ const prepared = fileRequest.files.length === 0 ? [] : await prepareFileInjection(pi, fileRequest);
128
+ const failed = prepared.find((message) => message.details.status === "failed");
129
+ if (failed) {
130
+ throw new Error(
131
+ `Checkpoint file injection failed for ${failed.details.path}: ${failed.details.error ?? "unknown error"}`,
132
+ );
133
+ }
134
+ const display = areCheckpointRowsVisible();
135
+ for (const message of prepared) pi.sendMessage({ ...message, display });
136
+ pi.sendMessage({
137
+ customType: CONTINUATION_TYPE,
138
+ content: formatContinuationMessage(params.continue),
139
+ display: false,
140
+ details: {
141
+ v: 1,
142
+ kind: "checkpoint.continuation",
143
+ source: "checkpoint",
144
+ batchId: fileBatchId,
145
+ },
146
+ });
147
+ return {
148
+ content: [{ type: "text", text }],
149
+ details: { v: 1, kind: "checkpoint.checkpoint", checkpointId: toolCallId, fileBatchId },
150
+ };
151
+ },
152
+ });
153
+ }
154
+
155
+ class CheckpointComponent implements Component {
156
+ private readonly theme: Theme;
157
+ private readonly content: string;
158
+ private readonly expanded: boolean;
159
+ private readonly call: boolean;
160
+
161
+ constructor(theme: Theme, content: string, expanded: boolean, call: boolean) {
162
+ this.theme = theme;
163
+ this.content = content;
164
+ this.expanded = expanded;
165
+ this.call = call;
166
+ }
167
+
168
+ render(width: number): string[] {
169
+ if (!areCheckpointRowsVisible()) return [];
170
+ if (this.call) return [truncateToWidth(this.theme.fg("toolTitle", this.theme.bold(CHECKPOINT_TOOL)), width, "…")];
171
+ const displayContent = this.content.replace(CONVERSATION_MESSAGE_ID_ATTRIBUTE, "$1>");
172
+ if (!this.expanded) {
173
+ const preview = displayContent.replace(/\s+/g, " ").trim().slice(0, CHECKPOINT_PREVIEW_CHARACTERS);
174
+ const suffix = displayContent.length > CHECKPOINT_PREVIEW_CHARACTERS ? "…" : "";
175
+ return [
176
+ truncateToWidth(
177
+ `${this.theme.fg("dim", preview + suffix)} ${this.theme.fg("muted", `(${keyText("app.tools.expand")} to expand)`)}`,
178
+ width,
179
+ "…",
180
+ ),
181
+ ];
182
+ }
183
+
184
+ const bounded =
185
+ displayContent.length > CHECKPOINT_RENDER_CHARACTERS
186
+ ? `${displayContent.slice(0, CHECKPOINT_RENDER_CHARACTERS)}\n…`
187
+ : displayContent;
188
+ return wrapTextWithAnsi(this.theme.fg("dim", bounded), Math.max(1, width))
189
+ .slice(0, CHECKPOINT_RENDER_LINES)
190
+ .map((line) => truncateToWidth(line, width, "…"));
191
+ }
192
+
193
+ invalidate(): void {}
194
+ }
195
+
196
+ function resolveSelectedMessages(ids: readonly string[], branch: readonly SessionEntry[]): SessionMessageEntry[] {
197
+ const entriesById = new Map(branch.map((entry) => [entry.id, entry]));
198
+ const selectedIds = new Set(ids);
199
+ const latestUser = [...branch].reverse().find(isUserMessageEntry);
200
+ if (!latestUser) throw new Error("Checkpoint requires a current user message");
201
+ selectedIds.add(latestUser.id);
202
+
203
+ for (const id of selectedIds) {
204
+ const entry = entriesById.get(id);
205
+ if (!entry || !isConversationEntry(entry)) throw new Error(`Unknown user or assistant message ID: ${id}`);
206
+ if (entry.message.role === "assistant" && extractConversationText(entry.message) === "") {
207
+ throw new Error(`Assistant message ID has no text content: ${id}`);
208
+ }
209
+ }
210
+
211
+ return branch.filter(
212
+ (entry): entry is SessionMessageEntry => selectedIds.has(entry.id) && isConversationEntry(entry),
213
+ );
214
+ }
215
+
216
+ function formatCheckpointText(selected: readonly SessionMessageEntry[], params: CheckpointInput): string {
217
+ const conversation = selected
218
+ .map((entry) => {
219
+ const text = extractConversationText(entry.message);
220
+ if (text === undefined) throw new Error(`Message ID is not a user or assistant message: ${entry.id}`);
221
+ const role = entry.message.role === "user" ? "user" : "assistant";
222
+ return `<${role}-message id=${JSON.stringify(entry.id)}>\n${text}\n</${role}-message>`;
223
+ })
224
+ .join("\n\n");
225
+ const deferred = params.files
226
+ .filter(
227
+ (file): file is Extract<CheckpointInput["files"][number], { mode: "deferred" }> => file.mode === "deferred",
228
+ )
229
+ .map((file) => `- ${file.path} — ${file.when}`);
230
+
231
+ return [
232
+ `Conversation:\n${conversation || "(none)"}`,
233
+ `Work:\n${formatList(params.work)}`,
234
+ `Facts:\n${formatList(params.facts)}`,
235
+ `Decisions:\n${formatList(params.decisions)}`,
236
+ `Continue:\n${params.continue}`,
237
+ `Deferred files:\n${deferred.length ? deferred.join("\n") : "(none)"}`,
238
+ ].join("\n\n");
239
+ }
240
+
241
+ function formatContinuationMessage(continueNudge: string): string {
242
+ return formatCheckpointMessage("continuation", `${CONTINUATION_PREAMBLE}\n\n${continueNudge}`);
243
+ }
244
+
245
+ function buildFileInjectionRequest(
246
+ cwd: string,
247
+ batchId: string,
248
+ files: CheckpointInput["files"],
249
+ signal: AbortSignal | undefined,
250
+ ) {
251
+ const activeFiles: FileInjectionFile[] = [];
252
+ for (const file of files) {
253
+ if (file.mode === "deferred") continue;
254
+ if (file.mode === "outline") {
255
+ activeFiles.push({ path: file.path, mode: "outline" });
256
+ continue;
257
+ }
258
+ activeFiles.push({
259
+ path: file.path,
260
+ mode: "full",
261
+ ...(file.ranges === undefined ? {} : { ranges: file.ranges }),
262
+ });
263
+ }
264
+ return { cwd, source: "checkpoint", batchId, files: activeFiles, signal };
265
+ }
266
+
267
+ function formatList(items: readonly string[]): string {
268
+ return items.length ? items.map((item) => `- ${item}`).join("\n") : "(none)";
269
+ }
270
+
271
+ function isConversationEntry(entry: SessionEntry): entry is SessionMessageEntry {
272
+ return entry.type === "message" && (entry.message.role === "user" || entry.message.role === "assistant");
273
+ }
274
+
275
+ function isUserMessageEntry(entry: SessionEntry): entry is SessionMessageEntry {
276
+ return entry.type === "message" && entry.message.role === "user";
277
+ }