@shanepadgett/tau-agent 0.5.0 → 0.6.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.
@@ -2,17 +2,21 @@ import {
2
2
  createReadToolDefinition,
3
3
  DEFAULT_MAX_BYTES,
4
4
  formatSize,
5
+ generateUnifiedPatch,
5
6
  truncateHead,
6
7
  type ReadToolDetails,
7
8
  type ToolDefinition,
8
9
  } from "@earendil-works/pi-coding-agent";
9
- import { Text } from "@earendil-works/pi-tui";
10
+ import { Container, Text } from "@earendil-works/pi-tui";
11
+ import { createHash } from "node:crypto";
10
12
  import { readFile } from "node:fs/promises";
11
13
  import { isAbsolute, resolve } from "node:path";
12
14
  import { Type, type Static } from "typebox";
13
15
  import { formatToolRowTitle, type ToolRowStateStore } from "../../shared/tool-row-state.js";
14
16
  import { normalizeCountLimit } from "./limits.ts";
15
17
  import { stripLeadingAt } from "./path-display.ts";
18
+ import { createReadCacheStore, type ReadCacheMetaV1, type ReadCacheStore } from "./read-cache.ts";
19
+ import { createReadSnapshotStore, type ReadSnapshotStore } from "./read-snapshots.ts";
16
20
 
17
21
  const readSchema = Type.Object({
18
22
  path: Type.String({ description: "Path to the file to read (relative or absolute)" }),
@@ -23,11 +27,26 @@ const readSchema = Type.Object({
23
27
 
24
28
  type ReadToolInput = Static<typeof readSchema>;
25
29
  type BaseReadDefinition = ReturnType<typeof createReadToolDefinition>;
26
- type ReadDefinition = ToolDefinition<typeof readSchema, ReadToolDetails | undefined>;
30
+ interface ExploreReadDetails extends ReadToolDetails {
31
+ readCache?: ReadCacheMetaV1;
32
+ }
33
+ type ReadDefinition = ToolDefinition<typeof readSchema, ExploreReadDetails | undefined>;
27
34
  type ReadExecute = ReadDefinition["execute"];
28
35
  type ReadRenderCall = NonNullable<ReadDefinition["renderCall"]>;
29
36
  type ReadRenderResult = NonNullable<ReadDefinition["renderResult"]>;
30
37
 
38
+ interface BaselineTextResult {
39
+ text: string;
40
+ details: ReadToolDetails | undefined;
41
+ totalLines: number;
42
+ startLine: number;
43
+ endLine: number;
44
+ completeFile: boolean;
45
+ scopeKey: string;
46
+ summary: string;
47
+ cacheable: boolean;
48
+ }
49
+
31
50
  const readDefinitionByCwd = new Map<string, BaseReadDefinition>();
32
51
 
33
52
  function readDefinitionForCwd(cwd: string): BaseReadDefinition {
@@ -67,7 +86,114 @@ function renderCallSummary(args: ReadToolInput | undefined): string {
67
86
  return `${path}:${start}${end === "" ? "" : `-${end}`}`;
68
87
  }
69
88
 
70
- export function createExploreReadTool(rowState: ToolRowStateStore): ReadDefinition {
89
+ function estimateTokens(text: string): number {
90
+ return Math.ceil(text.length / 4);
91
+ }
92
+
93
+ function baselineText(text: string, params: ReadToolInput): BaselineTextResult {
94
+ const allLines = text.split("\n");
95
+ const startIndex = params.offset ? Math.max(0, params.offset - 1) : 0;
96
+ const startLine = startIndex + 1;
97
+ if (startIndex >= allLines.length) {
98
+ throw new Error(`Offset ${params.offset} is beyond end of file (${allLines.length} lines total)`);
99
+ }
100
+
101
+ const selectedEnd =
102
+ params.limit === undefined ? allLines.length : Math.min(startIndex + params.limit, allLines.length);
103
+ const selectedLines = allLines.slice(startIndex, selectedEnd);
104
+ const selectedContent = selectedLines
105
+ .map((line, index) => (params.lineNumbers ? `${startLine + index}: ${line}` : line))
106
+ .join("\n");
107
+ const truncation = truncateHead(selectedContent);
108
+ let outputText: string;
109
+ let details: ReadToolDetails | undefined;
110
+ let outputLines = selectedLines.length;
111
+ let cacheable = true;
112
+
113
+ if (truncation.firstLineExceedsLimit) {
114
+ const firstLineSize = formatSize(Buffer.byteLength(selectedLines[0] ?? "", "utf-8"));
115
+ outputText = `[Line ${startLine} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLine}p' ${params.path} | head -c ${DEFAULT_MAX_BYTES}]`;
116
+ details = { truncation };
117
+ outputLines = 0;
118
+ cacheable = false;
119
+ } else if (truncation.truncated) {
120
+ outputLines = truncation.outputLines;
121
+ const endLineDisplay = startLine + outputLines - 1;
122
+ const nextOffset = endLineDisplay + 1;
123
+ outputText = truncation.content;
124
+ outputText +=
125
+ truncation.truncatedBy === "lines"
126
+ ? `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${allLines.length}. Use offset=${nextOffset} to continue.]`
127
+ : `\n\n[Showing lines ${startLine}-${endLineDisplay} of ${allLines.length} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
128
+ details = { truncation };
129
+ } else if (selectedEnd < allLines.length) {
130
+ outputText = `${truncation.content}\n\n[${allLines.length - selectedEnd} more lines in file. Use offset=${selectedEnd + 1} to continue.]`;
131
+ } else {
132
+ outputText = truncation.content;
133
+ }
134
+
135
+ const endLine = outputLines === 0 ? startLine : startLine + outputLines - 1;
136
+ const completeFile = startIndex === 0 && selectedEnd === allLines.length && !truncation.truncated;
137
+ const scopeKey = `${completeFile ? "full" : `r:${startLine}:${endLine}`}:n${params.lineNumbers ? 1 : 0}`;
138
+ const summary = completeFile ? `${allLines.length} lines` : `${outputLines} lines`;
139
+ return {
140
+ text: outputText,
141
+ details,
142
+ totalLines: allLines.length,
143
+ startLine,
144
+ endLine,
145
+ completeFile,
146
+ scopeKey,
147
+ summary,
148
+ cacheable,
149
+ };
150
+ }
151
+
152
+ function withMeta(baseline: BaselineTextResult, meta: ReadCacheMetaV1, text = baseline.text) {
153
+ return {
154
+ content: [{ type: "text" as const, text }],
155
+ details: { ...baseline.details, readCache: meta },
156
+ };
157
+ }
158
+
159
+ function createMeta(
160
+ baseline: BaselineTextResult,
161
+ pathKey: string,
162
+ hash: string,
163
+ mode: ReadCacheMetaV1["mode"],
164
+ returnedText: string,
165
+ baseHash?: string,
166
+ summary = baseline.summary,
167
+ ): ReadCacheMetaV1 {
168
+ return {
169
+ v: 1,
170
+ pathKey,
171
+ scopeKey: baseline.scopeKey,
172
+ servedHash: hash,
173
+ baseHash,
174
+ mode,
175
+ baselineTokens: estimateTokens(baseline.text),
176
+ returnedTokens: estimateTokens(returnedText),
177
+ totalLines: baseline.totalLines,
178
+ summary,
179
+ };
180
+ }
181
+
182
+ function countDiffLines(patch: string): { added: number; removed: number } {
183
+ let added = 0;
184
+ let removed = 0;
185
+ for (const line of patch.split("\n")) {
186
+ if (line.startsWith("+") && !line.startsWith("+++")) added += 1;
187
+ else if (line.startsWith("-") && !line.startsWith("---")) removed += 1;
188
+ }
189
+ return { added, removed };
190
+ }
191
+
192
+ export function createExploreReadTool(
193
+ rowState: ToolRowStateStore,
194
+ cache: ReadCacheStore = createReadCacheStore(),
195
+ snapshots: ReadSnapshotStore = createReadSnapshotStore(),
196
+ ): ReadDefinition {
71
197
  const baseDefinition = readDefinitionForCwd(process.cwd());
72
198
  return {
73
199
  ...baseDefinition,
@@ -81,7 +207,7 @@ export function createExploreReadTool(rowState: ToolRowStateStore): ReadDefiniti
81
207
  ) {
82
208
  const definition = readDefinitionForCwd(ctx.cwd);
83
209
  const normalized = normalizeReadParams(params);
84
- const path = isAbsolute(normalized.path) ? normalized.path : resolve(ctx.cwd, normalized.path);
210
+ const path = isAbsolute(normalized.path) ? resolve(normalized.path) : resolve(ctx.cwd, normalized.path);
85
211
  const buffer = await readFile(path);
86
212
  if (isSupportedImage(buffer)) {
87
213
  return definition.execute(
@@ -94,42 +220,49 @@ export function createExploreReadTool(rowState: ToolRowStateStore): ReadDefiniti
94
220
  }
95
221
 
96
222
  if (signal?.aborted) throw new Error("Operation aborted");
97
- const allLines = buffer.toString("utf-8").split("\n");
98
- const startLine = normalized.offset ? Math.max(0, normalized.offset - 1) : 0;
99
- const startLineDisplay = startLine + 1;
100
- if (startLine >= allLines.length) {
101
- throw new Error(`Offset ${normalized.offset} is beyond end of file (${allLines.length} lines total)`);
223
+ let text: string;
224
+ try {
225
+ text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
226
+ } catch {
227
+ return definition.execute(toolCallId, normalized, signal, onUpdate, ctx);
228
+ }
229
+
230
+ const baseline = baselineText(text, normalized);
231
+ if (!baseline.cacheable) {
232
+ return { content: [{ type: "text", text: baseline.text }], details: baseline.details };
102
233
  }
234
+ const hash = createHash("sha256").update(buffer).digest("hex");
235
+ const decision = cache.decision(ctx, path, baseline.scopeKey);
236
+ let output = baseline.text;
237
+ let mode: ReadCacheMetaV1["mode"] = decision.recovery ? "recovery" : "baseline";
238
+ let summary = baseline.summary;
103
239
 
104
- const endLine =
105
- normalized.limit === undefined ? allLines.length : Math.min(startLine + normalized.limit, allLines.length);
106
- const selectedLines = allLines.slice(startLine, endLine);
107
- const selectedContent = selectedLines
108
- .map((line, index) => (normalized.lineNumbers ? `${startLineDisplay + index}: ${line}` : line))
109
- .join("\n");
110
- const truncation = truncateHead(selectedContent);
111
- let outputText: string;
112
- let details: ReadToolDetails | undefined;
113
- if (truncation.firstLineExceedsLimit) {
114
- const firstLineSize = formatSize(Buffer.byteLength(selectedLines[0] ?? "", "utf-8"));
115
- outputText = `[Line ${startLineDisplay} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit. Use bash: sed -n '${startLineDisplay}p' ${normalized.path} | head -c ${DEFAULT_MAX_BYTES}]`;
116
- details = { truncation };
117
- } else if (truncation.truncated) {
118
- const endLineDisplay = startLineDisplay + truncation.outputLines - 1;
119
- const nextOffset = endLineDisplay + 1;
120
- outputText = truncation.content;
121
- outputText +=
122
- truncation.truncatedBy === "lines"
123
- ? `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${allLines.length}. Use offset=${nextOffset} to continue.]`
124
- : `\n\n[Showing lines ${startLineDisplay}-${endLineDisplay} of ${allLines.length} (${formatSize(DEFAULT_MAX_BYTES)} limit). Use offset=${nextOffset} to continue.]`;
125
- details = { truncation };
126
- } else if (endLine < allLines.length) {
127
- outputText = `${truncation.content}\n\n[${allLines.length - endLine} more lines in file. Use offset=${endLine + 1} to continue.]`;
128
- } else {
129
- outputText = truncation.content;
240
+ if (!decision.recovery && decision.baseHash === hash) {
241
+ output = baseline.completeFile
242
+ ? `unchanged, ${baseline.totalLines} lines`
243
+ : `unchanged, lines ${baseline.startLine}-${baseline.endLine} of ${baseline.totalLines}`;
244
+ mode = "unchanged";
245
+ summary = output;
246
+ } else if (!decision.recovery && decision.baseHash && baseline.completeFile && !normalized.lineNumbers) {
247
+ const baseText = snapshots.get(decision.baseHash);
248
+ if (baseText !== undefined) {
249
+ const patch = generateUnifiedPatch(normalized.path, baseText, text, 3);
250
+ const counts = countDiffLines(patch);
251
+ const candidate = `[read: ${counts.added} lines added, ${counts.removed} removed of ${baseline.totalLines}]\n${patch}`;
252
+ const candidateTruncation = truncateHead(candidate);
253
+ if (!candidateTruncation.truncated && estimateTokens(candidate) < estimateTokens(baseline.text)) {
254
+ output = candidate;
255
+ mode = "diff";
256
+ summary = `+${counts.added} -${counts.removed}`;
257
+ }
258
+ }
130
259
  }
131
260
 
132
- return { content: [{ type: "text", text: outputText }], details };
261
+ if (signal?.aborted) throw new Error("Operation aborted");
262
+ snapshots.set(hash, text, buffer.byteLength);
263
+ const meta = createMeta(baseline, path, hash, mode, output, decision.baseHash, summary);
264
+ cache.record(ctx, meta);
265
+ return withMeta(baseline, meta, output);
133
266
  },
134
267
  renderCall(
135
268
  args: Parameters<ReadRenderCall>[0],
@@ -138,6 +271,10 @@ export function createExploreReadTool(rowState: ToolRowStateStore): ReadDefiniti
138
271
  ) {
139
272
  rowState.watch(context.toolCallId, context.invalidate);
140
273
  const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
274
+ if (context.executionStarted) {
275
+ text.setText("");
276
+ return text;
277
+ }
141
278
  const title = formatToolRowTitle(rowState, context.toolCallId, "read", theme);
142
279
  text.setText(`${title} ${theme.fg("muted", renderCallSummary(args))}`);
143
280
  return text;
@@ -148,13 +285,32 @@ export function createExploreReadTool(rowState: ToolRowStateStore): ReadDefiniti
148
285
  theme: Parameters<ReadRenderResult>[2],
149
286
  context: Parameters<ReadRenderResult>[3],
150
287
  ) {
151
- if (!context.expanded) {
152
- const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
153
- text.setText("");
154
- return text;
288
+ rowState.watch(context.toolCallId, context.invalidate);
289
+ const container = new Container();
290
+ const title = formatToolRowTitle(rowState, context.toolCallId, "read", theme);
291
+ const args = context.args as ReadToolInput | undefined;
292
+ const details = result.details as ExploreReadDetails | undefined;
293
+ const summary = details?.readCache?.summary;
294
+ const summaryText = context.isError
295
+ ? theme.fg("error", "error")
296
+ : summary
297
+ ? theme.fg(
298
+ details?.readCache?.mode === "unchanged"
299
+ ? "success"
300
+ : details?.readCache?.mode === "diff"
301
+ ? "accent"
302
+ : "muted",
303
+ summary,
304
+ )
305
+ : "";
306
+ const header = `${title} ${theme.fg("muted", renderCallSummary(args))}${summaryText ? ` ${summaryText}` : ""}`;
307
+ container.addChild(new Text(header, 0, 0));
308
+ if (options.expanded) {
309
+ const definition = readDefinitionForCwd(context.cwd);
310
+ const body = definition.renderResult?.(result, { ...options, expanded: true }, theme, context);
311
+ if (body) container.addChild(body);
155
312
  }
156
- const definition = readDefinitionForCwd(context.cwd);
157
- return definition.renderResult?.(result, { ...options, expanded: true }, theme, context) ?? new Text("", 0, 0);
313
+ return container;
158
314
  },
159
315
  };
160
316
  }
@@ -7,7 +7,6 @@ Agent definitions can override the parent model and thinking level. If an overri
7
7
  Tau includes three built-in agents:
8
8
 
9
9
  - `scout` explores local files and code with `read`, `grep`, `find`, and `ls`.
10
- - `context-maintenance` reconciles reusable repository context entries and requests approval before updates.
11
10
  - `web-research` researches web and code sources with `websearch`, `codesearch`, and `webfetch`.
12
11
 
13
12
  Ask Tau to delegate a task, or let it call `subagent` with an agent name and task. Children use the parent's current working directory and inherit its model and thinking level unless their definition overrides either value. They do not receive the parent conversation. Tau loads only the extensions that own a child's declared tools, so unrelated extension hooks do not run in child sessions. When a child must inspect another repository, put its exact absolute path in the delegated task.
@@ -126,10 +126,6 @@ async function loadScope(
126
126
  const reason = error instanceof Error ? error.message : "directory unavailable";
127
127
  return new Map([
128
128
  ["scout", [{ path: directory, name: "scout", reason: `packaged agents unavailable: ${reason}` }]],
129
- [
130
- "context-maintenance",
131
- [{ path: directory, name: "context-maintenance", reason: `packaged agents unavailable: ${reason}` }],
132
- ],
133
129
  [
134
130
  "web-research",
135
131
  [{ path: directory, name: "web-research", reason: `packaged agents unavailable: ${reason}` }],
@@ -28,11 +28,11 @@ Adds `/commit` for semantic commit grouping, review, and committing selected rep
28
28
 
29
29
  ## context
30
30
 
31
- Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-manage <idea>` to run interactive, approval-based catalog maintenance. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
31
+ Adds `/context` to select reusable repository work scopes from `.pi/contexts`, and `/context-sync` to reconcile affected scopes from current Git changes. Folder names are tabs, TOML files are concepts, and TOML sections are selectable entries.
32
32
 
33
33
  ## explore
34
34
 
35
- Replaces Pi’s filesystem inspection tools with compact Tau versions: `ls`, `find`, `grep`, and `read`. They produce smaller model payloads and readable tool rows. `read` includes focused line ranges; `grep`, `find`, and `ls` keep discovery output compact so the agent spends fewer tokens rereading directory and search results.
35
+ Replaces Pi’s filesystem inspection tools with compact Tau versions: `ls`, `find`, `grep`, and `read`. They produce smaller model payloads and readable tool rows. Repeated `read` calls return unchanged markers or useful diffs when branch history proves the agent already saw the base content. A failed patch unlocks one normal reread of the affected path. `/read-stats` shows estimated token and cost savings for the current chat and whole session.
36
36
 
37
37
  ## footer
38
38
 
@@ -84,7 +84,7 @@ Adds `Alt+S` to stash the current prompt draft and `/pop` to browse stashed draf
84
84
 
85
85
  ## subagent
86
86
 
87
- Gives Tau a subagent delegation tool for isolated, focused work. You can also create your own subagents in the supported subagent directories. Ask Tau how to do it and have it consult the extension’s own documentation; the built-in `scout`, `context-maintenance`, and `web-research` subagents show the pattern. Each subagent can register its own model and the tools it is allowed to use.
87
+ Gives Tau a subagent delegation tool for isolated, focused work. You can also create your own subagents in the supported subagent directories. Ask Tau how to do it and have it consult the extension’s own documentation; the built-in `scout` and `web-research` subagents show the pattern. Each subagent can register its own model and the tools it is allowed to use.
88
88
 
89
89
  ## tau-help
90
90
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shanepadgett/tau-agent",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Tau is a custom agentic harness built with pi extensions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -28,9 +28,9 @@
28
28
  "README.md"
29
29
  ],
30
30
  "dependencies": {
31
- "@shanepadgett/tau-tui": "0.5.0",
31
+ "@shanepadgett/tau-tui": "0.6.0",
32
32
  "@toon-format/toon": "2.3.0",
33
- "smol-toml": "1.4.2"
33
+ "smol-toml": "1.7.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "@earendil-works/pi-ai": "*",
package/shared/git.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  const DEFAULT_GIT_TIMEOUT_MS = 10_000;
4
4
 
@@ -7,7 +7,7 @@ export interface GitRunner {
7
7
  run(args: string[], options?: { cwd?: string; optional?: boolean; timeout?: number }): Promise<string>;
8
8
  }
9
9
 
10
- export function createGitRunner(pi: ExtensionAPI, ctx: ExtensionCommandContext): GitRunner {
10
+ export function createGitRunner(pi: ExtensionAPI, ctx: ExtensionContext): GitRunner {
11
11
  return {
12
12
  cwd: ctx.cwd,
13
13
  async run(args, options = {}) {
@@ -1,23 +0,0 @@
1
- ---
2
- name: context-maintenance
3
- description: Reconcile repository context definitions after meaningful scope changes, with user approval before every update
4
- tools:
5
- - read
6
- - grep
7
- - find
8
- - ls
9
- - context_list
10
- - context_get
11
- - context_check
12
- - context_audit
13
- - context_changes
14
- - context_review
15
- model: openai-codex/gpt-5.6-luna
16
- thinking: high
17
- ---
18
-
19
- Maintain the repository context catalog. Research only the changed or requested scopes. Existing entries should stay small, reusable, and useful for future work. Prefer updating an existing entry over creating a near-duplicate. Do not create one entry per file.
20
-
21
- Use context_changes when the task does not provide exact changed paths. Use context_list, context_get, context_check, and context_audit to understand the current catalog. Call context_review with concrete proposed operations. That tool owns user approval and applies only selected operations.
22
-
23
- If context_review returns feedback, revise the affected proposal or batch and call context_review again. If it returns rejected, stop. Never claim an operation was applied unless context_review reports it applied. Finish with only a short applied/rejected summary.