@shanepadgett/tau-agent 0.1.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.
- package/README.md +30 -0
- package/docs/extending-tau-agent.md +119 -0
- package/docs/subagents.md +57 -0
- package/docs/tui.md +107 -0
- package/extensions/appshot/README.md +11 -0
- package/extensions/appshot/capture.swift +191 -0
- package/extensions/appshot/index.ts +245 -0
- package/extensions/appshot/native-helper.ts +102 -0
- package/extensions/attention/README.md +23 -0
- package/extensions/attention/index.ts +71 -0
- package/extensions/auto-name/README.md +26 -0
- package/extensions/auto-name/index.ts +163 -0
- package/extensions/branch/README.md +22 -0
- package/extensions/branch/index.ts +145 -0
- package/extensions/branch/panel.ts +148 -0
- package/extensions/clear-screen/README.md +29 -0
- package/extensions/clear-screen/index.ts +75 -0
- package/extensions/commit/README.md +15 -0
- package/extensions/commit/commit-plan.ts +328 -0
- package/extensions/commit/git-change-set.ts +268 -0
- package/extensions/commit/index.ts +138 -0
- package/extensions/commit/review-ui.ts +745 -0
- package/extensions/explore/README.md +7 -0
- package/extensions/explore/autoread.ts +140 -0
- package/extensions/explore/find.ts +147 -0
- package/extensions/explore/grep.ts +749 -0
- package/extensions/explore/index.ts +17 -0
- package/extensions/explore/limits.ts +22 -0
- package/extensions/explore/ls.ts +113 -0
- package/extensions/explore/path-display.ts +41 -0
- package/extensions/explore/path-tree.ts +150 -0
- package/extensions/explore/read.ts +160 -0
- package/extensions/explore/result.ts +29 -0
- package/extensions/explore/traverse.ts +226 -0
- package/extensions/footer/README.md +23 -0
- package/extensions/footer/index.ts +534 -0
- package/extensions/footer/settings.ts +13 -0
- package/extensions/ideas/README.md +10 -0
- package/extensions/ideas/browser.ts +66 -0
- package/extensions/ideas/index.ts +31 -0
- package/extensions/ideas/store.ts +32 -0
- package/extensions/image-gen/README.md +11 -0
- package/extensions/image-gen/client.ts +186 -0
- package/extensions/image-gen/index.ts +152 -0
- package/extensions/manage-sessions/README.md +12 -0
- package/extensions/manage-sessions/index.ts +58 -0
- package/extensions/manage-sessions/manager-ui.ts +323 -0
- package/extensions/manage-sessions/sessions.ts +126 -0
- package/extensions/patch/README.md +48 -0
- package/extensions/patch/executor.ts +355 -0
- package/extensions/patch/index.ts +170 -0
- package/extensions/patch/matcher.ts +236 -0
- package/extensions/patch/parser.ts +347 -0
- package/extensions/patch/render.ts +247 -0
- package/extensions/patch/summary.ts +35 -0
- package/extensions/publish/README.md +11 -0
- package/extensions/publish/index.ts +225 -0
- package/extensions/qna/README.md +45 -0
- package/extensions/qna/additional-context-body.ts +51 -0
- package/extensions/qna/body-render.ts +6 -0
- package/extensions/qna/choice-question-body.ts +322 -0
- package/extensions/qna/index.ts +245 -0
- package/extensions/qna/inline-editor-row.ts +56 -0
- package/extensions/qna/input-question-body.ts +89 -0
- package/extensions/qna/model.ts +317 -0
- package/extensions/qna/panel.ts +240 -0
- package/extensions/qna/ui.ts +16 -0
- package/extensions/reference/README.md +47 -0
- package/extensions/reference/index.ts +76 -0
- package/extensions/reference/panel.ts +874 -0
- package/extensions/reference/settings.ts +29 -0
- package/extensions/run-summary/README.md +5 -0
- package/extensions/run-summary/index.ts +98 -0
- package/extensions/silent-command-runner/README.md +25 -0
- package/extensions/silent-command-runner/index.ts +468 -0
- package/extensions/silent-command-runner/settings.ts +65 -0
- package/extensions/soul/README.md +19 -0
- package/extensions/soul/index.ts +20 -0
- package/extensions/soul/prompt.ts +254 -0
- package/extensions/soul/settings.ts +13 -0
- package/extensions/stash/README.md +48 -0
- package/extensions/stash/browser.ts +51 -0
- package/extensions/stash/index.ts +55 -0
- package/extensions/stash/store.ts +36 -0
- package/extensions/subagent/README.md +34 -0
- package/extensions/subagent/agents/scout.md +85 -0
- package/extensions/subagent/agents/web-research.md +95 -0
- package/extensions/subagent/agents.ts +196 -0
- package/extensions/subagent/index.ts +214 -0
- package/extensions/subagent/render.ts +89 -0
- package/extensions/subagent/run.ts +325 -0
- package/extensions/tau/README.md +17 -0
- package/extensions/tau/index.ts +122 -0
- package/extensions/tau-help/README.md +3 -0
- package/extensions/tau-help/help.md +125 -0
- package/extensions/tau-help/index.ts +68 -0
- package/extensions/turn-budget/README.md +14 -0
- package/extensions/turn-budget/index.ts +150 -0
- package/extensions/turn-budget/settings.ts +35 -0
- package/extensions/web/README.md +13 -0
- package/extensions/web/codesearch.ts +79 -0
- package/extensions/web/exa.ts +101 -0
- package/extensions/web/html.ts +67 -0
- package/extensions/web/index.ts +13 -0
- package/extensions/web/limits.ts +11 -0
- package/extensions/web/tool-output.ts +54 -0
- package/extensions/web/webfetch.ts +177 -0
- package/extensions/web/websearch.ts +100 -0
- package/package.json +57 -0
- package/prompts/.gitkeep +1 -0
- package/prompts/cavemanify.md +25 -0
- package/prompts/implement.md +25 -0
- package/prompts/interview.md +48 -0
- package/prompts/plan-feature.md +30 -0
- package/prompts/plan-implementation.md +50 -0
- package/schemas/tau.schema.json +205 -0
- package/shared/agent-blocked.ts +12 -0
- package/shared/description.ts +28 -0
- package/shared/events.ts +135 -0
- package/shared/git.ts +41 -0
- package/shared/injected-context.ts +69 -0
- package/shared/jsonl-store.ts +108 -0
- package/shared/model-fallback/index.ts +278 -0
- package/shared/model-fallback/settings.ts +18 -0
- package/shared/model-fallback/types.ts +8 -0
- package/shared/ranges.ts +21 -0
- package/shared/settings/define.ts +15 -0
- package/shared/settings/files.ts +20 -0
- package/shared/settings/json.ts +41 -0
- package/shared/settings/load.ts +59 -0
- package/shared/settings/merge.ts +21 -0
- package/shared/settings/paths.ts +41 -0
- package/shared/settings/schema.ts +25 -0
- package/shared/settings/specs.ts +23 -0
- package/shared/text.ts +11 -0
- package/shared/tool-row-state.ts +43 -0
- package/skills/writing-preferences/SKILL.md +136 -0
- package/themes/.gitkeep +1 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import type { Api, AssistantMessage, Message, Model, ThinkingLevel, Tool } from "@earendil-works/pi-ai";
|
|
2
|
+
import { completeSimple } from "@earendil-works/pi-ai/compat";
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { loadTauExtensionSettings, updateTauExtensionSettings } from "../settings/load.ts";
|
|
5
|
+
import { errorText, truncAt } from "../text.ts";
|
|
6
|
+
import modelFallbackSettings from "./settings.ts";
|
|
7
|
+
import type { ModelCandidate } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const MAX_ATTEMPTS = 5;
|
|
10
|
+
const MAX_TOOL_ATTEMPTS = 2;
|
|
11
|
+
const SEVEN_DAYS_MS = 604_800_000;
|
|
12
|
+
|
|
13
|
+
const PREFERRED_MODELS: ReadonlyArray<{ provider: string; model: string; reasoning: ThinkingLevel }> = [
|
|
14
|
+
{ provider: "openrouter", model: "cohere/north-mini-code:free", reasoning: "high" },
|
|
15
|
+
{ provider: "github-copilot", model: "gemini-3.5-flash", reasoning: "high" },
|
|
16
|
+
{ provider: "openai-codex", model: "gpt-5.4-mini", reasoning: "high" },
|
|
17
|
+
{ provider: "anthropic", model: "claude-haiku-4-5", reasoning: "high" },
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
interface GenerationContext {
|
|
21
|
+
ui: ExtensionContext["ui"];
|
|
22
|
+
signal: AbortSignal | undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function resolveCandidates(
|
|
26
|
+
ctx: Pick<ExtensionContext, "modelRegistry" | "model" | "cwd" | "isProjectTrusted">,
|
|
27
|
+
preferredModels: ReadonlyArray<{ provider: string; model: string; reasoning: ThinkingLevel }> = PREFERRED_MODELS,
|
|
28
|
+
): Promise<ModelCandidate[]> {
|
|
29
|
+
const settings = await loadTauExtensionSettings(ctx, modelFallbackSettings);
|
|
30
|
+
const candidates: ModelCandidate[] = [];
|
|
31
|
+
const seen = new Set<string>();
|
|
32
|
+
const blocked = currentBlockedProviders(settings.cooldowns ?? {});
|
|
33
|
+
|
|
34
|
+
const add = async (model: Model<Api>, reasoning: ThinkingLevel | undefined): Promise<void> => {
|
|
35
|
+
if (blocked.has(model.provider)) return;
|
|
36
|
+
const key = `${model.provider}/${model.id}`;
|
|
37
|
+
if (seen.has(key)) return;
|
|
38
|
+
|
|
39
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
40
|
+
if (!auth.ok || !auth.apiKey) return;
|
|
41
|
+
|
|
42
|
+
seen.add(key);
|
|
43
|
+
candidates.push({ model, apiKey: auth.apiKey, headers: auth.headers, reasoning });
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
for (const preferred of preferredModels) {
|
|
47
|
+
const model = ctx.modelRegistry.find(preferred.provider, preferred.model);
|
|
48
|
+
if (model) await add(model, preferred.reasoning);
|
|
49
|
+
}
|
|
50
|
+
if (ctx.model) await add(ctx.model, undefined);
|
|
51
|
+
|
|
52
|
+
if (candidates.length === 0) throw new Error("No authenticated model available for generation.");
|
|
53
|
+
return candidates;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function generateValidated<T>(
|
|
57
|
+
ctx: GenerationContext,
|
|
58
|
+
candidates: readonly ModelCandidate[],
|
|
59
|
+
prompt: string,
|
|
60
|
+
validate: (text: string) => T,
|
|
61
|
+
correctionPrompt?: (error: Error, text: string) => string,
|
|
62
|
+
options?: { statusKey?: string; notifyOnFallback?: boolean },
|
|
63
|
+
): Promise<T> {
|
|
64
|
+
return withModelFallback(ctx, candidates, options, (candidate) =>
|
|
65
|
+
requestValidated(ctx, candidate, prompt, validate, correctionPrompt),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function generateToolValidated<T>(
|
|
70
|
+
ctx: GenerationContext,
|
|
71
|
+
candidates: readonly ModelCandidate[],
|
|
72
|
+
prompt: string,
|
|
73
|
+
tool: Tool,
|
|
74
|
+
validate: (input: unknown) => T,
|
|
75
|
+
correctionPrompt?: (error: Error, output: string) => string,
|
|
76
|
+
options?: { statusKey?: string; notifyOnFallback?: boolean; maxAttempts?: number },
|
|
77
|
+
): Promise<T> {
|
|
78
|
+
return withModelFallback(ctx, candidates, options, (candidate) =>
|
|
79
|
+
requestToolValidated(
|
|
80
|
+
ctx,
|
|
81
|
+
candidate,
|
|
82
|
+
prompt,
|
|
83
|
+
tool,
|
|
84
|
+
validate,
|
|
85
|
+
correctionPrompt,
|
|
86
|
+
options?.maxAttempts ?? MAX_TOOL_ATTEMPTS,
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function withModelFallback<T>(
|
|
92
|
+
ctx: GenerationContext,
|
|
93
|
+
candidates: readonly ModelCandidate[],
|
|
94
|
+
options: { statusKey?: string; notifyOnFallback?: boolean } | undefined,
|
|
95
|
+
request: (candidate: ModelCandidate) => Promise<T>,
|
|
96
|
+
): Promise<T> {
|
|
97
|
+
const failures: string[] = [];
|
|
98
|
+
const statusKey = options?.statusKey;
|
|
99
|
+
const notifyOnFallback = options?.notifyOnFallback ?? false;
|
|
100
|
+
|
|
101
|
+
for (const [index, candidate] of candidates.entries()) {
|
|
102
|
+
const label = `${candidate.model.provider}/${candidate.model.id}`;
|
|
103
|
+
if (statusKey) ctx.ui.setStatus(statusKey, `generating (${label})`);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
return await request(candidate);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (ctx.signal?.aborted) throw new Error("Cancelled.");
|
|
109
|
+
if (shouldCooldownProvider(error)) await markProviderUnavailable(candidate.model.provider);
|
|
110
|
+
const message = errorText(error);
|
|
111
|
+
failures.push(`- ${label}: ${message}`);
|
|
112
|
+
if (index < candidates.length - 1 && notifyOnFallback) {
|
|
113
|
+
ctx.ui.notify(`Model failed (${label}): ${message}\nTrying next model.`, "info");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
throw new Error(["Model generation failed for all candidates:", ...failures].join("\n"));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function requestValidated<T>(
|
|
122
|
+
ctx: GenerationContext,
|
|
123
|
+
candidate: ModelCandidate,
|
|
124
|
+
prompt: string,
|
|
125
|
+
validate: (text: string) => T,
|
|
126
|
+
correctionPrompt?: (error: Error, text: string) => string,
|
|
127
|
+
): Promise<T> {
|
|
128
|
+
const userMessage: Message = { role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() };
|
|
129
|
+
const messages: Message[] = [userMessage];
|
|
130
|
+
|
|
131
|
+
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
|
|
132
|
+
const response = await completeCandidate(ctx, candidate, messages);
|
|
133
|
+
const text = responseText(response);
|
|
134
|
+
if (response.stopReason === "error") {
|
|
135
|
+
const error = new Error(response.errorMessage || "model returned an error");
|
|
136
|
+
if (attempt < MAX_ATTEMPTS && !shouldCooldownProvider(error)) continue;
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
return validate(text);
|
|
142
|
+
} catch (error) {
|
|
143
|
+
if (!(error instanceof Error) || attempt >= MAX_ATTEMPTS) throw error;
|
|
144
|
+
if (!correctionPrompt) throw error;
|
|
145
|
+
|
|
146
|
+
if (text) messages.push({ ...response, content: [{ type: "text", text }] });
|
|
147
|
+
messages.push({
|
|
148
|
+
role: "user",
|
|
149
|
+
content: [{ type: "text", text: correctionPrompt(error, truncAt(text, 4_000)) }],
|
|
150
|
+
timestamp: Date.now(),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
throw new Error("Model generation failed.");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function requestToolValidated<T>(
|
|
159
|
+
ctx: GenerationContext,
|
|
160
|
+
candidate: ModelCandidate,
|
|
161
|
+
prompt: string,
|
|
162
|
+
tool: Tool,
|
|
163
|
+
validate: (input: unknown) => T,
|
|
164
|
+
correctionPrompt?: (error: Error, output: string) => string,
|
|
165
|
+
maxAttempts = MAX_TOOL_ATTEMPTS,
|
|
166
|
+
): Promise<T> {
|
|
167
|
+
const messages: Message[] = [{ role: "user", content: [{ type: "text", text: prompt }], timestamp: Date.now() }];
|
|
168
|
+
|
|
169
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
170
|
+
const response = await completeCandidate(ctx, candidate, messages, [tool]);
|
|
171
|
+
const text = responseText(response);
|
|
172
|
+
const toolCalls = response.content.flatMap((part) => (part.type === "toolCall" ? [part] : []));
|
|
173
|
+
const output = text || formatToolCalls(toolCalls);
|
|
174
|
+
if (response.stopReason === "error") {
|
|
175
|
+
const error = new Error(response.errorMessage || "model returned an error");
|
|
176
|
+
if (attempt < maxAttempts && !shouldCooldownProvider(error)) continue;
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
try {
|
|
181
|
+
if (toolCalls.length !== 1) throw new Error(`Model must call ${tool.name} exactly once.`);
|
|
182
|
+
const [toolCall] = toolCalls;
|
|
183
|
+
if (!toolCall) throw new Error(`Model must call ${tool.name}.`);
|
|
184
|
+
if (toolCall.name !== tool.name) throw new Error(`Model called ${toolCall.name}; expected ${tool.name}.`);
|
|
185
|
+
return validate(toolCall.arguments);
|
|
186
|
+
} catch (error) {
|
|
187
|
+
if (!(error instanceof Error) || attempt >= maxAttempts) throw error;
|
|
188
|
+
if (!correctionPrompt) throw error;
|
|
189
|
+
|
|
190
|
+
messages.push({
|
|
191
|
+
role: "user",
|
|
192
|
+
content: [{ type: "text", text: correctionPrompt(error, truncAt(output, 4_000)) }],
|
|
193
|
+
timestamp: Date.now(),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
throw new Error("Model generation failed.");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function completeCandidate(
|
|
202
|
+
ctx: GenerationContext,
|
|
203
|
+
candidate: ModelCandidate,
|
|
204
|
+
messages: readonly Message[],
|
|
205
|
+
tools?: Tool[],
|
|
206
|
+
): Promise<AssistantMessage> {
|
|
207
|
+
return completeSimple(candidate.model, tools ? { messages: [...messages], tools } : { messages: [...messages] }, {
|
|
208
|
+
apiKey: candidate.apiKey,
|
|
209
|
+
headers: candidate.headers,
|
|
210
|
+
signal: ctx.signal,
|
|
211
|
+
reasoning: candidate.reasoning,
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function responseText(response: AssistantMessage): string {
|
|
216
|
+
if (response.stopReason === "aborted") throw new Error("Cancelled.");
|
|
217
|
+
return response.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function formatToolCalls(toolCalls: readonly { name: string; arguments: unknown }[]): string {
|
|
221
|
+
if (toolCalls.length === 0) return "(no tool call)";
|
|
222
|
+
try {
|
|
223
|
+
return JSON.stringify(
|
|
224
|
+
toolCalls.map((call) => ({ name: call.name, arguments: call.arguments })),
|
|
225
|
+
null,
|
|
226
|
+
2,
|
|
227
|
+
);
|
|
228
|
+
} catch {
|
|
229
|
+
return "(tool call arguments unavailable)";
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function shouldCooldownProvider(error: unknown): boolean {
|
|
234
|
+
const message = errorText(error).toLowerCase();
|
|
235
|
+
return [
|
|
236
|
+
"401",
|
|
237
|
+
"402",
|
|
238
|
+
"403",
|
|
239
|
+
"429",
|
|
240
|
+
"api key",
|
|
241
|
+
"authentication",
|
|
242
|
+
"balance",
|
|
243
|
+
"billing",
|
|
244
|
+
"credit",
|
|
245
|
+
"forbidden",
|
|
246
|
+
"insufficient",
|
|
247
|
+
"payment",
|
|
248
|
+
"quota",
|
|
249
|
+
"rate limit",
|
|
250
|
+
"rate_limit",
|
|
251
|
+
"unauthorized",
|
|
252
|
+
].some((marker) => message.includes(marker));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function currentBlockedProviders(cooldowns: Record<string, number>): Set<string> {
|
|
256
|
+
const now = Date.now();
|
|
257
|
+
const blocked = new Set<string>();
|
|
258
|
+
for (const [provider, availableAt] of Object.entries(cooldowns)) {
|
|
259
|
+
if (availableAt > now) blocked.add(provider);
|
|
260
|
+
}
|
|
261
|
+
return blocked;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function markProviderUnavailable(provider: string): Promise<void> {
|
|
265
|
+
const until = provider === "github-copilot" ? nextMonthStartMs() : Date.now() + SEVEN_DAYS_MS;
|
|
266
|
+
await updateTauExtensionSettings("global", { cwd: process.cwd() }, modelFallbackSettings, (current) => {
|
|
267
|
+
const cooldowns: Record<string, number> = { ...(current.cooldowns ?? {}) };
|
|
268
|
+
cooldowns[provider] = until;
|
|
269
|
+
return { ...current, cooldowns };
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function nextMonthStartMs(): number {
|
|
274
|
+
const next = new Date();
|
|
275
|
+
next.setMonth(next.getMonth() + 1, 1);
|
|
276
|
+
next.setHours(0, 0, 0, 0);
|
|
277
|
+
return next.getTime();
|
|
278
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { defineTauExtensionSettings } from "../settings/define.ts";
|
|
3
|
+
|
|
4
|
+
export default defineTauExtensionSettings({
|
|
5
|
+
key: "modelFallback",
|
|
6
|
+
defaults: { cooldowns: {} },
|
|
7
|
+
schema: Type.Object(
|
|
8
|
+
{
|
|
9
|
+
cooldowns: Type.Optional(
|
|
10
|
+
Type.Record(
|
|
11
|
+
Type.String(),
|
|
12
|
+
Type.Number({ description: "Unix epoch milliseconds when the provider becomes available again." }),
|
|
13
|
+
),
|
|
14
|
+
),
|
|
15
|
+
},
|
|
16
|
+
{ additionalProperties: false },
|
|
17
|
+
),
|
|
18
|
+
});
|
package/shared/ranges.ts
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface LineRange {
|
|
2
|
+
startLine: number;
|
|
3
|
+
endLine: number;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function mergeLineRanges<Range extends LineRange>(ranges: Range[]): Range[] {
|
|
7
|
+
const sorted = ranges
|
|
8
|
+
.filter((range) => Number.isInteger(range.startLine) && Number.isInteger(range.endLine) && range.startLine > 0)
|
|
9
|
+
.map((range) => ({ ...range, endLine: Math.max(range.startLine, range.endLine) }))
|
|
10
|
+
.sort((a, b) => a.startLine - b.startLine || a.endLine - b.endLine);
|
|
11
|
+
const merged: Range[] = [];
|
|
12
|
+
for (const range of sorted) {
|
|
13
|
+
const previous = merged[merged.length - 1];
|
|
14
|
+
if (previous && range.startLine <= previous.endLine + 3) {
|
|
15
|
+
previous.endLine = Math.max(previous.endLine, range.endLine);
|
|
16
|
+
} else {
|
|
17
|
+
merged.push({ ...range });
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return merged;
|
|
21
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { TSchema } from "typebox";
|
|
2
|
+
|
|
3
|
+
export type JsonObject = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
export interface TauExtensionSettingsSpec<TDefaults extends JsonObject = JsonObject> {
|
|
6
|
+
key: string;
|
|
7
|
+
defaults: TDefaults;
|
|
8
|
+
schema: TSchema;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function defineTauExtensionSettings<const TDefaults extends JsonObject>(
|
|
12
|
+
spec: TauExtensionSettingsSpec<TDefaults>,
|
|
13
|
+
): TauExtensionSettingsSpec<TDefaults> {
|
|
14
|
+
return spec;
|
|
15
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import { readdir } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export async function listSettingsFiles(root: string): Promise<string[]> {
|
|
6
|
+
let entries: Dirent<string>[];
|
|
7
|
+
try {
|
|
8
|
+
entries = await readdir(root, { withFileTypes: true });
|
|
9
|
+
} catch {
|
|
10
|
+
return [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const files: string[] = [];
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const path = join(root, entry.name);
|
|
16
|
+
if (entry.isDirectory()) files.push(...(await listSettingsFiles(path)));
|
|
17
|
+
else if (entry.isFile() && entry.name === "settings.ts") files.push(path);
|
|
18
|
+
}
|
|
19
|
+
return files.sort();
|
|
20
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname } from "node:path";
|
|
3
|
+
import type { JsonObject } from "./define.ts";
|
|
4
|
+
|
|
5
|
+
export type JsonStatus =
|
|
6
|
+
| { exists: false; path: string }
|
|
7
|
+
| { exists: true; path: string; ok: true; value: JsonObject }
|
|
8
|
+
| { exists: true; path: string; ok: false; error: string };
|
|
9
|
+
|
|
10
|
+
const writeQueues = new Map<string, Promise<void>>();
|
|
11
|
+
|
|
12
|
+
export function asObject(value: unknown): JsonObject | undefined {
|
|
13
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonObject) : undefined;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function readJsonStatus(path: string): Promise<JsonStatus> {
|
|
17
|
+
try {
|
|
18
|
+
const raw = await readFile(path, "utf8");
|
|
19
|
+
const value = asObject(JSON.parse(raw)) ?? {};
|
|
20
|
+
return { exists: true, path, ok: true, value };
|
|
21
|
+
} catch (error) {
|
|
22
|
+
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT")
|
|
23
|
+
return { exists: false, path };
|
|
24
|
+
return { exists: true, path, ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function writeJsonObject(path: string, value: JsonObject): Promise<void> {
|
|
29
|
+
const previous = writeQueues.get(path) ?? Promise.resolve();
|
|
30
|
+
const next = previous.then(async () => {
|
|
31
|
+
await mkdir(dirname(path), { recursive: true });
|
|
32
|
+
const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
|
|
33
|
+
await writeFile(temp, `${JSON.stringify(value, null, "\t")}\n`, "utf8");
|
|
34
|
+
await rename(temp, path);
|
|
35
|
+
});
|
|
36
|
+
writeQueues.set(
|
|
37
|
+
path,
|
|
38
|
+
next.catch(() => undefined),
|
|
39
|
+
);
|
|
40
|
+
await next;
|
|
41
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Value } from "typebox/value";
|
|
3
|
+
import type { JsonObject, TauExtensionSettingsSpec } from "./define.ts";
|
|
4
|
+
import { asObject, readJsonStatus, writeJsonObject } from "./json.ts";
|
|
5
|
+
import { mergeSettings } from "./merge.ts";
|
|
6
|
+
import { globalTauSettingsPath, projectTauSettingsPath, TAU_SCHEMA_URL } from "./paths.ts";
|
|
7
|
+
|
|
8
|
+
export type TauSettingsScope = "global" | "project";
|
|
9
|
+
|
|
10
|
+
export async function loadTauExtensionSettings<TDefaults extends JsonObject>(
|
|
11
|
+
ctx: Pick<ExtensionContext, "cwd" | "isProjectTrusted">,
|
|
12
|
+
spec: TauExtensionSettingsSpec<TDefaults>,
|
|
13
|
+
): Promise<TDefaults> {
|
|
14
|
+
const [globalStatus, projectStatus] = await Promise.all([
|
|
15
|
+
readJsonStatus(globalTauSettingsPath()),
|
|
16
|
+
ctx.isProjectTrusted() ? readJsonStatus(await projectTauSettingsPath(ctx.cwd)) : Promise.resolve(undefined),
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
let merged = spec.defaults;
|
|
20
|
+
if (globalStatus?.exists && globalStatus.ok)
|
|
21
|
+
merged = mergeSettings(merged, extensionSection(globalStatus.value, spec.key));
|
|
22
|
+
if (projectStatus?.exists && projectStatus.ok)
|
|
23
|
+
merged = mergeSettings(merged, extensionSection(projectStatus.value, spec.key));
|
|
24
|
+
|
|
25
|
+
return Value.Check(spec.schema, merged) ? merged : spec.defaults;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function updateTauExtensionSettings<TDefaults extends JsonObject>(
|
|
29
|
+
scope: TauSettingsScope,
|
|
30
|
+
ctx: Pick<ExtensionContext, "cwd">,
|
|
31
|
+
spec: TauExtensionSettingsSpec<TDefaults>,
|
|
32
|
+
updater: (current: TDefaults) => TDefaults,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
await updateTauSettings(scope, ctx, (current) => {
|
|
35
|
+
const next = { ...current };
|
|
36
|
+
const extensions = asObject(next.extensions) ?? {};
|
|
37
|
+
extensions[spec.key] = updater((asObject(extensions[spec.key]) as TDefaults | undefined) ?? spec.defaults);
|
|
38
|
+
next.extensions = extensions;
|
|
39
|
+
return next;
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function updateTauSettings(
|
|
44
|
+
scope: TauSettingsScope,
|
|
45
|
+
ctx: Pick<ExtensionContext, "cwd">,
|
|
46
|
+
updater: (current: JsonObject) => JsonObject,
|
|
47
|
+
): Promise<void> {
|
|
48
|
+
const path = scope === "global" ? globalTauSettingsPath() : await projectTauSettingsPath(ctx.cwd);
|
|
49
|
+
const currentStatus = await readJsonStatus(path);
|
|
50
|
+
const current =
|
|
51
|
+
currentStatus.exists && currentStatus.ok ? currentStatus.value : { $schema: TAU_SCHEMA_URL, extensions: {} };
|
|
52
|
+
const next = updater(current);
|
|
53
|
+
next.$schema = typeof next.$schema === "string" ? next.$schema : TAU_SCHEMA_URL;
|
|
54
|
+
await writeJsonObject(path, next);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function extensionSection(root: JsonObject, key: string): JsonObject | undefined {
|
|
58
|
+
return asObject(asObject(root.extensions)?.[key]);
|
|
59
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { asObject } from "./json.ts";
|
|
2
|
+
|
|
3
|
+
export function mergeSettings<T>(base: T, override: unknown): T {
|
|
4
|
+
return mergeNode(base, override) as T;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function mergeNode(base: unknown, override: unknown): unknown {
|
|
8
|
+
if (override === undefined) return clone(base);
|
|
9
|
+
|
|
10
|
+
const baseObject = asObject(base);
|
|
11
|
+
const overrideObject = asObject(override);
|
|
12
|
+
if (!baseObject || !overrideObject) return clone(override);
|
|
13
|
+
|
|
14
|
+
const merged = { ...baseObject };
|
|
15
|
+
for (const key of Object.keys(overrideObject)) merged[key] = mergeNode(merged[key], overrideObject[key]);
|
|
16
|
+
return merged;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function clone(value: unknown): unknown {
|
|
20
|
+
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
|
21
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
const CONFIG_DIR_NAME = ".pi";
|
|
7
|
+
|
|
8
|
+
export const TAU_SCHEMA_URL =
|
|
9
|
+
"https://raw.githubusercontent.com/shanepadgett/tau-agent/refs/heads/main/packages/agent/schemas/tau.schema.json";
|
|
10
|
+
|
|
11
|
+
export function globalTauSettingsPath(): string {
|
|
12
|
+
return join(getAgentDir(), "tau", "settings.json");
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function projectTauSettingsPath(cwd: string): Promise<string> {
|
|
16
|
+
return join(await resolveProjectRoot(cwd), CONFIG_DIR_NAME, "tau", "settings.json");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function exists(path: string): Promise<boolean> {
|
|
20
|
+
try {
|
|
21
|
+
await access(path);
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function resolveProjectRoot(cwd: string): Promise<string> {
|
|
29
|
+
const start = resolve(cwd);
|
|
30
|
+
let current = start;
|
|
31
|
+
while (true) {
|
|
32
|
+
if (resolve(current) !== resolve(homedir()) && (await isProjectRoot(current))) return current;
|
|
33
|
+
const parent = dirname(current);
|
|
34
|
+
if (parent === current) return start;
|
|
35
|
+
current = parent;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function isProjectRoot(path: string): Promise<boolean> {
|
|
40
|
+
return (await exists(join(path, ".git"))) || (await exists(join(path, CONFIG_DIR_NAME, "tau", "settings.json")));
|
|
41
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { JsonObject, TauExtensionSettingsSpec } from "./define.ts";
|
|
2
|
+
import { TAU_SCHEMA_URL } from "./paths.ts";
|
|
3
|
+
|
|
4
|
+
export function buildTauSettingsSchema(specs: readonly TauExtensionSettingsSpec[]): JsonObject {
|
|
5
|
+
return {
|
|
6
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
7
|
+
$id: TAU_SCHEMA_URL,
|
|
8
|
+
title: "Tau Configuration",
|
|
9
|
+
description: "Configuration for Tau and Tau extensions.",
|
|
10
|
+
type: "object",
|
|
11
|
+
properties: {
|
|
12
|
+
$schema: {
|
|
13
|
+
type: "string",
|
|
14
|
+
description: "Optional schema reference used by editors for validation and autocomplete.",
|
|
15
|
+
},
|
|
16
|
+
extensions: {
|
|
17
|
+
type: "object",
|
|
18
|
+
properties: Object.fromEntries(specs.map((spec) => [spec.key, spec.schema])),
|
|
19
|
+
additionalProperties: true,
|
|
20
|
+
default: {},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
additionalProperties: false,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { dirname, join } from "node:path";
|
|
2
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
3
|
+
import type { TauExtensionSettingsSpec } from "./define.ts";
|
|
4
|
+
import { listSettingsFiles } from "./files.ts";
|
|
5
|
+
|
|
6
|
+
const PACKAGE_ROOT = join(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
7
|
+
|
|
8
|
+
export async function discoverTauSettingsSpecs(_cwd?: string): Promise<TauExtensionSettingsSpec[]> {
|
|
9
|
+
const roots = [join(PACKAGE_ROOT, "extensions"), join(PACKAGE_ROOT, "shared")];
|
|
10
|
+
const files = (await Promise.all(roots.map(listSettingsFiles))).flat();
|
|
11
|
+
const specs: TauExtensionSettingsSpec[] = [];
|
|
12
|
+
|
|
13
|
+
for (const file of files) {
|
|
14
|
+
const module = (await import(pathToFileURL(file).href)) as { default?: unknown };
|
|
15
|
+
if (isSpec(module.default)) specs.push(module.default);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return specs.sort((left, right) => left.key.localeCompare(right.key));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isSpec(value: unknown): value is TauExtensionSettingsSpec {
|
|
22
|
+
return !!value && typeof value === "object" && "key" in value && "defaults" in value && "schema" in value;
|
|
23
|
+
}
|
package/shared/text.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Small text helpers shared across extensions.
|
|
2
|
+
|
|
3
|
+
export { formatAge, preview } from "@shanepadgett/tau-tui";
|
|
4
|
+
|
|
5
|
+
export function errorText(error: unknown): string {
|
|
6
|
+
return error instanceof Error ? error.message : String(error);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function truncAt(text: string, cap: number): string {
|
|
10
|
+
return text.length > cap ? `${text.slice(0, cap)}\n(truncated)` : text;
|
|
11
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { onTauEvent } from "./events.js";
|
|
3
|
+
|
|
4
|
+
export type ToolRowVisualState = "pruned";
|
|
5
|
+
|
|
6
|
+
interface EventAPI extends Pick<ExtensionAPI, "events"> {
|
|
7
|
+
on(event: "session_start", handler: () => void): void;
|
|
8
|
+
on(event: "session_shutdown", handler: () => void): void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface ToolRowStateStore {
|
|
12
|
+
get(rowId: string): ToolRowVisualState | undefined;
|
|
13
|
+
watch(rowId: string, invalidate: () => void): void;
|
|
14
|
+
clear(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createToolRowStateStore(pi: EventAPI, owner: string): ToolRowStateStore {
|
|
18
|
+
const states = new Map<string, ToolRowVisualState>();
|
|
19
|
+
const invalidators = new Map<string, () => void>();
|
|
20
|
+
onTauEvent(pi, owner, "tau:tool-row-state.set", ({ rowId, state }) => {
|
|
21
|
+
if (state === undefined) states.delete(rowId);
|
|
22
|
+
else states.set(rowId, state);
|
|
23
|
+
invalidators.get(rowId)?.();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
return {
|
|
27
|
+
get(rowId) {
|
|
28
|
+
return states.get(rowId);
|
|
29
|
+
},
|
|
30
|
+
watch(rowId, invalidate) {
|
|
31
|
+
invalidators.set(rowId, invalidate);
|
|
32
|
+
},
|
|
33
|
+
clear() {
|
|
34
|
+
states.clear();
|
|
35
|
+
invalidators.clear();
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatToolRowTitle(store: ToolRowStateStore, rowId: string, toolName: string, theme: Theme): string {
|
|
41
|
+
const color = store.get(rowId) === "pruned" ? "warning" : "toolTitle";
|
|
42
|
+
return theme.fg(color, theme.bold(toolName));
|
|
43
|
+
}
|