@shanepadgett/tau-agent 0.16.0 → 0.18.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 (55) 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/run-summary/index.ts +5 -5
  29. package/extensions/silent-command-runner/README.md +1 -1
  30. package/extensions/silent-command-runner/index.ts +45 -28
  31. package/extensions/soul/prompt.ts +3 -1
  32. package/extensions/subagent/README.md +21 -5
  33. package/extensions/subagent/agents/context-sync.md +97 -0
  34. package/extensions/subagent/agents/{generalist.md → dormant/generalist.md} +6 -0
  35. package/extensions/subagent/agents/{scout.md → dormant/scout.md} +6 -0
  36. package/extensions/subagent/agents/review.md +48 -0
  37. package/extensions/subagent/agents/web-research.md +6 -0
  38. package/extensions/subagent/agents.ts +15 -2
  39. package/extensions/subagent/cmux-dashboard.ts +454 -0
  40. package/extensions/subagent/index.ts +181 -234
  41. package/extensions/subagent/render.ts +1 -1
  42. package/extensions/subagent/resume.ts +78 -0
  43. package/extensions/subagent/run.ts +213 -118
  44. package/extensions/subagent/runtime.ts +856 -0
  45. package/extensions/subagent/session-resource.ts +169 -0
  46. package/extensions/tau-help/help.md +6 -2
  47. package/extensions/turn-budget/index.ts +8 -36
  48. package/package.json +2 -2
  49. package/schemas/tau.schema.json +48 -1
  50. package/shared/context-pruning-state.ts +364 -0
  51. package/shared/events.ts +18 -0
  52. package/shared/model-fallback/index.ts +21 -10
  53. package/shared/model-fallback/types.ts +5 -3
  54. package/shared/settings/load.ts +78 -1
  55. package/shared/tool-row-state.ts +21 -1
@@ -68,6 +68,9 @@ export function formatContextValidationFailure(result: ContextValidationResult):
68
68
  );
69
69
  if (result.uncovered.length)
70
70
  output.push("", "Changed files with no context membership:", ...result.uncovered.map((path) => `- ${path}`));
71
- output.push("", "Run context_sync to reconcile the context catalog.");
71
+ output.push(
72
+ "",
73
+ "With validation enabled, Tau runs context-sync automatically after agent turns. Manual: /context-sync or /context-sync <nudge>.",
74
+ );
72
75
  return output.join("\n");
73
76
  }
@@ -0,0 +1,109 @@
1
+ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import type { GitRunner } from "../../shared/git.ts";
4
+
5
+ export type OutsidePathSnapshot = Map<string, Buffer | null>;
6
+
7
+ export function isContextCatalogPath(path: string): boolean {
8
+ const normalized = path.replaceAll("\\", "/");
9
+ return normalized === ".pi/contexts" || normalized.startsWith(".pi/contexts/");
10
+ }
11
+
12
+ export async function snapshotOutsideContext(git: GitRunner, root: string): Promise<OutsidePathSnapshot> {
13
+ const snapshot: OutsidePathSnapshot = new Map();
14
+ for (const path of await listDirtyPaths(git, root)) {
15
+ if (isContextCatalogPath(path)) continue;
16
+ snapshot.set(path, await readPathContent(root, path));
17
+ }
18
+ return snapshot;
19
+ }
20
+
21
+ export async function restoreOutsideContextMutations(
22
+ git: GitRunner,
23
+ root: string,
24
+ before: OutsidePathSnapshot,
25
+ ): Promise<string[]> {
26
+ const afterDirty = await listDirtyPaths(git, root);
27
+ const candidates = new Set([...before.keys(), ...afterDirty.filter((path) => !isContextCatalogPath(path))]);
28
+ const violations: string[] = [];
29
+ for (const path of [...candidates].sort((left, right) => left.localeCompare(right))) {
30
+ if (isContextCatalogPath(path)) continue;
31
+ const prior = before.has(path) ? before.get(path) : undefined;
32
+ const current = await readPathContent(root, path);
33
+ if (sameContent(prior, current)) continue;
34
+ violations.push(path);
35
+ await restorePath(git, root, path, prior);
36
+ }
37
+ return violations;
38
+ }
39
+
40
+ async function listDirtyPaths(git: GitRunner, root: string): Promise<string[]> {
41
+ const raw = await git.run(["status", "--porcelain=v2", "-z", "--untracked-files=all"], { cwd: root });
42
+ const parts = raw.split("\0").filter(Boolean);
43
+ const paths = new Set<string>();
44
+ for (let index = 0; index < parts.length; index++) {
45
+ const record = parts[index];
46
+ if (!record) continue;
47
+ if (record[0] === "?") {
48
+ paths.add(record.slice(2).replaceAll("\\", "/"));
49
+ continue;
50
+ }
51
+ const fields = record.split(" ");
52
+ if (record[0] === "1") {
53
+ const path = fields.slice(8).join(" ").replaceAll("\\", "/");
54
+ if (path) paths.add(path);
55
+ } else if (record[0] === "2") {
56
+ const path = fields.slice(9).join(" ").replaceAll("\\", "/");
57
+ const oldPath = parts[index + 1]?.replaceAll("\\", "/");
58
+ if (path) paths.add(path);
59
+ if (oldPath) paths.add(oldPath);
60
+ index++;
61
+ } else if (record[0] === "u") {
62
+ const path = fields.slice(10).join(" ").replaceAll("\\", "/");
63
+ if (path) paths.add(path);
64
+ }
65
+ }
66
+ return [...paths].sort((left, right) => left.localeCompare(right));
67
+ }
68
+
69
+ async function readPathContent(root: string, path: string): Promise<Buffer | null> {
70
+ try {
71
+ return await readFile(join(root, path));
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function sameContent(left: Buffer | null | undefined, right: Buffer | null): boolean {
78
+ if (left === undefined) return false;
79
+ if (left === null || right === null) return left === right;
80
+ return left.equals(right);
81
+ }
82
+
83
+ async function restorePath(
84
+ git: GitRunner,
85
+ root: string,
86
+ path: string,
87
+ prior: Buffer | null | undefined,
88
+ ): Promise<void> {
89
+ const absolute = join(root, path);
90
+ if (prior === undefined) {
91
+ // Was clean before the run: restore tracked paths from HEAD, delete new untracked paths.
92
+ const tracked = await git.run(["ls-files", "--error-unmatch", "--", path], {
93
+ cwd: root,
94
+ optional: true,
95
+ });
96
+ if (tracked) {
97
+ await git.run(["restore", "--worktree", "--source=HEAD", "--", path], { cwd: root });
98
+ return;
99
+ }
100
+ await unlink(absolute).catch(() => {});
101
+ return;
102
+ }
103
+ if (prior === null) {
104
+ await unlink(absolute).catch(() => {});
105
+ return;
106
+ }
107
+ await mkdir(dirname(absolute), { recursive: true });
108
+ await writeFile(absolute, prior);
109
+ }
@@ -0,0 +1,22 @@
1
+ # Context Pruning
2
+
3
+ Context Pruning removes stale tool evidence from the context sent to the model while leaving the session transcript intact. The agent can create a prune anchor after broad exploration has converged or enough irrelevant evidence has accumulated.
4
+
5
+ Before calling `context_prune`, the agent records durable conclusions and its next action in visible prose. It can retain exact tool exchanges, retain current knowledge of selected files, and defer files until a stated condition applies. The tool prepares any required file snapshots before applying the anchor, skips attempts with low estimated savings, and continues the current agent run automatically.
6
+
7
+ After tool-using turns, Tau occasionally adds a compact context marker. At lower usage it is informational. Under pressure it asks the agent to finish a coherent step and prune only when the savings are worthwhile. The full steering text stays hidden, and the agent is told not to discuss context management.
8
+
9
+ Run `/prune` with no arguments to ask the agent to create an anchor and continue its unfinished work. The same validation and minimum-savings rules still apply, so a low-value attempt is skipped.
10
+
11
+ Prune anchors, automatic marker boundaries, and warning-colored Tau tool rows follow the active session branch. Switching branches restores that branch's state. Pi's manual, threshold, and overflow compaction continue to work normally.
12
+
13
+ Manual UI check: Tau-owned tool rows and autoreads should turn warning-colored when pruned. Native Pi rows and fallback rows that don't use Tau's row-state renderer won't change color; pruning and read-cache behavior still apply to them.
14
+
15
+ ## Settings
16
+
17
+ Settings live under `extensions.contextPruning` in Tau settings.
18
+
19
+ - `enabled`: enables the tool, `/prune`, automatic markers, branch replay, context projection, and pruned-row display. Defaults to `true`.
20
+ - `nudgeEveryPercent`: context-growth interval between automatic markers. Defaults to `20`.
21
+ - `pressurePercent`: usage above which a marker suggests pruning after the current coherent step. Defaults to `50`.
22
+ - `minimumReclaimTokens`: minimum estimated saving required to apply a prune. Defaults to `8000`.
@@ -0,0 +1,265 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { basename, dirname, relative, resolve, sep } from "node:path";
3
+ import { estimateTokens, type ContextEvent } from "@earendil-works/pi-coding-agent";
4
+ import { prepareAutoreadMessage, type PreparedAutoreadMessage } from "../explore/autoread.ts";
5
+ import { COMPLETE_FILE_SCOPE, MAX_COMPLETE_FILE_SNAPSHOT_BYTES } from "../explore/full-file-knowledge.ts";
6
+ import { readMetaFromMessage, replayReadCache } from "../explore/read-cache.ts";
7
+
8
+ type ContextMessage = ContextEvent["messages"][number];
9
+ type ReadEvidenceMessage =
10
+ | Extract<ContextMessage, { role: "toolResult" }>
11
+ | Extract<ContextMessage, { role: "custom" }>;
12
+
13
+ interface RequestedFile {
14
+ path: string;
15
+ relevance: string;
16
+ }
17
+
18
+ interface RequestedDeferredFile {
19
+ path: string;
20
+ reason: string;
21
+ relevantWhen: string;
22
+ }
23
+
24
+ interface CanonicalFile<T> {
25
+ canonicalPath: string;
26
+ displayPath: string;
27
+ request: T;
28
+ }
29
+
30
+ interface FileEvidenceSelection {
31
+ retainedToolCallIds: ReadonlySet<string>;
32
+ retainedAutoreadRowIds: ReadonlySet<string>;
33
+ preparedSnapshots: readonly PreparedAutoreadMessage[];
34
+ refreshedFiles: readonly { path: string; rowId: string; servedHash: string }[];
35
+ }
36
+
37
+ export async function canonicalizeFileSelections(options: {
38
+ cwd: string;
39
+ keepFiles: readonly RequestedFile[];
40
+ deferFiles: readonly RequestedDeferredFile[];
41
+ }): Promise<{
42
+ keepFiles: CanonicalFile<RequestedFile>[];
43
+ deferFiles: CanonicalFile<RequestedDeferredFile>[];
44
+ }> {
45
+ const seen = new Map<string, string>();
46
+ const canonicalCwd = await canonicalizePath(resolve(options.cwd));
47
+ const canonicalize = async <T extends { path: string }>(request: T): Promise<CanonicalFile<T>> => {
48
+ const requested = resolve(options.cwd, request.path.replace(/^@/, ""));
49
+ const canonicalPath = await canonicalizePath(requested);
50
+ const previous = seen.get(canonicalPath);
51
+ if (previous !== undefined) {
52
+ throw new Error(`Duplicate file selection: ${request.path} resolves to the same path as ${previous}`);
53
+ }
54
+ seen.set(canonicalPath, request.path);
55
+ const relativePath = relative(canonicalCwd, canonicalPath);
56
+ const displayPath =
57
+ relativePath !== "" && relativePath !== ".." && !relativePath.startsWith(`..${sep}`)
58
+ ? relativePath
59
+ : canonicalPath;
60
+ return { canonicalPath, displayPath, request };
61
+ };
62
+
63
+ const keepFiles: CanonicalFile<RequestedFile>[] = [];
64
+ for (const request of options.keepFiles) keepFiles.push(await canonicalize(request));
65
+ const deferFiles: CanonicalFile<RequestedDeferredFile>[] = [];
66
+ for (const request of options.deferFiles) deferFiles.push(await canonicalize(request));
67
+ return { keepFiles, deferFiles };
68
+ }
69
+
70
+ export async function selectFileEvidence(options: {
71
+ cwd: string;
72
+ messages: readonly ContextMessage[];
73
+ files: readonly CanonicalFile<RequestedFile>[];
74
+ anchorToolCallId: string;
75
+ signal: AbortSignal | undefined;
76
+ isLifecycleCurrent: () => boolean;
77
+ }): Promise<FileEvidenceSelection> {
78
+ const entries = options.messages.map((message) =>
79
+ message.role === "custom" ? { type: "custom_message", ...message } : { type: "message", message },
80
+ );
81
+ const replay = replayReadCache(entries, options.cwd);
82
+ const retainedToolCallIds = new Set<string>();
83
+ const retainedAutoreadRowIds = new Set<string>();
84
+ const preparedSnapshots: PreparedAutoreadMessage[] = [];
85
+ const refreshedFiles: Array<{ path: string; rowId: string; servedHash: string }> = [];
86
+ const rowMessages = indexEvidenceRows(options.messages);
87
+
88
+ for (let fileIndex = 0; fileIndex < options.files.length; fileIndex += 1) {
89
+ const file = options.files[fileIndex];
90
+ if (!file) continue;
91
+ options.signal?.throwIfAborted();
92
+ if (!options.isLifecycleCurrent()) throw new Error("Prune preparation crossed a session lifecycle boundary");
93
+
94
+ const rawRows: ContextMessage[] = [];
95
+ for (const message of options.messages) {
96
+ const pathKey = rawReadPath(message, options.cwd);
97
+ if (pathKey && (await canonicalizePath(pathKey)) === file.canonicalPath) rawRows.push(message);
98
+ }
99
+ if (rawRows.length === 0) throw new Error(`${file.displayPath} has no prior read evidence; read the file first`);
100
+ const rawCompleteRows = rawRows.filter((message) => rawScopeKey(message) === COMPLETE_FILE_SCOPE);
101
+ if (rawCompleteRows.length === 0) {
102
+ throw new Error(`${file.displayPath} has only partial read evidence; read the complete file first`);
103
+ }
104
+ const acceptedRows: Array<(typeof replay.acceptedRows)[number]> = [];
105
+ for (const row of replay.acceptedRows) {
106
+ if (row.scopeKey === COMPLETE_FILE_SCOPE && (await canonicalizePath(row.pathKey)) === file.canonicalPath)
107
+ acceptedRows.push(row);
108
+ }
109
+ if (acceptedRows.length === 0) {
110
+ throw new Error(`${file.displayPath} has malformed complete-file evidence; read the file again`);
111
+ }
112
+
113
+ const rowId = `${options.anchorToolCallId}:${fileIndex}`;
114
+ const prepared = await prepareAutoreadMessage({
115
+ rowId,
116
+ path: file.displayPath,
117
+ cwd: options.cwd,
118
+ source: "context-pruning",
119
+ batchId: options.anchorToolCallId,
120
+ signal: options.signal,
121
+ isLifecycleCurrent: options.isLifecycleCurrent,
122
+ maximumBytes: MAX_COMPLETE_FILE_SNAPSHOT_BYTES,
123
+ });
124
+ const currentHash = prepared.details.readCache.servedHash;
125
+ const unchangedBaseline = acceptedRows.find(
126
+ (row) => (row.meta.mode === "baseline" || row.meta.mode === "recovery") && row.meta.servedHash === currentHash,
127
+ );
128
+ if (unchangedBaseline) {
129
+ retainRows(unchangedBaseline.dependencyRowIds, rowMessages, retainedToolCallIds, retainedAutoreadRowIds);
130
+ continue;
131
+ }
132
+
133
+ let chain = replay.completeFileChains.get(file.canonicalPath);
134
+ if (!chain) {
135
+ for (const candidate of replay.completeFileChains.values()) {
136
+ if ((await canonicalizePath(candidate.pathKey)) === file.canonicalPath) {
137
+ chain = candidate;
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ if (chain?.servedHash === currentHash) {
143
+ const chainCost = chain.rowIds.reduce((total, id) => total + (rowMessages.get(id)?.tokens ?? 0), 0);
144
+ const snapshotCost = estimatePreparedSnapshot(prepared);
145
+ if (chainCost <= snapshotCost) {
146
+ retainRows(chain.rowIds, rowMessages, retainedToolCallIds, retainedAutoreadRowIds);
147
+ continue;
148
+ }
149
+ }
150
+
151
+ preparedSnapshots.push(prepared);
152
+ retainedAutoreadRowIds.add(rowId);
153
+ refreshedFiles.push({ path: file.displayPath, rowId, servedHash: currentHash });
154
+ }
155
+
156
+ return { retainedToolCallIds, retainedAutoreadRowIds, preparedSnapshots, refreshedFiles };
157
+ }
158
+
159
+ function indexEvidenceRows(
160
+ messages: readonly ContextMessage[],
161
+ ): Map<string, { tokens: number; kind: "tool" | "autoread" }> {
162
+ const rows = new Map<string, { tokens: number; kind: "tool" | "autoread" }>();
163
+ const calls = new Map<string, { message: Extract<ContextMessage, { role: "assistant" }>; blockIndex: number }>();
164
+ for (const message of messages) {
165
+ if (message.role === "assistant") {
166
+ for (let blockIndex = 0; blockIndex < message.content.length; blockIndex += 1) {
167
+ const block = message.content[blockIndex];
168
+ if (block?.type === "toolCall") calls.set(block.id, { message, blockIndex });
169
+ }
170
+ continue;
171
+ }
172
+ if (message.role === "toolResult" && message.toolName === "read") {
173
+ const call = calls.get(message.toolCallId);
174
+ const block = call?.message.content[call.blockIndex];
175
+ if (call && block?.type === "toolCall") {
176
+ rows.set(message.toolCallId, {
177
+ tokens: estimateTokens({ ...call.message, content: [block] }) + estimateTokens(message),
178
+ kind: "tool",
179
+ });
180
+ }
181
+ continue;
182
+ }
183
+ if (message.role === "custom" && message.customType === "tau.autoread") {
184
+ const rowId = autoreadRowId(message);
185
+ if (rowId) rows.set(rowId, { tokens: estimateTokens(message), kind: "autoread" });
186
+ }
187
+ }
188
+ return rows;
189
+ }
190
+
191
+ function retainRows(
192
+ rowIds: readonly string[],
193
+ rows: ReadonlyMap<string, { kind: "tool" | "autoread" }>,
194
+ toolIds: Set<string>,
195
+ autoreadIds: Set<string>,
196
+ ): void {
197
+ for (const rowId of rowIds) {
198
+ const row = rows.get(rowId);
199
+ if (!row) throw new Error(`Complete-file dependency row is absent from projected context: ${rowId}`);
200
+ if (row.kind === "tool") toolIds.add(rowId);
201
+ else autoreadIds.add(rowId);
202
+ }
203
+ }
204
+
205
+ function estimatePreparedSnapshot(prepared: PreparedAutoreadMessage): number {
206
+ const message: ContextMessage = {
207
+ role: "custom",
208
+ customType: prepared.customType,
209
+ content: prepared.content,
210
+ display: prepared.display,
211
+ details: prepared.details,
212
+ timestamp: 0,
213
+ };
214
+ return estimateTokens(message);
215
+ }
216
+
217
+ function rawReadPath(message: ContextMessage, cwd: string): string | undefined {
218
+ const meta = readMetaFromMessage(message);
219
+ if (meta) return resolve(cwd, meta.pathKey);
220
+ if (!isReadEvidence(message) || !isRecord(message.details) || !isRecord(message.details.readCache)) return undefined;
221
+ const pathKey = message.details.readCache.pathKey;
222
+ return typeof pathKey === "string" ? resolve(cwd, pathKey) : undefined;
223
+ }
224
+
225
+ function rawScopeKey(message: ContextMessage): string | undefined {
226
+ if (!isReadEvidence(message) || !isRecord(message.details) || !isRecord(message.details.readCache)) return undefined;
227
+ return typeof message.details.readCache.scopeKey === "string" ? message.details.readCache.scopeKey : undefined;
228
+ }
229
+
230
+ function isReadEvidence(message: ContextMessage): message is ReadEvidenceMessage {
231
+ return (
232
+ (message.role === "toolResult" && message.toolName === "read") ||
233
+ (message.role === "custom" && message.customType === "tau.autoread")
234
+ );
235
+ }
236
+
237
+ function autoreadRowId(message: ContextMessage): string | undefined {
238
+ if (message.role !== "custom" || !isRecord(message.details)) return undefined;
239
+ return typeof message.details.rowId === "string" ? message.details.rowId : undefined;
240
+ }
241
+
242
+ function isMissingPathError(error: unknown): boolean {
243
+ return isRecord(error) && error.code === "ENOENT";
244
+ }
245
+
246
+ async function canonicalizePath(path: string): Promise<string> {
247
+ const missingSegments: string[] = [];
248
+ let cursor = path;
249
+ while (true) {
250
+ try {
251
+ const canonicalAncestor = await realpath(cursor);
252
+ return resolve(canonicalAncestor, ...missingSegments.reverse());
253
+ } catch (error) {
254
+ if (!isMissingPathError(error)) throw error;
255
+ const parent = dirname(cursor);
256
+ if (parent === cursor) return path;
257
+ missingSegments.push(basename(cursor));
258
+ cursor = parent;
259
+ }
260
+ }
261
+ }
262
+
263
+ function isRecord(value: unknown): value is Record<string, unknown> {
264
+ return typeof value === "object" && value !== null && !Array.isArray(value);
265
+ }