@shanepadgett/tau-agent 0.15.0 → 0.17.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 (57) hide show
  1. package/docs/subagents.md +25 -6
  2. package/extensions/attention/README.md +1 -0
  3. package/extensions/attention/index.ts +38 -0
  4. package/extensions/context/README.md +13 -3
  5. package/extensions/context/definitions.ts +3 -15
  6. package/extensions/context/evidence.ts +516 -0
  7. package/extensions/context/index.ts +114 -116
  8. package/extensions/context/panel.ts +60 -0
  9. package/extensions/context/settings.ts +30 -1
  10. package/extensions/context/sync.ts +171 -686
  11. package/extensions/context/validation.ts +4 -1
  12. package/extensions/context/write-scope.ts +109 -0
  13. package/extensions/context-pruning/README.md +22 -0
  14. package/extensions/context-pruning/file-evidence.ts +265 -0
  15. package/extensions/context-pruning/index.ts +379 -0
  16. package/extensions/context-pruning/projection.ts +108 -0
  17. package/extensions/context-pruning/prune.ts +346 -0
  18. package/extensions/context-pruning/render.ts +179 -0
  19. package/extensions/context-pruning/settings.ts +41 -0
  20. package/extensions/explore/README.md +3 -1
  21. package/extensions/explore/autoread.ts +85 -22
  22. package/extensions/explore/full-file-knowledge.ts +234 -0
  23. package/extensions/explore/index.ts +2 -3
  24. package/extensions/explore/read-cache.ts +150 -86
  25. package/extensions/explore/read-snapshots.ts +17 -4
  26. package/extensions/explore/read.ts +57 -38
  27. package/extensions/footer/index.ts +62 -60
  28. package/extensions/patch/README.md +1 -1
  29. package/extensions/patch/index.ts +19 -5
  30. package/extensions/run-summary/index.ts +5 -5
  31. package/extensions/silent-command-runner/README.md +1 -1
  32. package/extensions/silent-command-runner/index.ts +45 -28
  33. package/extensions/soul/prompt.ts +3 -1
  34. package/extensions/subagent/README.md +21 -5
  35. package/extensions/subagent/agents/context-sync.md +90 -0
  36. package/extensions/subagent/agents/{generalist.md → dormant/generalist.md} +6 -0
  37. package/extensions/subagent/agents/{scout.md → dormant/scout.md} +6 -0
  38. package/extensions/subagent/agents/review.md +48 -0
  39. package/extensions/subagent/agents/web-research.md +6 -0
  40. package/extensions/subagent/agents.ts +15 -2
  41. package/extensions/subagent/cmux-dashboard.ts +454 -0
  42. package/extensions/subagent/index.ts +181 -234
  43. package/extensions/subagent/render.ts +1 -1
  44. package/extensions/subagent/resume.ts +78 -0
  45. package/extensions/subagent/run.ts +213 -118
  46. package/extensions/subagent/runtime.ts +856 -0
  47. package/extensions/subagent/session-resource.ts +169 -0
  48. package/extensions/tau-help/help.md +7 -3
  49. package/extensions/turn-budget/index.ts +8 -36
  50. package/package.json +2 -2
  51. package/schemas/tau.schema.json +48 -1
  52. package/shared/context-pruning-state.ts +364 -0
  53. package/shared/events.ts +18 -0
  54. package/shared/model-fallback/index.ts +21 -10
  55. package/shared/model-fallback/types.ts +5 -3
  56. package/shared/settings/load.ts +78 -1
  57. package/shared/tool-row-state.ts +21 -1
@@ -5,13 +5,14 @@ import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
5
5
  import { onTauEvent, type TauAgentEvents } from "../../shared/events.js";
6
6
  import type { ToolRowStateStore } from "../../shared/tool-row-state.js";
7
7
  import { Marker, type MarkerState } from "@shanepadgett/tau-tui";
8
+ import { createCompleteFileMeta } from "./full-file-knowledge.ts";
8
9
  import type { ReadCacheMetaV1 } from "./read-cache.ts";
9
10
 
10
11
  const AUTOREAD_MESSAGE_TYPE = "tau.autoread";
11
12
 
12
13
  type AutoreadStatus = "reading" | "read" | "failed";
13
14
 
14
- interface AutoreadDetails {
15
+ export interface AutoreadDetails {
15
16
  rowId: string;
16
17
  path: string;
17
18
  cwd: string;
@@ -22,10 +23,77 @@ interface AutoreadDetails {
22
23
  readCache?: ReadCacheMetaV1;
23
24
  }
24
25
 
26
+ export interface PreparedAutoreadMessage {
27
+ customType: typeof AUTOREAD_MESSAGE_TYPE;
28
+ content: string;
29
+ display: true;
30
+ details: AutoreadDetails & { status: "read"; readCache: ReadCacheMetaV1 };
31
+ }
32
+
33
+ export async function prepareAutoreadMessage(options: {
34
+ rowId: string;
35
+ path: string;
36
+ cwd: string;
37
+ source: string;
38
+ batchId: string;
39
+ signal: AbortSignal | undefined;
40
+ isLifecycleCurrent: () => boolean;
41
+ maximumBytes?: number;
42
+ }): Promise<PreparedAutoreadMessage> {
43
+ assertPreparationCurrent(options.signal, options.isLifecycleCurrent);
44
+ const pathKey = resolve(options.cwd, options.path);
45
+ const bytes = options.signal ? await readFile(pathKey, { signal: options.signal }) : await readFile(pathKey);
46
+ if (options.maximumBytes !== undefined && bytes.byteLength > options.maximumBytes) {
47
+ throw new Error(`File exceeds the ${options.maximumBytes}-byte complete-file snapshot limit`);
48
+ }
49
+ const content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(bytes);
50
+ const messageContent = `${options.path}\n${content}`;
51
+ const totalLines = content.split("\n").length;
52
+ const readCache = createCompleteFileMeta({
53
+ pathKey,
54
+ presentation: "plain",
55
+ servedHash: createHash("sha256").update(content, "utf8").digest("hex"),
56
+ mode: "baseline",
57
+ sourceText: content,
58
+ returnedText: messageContent,
59
+ totalLines,
60
+ summary: `${totalLines} lines`,
61
+ }) satisfies ReadCacheMetaV1;
62
+ assertPreparationCurrent(options.signal, options.isLifecycleCurrent);
63
+ return {
64
+ customType: AUTOREAD_MESSAGE_TYPE,
65
+ content: messageContent,
66
+ display: true,
67
+ details: {
68
+ rowId: options.rowId,
69
+ path: options.path,
70
+ cwd: options.cwd,
71
+ source: options.source,
72
+ batchId: options.batchId,
73
+ status: "read",
74
+ readCache,
75
+ },
76
+ };
77
+ }
78
+
25
79
  export function registerAutoread(pi: ExtensionAPI, rowState: ToolRowStateStore): void {
80
+ let lifecycleGeneration = 0;
81
+ pi.on("session_start", () => {
82
+ lifecycleGeneration += 1;
83
+ });
84
+ pi.on("session_compact", () => {
85
+ lifecycleGeneration += 1;
86
+ });
87
+ pi.on("session_tree", () => {
88
+ lifecycleGeneration += 1;
89
+ });
90
+ pi.on("session_shutdown", () => {
91
+ lifecycleGeneration += 1;
92
+ });
26
93
  onTauEvent(pi, "explore.autoread", "tau:autoread.requested", async (data) => {
27
94
  const event = readAutoreadRequestedEvent(data);
28
95
  if (!event) return;
96
+ const generation = lifecycleGeneration;
29
97
  await Promise.all(
30
98
  event.files.map(async (file, index) => {
31
99
  const rowId = `${event.batchId}:${index}`;
@@ -37,28 +105,14 @@ export function registerAutoread(pi: ExtensionAPI, rowState: ToolRowStateStore):
37
105
  batchId: event.batchId,
38
106
  } satisfies Omit<AutoreadDetails, "status" | "error">;
39
107
  try {
40
- const pathKey = resolve(event.cwd, file.path);
41
- const bytes = await readFile(pathKey);
42
- const content = bytes.toString("utf8");
43
- const totalLines = content.split("\n").length;
44
- const readCache = {
45
- v: 1,
46
- pathKey,
47
- scopeKey: "full:n0",
48
- servedHash: createHash("sha256").update(bytes).digest("hex"),
49
- mode: "baseline",
50
- baselineTokens: Math.ceil(content.length / 4),
51
- returnedTokens: Math.ceil(content.length / 4),
52
- totalLines,
53
- summary: `${totalLines} lines`,
54
- } satisfies ReadCacheMetaV1;
55
- pi.sendMessage({
56
- customType: AUTOREAD_MESSAGE_TYPE,
57
- content: `${file.path}\n${content}`,
58
- display: true,
59
- details: { ...details, status: "read", readCache },
108
+ const message = await prepareAutoreadMessage({
109
+ ...details,
110
+ signal: undefined,
111
+ isLifecycleCurrent: () => generation === lifecycleGeneration,
60
112
  });
113
+ pi.sendMessage(message);
61
114
  } catch (error) {
115
+ if (generation !== lifecycleGeneration) return;
62
116
  const message = error instanceof Error ? error.message : String(error);
63
117
  pi.sendMessage({
64
118
  customType: AUTOREAD_MESSAGE_TYPE,
@@ -78,6 +132,11 @@ export function registerAutoread(pi: ExtensionAPI, rowState: ToolRowStateStore):
78
132
  });
79
133
  }
80
134
 
135
+ function assertPreparationCurrent(signal: AbortSignal | undefined, isLifecycleCurrent: () => boolean): void {
136
+ signal?.throwIfAborted();
137
+ if (!isLifecycleCurrent()) throw new Error("Autoread preparation crossed a session lifecycle boundary");
138
+ }
139
+
81
140
  function readAutoreadRequestedEvent(value: unknown): TauAgentEvents["tau:autoread.requested"] | undefined {
82
141
  if (!value || typeof value !== "object") return undefined;
83
142
  const record = value as Record<string, unknown>;
@@ -136,6 +195,7 @@ class AutoreadMessageComponent {
136
195
  this.path = path;
137
196
  this.status = status;
138
197
  this.theme = theme;
198
+ this.rowState.watch(this.rowId, () => this.invalidate());
139
199
  }
140
200
 
141
201
  render(width: number): string[] {
@@ -147,7 +207,10 @@ class AutoreadMessageComponent {
147
207
  }).render(width);
148
208
  }
149
209
 
150
- invalidate(): void {}
210
+ invalidate(): void {
211
+ // Rendering is stateless; this watcher gives the owning custom-message row
212
+ // the invalidation signal needed to redraw after a row-state snapshot.
213
+ }
151
214
 
152
215
  private markerState(): MarkerState {
153
216
  if (this.rowState.get(this.rowId) === "pruned") return "warning";
@@ -0,0 +1,234 @@
1
+ import { createHash } from "node:crypto";
2
+ import { generateUnifiedPatch, truncateHead } from "@earendil-works/pi-coding-agent";
3
+ import type { ReadCacheMetaV1, ReadCacheMode, ReadCachePresentation } from "./read-cache.ts";
4
+
5
+ export const COMPLETE_FILE_SCOPE = "full";
6
+ export const MAX_COMPLETE_FILE_SNAPSHOT_BYTES = 1024 * 1024;
7
+
8
+ export interface CompleteFileSelection {
9
+ text: string;
10
+ mode: ReadCacheMode;
11
+ summary: string;
12
+ }
13
+
14
+ export function createCompleteFileMeta(options: {
15
+ pathKey: string;
16
+ presentation: ReadCachePresentation;
17
+ servedHash: string;
18
+ mode: ReadCacheMode;
19
+ sourceText: string;
20
+ returnedText: string;
21
+ totalLines: number;
22
+ summary: string;
23
+ baseHash?: string;
24
+ }): ReadCacheMetaV1 {
25
+ return {
26
+ v: 1,
27
+ pathKey: options.pathKey,
28
+ scopeKey: COMPLETE_FILE_SCOPE,
29
+ presentation: options.presentation,
30
+ servedHash: options.servedHash,
31
+ baseHash: options.baseHash,
32
+ mode: options.mode,
33
+ baselineTokens: estimateTokens(options.sourceText),
34
+ returnedTokens: estimateTokens(options.returnedText),
35
+ totalLines: options.totalLines,
36
+ summary: options.summary,
37
+ };
38
+ }
39
+
40
+ export function readScopeKey(
41
+ completeFile: boolean,
42
+ startLine: number,
43
+ endLine: number,
44
+ presentation: ReadCachePresentation,
45
+ ): string {
46
+ return completeFile ? COMPLETE_FILE_SCOPE : `r:${startLine}:${endLine}:n${presentation === "line-numbered" ? 1 : 0}`;
47
+ }
48
+
49
+ export function decodeCompleteFileSource(options: {
50
+ content: unknown;
51
+ autoread: boolean;
52
+ pathHeader?: string;
53
+ presentation: ReadCachePresentation;
54
+ servedHash: string;
55
+ }): { valid: boolean; text?: string } {
56
+ const payload = textContent(options.content);
57
+ if (payload === undefined) return { valid: false };
58
+ const separator = payload.indexOf("\n");
59
+ if (
60
+ options.autoread &&
61
+ (separator < 0 || options.pathHeader === undefined || payload.slice(0, separator) !== options.pathHeader)
62
+ )
63
+ return { valid: false };
64
+ const body = options.autoread ? payload.slice(separator + 1) : payload;
65
+ const text = options.presentation === "plain" ? body : decodeLineNumbers(body);
66
+ if (text === undefined) return { valid: false };
67
+ const bytes = Buffer.byteLength(text, "utf8");
68
+ if (createHash("sha256").update(text, "utf8").digest("hex") !== options.servedHash) return { valid: false };
69
+ return bytes <= MAX_COMPLETE_FILE_SNAPSHOT_BYTES ? { valid: true, text } : { valid: true };
70
+ }
71
+
72
+ export function applyCompleteFileDiff(options: {
73
+ content: unknown;
74
+ baseText: string;
75
+ baseHash: string;
76
+ servedHash: string;
77
+ }): { valid: boolean; text?: string } {
78
+ if (hashText(options.baseText) !== options.baseHash) return { valid: false };
79
+ const payload = textContent(options.content);
80
+ if (payload === undefined) return { valid: false };
81
+ const separator = payload.indexOf("\n");
82
+ if (separator < 0 || !/^\[read: \d+ lines added, \d+ removed of \d+\]$/.test(payload.slice(0, separator))) {
83
+ return { valid: false };
84
+ }
85
+ const text = applyUnifiedDiff(options.baseText, payload.slice(separator + 1));
86
+ if (text === undefined || hashText(text) !== options.servedHash) return { valid: false };
87
+ return Buffer.byteLength(text, "utf8") <= MAX_COMPLETE_FILE_SNAPSHOT_BYTES ? { valid: true, text } : { valid: true };
88
+ }
89
+
90
+ export function selectCompleteFileResponse(options: {
91
+ displayPath: string;
92
+ currentText: string;
93
+ currentHash: string;
94
+ fullText: string;
95
+ totalLines: number;
96
+ recovery: boolean;
97
+ baseHash?: string;
98
+ baselineText?: string;
99
+ }): CompleteFileSelection {
100
+ if (options.recovery) return { text: options.fullText, mode: "recovery", summary: `${options.totalLines} lines` };
101
+ if (!options.baseHash) return { text: options.fullText, mode: "baseline", summary: `${options.totalLines} lines` };
102
+ if (options.baseHash === options.currentHash) {
103
+ const text = `unchanged, ${options.totalLines} lines`;
104
+ return { text, mode: "unchanged", summary: text };
105
+ }
106
+ if (options.baselineText !== undefined) {
107
+ const patch = generateUnifiedPatch(options.displayPath, options.baselineText, options.currentText, 3);
108
+ const counts = countDiffLines(patch);
109
+ const candidate = `[read: ${counts.added} lines added, ${counts.removed} removed of ${options.totalLines}]\n${patch}`;
110
+ if (!truncateHead(candidate).truncated && estimateTokens(candidate) < estimateTokens(options.fullText)) {
111
+ return { text: candidate, mode: "diff", summary: `+${counts.added} -${counts.removed}` };
112
+ }
113
+ }
114
+ return { text: options.fullText, mode: "baseline", summary: `${options.totalLines} lines` };
115
+ }
116
+
117
+ export function estimateTokens(text: string): number {
118
+ return Math.ceil(text.length / 4);
119
+ }
120
+
121
+ function textContent(content: unknown): string | undefined {
122
+ if (typeof content === "string") return content;
123
+ if (!Array.isArray(content) || content.length !== 1) return undefined;
124
+ const part = content[0];
125
+ if (!part || typeof part !== "object") return undefined;
126
+ const record = part as Record<string, unknown>;
127
+ return record.type === "text" && typeof record.text === "string" ? record.text : undefined;
128
+ }
129
+
130
+ function applyUnifiedDiff(source: string, patch: string): string | undefined {
131
+ const patchLines = patch.split("\n");
132
+ const oldHeaderIndex = patchLines.findIndex(
133
+ (line, index) => line.startsWith("--- ") && patchLines[index + 1]?.startsWith("+++ "),
134
+ );
135
+ if (oldHeaderIndex < 0) return undefined;
136
+ const sourceEndsWithNewline = source.endsWith("\n");
137
+ const sourceLines = source === "" ? [] : source.split("\n");
138
+ if (sourceEndsWithNewline) sourceLines.pop();
139
+ const output: string[] = [];
140
+ let sourceIndex = 0;
141
+ let patchIndex = oldHeaderIndex + 2;
142
+ let hunkCount = 0;
143
+ let outputEndsWithNewline = sourceEndsWithNewline;
144
+
145
+ while (patchIndex < patchLines.length) {
146
+ const header = patchLines[patchIndex];
147
+ if (header === "" && patchIndex === patchLines.length - 1) break;
148
+ if (header === undefined) return undefined;
149
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$/.exec(header);
150
+ if (!match) return undefined;
151
+ const oldStartValue = Number(match[1]);
152
+ const oldCount = match[2] === undefined ? 1 : Number(match[2]);
153
+ const newCount = match[4] === undefined ? 1 : Number(match[4]);
154
+ if (!Number.isSafeInteger(oldStartValue) || !Number.isSafeInteger(oldCount) || !Number.isSafeInteger(newCount)) {
155
+ return undefined;
156
+ }
157
+ const oldStart = oldCount === 0 ? oldStartValue : oldStartValue - 1;
158
+ if (oldStart < sourceIndex || oldStart > sourceLines.length) return undefined;
159
+ output.push(...sourceLines.slice(sourceIndex, oldStart));
160
+ sourceIndex = oldStart;
161
+ patchIndex += 1;
162
+ let consumedOld = 0;
163
+ let producedNew = 0;
164
+ let previousMarker: string | undefined;
165
+ let newSideHasNoFinalNewline = false;
166
+
167
+ while (patchIndex < patchLines.length && !patchLines[patchIndex]?.startsWith("@@ ")) {
168
+ const line = patchLines[patchIndex];
169
+ if (line === "" && patchIndex === patchLines.length - 1) break;
170
+ if (line === undefined) return undefined;
171
+ if (line === "\") {
172
+ if (previousMarker !== "+" && previousMarker !== "-" && previousMarker !== " ") return undefined;
173
+ if (previousMarker === "+" || previousMarker === " ") newSideHasNoFinalNewline = true;
174
+ patchIndex += 1;
175
+ continue;
176
+ }
177
+ const marker = line[0];
178
+ const value = line.slice(1);
179
+ if (marker === " ") {
180
+ if (sourceLines[sourceIndex] !== value) return undefined;
181
+ output.push(value);
182
+ sourceIndex += 1;
183
+ consumedOld += 1;
184
+ producedNew += 1;
185
+ } else if (marker === "-") {
186
+ if (sourceLines[sourceIndex] !== value) return undefined;
187
+ sourceIndex += 1;
188
+ consumedOld += 1;
189
+ } else if (marker === "+") {
190
+ output.push(value);
191
+ producedNew += 1;
192
+ } else {
193
+ return undefined;
194
+ }
195
+ previousMarker = marker;
196
+ patchIndex += 1;
197
+ }
198
+ if (consumedOld !== oldCount || producedNew !== newCount) return undefined;
199
+ if (sourceIndex === sourceLines.length) {
200
+ outputEndsWithNewline = newCount > 0 && !newSideHasNoFinalNewline;
201
+ }
202
+ hunkCount += 1;
203
+ }
204
+ if (hunkCount === 0) return undefined;
205
+ output.push(...sourceLines.slice(sourceIndex));
206
+ const text = output.join("\n");
207
+ return outputEndsWithNewline && output.length > 0 ? `${text}\n` : text;
208
+ }
209
+
210
+ function hashText(text: string): string {
211
+ return createHash("sha256").update(text, "utf8").digest("hex");
212
+ }
213
+
214
+ function decodeLineNumbers(content: string): string | undefined {
215
+ const lines = content.split("\n");
216
+ const decoded: string[] = [];
217
+ for (let index = 0; index < lines.length; index += 1) {
218
+ const prefix = `${index + 1}: `;
219
+ const line = lines[index];
220
+ if (line === undefined || !line.startsWith(prefix)) return undefined;
221
+ decoded.push(line.slice(prefix.length));
222
+ }
223
+ return decoded.join("\n");
224
+ }
225
+
226
+ function countDiffLines(patch: string): { added: number; removed: number } {
227
+ let added = 0;
228
+ let removed = 0;
229
+ for (const line of patch.split("\n")) {
230
+ if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
231
+ else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
232
+ }
233
+ return { added, removed };
234
+ }
@@ -30,9 +30,8 @@ export default function exploreExtension(pi: ExtensionAPI): void {
30
30
  });
31
31
  pi.on("session_start", () => {
32
32
  rowState.clear();
33
- readCache.clear();
34
33
  readSnapshots.clear();
35
34
  });
36
- pi.on("session_compact", () => readCache.clear());
37
- pi.on("session_tree", () => readCache.clear());
35
+ pi.on("session_compact", () => readSnapshots.clear());
36
+ pi.on("session_tree", () => readSnapshots.clear());
38
37
  }