pi-goal-x 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +307 -0
- package/docs/agent-flow-design.md +432 -0
- package/docs/agentic-runtime-prd.md +764 -0
- package/docs/architecture.md +239 -0
- package/docs/goal-ts-refactor-test-strategy.md +82 -0
- package/docs/pi-autoresearch-survey.md +45 -0
- package/extensions/goal-auditor.ts +341 -0
- package/extensions/goal-compaction.ts +124 -0
- package/extensions/goal-core.ts +77 -0
- package/extensions/goal-draft.ts +148 -0
- package/extensions/goal-ledger.ts +319 -0
- package/extensions/goal-policy.ts +152 -0
- package/extensions/goal-pool.ts +94 -0
- package/extensions/goal-questionnaire.ts +533 -0
- package/extensions/goal-record.ts +171 -0
- package/extensions/goal-tool-names.ts +69 -0
- package/extensions/goal.ts +2610 -0
- package/extensions/prompts/goal-prompts.ts +166 -0
- package/extensions/storage/goal-files.ts +267 -0
- package/extensions/widgets/goal-notifications.ts +9 -0
- package/extensions/widgets/goal-widget.ts +219 -0
- package/package.json +57 -0
|
@@ -0,0 +1,533 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Editor, type EditorTheme, Key, matchesKey, Text, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
4
|
+
|
|
5
|
+
import { truncateText } from "./goal-core.ts";
|
|
6
|
+
import { QUESTIONNAIRE_TOOL_NAME, QUESTION_TOOL_NAME } from "./goal-tool-names.ts";
|
|
7
|
+
import type { GoalDraftingFocus } from "./goal-draft.ts";
|
|
8
|
+
|
|
9
|
+
export interface GoalQuestionnaireQuestion {
|
|
10
|
+
id: string;
|
|
11
|
+
question: string;
|
|
12
|
+
context?: string;
|
|
13
|
+
options: string[];
|
|
14
|
+
recommended?: number;
|
|
15
|
+
allowCustom?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GoalQuestionnaireAnswer {
|
|
19
|
+
id: string;
|
|
20
|
+
question: string;
|
|
21
|
+
answer: string;
|
|
22
|
+
wasCustom: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface GoalQuestionnaireResult {
|
|
26
|
+
questions: GoalQuestionnaireQuestion[];
|
|
27
|
+
answers: GoalQuestionnaireAnswer[];
|
|
28
|
+
cancelled: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type ProposalDecision = "confirm" | "continue";
|
|
32
|
+
|
|
33
|
+
export function normalizeQuestionnaireQuestions(rawQuestions: GoalQuestionnaireQuestion[]): GoalQuestionnaireQuestion[] {
|
|
34
|
+
const seenIds = new Set<string>();
|
|
35
|
+
return rawQuestions.map((q, i) => {
|
|
36
|
+
let id = q.id.trim() || `q${i + 1}`;
|
|
37
|
+
if (seenIds.has(id)) id = `${id}-${i + 1}`;
|
|
38
|
+
seenIds.add(id);
|
|
39
|
+
const options = q.options.filter((option) => option.trim().length > 0);
|
|
40
|
+
const recommended = q.recommended !== undefined && q.recommended >= 0 && q.recommended < options.length
|
|
41
|
+
? q.recommended
|
|
42
|
+
: undefined;
|
|
43
|
+
return { ...q, id, options, recommended, allowCustom: q.allowCustom ?? true };
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function formatQuestionnaireAnswers(result: GoalQuestionnaireResult): string {
|
|
48
|
+
return result.answers.map((answer) => {
|
|
49
|
+
const question = result.questions.find((q) => q.id === answer.id);
|
|
50
|
+
const lines = [`**Q:** ${answer.question}`];
|
|
51
|
+
if (question?.context) lines.push(`\n${question.context}`);
|
|
52
|
+
if (question && question.options.length > 0) lines.push(`\nOptions: ${question.options.join(" / ")}`);
|
|
53
|
+
lines.push(`\n**A:** ${answer.answer}`);
|
|
54
|
+
return lines.join("");
|
|
55
|
+
}).join("\n\n---\n\n");
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function shouldAutoConfirmProposal(args: { hasUI: boolean; autoConfirmEnv?: string }): boolean {
|
|
59
|
+
return !args.hasUI || args.autoConfirmEnv === "1";
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function proposalDecisionFromQuestionnaireResult(args: { cancelled: boolean; answer?: string }): ProposalDecision {
|
|
63
|
+
if (args.cancelled) return "continue";
|
|
64
|
+
return (args.answer ?? "").startsWith("Confirm") ? "confirm" : "continue";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function isHeadlessQuestionSufficientForDraft(args: { topic: string; questionText: string }): boolean {
|
|
68
|
+
const topic = args.topic.toLowerCase();
|
|
69
|
+
void args;
|
|
70
|
+
const vagueTopic = topic.trim().length < 20 || /(整理笔记|organize notes|notes|笔记)$/.test(topic.trim());
|
|
71
|
+
return !vagueTopic;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function proposalDialogFailureMessage(error: unknown): string {
|
|
75
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
76
|
+
return `Goal draft confirmation failed: ${detail}. The goal was NOT created; drafting remains active.`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Shared question UI used by both the agent-callable goal_questionnaire tool and
|
|
81
|
+
* the internal draft-confirm prompt. This keeps pi-goal self-contained and
|
|
82
|
+
* avoids depending on external question/questionnaire packages.
|
|
83
|
+
*/
|
|
84
|
+
export async function runGoalQuestionnaire(ctx: ExtensionContext, rawQuestions: GoalQuestionnaireQuestion[]): Promise<GoalQuestionnaireResult> {
|
|
85
|
+
if (!ctx.hasUI) {
|
|
86
|
+
return { questions: [], answers: [], cancelled: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const questions = normalizeQuestionnaireQuestions(rawQuestions);
|
|
90
|
+
const isMulti = questions.length > 1;
|
|
91
|
+
const totalTabs = questions.length + 1;
|
|
92
|
+
|
|
93
|
+
return await ctx.ui.custom<GoalQuestionnaireResult>((tui, theme, _kb, done) => {
|
|
94
|
+
let currentTab = 0;
|
|
95
|
+
let optionIndex = 0;
|
|
96
|
+
let inputMode = false;
|
|
97
|
+
let inputQuestionId: string | null = null;
|
|
98
|
+
let cachedLines: string[] | undefined;
|
|
99
|
+
const answers = new Map<string, GoalQuestionnaireAnswer>();
|
|
100
|
+
const drafts = new Map<string, string>();
|
|
101
|
+
|
|
102
|
+
const editorTheme: EditorTheme = {
|
|
103
|
+
borderColor: (s) => theme.fg("accent", s),
|
|
104
|
+
selectList: {
|
|
105
|
+
selectedPrefix: (t) => theme.fg("accent", t),
|
|
106
|
+
selectedText: (t) => theme.fg("accent", t),
|
|
107
|
+
description: (t) => theme.fg("muted", t),
|
|
108
|
+
scrollInfo: (t) => theme.fg("dim", t),
|
|
109
|
+
noMatch: (t) => theme.fg("warning", t),
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
const editor = new Editor(tui, editorTheme);
|
|
113
|
+
|
|
114
|
+
function refresh() {
|
|
115
|
+
cachedLines = undefined;
|
|
116
|
+
tui.requestRender();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function submit(cancelled: boolean) {
|
|
120
|
+
const ordered = questions.map((q) => answers.get(q.id)).filter((a): a is GoalQuestionnaireAnswer => !!a);
|
|
121
|
+
done({ questions, answers: ordered, cancelled });
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function currentQuestion(): GoalQuestionnaireQuestion | undefined {
|
|
125
|
+
return questions[currentTab];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function displayOptions(): Array<{ label: string; isCustom?: boolean }> {
|
|
129
|
+
const q = currentQuestion();
|
|
130
|
+
if (!q) return [];
|
|
131
|
+
const opts: Array<{ label: string; isCustom?: boolean }> = q.options.map((label) => ({ label }));
|
|
132
|
+
if (q.allowCustom !== false) opts.push({ label: "Write your own answer...", isCustom: true });
|
|
133
|
+
return opts;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function allAnswered(): boolean {
|
|
137
|
+
return questions.every((q) => answers.has(q.id));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function enterQuestion(q: GoalQuestionnaireQuestion) {
|
|
141
|
+
const existing = answers.get(q.id);
|
|
142
|
+
const draft = drafts.get(q.id);
|
|
143
|
+
if (q.options.length === 0) {
|
|
144
|
+
inputMode = true;
|
|
145
|
+
inputQuestionId = q.id;
|
|
146
|
+
editor.setText(draft ?? (existing?.wasCustom ? existing.answer : ""));
|
|
147
|
+
} else if (existing?.wasCustom) {
|
|
148
|
+
optionIndex = q.options.length;
|
|
149
|
+
} else if (existing && !existing.wasCustom) {
|
|
150
|
+
const idx = q.options.indexOf(existing.answer);
|
|
151
|
+
optionIndex = idx >= 0 ? idx : 0;
|
|
152
|
+
} else {
|
|
153
|
+
optionIndex = q.recommended ?? 0;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function advanceAfterAnswer() {
|
|
158
|
+
if (!isMulti) {
|
|
159
|
+
submit(false);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (currentTab < questions.length - 1) currentTab++;
|
|
163
|
+
else currentTab = questions.length;
|
|
164
|
+
const nextQ = currentQuestion();
|
|
165
|
+
if (nextQ) enterQuestion(nextQ);
|
|
166
|
+
else optionIndex = 0;
|
|
167
|
+
refresh();
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function saveAnswer(qId: string, value: string, wasCustom: boolean) {
|
|
171
|
+
const q = questions.find((qq) => qq.id === qId);
|
|
172
|
+
answers.set(qId, { id: qId, question: q?.question ?? qId, answer: value, wasCustom });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
editor.onSubmit = (value) => {
|
|
176
|
+
if (!inputQuestionId) return;
|
|
177
|
+
const trimmed = value.trim();
|
|
178
|
+
if (!trimmed) {
|
|
179
|
+
refresh();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
drafts.delete(inputQuestionId);
|
|
183
|
+
saveAnswer(inputQuestionId, trimmed, true);
|
|
184
|
+
inputMode = false;
|
|
185
|
+
inputQuestionId = null;
|
|
186
|
+
editor.setText("");
|
|
187
|
+
advanceAfterAnswer();
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
function exitEditor() {
|
|
191
|
+
if (inputQuestionId) {
|
|
192
|
+
const text = editor.getText();
|
|
193
|
+
if (text.trim()) drafts.set(inputQuestionId, text);
|
|
194
|
+
else drafts.delete(inputQuestionId);
|
|
195
|
+
}
|
|
196
|
+
inputMode = false;
|
|
197
|
+
inputQuestionId = null;
|
|
198
|
+
editor.setText("");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
enterQuestion(questions[0]);
|
|
202
|
+
|
|
203
|
+
function handleInput(data: string) {
|
|
204
|
+
if (inputMode) {
|
|
205
|
+
if (matchesKey(data, Key.escape)) {
|
|
206
|
+
const q = currentQuestion();
|
|
207
|
+
if (q && q.options.length === 0 && !isMulti) submit(true);
|
|
208
|
+
else {
|
|
209
|
+
exitEditor();
|
|
210
|
+
refresh();
|
|
211
|
+
}
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
if (isMulti && (matchesKey(data, Key.tab) || matchesKey(data, Key.shift("tab")))) {
|
|
215
|
+
exitEditor();
|
|
216
|
+
currentTab = matchesKey(data, Key.tab) ? (currentTab + 1) % totalTabs : (currentTab - 1 + totalTabs) % totalTabs;
|
|
217
|
+
const nextQ = currentQuestion();
|
|
218
|
+
if (nextQ) enterQuestion(nextQ);
|
|
219
|
+
else optionIndex = 0;
|
|
220
|
+
refresh();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
editor.handleInput(data);
|
|
224
|
+
refresh();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const q = currentQuestion();
|
|
229
|
+
const opts = displayOptions();
|
|
230
|
+
|
|
231
|
+
if (isMulti) {
|
|
232
|
+
if (matchesKey(data, Key.tab) || matchesKey(data, Key.right)) {
|
|
233
|
+
currentTab = (currentTab + 1) % totalTabs;
|
|
234
|
+
const nextQ = currentQuestion();
|
|
235
|
+
if (nextQ) enterQuestion(nextQ);
|
|
236
|
+
else optionIndex = 0;
|
|
237
|
+
refresh();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (matchesKey(data, Key.shift("tab")) || matchesKey(data, Key.left)) {
|
|
241
|
+
currentTab = (currentTab - 1 + totalTabs) % totalTabs;
|
|
242
|
+
const nextQ = currentQuestion();
|
|
243
|
+
if (nextQ) enterQuestion(nextQ);
|
|
244
|
+
else optionIndex = 0;
|
|
245
|
+
refresh();
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (currentTab === questions.length) {
|
|
251
|
+
if (matchesKey(data, Key.enter) && allAnswered()) submit(false);
|
|
252
|
+
else if (matchesKey(data, Key.escape)) submit(true);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (matchesKey(data, Key.up)) {
|
|
257
|
+
optionIndex = Math.max(0, optionIndex - 1);
|
|
258
|
+
refresh();
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (matchesKey(data, Key.down)) {
|
|
262
|
+
optionIndex = Math.min(opts.length - 1, optionIndex + 1);
|
|
263
|
+
refresh();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (matchesKey(data, Key.enter) && q) {
|
|
268
|
+
if (q.options.length === 0 || opts[optionIndex]?.isCustom) {
|
|
269
|
+
inputMode = true;
|
|
270
|
+
inputQuestionId = q.id;
|
|
271
|
+
const draft = drafts.get(q.id);
|
|
272
|
+
const existing = answers.get(q.id);
|
|
273
|
+
editor.setText(draft ?? (existing?.wasCustom ? existing.answer : ""));
|
|
274
|
+
refresh();
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const opt = opts[optionIndex];
|
|
278
|
+
if (opt) {
|
|
279
|
+
saveAnswer(q.id, opt.label, false);
|
|
280
|
+
advanceAfterAnswer();
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (matchesKey(data, Key.escape)) submit(true);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function render(width: number): string[] {
|
|
289
|
+
if (cachedLines) return cachedLines;
|
|
290
|
+
const safeWidth = Math.max(20, width);
|
|
291
|
+
const lines: string[] = [];
|
|
292
|
+
const q = currentQuestion();
|
|
293
|
+
const opts = displayOptions();
|
|
294
|
+
const add = (s: string) => lines.push(truncateToWidth(s, safeWidth, "…", true));
|
|
295
|
+
const addWrapped = (s: string) => lines.push(...wrapTextWithAnsi(s, safeWidth));
|
|
296
|
+
|
|
297
|
+
add(theme.fg("accent", "─".repeat(safeWidth)));
|
|
298
|
+
if (isMulti) {
|
|
299
|
+
const tabs: string[] = ["← "];
|
|
300
|
+
for (let i = 0; i < questions.length; i++) {
|
|
301
|
+
const isActive = i === currentTab;
|
|
302
|
+
const isAnswered = answers.has(questions[i].id);
|
|
303
|
+
const label = ` ${isAnswered ? "■" : "□"} ${questions[i].id} `;
|
|
304
|
+
tabs.push(isActive ? theme.bg("selectedBg", theme.fg("text", label)) : theme.fg(isAnswered ? "success" : "muted", label));
|
|
305
|
+
tabs.push(" ");
|
|
306
|
+
}
|
|
307
|
+
const submitText = " ✓ Submit ";
|
|
308
|
+
tabs.push(currentTab === questions.length ? theme.bg("selectedBg", theme.fg("text", submitText)) : theme.fg(allAnswered() ? "success" : "dim", submitText));
|
|
309
|
+
tabs.push(" →");
|
|
310
|
+
add(` ${tabs.join("")}`);
|
|
311
|
+
lines.push("");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function renderOptions() {
|
|
315
|
+
for (let i = 0; i < opts.length; i++) {
|
|
316
|
+
const opt = opts[i];
|
|
317
|
+
const selected = i === optionIndex;
|
|
318
|
+
const prefix = selected ? theme.fg("accent", "> ") : " ";
|
|
319
|
+
const recTag = !opt.isCustom && q?.recommended === i ? theme.fg("success", " ★") : "";
|
|
320
|
+
add(prefix + theme.fg(selected ? "accent" : "text", `${i + 1}. ${opt.label}`) + recTag);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (inputMode && q) {
|
|
325
|
+
addWrapped(theme.fg("text", ` ${q.question}`));
|
|
326
|
+
if (q.context) addWrapped(theme.fg("muted", ` ${q.context}`));
|
|
327
|
+
lines.push("");
|
|
328
|
+
if (q.options.length > 0) {
|
|
329
|
+
renderOptions();
|
|
330
|
+
lines.push("");
|
|
331
|
+
}
|
|
332
|
+
add(theme.fg("muted", " Your answer:"));
|
|
333
|
+
for (const line of editor.render(safeWidth - 2)) add(` ${line}`);
|
|
334
|
+
lines.push("");
|
|
335
|
+
add(theme.fg("dim", " Enter to submit • Esc to cancel"));
|
|
336
|
+
} else if (currentTab === questions.length) {
|
|
337
|
+
add(theme.fg("accent", theme.bold(" Ready to submit")));
|
|
338
|
+
lines.push("");
|
|
339
|
+
for (const question of questions) {
|
|
340
|
+
const answer = answers.get(question.id);
|
|
341
|
+
add(`${theme.fg("muted", ` ${question.id}: `)}${answer ? theme.fg("text", `${answer.wasCustom ? "(wrote) " : ""}${answer.answer}`) : theme.fg("warning", "(unanswered)")}`);
|
|
342
|
+
}
|
|
343
|
+
lines.push("");
|
|
344
|
+
add(allAnswered() ? theme.fg("success", " Press Enter to submit") : theme.fg("warning", ` Unanswered: ${questions.filter((qq) => !answers.has(qq.id)).map((qq) => qq.id).join(", ")}`));
|
|
345
|
+
} else if (q) {
|
|
346
|
+
addWrapped(theme.fg("text", ` ${q.question}`));
|
|
347
|
+
if (q.context) addWrapped(theme.fg("muted", ` ${q.context}`));
|
|
348
|
+
const existing = answers.get(q.id);
|
|
349
|
+
if (existing) add(theme.fg("dim", ` Current: ${existing.wasCustom ? "(wrote) " : ""}${existing.answer}`));
|
|
350
|
+
lines.push("");
|
|
351
|
+
if (opts.length > 0) renderOptions();
|
|
352
|
+
else add(theme.fg("muted", " Press Enter to write your answer"));
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
lines.push("");
|
|
356
|
+
if (!inputMode) add(theme.fg("dim", isMulti ? " Tab/←→ navigate • ↑↓ select • Enter confirm • Esc cancel" : " ↑↓ navigate • Enter select • Esc cancel"));
|
|
357
|
+
add(theme.fg("accent", "─".repeat(safeWidth)));
|
|
358
|
+
cachedLines = lines;
|
|
359
|
+
return lines;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
return { render, invalidate: () => { cachedLines = undefined; }, handleInput };
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Confirm a proposed draft through the shared questionnaire UI. Escape / cancel
|
|
368
|
+
* maps to "continue" so the user is never trapped.
|
|
369
|
+
*/
|
|
370
|
+
export async function showProposalDialog(
|
|
371
|
+
ctx: ExtensionContext,
|
|
372
|
+
confirmationText: string,
|
|
373
|
+
focus: GoalDraftingFocus,
|
|
374
|
+
): Promise<ProposalDecision> {
|
|
375
|
+
const headerTitle = focus === "sisyphus" ? "Confirm Sisyphus Goal Draft" : "Confirm Goal Draft";
|
|
376
|
+
const result = await runGoalQuestionnaire(ctx, [{
|
|
377
|
+
id: "confirm",
|
|
378
|
+
question: headerTitle,
|
|
379
|
+
context: confirmationText,
|
|
380
|
+
options: ["Confirm — create this goal now", "Continue chatting — keep refining"],
|
|
381
|
+
recommended: 0,
|
|
382
|
+
allowCustom: false,
|
|
383
|
+
}]);
|
|
384
|
+
return proposalDecisionFromQuestionnaireResult({
|
|
385
|
+
cancelled: result.cancelled,
|
|
386
|
+
answer: result.answers[0]?.answer,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export function registerQuestionnaireTools(pi: ExtensionAPI): void {
|
|
391
|
+
pi.registerTool(defineTool({
|
|
392
|
+
name: QUESTION_TOOL_NAME,
|
|
393
|
+
label: "goal_question",
|
|
394
|
+
description:
|
|
395
|
+
"Ask the user a focused single question through pi-goal's built-in goal_question UI. " +
|
|
396
|
+
"This is the single-question alias for goal_questionnaire and is allowed during drafting.",
|
|
397
|
+
promptSnippet: "Ask the user a focused question with optional choices.",
|
|
398
|
+
promptGuidelines: [
|
|
399
|
+
"Use goal_question when exactly one user decision is required before proceeding.",
|
|
400
|
+
"During drafting this is allowed; it returns user Q&A into the conversation and is not task execution.",
|
|
401
|
+
"Prefer concise options. Use allowFreeText=false only when the user must pick from fixed choices.",
|
|
402
|
+
],
|
|
403
|
+
parameters: Type.Object({
|
|
404
|
+
question: Type.String({ description: "Question to ask the user." }),
|
|
405
|
+
context: Type.Optional(Type.String({ description: "Short context explaining why the answer is needed." })),
|
|
406
|
+
options: Type.Optional(Type.Array(Type.String({ description: "Suggested answer option." }))),
|
|
407
|
+
recommended: Type.Optional(Type.Integer({ minimum: 0, description: "0-based index of the recommended option." })),
|
|
408
|
+
allowFreeText: Type.Optional(Type.Boolean({ description: "Allow the user to write a custom answer. Defaults to true." })),
|
|
409
|
+
}),
|
|
410
|
+
executionMode: "sequential",
|
|
411
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
412
|
+
if (!ctx.hasUI) {
|
|
413
|
+
return {
|
|
414
|
+
content: [{ type: "text", text: "Headless mode: the question was recorded, but no interactive UI answer was collected. If the original request is already fully specified, proceed with the documented/default assumption; otherwise ask the user in final text and stop." }],
|
|
415
|
+
details: { questions: [], answers: [], cancelled: true, answer: undefined },
|
|
416
|
+
};
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const result = await runGoalQuestionnaire(ctx, [{
|
|
420
|
+
id: "answer",
|
|
421
|
+
question: params.question,
|
|
422
|
+
context: params.context,
|
|
423
|
+
options: params.options ?? [],
|
|
424
|
+
recommended: params.recommended,
|
|
425
|
+
allowCustom: params.allowFreeText ?? true,
|
|
426
|
+
}]);
|
|
427
|
+
|
|
428
|
+
if (result.cancelled) {
|
|
429
|
+
return {
|
|
430
|
+
content: [{ type: "text", text: "User cancelled the question." }],
|
|
431
|
+
details: { ...result, answer: undefined },
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const answer = result.answers[0]?.answer ?? "";
|
|
436
|
+
return {
|
|
437
|
+
content: [{ type: "text", text: `User answered: ${answer}` }],
|
|
438
|
+
details: { ...result, answer },
|
|
439
|
+
};
|
|
440
|
+
},
|
|
441
|
+
renderCall(args, theme) {
|
|
442
|
+
return new Text(theme.fg("toolTitle", theme.bold("goal_question ")) + theme.fg("muted", truncateText(args?.question ?? "", 80)), 0, 0);
|
|
443
|
+
},
|
|
444
|
+
renderResult(result, _options, theme) {
|
|
445
|
+
const details = result.details as { answer?: string; cancelled?: boolean } | undefined;
|
|
446
|
+
if (details?.cancelled) return new Text(theme.fg("warning", "(cancelled)"), 0, 0);
|
|
447
|
+
if (details?.answer !== undefined) return new Text(theme.fg("success", "✓ ") + theme.fg("muted", details.answer), 0, 0);
|
|
448
|
+
const text = result.content[0];
|
|
449
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
450
|
+
},
|
|
451
|
+
}));
|
|
452
|
+
|
|
453
|
+
pi.registerTool(defineTool({
|
|
454
|
+
name: QUESTIONNAIRE_TOOL_NAME,
|
|
455
|
+
label: "goal_questionnaire",
|
|
456
|
+
description:
|
|
457
|
+
"Ask the user one or more questions via pi-goal's built-in goal_questionnaire UI. " +
|
|
458
|
+
"Use this during drafting when you need structured grill/Q&A before propose_goal_draft; " +
|
|
459
|
+
"batch related questions into one call. Returns Q&A records in the conversation history.",
|
|
460
|
+
promptSnippet: "Ask the user one or more structured questions with choices and optional free-text answers.",
|
|
461
|
+
promptGuidelines: [
|
|
462
|
+
"Use goal_questionnaire when a user decision or missing requirement blocks a concrete draft.",
|
|
463
|
+
"During /goals or /sisyphus intent discussion, goal_questionnaire is allowed when structured Q&A helps produce a concrete draft.",
|
|
464
|
+
"Prefer 1-3 focused questions. Batch related choices in one questionnaire call instead of repeatedly interrupting the user.",
|
|
465
|
+
"Use recommended to mark the best default choice when there is one. Set allowCustom=false only for strict binary/choice prompts such as confirmation.",
|
|
466
|
+
],
|
|
467
|
+
parameters: Type.Object({
|
|
468
|
+
questions: Type.Array(
|
|
469
|
+
Type.Object({
|
|
470
|
+
id: Type.String({ description: "Short stable identifier, e.g. 'scope', 'success', 'constraints'." }),
|
|
471
|
+
question: Type.String({ description: "The question to ask the user." }),
|
|
472
|
+
context: Type.Optional(Type.String({ description: "Optional background, trade-offs, or why the answer matters." })),
|
|
473
|
+
options: Type.Optional(Type.Array(Type.String({ description: "Suggested answer option." }), { description: "Suggested answers. Free-text is still available unless allowCustom=false." })),
|
|
474
|
+
recommended: Type.Optional(Type.Integer({ minimum: 0, description: "0-based index of the recommended option. Shown with a star and selected by default." })),
|
|
475
|
+
allowCustom: Type.Optional(Type.Boolean({ description: "Allow the user to write a custom answer. Defaults to true." })),
|
|
476
|
+
}),
|
|
477
|
+
{ minItems: 1 },
|
|
478
|
+
),
|
|
479
|
+
}),
|
|
480
|
+
executionMode: "sequential",
|
|
481
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
482
|
+
if (!ctx.hasUI) {
|
|
483
|
+
return {
|
|
484
|
+
content: [{ type: "text", text: "Headless mode: the questions were recorded, but no interactive UI answers were collected. If the original request is already fully specified, proceed with documented/default assumptions; otherwise ask the user in final text and stop." }],
|
|
485
|
+
details: { questions: [], answers: [], cancelled: true } satisfies GoalQuestionnaireResult,
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const rawQuestions = params.questions.map((q) => ({
|
|
490
|
+
id: q.id,
|
|
491
|
+
question: q.question,
|
|
492
|
+
context: q.context,
|
|
493
|
+
options: q.options ?? [],
|
|
494
|
+
recommended: q.recommended,
|
|
495
|
+
allowCustom: q.allowCustom ?? true,
|
|
496
|
+
}));
|
|
497
|
+
|
|
498
|
+
const result = await runGoalQuestionnaire(ctx, rawQuestions);
|
|
499
|
+
if (result.cancelled) {
|
|
500
|
+
return {
|
|
501
|
+
content: [{ type: "text", text: "(goal_questionnaire dismissed)" }],
|
|
502
|
+
details: result,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return {
|
|
507
|
+
content: [{ type: "text", text: formatQuestionnaireAnswers(result) }],
|
|
508
|
+
details: result,
|
|
509
|
+
};
|
|
510
|
+
},
|
|
511
|
+
renderCall(args, theme) {
|
|
512
|
+
const qs = (args.questions as Array<{ id: string; question: string }>) || [];
|
|
513
|
+
const labels = qs.map((q) => q.id).join(", ");
|
|
514
|
+
let text = theme.fg("toolTitle", theme.bold("goal_questionnaire "));
|
|
515
|
+
text += theme.fg("muted", `${qs.length} question${qs.length !== 1 ? "s" : ""}`);
|
|
516
|
+
if (labels) text += theme.fg("dim", ` (${truncateToWidth(labels, 40)})`);
|
|
517
|
+
return new Text(text, 0, 0);
|
|
518
|
+
},
|
|
519
|
+
renderResult(result, _options, theme) {
|
|
520
|
+
const details = result.details as GoalQuestionnaireResult | undefined;
|
|
521
|
+
if (!details) {
|
|
522
|
+
const text = result.content[0];
|
|
523
|
+
return new Text(text?.type === "text" ? text.text : "", 0, 0);
|
|
524
|
+
}
|
|
525
|
+
if (details.cancelled) return new Text(theme.fg("warning", "(dismissed)"), 0, 0);
|
|
526
|
+
const lines = details.answers.map((answer) => {
|
|
527
|
+
const prefix = answer.wasCustom ? "(wrote) " : "";
|
|
528
|
+
return `${theme.fg("success", "✓ ")}${theme.fg("accent", answer.id)}: ${theme.fg("muted", prefix)}${answer.answer}`;
|
|
529
|
+
});
|
|
530
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
531
|
+
},
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export type GoalStatus = "active" | "paused" | "complete";
|
|
2
|
+
export type StopReason = "user" | "agent";
|
|
3
|
+
export type GoalEventKind = "checkpoint" | "stale" | "drafting";
|
|
4
|
+
export type DraftingFocus = "goal" | "sisyphus";
|
|
5
|
+
export type GoalFocusReason = "created" | "selected" | "resumed" | "completed" | "cleared" | "aborted" | "migrated";
|
|
6
|
+
|
|
7
|
+
export interface GoalUsage {
|
|
8
|
+
tokensUsed: number;
|
|
9
|
+
activeSeconds: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface GoalRecord {
|
|
13
|
+
id: string;
|
|
14
|
+
objective: string;
|
|
15
|
+
status: GoalStatus;
|
|
16
|
+
autoContinue: boolean;
|
|
17
|
+
usage: GoalUsage;
|
|
18
|
+
sisyphus: boolean;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
updatedAt: string;
|
|
21
|
+
activePath?: string;
|
|
22
|
+
archivedPath?: string;
|
|
23
|
+
stopReason?: StopReason;
|
|
24
|
+
// Set by the agent's pause_goal tool. Cleared when the goal becomes active again.
|
|
25
|
+
pauseReason?: string;
|
|
26
|
+
pauseSuggestedAction?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface GoalStateEntry {
|
|
30
|
+
version: 3;
|
|
31
|
+
goal: GoalRecord | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface GoalFocusEntry {
|
|
35
|
+
version: 1;
|
|
36
|
+
focusedGoalId: string | null;
|
|
37
|
+
reason: GoalFocusReason;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface GoalEventDetails {
|
|
41
|
+
kind: GoalEventKind;
|
|
42
|
+
goalId: string;
|
|
43
|
+
status?: GoalStatus;
|
|
44
|
+
objective?: string;
|
|
45
|
+
timestamp?: number;
|
|
46
|
+
currentGoalId?: string | null;
|
|
47
|
+
currentStatus?: GoalStatus | null;
|
|
48
|
+
focus?: DraftingFocus;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface GoalCreationConfig {
|
|
52
|
+
objective: string;
|
|
53
|
+
autoContinue: boolean;
|
|
54
|
+
sisyphus: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface AssistantUsage {
|
|
58
|
+
input?: number;
|
|
59
|
+
output?: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface AssistantMessageLike {
|
|
63
|
+
role?: string;
|
|
64
|
+
stopReason?: string;
|
|
65
|
+
usage?: AssistantUsage;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function nowIso(now = Date.now()): string {
|
|
69
|
+
return new Date(now).toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function safeIdPart(value: string): string {
|
|
73
|
+
return value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 80) || "goal";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function newGoalId(): string {
|
|
77
|
+
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function normalizeRelPath(relPath: string): string {
|
|
81
|
+
return relPath.split(/[\\/]+/).join("/");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function asRecord(value: unknown): Record<string, unknown> | null {
|
|
85
|
+
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function emptyUsage(): GoalUsage {
|
|
89
|
+
return { tokensUsed: 0, activeSeconds: 0 };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function cloneGoal(goal: GoalRecord): GoalRecord {
|
|
93
|
+
return { ...goal, usage: { ...goal.usage } };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function goalFocusDetails(focusedGoalId: string | null, reason: GoalFocusReason): GoalFocusEntry {
|
|
97
|
+
return {
|
|
98
|
+
version: 1,
|
|
99
|
+
focusedGoalId: focusedGoalId ? safeIdPart(focusedGoalId) : null,
|
|
100
|
+
reason,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function normalizeGoalFocusEntry(value: unknown): GoalFocusEntry | null {
|
|
105
|
+
const raw = asRecord(value);
|
|
106
|
+
if (!raw || raw.version !== 1) return null;
|
|
107
|
+
const focusedGoalId = typeof raw.focusedGoalId === "string" && raw.focusedGoalId.trim()
|
|
108
|
+
? safeIdPart(raw.focusedGoalId)
|
|
109
|
+
: null;
|
|
110
|
+
const reason: GoalFocusReason =
|
|
111
|
+
raw.reason === "created" || raw.reason === "selected" || raw.reason === "resumed" || raw.reason === "completed" || raw.reason === "cleared" || raw.reason === "aborted" || raw.reason === "migrated"
|
|
112
|
+
? raw.reason
|
|
113
|
+
: "selected";
|
|
114
|
+
return { version: 1, focusedGoalId, reason };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createGoal(config: GoalCreationConfig, now = Date.now()): GoalRecord {
|
|
118
|
+
const timestamp = nowIso(now);
|
|
119
|
+
return {
|
|
120
|
+
id: newGoalId(),
|
|
121
|
+
objective: config.objective,
|
|
122
|
+
status: "active",
|
|
123
|
+
autoContinue: config.autoContinue,
|
|
124
|
+
usage: emptyUsage(),
|
|
125
|
+
sisyphus: config.sisyphus,
|
|
126
|
+
createdAt: timestamp,
|
|
127
|
+
updatedAt: timestamp,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function normalizeUsage(value: unknown): GoalUsage {
|
|
132
|
+
const raw = asRecord(value);
|
|
133
|
+
if (!raw) return emptyUsage();
|
|
134
|
+
const tokensUsed = typeof raw.tokensUsed === "number" && Number.isFinite(raw.tokensUsed) ? Math.max(0, Math.floor(raw.tokensUsed)) : 0;
|
|
135
|
+
const activeSeconds = typeof raw.activeSeconds === "number" && Number.isFinite(raw.activeSeconds) ? Math.max(0, Math.floor(raw.activeSeconds)) : 0;
|
|
136
|
+
return { tokensUsed, activeSeconds };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function normalizeGoalRecord(value: unknown): GoalRecord | null {
|
|
140
|
+
const raw = asRecord(value);
|
|
141
|
+
if (!raw) return null;
|
|
142
|
+
const objective = typeof raw.objective === "string" ? raw.objective.trim() : "";
|
|
143
|
+
if (!objective) return null;
|
|
144
|
+
|
|
145
|
+
const timestamp = nowIso();
|
|
146
|
+
const rawStatus = raw.status;
|
|
147
|
+
let status: GoalStatus = rawStatus === "complete" ? "complete" : rawStatus === "paused" ? "paused" : "active";
|
|
148
|
+
const autoContinue = typeof raw.autoContinue === "boolean" ? raw.autoContinue : true;
|
|
149
|
+
const usage = normalizeUsage(raw.usage);
|
|
150
|
+
const sisyphus = raw.sisyphus === true;
|
|
151
|
+
|
|
152
|
+
if (status === "paused" && autoContinue) {
|
|
153
|
+
status = "active";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
id: typeof raw.id === "string" && raw.id ? safeIdPart(raw.id) : newGoalId(),
|
|
158
|
+
objective,
|
|
159
|
+
status,
|
|
160
|
+
autoContinue,
|
|
161
|
+
usage,
|
|
162
|
+
sisyphus,
|
|
163
|
+
createdAt: typeof raw.createdAt === "string" ? raw.createdAt : timestamp,
|
|
164
|
+
updatedAt: typeof raw.updatedAt === "string" ? raw.updatedAt : timestamp,
|
|
165
|
+
activePath: typeof raw.activePath === "string" ? raw.activePath : undefined,
|
|
166
|
+
archivedPath: typeof raw.archivedPath === "string" ? raw.archivedPath : undefined,
|
|
167
|
+
stopReason: raw.stopReason === "agent" || raw.stopReason === "user" ? raw.stopReason : undefined,
|
|
168
|
+
pauseReason: typeof raw.pauseReason === "string" && raw.pauseReason.trim() ? raw.pauseReason : undefined,
|
|
169
|
+
pauseSuggestedAction: typeof raw.pauseSuggestedAction === "string" && raw.pauseSuggestedAction.trim() ? raw.pauseSuggestedAction : undefined,
|
|
170
|
+
};
|
|
171
|
+
}
|