@shanepadgett/tau-agent 0.28.1 → 0.30.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/context.md +14 -9
- package/docs/subagents.md +7 -5
- package/extensions/cache-diagnostics/index.ts +4 -5
- package/extensions/context/README.md +8 -5
- package/extensions/context/definitions.ts +28 -17
- package/extensions/context/evidence.ts +4 -3
- package/extensions/context/index.ts +57 -35
- package/extensions/context/panel.ts +10 -9
- package/extensions/context/projection.ts +141 -0
- package/extensions/context/state.ts +30 -0
- package/extensions/explore/README.md +1 -1
- package/extensions/explore/ast/read/hook.ts +5 -35
- package/extensions/explore/ast/read/policy.ts +0 -32
- package/extensions/explore/index.ts +2 -0
- package/extensions/explore/outline-injection.ts +151 -0
- package/extensions/explore/settings.ts +0 -8
- package/extensions/ideas/browser.ts +27 -16
- package/extensions/review/README.md +11 -0
- package/extensions/review/index.ts +135 -0
- package/extensions/review/model.ts +144 -0
- package/extensions/review/panel.ts +128 -0
- package/extensions/review/session.ts +106 -0
- package/extensions/runtime-context/README.md +1 -1
- package/extensions/runtime-context/index.ts +3 -66
- package/extensions/script-runner/README.md +7 -0
- package/extensions/script-runner/index.ts +275 -0
- package/extensions/stash/browser.ts +30 -18
- package/extensions/subagent/README.md +17 -4
- package/extensions/subagent/agents/context-sync.md +2 -2
- package/extensions/subagent/agents/scout.md +49 -52
- package/extensions/subagent/agents.ts +0 -1
- package/extensions/subagent/cmux-dashboard.ts +7 -3
- package/extensions/subagent/index.ts +105 -4
- package/extensions/subagent/panel.ts +124 -0
- package/extensions/subagent/render.ts +2 -1
- package/extensions/subagent/run.ts +9 -9
- package/extensions/subagent/runtime.ts +5 -5
- package/extensions/subagent/session-resource.ts +19 -127
- package/extensions/subagent/settings.ts +18 -0
- package/extensions/tau-help/help.md +12 -8
- package/extensions/working-memory/README.md +9 -0
- package/extensions/working-memory/checkpoint.ts +175 -0
- package/extensions/working-memory/index.ts +338 -0
- package/extensions/working-memory/memory.ts +266 -0
- package/extensions/working-memory/render.ts +178 -0
- package/extensions/working-memory/settings.ts +38 -0
- package/extensions/working-memory/state.ts +152 -0
- package/package.json +2 -2
- package/schemas/tau.schema.json +35 -55
- package/shared/context-messages.ts +19 -0
- package/shared/events.ts +5 -0
- package/shared/full-file-knowledge.ts +0 -1
- package/shared/injected-context.ts +2 -2
- package/shared/isolated-session.ts +151 -0
- package/shared/outline-injection.ts +56 -0
- package/extensions/context-pruning/README.md +0 -39
- package/extensions/context-pruning/index.ts +0 -382
- package/extensions/context-pruning/projection.ts +0 -60
- package/extensions/context-pruning/prune.ts +0 -199
- package/extensions/context-pruning/render.ts +0 -251
- package/extensions/context-pruning/settings.ts +0 -39
- package/extensions/subagent/agents/review.md +0 -68
- package/extensions/turn-budget/README.md +0 -14
- package/extensions/turn-budget/index.ts +0 -116
- package/extensions/turn-budget/settings.ts +0 -35
- package/shared/context-pruning-state.ts +0 -152
|
@@ -6,7 +6,7 @@ Pi keeps ordinary filesystem tools (`ls`, `find`, `grep`, `read`). Explore adds
|
|
|
6
6
|
|
|
7
7
|
## Large `read` / autoread
|
|
8
8
|
|
|
9
|
-
When `explore.read.enabled` is on (default), a full Pi `read` or autoread of a registered source file (including Markdown) above `explore.read.structureThresholdLines` (default 200) returns an **outline** — declarations or headings — not the full body. Use ranged `read` (`offset`/`limit
|
|
9
|
+
When `explore.read.enabled` is on (default), a full Pi `read` or autoread of a registered source file (including Markdown) above `explore.read.structureThresholdLines` (default 200) returns an **outline** — declarations or headings — not the full body. Use ranged `read` (`offset`/`limit`) or `show` for bodies and sections. Small files and unregistered paths stay ordinary Pi full text. Set `explore.read.enabled` to `false` to turn the overlay off.
|
|
10
10
|
|
|
11
11
|
## Tools
|
|
12
12
|
|
|
@@ -1,21 +1,15 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ReadToolDetails } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { loadTauExtensionSettings } from "../../../../shared/settings/load.ts";
|
|
3
3
|
import exploreSettings from "../../settings.ts";
|
|
4
4
|
import { resolveExplorePath } from "../../traverse.ts";
|
|
5
5
|
import type { ExploreEngine } from "../engine.ts";
|
|
6
6
|
import { formatOutlineFile } from "../format/outline.ts";
|
|
7
7
|
import { outlinePath } from "../queries/outline.ts";
|
|
8
|
-
import {
|
|
9
|
-
countLines,
|
|
10
|
-
formatLargeReadOutline,
|
|
11
|
-
rangedReadOverLimitMessage,
|
|
12
|
-
readCallKind,
|
|
13
|
-
shouldOutlineFullRead,
|
|
14
|
-
type ExploreReadSettings,
|
|
15
|
-
} from "./policy.ts";
|
|
8
|
+
import { formatLargeReadOutline, readCallKind, shouldOutlineFullRead, type ExploreReadSettings } from "./policy.ts";
|
|
16
9
|
|
|
17
10
|
type ReadOverlayResult = {
|
|
18
11
|
content: Array<{ type: "text"; text: string }>;
|
|
12
|
+
details: ReadToolDetails;
|
|
19
13
|
isError: boolean;
|
|
20
14
|
};
|
|
21
15
|
|
|
@@ -47,9 +41,7 @@ export function registerReadOutlineHook(pi: ExtensionAPI, engineFor: (cwd: strin
|
|
|
47
41
|
if (engine.registry.adapterForPath(absolutePath) === undefined) return;
|
|
48
42
|
|
|
49
43
|
const kind = readCallKind(event.input);
|
|
50
|
-
if (kind === "ranged")
|
|
51
|
-
return enforceRangedLimit(event.input, event.content, readSettings.maxRangeLines);
|
|
52
|
-
}
|
|
44
|
+
if (kind === "ranged") return;
|
|
53
45
|
|
|
54
46
|
return substituteLargeFullRead({
|
|
55
47
|
engine,
|
|
@@ -89,6 +81,7 @@ async function substituteLargeFullRead(options: {
|
|
|
89
81
|
result.file.rows.length === 0 ? "No declarations" : formatOutlineFile(result.file, options.engine.cwd, false);
|
|
90
82
|
return {
|
|
91
83
|
content: [{ type: "text", text: formatLargeReadOutline(outlineText) }],
|
|
84
|
+
details: {},
|
|
92
85
|
isError: false,
|
|
93
86
|
};
|
|
94
87
|
} catch {
|
|
@@ -96,30 +89,7 @@ async function substituteLargeFullRead(options: {
|
|
|
96
89
|
}
|
|
97
90
|
}
|
|
98
91
|
|
|
99
|
-
function enforceRangedLimit(
|
|
100
|
-
input: Record<string, unknown>,
|
|
101
|
-
content: ReadonlyArray<{ type: string; text?: string }>,
|
|
102
|
-
maxRangeLines: number,
|
|
103
|
-
): ReadOverlayResult | undefined {
|
|
104
|
-
const limit = typeof input.limit === "number" && Number.isFinite(input.limit) ? input.limit : undefined;
|
|
105
|
-
const returnedLines = countLines(textFromContent(content));
|
|
106
|
-
const message = rangedReadOverLimitMessage({ limit, returnedLines, maxRangeLines });
|
|
107
|
-
if (message === undefined) return undefined;
|
|
108
|
-
return {
|
|
109
|
-
content: [{ type: "text", text: message }],
|
|
110
|
-
isError: true,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
92
|
function readPathInput(input: Record<string, unknown>): string | undefined {
|
|
115
93
|
const path = input.path;
|
|
116
94
|
return typeof path === "string" && path.length > 0 ? path : undefined;
|
|
117
95
|
}
|
|
118
|
-
|
|
119
|
-
function textFromContent(content: ReadonlyArray<{ type: string; text?: string }>): string {
|
|
120
|
-
const parts: string[] = [];
|
|
121
|
-
for (const part of content) {
|
|
122
|
-
if (part.type === "text" && typeof part.text === "string") parts.push(part.text);
|
|
123
|
-
}
|
|
124
|
-
return parts.join("\n");
|
|
125
|
-
}
|
|
@@ -1,28 +1,12 @@
|
|
|
1
1
|
export type ExploreReadSettings = {
|
|
2
2
|
enabled: boolean;
|
|
3
3
|
structureThresholdLines: number;
|
|
4
|
-
maxRangeLines: number;
|
|
5
4
|
};
|
|
6
5
|
|
|
7
6
|
export type ReadCallKind = "full" | "ranged";
|
|
8
7
|
|
|
9
8
|
const LARGE_READ_OUTLINE_INSTRUCTION = "large file: use ranged read or show for bodies";
|
|
10
9
|
|
|
11
|
-
/** Newline-based line count (empty string → 0). */
|
|
12
|
-
export function countLines(text: string): number {
|
|
13
|
-
if (text.length === 0) return 0;
|
|
14
|
-
let count = 1;
|
|
15
|
-
for (let i = 0; i < text.length; i += 1) {
|
|
16
|
-
const code = text.charCodeAt(i);
|
|
17
|
-
if (code === 10) count += 1;
|
|
18
|
-
else if (code === 13) {
|
|
19
|
-
count += 1;
|
|
20
|
-
if (text.charCodeAt(i + 1) === 10) i += 1;
|
|
21
|
-
}
|
|
22
|
-
}
|
|
23
|
-
return count;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
10
|
export function readCallKind(input: { offset?: unknown; limit?: unknown }): ReadCallKind {
|
|
27
11
|
const hasOffset = typeof input.offset === "number" && Number.isFinite(input.offset);
|
|
28
12
|
const hasLimit = typeof input.limit === "number" && Number.isFinite(input.limit);
|
|
@@ -33,22 +17,6 @@ export function shouldOutlineFullRead(lineCount: number, structureThresholdLines
|
|
|
33
17
|
return lineCount > structureThresholdLines;
|
|
34
18
|
}
|
|
35
19
|
|
|
36
|
-
/** Hard-error message when a ranged read exceeds maxRangeLines; undefined when allowed. */
|
|
37
|
-
export function rangedReadOverLimitMessage(options: {
|
|
38
|
-
limit: number | undefined;
|
|
39
|
-
returnedLines: number;
|
|
40
|
-
maxRangeLines: number;
|
|
41
|
-
}): string | undefined {
|
|
42
|
-
const { limit, returnedLines, maxRangeLines } = options;
|
|
43
|
-
if (typeof limit === "number" && limit > maxRangeLines) {
|
|
44
|
-
return `Range limit ${limit} exceeds explore.read.maxRangeLines (${maxRangeLines}). Shrink limit and retry.`;
|
|
45
|
-
}
|
|
46
|
-
if (returnedLines > maxRangeLines) {
|
|
47
|
-
return `Ranged read returned ${returnedLines} lines; max is explore.read.maxRangeLines (${maxRangeLines}). Set a smaller limit and retry.`;
|
|
48
|
-
}
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
20
|
export function formatLargeReadOutline(outlineText: string): string {
|
|
53
21
|
if (outlineText.length === 0) return LARGE_READ_OUTLINE_INSTRUCTION;
|
|
54
22
|
return `${outlineText}\n${LARGE_READ_OUTLINE_INSTRUCTION}`;
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
import { createReverseDepsTool } from "./ast/tools/reverse-deps.ts";
|
|
22
22
|
import { createShowTool } from "./ast/tools/show.ts";
|
|
23
23
|
import { registerExploreGuidance } from "./guidance.ts";
|
|
24
|
+
import { registerExploreOutlineInjection } from "./outline-injection.ts";
|
|
24
25
|
import { registerExploreAutoread } from "./read/autoread.ts";
|
|
25
26
|
|
|
26
27
|
export default function exploreExtension(pi: ExtensionAPI): void {
|
|
@@ -61,6 +62,7 @@ export default function exploreExtension(pi: ExtensionAPI): void {
|
|
|
61
62
|
pi.registerTool(createContextTool(rowState, temporaryOutput, engineFor, graphFor));
|
|
62
63
|
registerReadOutlineHook(pi, engineFor);
|
|
63
64
|
registerExploreAutoread(pi, rowState, engineFor);
|
|
65
|
+
registerExploreOutlineInjection(pi, rowState, temporaryOutput, engineFor);
|
|
64
66
|
registerExploreGuidance(pi, engineFor);
|
|
65
67
|
|
|
66
68
|
pi.on("session_start", async (_event, ctx) => {
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { ExtensionAPI, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Marker } from "@shanepadgett/tau-tui";
|
|
4
|
+
import { BoundedTextResultBuilder } from "../../shared/bounded-text-result.ts";
|
|
5
|
+
import {
|
|
6
|
+
registerOutlineInjectionProvider,
|
|
7
|
+
type OutlineInjectionDetails,
|
|
8
|
+
type PreparedOutlineInjection,
|
|
9
|
+
} from "../../shared/outline-injection.ts";
|
|
10
|
+
import type { TemporaryOutputStore } from "../../shared/temporary-output-store.ts";
|
|
11
|
+
import type { ToolRowStateStore } from "../../shared/tool-row-state.ts";
|
|
12
|
+
import type { ExploreEngine } from "./ast/engine.ts";
|
|
13
|
+
import { formatOutlineEmpty, formatOutlineFile } from "./ast/format/outline.ts";
|
|
14
|
+
import { outlinePath } from "./ast/queries/outline.ts";
|
|
15
|
+
import { formatPathForDisplay, stripLeadingAt } from "./traverse.ts";
|
|
16
|
+
|
|
17
|
+
const OPTIONS = { includePrivate: false, includeDocs: false, names: [] as readonly string[] };
|
|
18
|
+
|
|
19
|
+
export function registerExploreOutlineInjection(
|
|
20
|
+
pi: ExtensionAPI,
|
|
21
|
+
rowState: ToolRowStateStore,
|
|
22
|
+
temporaryOutput: TemporaryOutputStore,
|
|
23
|
+
engineFor: (cwd: string) => ExploreEngine,
|
|
24
|
+
): void {
|
|
25
|
+
registerOutlineInjectionProvider(pi, async (request) => {
|
|
26
|
+
const messages: PreparedOutlineInjection[] = [];
|
|
27
|
+
const warnings: string[] = [];
|
|
28
|
+
const engine = engineFor(request.cwd);
|
|
29
|
+
for (let index = 0; index < request.paths.length; index += 1) {
|
|
30
|
+
const path = stripLeadingAt(request.paths[index] ?? "");
|
|
31
|
+
assertCurrent(request.signal, request.isLifecycleCurrent);
|
|
32
|
+
try {
|
|
33
|
+
const abort = request.signal ?? new AbortController().signal;
|
|
34
|
+
const result = await outlinePath(engine, path, OPTIONS, abort);
|
|
35
|
+
if (result.mode !== "file") throw new Error("Working-memory outlines require a file path");
|
|
36
|
+
const body =
|
|
37
|
+
result.file.rows.length === 0
|
|
38
|
+
? formatOutlineEmpty(OPTIONS.names)
|
|
39
|
+
: formatOutlineFile(result.file, engine.cwd, false);
|
|
40
|
+
const builder = new BoundedTextResultBuilder(temporaryOutput, "completeBlocks");
|
|
41
|
+
let content: string;
|
|
42
|
+
try {
|
|
43
|
+
await builder.appendBlock(
|
|
44
|
+
result.file.path,
|
|
45
|
+
formatPathForDisplay(result.file.path, engine.cwd),
|
|
46
|
+
`${path}\n${body}`,
|
|
47
|
+
);
|
|
48
|
+
assertCurrent(request.signal, request.isLifecycleCurrent);
|
|
49
|
+
content = (await builder.finish()).content;
|
|
50
|
+
} catch (error) {
|
|
51
|
+
await builder.abort();
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
messages.push({
|
|
55
|
+
customType: "tau.explore.outline" as const,
|
|
56
|
+
content,
|
|
57
|
+
display: true as const,
|
|
58
|
+
details: {
|
|
59
|
+
v: 1 as const,
|
|
60
|
+
rowId: `${request.batchId}:${index}`,
|
|
61
|
+
path,
|
|
62
|
+
cwd: request.cwd,
|
|
63
|
+
batchId: request.batchId,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if (request.signal?.aborted || !request.isLifecycleCurrent()) throw error;
|
|
68
|
+
warnings.push(`${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { messages, warnings };
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
pi.registerMessageRenderer<OutlineInjectionDetails>("tau.explore.outline", (message, options, theme) => {
|
|
75
|
+
const details = parseDetails(message.details);
|
|
76
|
+
if (!details) return undefined;
|
|
77
|
+
return new OutlineMessageComponent(
|
|
78
|
+
rowState,
|
|
79
|
+
details.rowId,
|
|
80
|
+
details.path,
|
|
81
|
+
typeof message.content === "string" ? message.content : "",
|
|
82
|
+
options.expanded,
|
|
83
|
+
theme,
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function assertCurrent(signal: AbortSignal | undefined, isLifecycleCurrent: () => boolean): void {
|
|
89
|
+
signal?.throwIfAborted();
|
|
90
|
+
if (!isLifecycleCurrent()) throw new Error("Outline preparation crossed a session lifecycle boundary");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function parseDetails(value: unknown): OutlineInjectionDetails | undefined {
|
|
94
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
95
|
+
const details = value as Record<string, unknown>;
|
|
96
|
+
if (
|
|
97
|
+
details.v !== 1 ||
|
|
98
|
+
typeof details.rowId !== "string" ||
|
|
99
|
+
typeof details.path !== "string" ||
|
|
100
|
+
typeof details.cwd !== "string" ||
|
|
101
|
+
typeof details.batchId !== "string"
|
|
102
|
+
) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
v: 1,
|
|
107
|
+
rowId: details.rowId,
|
|
108
|
+
path: details.path,
|
|
109
|
+
cwd: details.cwd,
|
|
110
|
+
batchId: details.batchId,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
class OutlineMessageComponent {
|
|
115
|
+
private readonly rowState: ToolRowStateStore;
|
|
116
|
+
private readonly rowId: string;
|
|
117
|
+
private readonly path: string;
|
|
118
|
+
private readonly content: string;
|
|
119
|
+
private readonly expanded: boolean;
|
|
120
|
+
private readonly theme: Theme;
|
|
121
|
+
|
|
122
|
+
constructor(
|
|
123
|
+
rowState: ToolRowStateStore,
|
|
124
|
+
rowId: string,
|
|
125
|
+
path: string,
|
|
126
|
+
content: string,
|
|
127
|
+
expanded: boolean,
|
|
128
|
+
theme: Theme,
|
|
129
|
+
) {
|
|
130
|
+
this.rowState = rowState;
|
|
131
|
+
this.rowId = rowId;
|
|
132
|
+
this.path = path;
|
|
133
|
+
this.content = content;
|
|
134
|
+
this.expanded = expanded;
|
|
135
|
+
this.theme = theme;
|
|
136
|
+
this.rowState.watch(this.rowId, () => this.invalidate());
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
render(width: number): string[] {
|
|
140
|
+
const marker = new Marker({
|
|
141
|
+
theme: this.theme,
|
|
142
|
+
state: this.rowState.get(this.rowId) === "pruned" ? "warning" : "complete",
|
|
143
|
+
label: "outline",
|
|
144
|
+
parts: [this.path],
|
|
145
|
+
}).render(width);
|
|
146
|
+
if (!this.expanded) return marker;
|
|
147
|
+
return [...marker, ...new Text(this.theme.fg("dim", this.content), 1, 0).render(width)];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
invalidate(): void {}
|
|
151
|
+
}
|
|
@@ -10,7 +10,6 @@ export default defineTauExtensionSettings({
|
|
|
10
10
|
read: {
|
|
11
11
|
enabled: true as boolean,
|
|
12
12
|
structureThresholdLines: 200 as number,
|
|
13
|
-
maxRangeLines: 200 as number,
|
|
14
13
|
},
|
|
15
14
|
},
|
|
16
15
|
schema: Type.Object(
|
|
@@ -47,13 +46,6 @@ export default defineTauExtensionSettings({
|
|
|
47
46
|
"Registered source at or under this line count may be full-read. Above it, full read model-visible result is outline only.",
|
|
48
47
|
}),
|
|
49
48
|
),
|
|
50
|
-
maxRangeLines: Type.Optional(
|
|
51
|
-
Type.Integer({
|
|
52
|
-
default: 200,
|
|
53
|
-
minimum: 1,
|
|
54
|
-
description: "Maximum lines returned by one ranged read on registered source.",
|
|
55
|
-
}),
|
|
56
|
-
),
|
|
57
49
|
},
|
|
58
50
|
{ additionalProperties: false },
|
|
59
51
|
),
|
|
@@ -6,17 +6,15 @@ import {
|
|
|
6
6
|
type TextRecordSelectPanelConfig,
|
|
7
7
|
type TextRecordSelectResult,
|
|
8
8
|
} from "@shanepadgett/tau-tui";
|
|
9
|
+
import { errorText } from "../../shared/text.ts";
|
|
9
10
|
import { deleteIdea, type Idea, ideasFilePath, loadIdeas, updateIdea } from "./store.ts";
|
|
10
11
|
|
|
11
|
-
const CONFIG: Omit<TextRecordSelectPanelConfig
|
|
12
|
+
const CONFIG: Omit<TextRecordSelectPanelConfig<Idea>, "path" | "destructiveAction"> = {
|
|
12
13
|
title: "Ideas",
|
|
13
14
|
emptyMessage: "No ideas yet. Use /ideas <text> to log one.",
|
|
14
15
|
primaryLabel: "insert",
|
|
15
16
|
expandActiveItem: true,
|
|
16
|
-
actions: [
|
|
17
|
-
{ id: "edit", key: Key.ctrl("e"), hint: rawHint("ctrl+e", "edit") },
|
|
18
|
-
{ id: "delete", key: Key.ctrl("d"), hint: rawHint("ctrl+d", "delete") },
|
|
19
|
-
],
|
|
17
|
+
actions: [{ id: "edit", key: Key.ctrl("e"), hint: rawHint("ctrl+e", "edit") }],
|
|
20
18
|
};
|
|
21
19
|
|
|
22
20
|
export async function browseIdeas(ctx: ExtensionCommandContext): Promise<Idea | undefined> {
|
|
@@ -34,15 +32,6 @@ export async function browseIdeas(ctx: ExtensionCommandContext): Promise<Idea |
|
|
|
34
32
|
if (result.kind === "cancel") return undefined;
|
|
35
33
|
if (result.kind === "primary") return result.item;
|
|
36
34
|
|
|
37
|
-
if (result.actionId === "delete") {
|
|
38
|
-
const ok = await ctx.ui.confirm("Delete idea?", result.item.text);
|
|
39
|
-
if (ok) {
|
|
40
|
-
await deleteIdea(ctx.cwd, result.item.id);
|
|
41
|
-
ctx.ui.notify("Idea deleted.", "info");
|
|
42
|
-
}
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
35
|
// edit: native multiline editor, prefilled with the current text.
|
|
47
36
|
const edited = await ctx.ui.editor("Edit idea", result.item.text);
|
|
48
37
|
if (edited == null) continue;
|
|
@@ -60,7 +49,29 @@ async function show(
|
|
|
60
49
|
ideas: readonly Idea[],
|
|
61
50
|
path: string,
|
|
62
51
|
): Promise<TextRecordSelectResult<Idea>> {
|
|
63
|
-
return ctx.ui.custom<TextRecordSelectResult<Idea>>((
|
|
64
|
-
createTextRecordSelectPanel(
|
|
52
|
+
return ctx.ui.custom<TextRecordSelectResult<Idea>>((tui, theme, _keybindings, done) =>
|
|
53
|
+
createTextRecordSelectPanel(
|
|
54
|
+
tui,
|
|
55
|
+
theme,
|
|
56
|
+
ideas,
|
|
57
|
+
{
|
|
58
|
+
...CONFIG,
|
|
59
|
+
path,
|
|
60
|
+
destructiveAction: {
|
|
61
|
+
id: "delete",
|
|
62
|
+
key: Key.ctrl("d"),
|
|
63
|
+
hint: rawHint("ctrl+d", "delete"),
|
|
64
|
+
confirmLabel: () => "Delete idea?",
|
|
65
|
+
runningLabel: "Deleting idea…",
|
|
66
|
+
onConfirm: async (item) => {
|
|
67
|
+
const next = await deleteIdea(ctx.cwd, item.id);
|
|
68
|
+
ctx.ui.notify("Idea deleted.", "info");
|
|
69
|
+
return next;
|
|
70
|
+
},
|
|
71
|
+
onError: (error) => ctx.ui.notify(`Idea delete failed: ${errorText(error)}`, "error"),
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
done,
|
|
75
|
+
),
|
|
65
76
|
);
|
|
66
77
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Review
|
|
2
|
+
|
|
3
|
+
Run `/review` to inspect current repository's staged, unstaged, and untracked work in a fresh isolated session. Choose one focused pass:
|
|
4
|
+
|
|
5
|
+
- `simplify` looks for code and concepts that can disappear or reuse what already exists.
|
|
6
|
+
- `architecture` performs a nuclear maintainability review and accepts substantial redesign when ownership, reuse, or structure is poor.
|
|
7
|
+
- `correctness` checks runtime bugs and failures after architecture is accepted.
|
|
8
|
+
|
|
9
|
+
Run a mode directly with `/review simplify`, `/review architecture`, or `/review correctness`. Run `/review show` to reopen latest result on current session branch.
|
|
10
|
+
|
|
11
|
+
Results stay outside parent agent context. In result view, press `a` to send review to agent's next turn or `e` to export Markdown under `.pi/tau/reviews/`.
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import type { ExtensionAPI, ExtensionContext, SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { createGitRunner, loadRepoStatus } from "../../shared/git.ts";
|
|
5
|
+
import { createInjectedContext } from "../../shared/injected-context.ts";
|
|
6
|
+
import { errorText } from "../../shared/text.ts";
|
|
7
|
+
import {
|
|
8
|
+
formatReviewMarkdown,
|
|
9
|
+
isReviewRecord,
|
|
10
|
+
isReviewMode,
|
|
11
|
+
REVIEW_ENTRY_TYPE,
|
|
12
|
+
type ReviewMode,
|
|
13
|
+
type ReviewRecord,
|
|
14
|
+
} from "./model.ts";
|
|
15
|
+
import { ReviewProgressPanel, ReviewResultPanel, type ReviewResultAction } from "./panel.ts";
|
|
16
|
+
import { runReview } from "./session.ts";
|
|
17
|
+
|
|
18
|
+
export default function reviewExtension(pi: ExtensionAPI): void {
|
|
19
|
+
pi.registerCommand("review", {
|
|
20
|
+
description: "Run or show an isolated simplify, architecture, or correctness review",
|
|
21
|
+
handler: async (args, ctx) => {
|
|
22
|
+
if (ctx.mode !== "tui" || !ctx.isProjectTrusted()) {
|
|
23
|
+
ctx.ui.notify("/review requires a trusted TUI project", "warning");
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
await ctx.waitForIdle();
|
|
27
|
+
const requested = args.trim().toLowerCase();
|
|
28
|
+
if (requested === "show") {
|
|
29
|
+
const latest = latestReview(ctx.sessionManager.getBranch());
|
|
30
|
+
if (!latest) {
|
|
31
|
+
ctx.ui.notify("No review exists on this session branch", "warning");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
await showReview(pi, ctx, latest);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let mode: ReviewMode | undefined;
|
|
39
|
+
if (requested) {
|
|
40
|
+
if (!isReviewMode(requested)) {
|
|
41
|
+
ctx.ui.notify("Usage: /review [simplify|architecture|correctness|show]", "warning");
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
mode = requested;
|
|
45
|
+
} else {
|
|
46
|
+
const selected = await ctx.ui.select("Review mode", ["Simplify", "Architecture", "Correctness"]);
|
|
47
|
+
if (!selected) return;
|
|
48
|
+
const normalized = selected.toLowerCase();
|
|
49
|
+
if (!isReviewMode(normalized)) return;
|
|
50
|
+
mode = normalized;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const status = await loadRepoStatus(createGitRunner(pi, ctx));
|
|
54
|
+
if (!status) {
|
|
55
|
+
ctx.ui.notify("/review requires a Git repository", "warning");
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (status.fileCount === 0) {
|
|
59
|
+
ctx.ui.notify("Nothing to review: working tree is clean", "info");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const controller = new AbortController();
|
|
64
|
+
const signal = ctx.signal ? AbortSignal.any([controller.signal, ctx.signal]) : controller.signal;
|
|
65
|
+
let failure: string | undefined;
|
|
66
|
+
const output = await ctx.ui.custom<ReviewRecord | undefined>((tui, theme, keys, done) => {
|
|
67
|
+
const panel = new ReviewProgressPanel(tui, theme, mode, keys, () => controller.abort());
|
|
68
|
+
void runReview({
|
|
69
|
+
ctx,
|
|
70
|
+
root: status.root,
|
|
71
|
+
mode,
|
|
72
|
+
parentThinkingLevel: pi.getThinkingLevel(),
|
|
73
|
+
signal,
|
|
74
|
+
onProgress: (line) => panel.update(line),
|
|
75
|
+
})
|
|
76
|
+
.then((result) => done({ ...result, mode, root: status.root, createdAt: new Date().toISOString() }))
|
|
77
|
+
.catch((error: unknown) => {
|
|
78
|
+
failure = errorText(error);
|
|
79
|
+
done(undefined);
|
|
80
|
+
});
|
|
81
|
+
return panel;
|
|
82
|
+
});
|
|
83
|
+
if (!output) {
|
|
84
|
+
ctx.ui.notify(
|
|
85
|
+
signal.aborted ? "Review cancelled" : `Review failed: ${failure ?? "unknown error"}`,
|
|
86
|
+
signal.aborted ? "info" : "error",
|
|
87
|
+
);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
pi.appendEntry(REVIEW_ENTRY_TYPE, output);
|
|
91
|
+
await showReview(pi, ctx, output);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function latestReview(entries: readonly SessionEntry[]): ReviewRecord | undefined {
|
|
97
|
+
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
|
98
|
+
const entry = entries[index];
|
|
99
|
+
if (entry?.type !== "custom" || entry.customType !== REVIEW_ENTRY_TYPE) continue;
|
|
100
|
+
if (isReviewRecord(entry.data)) return entry.data;
|
|
101
|
+
}
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function showReview(pi: ExtensionAPI, ctx: ExtensionContext, review: ReviewRecord): Promise<void> {
|
|
106
|
+
const action = await ctx.ui.custom<ReviewResultAction>(
|
|
107
|
+
(tui, theme, keys, done) => new ReviewResultPanel(tui, theme, keys, review, done),
|
|
108
|
+
{
|
|
109
|
+
overlay: true,
|
|
110
|
+
overlayOptions: { anchor: "top-center", width: "80%", minWidth: 68, maxHeight: "90%", margin: 1 },
|
|
111
|
+
},
|
|
112
|
+
);
|
|
113
|
+
if (action === "send") {
|
|
114
|
+
try {
|
|
115
|
+
await pi.sendMessage(
|
|
116
|
+
createInjectedContext(formatReviewMarkdown(review), { source: "review", title: `${review.mode} review` }),
|
|
117
|
+
{ triggerTurn: false, deliverAs: "nextTurn" },
|
|
118
|
+
);
|
|
119
|
+
ctx.ui.notify("Review queued for agent's next turn", "info");
|
|
120
|
+
} catch (error) {
|
|
121
|
+
ctx.ui.notify(`Failed to send review: ${errorText(error)}`, "error");
|
|
122
|
+
}
|
|
123
|
+
} else if (action === "export") {
|
|
124
|
+
try {
|
|
125
|
+
const directory = join(review.root, ".pi", "tau", "reviews");
|
|
126
|
+
await mkdir(directory, { recursive: true });
|
|
127
|
+
const timestamp = review.createdAt.replace(/[^0-9A-Za-z-]/g, "-");
|
|
128
|
+
const path = join(directory, `${timestamp}-${review.mode}.md`);
|
|
129
|
+
await writeFile(path, formatReviewMarkdown(review), { encoding: "utf8", mode: 0o600 });
|
|
130
|
+
ctx.ui.notify(`Review exported to ${path}`, "info");
|
|
131
|
+
} catch (error) {
|
|
132
|
+
ctx.ui.notify(`Failed to export review: ${errorText(error)}`, "error");
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import type { Tool } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { Value } from "typebox/value";
|
|
4
|
+
|
|
5
|
+
const MAX_SUMMARY_LENGTH = 2_000;
|
|
6
|
+
const MAX_FINDINGS = 12;
|
|
7
|
+
const MAX_PATH_LENGTH = 400;
|
|
8
|
+
const MAX_LINES_LENGTH = 80;
|
|
9
|
+
const MAX_FINDING_LENGTH = 1_500;
|
|
10
|
+
const REVIEW_MODE_SCHEMA = Type.Union([
|
|
11
|
+
Type.Literal("simplify"),
|
|
12
|
+
Type.Literal("architecture"),
|
|
13
|
+
Type.Literal("correctness"),
|
|
14
|
+
]);
|
|
15
|
+
const REVIEW_FINDING_SCHEMA = Type.Object(
|
|
16
|
+
{
|
|
17
|
+
severity: Type.Union([
|
|
18
|
+
Type.Literal("critical"),
|
|
19
|
+
Type.Literal("high"),
|
|
20
|
+
Type.Literal("medium"),
|
|
21
|
+
Type.Literal("low"),
|
|
22
|
+
]),
|
|
23
|
+
path: Type.String({
|
|
24
|
+
minLength: 1,
|
|
25
|
+
maxLength: MAX_PATH_LENGTH,
|
|
26
|
+
description: "Repository-relative file path",
|
|
27
|
+
}),
|
|
28
|
+
lines: Type.String({
|
|
29
|
+
minLength: 1,
|
|
30
|
+
maxLength: MAX_LINES_LENGTH,
|
|
31
|
+
description: "Exact line or line range, or N/A when no source line exists",
|
|
32
|
+
}),
|
|
33
|
+
finding: Type.String({
|
|
34
|
+
minLength: 1,
|
|
35
|
+
maxLength: MAX_FINDING_LENGTH,
|
|
36
|
+
description: "Failure mechanism or concrete maintenance cost",
|
|
37
|
+
}),
|
|
38
|
+
fix: Type.String({
|
|
39
|
+
minLength: 1,
|
|
40
|
+
maxLength: MAX_FINDING_LENGTH,
|
|
41
|
+
description: "Smallest credible fix direction",
|
|
42
|
+
}),
|
|
43
|
+
},
|
|
44
|
+
{ additionalProperties: false },
|
|
45
|
+
);
|
|
46
|
+
const REVIEW_OUTPUT_SCHEMA = Type.Object(
|
|
47
|
+
{
|
|
48
|
+
verdict: Type.Union([Type.Literal("pass"), Type.Literal("changes-required")]),
|
|
49
|
+
summary: Type.String({
|
|
50
|
+
minLength: 1,
|
|
51
|
+
maxLength: MAX_SUMMARY_LENGTH,
|
|
52
|
+
description: "Short, blunt prose verdict",
|
|
53
|
+
}),
|
|
54
|
+
findings: Type.Array(REVIEW_FINDING_SCHEMA, { maxItems: MAX_FINDINGS }),
|
|
55
|
+
},
|
|
56
|
+
{ additionalProperties: false },
|
|
57
|
+
);
|
|
58
|
+
const REVIEW_RECORD_SCHEMA = Type.Object(
|
|
59
|
+
{
|
|
60
|
+
...REVIEW_OUTPUT_SCHEMA.properties,
|
|
61
|
+
mode: REVIEW_MODE_SCHEMA,
|
|
62
|
+
root: Type.String({ minLength: 1 }),
|
|
63
|
+
createdAt: Type.String({ minLength: 1 }),
|
|
64
|
+
},
|
|
65
|
+
{ additionalProperties: false },
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
export type ReviewMode = Static<typeof REVIEW_MODE_SCHEMA>;
|
|
69
|
+
export type ReviewOutput = Static<typeof REVIEW_OUTPUT_SCHEMA>;
|
|
70
|
+
export type ReviewRecord = Static<typeof REVIEW_RECORD_SCHEMA>;
|
|
71
|
+
|
|
72
|
+
export const REVIEW_ENTRY_TYPE = "tau.review.result";
|
|
73
|
+
|
|
74
|
+
export const REVIEW_RESULT_TOOL = {
|
|
75
|
+
name: "review_result",
|
|
76
|
+
description: "Submit final review. This is the only allowed final response.",
|
|
77
|
+
parameters: REVIEW_OUTPUT_SCHEMA,
|
|
78
|
+
} satisfies Tool;
|
|
79
|
+
|
|
80
|
+
const MODE_INSTRUCTIONS: Record<ReviewMode, string> = {
|
|
81
|
+
simplify: [
|
|
82
|
+
"Delete concepts before improving them.",
|
|
83
|
+
"Find behavior that was not requested, code the repository or platform already provides, duplicate concepts, needless files, wrappers, helpers, types, options, and branches.",
|
|
84
|
+
"Judge against the smallest credible implementation. Runtime correctness is out of scope unless a problem directly proves needless complexity.",
|
|
85
|
+
].join(" "),
|
|
86
|
+
architecture: [
|
|
87
|
+
"Assume the current architecture is poor and likely needs substantial rework. Make existing structure prove it should remain.",
|
|
88
|
+
"Inspect ownership, boundaries, shared roots, reuse, sources of truth, coupling, cohesion, and clean coding patterns.",
|
|
89
|
+
"Prefer a coherent redesign over preserving a bad local structure. Runtime correctness is secondary and should appear only when it supports an architectural finding.",
|
|
90
|
+
].join(" "),
|
|
91
|
+
correctness: [
|
|
92
|
+
"Architecture is accepted for this pass.",
|
|
93
|
+
"Look only for concrete runtime bugs, broken state transitions, unsafe boundaries, data loss, error-handling failures, and affected callers.",
|
|
94
|
+
"Do not report style, simplification, or architecture preferences.",
|
|
95
|
+
].join(" "),
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export function buildReviewPrompt(root: string, mode: ReviewMode): string {
|
|
99
|
+
return [
|
|
100
|
+
`Review uncommitted work in ${root}.`,
|
|
101
|
+
"Inspect staged, unstaged, and untracked changes. Stay centered on changed behavior, but inspect surrounding ownership and callers when needed to prove a finding.",
|
|
102
|
+
"Do not modify files. Do not report theoretical concerns or personal preferences. Use the cheapest evidence that settles each point.",
|
|
103
|
+
MODE_INSTRUCTIONS[mode],
|
|
104
|
+
`Call ${REVIEW_RESULT_TOOL.name} exactly once as the final action. Write no final prose outside that tool call.`,
|
|
105
|
+
"Order findings by severity. Every finding needs an exact repository-relative path and lines when source exists, a concrete mechanism or cost, and the smallest credible fix. Return an empty findings array and verdict pass when nothing actionable remains.",
|
|
106
|
+
].join("\n\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function formatReviewMarkdown(review: ReviewRecord): string {
|
|
110
|
+
const findings = review.findings.length
|
|
111
|
+
? review.findings.flatMap((finding, index) => [
|
|
112
|
+
`### ${index + 1}. ${finding.severity.toUpperCase()} — ${finding.path}:${finding.lines}`,
|
|
113
|
+
"",
|
|
114
|
+
finding.finding,
|
|
115
|
+
"",
|
|
116
|
+
`**Fix:** ${finding.fix}`,
|
|
117
|
+
"",
|
|
118
|
+
])
|
|
119
|
+
: ["No actionable findings.", ""];
|
|
120
|
+
return [
|
|
121
|
+
`# ${reviewModeLabel(review.mode)} review`,
|
|
122
|
+
"",
|
|
123
|
+
`**Verdict:** ${review.verdict}`,
|
|
124
|
+
`**Created:** ${review.createdAt}`,
|
|
125
|
+
"",
|
|
126
|
+
review.summary,
|
|
127
|
+
"",
|
|
128
|
+
"## Findings",
|
|
129
|
+
"",
|
|
130
|
+
...findings,
|
|
131
|
+
].join("\n");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function isReviewMode(value: string): value is ReviewMode {
|
|
135
|
+
return Value.Check(REVIEW_MODE_SCHEMA, value);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function reviewModeLabel(mode: ReviewMode): string {
|
|
139
|
+
return `${mode.charAt(0).toUpperCase()}${mode.slice(1)}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function isReviewRecord(value: unknown): value is ReviewRecord {
|
|
143
|
+
return Value.Check(REVIEW_RECORD_SCHEMA, value);
|
|
144
|
+
}
|