@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.
@@ -1,204 +1,23 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { join } from "node:path";
3
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
3
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { Text } from "@earendil-works/pi-tui";
4
5
  import { Type } from "typebox";
5
6
  import { emitTauEvent } from "../../shared/events.ts";
6
7
  import { createInjectedContext } from "../../shared/injected-context.ts";
7
- import { discoverAgents } from "../subagent/agents.ts";
8
- import { extensionPathsForTools, runSubagent } from "../subagent/run.ts";
9
- import { ContextPanel, ProposalPanel, type ProposalReviewDecision } from "./panel.ts";
10
- import {
11
- applyContextOperation,
12
- findProjectRoot,
13
- loadContextEntries,
14
- normalizeProjectPath,
15
- pathExists,
16
- requireFiles,
17
- updateContextFiles,
18
- validSlug,
19
- writeContextEntry,
20
- type ContextEntry,
21
- type ContextOperation,
22
- } from "./definitions.ts";
8
+ import { ContextPanel } from "./panel.ts";
9
+ import { findProjectRoot, loadContextEntries, type ContextEntry } from "./definitions.ts";
10
+ import { runContextSync, type ContextSyncDetails } from "./sync.ts";
23
11
 
24
- const identityParams = Type.Object(
25
- { tab: Type.String(), concept: Type.String(), entry: Type.String() },
26
- { additionalProperties: false },
27
- );
28
- const membershipParams = Type.Object(
29
- {
30
- tab: Type.String(),
31
- concept: Type.String(),
32
- entry: Type.String(),
33
- paths: Type.Array(Type.String(), { minItems: 1 }),
34
- },
35
- { additionalProperties: false },
36
- );
37
- const createParams = Type.Object(
38
- {
39
- tab: Type.String(),
40
- concept: Type.String(),
41
- conceptName: Type.String(),
42
- conceptDescription: Type.String(),
43
- entry: Type.String(),
44
- description: Type.String(),
45
- paths: Type.Array(Type.String(), { minItems: 1 }),
46
- },
47
- { additionalProperties: false },
48
- );
12
+ const contextSyncParams = Type.Object({}, { additionalProperties: false });
49
13
 
50
- const operationIdentity = {
51
- id: Type.String({ minLength: 1 }),
52
- tab: Type.String(),
53
- concept: Type.String(),
54
- entry: Type.String(),
55
- reason: Type.String({ minLength: 1 }),
56
- };
57
- const reviewParams = Type.Object(
58
- {
59
- operations: Type.Array(
60
- Type.Union([
61
- Type.Object({
62
- ...operationIdentity,
63
- kind: Type.Literal("create"),
64
- conceptName: Type.String(),
65
- conceptDescription: Type.String(),
66
- description: Type.String(),
67
- files: Type.Array(Type.String(), { minItems: 1 }),
68
- }),
69
- Type.Object({
70
- ...operationIdentity,
71
- kind: Type.Union([Type.Literal("add-files"), Type.Literal("remove-files")]),
72
- files: Type.Array(Type.String(), { minItems: 1 }),
73
- }),
74
- Type.Object({
75
- ...operationIdentity,
76
- kind: Type.Literal("replace-file"),
77
- from: Type.String(),
78
- to: Type.String(),
79
- }),
80
- ]),
81
- { minItems: 1 },
82
- ),
83
- },
84
- { additionalProperties: false },
85
- );
86
-
87
- function result(value: unknown) {
88
- return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }], details: value };
89
- }
90
-
91
- let reviewQueue = Promise.resolve();
92
-
93
- async function withReviewLock<T>(task: () => Promise<T>): Promise<T> {
94
- const previous = reviewQueue;
95
- let release = () => {};
96
- reviewQueue = new Promise<void>((resolve) => {
97
- release = resolve;
98
- });
99
- await previous;
100
- try {
101
- return await task();
102
- } finally {
103
- release();
104
- }
105
- }
106
-
107
- function parseChangedFiles(raw: string): Array<{ path: string; status: string; renamedFrom?: string }> {
108
- const fields = raw.split("\0").filter(Boolean);
109
- const changes: Array<{ path: string; status: string; renamedFrom?: string }> = [];
110
- for (let index = 0; index < fields.length; index += 1) {
111
- const field = fields[index];
112
- if (!field) continue;
113
- const status = field.slice(0, 2);
114
- const path = field.slice(3);
115
- if (status.includes("R") || status.includes("C")) {
116
- const renamedFrom = fields[index + 1];
117
- if (renamedFrom) index += 1;
118
- changes.push({ path, status, ...(renamedFrom ? { renamedFrom } : {}) });
119
- } else changes.push({ path, status });
120
- }
121
- return changes;
122
- }
123
-
124
- async function validateContextOperations(
125
- root: string,
126
- operations: readonly ContextOperation[],
127
- ): Promise<ContextOperation[]> {
128
- const entries = await loadContextEntries(root);
129
- const ids = new Set(entries.map((entry) => entry.id));
130
- const normalized: ContextOperation[] = [];
131
- for (const operation of operations) {
132
- const id = operation.id.trim();
133
- const reason = operation.reason.trim();
134
- if (!id) throw new Error("Context operation id is required");
135
- if (!reason) throw new Error(`Context operation reason is required: ${id}`);
136
- const tab = validSlug(operation.tab, "Context tab");
137
- const concept = validSlug(operation.concept, "Context concept");
138
- const entry = validSlug(operation.entry, "Context entry");
139
- const contextId = `${tab}/${concept}/${entry}`;
140
- if (operation.kind === "create") {
141
- if (ids.has(contextId)) throw new Error(`Context entry already exists: ${contextId}`);
142
- if (!operation.conceptName.trim()) throw new Error(`Context concept name is required: ${id}`);
143
- if (!operation.description.trim()) throw new Error(`Context entry description is required: ${id}`);
144
- ids.add(contextId);
145
- normalized.push({
146
- ...operation,
147
- id,
148
- reason,
149
- tab,
150
- concept,
151
- entry,
152
- conceptName: operation.conceptName.trim(),
153
- conceptDescription: operation.conceptDescription.trim(),
154
- description: operation.description.trim(),
155
- files: await requireFiles(root, operation.files),
156
- });
157
- continue;
158
- }
159
- const current = entries.find((item) => item.id === contextId);
160
- if (!current) throw new Error(`Unknown context entry: ${contextId}`);
161
- if (operation.kind === "replace-file") {
162
- const from = normalizeProjectPath(root, operation.from);
163
- if (!current.files.includes(from)) throw new Error(`Context entry does not contain: ${from}`);
164
- const [to] = await requireFiles(root, [operation.to]);
165
- normalized.push({ ...operation, id, reason, tab, concept, entry, from, to });
166
- continue;
167
- }
168
- const files =
169
- operation.kind === "add-files"
170
- ? await requireFiles(root, operation.files)
171
- : operation.files.map((path) => normalizeProjectPath(root, path));
172
- if (operation.kind === "remove-files" && current.files.filter((path) => !files.includes(path)).length === 0)
173
- throw new Error(`Context operation would empty entry: ${contextId}`);
174
- if (operation.kind === "remove-files") {
175
- const unknown = files.find((path) => !current.files.includes(path));
176
- if (unknown) throw new Error(`Context entry does not contain: ${unknown}`);
177
- }
178
- normalized.push({ ...operation, id, reason, tab, concept, entry, files });
179
- }
180
- return normalized;
181
- }
182
-
183
- async function maintain(pi: ExtensionAPI, ctx: ExtensionCommandContext, root: string, request: string): Promise<void> {
184
- const discovery = await discoverAgents(root, ctx.isProjectTrusted());
185
- const definition = discovery.agents.get("context-maintenance");
186
- if (!definition) throw new Error("Built-in context-maintenance subagent is unavailable");
187
- const existing = await loadContextEntries(root);
188
- const controller = new AbortController();
189
- const response = await runSubagent({
190
- definition,
191
- extensionPaths: extensionPathsForTools(pi, definition.tools),
192
- task: `Maintain repository context for this request:\n${request}\n\nExisting entries:\n${JSON.stringify(
193
- existing.map(({ id, description, files }) => ({ id, description, files })),
194
- null,
195
- 2,
196
- )}`,
197
- ctx,
198
- thinkingLevel: pi.getThinkingLevel(),
199
- signal: controller.signal,
200
- });
201
- if (response.details.status !== "completed") throw new Error(response.content);
14
+ function compactResult(details: ContextSyncDetails) {
15
+ const value = {
16
+ outcome: details.outcome,
17
+ summary: details.summary,
18
+ changedContextFiles: details.changedContextFiles,
19
+ };
20
+ return { content: [{ type: "text" as const, text: JSON.stringify(value) }], details };
202
21
  }
203
22
 
204
23
  export default function contextExtension(pi: ExtensionAPI): void {
@@ -214,8 +33,7 @@ export default function contextExtension(pi: ExtensionAPI): void {
214
33
  await ctx.waitForIdle();
215
34
  const root = await findProjectRoot(ctx.cwd);
216
35
  const entries = await loadContextEntries(root);
217
- const firstEntry = entries[0];
218
- if (!firstEntry) {
36
+ if (!entries[0]) {
219
37
  ctx.ui.notify(`No context entries found in ${join(root, ".pi", "contexts")}`, "warning");
220
38
  return;
221
39
  }
@@ -223,13 +41,7 @@ export default function contextExtension(pi: ExtensionAPI): void {
223
41
  (tui, theme, _keys, done) => new ContextPanel(tui, theme, entries, done),
224
42
  {
225
43
  overlay: true,
226
- overlayOptions: {
227
- anchor: "top-center",
228
- width: "70%",
229
- minWidth: 64,
230
- maxHeight: "80%",
231
- margin: 2,
232
- },
44
+ overlayOptions: { anchor: "top-center", width: "70%", minWidth: 64, maxHeight: "80%", margin: 2 },
233
45
  },
234
46
  );
235
47
  if (!selected?.length) return;
@@ -252,211 +64,76 @@ export default function contextExtension(pi: ExtensionAPI): void {
252
64
  },
253
65
  });
254
66
 
255
- pi.registerCommand("context-manage", {
256
- description: "Research, review, and maintain repository context entries",
67
+ pi.registerCommand("context-sync", {
68
+ description: "Synchronize repository context from current Git changes",
257
69
  handler: async (args, ctx) => {
258
- if (!args.trim()) {
259
- ctx.ui.notify("Usage: /context-manage <context idea>", "warning");
70
+ if (args.trim()) {
71
+ ctx.ui.notify("Usage: /context-sync", "warning");
260
72
  return;
261
73
  }
262
74
  if (ctx.mode !== "tui" || !ctx.isProjectTrusted()) {
263
- ctx.ui.notify("/context-manage requires a trusted TUI project", "warning");
75
+ ctx.ui.notify("/context-sync requires a trusted TUI project", "warning");
264
76
  return;
265
77
  }
266
78
  await ctx.waitForIdle();
267
- const root = await findProjectRoot(ctx.cwd);
268
- ctx.ui.setStatus("context-manage", "maintaining contexts");
79
+ ctx.ui.setStatus("context-sync", "synchronizing context");
269
80
  try {
270
- await maintain(pi, ctx, root, args.trim());
81
+ const result = await runContextSync(pi, ctx);
82
+ ctx.ui.notify(result.summary, "info");
271
83
  } catch (error) {
272
- ctx.ui.notify(
273
- `Context maintenance failed: ${error instanceof Error ? error.message : String(error)}`,
274
- "error",
275
- );
276
- return;
84
+ ctx.ui.notify(`Context sync failed: ${error instanceof Error ? error.message : String(error)}`, "error");
277
85
  } finally {
278
- ctx.ui.setStatus("context-manage", undefined);
86
+ ctx.ui.setStatus("context-sync", undefined);
279
87
  }
280
88
  },
281
89
  });
282
90
 
283
- pi.registerTool({
284
- name: "context_changes",
285
- label: "Context Changes",
286
- description: "Report uncommitted repository paths for context maintenance.",
287
- parameters: Type.Object({}, { additionalProperties: false }),
288
- async execute(_id, _params, signal, _update, ctx) {
289
- if (!ctx.isProjectTrusted()) throw new Error("Context changes require a trusted project");
290
- const rootResult = await pi.exec("git", ["rev-parse", "--show-toplevel"], { cwd: ctx.cwd, signal });
291
- if (rootResult.code !== 0) throw new Error("No Git repository found");
292
- const root = rootResult.stdout.trim();
293
- const status = await pi.exec("git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"], {
294
- cwd: root,
295
- signal,
296
- });
297
- if (status.code !== 0) throw new Error(status.stderr.trim() || "Unable to read Git status");
298
- return result({ root, changes: parseChangedFiles(status.stdout) });
299
- },
300
- });
301
- pi.registerTool({
302
- name: "context_review",
303
- label: "Context Review",
304
- description: "Present context maintenance operations for user approval and apply only selected operations.",
305
- parameters: reviewParams,
306
- executionMode: "sequential",
307
- async execute(_id, params, _signal, _update, ctx) {
308
- if (ctx.mode !== "tui" || !ctx.hasUI) throw new Error("Context review requires TUI mode");
309
- if (!ctx.isProjectTrusted()) throw new Error("Context review requires a trusted project");
310
- const rawOperations = params.operations as ContextOperation[];
311
- if (new Set(rawOperations.map((operation) => operation.id)).size !== rawOperations.length)
312
- throw new Error("Context operation ids must be unique");
313
- const root = await findProjectRoot(ctx.cwd);
314
- const operations = await validateContextOperations(root, rawOperations);
315
- if (new Set(operations.map((operation) => operation.id)).size !== operations.length)
316
- throw new Error("Context operation ids must be unique after trimming");
317
- ctx.ui.setWorkingVisible(false);
318
- try {
319
- return await withReviewLock(async () => {
320
- const decision = await ctx.ui.custom<ProposalReviewDecision>(
321
- (tui, theme, _keys, done) => new ProposalPanel(tui, theme, operations, done),
322
- );
323
- if (decision.kind === "rejected") return result(decision);
324
- if (decision.kind === "feedback") {
325
- const feedback = await ctx.ui.editor(
326
- decision.scope === "batch" ? "Context proposal feedback" : `Feedback for ${decision.operationId}`,
327
- "",
328
- );
329
- return result(
330
- feedback?.trim()
331
- ? { ...decision, text: feedback.trim() }
332
- : { kind: "rejected", reason: "Feedback cancelled" },
333
- );
334
- }
335
- for (const operation of decision.operations) await applyContextOperation(root, operation);
336
- return result({
337
- kind: "applied" as const,
338
- operationIds: decision.operations.map((operation) => operation.id),
339
- });
340
- });
341
- } finally {
342
- ctx.ui.setWorkingVisible(true);
343
- }
344
- },
345
- });
346
- pi.registerTool({
347
- name: "context_list",
348
- label: "Context List",
349
- description: "List repository context tabs, concepts, entries, and file counts.",
350
- parameters: Type.Object({}, { additionalProperties: false }),
351
- async execute(_id, _params, _signal, _update, ctx) {
352
- const root = await findProjectRoot(ctx.cwd);
353
- return result(
354
- (await loadContextEntries(root)).map(({ id, description, files }) => ({
355
- id,
356
- description,
357
- fileCount: files.length,
358
- })),
359
- );
360
- },
361
- });
362
- pi.registerTool({
363
- name: "context_get",
364
- label: "Context Get",
365
- description: "Get one repository context entry and its files.",
366
- parameters: identityParams,
367
- async execute(_id, params, _signal, _update, ctx) {
368
- const root = await findProjectRoot(ctx.cwd);
369
- const id = `${params.tab}/${params.concept}/${params.entry}`;
370
- const entry = (await loadContextEntries(root)).find((item) => item.id === id);
371
- if (!entry) throw new Error(`Unknown context entry: ${id}`);
372
- return result(entry);
373
- },
374
- });
375
- pi.registerTool({
376
- name: "context_create",
377
- label: "Context Create",
378
- description: "Create one validated repository context entry.",
379
- parameters: createParams,
380
- async execute(_id, params, _signal, _update, ctx) {
381
- const root = await findProjectRoot(ctx.cwd);
382
- await writeContextEntry(root, { ...params, files: params.paths }, false);
383
- return result({ created: `${params.tab}/${params.concept}/${params.entry}` });
384
- },
385
- });
386
- pi.registerTool({
387
- name: "context_add_files",
388
- label: "Context Add Files",
389
- description: "Add existing project files to one context entry.",
390
- parameters: membershipParams,
391
- async execute(_id, params, _signal, _update, ctx) {
392
- const root = await findProjectRoot(ctx.cwd);
393
- return result({
394
- added: await updateContextFiles(root, params.tab, params.concept, params.entry, params.paths, "add"),
395
- });
396
- },
397
- });
398
- pi.registerTool({
399
- name: "context_remove_files",
400
- label: "Context Remove Files",
401
- description: "Remove project files from one context entry.",
402
- parameters: membershipParams,
403
- async execute(_id, params, _signal, _update, ctx) {
404
- const root = await findProjectRoot(ctx.cwd);
405
- return result({
406
- removed: await updateContextFiles(root, params.tab, params.concept, params.entry, params.paths, "remove"),
407
- });
408
- },
409
- });
410
- pi.registerTool({
411
- name: "context_check",
412
- label: "Context Check",
413
- description: "Report context memberships and existence for exact project paths.",
414
- parameters: Type.Object({ paths: Type.Array(Type.String(), { minItems: 1 }) }, { additionalProperties: false }),
415
- async execute(_id, params, _signal, _update, ctx) {
416
- const root = await findProjectRoot(ctx.cwd);
417
- const entries = await loadContextEntries(root);
418
- return result(
419
- await Promise.all(
420
- params.paths.map(async (input) => {
421
- const path = normalizeProjectPath(root, input);
422
- return {
423
- path,
424
- exists: await pathExists(join(root, path)),
425
- memberships: entries.filter((entry) => entry.files.includes(path)).map((entry) => entry.id),
426
- };
427
- }),
428
- ),
429
- );
430
- },
431
- });
432
- pi.registerTool({
433
- name: "context_audit",
434
- label: "Context Audit",
435
- description: "Report context paths that no longer exist.",
436
- parameters: Type.Object({}, { additionalProperties: false }),
437
- async execute(_id, _params, _signal, _update, ctx) {
438
- const root = await findProjectRoot(ctx.cwd);
439
- const entries = await loadContextEntries(root);
440
- const stale = (
441
- await Promise.all(
442
- entries.flatMap((entry) =>
443
- entry.files.map(async (path) =>
444
- (await pathExists(join(root, path))) ? undefined : { entry: entry.id, path },
445
- ),
446
- ),
447
- )
448
- ).filter((item) => item !== undefined);
449
- return result({ stale });
450
- },
451
- });
91
+ pi.registerTool(
92
+ defineTool<typeof contextSyncParams, ContextSyncDetails>({
93
+ name: "context_sync",
94
+ label: "context_sync",
95
+ description: "Synchronize repository context from current Git changes.",
96
+ parameters: contextSyncParams,
97
+ async execute(_id, _params, _signal, _update, ctx) {
98
+ return compactResult(await runContextSync(pi, ctx));
99
+ },
100
+ renderCall(_args, theme, context) {
101
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
102
+ text.setText(theme.fg("toolTitle", "context_sync"));
103
+ return text;
104
+ },
105
+ renderResult(result, _options, theme, context) {
106
+ const text = (context.lastComponent as Text | undefined) ?? new Text("", 0, 0);
107
+ const details = result.details;
108
+ text.setText(
109
+ context.expanded && details
110
+ ? [
111
+ details.summary,
112
+ details.reason,
113
+ ...details.changes.map((change) =>
114
+ change.action === "set-entry"
115
+ ? `${change.action} ${change.tab}/${change.concept}/${change.entry}: ${change.files.join(", ")}`
116
+ : `${change.action} ${change.tab}/${change.concept}/${change.entry}`,
117
+ ),
118
+ ...details.changedContextFiles,
119
+ ].join("\n")
120
+ : (details?.summary ??
121
+ theme.fg(
122
+ "error",
123
+ result.content.map((part) => (part.type === "text" ? part.text : "")).join(""),
124
+ )),
125
+ );
126
+ return text;
127
+ },
128
+ }),
129
+ );
452
130
 
453
131
  pi.on("session_start", async (_event, ctx) => {
454
132
  const root = await findProjectRoot(ctx.cwd);
455
133
  const entries = await loadContextEntries(root);
456
134
  let selectionData: unknown;
457
- for (const entry of ctx.sessionManager.getBranch()) {
135
+ for (const entry of ctx.sessionManager.getBranch())
458
136
  if (entry.type === "custom" && entry.customType === "tau.context-selection") selectionData = entry.data;
459
- }
460
137
  const ids =
461
138
  typeof selectionData === "object" &&
462
139
  selectionData !== null &&