pi-jar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +288 -0
- package/docs/DESIGN.md +85 -0
- package/docs/DEVELOPMENT.md +95 -0
- package/docs/FLAME.md +54 -0
- package/docs/INTEGRATIONS.md +82 -0
- package/docs/PLAN.md +68 -0
- package/docs/SHOWCASE.md +22 -0
- package/docs/WORKFLOWS.md +141 -0
- package/extensions/index.ts +773 -0
- package/package.json +62 -0
- package/src/accent.ts +36 -0
- package/src/advisor.ts +194 -0
- package/src/animations.ts +23 -0
- package/src/ask-tool.ts +185 -0
- package/src/attachments.ts +79 -0
- package/src/changes.ts +146 -0
- package/src/commit.ts +61 -0
- package/src/compact-tools.ts +129 -0
- package/src/composer.ts +404 -0
- package/src/context-view.ts +141 -0
- package/src/delegate.ts +208 -0
- package/src/dialogs.ts +127 -0
- package/src/diff-view.ts +160 -0
- package/src/flame.ts +245 -0
- package/src/footer-settings.ts +32 -0
- package/src/footer.ts +147 -0
- package/src/goal-loop.ts +313 -0
- package/src/goals.ts +114 -0
- package/src/history-ui.ts +136 -0
- package/src/history.ts +168 -0
- package/src/info-panels.ts +75 -0
- package/src/mascot.ts +103 -0
- package/src/model-roles.ts +332 -0
- package/src/panel.ts +90 -0
- package/src/plan-utils.ts +191 -0
- package/src/plan-view.ts +149 -0
- package/src/plan.ts +460 -0
- package/src/prompt-search.ts +83 -0
- package/src/quota.ts +134 -0
- package/src/roles-ui.ts +190 -0
- package/src/roles.ts +25 -0
- package/src/session-gallery.ts +52 -0
- package/src/session-ui.ts +76 -0
- package/src/settings-ui.ts +154 -0
- package/src/settings.ts +96 -0
- package/src/shells.ts +319 -0
- package/src/side-model.ts +48 -0
- package/src/split-view.ts +74 -0
- package/src/status.ts +71 -0
- package/src/suggest.ts +89 -0
- package/src/task-tool.ts +135 -0
- package/src/tasks-ui.ts +73 -0
- package/src/tasks.ts +147 -0
- package/src/usage-view.ts +98 -0
- package/src/usage.ts +16 -0
- package/src/welcome.ts +260 -0
- package/src/working.ts +60 -0
- package/themes/pi-jar-dark-amber.json +84 -0
- package/themes/pi-jar-dark-azure.json +84 -0
- package/themes/pi-jar-dark-gray.json +84 -0
- package/themes/pi-jar-dark-pink.json +84 -0
- package/themes/pi-jar-dark-teal.json +84 -0
- package/themes/pi-jar-dark-violet.json +84 -0
- package/themes/pi-jar-dark.json +84 -0
package/src/goal-loop.ts
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
3
|
+
import { Type } from "typebox";
|
|
4
|
+
import type { Goal, GoalStore } from "./goals.ts";
|
|
5
|
+
import type { ModelRoleManager } from "./model-roles.ts";
|
|
6
|
+
import { isSafePlanCommand } from "./plan-utils.ts";
|
|
7
|
+
import type { Todo, TodoStore } from "./tasks.ts";
|
|
8
|
+
|
|
9
|
+
export const GOAL_TOOL = "jar_goal";
|
|
10
|
+
export const DEFAULT_GOAL_ROUNDS = 8;
|
|
11
|
+
const CONTEXT_TYPE = "pi-jar.goal-context";
|
|
12
|
+
const CONTINUATION_TYPE = "pi-jar.goal-continuation";
|
|
13
|
+
const WRITE_TOOLS = new Set(["write", "edit"]);
|
|
14
|
+
|
|
15
|
+
export interface GoalLoopOptions {
|
|
16
|
+
goals: () => GoalStore | undefined;
|
|
17
|
+
todos: () => TodoStore | undefined;
|
|
18
|
+
roles: ModelRoleManager;
|
|
19
|
+
planActive: () => boolean;
|
|
20
|
+
maxRounds?: () => number;
|
|
21
|
+
changed?: (ctx: ExtensionContext) => void;
|
|
22
|
+
completed?: (ctx: ExtensionContext, goal: Goal) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const taskLines = (items: readonly Todo[]) => items.length ? items.map((item) => `${item.done ? "[x]" : "[ ]"} ${item.title} (${item.id})`).join("\n") : "(no tasks yet)";
|
|
26
|
+
|
|
27
|
+
/** One-line goal progress for the footer and welcome card. */
|
|
28
|
+
export function goalProgress(goal: Goal | undefined, todos: readonly Todo[], maxRounds = DEFAULT_GOAL_ROUNDS): string | undefined {
|
|
29
|
+
if (!goal) return undefined;
|
|
30
|
+
const done = todos.filter((item) => item.done).length;
|
|
31
|
+
const parts = [goal.text];
|
|
32
|
+
if (todos.length) parts.push(`${done}/${todos.length} tasks`);
|
|
33
|
+
if (goal.status === "active" && goal.rounds) parts.push(`round ${goal.rounds}/${maxRounds}`);
|
|
34
|
+
if (goal.status === "active" && goal.phase === "audit") parts.push("auditing");
|
|
35
|
+
if (goal.status === "paused") parts.push("paused" + (goal.reason ? ` (${goal.reason})` : ""));
|
|
36
|
+
if (goal.status === "complete") parts.push("✓ complete");
|
|
37
|
+
return parts.join(" · ");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Goal mode: the implementor keeps working while jar_todo tasks are open; once they are all done
|
|
42
|
+
* an auditor pass (advisor role) must verify the goal and call jar_goal complete with evidence.
|
|
43
|
+
*/
|
|
44
|
+
export class GoalLoop {
|
|
45
|
+
private readonly pi: ExtensionAPI;
|
|
46
|
+
private readonly options: GoalLoopOptions;
|
|
47
|
+
private prompting = 0;
|
|
48
|
+
private restoreRole: (() => Promise<void>) | undefined;
|
|
49
|
+
private roleInUse: "implement" | "advisor" | undefined;
|
|
50
|
+
|
|
51
|
+
constructor(pi: ExtensionAPI, options: GoalLoopOptions) {
|
|
52
|
+
this.pi = pi;
|
|
53
|
+
this.options = options;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
maxRounds(): number { return Math.max(1, Math.floor(this.options.maxRounds?.() ?? DEFAULT_GOAL_ROUNDS)); }
|
|
57
|
+
private tasks(): Todo[] { return this.options.todos()?.all() ?? []; }
|
|
58
|
+
private goal(): Goal | undefined { return this.options.goals()?.current(); }
|
|
59
|
+
progress(): string | undefined { return goalProgress(this.goal(), this.tasks(), this.maxRounds()); }
|
|
60
|
+
|
|
61
|
+
/** Pause automation (for example when plan mode starts). */
|
|
62
|
+
pause(ctx: ExtensionContext | undefined, reason: string): void {
|
|
63
|
+
const store = this.options.goals();
|
|
64
|
+
if (!store?.isActive()) return;
|
|
65
|
+
store.setStatus("paused", { reason });
|
|
66
|
+
if (ctx) { ctx.ui.notify("◎ Goal paused · " + reason + " · /goal resume", "info"); this.options.changed?.(ctx); }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async restore(): Promise<void> {
|
|
70
|
+
const restore = this.restoreRole;
|
|
71
|
+
this.restoreRole = undefined;
|
|
72
|
+
this.roleInUse = undefined;
|
|
73
|
+
if (restore) await restore();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Run goal rounds on their role: `implement` while building, `advisor` for the audit. */
|
|
77
|
+
private async useRole(role: "implement" | "advisor", ctx: ExtensionContext): Promise<void> {
|
|
78
|
+
if (this.roleInUse === role) return;
|
|
79
|
+
await this.restore();
|
|
80
|
+
this.restoreRole = await this.options.roles.activateTemporary(role, ctx);
|
|
81
|
+
this.roleInUse = role;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private context(goal: Goal): string {
|
|
85
|
+
const tasks = this.tasks();
|
|
86
|
+
return [
|
|
87
|
+
"[PI-JAR ACTIVE GOAL]",
|
|
88
|
+
goal.text,
|
|
89
|
+
"Goal mode is on. Work until the goal is actually achieved:",
|
|
90
|
+
"- Break the goal into concrete jar_todo tasks before editing anything; edits are blocked while no task is open.",
|
|
91
|
+
"- Keep jar_todo synchronized: mark tasks done only when their outcome is verified, add tasks for newly discovered work.",
|
|
92
|
+
"- pi-jar keeps you going while tasks remain. When all are done an audit pass verifies the goal; only then call jar_goal complete with evidence.",
|
|
93
|
+
"- If you are truly blocked on the user, call jar_goal block with the reason instead of stopping silently.",
|
|
94
|
+
"Current tasks:",
|
|
95
|
+
taskLines(tasks)
|
|
96
|
+
].join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private implementMessage(goal: Goal, round: number): string {
|
|
100
|
+
const open = this.tasks().filter((item) => !item.done);
|
|
101
|
+
return [
|
|
102
|
+
`[PI-JAR GOAL · implementor · round ${round}/${this.maxRounds()}]`,
|
|
103
|
+
"Goal: " + goal.text,
|
|
104
|
+
open.length ? "Continue with the open tasks. Verify each outcome before marking it done:" : "There are no tasks yet. Break the goal into concrete jar_todo tasks now, then start the first one.",
|
|
105
|
+
open.length ? taskLines(open) : ""
|
|
106
|
+
].filter(Boolean).join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private auditMessage(goal: Goal, round: number): string {
|
|
110
|
+
return [
|
|
111
|
+
`[PI-JAR GOAL · auditor · round ${round}/${this.maxRounds()}]`,
|
|
112
|
+
"Goal: " + goal.text,
|
|
113
|
+
"All tracked tasks are marked done. Act as an independent auditor: check the goal against the current repository state, run the relevant tests or commands, and look for missed requirements or regressions.",
|
|
114
|
+
"- If anything is missing or wrong, add jar_todo tasks for the gaps (do not edit files during the audit) and stop; the implementor will continue.",
|
|
115
|
+
"- If the goal is fully achieved, call jar_goal complete with concrete evidence (commands run, results, files).",
|
|
116
|
+
"Tasks:",
|
|
117
|
+
taskLines(this.tasks())
|
|
118
|
+
].join("\n");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private complete(ctx: ExtensionContext, evidence: string): { ok: boolean; message: string } {
|
|
122
|
+
const store = this.options.goals();
|
|
123
|
+
const goal = store?.current();
|
|
124
|
+
if (!store || !goal || goal.status !== "active") return { ok: false, message: "No active goal." };
|
|
125
|
+
const open = this.tasks().filter((item) => !item.done);
|
|
126
|
+
if (open.length) return { ok: false, message: "Cannot complete: tasks are still open:\n" + taskLines(open) };
|
|
127
|
+
if (goal.phase !== "audit") return { ok: false, message: "Cannot complete yet: finish your turn; pi-jar runs an audit pass before a goal can be completed." };
|
|
128
|
+
if (!evidence.trim()) return { ok: false, message: "Provide concrete evidence (commands run, results, files) to complete the goal." };
|
|
129
|
+
if (!store.setStatus("complete", { evidence })) return { ok: false, message: "Could not record goal completion." };
|
|
130
|
+
ctx.ui.notify("◎ Goal complete · " + goal.text, "info");
|
|
131
|
+
this.options.changed?.(ctx);
|
|
132
|
+
this.options.completed?.(ctx, { ...goal, status: "complete", evidence });
|
|
133
|
+
return { ok: true, message: "Goal marked complete." };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Set a goal and kick off the implementor. */
|
|
137
|
+
async start(ctx: ExtensionContext, text: string): Promise<void> {
|
|
138
|
+
const store = this.options.goals();
|
|
139
|
+
if (!store) { ctx.ui.notify("Goal state is unavailable before the session starts", "warning"); return; }
|
|
140
|
+
if (!store.set(text)) { ctx.ui.notify("Could not set goal; keep it concise and plain-text", "error"); return; }
|
|
141
|
+
this.options.changed?.(ctx);
|
|
142
|
+
ctx.ui.notify("◎ Goal active · " + store.current()!.text, "info");
|
|
143
|
+
if (this.options.planActive()) { this.pause(ctx, "plan mode is on"); return; }
|
|
144
|
+
this.pi.sendUserMessage("Work toward the active goal: " + store.current()!.text, { deliverAs: "followUp" });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Ask for a goal in the TUI editor (welcome action). */
|
|
148
|
+
async prompt(ctx: ExtensionContext): Promise<void> {
|
|
149
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
150
|
+
const current = this.options.goals()?.text();
|
|
151
|
+
const edited = (await ctx.ui.editor(current ? "◎ Replace goal" : "◎ New goal", current ?? ""))?.trim();
|
|
152
|
+
if (edited && edited !== current) await this.start(ctx, edited);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
register(): void {
|
|
156
|
+
const progress = () => this.progress();
|
|
157
|
+
this.pi.registerTool?.({
|
|
158
|
+
name: GOAL_TOOL,
|
|
159
|
+
label: "goal",
|
|
160
|
+
description: "Inspect or finish pi-jar's active goal. complete requires audit-phase evidence and no open jar_todo tasks; block pauses the loop for the user.",
|
|
161
|
+
promptSnippet: "When a pi-jar goal is active, track work with jar_todo and finish with jar_goal complete (evidence) after the audit pass.",
|
|
162
|
+
parameters: Type.Object({
|
|
163
|
+
action: Type.Unsafe<"get" | "complete" | "block">({ type: "string", enum: ["get", "complete", "block"] }),
|
|
164
|
+
evidence: Type.Optional(Type.String({ description: "For complete: commands run, results and files that prove the goal is met." })),
|
|
165
|
+
reason: Type.Optional(Type.String({ description: "For block: what you need from the user." }))
|
|
166
|
+
}),
|
|
167
|
+
execute: async (_id, params, _signal, _update, ctx) => {
|
|
168
|
+
const goal = this.goal();
|
|
169
|
+
if (params.action === "complete") {
|
|
170
|
+
const result = this.complete(ctx, params.evidence ?? "");
|
|
171
|
+
return { content: [{ type: "text", text: result.message }], details: { action: "complete", ok: result.ok }, ...(result.ok ? { terminate: true } : {}) };
|
|
172
|
+
}
|
|
173
|
+
if (params.action === "block") {
|
|
174
|
+
if (!goal || goal.status !== "active") return { content: [{ type: "text", text: "No active goal." }], details: { action: "block", ok: false } };
|
|
175
|
+
const reason = (params.reason ?? "").trim() || "needs user input";
|
|
176
|
+
this.options.goals()?.setStatus("paused", { reason });
|
|
177
|
+
ctx.ui.notify("◎ Goal blocked · " + reason, "warning");
|
|
178
|
+
this.options.changed?.(ctx);
|
|
179
|
+
return { content: [{ type: "text", text: "Goal paused for the user: " + reason }], details: { action: "block", ok: true }, terminate: true };
|
|
180
|
+
}
|
|
181
|
+
const text = goal ? [`Goal: ${goal.text}`, `Status: ${goal.status} · phase ${goal.phase} · round ${goal.rounds}/${this.maxRounds()}`, taskLines(this.tasks())].join("\n") : "No goal is set.";
|
|
182
|
+
return { content: [{ type: "text", text }], details: { action: "get", ok: !!goal } };
|
|
183
|
+
},
|
|
184
|
+
renderCall(args, theme) { return new Text(theme.fg("toolTitle", theme.bold("goal")) + " " + theme.fg("accent", String(args.action)), 0, 0); },
|
|
185
|
+
renderResult(result, _options, theme) {
|
|
186
|
+
const details = result.details as { action?: string; ok?: boolean } | undefined;
|
|
187
|
+
const label = details?.action === "complete" ? (details.ok ? "◎ goal complete" : "not complete yet") : details?.action === "block" ? "goal paused for you" : progress() ?? "no goal";
|
|
188
|
+
return new Text(theme.fg(details?.ok === false ? "warning" : "success", label), 0, 0);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
this.pi.registerCommand("goal", {
|
|
193
|
+
description: "Goal mode: /goal <outcome> starts an implement → audit loop; /goal pause|resume|clear|status",
|
|
194
|
+
handler: async (args, ctx) => {
|
|
195
|
+
const store = this.options.goals();
|
|
196
|
+
if (!store) { ctx.ui.notify("Goal state is unavailable before the session starts", "warning"); return; }
|
|
197
|
+
const raw = args.trim();
|
|
198
|
+
const verb = raw.toLowerCase();
|
|
199
|
+
if (/^(?:clear|off|none|drop)$/.test(verb)) {
|
|
200
|
+
await this.restore();
|
|
201
|
+
if (store.clear()) { this.options.changed?.(ctx); ctx.ui.notify("Goal cleared", "info"); }
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
if (verb === "pause") { this.pause(ctx, "paused by you"); return; }
|
|
205
|
+
if (verb === "resume") {
|
|
206
|
+
const goal = store.current();
|
|
207
|
+
if (!goal || goal.status !== "paused") { ctx.ui.notify(goal ? "Goal is " + goal.status : "No goal to resume", "info"); return; }
|
|
208
|
+
if (this.options.planActive()) { ctx.ui.notify("Leave plan mode before resuming the goal", "warning"); return; }
|
|
209
|
+
store.setStatus("active");
|
|
210
|
+
store.setRound(0, goal.phase);
|
|
211
|
+
this.options.changed?.(ctx);
|
|
212
|
+
this.pi.sendUserMessage("Resume work on the active goal: " + goal.text, { deliverAs: "followUp" });
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (!raw || verb === "status") {
|
|
216
|
+
if (!raw && !store.text() && ctx.hasUI && ctx.mode === "tui") {
|
|
217
|
+
const edited = (await ctx.ui.editor("◎ New goal", ""))?.trim();
|
|
218
|
+
if (edited) await this.start(ctx, edited);
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
ctx.ui.notify(this.progress() ? "◎ " + this.progress() : "No goal. Use /goal <outcome>.", "info");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
await this.start(ctx, raw);
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
this.pi.on("ui_prompt_start", () => { this.prompting++; });
|
|
229
|
+
this.pi.on("ui_prompt_end", () => { this.prompting = Math.max(0, this.prompting - 1); });
|
|
230
|
+
|
|
231
|
+
// A real user message starts a fresh budget of automatic rounds.
|
|
232
|
+
this.pi.on("input", (event) => {
|
|
233
|
+
if (event.source !== "interactive") return;
|
|
234
|
+
const store = this.options.goals();
|
|
235
|
+
const goal = store?.current();
|
|
236
|
+
if (goal?.status === "active") store!.setRound(0, goal.phase);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
this.pi.on("tool_call", async (event) => {
|
|
240
|
+
const goal = this.goal();
|
|
241
|
+
if (!goal || goal.status !== "active" || this.options.planActive()) return;
|
|
242
|
+
const open = this.tasks().filter((item) => !item.done).length;
|
|
243
|
+
if (open) return;
|
|
244
|
+
const input = event.input && typeof event.input === "object" ? event.input as Record<string, unknown> : {};
|
|
245
|
+
const unsafeCommand = !(typeof input.command === "string" && isSafePlanCommand(input.command));
|
|
246
|
+
const mutating = WRITE_TOOLS.has(event.toolName)
|
|
247
|
+
|| (goal.phase === "implement" && event.toolName === "bash" && unsafeCommand)
|
|
248
|
+
|| (goal.phase === "implement" && event.toolName === "jar_shell" && input.action === "start" && unsafeCommand)
|
|
249
|
+
|| (event.toolName === "jar_delegate" && input.write === true);
|
|
250
|
+
if (!mutating) return;
|
|
251
|
+
return { block: true, reason: goal.phase === "audit"
|
|
252
|
+
? "Goal audit: do not edit during the audit. Add jar_todo tasks for the gaps you found instead."
|
|
253
|
+
: "Goal active: create jar_todo tasks for the goal first; changes are blocked while no task is open." };
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
this.pi.on("before_agent_start", async (_event, ctx) => {
|
|
257
|
+
const goal = this.goal();
|
|
258
|
+
if (!goal || goal.status === "complete") return;
|
|
259
|
+
if (goal.status === "active" && !this.options.planActive()) await this.useRole(goal.phase === "audit" ? "advisor" : "implement", ctx);
|
|
260
|
+
return { message: { customType: CONTEXT_TYPE, content: this.context(goal), display: false } };
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
// Keep only the newest goal context and continuation so repeated rounds do not bloat the prompt.
|
|
264
|
+
this.pi.on("context", async (event) => {
|
|
265
|
+
const goal = this.goal();
|
|
266
|
+
const seen = new Set<string>();
|
|
267
|
+
const messages = [...event.messages].reverse().filter((raw) => {
|
|
268
|
+
const type = (raw as { customType?: string }).customType;
|
|
269
|
+
if (type !== CONTEXT_TYPE && type !== CONTINUATION_TYPE) return true;
|
|
270
|
+
if (!goal || goal.status === "complete" || seen.has(type)) return false;
|
|
271
|
+
seen.add(type);
|
|
272
|
+
return true;
|
|
273
|
+
}).reverse();
|
|
274
|
+
return { messages };
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
this.pi.on("agent_before_settle", async (event, ctx) => {
|
|
278
|
+
const store = this.options.goals();
|
|
279
|
+
const goal = store?.current();
|
|
280
|
+
if (!store || !goal || goal.status !== "active" || this.options.planActive() || event.continue) return;
|
|
281
|
+
if (event.outcome !== "completed") {
|
|
282
|
+
await this.restore();
|
|
283
|
+
this.pause(ctx, event.outcome === "aborted" ? "interrupted" : "turn failed");
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (this.prompting) return;
|
|
287
|
+
if (goal.rounds >= this.maxRounds()) {
|
|
288
|
+
await this.restore();
|
|
289
|
+
this.pause(ctx, `reached ${this.maxRounds()} automatic rounds`);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const tasks = this.tasks();
|
|
293
|
+
const audit = tasks.length > 0 && tasks.every((item) => item.done);
|
|
294
|
+
const round = goal.rounds + 1;
|
|
295
|
+
store.setRound(round, audit ? "audit" : "implement");
|
|
296
|
+
await this.useRole(audit ? "advisor" : "implement", ctx);
|
|
297
|
+
this.options.changed?.(ctx);
|
|
298
|
+
return {
|
|
299
|
+
entries: [{ type: "custom_message", customType: CONTINUATION_TYPE, display: false,
|
|
300
|
+
content: audit ? this.auditMessage(goal, round) : this.implementMessage(goal, round) }],
|
|
301
|
+
continue: true
|
|
302
|
+
};
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
this.pi.on("agent_settled", async (_event, ctx) => {
|
|
306
|
+
if (!this.restoreRole) return;
|
|
307
|
+
await this.restore();
|
|
308
|
+
this.options.changed?.(ctx);
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
this.pi.on("session_shutdown", async () => { await this.restore(); });
|
|
312
|
+
}
|
|
313
|
+
}
|
package/src/goals.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { cleanText } from "./status.ts";
|
|
3
|
+
|
|
4
|
+
export const GOAL_ENTRY = "pi-jar.goal";
|
|
5
|
+
export type GoalStatus = "active" | "paused" | "complete" | "dropped";
|
|
6
|
+
export type GoalPhase = "implement" | "audit";
|
|
7
|
+
export interface Goal { id: string; text: string; status: GoalStatus; phase: GoalPhase; rounds: number; evidence?: string; reason?: string }
|
|
8
|
+
export type GoalEvent =
|
|
9
|
+
| { v: 1; op: "set"; text: string }
|
|
10
|
+
| { v: 1; op: "clear" }
|
|
11
|
+
| { v: 2; op: "set"; id: string; text: string }
|
|
12
|
+
| { v: 2; op: "status"; id: string; status: GoalStatus; evidence?: string; reason?: string }
|
|
13
|
+
| { v: 2; op: "round"; id: string; rounds: number; phase: GoalPhase };
|
|
14
|
+
|
|
15
|
+
const STATUSES: GoalStatus[] = ["active", "paused", "complete", "dropped"];
|
|
16
|
+
const validGoal = (value: unknown): value is string =>
|
|
17
|
+
typeof value === "string" && value.length <= 1024 && !!cleanText(value, 240);
|
|
18
|
+
const validId = (id: unknown): id is string => typeof id === "string" && /^[a-zA-Z0-9-]{1,64}$/.test(id);
|
|
19
|
+
|
|
20
|
+
/** Branch-aware, append-only goal lifecycle: set → active ⇄ paused → complete | dropped. */
|
|
21
|
+
export class GoalStore {
|
|
22
|
+
private value: Goal | undefined;
|
|
23
|
+
private readonly append: (event: GoalEvent) => void;
|
|
24
|
+
|
|
25
|
+
constructor(append: (event: GoalEvent) => void) {
|
|
26
|
+
this.append = append;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The goal on this branch, including a completed one until it is cleared; dropped goals are gone. */
|
|
30
|
+
current(): Goal | undefined {
|
|
31
|
+
return this.value && { ...this.value };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Text of a goal that is still being worked on (active or paused). */
|
|
35
|
+
text(): string | undefined {
|
|
36
|
+
return this.value && (this.value.status === "active" || this.value.status === "paused") ? this.value.text : undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
isActive(): boolean { return this.value?.status === "active"; }
|
|
40
|
+
|
|
41
|
+
apply(raw: unknown): boolean {
|
|
42
|
+
if (!raw || typeof raw !== "object") return false;
|
|
43
|
+
const event = raw as Record<string, unknown>;
|
|
44
|
+
if (event.v === 1) {
|
|
45
|
+
if (event.op === "clear") { this.value = undefined; return true; }
|
|
46
|
+
if (event.op === "set" && validGoal(event.text)) {
|
|
47
|
+
this.value = { id: "legacy", text: cleanText(event.text, 240), status: "active", phase: "implement", rounds: 0 };
|
|
48
|
+
return true;
|
|
49
|
+
}
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
if (event.v !== 2 || !validId(event.id)) return false;
|
|
53
|
+
if (event.op === "set" && validGoal(event.text)) {
|
|
54
|
+
this.value = { id: event.id, text: cleanText(event.text, 240), status: "active", phase: "implement", rounds: 0 };
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
if (!this.value || this.value.id !== event.id) return false;
|
|
58
|
+
if (event.op === "status" && STATUSES.includes(event.status as GoalStatus)) {
|
|
59
|
+
if (event.status === "dropped") { this.value = undefined; return true; }
|
|
60
|
+
const { evidence: _evidence, reason: _reason, ...rest } = this.value;
|
|
61
|
+
this.value = { ...rest, status: event.status as GoalStatus,
|
|
62
|
+
...(typeof event.evidence === "string" && event.evidence.trim() ? { evidence: cleanText(event.evidence, 600) } : {}),
|
|
63
|
+
...(typeof event.reason === "string" && event.reason.trim() ? { reason: cleanText(event.reason, 240) } : {}) };
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
if (event.op === "round" && Number.isInteger(event.rounds) && (event.rounds as number) >= 0 && (event.phase === "implement" || event.phase === "audit")) {
|
|
67
|
+
this.value = { ...this.value, rounds: Math.min(event.rounds as number, 1000), phase: event.phase };
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
restore(branch: readonly unknown[]): void {
|
|
74
|
+
this.value = undefined;
|
|
75
|
+
for (const raw of branch) {
|
|
76
|
+
if (!raw || typeof raw !== "object") continue;
|
|
77
|
+
const entry = raw as Record<string, unknown>;
|
|
78
|
+
if (entry.type === "custom" && entry.customType === GOAL_ENTRY) this.apply(entry.data);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private commit(event: GoalEvent): boolean {
|
|
83
|
+
const previous = this.value;
|
|
84
|
+
if (!this.apply(event)) return false;
|
|
85
|
+
try {
|
|
86
|
+
this.append(event);
|
|
87
|
+
return true;
|
|
88
|
+
} catch {
|
|
89
|
+
this.value = previous;
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
set(text: string): boolean {
|
|
95
|
+
if (!validGoal(text)) return false;
|
|
96
|
+
return this.commit({ v: 2, op: "set", id: randomUUID(), text: cleanText(text, 240) });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
setStatus(status: GoalStatus, detail: { evidence?: string; reason?: string } = {}): boolean {
|
|
100
|
+
if (!this.value) return false;
|
|
101
|
+
return this.commit({ v: 2, op: "status", id: this.value.id, status, ...detail });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
setRound(rounds: number, phase: GoalPhase): boolean {
|
|
105
|
+
if (!this.value) return false;
|
|
106
|
+
if (this.value.rounds === rounds && this.value.phase === phase) return true;
|
|
107
|
+
return this.commit({ v: 2, op: "round", id: this.value.id, rounds, phase });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
clear(): boolean {
|
|
111
|
+
if (!this.value) return true;
|
|
112
|
+
return this.commit({ v: 2, op: "status", id: this.value.id, status: "dropped" });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Input, Key, matchesKey, truncateToWidth, visibleWidth, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
+
import { createHistorySnapshot, historyChunk, historyPage, safeHistoryText, type HistoryItem } from "./history.ts";
|
|
4
|
+
|
|
5
|
+
/** Separate, read-only view. Pi's native transcript and session file are never modified. */
|
|
6
|
+
export async function openJarHistory(ctx: ExtensionContext): Promise<void> {
|
|
7
|
+
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
8
|
+
// Capture the active branch ONCE on open. Paging and search operate on this snapshot.
|
|
9
|
+
const snapshot = createHistorySnapshot(ctx.sessionManager.getBranch());
|
|
10
|
+
await ctx.ui.custom<void>((tui, theme, _keys, done) => {
|
|
11
|
+
let current = historyPage(snapshot);
|
|
12
|
+
let selected = Math.max(0, current.items.length - 1);
|
|
13
|
+
let expanded = false;
|
|
14
|
+
let detailScroll = 0;
|
|
15
|
+
let offsets = [0];
|
|
16
|
+
let search = "";
|
|
17
|
+
let searching = false;
|
|
18
|
+
let notice = "";
|
|
19
|
+
let width = 76;
|
|
20
|
+
const input = new Input({ prompt: "/", placeholder: "Search previews on this page" });
|
|
21
|
+
const selectedItem = (): HistoryItem | undefined => current.items[selected];
|
|
22
|
+
const budget = () => Math.max(7, Math.floor((process.stdout.rows ?? 24) * 0.8));
|
|
23
|
+
const detailRows = () => expanded ? Math.min(8, Math.max(1, budget() - 9)) : 2;
|
|
24
|
+
const listRows = () => Math.max(1, budget() - 7 - detailRows());
|
|
25
|
+
const first = () => Math.max(0, Math.min(selected - listRows() + 1, current.items.length - listRows()));
|
|
26
|
+
const resetDetails = () => { expanded = false; detailScroll = 0; offsets = [0]; };
|
|
27
|
+
const select = (index: number) => {
|
|
28
|
+
const next = Math.max(0, Math.min(current.items.length - 1, index));
|
|
29
|
+
if (next !== selected) { selected = next; resetDetails(); }
|
|
30
|
+
};
|
|
31
|
+
const page = (number: number) => {
|
|
32
|
+
current = historyPage(snapshot, number);
|
|
33
|
+
selected = Math.max(0, current.items.length - 1);
|
|
34
|
+
resetDetails();
|
|
35
|
+
notice = "";
|
|
36
|
+
};
|
|
37
|
+
const find = (direction: number, includeSelected = false) => {
|
|
38
|
+
if (!search || !current.items.length) return;
|
|
39
|
+
const term = search.toLocaleLowerCase();
|
|
40
|
+
for (let step = includeSelected ? 0 : 1; step < current.items.length + (includeSelected ? 0 : 1); step++) {
|
|
41
|
+
const index = (selected + direction * step + current.items.length * 2) % current.items.length;
|
|
42
|
+
if (current.items[index]!.search.toLocaleLowerCase().includes(term)) { select(index); notice = ""; return; }
|
|
43
|
+
}
|
|
44
|
+
notice = "No match in this page's previews";
|
|
45
|
+
};
|
|
46
|
+
input.onSubmit = (value) => {
|
|
47
|
+
searching = false; input.focused = false;
|
|
48
|
+
search = safeHistoryText(value, 80).trim();
|
|
49
|
+
notice = "";
|
|
50
|
+
find(1, true);
|
|
51
|
+
tui.requestRender();
|
|
52
|
+
};
|
|
53
|
+
input.onEscape = () => { searching = false; input.focused = false; tui.requestRender(); };
|
|
54
|
+
const chunks = () => selectedItem() ? historyChunk(selectedItem()!, offsets.at(-1)!) : undefined;
|
|
55
|
+
const moveChunk = (direction: number) => {
|
|
56
|
+
const chunk = chunks();
|
|
57
|
+
if (direction > 0 && chunk?.more && chunk.nextOffset > offsets.at(-1)!) offsets.push(chunk.nextOffset);
|
|
58
|
+
else if (direction < 0 && offsets.length > 1) offsets.pop();
|
|
59
|
+
detailScroll = 0;
|
|
60
|
+
expanded = true;
|
|
61
|
+
};
|
|
62
|
+
const fit = (text: string) => truncateToWidth(text, Math.max(0, width));
|
|
63
|
+
const line = (content: string) => {
|
|
64
|
+
const inside = Math.max(0, width - 4);
|
|
65
|
+
const clipped = truncateToWidth(content, inside);
|
|
66
|
+
return fit(theme.fg("dim", "│ ") + clipped + " ".repeat(Math.max(0, inside - visibleWidth(clipped))) + theme.fg("dim", " │"));
|
|
67
|
+
};
|
|
68
|
+
const border = (left: string, middle: string, right: string) => fit(theme.fg("dim", left + middle.repeat(Math.max(0, width - 2)) + right));
|
|
69
|
+
return {
|
|
70
|
+
invalidate() { input.invalidate(); },
|
|
71
|
+
handleInput(data: string) {
|
|
72
|
+
if (searching) { input.handleInput(data); tui.requestRender(); return; }
|
|
73
|
+
if (matchesKey(data, Key.escape) || data === "q") { done(); return; }
|
|
74
|
+
if (data === "/") { searching = true; input.focused = true; input.setValue(search); tui.requestRender(); return; }
|
|
75
|
+
if (matchesKey(data, Key.up) || data === "k") select(selected - 1);
|
|
76
|
+
else if (matchesKey(data, Key.down) || data === "j") select(selected + 1);
|
|
77
|
+
else if (matchesKey(data, Key.pageUp)) select(selected - listRows());
|
|
78
|
+
else if (matchesKey(data, Key.pageDown)) select(selected + listRows());
|
|
79
|
+
else if (matchesKey(data, Key.home)) select(0);
|
|
80
|
+
else if (matchesKey(data, Key.end)) select(current.items.length - 1);
|
|
81
|
+
else if (data === "p") page(current.page + 1);
|
|
82
|
+
else if (data === "o") page(current.page - 1);
|
|
83
|
+
else if (data === "n") find(1);
|
|
84
|
+
else if (data === "N") find(-1);
|
|
85
|
+
else if (data === "e" || data === " " || matchesKey(data, Key.enter)) { expanded = !expanded; detailScroll = 0; }
|
|
86
|
+
else if (data === "[" || data === "]") moveChunk(data === "]" ? 1 : -1);
|
|
87
|
+
else if (data === "d") { expanded = true; detailScroll = Math.min(Math.max(0, (chunks()?.lines.length ?? 0) - detailRows()), detailScroll + detailRows()); }
|
|
88
|
+
else if (data === "u") { expanded = true; detailScroll = Math.max(0, detailScroll - detailRows()); }
|
|
89
|
+
tui.requestRender();
|
|
90
|
+
},
|
|
91
|
+
handleMouse(event: TuiMouseEvent) {
|
|
92
|
+
if (event.type === "wheel" && event.wheelDelta) { select(selected + Math.sign(event.wheelDelta) * Math.max(1, Math.abs(event.wheelDelta))); tui.requestRender(); return { handled: true, focus: true }; }
|
|
93
|
+
if (event.type !== "click" || event.button !== "left") return;
|
|
94
|
+
if (event.y === 0 && event.x >= width - 4) { done(); return { handled: true }; }
|
|
95
|
+
if (event.x < 1 || event.x >= width - 1) return;
|
|
96
|
+
const start = first();
|
|
97
|
+
if (event.y >= 3 && event.y < 3 + listRows()) {
|
|
98
|
+
const index = start + event.y - 3;
|
|
99
|
+
if (index < current.items.length) { select(index); tui.requestRender(); return { handled: true, focus: true }; }
|
|
100
|
+
}
|
|
101
|
+
if (event.y === 4 + listRows()) { expanded = !expanded; tui.requestRender(); return { handled: true, focus: true }; }
|
|
102
|
+
},
|
|
103
|
+
render(available: number): string[] {
|
|
104
|
+
width = Math.max(8, available);
|
|
105
|
+
const start = first();
|
|
106
|
+
const items = current.items.slice(start, start + listRows());
|
|
107
|
+
const heading = ` pi-jar · history · ${snapshot.visible.length} turns`;
|
|
108
|
+
const top = fit(theme.fg("accent", "╭─" + truncateToWidth(heading, Math.max(0, width - 6))
|
|
109
|
+
+ "─".repeat(Math.max(0, width - 4 - visibleWidth(heading))) + "×╮"));
|
|
110
|
+
const subtitle = ` Active branch · page ${current.page + 1}/${current.totalPages} · snapshot at open`;
|
|
111
|
+
const rows = items.length ? items.map((item, position) => {
|
|
112
|
+
const index = start + position;
|
|
113
|
+
const prefix = `${index === selected ? "●" : "○"} #${item.sequence} ${item.actor}${item.error ? " !" : ""}`;
|
|
114
|
+
const time = item.time && width >= 74 ? ` · ${item.time}` : "";
|
|
115
|
+
const spare = Math.max(0, width - 6 - visibleWidth(prefix + time));
|
|
116
|
+
const summary = truncateToWidth(item.summary.replace(/\s+/g, " "), spare);
|
|
117
|
+
return line(theme.fg(index === selected ? "accent" : "muted", prefix) + theme.fg("dim", time + " │ ") + summary);
|
|
118
|
+
}) : [line(theme.fg("dim", " No visible messages on this branch"))];
|
|
119
|
+
while (rows.length < listRows()) rows.push(line(""));
|
|
120
|
+
const item = selectedItem();
|
|
121
|
+
const chunk = chunks();
|
|
122
|
+
const headingDetail = item ? `${item.actor} #${item.sequence} ${item.time ?? ""} · ${offsets.length} segment${chunk?.more ? " →" : ""}` : "No selected entry";
|
|
123
|
+
const details = item && chunk ? chunk.lines.slice(detailScroll, detailScroll + detailRows()) : [];
|
|
124
|
+
const body = Array.from({ length: detailRows() }, (_, i) => {
|
|
125
|
+
const raw = details[i] ?? "";
|
|
126
|
+
const code = raw.trimStart().startsWith("```") || raw.startsWith(" ");
|
|
127
|
+
return line(theme.fg(code ? "muted" : "text", truncateToWidth(raw, Math.max(0, width - 5))));
|
|
128
|
+
});
|
|
129
|
+
const help = searching ? line(input.render(Math.max(1, width - 4))[0] ?? "/")
|
|
130
|
+
: line(theme.fg("dim", notice || (width >= 66 ? "↑↓/Pg: move · /: search · n/N: next · e: expand · d/u: detail · [ ]: chunks · p/o: pages · Esc" : "↑↓ move · / search · e expand · p/o pages · Esc")));
|
|
131
|
+
return [top, line(theme.fg("muted", subtitle)), border("├", "─", "┤"), ...rows,
|
|
132
|
+
border("├", "─", "┤"), line(theme.fg("accent", headingDetail)), ...body, help, border("╰", "─", "╯")].map(fit);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
}, { overlay: true, overlayOptions: { anchor: "center", width: 92, maxHeight: "80%" } });
|
|
136
|
+
}
|