@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.
- package/docs/subagents.md +1 -2
- package/extensions/context/README.md +1 -1
- package/extensions/context/definitions.ts +2 -142
- package/extensions/context/index.ts +65 -388
- package/extensions/context/panel.ts +43 -108
- package/extensions/context/sync.ts +683 -0
- package/extensions/explore/README.md +2 -2
- package/extensions/explore/autoread.ts +20 -3
- package/extensions/explore/index.ts +23 -2
- package/extensions/explore/read-cache.ts +209 -0
- package/extensions/explore/read-snapshots.ts +44 -0
- package/extensions/explore/read-stats-panel.ts +154 -0
- package/extensions/explore/read-stats.ts +120 -0
- package/extensions/explore/read.ts +198 -42
- package/extensions/subagent/README.md +0 -1
- package/extensions/subagent/agents.ts +0 -4
- package/extensions/tau-help/help.md +3 -3
- package/package.json +3 -3
- package/shared/git.ts +2 -2
- package/extensions/subagent/agents/context-maintenance.md +0 -23
|
@@ -0,0 +1,683 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
4
|
+
import { Type, type Tool } from "@earendil-works/pi-ai";
|
|
5
|
+
import { withFileMutationQueue, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { parse, stringify } from "smol-toml";
|
|
7
|
+
import { createGitRunner, loadRepoStatus, type GitRunner } from "../../shared/git.ts";
|
|
8
|
+
import { generateToolValidated, resolveCandidates } from "../../shared/model-fallback/index.ts";
|
|
9
|
+
import { truncAt } from "../../shared/text.ts";
|
|
10
|
+
import {
|
|
11
|
+
loadContextEntries,
|
|
12
|
+
normalizeProjectPath,
|
|
13
|
+
pathExists,
|
|
14
|
+
requireFiles,
|
|
15
|
+
validSlug,
|
|
16
|
+
type ContextEntry,
|
|
17
|
+
} from "./definitions.ts";
|
|
18
|
+
|
|
19
|
+
const MAX_DIRTY_EVIDENCE = 4_000;
|
|
20
|
+
const MAX_STRUCTURAL_PREVIEW = 1_500;
|
|
21
|
+
const MAX_TOTAL_EVIDENCE = 64_000;
|
|
22
|
+
const MAX_UNTRACKED_BYTES = 12_000;
|
|
23
|
+
const EVIDENCE_CONCURRENCY = 4;
|
|
24
|
+
|
|
25
|
+
const SUBMIT_TOOL = {
|
|
26
|
+
name: "submit_context_sync",
|
|
27
|
+
description: "Submit the desired context catalog changes.",
|
|
28
|
+
parameters: Type.Union([
|
|
29
|
+
Type.Object(
|
|
30
|
+
{ outcome: Type.Literal("no-change"), reason: Type.String({ minLength: 1 }) },
|
|
31
|
+
{ additionalProperties: false },
|
|
32
|
+
),
|
|
33
|
+
Type.Object(
|
|
34
|
+
{
|
|
35
|
+
outcome: Type.Literal("apply"),
|
|
36
|
+
reason: Type.String({ minLength: 1 }),
|
|
37
|
+
changes: Type.Array(
|
|
38
|
+
Type.Union([
|
|
39
|
+
Type.Object(
|
|
40
|
+
{
|
|
41
|
+
action: Type.Literal("set-entry"),
|
|
42
|
+
tab: Type.String(),
|
|
43
|
+
concept: Type.String(),
|
|
44
|
+
conceptName: Type.String(),
|
|
45
|
+
conceptDescription: Type.String(),
|
|
46
|
+
entry: Type.String(),
|
|
47
|
+
description: Type.String(),
|
|
48
|
+
files: Type.Array(Type.String(), { minItems: 1 }),
|
|
49
|
+
},
|
|
50
|
+
{ additionalProperties: false },
|
|
51
|
+
),
|
|
52
|
+
Type.Object(
|
|
53
|
+
{
|
|
54
|
+
action: Type.Literal("delete-entry"),
|
|
55
|
+
tab: Type.String(),
|
|
56
|
+
concept: Type.String(),
|
|
57
|
+
entry: Type.String(),
|
|
58
|
+
},
|
|
59
|
+
{ additionalProperties: false },
|
|
60
|
+
),
|
|
61
|
+
]),
|
|
62
|
+
{ minItems: 1 },
|
|
63
|
+
),
|
|
64
|
+
},
|
|
65
|
+
{ additionalProperties: false },
|
|
66
|
+
),
|
|
67
|
+
]),
|
|
68
|
+
} satisfies Tool;
|
|
69
|
+
|
|
70
|
+
export interface SyncDirtyFile {
|
|
71
|
+
id: number;
|
|
72
|
+
path: string;
|
|
73
|
+
status: string;
|
|
74
|
+
kind: "modified" | "added" | "deleted" | "renamed" | "copied" | "untracked";
|
|
75
|
+
untracked: boolean;
|
|
76
|
+
oldPath?: string;
|
|
77
|
+
memberships: string[];
|
|
78
|
+
oldMemberships: string[];
|
|
79
|
+
evidence: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export type ContextSyncChange =
|
|
83
|
+
| {
|
|
84
|
+
action: "set-entry";
|
|
85
|
+
tab: string;
|
|
86
|
+
concept: string;
|
|
87
|
+
conceptName: string;
|
|
88
|
+
conceptDescription: string;
|
|
89
|
+
entry: string;
|
|
90
|
+
description: string;
|
|
91
|
+
files: string[];
|
|
92
|
+
}
|
|
93
|
+
| { action: "delete-entry"; tab: string; concept: string; entry: string };
|
|
94
|
+
|
|
95
|
+
export type ContextSyncPlan =
|
|
96
|
+
| { outcome: "no-change"; reason: string }
|
|
97
|
+
| { outcome: "apply"; reason: string; changes: ContextSyncChange[] };
|
|
98
|
+
|
|
99
|
+
export interface ContextSyncDetails {
|
|
100
|
+
outcome: "applied" | "no-change";
|
|
101
|
+
summary: string;
|
|
102
|
+
changedContextFiles: string[];
|
|
103
|
+
reason: string;
|
|
104
|
+
changes: ContextSyncChange[];
|
|
105
|
+
counts: { created: number; updated: number; deleted: number; unchanged: number };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface SyncEvidence {
|
|
109
|
+
root: string;
|
|
110
|
+
files: SyncDirtyFile[];
|
|
111
|
+
entries: ContextEntry[];
|
|
112
|
+
dirtyExisting: Set<string>;
|
|
113
|
+
dependencies: Set<string>;
|
|
114
|
+
affectedIds: Set<string>;
|
|
115
|
+
affectedConcepts: Set<string>;
|
|
116
|
+
eligibleFiles: Set<string>;
|
|
117
|
+
missingPaths: Set<string>;
|
|
118
|
+
structuralPreviews: Map<string, string>;
|
|
119
|
+
worktreeSignature: string;
|
|
120
|
+
catalogSignature: string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let syncQueue = Promise.resolve();
|
|
124
|
+
|
|
125
|
+
export async function runContextSync(pi: ExtensionAPI, ctx: ExtensionContext): Promise<ContextSyncDetails> {
|
|
126
|
+
if (!ctx.isProjectTrusted()) throw new Error("Context sync requires a trusted project");
|
|
127
|
+
const git = createGitRunner(pi, ctx);
|
|
128
|
+
const status = await loadRepoStatus(git);
|
|
129
|
+
if (!status) throw new Error("No Git repository found");
|
|
130
|
+
if (status.fileCount === 0) return noChange("Existing context mappings already fit the changed scope.");
|
|
131
|
+
const evidence = await collectSyncEvidence(git, status.root);
|
|
132
|
+
const prompt = buildContextSyncPrompt(evidence);
|
|
133
|
+
const plan = await generateToolValidated(
|
|
134
|
+
ctx,
|
|
135
|
+
await resolveCandidates(ctx),
|
|
136
|
+
prompt,
|
|
137
|
+
SUBMIT_TOOL,
|
|
138
|
+
(input) => normalizeContextSyncPlan(input, evidence),
|
|
139
|
+
(error) => `Validation failed: ${error.message}\nCall submit_context_sync once with corrected arguments only.`,
|
|
140
|
+
{ statusKey: "context-sync", notifyOnFallback: true },
|
|
141
|
+
);
|
|
142
|
+
if (plan.outcome === "no-change") return noChange(plan.reason);
|
|
143
|
+
return withSyncLock(async () => {
|
|
144
|
+
return applyContextSyncPlan(evidence.root, plan, evidence.entries, async () => {
|
|
145
|
+
const currentEntries = await loadContextEntries(evidence.root);
|
|
146
|
+
const currentFiles = await collectDirtyFiles(git, evidence.root, currentEntries);
|
|
147
|
+
if ((await computeWorktreeSignature(git, evidence.root, currentFiles)) !== evidence.worktreeSignature)
|
|
148
|
+
throw new Error("Repository changed during context sync. Rerun context sync.");
|
|
149
|
+
if ((await computeCatalogSignature(evidence.root)) !== evidence.catalogSignature)
|
|
150
|
+
throw new Error("Context catalog changed during context sync. Rerun context sync.");
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function noChange(reason: string): ContextSyncDetails {
|
|
156
|
+
return {
|
|
157
|
+
outcome: "no-change",
|
|
158
|
+
summary: "Existing context mappings already fit the changed scope.",
|
|
159
|
+
changedContextFiles: [],
|
|
160
|
+
reason,
|
|
161
|
+
changes: [],
|
|
162
|
+
counts: { created: 0, updated: 0, deleted: 0, unchanged: 0 },
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function withSyncLock<T>(task: () => Promise<T>): Promise<T> {
|
|
167
|
+
const previous = syncQueue;
|
|
168
|
+
let release = () => {};
|
|
169
|
+
syncQueue = new Promise<void>((resolve) => {
|
|
170
|
+
release = resolve;
|
|
171
|
+
});
|
|
172
|
+
await previous;
|
|
173
|
+
try {
|
|
174
|
+
return await task();
|
|
175
|
+
} finally {
|
|
176
|
+
release();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function collectSyncEvidence(git: GitRunner, root: string): Promise<SyncEvidence> {
|
|
181
|
+
const entries = await loadContextEntries(root);
|
|
182
|
+
const files = await collectDirtyFiles(git, root, entries);
|
|
183
|
+
const dirtyExisting = new Set<string>();
|
|
184
|
+
for (const file of files)
|
|
185
|
+
if (file.kind !== "deleted" && (await isFile(join(root, file.path)))) dirtyExisting.add(file.path);
|
|
186
|
+
const dependencies = await discoverDirectDependencies(
|
|
187
|
+
root,
|
|
188
|
+
files.filter((file) => dirtyExisting.has(file.path)),
|
|
189
|
+
);
|
|
190
|
+
const affectedIds = new Set<string>();
|
|
191
|
+
for (const entry of entries) {
|
|
192
|
+
if (
|
|
193
|
+
entry.files.some(
|
|
194
|
+
(path) => files.some((file) => file.path === path || file.oldPath === path) || dependencies.has(path),
|
|
195
|
+
)
|
|
196
|
+
)
|
|
197
|
+
affectedIds.add(entry.id);
|
|
198
|
+
}
|
|
199
|
+
const affectedConcepts = new Set([...affectedIds].map((id) => id.split("/").slice(0, 2).join("/")));
|
|
200
|
+
const siblingEntries = entries.filter((entry) => affectedConcepts.has(`${entry.tab}/${entry.concept}`));
|
|
201
|
+
const missingPaths = new Set<string>();
|
|
202
|
+
for (const entry of entries)
|
|
203
|
+
for (const path of entry.files) if (!(await isFile(join(root, path)))) missingPaths.add(path);
|
|
204
|
+
const siblingFiles = siblingEntries.flatMap((entry) => entry.files).filter((path) => !missingPaths.has(path));
|
|
205
|
+
const eligibleFiles = new Set([...dirtyExisting, ...dependencies, ...siblingFiles]);
|
|
206
|
+
const structuralPreviews = new Map<string, string>();
|
|
207
|
+
for (const path of [...new Set(siblingFiles)].sort()) {
|
|
208
|
+
try {
|
|
209
|
+
const content = await readFile(join(root, path), "utf8");
|
|
210
|
+
const lines = content
|
|
211
|
+
.split("\n")
|
|
212
|
+
.filter((line) => line.trim())
|
|
213
|
+
.slice(0, 30)
|
|
214
|
+
.join("\n");
|
|
215
|
+
structuralPreviews.set(path, truncAt(lines, MAX_STRUCTURAL_PREVIEW));
|
|
216
|
+
} catch {
|
|
217
|
+
structuralPreviews.set(path, "preview unavailable");
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
root,
|
|
222
|
+
files,
|
|
223
|
+
entries,
|
|
224
|
+
dirtyExisting,
|
|
225
|
+
dependencies,
|
|
226
|
+
affectedIds,
|
|
227
|
+
affectedConcepts,
|
|
228
|
+
eligibleFiles,
|
|
229
|
+
missingPaths,
|
|
230
|
+
structuralPreviews,
|
|
231
|
+
worktreeSignature: await computeWorktreeSignature(git, root, files),
|
|
232
|
+
catalogSignature: await computeCatalogSignature(root),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function collectDirtyFiles(
|
|
237
|
+
git: GitRunner,
|
|
238
|
+
root: string,
|
|
239
|
+
entries: readonly ContextEntry[],
|
|
240
|
+
): Promise<SyncDirtyFile[]> {
|
|
241
|
+
const raw = await git.run(["status", "--porcelain=v2", "-z", "--untracked-files=all"], { cwd: root });
|
|
242
|
+
const parts = raw.split("\0").filter(Boolean);
|
|
243
|
+
const parsed: Omit<SyncDirtyFile, "id" | "memberships" | "oldMemberships" | "evidence">[] = [];
|
|
244
|
+
for (let index = 0; index < parts.length; index++) {
|
|
245
|
+
const record = parts[index];
|
|
246
|
+
if (!record) continue;
|
|
247
|
+
if (record[0] === "u")
|
|
248
|
+
throw new Error("Unmerged conflict entries detected; resolve conflicts before context sync.");
|
|
249
|
+
if (record[0] === "?") {
|
|
250
|
+
parsed.push({ path: record.slice(2), status: "??", kind: "untracked", untracked: true });
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const fields = record.split(" ");
|
|
254
|
+
if (record[0] === "1") {
|
|
255
|
+
const status = (fields[1] ?? "..").replace(/\./g, " ");
|
|
256
|
+
parsed.push({
|
|
257
|
+
path: fields.slice(8).join(" "),
|
|
258
|
+
status,
|
|
259
|
+
kind: status.includes("D") ? "deleted" : status.includes("A") ? "added" : "modified",
|
|
260
|
+
untracked: false,
|
|
261
|
+
});
|
|
262
|
+
} else if (record[0] === "2") {
|
|
263
|
+
const oldPath = parts[index + 1];
|
|
264
|
+
const status = (fields[1] ?? "..").replace(/\./g, " ");
|
|
265
|
+
parsed.push({
|
|
266
|
+
path: fields.slice(9).join(" "),
|
|
267
|
+
status,
|
|
268
|
+
kind: fields[8]?.startsWith("C") ? "copied" : "renamed",
|
|
269
|
+
untracked: false,
|
|
270
|
+
...(oldPath ? { oldPath } : {}),
|
|
271
|
+
});
|
|
272
|
+
index++;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
const sorted = parsed.sort((a, b) => a.path.localeCompare(b.path));
|
|
276
|
+
const result: SyncDirtyFile[] = [];
|
|
277
|
+
for (let offset = 0; offset < sorted.length; offset += EVIDENCE_CONCURRENCY) {
|
|
278
|
+
result.push(
|
|
279
|
+
...(await Promise.all(
|
|
280
|
+
sorted.slice(offset, offset + EVIDENCE_CONCURRENCY).map(async (file, inner) => ({
|
|
281
|
+
...file,
|
|
282
|
+
id: offset + inner + 1,
|
|
283
|
+
memberships: entries.filter((entry) => entry.files.includes(file.path)).map((entry) => entry.id),
|
|
284
|
+
oldMemberships: file.oldPath
|
|
285
|
+
? entries.filter((entry) => entry.files.includes(file.oldPath ?? "")).map((entry) => entry.id)
|
|
286
|
+
: [],
|
|
287
|
+
evidence: await dirtyEvidence(git, root, file),
|
|
288
|
+
})),
|
|
289
|
+
)),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function dirtyEvidence(
|
|
296
|
+
git: GitRunner,
|
|
297
|
+
root: string,
|
|
298
|
+
file: { path: string; oldPath?: string; kind: string; untracked: boolean },
|
|
299
|
+
): Promise<string> {
|
|
300
|
+
if (file.kind === "deleted") return "deleted file; contents omitted";
|
|
301
|
+
if (file.untracked) {
|
|
302
|
+
try {
|
|
303
|
+
const info = await stat(join(root, file.path));
|
|
304
|
+
if (!info.isFile()) return "untracked non-file; contents omitted";
|
|
305
|
+
if (info.size > MAX_UNTRACKED_BYTES) return `untracked file, ${info.size} bytes; contents omitted`;
|
|
306
|
+
const bytes = await readFile(join(root, file.path));
|
|
307
|
+
if (bytes.includes(0)) return `untracked binary file, ${info.size} bytes`;
|
|
308
|
+
return truncAt(`untracked file preview:\n${bytes.toString("utf8")}`, MAX_DIRTY_EVIDENCE);
|
|
309
|
+
} catch {
|
|
310
|
+
return "untracked preview unavailable";
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
const paths = file.oldPath ? [file.oldPath, file.path] : [file.path];
|
|
314
|
+
const diff = await git.run(["diff", "--stat", "--patch", "--no-color", "--no-ext-diff", "HEAD", "--", ...paths], {
|
|
315
|
+
cwd: root,
|
|
316
|
+
optional: true,
|
|
317
|
+
});
|
|
318
|
+
return truncAt(diff || "metadata-only change", MAX_DIRTY_EVIDENCE);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export async function discoverDirectDependencies(
|
|
322
|
+
root: string,
|
|
323
|
+
files: readonly Pick<SyncDirtyFile, "path" | "evidence">[],
|
|
324
|
+
): Promise<Set<string>> {
|
|
325
|
+
const result = new Set<string>();
|
|
326
|
+
const knownExtensions = [
|
|
327
|
+
".ts",
|
|
328
|
+
".tsx",
|
|
329
|
+
".js",
|
|
330
|
+
".jsx",
|
|
331
|
+
".mjs",
|
|
332
|
+
".cjs",
|
|
333
|
+
".json",
|
|
334
|
+
".py",
|
|
335
|
+
".rs",
|
|
336
|
+
".go",
|
|
337
|
+
".c",
|
|
338
|
+
".cc",
|
|
339
|
+
".cpp",
|
|
340
|
+
".h",
|
|
341
|
+
".hpp",
|
|
342
|
+
];
|
|
343
|
+
for (const file of files) {
|
|
344
|
+
let content = file.evidence;
|
|
345
|
+
try {
|
|
346
|
+
content += `\n${await readFile(join(root, file.path), "utf8")}`;
|
|
347
|
+
} catch {
|
|
348
|
+
/* evidence remains */
|
|
349
|
+
}
|
|
350
|
+
const statement = /(?:import|export|require|include|use|mod(?:ule)?)\b[^\n]*?["'`]([^"'`]+)["'`]/g;
|
|
351
|
+
for (const match of content.matchAll(statement)) {
|
|
352
|
+
const specifier = match[1];
|
|
353
|
+
if (!specifier?.startsWith("./") && !specifier?.startsWith("../")) continue;
|
|
354
|
+
const absolute = resolve(dirname(join(root, file.path)), specifier);
|
|
355
|
+
const relativePath = relative(root, absolute).split(sep).join("/");
|
|
356
|
+
if (!relativePath || relativePath === ".." || relativePath.startsWith("../")) continue;
|
|
357
|
+
const candidates = extname(absolute)
|
|
358
|
+
? [absolute]
|
|
359
|
+
: [
|
|
360
|
+
...knownExtensions.map((extension) => `${absolute}${extension}`),
|
|
361
|
+
...knownExtensions.map((extension) => join(absolute, `index${extension}`)),
|
|
362
|
+
];
|
|
363
|
+
const matches: string[] = [];
|
|
364
|
+
if (await isFile(absolute)) matches.push(absolute);
|
|
365
|
+
else for (const candidate of candidates) if (await isFile(candidate)) matches.push(candidate);
|
|
366
|
+
if (matches.length === 1)
|
|
367
|
+
result.add(
|
|
368
|
+
relative(root, matches[0] ?? absolute)
|
|
369
|
+
.split(sep)
|
|
370
|
+
.join("/"),
|
|
371
|
+
);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function isFile(path: string): Promise<boolean> {
|
|
378
|
+
try {
|
|
379
|
+
return (await stat(path)).isFile();
|
|
380
|
+
} catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function buildContextSyncPrompt(evidence: SyncEvidence): string {
|
|
386
|
+
const stale = evidence.entries.flatMap((entry) =>
|
|
387
|
+
entry.files
|
|
388
|
+
.filter(
|
|
389
|
+
(path) =>
|
|
390
|
+
evidence.missingPaths.has(path) &&
|
|
391
|
+
!evidence.files.some((file) => file.path === path || file.oldPath === path),
|
|
392
|
+
)
|
|
393
|
+
.map((path) => `${entry.id}: ${path}`),
|
|
394
|
+
);
|
|
395
|
+
const affected = evidence.entries.filter((entry) => evidence.affectedIds.has(entry.id));
|
|
396
|
+
const previews = [...evidence.structuralPreviews].map(([path, preview]) => `${path}\n${preview}`);
|
|
397
|
+
return truncAt(
|
|
398
|
+
[
|
|
399
|
+
"Synchronize reusable repository context scopes from the supplied Git and catalog evidence.",
|
|
400
|
+
"Call submit_context_sync exactly once and produce no prose response.",
|
|
401
|
+
"Return no-change when existing mappings already support likely future work.",
|
|
402
|
+
"Context entries are reusable work scopes, not inventories of every touched file. Prefer updating an existing entry over creating a near-duplicate. Do not create one entry per file.",
|
|
403
|
+
"Follow direct local dependency candidates only when needed. Do not add package dependencies, generated files, incidental imports, or recursive dependencies.",
|
|
404
|
+
"Reconsider granularity only inside affected concepts. Preserve broad entries when splitting would duplicate files without improving future work.",
|
|
405
|
+
"New or unmapped changed files may remain unmapped. Do not clean unrelated stale paths. Use only supplied candidate paths.",
|
|
406
|
+
"Example: replace gameplay/player/all with gameplay/player/movement and gameplay/player/input only when every still-useful file from all remains in the final entries.",
|
|
407
|
+
"Changed-file catalog and current memberships:",
|
|
408
|
+
...evidence.files.map(
|
|
409
|
+
(file) =>
|
|
410
|
+
`[${file.id}] ${file.status} ${file.path}${file.oldPath ? ` <- ${file.oldPath}` : ""}; kind=${file.kind}; memberships=${file.memberships.join(",") || "none"}; oldMemberships=${file.oldMemberships.join(",") || "none"}`,
|
|
411
|
+
),
|
|
412
|
+
"Complete context catalog:",
|
|
413
|
+
JSON.stringify(
|
|
414
|
+
evidence.entries.map((entry) => ({
|
|
415
|
+
id: entry.id,
|
|
416
|
+
conceptName: entry.conceptName,
|
|
417
|
+
conceptDescription: entry.conceptDescription,
|
|
418
|
+
description: entry.description,
|
|
419
|
+
files: entry.files,
|
|
420
|
+
missingFiles: entry.files.filter((path) => evidence.missingPaths.has(path)),
|
|
421
|
+
})),
|
|
422
|
+
),
|
|
423
|
+
"Changed files with no membership:",
|
|
424
|
+
evidence.files
|
|
425
|
+
.filter((file) => file.memberships.length === 0 && file.oldMemberships.length === 0)
|
|
426
|
+
.map((file) => file.path)
|
|
427
|
+
.join("\n") || "(none)",
|
|
428
|
+
"Affected entries:",
|
|
429
|
+
affected.map((entry) => entry.id).join("\n") || "(none)",
|
|
430
|
+
"Affected concepts:",
|
|
431
|
+
[...evidence.affectedConcepts].sort().join("\n") || "(none)",
|
|
432
|
+
"Resolved direct dependency candidates:",
|
|
433
|
+
[...evidence.dependencies].sort().join("\n") || "(none)",
|
|
434
|
+
"Unrelated stale catalog paths (evidence only; do not modify):",
|
|
435
|
+
stale.join("\n") || "(none)",
|
|
436
|
+
"Dirty files:",
|
|
437
|
+
...evidence.files.map(
|
|
438
|
+
(file) =>
|
|
439
|
+
`[${file.id}] ${file.status} ${file.path}${file.oldPath ? ` <- ${file.oldPath}` : ""}\nkind=${file.kind}\nmemberships=${file.memberships.join(",") || "none"}\noldMemberships=${file.oldMemberships.join(",") || "none"}\n${file.evidence}`,
|
|
440
|
+
),
|
|
441
|
+
"Bounded affected-entry structural previews:",
|
|
442
|
+
...previews,
|
|
443
|
+
].join("\n\n"),
|
|
444
|
+
MAX_TOTAL_EVIDENCE,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export function normalizeContextSyncPlan(input: unknown, evidence: SyncEvidence): ContextSyncPlan {
|
|
449
|
+
if (!isRecord(input) || (input.outcome !== "no-change" && input.outcome !== "apply"))
|
|
450
|
+
throw new Error("Context sync submission is malformed.");
|
|
451
|
+
const reason = typeof input.reason === "string" ? input.reason.trim() : "";
|
|
452
|
+
if (!reason) throw new Error("Context sync reason is required.");
|
|
453
|
+
if (input.outcome === "no-change") {
|
|
454
|
+
if ("changes" in input) throw new Error("no-change must not include changes.");
|
|
455
|
+
return { outcome: "no-change", reason };
|
|
456
|
+
}
|
|
457
|
+
if (!Array.isArray(input.changes) || input.changes.length === 0)
|
|
458
|
+
throw new Error("apply requires at least one change.");
|
|
459
|
+
const current = new Map(evidence.entries.map((entry) => [entry.id, entry]));
|
|
460
|
+
const changes: ContextSyncChange[] = [];
|
|
461
|
+
const seen = new Set<string>();
|
|
462
|
+
for (const raw of input.changes) {
|
|
463
|
+
if (!isRecord(raw) || (raw.action !== "set-entry" && raw.action !== "delete-entry"))
|
|
464
|
+
throw new Error("Invalid context sync change.");
|
|
465
|
+
const tab = validSlug(String(raw.tab ?? ""), "Context tab");
|
|
466
|
+
const concept = validSlug(String(raw.concept ?? ""), "Context concept");
|
|
467
|
+
const entry = validSlug(String(raw.entry ?? ""), "Context entry");
|
|
468
|
+
const id = `${tab}/${concept}/${entry}`;
|
|
469
|
+
if (seen.has(id)) throw new Error(`Duplicate context change: ${id}`);
|
|
470
|
+
seen.add(id);
|
|
471
|
+
const existing = current.get(id);
|
|
472
|
+
if (raw.action === "delete-entry") {
|
|
473
|
+
if (!existing || !evidence.affectedIds.has(id)) throw new Error(`Entry is not eligible for deletion: ${id}`);
|
|
474
|
+
changes.push({ action: "delete-entry", tab, concept, entry });
|
|
475
|
+
continue;
|
|
476
|
+
}
|
|
477
|
+
const conceptName = typeof raw.conceptName === "string" ? raw.conceptName.trim() : "";
|
|
478
|
+
const conceptDescription = typeof raw.conceptDescription === "string" ? raw.conceptDescription.trim() : "";
|
|
479
|
+
const description = typeof raw.description === "string" ? raw.description.trim() : "";
|
|
480
|
+
if (
|
|
481
|
+
!conceptName ||
|
|
482
|
+
!description ||
|
|
483
|
+
!Array.isArray(raw.files) ||
|
|
484
|
+
raw.files.length === 0 ||
|
|
485
|
+
raw.files.some((path) => typeof path !== "string")
|
|
486
|
+
)
|
|
487
|
+
throw new Error(`Invalid set-entry: ${id}`);
|
|
488
|
+
const files = [
|
|
489
|
+
...new Set((raw.files as string[]).map((path) => normalizeProjectPath(evidence.root, path))),
|
|
490
|
+
].sort();
|
|
491
|
+
if (files.some((path) => !evidence.eligibleFiles.has(path)))
|
|
492
|
+
throw new Error(`Entry uses a path outside the eligible scope: ${id}`);
|
|
493
|
+
if (existing) {
|
|
494
|
+
const conceptId = `${tab}/${concept}`;
|
|
495
|
+
if (!evidence.affectedIds.has(id) && !evidence.affectedConcepts.has(conceptId))
|
|
496
|
+
throw new Error(`Entry is unrelated: ${id}`);
|
|
497
|
+
if (conceptName !== existing.conceptName || conceptDescription !== existing.conceptDescription)
|
|
498
|
+
throw new Error(`Existing concept metadata cannot change: ${conceptId}`);
|
|
499
|
+
if (description === existing.description && files.join("\0") === existing.files.join("\0"))
|
|
500
|
+
throw new Error(`Set-entry is identical: ${id}`);
|
|
501
|
+
} else {
|
|
502
|
+
const existingConcept = evidence.entries.find((item) => item.tab === tab && item.concept === concept);
|
|
503
|
+
if (
|
|
504
|
+
existingConcept &&
|
|
505
|
+
(conceptName !== existingConcept.conceptName || conceptDescription !== existingConcept.conceptDescription)
|
|
506
|
+
)
|
|
507
|
+
throw new Error(`Existing concept metadata cannot change: ${tab}/${concept}`);
|
|
508
|
+
if (!files.some((path) => evidence.dirtyExisting.has(path) || evidence.dependencies.has(path)))
|
|
509
|
+
throw new Error(`New entry lacks a dirty file or dependency: ${id}`);
|
|
510
|
+
if (
|
|
511
|
+
!existingConcept &&
|
|
512
|
+
(!files.some((path) => evidence.dirtyExisting.has(path)) ||
|
|
513
|
+
files.some((path) => !evidence.eligibleFiles.has(path)))
|
|
514
|
+
)
|
|
515
|
+
throw new Error(`New concept is outside the affected scope: ${tab}/${concept}`);
|
|
516
|
+
}
|
|
517
|
+
changes.push({ action: "set-entry", tab, concept, conceptName, conceptDescription, entry, description, files });
|
|
518
|
+
}
|
|
519
|
+
const final = new Map(evidence.entries.map((entry) => [entry.id, [...entry.files]]));
|
|
520
|
+
for (const change of changes) {
|
|
521
|
+
const id = `${change.tab}/${change.concept}/${change.entry}`;
|
|
522
|
+
if (change.action === "delete-entry") final.delete(id);
|
|
523
|
+
else final.set(id, change.files);
|
|
524
|
+
}
|
|
525
|
+
for (const change of changes.filter(
|
|
526
|
+
(item): item is Extract<ContextSyncChange, { action: "delete-entry" }> => item.action === "delete-entry",
|
|
527
|
+
)) {
|
|
528
|
+
const old = current.get(`${change.tab}/${change.concept}/${change.entry}`);
|
|
529
|
+
if (!old) continue;
|
|
530
|
+
for (const path of old.files)
|
|
531
|
+
if (evidence.eligibleFiles.has(path) && ![...final.values()].some((files) => files.includes(path)))
|
|
532
|
+
throw new Error(`Deleting entry would orphan surviving file: ${path}`);
|
|
533
|
+
}
|
|
534
|
+
return { outcome: "apply", reason, changes };
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
async function computeWorktreeSignature(
|
|
538
|
+
git: GitRunner,
|
|
539
|
+
root: string,
|
|
540
|
+
files: readonly Pick<SyncDirtyFile, "path" | "untracked">[],
|
|
541
|
+
): Promise<string> {
|
|
542
|
+
const hash = createHash("sha256");
|
|
543
|
+
const status = await git.run(["status", "--porcelain=v2", "-z", "--untracked-files=all"], { cwd: root });
|
|
544
|
+
const [staged, unstaged] = await Promise.all([
|
|
545
|
+
git.run(["diff", "--cached", "--no-color", "--no-ext-diff"], { cwd: root, optional: true }),
|
|
546
|
+
git.run(["diff", "--no-color", "--no-ext-diff"], { cwd: root, optional: true }),
|
|
547
|
+
]);
|
|
548
|
+
hash.update(status);
|
|
549
|
+
hash.update("\0staged\0");
|
|
550
|
+
hash.update(staged);
|
|
551
|
+
hash.update("\0unstaged\0");
|
|
552
|
+
hash.update(unstaged);
|
|
553
|
+
for (const file of files.filter((item) => item.untracked).sort((a, b) => a.path.localeCompare(b.path))) {
|
|
554
|
+
try {
|
|
555
|
+
const info = await stat(join(root, file.path));
|
|
556
|
+
hash.update(`\0${file.path}:${info.size}:${info.mtimeMs}`);
|
|
557
|
+
if (info.isFile() && info.size <= MAX_UNTRACKED_BYTES) hash.update(await readFile(join(root, file.path)));
|
|
558
|
+
} catch {
|
|
559
|
+
hash.update(`\0${file.path}:missing`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return hash.digest("hex");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
async function computeCatalogSignature(root: string): Promise<string> {
|
|
566
|
+
const hash = createHash("sha256");
|
|
567
|
+
const base = join(root, ".pi", "contexts");
|
|
568
|
+
if (!(await pathExists(base))) return hash.digest("hex");
|
|
569
|
+
for (const tab of (await readdir(base, { withFileTypes: true }))
|
|
570
|
+
.filter((item) => item.isDirectory())
|
|
571
|
+
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
572
|
+
for (const file of (await readdir(join(base, tab.name), { withFileTypes: true }))
|
|
573
|
+
.filter((item) => item.isFile() && extname(item.name) === ".toml")
|
|
574
|
+
.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
575
|
+
const path = join(base, tab.name, file.name);
|
|
576
|
+
hash.update(relative(root, path));
|
|
577
|
+
hash.update("\0");
|
|
578
|
+
hash.update(await readFile(path));
|
|
579
|
+
hash.update("\0");
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
return hash.digest("hex");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export async function applyContextSyncPlan(
|
|
586
|
+
root: string,
|
|
587
|
+
plan: Extract<ContextSyncPlan, { outcome: "apply" }>,
|
|
588
|
+
entries: readonly ContextEntry[],
|
|
589
|
+
verifyFreshness?: () => Promise<void>,
|
|
590
|
+
): Promise<ContextSyncDetails> {
|
|
591
|
+
for (const change of plan.changes) if (change.action === "set-entry") await requireFiles(root, change.files);
|
|
592
|
+
const concepts = [...new Set(plan.changes.map((change) => `${change.tab}/${change.concept}`))].sort();
|
|
593
|
+
const outputs = new Map<string, string | undefined>();
|
|
594
|
+
for (const key of concepts) {
|
|
595
|
+
const [tab, concept] = key.split("/");
|
|
596
|
+
if (!tab || !concept) continue;
|
|
597
|
+
const path = join(root, ".pi", "contexts", tab, `${concept}.toml`);
|
|
598
|
+
const raw: Record<string, unknown> = (await pathExists(path))
|
|
599
|
+
? (parse(await readFile(path, "utf8")) as Record<string, unknown>)
|
|
600
|
+
: {};
|
|
601
|
+
for (const change of plan.changes.filter((item) => item.tab === tab && item.concept === concept)) {
|
|
602
|
+
if (change.action === "delete-entry") delete raw[change.entry];
|
|
603
|
+
else {
|
|
604
|
+
if (raw.name === undefined) raw.name = change.conceptName;
|
|
605
|
+
if (raw.description === undefined) raw.description = change.conceptDescription;
|
|
606
|
+
raw[change.entry] = { description: change.description, files: change.files };
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
outputs.set(
|
|
610
|
+
path,
|
|
611
|
+
Object.keys(raw).some((key) => key !== "name" && key !== "description") ? stringify(raw) : undefined,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
const paths = [...outputs.keys()].sort();
|
|
615
|
+
const lockPaths = [...new Set([...paths, ...entries.map((entry) => entry.path)])].sort();
|
|
616
|
+
const originals = new Map<string, Buffer | undefined>();
|
|
617
|
+
for (const path of paths) originals.set(path, (await pathExists(path)) ? await readFile(path) : undefined);
|
|
618
|
+
const temporaryFiles = new Map<string, string>();
|
|
619
|
+
try {
|
|
620
|
+
for (const path of paths) {
|
|
621
|
+
const output = outputs.get(path);
|
|
622
|
+
if (output === undefined) continue;
|
|
623
|
+
await mkdir(dirname(path), { recursive: true });
|
|
624
|
+
const temporary = `${path}.${process.pid}.${Date.now()}.${temporaryFiles.size}.tmp`;
|
|
625
|
+
await writeFile(temporary, output, "utf8");
|
|
626
|
+
temporaryFiles.set(path, temporary);
|
|
627
|
+
}
|
|
628
|
+
await acquireMutationQueues(lockPaths, async () => {
|
|
629
|
+
await verifyFreshness?.();
|
|
630
|
+
const changed: string[] = [];
|
|
631
|
+
try {
|
|
632
|
+
for (const path of paths) {
|
|
633
|
+
const temporary = temporaryFiles.get(path);
|
|
634
|
+
if (!temporary) await rm(path, { force: true });
|
|
635
|
+
else await rename(temporary, path);
|
|
636
|
+
changed.push(path);
|
|
637
|
+
}
|
|
638
|
+
} catch (error) {
|
|
639
|
+
for (const path of changed.reverse()) {
|
|
640
|
+
const original = originals.get(path);
|
|
641
|
+
if (original === undefined) await rm(path, { force: true });
|
|
642
|
+
else {
|
|
643
|
+
await mkdir(dirname(path), { recursive: true });
|
|
644
|
+
await writeFile(path, original);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
throw error;
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
} finally {
|
|
651
|
+
await Promise.all([...temporaryFiles.values()].map((path) => rm(path, { force: true })));
|
|
652
|
+
}
|
|
653
|
+
for (const path of paths.filter((path) => outputs.get(path) === undefined))
|
|
654
|
+
await rm(dirname(path), { recursive: false }).catch(() => undefined);
|
|
655
|
+
const currentIds = new Set(entries.map((entry) => entry.id));
|
|
656
|
+
const created = plan.changes.filter(
|
|
657
|
+
(change) => change.action === "set-entry" && !currentIds.has(`${change.tab}/${change.concept}/${change.entry}`),
|
|
658
|
+
).length;
|
|
659
|
+
const updated = plan.changes.filter(
|
|
660
|
+
(change) => change.action === "set-entry" && currentIds.has(`${change.tab}/${change.concept}/${change.entry}`),
|
|
661
|
+
).length;
|
|
662
|
+
const deleted = plan.changes.filter((change) => change.action === "delete-entry").length;
|
|
663
|
+
const changedContextFiles = paths.map((path) => relative(root, path).split(sep).join("/"));
|
|
664
|
+
const summary = `Updated ${updated} context entries; created ${created}; removed ${deleted}.`;
|
|
665
|
+
return {
|
|
666
|
+
outcome: "applied",
|
|
667
|
+
summary,
|
|
668
|
+
changedContextFiles,
|
|
669
|
+
reason: plan.reason,
|
|
670
|
+
changes: plan.changes,
|
|
671
|
+
counts: { created, updated, deleted, unchanged: entries.length - updated - deleted },
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
async function acquireMutationQueues<T>(paths: readonly string[], task: () => Promise<T>, index = 0): Promise<T> {
|
|
676
|
+
const path = paths[index];
|
|
677
|
+
if (!path) return task();
|
|
678
|
+
return withFileMutationQueue(path, () => acquireMutationQueues(paths, task, index + 1));
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
682
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
683
|
+
}
|
|
@@ -2,6 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
Explore is Tau's first-party filesystem exploration extension.
|
|
4
4
|
|
|
5
|
-
It exists so agents can inspect paths, discover files, search text, and read file contents with compact model payloads and readable tool rows.
|
|
5
|
+
It exists so agents can inspect paths, discover files, search text, and read file contents with compact model payloads and readable tool rows. Repeated reads avoid resending content the agent already saw, including files supplied by autoread, while changed files return useful diffs or current source.
|
|
6
6
|
|
|
7
|
-
Agents invoke it with `ls`, `find`, `grep`, and `read`.
|
|
7
|
+
Agents invoke it with `ls`, `find`, `grep`, and `read`. Users run `/read-stats` to see estimated token and cost savings for the session.
|