@shanepadgett/tau-agent 0.31.1 → 0.32.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/extensions/commit/commit-effort.ts +14 -0
- package/extensions/commit/commit-plan.ts +6 -7
- package/extensions/commit/git-change-set.ts +36 -4
- package/extensions/context/index.ts +1 -1
- package/extensions/reference/README.md +1 -0
- package/extensions/reference/panel.ts +34 -4
- package/extensions/tau-help/help.md +0 -4
- package/extensions/working-memory/README.md +9 -1
- package/extensions/working-memory/checkpoint.ts +80 -15
- package/extensions/working-memory/index.ts +7 -5
- package/extensions/working-memory/memory.ts +1 -1
- package/extensions/working-memory/render.ts +9 -2
- package/extensions/working-memory/state.ts +10 -5
- package/package.json +2 -2
- package/shared/model-effort.ts +38 -0
- package/extensions/publish/README.md +0 -13
- package/extensions/publish/index.ts +0 -301
- package/extensions/publish/publish-ui.ts +0 -73
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ModelEffort } from "../../shared/model-effort.ts";
|
|
2
|
+
import type { DirtyFile } from "./git-change-set.ts";
|
|
3
|
+
|
|
4
|
+
const LOW_EFFORT_MAX_CHANGED_LINES = 500;
|
|
5
|
+
|
|
6
|
+
export function commitEffort(files: readonly Pick<DirtyFile, "changeSize">[]): ModelEffort {
|
|
7
|
+
let changedLines = 0;
|
|
8
|
+
for (const file of files) {
|
|
9
|
+
if (file.changeSize === "binary") return "medium";
|
|
10
|
+
changedLines += file.changeSize;
|
|
11
|
+
if (changedLines > LOW_EFFORT_MAX_CHANGED_LINES) return "medium";
|
|
12
|
+
}
|
|
13
|
+
return "low";
|
|
14
|
+
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { type
|
|
2
|
+
import { type Tool, Type } from "@earendil-works/pi-ai";
|
|
3
3
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import {
|
|
4
|
+
import { resolveEffortCandidates } from "../../shared/model-effort.ts";
|
|
5
|
+
import { generateToolValidated, generateValidated } from "../../shared/model-fallback/index.ts";
|
|
5
6
|
import { truncAt } from "../../shared/text.ts";
|
|
7
|
+
import { commitEffort } from "./commit-effort.ts";
|
|
6
8
|
import type { CommitEvidence, DirtyFile } from "./git-change-set.ts";
|
|
7
9
|
|
|
8
10
|
const MAX_PLAN_EVIDENCE_CHARS = 48_000;
|
|
9
11
|
const CONVENTIONAL_COMMIT_TYPES = ["feat", "fix", "docs", "refactor", "test", "chore", "perf", "ci", "build", "revert"];
|
|
10
|
-
const COMMIT_MODELS: ReadonlyArray<{ provider: string; model: string; reasoning: ThinkingLevel }> = [
|
|
11
|
-
{ provider: "openai-codex", model: "gpt-5.6-terra", reasoning: "low" },
|
|
12
|
-
];
|
|
13
12
|
const COMMIT_PLAN_TOOL = {
|
|
14
13
|
name: "create_commit_plan",
|
|
15
14
|
description: "Submit the commit plan for the dirty repository files.",
|
|
@@ -55,7 +54,7 @@ export async function generatePlan(
|
|
|
55
54
|
const prompt = buildPlanPrompt(evidence, previousPlan, regenerationNote);
|
|
56
55
|
return generateToolValidated(
|
|
57
56
|
ctx,
|
|
58
|
-
await
|
|
57
|
+
await resolveEffortCandidates(ctx, commitEffort(evidence.files), true),
|
|
59
58
|
prompt,
|
|
60
59
|
COMMIT_PLAN_TOOL,
|
|
61
60
|
(input) => commitGroupsFromToolInput(input, evidence.files),
|
|
@@ -85,7 +84,7 @@ export async function regenerateMessage(
|
|
|
85
84
|
const prompt = buildMessagePrompt(evidence, selected, previousPlan, selectedGroupId, regenerationNote);
|
|
86
85
|
return generateValidated(
|
|
87
86
|
ctx,
|
|
88
|
-
await
|
|
87
|
+
await resolveEffortCandidates(ctx, commitEffort(selected), true),
|
|
89
88
|
prompt,
|
|
90
89
|
requireCommitMessage,
|
|
91
90
|
undefined,
|
|
@@ -26,6 +26,7 @@ export interface DirtyFile {
|
|
|
26
26
|
kind: "modified" | "added" | "deleted" | "renamed" | "untracked";
|
|
27
27
|
untracked: boolean;
|
|
28
28
|
evidence: string;
|
|
29
|
+
changeSize: number | "binary";
|
|
29
30
|
renamedFrom?: string;
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -115,9 +116,11 @@ async function collectFileEvidence(
|
|
|
115
116
|
for (let index = 0; index < files.length; index += EVIDENCE_CONCURRENCY) {
|
|
116
117
|
withEvidence.push(
|
|
117
118
|
...(await Promise.all(
|
|
118
|
-
files
|
|
119
|
-
|
|
120
|
-
|
|
119
|
+
files.slice(index, index + EVIDENCE_CONCURRENCY).map(async (file) => ({
|
|
120
|
+
...file,
|
|
121
|
+
evidence: await loadEvidenceForFile(git, root, file, hasHead),
|
|
122
|
+
changeSize: await loadChangeSize(git, root, file, hasHead),
|
|
123
|
+
})),
|
|
121
124
|
)),
|
|
122
125
|
);
|
|
123
126
|
}
|
|
@@ -155,7 +158,7 @@ function parsePorcelainV2Z(raw: string): DirtyFile[] {
|
|
|
155
158
|
|
|
156
159
|
return parsed
|
|
157
160
|
.sort((left, right) => left.path.localeCompare(right.path))
|
|
158
|
-
.map((file, index) => ({ ...file, id: index + 1, kind: changeKind(file), evidence: "" }));
|
|
161
|
+
.map((file, index) => ({ ...file, id: index + 1, kind: changeKind(file), evidence: "", changeSize: 0 }));
|
|
159
162
|
}
|
|
160
163
|
|
|
161
164
|
function statusText(fields: readonly string[]): string {
|
|
@@ -176,6 +179,35 @@ async function loadEvidenceForFile(git: GitRunner, root: string, file: DirtyFile
|
|
|
176
179
|
return truncAt(diff || "metadata-only change", MAX_FILE_EVIDENCE_CHARS);
|
|
177
180
|
}
|
|
178
181
|
|
|
182
|
+
async function loadChangeSize(
|
|
183
|
+
git: GitRunner,
|
|
184
|
+
root: string,
|
|
185
|
+
file: DirtyFile,
|
|
186
|
+
hasHead: boolean,
|
|
187
|
+
): Promise<number | "binary"> {
|
|
188
|
+
if (file.renamedFrom && !/[MAD]/.test(file.status)) return 0;
|
|
189
|
+
const paths = file.renamedFrom ? [file.renamedFrom, file.path] : [file.path];
|
|
190
|
+
const output = file.untracked
|
|
191
|
+
? await git.run(["diff", "--no-index", "--numstat", "--", "/dev/null", file.path], { cwd: root, optional: true })
|
|
192
|
+
: hasHead
|
|
193
|
+
? await git.run(["diff", "--numstat", "HEAD", "--", ...paths], { cwd: root, optional: true })
|
|
194
|
+
: [
|
|
195
|
+
await git.run(["diff", "--cached", "--numstat", "--", ...paths], { cwd: root, optional: true }),
|
|
196
|
+
await git.run(["diff", "--numstat", "--", ...paths], { cwd: root, optional: true }),
|
|
197
|
+
].join("\n");
|
|
198
|
+
|
|
199
|
+
let total = 0;
|
|
200
|
+
for (const line of output.split("\n")) {
|
|
201
|
+
if (!line) continue;
|
|
202
|
+
const [added, deleted] = line.split("\t");
|
|
203
|
+
if (added === "-" || deleted === "-") return "binary";
|
|
204
|
+
const additions = Number(added);
|
|
205
|
+
const deletions = Number(deleted);
|
|
206
|
+
if (Number.isFinite(additions) && Number.isFinite(deletions)) total += additions + deletions;
|
|
207
|
+
}
|
|
208
|
+
return total;
|
|
209
|
+
}
|
|
210
|
+
|
|
179
211
|
async function diffFromIndexAndWorktree(git: GitRunner, root: string, paths: readonly string[]): Promise<string> {
|
|
180
212
|
const [staged, unstaged] = await Promise.all([
|
|
181
213
|
git.run(["diff", "--cached", "--stat", "--patch", "--no-color", "--no-ext-diff", "--", ...paths], {
|
|
@@ -147,7 +147,7 @@ export default function contextExtension(pi: ExtensionAPI): void {
|
|
|
147
147
|
),
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
|
-
return { messages: [contextProjectionMessage(projectionCache.content)
|
|
150
|
+
return { messages: [...messages, contextProjectionMessage(projectionCache.content)] };
|
|
151
151
|
});
|
|
152
152
|
pi.on("agent_start", async (_event, ctx) => {
|
|
153
153
|
await refreshSettings(ctx);
|
|
@@ -13,6 +13,7 @@ Select local reference repositories, add new ones, and send a prompt that points
|
|
|
13
13
|
|
|
14
14
|
- Stores references in `~/.local/share/tau-agent/references/`.
|
|
15
15
|
- `/reference` opens a TUI picker.
|
|
16
|
+
- The picker shows each repository's name with its `origin` owner in muted parentheses when available, falling back to its local folder name.
|
|
16
17
|
- `n` adds a new reference from a git URL, showing clone progress in the list as `↓ 37%`.
|
|
17
18
|
- New references clone the remote default branch.
|
|
18
19
|
- `b` switches the highlighted reference to another branch.
|
|
@@ -27,6 +27,7 @@ const BRANCH_VISIBLE_ROWS = 5;
|
|
|
27
27
|
|
|
28
28
|
export interface ReferenceItem {
|
|
29
29
|
name: string;
|
|
30
|
+
displayName: string;
|
|
30
31
|
path: string;
|
|
31
32
|
dirty: boolean;
|
|
32
33
|
branch: string;
|
|
@@ -190,7 +191,7 @@ class ReferencePanel implements Component {
|
|
|
190
191
|
items,
|
|
191
192
|
emptyMessage: "No references. Press ctrl+n for new.",
|
|
192
193
|
selection: { kind: "multi", primaryLabel: "attach" },
|
|
193
|
-
filter: { searchText: (item) => `${item.name} ${item.branch} ${item.path}` },
|
|
194
|
+
filter: { searchText: (item) => `${item.displayName} ${item.name} ${item.branch} ${item.path}` },
|
|
194
195
|
actions: [
|
|
195
196
|
{ id: "new", key: Key.ctrl("n"), hint: rawHint("ctrl+n", "new") },
|
|
196
197
|
{ id: "open", key: Key.ctrl("o"), hint: rawHint("ctrl+o", "open"), target: "current" },
|
|
@@ -303,13 +304,16 @@ class ReferencePanel implements Component {
|
|
|
303
304
|
}
|
|
304
305
|
|
|
305
306
|
private renderReferenceRow(item: ReferenceListItem, active: boolean, width: number): string[] {
|
|
306
|
-
const
|
|
307
|
+
const separator = item.displayName.lastIndexOf("/");
|
|
308
|
+
const repository = separator < 0 ? item.displayName : item.displayName.slice(separator + 1);
|
|
309
|
+
const owner = separator < 0 ? "" : this.theme.fg("muted", ` (${item.displayName.slice(0, separator)})`);
|
|
310
|
+
const name = item.dirty ? `${repository} *` : repository;
|
|
307
311
|
const label = active ? this.theme.fg("accent", name) : this.theme.fg(item.dirty ? "warning" : "text", name);
|
|
308
312
|
const branch = item.branch ? this.theme.fg("muted", ` (${item.branch})`) : "";
|
|
309
313
|
const status = item.state
|
|
310
314
|
? ` ${this.theme.fg(item.state === "failed" ? "error" : item.state === "updated" ? "success" : "muted", item.state === "failed" ? "!" : item.state === "updated" ? "✓" : "…")}`
|
|
311
315
|
: "";
|
|
312
|
-
return [truncateToWidth(`${label}${branch}${status}`, width, "")];
|
|
316
|
+
return [truncateToWidth(`${label}${owner}${branch}${status}`, width, "")];
|
|
313
317
|
}
|
|
314
318
|
|
|
315
319
|
private handleBranchInput(data: string): void {
|
|
@@ -636,7 +640,7 @@ function toListItem(item: ReferenceItem, state?: ReferenceListItem["state"]): Re
|
|
|
636
640
|
}
|
|
637
641
|
|
|
638
642
|
function toReferenceItem(item: ReferenceListItem): ReferenceItem {
|
|
639
|
-
return { name: item.name, path: item.path, dirty: item.dirty, branch: item.branch };
|
|
643
|
+
return { name: item.name, displayName: item.displayName, path: item.path, dirty: item.dirty, branch: item.branch };
|
|
640
644
|
}
|
|
641
645
|
|
|
642
646
|
async function cloneReference(
|
|
@@ -747,8 +751,10 @@ async function loadReferences(git: GitRunner): Promise<ReferenceItem[]> {
|
|
|
747
751
|
async function loadReference(git: GitRunner, name: string, path: string): Promise<ReferenceItem> {
|
|
748
752
|
const branch = await git.run(["branch", "--show-current"], { cwd: path, optional: true });
|
|
749
753
|
const commit = branch ? "" : await git.run(["rev-parse", "--short", "HEAD"], { cwd: path, optional: true });
|
|
754
|
+
const remoteUrl = await git.run(["config", "--get", "remote.origin.url"], { cwd: path, optional: true });
|
|
750
755
|
return {
|
|
751
756
|
name,
|
|
757
|
+
displayName: referenceDisplayName(remoteUrl, name),
|
|
752
758
|
path,
|
|
753
759
|
dirty: (await git.run(["status", "--porcelain=v1"], { cwd: path, optional: true })).length > 0,
|
|
754
760
|
branch: branch || (commit ? `detached ${commit}` : ""),
|
|
@@ -865,6 +871,30 @@ function referenceNameFromUrl(url: string): string {
|
|
|
865
871
|
return name;
|
|
866
872
|
}
|
|
867
873
|
|
|
874
|
+
export function referenceDisplayName(remoteUrl: string, fallback: string): string {
|
|
875
|
+
const remote = remoteUrl
|
|
876
|
+
.trim()
|
|
877
|
+
.replace(/[?#].*$/, "")
|
|
878
|
+
.replace(/\/+$/, "")
|
|
879
|
+
.replace(/\.git$/i, "");
|
|
880
|
+
if (!remote) return fallback;
|
|
881
|
+
|
|
882
|
+
let path: string;
|
|
883
|
+
try {
|
|
884
|
+
const parsed = new URL(remote);
|
|
885
|
+
if (!parsed.host) return fallback;
|
|
886
|
+
path = parsed.pathname;
|
|
887
|
+
} catch {
|
|
888
|
+
const separator = remote.indexOf(":");
|
|
889
|
+
if (separator <= 0 || remote.slice(0, separator).includes("/")) return fallback;
|
|
890
|
+
path = remote.slice(separator + 1);
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
const segments = path.split("/").filter(Boolean);
|
|
894
|
+
if (segments.length < 2) return fallback;
|
|
895
|
+
return segments.slice(-2).join("/");
|
|
896
|
+
}
|
|
897
|
+
|
|
868
898
|
function shellQuote(value: string): string {
|
|
869
899
|
return `'${value.replaceAll("'", "'\\''")}'`;
|
|
870
900
|
}
|
|
@@ -66,10 +66,6 @@ Adds `/manage-sessions` to browse saved sessions and `/sweep` to archive or dele
|
|
|
66
66
|
|
|
67
67
|
Replaces separate edit/write operations with one multi-file `patch` tool. It can create, rewrite, edit, move, and delete files in one structured call. Fewer tool calls means fewer turns, and each avoided turn prevents the full chat context from being sent again. Tau keeps `patch` disabled and uses `edit` and `write` for xAI and Grok models.
|
|
68
68
|
|
|
69
|
-
## publish
|
|
70
|
-
|
|
71
|
-
Adds `/publish` to create a tagged release, trigger trusted npm publishing in GitHub Actions, and show its status. It recommends a semantic version bump from commits since the prior release tag, but you must confirm the release type and publish action. If publishing fails, the agent investigates with read-only diagnostics and recommends a solution without applying it or retrying the release.
|
|
72
|
-
|
|
73
69
|
## qna
|
|
74
70
|
|
|
75
71
|
Adds `/qna` for when the agent has asked you several questions in chat and you want a friendly UI for answering them on your own terms. It is only active when you manually run the command.
|
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Working Memory gives agent selective checkpoints without changing saved conversation.
|
|
4
4
|
|
|
5
|
-
`working_memory` keeps referenced user messages and visible assistant text
|
|
5
|
+
`working_memory` keeps referenced user messages and visible assistant text. It has three file tiers:
|
|
6
|
+
|
|
7
|
+
- Auto-read full source into next turn when agent needs its body to decide, edit, or debug.
|
|
8
|
+
- Carry a structural outline when symbols and locations will support a later scoped read.
|
|
9
|
+
- Defer inactive files with reason and condition that makes them relevant again.
|
|
10
|
+
|
|
11
|
+
Use one tier for each path. `readFiles` publishes Tau's built-in autoread request; `outlineFiles` injects a structural outline; `deferFiles` records a conditional reminder.
|
|
12
|
+
|
|
13
|
+
`continuation` is a working note for next step: keep durable decisions, findings, unresolved questions, remaining work, and next action. It should carry enough reasoning to resume without rereading discarded results.
|
|
6
14
|
|
|
7
15
|
Automatic reminders begin at 40,000 active-context tokens and remain advisory. Run `/prune` to request reassessment manually.
|
|
8
16
|
|
|
@@ -2,17 +2,38 @@ import { resolve } from "node:path";
|
|
|
2
2
|
import { type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { type Static, Type } from "typebox";
|
|
4
4
|
import { truncateBoundedHead } from "../../shared/bounded-text-result.ts";
|
|
5
|
+
import { emitTauEvent } from "../../shared/events.ts";
|
|
5
6
|
import { requestOutlineInjections, type PreparedOutlineInjection } from "../../shared/outline-injection.ts";
|
|
6
7
|
import { buildMemoryCatalog, collectPrunedRowIds } from "./memory.ts";
|
|
7
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
replayWorkingMemoryState,
|
|
10
|
+
WORKING_MEMORY_TOOL,
|
|
11
|
+
type DeferredFile,
|
|
12
|
+
type WorkingMemoryCheckpointDetailsV2,
|
|
13
|
+
} from "./state.ts";
|
|
8
14
|
|
|
9
15
|
const PATH = Type.String({ minLength: 1, maxLength: 500, pattern: "\\S" });
|
|
10
16
|
|
|
11
17
|
export const workingMemoryParameters = Type.Object(
|
|
12
18
|
{
|
|
13
|
-
continuation: Type.String({
|
|
19
|
+
continuation: Type.String({
|
|
20
|
+
minLength: 1,
|
|
21
|
+
maxLength: 8_000,
|
|
22
|
+
pattern: "\\S",
|
|
23
|
+
description:
|
|
24
|
+
"Working note for resuming mid-task. Carry durable decisions, concrete findings, live reasoning, unresolved questions, remaining work, and next action; include enough detail to continue without rereading discarded results.",
|
|
25
|
+
}),
|
|
14
26
|
keep: Type.Array(Type.String({ minLength: 3, maxLength: 100 }), { maxItems: 100 }),
|
|
15
|
-
|
|
27
|
+
readFiles: Type.Array(PATH, {
|
|
28
|
+
maxItems: 12,
|
|
29
|
+
description:
|
|
30
|
+
"Files whose source should be auto-read into the next turn. Use when the next work needs the complete file; do not also outline or defer these files.",
|
|
31
|
+
}),
|
|
32
|
+
outlineFiles: Type.Array(PATH, {
|
|
33
|
+
maxItems: 12,
|
|
34
|
+
description:
|
|
35
|
+
"Files whose structural outline should be carried into the next turn. Use when symbols and locations will guide later scoped inspection; do not use when the full source is still needed.",
|
|
36
|
+
}),
|
|
16
37
|
deferFiles: Type.Array(
|
|
17
38
|
Type.Object(
|
|
18
39
|
{
|
|
@@ -22,7 +43,11 @@ export const workingMemoryParameters = Type.Object(
|
|
|
22
43
|
},
|
|
23
44
|
{ additionalProperties: false },
|
|
24
45
|
),
|
|
25
|
-
{
|
|
46
|
+
{
|
|
47
|
+
maxItems: 8,
|
|
48
|
+
description:
|
|
49
|
+
"Files to leave out of active context. Record why they are inactive and the concrete condition that would make them relevant.",
|
|
50
|
+
},
|
|
26
51
|
),
|
|
27
52
|
},
|
|
28
53
|
{ additionalProperties: false },
|
|
@@ -43,7 +68,7 @@ interface ExecuteWorkingMemoryOptions {
|
|
|
43
68
|
export interface WorkingMemoryExecution {
|
|
44
69
|
result: {
|
|
45
70
|
content: Array<{ type: "text"; text: string }>;
|
|
46
|
-
details:
|
|
71
|
+
details: WorkingMemoryCheckpointDetailsV2;
|
|
47
72
|
};
|
|
48
73
|
outlines: PreparedOutlineInjection[];
|
|
49
74
|
}
|
|
@@ -52,6 +77,12 @@ export async function executeWorkingMemory(options: ExecuteWorkingMemoryOptions)
|
|
|
52
77
|
assertCurrent(options);
|
|
53
78
|
const branch = options.ctx.sessionManager.getBranch();
|
|
54
79
|
const catalog = buildMemoryCatalog(branch);
|
|
80
|
+
const anchorIndex = findAnchorEntry(branch, options.toolCallId);
|
|
81
|
+
const previousState = replayWorkingMemoryState(branch, true);
|
|
82
|
+
const previousAnchorIndex =
|
|
83
|
+
previousState.latestAnchorToolCallId === undefined
|
|
84
|
+
? -1
|
|
85
|
+
: findAnchorEntry(branch, previousState.latestAnchorToolCallId);
|
|
55
86
|
const requestedRefs = [...new Set(options.params.keep)];
|
|
56
87
|
const retained = requestedRefs
|
|
57
88
|
.flatMap((ref) => {
|
|
@@ -64,7 +95,25 @@ export async function executeWorkingMemory(options: ExecuteWorkingMemoryOptions)
|
|
|
64
95
|
.filter((ref) => !catalog.has(ref))
|
|
65
96
|
.map((ref) => `${ref}: memory reference is unavailable and was not retained`);
|
|
66
97
|
|
|
67
|
-
const
|
|
98
|
+
const rootInstructionsKey = resolve(options.ctx.cwd, "AGENTS.md");
|
|
99
|
+
const requestedRootInstructions = [
|
|
100
|
+
...options.params.readFiles,
|
|
101
|
+
...options.params.outlineFiles,
|
|
102
|
+
...options.params.deferFiles.map((file) => file.path),
|
|
103
|
+
].some((path) => resolve(options.ctx.cwd, normalizePath(path)) === rootInstructionsKey);
|
|
104
|
+
if (requestedRootInstructions) {
|
|
105
|
+
warnings.push(
|
|
106
|
+
"AGENTS.md: omitted from working-memory checkpoint because it is already in your context window. Read it directly only when actively changing it.",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const readPaths = dedupePaths(options.params.readFiles, options.ctx.cwd).filter(
|
|
111
|
+
(path) => resolve(options.ctx.cwd, path) !== rootInstructionsKey,
|
|
112
|
+
);
|
|
113
|
+
const readKeys = new Set(readPaths.map((path) => resolve(options.ctx.cwd, path)));
|
|
114
|
+
const outlinePaths = dedupePaths(options.params.outlineFiles, options.ctx.cwd).filter(
|
|
115
|
+
(path) => resolve(options.ctx.cwd, path) !== rootInstructionsKey && !readKeys.has(resolve(options.ctx.cwd, path)),
|
|
116
|
+
);
|
|
68
117
|
const outlineResponse = await requestOutlineInjections(options.pi, {
|
|
69
118
|
cwd: options.ctx.cwd,
|
|
70
119
|
batchId: options.toolCallId,
|
|
@@ -75,34 +124,50 @@ export async function executeWorkingMemory(options: ExecuteWorkingMemoryOptions)
|
|
|
75
124
|
warnings.push(...outlineResponse.warnings.map((warning) => boundedWarning(warning)));
|
|
76
125
|
assertCurrent(options);
|
|
77
126
|
|
|
78
|
-
const
|
|
79
|
-
|
|
80
|
-
|
|
127
|
+
const carriedKeys = new Set([
|
|
128
|
+
...readKeys,
|
|
129
|
+
...outlineResponse.messages.map((message) => resolve(options.ctx.cwd, message.details.path)),
|
|
130
|
+
]);
|
|
81
131
|
const deferredFiles: DeferredFile[] = [];
|
|
82
132
|
const deferredKeys = new Set<string>();
|
|
83
133
|
for (const file of options.params.deferFiles) {
|
|
84
134
|
const path = normalizePath(file.path);
|
|
85
135
|
const key = resolve(options.ctx.cwd, path);
|
|
86
|
-
if (
|
|
136
|
+
if (key === rootInstructionsKey || carriedKeys.has(key) || deferredKeys.has(key)) continue;
|
|
87
137
|
deferredKeys.add(key);
|
|
88
138
|
deferredFiles.push({ path, reason: file.reason.trim(), relevantWhen: file.relevantWhen.trim() });
|
|
89
139
|
}
|
|
90
140
|
|
|
91
|
-
|
|
92
|
-
|
|
141
|
+
if (readPaths.length > 0) {
|
|
142
|
+
emitTauEvent(options.pi, "tau:autoread.requested", {
|
|
143
|
+
source: WORKING_MEMORY_TOOL,
|
|
144
|
+
title: "Working-memory checkpoint",
|
|
145
|
+
cwd: options.ctx.cwd,
|
|
146
|
+
batchId: options.toolCallId,
|
|
147
|
+
files: readPaths.map((path) => ({ path })),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
93
151
|
const prunedRowIds = collectPrunedRowIds(branch, anchorIndex);
|
|
94
|
-
const
|
|
95
|
-
|
|
152
|
+
const activeRefs = new Set(previousState.retainedRefs.filter((ref) => catalog.has(ref)));
|
|
153
|
+
for (const unit of catalog.values()) {
|
|
154
|
+
if (unit.order > previousAnchorIndex && unit.order < anchorIndex) activeRefs.add(unit.ref);
|
|
155
|
+
}
|
|
156
|
+
const newlyPrunedRows = prunedRowIds.filter((rowId) => !previousState.prunedRowIds.has(rowId));
|
|
157
|
+
const droppedBlocks = [...activeRefs].filter((ref) => !retainedRefs.includes(ref)).length + newlyPrunedRows.length;
|
|
158
|
+
const details: WorkingMemoryCheckpointDetailsV2 = {
|
|
159
|
+
v: 2,
|
|
96
160
|
anchorToolCallId: options.toolCallId,
|
|
97
161
|
retainedRefs,
|
|
98
162
|
retainedLabels: retained.map((unit) => ({ ref: unit.ref, label: unit.label, preview: unit.preview })),
|
|
99
163
|
prunedRowIds,
|
|
164
|
+
readFiles: readPaths,
|
|
100
165
|
outlinedFiles: outlineResponse.messages.map((message) => ({
|
|
101
166
|
path: message.details.path,
|
|
102
167
|
rowId: message.details.rowId,
|
|
103
168
|
})),
|
|
104
169
|
deferredFiles,
|
|
105
|
-
removedUnits:
|
|
170
|
+
removedUnits: droppedBlocks,
|
|
106
171
|
warnings,
|
|
107
172
|
};
|
|
108
173
|
const markdown = formatResult(options.params.continuation.trim(), deferredFiles, warnings);
|
|
@@ -17,12 +17,12 @@ import {
|
|
|
17
17
|
type WorkingMemoryNudgeDetails,
|
|
18
18
|
} from "./render.ts";
|
|
19
19
|
import workingMemorySettings from "./settings.ts";
|
|
20
|
-
import { replayWorkingMemoryState, WORKING_MEMORY_TOOL, type
|
|
20
|
+
import { replayWorkingMemoryState, WORKING_MEMORY_TOOL, type WorkingMemoryCheckpointDetailsV2 } from "./state.ts";
|
|
21
21
|
|
|
22
22
|
const NUDGE_TYPE = "tau.working-memory.nudge";
|
|
23
23
|
const BASELINE_TYPE = "tau.working-memory.nudge-baseline";
|
|
24
24
|
const TOOL_DESCRIPTION =
|
|
25
|
-
"Create a selective hard checkpoint for future model context. Keep valuable user and visible assistant messages, carry file structure as
|
|
25
|
+
"Create a selective hard checkpoint for future model context. Keep valuable user and visible assistant messages, auto-read full source or carry file structure as needed, defer conditionally relevant files, and distill exploration findings into one compact continuation note.";
|
|
26
26
|
|
|
27
27
|
interface NudgeState {
|
|
28
28
|
anchorToolCallId: string | undefined;
|
|
@@ -91,7 +91,7 @@ export default function workingMemoryExtension(pi: ExtensionAPI): void {
|
|
|
91
91
|
instructions = settings.nudgeInstructions;
|
|
92
92
|
if (enabled && !toolRegistered) {
|
|
93
93
|
pi.registerTool(
|
|
94
|
-
defineTool<typeof workingMemoryParameters,
|
|
94
|
+
defineTool<typeof workingMemoryParameters, WorkingMemoryCheckpointDetailsV2>({
|
|
95
95
|
name: WORKING_MEMORY_TOOL,
|
|
96
96
|
label: WORKING_MEMORY_TOOL,
|
|
97
97
|
description: TOOL_DESCRIPTION,
|
|
@@ -99,8 +99,9 @@ export default function workingMemoryExtension(pi: ExtensionAPI): void {
|
|
|
99
99
|
promptGuidelines: [
|
|
100
100
|
"Use working_memory when stale evidence has accumulated or a memory reminder asks for reassessment; continue coherent exploration when current evidence remains useful.",
|
|
101
101
|
"A hidden working-memory reference catalog provides keep refs only for user messages and visible assistant text. Tool calls, tool results, hidden reasoning, and framework messages cannot be retained.",
|
|
102
|
-
"
|
|
103
|
-
"
|
|
102
|
+
"Choose one file tier: readFiles auto-reads source into the next turn when its body is needed; outlineFiles carries symbols and locations for later scoped inspection; deferFiles records inactive conditional paths. Do not read a file merely to decide whether to outline it.",
|
|
103
|
+
"Choose readFiles instead of outlineFiles when next work will require the complete file. Do not list a path in more than one file tier.",
|
|
104
|
+
"Use continuation as a working note for resuming mid-task. Carry durable decisions, concrete findings, live reasoning, unresolved questions, remaining work, and next action in as much detail as needed to continue without rereading discarded results. Do not make it a user-facing status update or narrate the checkpoint.",
|
|
104
105
|
],
|
|
105
106
|
parameters: workingMemoryParameters,
|
|
106
107
|
executionMode: "sequential",
|
|
@@ -123,6 +124,7 @@ export default function workingMemoryExtension(pi: ExtensionAPI): void {
|
|
|
123
124
|
rowId: context.toolCallId,
|
|
124
125
|
invalidate: context.invalidate,
|
|
125
126
|
lastComponent: context.lastComponent,
|
|
127
|
+
executionStarted: context.executionStarted,
|
|
126
128
|
});
|
|
127
129
|
},
|
|
128
130
|
renderResult(result, options, theme, context) {
|
|
@@ -64,7 +64,7 @@ export function collectPrunedRowIds(branch: readonly SessionEntry[], before: num
|
|
|
64
64
|
}
|
|
65
65
|
if (
|
|
66
66
|
message.role === "custom" &&
|
|
67
|
-
message.customType === "tau.explore.outline" &&
|
|
67
|
+
(message.customType === "tau.explore.outline" || message.customType === "tau.autoread") &&
|
|
68
68
|
isRecord(message.details) &&
|
|
69
69
|
typeof message.details.rowId === "string"
|
|
70
70
|
) {
|
|
@@ -99,15 +99,21 @@ export function renderWorkingMemoryCall(
|
|
|
99
99
|
rowId: string;
|
|
100
100
|
invalidate: () => void;
|
|
101
101
|
lastComponent: unknown;
|
|
102
|
+
executionStarted: boolean;
|
|
102
103
|
},
|
|
103
104
|
): Text {
|
|
104
105
|
context.rowState.watch(context.rowId, context.invalidate);
|
|
105
106
|
const component = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
|
|
107
|
+
if (context.executionStarted) {
|
|
108
|
+
component.setText("");
|
|
109
|
+
return component;
|
|
110
|
+
}
|
|
106
111
|
const kept = Array.isArray(args.keep) ? args.keep.length : 0;
|
|
112
|
+
const read = Array.isArray(args.readFiles) ? args.readFiles.length : 0;
|
|
107
113
|
const outlined = Array.isArray(args.outlineFiles) ? args.outlineFiles.length : 0;
|
|
108
114
|
const deferred = Array.isArray(args.deferFiles) ? args.deferFiles.length : 0;
|
|
109
115
|
component.setText(
|
|
110
|
-
`${formatToolRowTitle(context.rowState, context.rowId, "working_memory", theme)} ${theme.fg("muted", `${kept} kept · ${outlined} outline · ${deferred} deferred`)}`,
|
|
116
|
+
`${formatToolRowTitle(context.rowState, context.rowId, "working_memory", theme)} ${theme.fg("muted", `${kept} kept · ${read} read · ${outlined} outline · ${deferred} deferred`)}`,
|
|
111
117
|
);
|
|
112
118
|
return component;
|
|
113
119
|
}
|
|
@@ -128,12 +134,13 @@ export function renderWorkingMemoryResult(
|
|
|
128
134
|
}
|
|
129
135
|
let text = theme.fg(
|
|
130
136
|
details.warnings.length === 0 ? "success" : "warning",
|
|
131
|
-
`Checkpoint · kept ${details.retainedRefs.length} · outlined ${details.outlinedFiles.length} · deferred ${details.deferredFiles.length} ·
|
|
137
|
+
`Checkpoint · kept ${details.retainedRefs.length} · read ${details.readFiles.length} · outlined ${details.outlinedFiles.length} · deferred ${details.deferredFiles.length} · pruned ${details.removedUnits}${details.warnings.length === 0 ? "" : ` · warnings ${details.warnings.length}`}`,
|
|
132
138
|
);
|
|
133
139
|
if (expanded) {
|
|
134
140
|
const lines = [
|
|
135
141
|
bounded(firstText(result), 1_500),
|
|
136
142
|
...details.retainedLabels.map((item) => `${item.ref} ${item.label}: ${item.preview}`),
|
|
143
|
+
...details.readFiles.map((path) => `read: ${path}`),
|
|
137
144
|
...details.outlinedFiles.map((file) => `outlined: ${file.path}`),
|
|
138
145
|
...details.deferredFiles.map((file) => `deferred: ${file.path} — ${file.reason}; when ${file.relevantWhen}`),
|
|
139
146
|
...details.warnings.map((warning) => `warning: ${warning}`),
|
|
@@ -14,12 +14,13 @@ export interface RetainedLabel {
|
|
|
14
14
|
preview: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
export interface
|
|
18
|
-
v:
|
|
17
|
+
export interface WorkingMemoryCheckpointDetailsV2 {
|
|
18
|
+
v: 2;
|
|
19
19
|
anchorToolCallId: string;
|
|
20
20
|
retainedRefs: string[];
|
|
21
21
|
retainedLabels: RetainedLabel[];
|
|
22
22
|
prunedRowIds: string[];
|
|
23
|
+
readFiles: string[];
|
|
23
24
|
outlinedFiles: Array<{ path: string; rowId: string }>;
|
|
24
25
|
deferredFiles: DeferredFile[];
|
|
25
26
|
removedUnits: number;
|
|
@@ -32,14 +33,15 @@ export interface ActiveWorkingMemoryState {
|
|
|
32
33
|
prunedRowIds: ReadonlySet<string>;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
export function parseWorkingMemoryDetails(value: unknown):
|
|
36
|
-
if (!isRecord(value) || value.v !==
|
|
36
|
+
export function parseWorkingMemoryDetails(value: unknown): WorkingMemoryCheckpointDetailsV2 | undefined {
|
|
37
|
+
if (!isRecord(value) || value.v !== 2 || !nonempty(value.anchorToolCallId)) return undefined;
|
|
37
38
|
const keys = [
|
|
38
39
|
"v",
|
|
39
40
|
"anchorToolCallId",
|
|
40
41
|
"retainedRefs",
|
|
41
42
|
"retainedLabels",
|
|
42
43
|
"prunedRowIds",
|
|
44
|
+
"readFiles",
|
|
43
45
|
"outlinedFiles",
|
|
44
46
|
"deferredFiles",
|
|
45
47
|
"removedUnits",
|
|
@@ -48,6 +50,7 @@ export function parseWorkingMemoryDetails(value: unknown): WorkingMemoryCheckpoi
|
|
|
48
50
|
if (Object.keys(value).length !== keys.length || !keys.every((key) => Object.hasOwn(value, key))) return undefined;
|
|
49
51
|
const retainedRefs = uniqueStrings(value.retainedRefs);
|
|
50
52
|
const prunedRowIds = uniqueStrings(value.prunedRowIds);
|
|
53
|
+
const readFiles = uniqueStrings(value.readFiles);
|
|
51
54
|
const warnings = strings(value.warnings);
|
|
52
55
|
const retainedLabels = parseRetainedLabels(value.retainedLabels);
|
|
53
56
|
const outlinedFiles = parseOutlinedFiles(value.outlinedFiles);
|
|
@@ -55,6 +58,7 @@ export function parseWorkingMemoryDetails(value: unknown): WorkingMemoryCheckpoi
|
|
|
55
58
|
if (
|
|
56
59
|
!retainedRefs ||
|
|
57
60
|
!prunedRowIds ||
|
|
61
|
+
!readFiles ||
|
|
58
62
|
!warnings ||
|
|
59
63
|
!retainedLabels ||
|
|
60
64
|
retainedLabels.length !== retainedRefs.length ||
|
|
@@ -67,11 +71,12 @@ export function parseWorkingMemoryDetails(value: unknown): WorkingMemoryCheckpoi
|
|
|
67
71
|
return undefined;
|
|
68
72
|
}
|
|
69
73
|
return {
|
|
70
|
-
v:
|
|
74
|
+
v: 2,
|
|
71
75
|
anchorToolCallId: value.anchorToolCallId,
|
|
72
76
|
retainedRefs,
|
|
73
77
|
retainedLabels,
|
|
74
78
|
prunedRowIds,
|
|
79
|
+
readFiles,
|
|
75
80
|
outlinedFiles,
|
|
76
81
|
deferredFiles,
|
|
77
82
|
removedUnits: value.removedUnits as number,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shanepadgett/tau-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.32.0",
|
|
4
4
|
"description": "Tau is a custom agentic harness built with pi extensions",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
],
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@ast-grep/wasm": "0.45.0",
|
|
38
|
-
"@shanepadgett/tau-tui": "0.
|
|
38
|
+
"@shanepadgett/tau-tui": "0.32.0",
|
|
39
39
|
"@vscode/tree-sitter-wasm": "0.3.1",
|
|
40
40
|
"image-size": "2.0.2",
|
|
41
41
|
"smol-toml": "1.7.0",
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ThinkingLevel } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { resolveCandidates } from "./model-fallback/index.ts";
|
|
4
|
+
import type { ModelCandidate } from "./model-fallback/types.ts";
|
|
5
|
+
|
|
6
|
+
export type ModelEffort = "low" | "medium" | "high";
|
|
7
|
+
|
|
8
|
+
interface ModelPreference {
|
|
9
|
+
provider: string;
|
|
10
|
+
model: string;
|
|
11
|
+
reasoning: ThinkingLevel;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const MODEL_PREFERENCES: Record<ModelEffort, readonly ModelPreference[]> = {
|
|
15
|
+
low: [
|
|
16
|
+
{ provider: "openai-codex", model: "gpt-5.6-luna", reasoning: "high" },
|
|
17
|
+
{ provider: "xai", model: "grok-4.5", reasoning: "medium" },
|
|
18
|
+
{ provider: "anthropic", model: "claude-haiku-4-5", reasoning: "high" },
|
|
19
|
+
],
|
|
20
|
+
medium: [
|
|
21
|
+
{ provider: "openai-codex", model: "gpt-5.6-terra", reasoning: "high" },
|
|
22
|
+
{ provider: "xai", model: "grok-4.5", reasoning: "high" },
|
|
23
|
+
{ provider: "anthropic", model: "claude-sonnet-5", reasoning: "high" },
|
|
24
|
+
],
|
|
25
|
+
high: [
|
|
26
|
+
{ provider: "openai-codex", model: "gpt-5.6-sol", reasoning: "high" },
|
|
27
|
+
{ provider: "xai", model: "grok-4.5", reasoning: "high" },
|
|
28
|
+
{ provider: "anthropic", model: "claude-opus-5", reasoning: "high" },
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export function resolveEffortCandidates(
|
|
33
|
+
ctx: Pick<ExtensionContext, "modelRegistry" | "model" | "cwd" | "isProjectTrusted">,
|
|
34
|
+
effort: ModelEffort,
|
|
35
|
+
includeParentModel: boolean,
|
|
36
|
+
): Promise<ModelCandidate[]> {
|
|
37
|
+
return resolveCandidates(ctx, MODEL_PREFERENCES[effort], includeParentModel);
|
|
38
|
+
}
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
# Publish
|
|
2
|
-
|
|
3
|
-
Create a tagged Tau release and monitor its GitHub Actions npm publish run.
|
|
4
|
-
|
|
5
|
-
## Usage
|
|
6
|
-
|
|
7
|
-
```text
|
|
8
|
-
/publish
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
`/publish` requires a clean Git working tree, authenticated GitHub CLI, and public approved npm package sources. It recommends a semantic version bump from commits since the previous release tag, asks for confirmation, then pushes a release tag. GitHub Actions publishes the TUI package before the agent package through npm trusted publishing.
|
|
12
|
-
|
|
13
|
-
If publishing fails, the agent runs read-only diagnostics and recommends a solution. It does not apply the fix or retry the release.
|
|
@@ -1,301 +0,0 @@
|
|
|
1
|
-
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { createGitRunner, type GitRunner, loadRepoStatus } from "../../shared/git.ts";
|
|
5
|
-
import { errorText } from "../../shared/text.ts";
|
|
6
|
-
import { PublishActivityPanel, type PublishProgress } from "./publish-ui.ts";
|
|
7
|
-
|
|
8
|
-
const PACKAGE_PATHS = ["packages/tui/package.json", "packages/agent/package.json"] as const;
|
|
9
|
-
const LOCKFILE_PATH = "package-lock.json";
|
|
10
|
-
const WORKFLOW_NAME = "publish.yml";
|
|
11
|
-
const WORKFLOW_WAIT_MS = 10 * 60_000;
|
|
12
|
-
const POLL_INTERVAL_MS = 5_000;
|
|
13
|
-
const DEFAULT_COMMAND_TIMEOUT_MS = 120_000;
|
|
14
|
-
|
|
15
|
-
type Bump = "patch" | "minor" | "major";
|
|
16
|
-
|
|
17
|
-
interface PackageManifest {
|
|
18
|
-
name: string;
|
|
19
|
-
version: string;
|
|
20
|
-
dependencies?: Record<string, string>;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
interface WorkflowRun {
|
|
24
|
-
databaseId: number;
|
|
25
|
-
status: string;
|
|
26
|
-
conclusion: string | null;
|
|
27
|
-
url: string;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export default function publishExtension(pi: ExtensionAPI): void {
|
|
31
|
-
pi.registerCommand("publish", {
|
|
32
|
-
description: "Create a tagged release and monitor trusted npm publishing",
|
|
33
|
-
handler: async (args, ctx) => {
|
|
34
|
-
await ctx.waitForIdle();
|
|
35
|
-
if (args.trim()) {
|
|
36
|
-
ctx.ui.notify("Usage: /publish", "error");
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
if (!ctx.hasUI) {
|
|
40
|
-
ctx.ui.notify("Publishing requires interactive confirmation.", "error");
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
try {
|
|
44
|
-
await publish(pi, ctx);
|
|
45
|
-
} catch (error) {
|
|
46
|
-
pi.sendMessage(
|
|
47
|
-
{
|
|
48
|
-
customType: "tau.publish-failure",
|
|
49
|
-
content: [
|
|
50
|
-
"The /publish command failed with this error:",
|
|
51
|
-
errorText(error),
|
|
52
|
-
"Investigate the failure using read-only diagnostics. Determine the likely cause and recommend a solution to the user. Do not change files, git state, package state, or remote state, and do not retry publishing.",
|
|
53
|
-
].join("\n\n"),
|
|
54
|
-
display: false,
|
|
55
|
-
},
|
|
56
|
-
{ triggerTurn: true },
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function publish(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
64
|
-
const git = createGitRunner(pi, ctx);
|
|
65
|
-
const repo = await loadRepoStatus(git);
|
|
66
|
-
if (!repo) throw new Error("No git repository found.");
|
|
67
|
-
if (repo.fileCount > 0)
|
|
68
|
-
throw new Error("Working tree is not clean. Commit or stash every change before publishing.");
|
|
69
|
-
await run(pi, ctx, "gh", ["auth", "status"], repo.root, DEFAULT_COMMAND_TIMEOUT_MS);
|
|
70
|
-
await run(pi, ctx, "npm", ["run", "check:package-sources"], repo.root, DEFAULT_COMMAND_TIMEOUT_MS);
|
|
71
|
-
|
|
72
|
-
const manifests = await loadManifests(repo.root);
|
|
73
|
-
const currentVersion = sharedVersion(manifests);
|
|
74
|
-
const tag = await latestReleaseTag(git, repo.root);
|
|
75
|
-
const bump = await recommendBump(git, repo.root, tag, currentVersion);
|
|
76
|
-
const version = tag ? incrementVersion(currentVersion, bump) : currentVersion;
|
|
77
|
-
const selectedBump = await ctx.ui.select(`Release ${version}`, [
|
|
78
|
-
`Use recommended ${bump} bump`,
|
|
79
|
-
"Choose a different bump",
|
|
80
|
-
"Cancel",
|
|
81
|
-
]);
|
|
82
|
-
if (!selectedBump || selectedBump === "Cancel") return;
|
|
83
|
-
|
|
84
|
-
const finalBump =
|
|
85
|
-
selectedBump === "Choose a different bump"
|
|
86
|
-
? await ctx.ui.select("Release type", ["patch", "minor", "major"])
|
|
87
|
-
: bump;
|
|
88
|
-
if (!finalBump) return;
|
|
89
|
-
const finalVersion = tag ? incrementVersion(currentVersion, finalBump as Bump) : currentVersion;
|
|
90
|
-
const releaseTag = `v${finalVersion}`;
|
|
91
|
-
if (await tagExists(git, repo.root, releaseTag)) throw new Error(`Tag ${releaseTag} already exists.`);
|
|
92
|
-
|
|
93
|
-
const confirmed = await ctx.ui.confirm(
|
|
94
|
-
`Publish ${releaseTag}?`,
|
|
95
|
-
`This will push ${releaseTag}. GitHub Actions will publish @shanepadgett/tau-tui and @shanepadgett/tau-agent to npm.`,
|
|
96
|
-
);
|
|
97
|
-
if (!confirmed) return;
|
|
98
|
-
|
|
99
|
-
if (ctx.mode !== "tui") {
|
|
100
|
-
await completePublish(pi, ctx, git, repo.root, manifests, tag, finalVersion, releaseTag);
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
const failure = await ctx.ui.custom<Error | undefined>((tui, theme, _keybindings, done) => {
|
|
104
|
-
const progress = new PublishActivityPanel(tui, theme, releaseTag);
|
|
105
|
-
void completePublish(pi, ctx, git, repo.root, manifests, tag, finalVersion, releaseTag, progress)
|
|
106
|
-
.then(() => done(undefined))
|
|
107
|
-
.catch((error) => done(error instanceof Error ? error : new Error(errorText(error))));
|
|
108
|
-
return progress;
|
|
109
|
-
});
|
|
110
|
-
if (failure) throw failure;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
async function completePublish(
|
|
114
|
-
pi: ExtensionAPI,
|
|
115
|
-
ctx: ExtensionCommandContext,
|
|
116
|
-
git: GitRunner,
|
|
117
|
-
root: string,
|
|
118
|
-
manifests: readonly PackageManifest[],
|
|
119
|
-
tag: string | undefined,
|
|
120
|
-
version: string,
|
|
121
|
-
releaseTag: string,
|
|
122
|
-
progress: PublishProgress | undefined = undefined,
|
|
123
|
-
): Promise<void> {
|
|
124
|
-
if (tag) {
|
|
125
|
-
progress?.update("write package versions");
|
|
126
|
-
await writeReleaseVersion(root, manifests, version);
|
|
127
|
-
}
|
|
128
|
-
progress?.update("npm pack --dry-run @shanepadgett/tau-tui");
|
|
129
|
-
await run(
|
|
130
|
-
pi,
|
|
131
|
-
ctx,
|
|
132
|
-
"npm",
|
|
133
|
-
["pack", "--dry-run", "--workspace", "@shanepadgett/tau-tui"],
|
|
134
|
-
root,
|
|
135
|
-
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
136
|
-
);
|
|
137
|
-
progress?.update("npm pack --dry-run @shanepadgett/tau-agent");
|
|
138
|
-
await run(
|
|
139
|
-
pi,
|
|
140
|
-
ctx,
|
|
141
|
-
"npm",
|
|
142
|
-
["pack", "--dry-run", "--workspace", "@shanepadgett/tau-agent"],
|
|
143
|
-
root,
|
|
144
|
-
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
145
|
-
);
|
|
146
|
-
if (tag) {
|
|
147
|
-
progress?.update("git add release files");
|
|
148
|
-
await git.run(["add", ...PACKAGE_PATHS, LOCKFILE_PATH], { cwd: root });
|
|
149
|
-
progress?.update(`git commit chore(release): ${releaseTag}`);
|
|
150
|
-
await git.run(["commit", "-m", `chore(release): ${releaseTag}`], { cwd: root });
|
|
151
|
-
}
|
|
152
|
-
progress?.update(`git tag ${releaseTag}`);
|
|
153
|
-
await git.run(["tag", releaseTag], { cwd: root });
|
|
154
|
-
progress?.update("git push origin HEAD");
|
|
155
|
-
await git.run(["push", "origin", "HEAD"], { cwd: root, timeout: 120_000 });
|
|
156
|
-
progress?.update(`git push origin ${releaseTag}`);
|
|
157
|
-
await git.run(["push", "origin", releaseTag], { cwd: root, timeout: 120_000 });
|
|
158
|
-
progress?.update("Waiting for GitHub Actions");
|
|
159
|
-
await monitorWorkflow(pi, ctx, root, await git.run(["rev-parse", "HEAD"], { cwd: root }), releaseTag, progress);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
async function loadManifests(root: string): Promise<PackageManifest[]> {
|
|
163
|
-
return Promise.all(
|
|
164
|
-
PACKAGE_PATHS.map(async (path) => JSON.parse(await readFile(join(root, path), "utf8")) as PackageManifest),
|
|
165
|
-
);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function sharedVersion(manifests: readonly PackageManifest[]): string {
|
|
169
|
-
const versions = new Set(manifests.map((manifest) => manifest.version));
|
|
170
|
-
if (versions.size !== 1) throw new Error("Publishable packages must use the same version.");
|
|
171
|
-
const version = manifests[0]?.version;
|
|
172
|
-
if (!version || !/^\d+\.\d+\.\d+$/.test(version)) throw new Error("Package version must be stable semver.");
|
|
173
|
-
return version;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
async function latestReleaseTag(git: GitRunner, root: string): Promise<string | undefined> {
|
|
177
|
-
const tags = await git.run(["tag", "--list", "v*", "--sort=-version:refname"], { cwd: root });
|
|
178
|
-
return tags.split("\n").find((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async function recommendBump(
|
|
182
|
-
git: GitRunner,
|
|
183
|
-
root: string,
|
|
184
|
-
tag: string | undefined,
|
|
185
|
-
currentVersion: string,
|
|
186
|
-
): Promise<Bump> {
|
|
187
|
-
const range = tag ? `${tag}..HEAD` : "HEAD";
|
|
188
|
-
const commits = await git.run(["log", "--format=%B%x00", range], { cwd: root });
|
|
189
|
-
if (/BREAKING CHANGE:|^[^\n]*!:/m.test(commits)) return currentVersion.startsWith("0.") ? "minor" : "major";
|
|
190
|
-
if (/^feat(?:\([^\n]+\))?:/m.test(commits)) return "minor";
|
|
191
|
-
return "patch";
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function incrementVersion(version: string, bump: Bump): string {
|
|
195
|
-
const match = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
|
196
|
-
if (!match) throw new Error(`Invalid version: ${version}`);
|
|
197
|
-
const major = Number(match[1]);
|
|
198
|
-
const minor = Number(match[2]);
|
|
199
|
-
const patch = Number(match[3]);
|
|
200
|
-
if (bump === "major") return `${major + 1}.0.0`;
|
|
201
|
-
if (bump === "minor") return `${major}.${minor + 1}.0`;
|
|
202
|
-
return `${major}.${minor}.${patch + 1}`;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
async function tagExists(git: GitRunner, root: string, tag: string): Promise<boolean> {
|
|
206
|
-
return Boolean(await git.run(["tag", "--list", tag], { cwd: root }));
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
async function writeReleaseVersion(
|
|
210
|
-
root: string,
|
|
211
|
-
manifests: readonly PackageManifest[],
|
|
212
|
-
version: string,
|
|
213
|
-
): Promise<void> {
|
|
214
|
-
for (const [index, path] of PACKAGE_PATHS.entries()) {
|
|
215
|
-
const manifest = manifests[index];
|
|
216
|
-
if (!manifest) throw new Error(`Missing package manifest: ${path}`);
|
|
217
|
-
manifest.version = version;
|
|
218
|
-
if (manifest.name === "@shanepadgett/tau-agent" && manifest.dependencies)
|
|
219
|
-
manifest.dependencies["@shanepadgett/tau-tui"] = version;
|
|
220
|
-
await writeFile(join(root, path), `${JSON.stringify(manifest, null, "\t")}\n`);
|
|
221
|
-
}
|
|
222
|
-
const lockfile = JSON.parse(await readFile(join(root, LOCKFILE_PATH), "utf8")) as {
|
|
223
|
-
packages: Record<string, PackageManifest>;
|
|
224
|
-
};
|
|
225
|
-
for (const path of PACKAGE_PATHS) {
|
|
226
|
-
const lockfilePath = path.slice(0, -"/package.json".length);
|
|
227
|
-
const manifest = lockfile.packages[lockfilePath];
|
|
228
|
-
if (!manifest) throw new Error(`Missing lockfile package: ${lockfilePath}`);
|
|
229
|
-
manifest.version = version;
|
|
230
|
-
if (manifest.name === "@shanepadgett/tau-agent" && manifest.dependencies)
|
|
231
|
-
manifest.dependencies["@shanepadgett/tau-tui"] = version;
|
|
232
|
-
}
|
|
233
|
-
await writeFile(join(root, LOCKFILE_PATH), `${JSON.stringify(lockfile, null, "\t")}\n`);
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async function run(
|
|
237
|
-
pi: ExtensionAPI,
|
|
238
|
-
ctx: ExtensionCommandContext,
|
|
239
|
-
command: string,
|
|
240
|
-
args: string[],
|
|
241
|
-
cwd: string,
|
|
242
|
-
timeout: number,
|
|
243
|
-
): Promise<string> {
|
|
244
|
-
const result = await pi.exec(command, args, { cwd, signal: ctx.signal, timeout });
|
|
245
|
-
if (result.code === 0) return result.stdout.trim();
|
|
246
|
-
throw new Error([result.stderr, result.stdout].filter(Boolean).join("\n").trim() || `${command} failed`);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
async function monitorWorkflow(
|
|
250
|
-
pi: ExtensionAPI,
|
|
251
|
-
ctx: ExtensionCommandContext,
|
|
252
|
-
root: string,
|
|
253
|
-
sha: string,
|
|
254
|
-
tag: string,
|
|
255
|
-
progress: PublishProgress | undefined = undefined,
|
|
256
|
-
): Promise<void> {
|
|
257
|
-
const deadline = Date.now() + WORKFLOW_WAIT_MS;
|
|
258
|
-
while (Date.now() < deadline) {
|
|
259
|
-
const output = await run(
|
|
260
|
-
pi,
|
|
261
|
-
ctx,
|
|
262
|
-
"gh",
|
|
263
|
-
[
|
|
264
|
-
"run",
|
|
265
|
-
"list",
|
|
266
|
-
"--workflow",
|
|
267
|
-
WORKFLOW_NAME,
|
|
268
|
-
"--commit",
|
|
269
|
-
sha,
|
|
270
|
-
"--limit",
|
|
271
|
-
"1",
|
|
272
|
-
"--json",
|
|
273
|
-
"databaseId,status,conclusion,url",
|
|
274
|
-
],
|
|
275
|
-
root,
|
|
276
|
-
DEFAULT_COMMAND_TIMEOUT_MS,
|
|
277
|
-
);
|
|
278
|
-
const workflowRun = (JSON.parse(output) as WorkflowRun[])[0];
|
|
279
|
-
if (workflowRun) {
|
|
280
|
-
progress?.update(`GitHub Actions: ${workflowRun.status}`, workflowRun.url);
|
|
281
|
-
if (workflowRun.status === "completed") {
|
|
282
|
-
if (workflowRun.conclusion === "success") {
|
|
283
|
-
const version = tag.slice(1);
|
|
284
|
-
ctx.ui.notify(
|
|
285
|
-
[
|
|
286
|
-
`Published ${tag}`,
|
|
287
|
-
`GitHub Actions: ${workflowRun.url}`,
|
|
288
|
-
`npm TUI: https://www.npmjs.com/package/@shanepadgett/tau-tui/v/${version}`,
|
|
289
|
-
`npm Agent: https://www.npmjs.com/package/@shanepadgett/tau-agent/v/${version}`,
|
|
290
|
-
].join("\n"),
|
|
291
|
-
"info",
|
|
292
|
-
);
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
throw new Error(`GitHub Actions ${workflowRun.conclusion ?? "failed"}: ${workflowRun.url}`);
|
|
296
|
-
}
|
|
297
|
-
}
|
|
298
|
-
await new Promise<void>((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
|
|
299
|
-
}
|
|
300
|
-
throw new Error(`Timed out waiting for GitHub Actions for ${tag}. Check the Actions tab.`);
|
|
301
|
-
}
|
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { type Component, Text, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
-
import { ToolPanel } from "@shanepadgett/tau-tui";
|
|
4
|
-
|
|
5
|
-
const RELEASE_STEPS = [
|
|
6
|
-
"write package versions",
|
|
7
|
-
"npm pack --dry-run @shanepadgett/tau-tui",
|
|
8
|
-
"npm pack --dry-run @shanepadgett/tau-agent",
|
|
9
|
-
"git add release files",
|
|
10
|
-
"git commit",
|
|
11
|
-
"git tag",
|
|
12
|
-
"git push origin HEAD",
|
|
13
|
-
"git push origin v",
|
|
14
|
-
"Waiting for GitHub Actions",
|
|
15
|
-
"GitHub Actions: publishing",
|
|
16
|
-
] as const;
|
|
17
|
-
|
|
18
|
-
export interface PublishProgress {
|
|
19
|
-
update(status: string, detail?: string): void;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export class PublishActivityPanel implements Component, PublishProgress {
|
|
23
|
-
private readonly tui: TUI;
|
|
24
|
-
private readonly theme: Theme;
|
|
25
|
-
private readonly body = new Text("", 0, 0);
|
|
26
|
-
private readonly panel: ToolPanel;
|
|
27
|
-
private status = "Preparing release";
|
|
28
|
-
private detail: string | undefined;
|
|
29
|
-
private readonly completed: string[] = ["Release confirmed"];
|
|
30
|
-
|
|
31
|
-
constructor(tui: TUI, theme: Theme, tag: string) {
|
|
32
|
-
this.tui = tui;
|
|
33
|
-
this.theme = theme;
|
|
34
|
-
this.panel = new ToolPanel(theme, {
|
|
35
|
-
title: `Publishing ${tag}`,
|
|
36
|
-
secondary: "Editor is unavailable until this release finishes.",
|
|
37
|
-
header: [theme.fg("muted", "GitHub Actions publishes through npm trusted publishing.")],
|
|
38
|
-
body: this.body,
|
|
39
|
-
footer: { kind: "hints", hints: [] },
|
|
40
|
-
});
|
|
41
|
-
this.renderBody();
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
update(status: string, detail: string | undefined = undefined): void {
|
|
45
|
-
if (this.status !== "Preparing release" && this.status !== status) this.completed.push(this.status);
|
|
46
|
-
this.status = status;
|
|
47
|
-
this.detail = detail;
|
|
48
|
-
this.renderBody();
|
|
49
|
-
this.tui.requestRender();
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
render(width: number): string[] {
|
|
53
|
-
return this.panel.render(width);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
invalidate(): void {
|
|
57
|
-
this.panel.invalidate();
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
private renderBody(): void {
|
|
61
|
-
const upcoming = RELEASE_STEPS.filter(
|
|
62
|
-
(step) => !this.completed.some((completed) => completed.startsWith(step)) && step !== this.status,
|
|
63
|
-
);
|
|
64
|
-
this.body.setText(
|
|
65
|
-
[
|
|
66
|
-
...this.completed.map((step) => this.theme.fg("success", `✓ ${step}`)),
|
|
67
|
-
this.theme.fg("accent", `… ${this.status}`),
|
|
68
|
-
...(this.detail ? [this.theme.fg("muted", this.detail)] : []),
|
|
69
|
-
...upcoming.map((step) => this.theme.fg("muted", `· ${step}`)),
|
|
70
|
-
].join("\n"),
|
|
71
|
-
);
|
|
72
|
-
}
|
|
73
|
-
}
|