pi-quests 0.2.0 → 0.4.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.
@@ -1,34 +1,49 @@
1
1
  export function formatQuestList(
2
- quests: { id: number; description: string; done: boolean }[],
2
+ quests: { id: string; description: string; done: boolean; parentId?: string }[],
3
3
  ): string {
4
4
  if (quests.length === 0) return "No quests.";
5
- return quests.map((q) => `#${q.id} [${q.done ? "x" : " "}] ${q.description}`).join("\n");
5
+
6
+ const lines: string[] = [];
7
+ let pos = 1;
8
+ for (const q of quests) {
9
+ if (q.parentId) {
10
+ lines.push(` [${q.id}] [${q.done ? "x" : " "}] ${q.description}`);
11
+ } else {
12
+ lines.push(`#${pos} [${q.id}] [${q.done ? "x" : " "}] ${q.description}`);
13
+ pos++;
14
+ }
15
+ }
16
+ return lines.join("\n");
17
+ }
18
+
19
+ export function formatAddResult(q: { id: string; description: string }): string {
20
+ return `Added quest [${q.id}]: ${q.description}`;
6
21
  }
7
22
 
8
- export function formatAddResult(q: { id: number; description: string }): string {
9
- return `Added quest #${q.id}: ${q.description}`;
23
+ export function formatBatchAddResult(added: { id: string; description: string }[]): string {
24
+ return `Added ${added.length} quests:\n${added.map((q) => `[${q.id}]: ${q.description}`).join("\n")}`;
10
25
  }
11
26
 
12
- export function formatBatchAddResult(added: { id: number; description: string }[]): string {
13
- return `Added ${added.length} quests:\n${added.map((q) => `#${q.id}: ${q.description}`).join("\n")}`;
27
+ export function formatToggleResult(id: string, done: boolean): string {
28
+ return `Quest [${id}] ${done ? "done" : "undone"}`;
14
29
  }
15
30
 
16
- export function formatToggleResult(id: number, done: boolean): string {
17
- return `Quest #${id} ${done ? "done" : "undone"}`;
31
+ export function formatUpdateResult(q: { id: string; description: string }): string {
32
+ return `Updated quest [${q.id}]: ${q.description}`;
18
33
  }
19
34
 
20
- export function formatUpdateResult(q: { id: number; description: string }): string {
21
- return `Updated quest #${q.id}: ${q.description}`;
35
+ export function formatDeleteResult(q: { id: string; description: string }): string {
36
+ return `Deleted quest [${q.id}]: ${q.description}`;
22
37
  }
23
38
 
24
- export function formatDeleteResult(q: { id: number; description: string }): string {
25
- return `Deleted quest #${q.id}: ${q.description}`;
39
+ export function formatNotFound(id: string): string {
40
+ return `Quest [${id}] not found`;
26
41
  }
27
42
 
28
- export function formatClearResult(count: number): string {
29
- return `Cleared ${count} quests`;
43
+ export function formatBlockedBySubQuests(id: string): string {
44
+ return `Quest [${id}] has incomplete sub-quests`;
30
45
  }
31
46
 
32
- export function formatNotFound(id: number): string {
33
- return `Quest #${id} not found`;
47
+ export function formatSubQuestCannotHaveSubQuests(id: string): string {
48
+ return `Sub-quest [${id}] cannot have nested sub-quests`;
34
49
  }
@@ -1,20 +1,7 @@
1
- export const COMPLEX_TASK_KEYWORDS = [
2
- "implement",
3
- "refactor",
4
- "investigate",
5
- "review",
6
- "analyze",
7
- "audit",
8
- "plan",
9
- "design",
10
- "create",
11
- "build",
12
- "write",
13
- "fix",
14
- ] as const;
1
+ import type { ResolvedConfig } from "../config.js";
15
2
 
16
3
  const ACKNOWLEDGEMENT =
17
- "ALWAYS acknowledge this reminder and create, update, or align on quests before making further tool calls.";
4
+ "ALWAYS acknowledge this reminder immediately and create, update, or align on quests before making further tool calls. DO NOT add this acknowledgement as another quest.";
18
5
 
19
6
  export class QuestUsageTracker {
20
7
  private totalToolCalls = 0;
@@ -23,6 +10,8 @@ export class QuestUsageTracker {
23
10
  private lastQuestToolTime = 0;
24
11
  private nudgedThisTurn = false;
25
12
 
13
+ constructor(private readonly config: ResolvedConfig) {}
14
+
26
15
  onToolExecution(toolName: string): void {
27
16
  this.totalToolCalls++;
28
17
  if (toolName === "quest") {
@@ -38,11 +27,15 @@ export class QuestUsageTracker {
38
27
  this.nudgedThisTurn = false;
39
28
  }
40
29
 
41
- getNudge(activeQuestCount: number, latestPrompt?: string): string | undefined {
30
+ getNudge(
31
+ activeQuestCount: number,
32
+ latestPrompt?: string,
33
+ hasTopLevelQuestWithoutSubs?: boolean,
34
+ ): string | undefined {
42
35
  if (this.nudgedThisTurn) return undefined;
43
36
 
44
37
  // 1. Initialization nudge
45
- if (this.totalToolCalls >= 3 && !this.hasEverUsedQuestTool) {
38
+ if (this.totalToolCalls >= this.config.nudges.toolCallThreshold && !this.hasEverUsedQuestTool) {
46
39
  this.nudgedThisTurn = true;
47
40
  return `QUEST REMINDER: You have made ${this.totalToolCalls} tool calls but have NEVER used the quest tool this session. USE the quest tool to initialize tracking and break your work into concrete steps. ${ACKNOWLEDGEMENT}`;
48
41
  }
@@ -57,24 +50,40 @@ export class QuestUsageTracker {
57
50
  if (
58
51
  this.hasEverUsedQuestTool &&
59
52
  this.lastQuestToolTime > 0 &&
60
- this.consecutiveNonQuestToolCalls >= 3 &&
61
- // 8 minutes without quest tool use
62
- Date.now() - this.lastQuestToolTime >= 8 * 60 * 1000
53
+ this.consecutiveNonQuestToolCalls >= this.config.nudges.timeBasedToolCallThreshold &&
54
+ Date.now() - this.lastQuestToolTime >= this.config.nudges.hintIntervalMinutes * 60 * 1000
63
55
  ) {
64
56
  this.nudgedThisTurn = true;
65
57
  return `QUEST REMINDER: It has been a while since your last quest tool use and ${this.consecutiveNonQuestToolCalls} tools have been called since then. ALIGN on quest status before continuing. ${ACKNOWLEDGEMENT}`;
66
58
  }
67
59
 
68
60
  // 4. Zero-active sustained-work nudge
69
- if (this.consecutiveNonQuestToolCalls >= 5 && activeQuestCount === 0) {
61
+ if (
62
+ this.consecutiveNonQuestToolCalls >= this.config.nudges.zeroActiveToolCallThreshold &&
63
+ activeQuestCount === 0
64
+ ) {
70
65
  this.nudgedThisTurn = true;
71
66
  return `QUEST REMINDER: You have made ${this.consecutiveNonQuestToolCalls} consecutive tool calls without using the quest tool and there are 0 active quests. TRACK your work with specific, actionable quests. ${ACKNOWLEDGEMENT}`;
72
67
  }
73
68
 
74
- // 5. Stale-progress sustained-work nudge
75
- if (this.consecutiveNonQuestToolCalls >= 10 && activeQuestCount > 0) {
69
+ // 5. Sub-quest suggestion nudge
70
+ if (
71
+ this.hasEverUsedQuestTool &&
72
+ this.consecutiveNonQuestToolCalls >= this.config.nudges.subQuestSuggestionToolCallThreshold &&
73
+ activeQuestCount > 0 &&
74
+ hasTopLevelQuestWithoutSubs
75
+ ) {
76
+ this.nudgedThisTurn = true;
77
+ return `QUEST REMINDER: You have made ${this.consecutiveNonQuestToolCalls} consecutive tool calls without using the quest tool and have active top-level quests without sub-quests. Use the \`add\` action with \`parentId\` to break down complex tasks into smaller, trackable steps. ${ACKNOWLEDGEMENT}`;
78
+ }
79
+
80
+ // 6. Stale-progress sustained-work nudge
81
+ if (
82
+ this.consecutiveNonQuestToolCalls >= this.config.nudges.staleProgressToolCallThreshold &&
83
+ activeQuestCount > 0
84
+ ) {
76
85
  this.nudgedThisTurn = true;
77
- return `QUEST REMINDER: You have made ${this.consecutiveNonQuestToolCalls} consecutive tool calls without using the quest tool despite having active quests. UPDATE your quest progress to reflect current status. ${ACKNOWLEDGEMENT}`;
86
+ return `QUEST REMINDER: You have made ${this.consecutiveNonQuestToolCalls} consecutive tool calls without using the quest tool despite having active quests.\nUPDATE your quest progress to reflect current status.\nALWAYS use sub quests to break down a quest into smaller steps, and to group related tasks together. ${ACKNOWLEDGEMENT}`;
78
87
  }
79
88
 
80
89
  return undefined;
@@ -82,6 +91,6 @@ export class QuestUsageTracker {
82
91
 
83
92
  private isComplexPrompt(prompt: string): boolean {
84
93
  const lower = prompt.toLowerCase();
85
- return COMPLEX_TASK_KEYWORDS.some((kw) => lower.includes(kw));
94
+ return this.config.nudges.complexTaskKeywords.some((kw) => lower.includes(kw));
86
95
  }
87
96
  }
@@ -9,6 +9,7 @@ export const QUEST_ACTIONS = {
9
9
  update: "update",
10
10
  delete: "delete",
11
11
  clear: "clear",
12
+ reorder: "reorder",
12
13
  revert: "revert",
13
14
  } as const;
14
15
 
@@ -19,15 +20,19 @@ export const QUEST_ACTION_VALUES = [
19
20
  QUEST_ACTIONS.update,
20
21
  QUEST_ACTIONS.delete,
21
22
  QUEST_ACTIONS.clear,
23
+ QUEST_ACTIONS.reorder,
22
24
  QUEST_ACTIONS.revert,
23
25
  ] as const;
24
26
 
25
27
  export type QuestActionType = (typeof QUEST_ACTION_VALUES)[number];
26
28
 
27
29
  export interface Quest {
28
- id: number;
30
+ id: string;
29
31
  description: string;
30
- additionalContext?: string;
31
32
  done: boolean;
32
33
  createdAt: number;
33
34
  }
35
+
36
+ export interface SubQuest extends Quest {
37
+ parentId: string;
38
+ }
@@ -1,20 +1,22 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
3
+ import type { ResolvedConfig } from "../config.js";
3
4
  import { logger } from "../logger.js";
4
- import type { Quest } from "../quest/types.js";
5
+ import type { QuestLog } from "../quest/dataplane.js";
6
+ import { formatQuestRow, formatSubQuestSpacerLine } from "./quests.js";
5
7
 
6
8
  export class QuestListWidget {
7
9
  private cachedWidth?: number;
8
10
  private cachedLines?: string[];
9
11
  private page = 0;
10
- private readonly pageSize = 10;
11
12
 
12
13
  constructor(
13
- private quests: Quest[],
14
+ private questLog: QuestLog,
14
15
  private theme: Theme,
15
16
  private onClose: () => void,
17
+ private readonly config: ResolvedConfig,
16
18
  ) {
17
- logger.debug("quests:widget", "create", { questCount: quests.length });
19
+ logger.debug("quests:widget", "create", { questCount: questLog.getAll().length });
18
20
  }
19
21
 
20
22
  handleInput(data: string): void {
@@ -37,7 +39,35 @@ export class QuestListWidget {
37
39
  }
38
40
 
39
41
  private get totalPages(): number {
40
- return Math.max(1, Math.ceil(this.quests.length / this.pageSize));
42
+ const totalLines = this.buildQuestLines(0).length;
43
+ return Math.max(1, Math.ceil(totalLines / this.config.display.pageSize));
44
+ }
45
+
46
+ private buildQuestLines(width: number): string[] {
47
+ const th = this.theme;
48
+ const parents = this.questLog.getQuests();
49
+ const lines: string[] = [];
50
+
51
+ for (let i = 0; i < parents.length; i++) {
52
+ const parent = parents[i];
53
+ const subs = this.questLog.getSubQuests(parent.id);
54
+
55
+ const row = formatQuestRow(th, parent, this.config.ids.length, i + 1);
56
+ lines.push(width > 0 ? truncateToWidth(row, width) : row);
57
+
58
+ if (subs.length > 0) {
59
+ lines.push(formatSubQuestSpacerLine(th, this.config.ids.length));
60
+
61
+ for (const sub of subs) {
62
+ const subRow = formatQuestRow(th, sub, this.config.ids.length);
63
+ lines.push(width > 0 ? truncateToWidth(subRow, width) : subRow);
64
+ }
65
+
66
+ lines.push("");
67
+ }
68
+ }
69
+
70
+ return lines;
41
71
  }
42
72
 
43
73
  private nextPage(): void {
@@ -72,13 +102,14 @@ export class QuestListWidget {
72
102
  width,
73
103
  page: this.page,
74
104
  totalPages: this.totalPages,
75
- totalQuests: this.quests.length,
105
+ totalQuests: this.questLog.getAll().length,
76
106
  });
77
107
 
78
108
  const th = this.theme;
79
109
  const lines: string[] = [];
80
- const total = this.quests.length;
81
- const doneCount = this.quests.filter((q) => q.done).length;
110
+ const allQuests = this.questLog.getAll();
111
+ const total = allQuests.length;
112
+ const doneCount = allQuests.filter((q) => q.done).length;
82
113
 
83
114
  // Top accent border
84
115
  lines.push(th.fg("accent", "─".repeat(width)));
@@ -91,7 +122,7 @@ export class QuestListWidget {
91
122
 
92
123
  lines.push("");
93
124
 
94
- if (this.quests.length === 0) {
125
+ if (total === 0) {
95
126
  lines.push(
96
127
  truncateToWidth(
97
128
  ` ${th.fg("dim", "No active quests. Add one with /quests add <description>")}`,
@@ -100,7 +131,7 @@ export class QuestListWidget {
100
131
  );
101
132
  } else {
102
133
  // Mini progress bar
103
- const barWidth = Math.min(width - 4, 24);
134
+ const barWidth = Math.min(width - 4, this.config.display.progressBarMaxWidth);
104
135
  const filled = Math.round((doneCount / total) * barWidth);
105
136
  const empty = barWidth - filled;
106
137
  const bar = th.fg("success", "█".repeat(filled)) + th.fg("dim", "░".repeat(empty));
@@ -108,18 +139,11 @@ export class QuestListWidget {
108
139
  lines.push(truncateToWidth(` ${bar} ${th.fg("muted", `${doneCount}/${total}`)}`, width));
109
140
  lines.push("");
110
141
 
111
- const start = this.page * this.pageSize;
112
- const pageQuests = this.quests.slice(start, start + this.pageSize);
113
- for (const q of pageQuests) {
114
- const marker = q.done ? th.fg("success", " ✓ ") : th.fg("muted", " ○ ");
115
- const idStr = th.fg(q.done ? "dim" : "accent", `#${q.id}`);
116
- const desc = q.done
117
- ? th.fg("dim", th.strikethrough(q.description))
118
- : th.fg("text", q.description);
119
- const row = `${marker}${idStr} ${desc}`;
120
-
121
- lines.push(truncateToWidth(row, width));
122
- }
142
+ const questLines = this.buildQuestLines(width);
143
+ const start = this.page * this.config.display.pageSize;
144
+ const pageLines = questLines.slice(start, start + this.config.display.pageSize);
145
+
146
+ lines.push(...pageLines);
123
147
 
124
148
  if (this.totalPages > 1) {
125
149
  const pageInfo = ` Page ${this.page + 1}/${this.totalPages} ${th.fg("dim", "· Tab/Shift+Tab to navigate")}`;
@@ -0,0 +1,45 @@
1
+ import type { Theme } from "@mariozechner/pi-coding-agent";
2
+ import { visibleWidth } from "@mariozechner/pi-tui";
3
+ import type { Quest, SubQuest } from "../quest/types.js";
4
+
5
+ export function formatQuestRow(
6
+ theme: Theme,
7
+ q: Quest | SubQuest,
8
+ idLength: number,
9
+ pos?: number,
10
+ ): string {
11
+ const isSub = "parentId" in q && q.parentId;
12
+
13
+ // position test is only relevant for quests, not subquests
14
+ const posText = isSub ? "" : pos !== undefined ? `#${pos}` : "";
15
+ const posWidth = visibleWidth(posText);
16
+ const idText = `[${q.id}]`;
17
+
18
+ // spacing is added to keep text aligned
19
+ const spacing = " ".repeat(visibleWidth(`${16 ** idLength}`) - posWidth);
20
+ const idStr = `${theme.fg("muted", idText)} ${theme.fg(q.done ? "dim" : "accent", `${posText}`)}${spacing}`;
21
+
22
+ // for subquests, use a different marker and indent
23
+ const markerNotDone = isSub ? theme.fg("muted", " └── ○ ") : theme.fg("muted", " ○ ");
24
+ const markerDone = isSub ? theme.fg("muted", " └── ✓ ") : theme.fg("success", " ✓ ");
25
+ const marker = q.done ? markerDone : markerNotDone;
26
+
27
+ // for subquests, use dim text to contrast with the parent quests
28
+ const descColor = q.done || isSub ? "dim" : "text";
29
+ const desc = q.done
30
+ ? theme.fg(descColor, theme.strikethrough(q.description))
31
+ : theme.fg(descColor, q.description);
32
+
33
+ const line = ` ${idStr}${marker} ${desc}`;
34
+ return line;
35
+ }
36
+
37
+ export function formatSubQuestSpacerLine(theme: Theme, idLength: number): string {
38
+ const idTextLength = visibleWidth(`${16 ** idLength}`);
39
+ // spacing for `[id](position string)`
40
+ const spacerStr = `${" ".repeat(idTextLength + 2)}${" ".repeat(idTextLength)}`;
41
+
42
+ const marker = theme.fg("muted", " │ ");
43
+
44
+ return ` ${spacerStr}${marker}`;
45
+ }
@@ -1,18 +1,26 @@
1
1
  import type { AgentToolResult, Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { Text } from "@mariozechner/pi-tui";
3
+ import type { ResolvedConfig } from "../config.js";
3
4
  import { logger } from "../logger.js";
5
+ import type { Quest } from "../quest/types.js";
4
6
  import { QUEST_ACTIONS } from "../quest/types.js";
7
+ import { formatQuestRow, formatSubQuestSpacerLine } from "./quests.js";
5
8
 
6
9
  type QuestArgs = {
7
10
  action: string;
8
11
  descriptions?: string[];
9
- id?: number;
12
+ targetId?: string;
13
+ id?: string;
14
+ all?: boolean;
10
15
  };
11
16
 
12
17
  export function renderQuestCall(args: QuestArgs, theme: Theme, _context: unknown): Text {
13
18
  const id = "id" in args ? args.id : undefined;
14
19
  logger.debug("quests:tool", "renderCall", { action: args.action, id });
15
- const actionText = theme.fg("toolTitle", theme.bold("quest ")) + theme.fg("accent", args.action);
20
+ const actionText =
21
+ theme.fg("toolTitle", theme.bold("quest ")) +
22
+ theme.fg("accent", args.action) +
23
+ theme.fg("muted", args.targetId ? ` ${args.targetId}` : "");
16
24
 
17
25
  if (args.action === QUEST_ACTIONS.add && args.descriptions && args.descriptions.length > 0) {
18
26
  const n = args.descriptions.length;
@@ -29,36 +37,82 @@ export function renderQuestCall(args: QuestArgs, theme: Theme, _context: unknown
29
37
  args.action === QUEST_ACTIONS.delete) &&
30
38
  args.id !== undefined
31
39
  ) {
32
- return new Text(`${actionText} ${theme.fg("muted", `#${args.id}`)}`, 0, 0);
40
+ return new Text(`${actionText} ${theme.fg("muted", `[${args.id}]`)}`, 0, 0);
41
+ }
42
+
43
+ if (args.action === QUEST_ACTIONS.clear) {
44
+ return new Text(`${actionText} ${theme.fg("muted", `[${args.all ? "all" : "done"}]`)}`, 0, 0);
33
45
  }
34
46
 
35
47
  return new Text(actionText, 0, 0);
36
48
  }
37
49
 
38
- export function renderQuestResult(
39
- result: AgentToolResult<unknown>,
40
- options: { expanded: boolean; isPartial: boolean },
41
- theme: Theme,
42
- _context: unknown,
43
- ): Text {
44
- logger.debug("quests:tool", "renderResult", {
45
- expanded: options.expanded,
46
- isPartial: options.isPartial,
47
- });
48
- const details = result.details as Record<string, unknown> | undefined;
49
- const questsToRender = details?.displayQuests ?? details?.quests;
50
-
51
- if (Array.isArray(questsToRender) && questsToRender.length > 0) {
52
- const lines = (questsToRender as Array<{ id: number; description: string; done: boolean }>).map(
53
- (q) => {
54
- const marker = q.done ? theme.fg("success", "✓") : theme.fg("dim", "○");
55
- return `${marker} ${theme.fg("text", `#${q.id}`)} ${theme.fg("muted", q.description)}`;
56
- },
57
- );
50
+ export function renderQuestResult(config: ResolvedConfig) {
51
+ return (
52
+ result: AgentToolResult<unknown>,
53
+ options: { expanded: boolean; isPartial: boolean },
54
+ theme: Theme,
55
+ _context: unknown,
56
+ ): Text => {
57
+ logger.debug("quests:tool", "renderResult", {
58
+ expanded: options.expanded,
59
+ isPartial: options.isPartial,
60
+ });
61
+ const details = result.details as Record<string, unknown> | undefined;
62
+ const allQuests = details?.quests as Quest[] | undefined;
63
+ const questsToRender = (details?.displayQuests ?? details?.quests) as Quest[] | undefined;
58
64
 
59
- return new Text(lines.join("\n"), 0, 0);
60
- }
65
+ if (
66
+ !Array.isArray(questsToRender) ||
67
+ questsToRender.length === 0 ||
68
+ !Array.isArray(allQuests)
69
+ ) {
70
+ const text = result.content.map((c) => (c.type === "text" ? c.text : "[image]")).join("\n");
71
+ return new Text(theme.fg("text", text), 0, 0);
72
+ }
73
+
74
+ const quests = allQuests;
75
+ const renderedIds = new Set(questsToRender.map((q) => q.id));
76
+ const parents = quests.filter((q) => !(q as Quest & { parentId?: string }).parentId);
77
+ const lines: string[] = [];
78
+
79
+ function getSubQuests(parentId: string) {
80
+ return quests.filter((q) => (q as Quest & { parentId?: string }).parentId === parentId);
81
+ }
82
+
83
+ function willRenderLater(idx: number): boolean {
84
+ for (let j = idx + 1; j < parents.length; j++) {
85
+ const p = parents[j];
86
+ if (renderedIds.has(p.id)) return true;
61
87
 
62
- const text = result.content.map((c) => (c.type === "text" ? c.text : "[image]")).join("\n");
63
- return new Text(theme.fg("text", text), 0, 0);
88
+ const subs = getSubQuests(p.id).filter((sq) => renderedIds.has(sq.id));
89
+ if (subs.length > 0) return true;
90
+ }
91
+
92
+ return false;
93
+ }
94
+
95
+ for (let i = 0; i < parents.length; i++) {
96
+ const parent = parents[i];
97
+ const parentIncluded = renderedIds.has(parent.id);
98
+ const includedSubs = getSubQuests(parent.id).filter((sq) => renderedIds.has(sq.id));
99
+
100
+ if (!parentIncluded && includedSubs.length === 0) continue;
101
+
102
+ if (parentIncluded) {
103
+ lines.push(formatQuestRow(theme, parent, config.ids.length, i + 1));
104
+ if (includedSubs.length > 0) lines.push(formatSubQuestSpacerLine(theme, config.ids.length));
105
+ }
106
+
107
+ for (const sub of includedSubs) {
108
+ lines.push(formatQuestRow(theme, sub, config.ids.length));
109
+ }
110
+
111
+ if (includedSubs.length > 0 && willRenderLater(i)) {
112
+ lines.push("");
113
+ }
114
+ }
115
+
116
+ return new Text(lines.join("\n"), 0, 0);
117
+ };
64
118
  }
@@ -1,12 +1,14 @@
1
1
  import type { AgentToolResult, ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
2
  import type { Static } from "@sinclair/typebox";
3
+ import type { ResolvedConfig } from "../config.js";
3
4
  import { logger } from "../logger.js";
5
+ import { QUEST_PROMPT_GATE, QUEST_PROMPT_REMINDER } from "../prompts.js";
4
6
  import { makeToolResult, type QuestAction, type QuestLog } from "../quest/dataplane.js";
5
7
  import { QUEST_ACTIONS } from "../quest/types.js";
6
8
  import { renderQuestCall, renderQuestResult } from "../renderers/tools.js";
7
- import { QuestParams } from "./params.js";
9
+ import { createQuestParams, type QuestParamsType } from "./params.js";
8
10
 
9
- type QuestToolParams = Static<typeof QuestParams>;
11
+ type QuestToolParams = Static<QuestParamsType>;
10
12
 
11
13
  const toolHandlers: {
12
14
  [K in QuestToolParams["action"]]: (
@@ -19,6 +21,7 @@ const toolHandlers: {
19
21
  return runTool(questLog, toolCallId, {
20
22
  type: QUEST_ACTIONS.add,
21
23
  descriptions: params.descriptions,
24
+ parentId: params.parentId,
22
25
  });
23
26
  },
24
27
  [QUEST_ACTIONS.list](questLog, _params, toolCallId) {
@@ -37,8 +40,15 @@ const toolHandlers: {
37
40
  [QUEST_ACTIONS.delete](questLog, params, toolCallId) {
38
41
  return runTool(questLog, toolCallId, { type: QUEST_ACTIONS.delete, id: params.id });
39
42
  },
40
- [QUEST_ACTIONS.clear](questLog, _params, toolCallId) {
41
- return runTool(questLog, toolCallId, { type: QUEST_ACTIONS.clear });
43
+ [QUEST_ACTIONS.clear](questLog, params, toolCallId) {
44
+ return runTool(questLog, toolCallId, { type: QUEST_ACTIONS.clear, all: params.all });
45
+ },
46
+ [QUEST_ACTIONS.reorder](questLog, params, toolCallId) {
47
+ return runTool(questLog, toolCallId, {
48
+ type: QUEST_ACTIONS.reorder,
49
+ id: params.id,
50
+ targetId: params.targetId,
51
+ });
42
52
  },
43
53
  [QUEST_ACTIONS.revert](questLog, _params, toolCallId) {
44
54
  return runTool(questLog, toolCallId, { type: QUEST_ACTIONS.revert });
@@ -75,26 +85,24 @@ export async function questToolExecute(
75
85
  return handler(questLog, params, toolCallId);
76
86
  }
77
87
 
78
- export function registerQuestTool(pi: ExtensionAPI, questLog: QuestLog): void {
88
+ export function registerQuestTool(
89
+ pi: ExtensionAPI,
90
+ questLog: QuestLog,
91
+ config: ResolvedConfig,
92
+ ): void {
79
93
  logger.debug("quests:tool", "register");
80
94
  pi.registerTool({
81
95
  name: "quest",
82
96
  label: "Quest",
83
97
  description:
84
- "Manage the session quest log. Use this VERY frequently to track tasks, plans, and progress throughout the conversation.",
85
- promptSnippet: "Add, list, toggle, update, delete, clear, or revert quest items",
86
- promptGuidelines: [
87
- "Before reading files, running commands, or making edits, ensure the current work is tracked as specific, actionable quests.",
88
- "Do not create a single vague quest for broad requests. Break them into concrete, independent steps.",
89
- "When the user gives a plan or a list of tasks, add them as quests immediately.",
90
- "It is critical that you toggle quests to done as soon as you complete them. Do NOT batch completions.",
91
- "Before delegating to a minion, add a quest for the delegated task.",
92
- "If you are unsure what to do next, use the list action to check active quests.",
93
- ],
94
- parameters: QuestParams,
98
+ "Manage the session quest log including top-level quests and sub-quests. Use this VERY frequently to track tasks, plans, and progress throughout the conversation.",
99
+ promptSnippet:
100
+ "Add (with optional parentId for sub-quests), list, toggle, update, delete, clear, or revert quest items",
101
+ promptGuidelines: [...QUEST_PROMPT_GATE, ...QUEST_PROMPT_REMINDER],
102
+ parameters: createQuestParams(config.ids.length),
95
103
  execute: (toolCallId, params, _signal, _onUpdate, _ctx) =>
96
104
  questToolExecute(questLog, toolCallId, params),
97
105
  renderCall: renderQuestCall,
98
- renderResult: renderQuestResult,
106
+ renderResult: renderQuestResult(config),
99
107
  });
100
108
  }
@@ -2,23 +2,48 @@ import { StringEnum } from "@mariozechner/pi-ai";
2
2
  import { Type } from "@sinclair/typebox";
3
3
  import { QUEST_ACTION_VALUES } from "../quest/types.js";
4
4
 
5
- export const QuestParams = Type.Object({
6
- action: StringEnum(QUEST_ACTION_VALUES, {
7
- description: "The quest action to perform",
8
- }),
9
- descriptions: Type.Optional(
10
- Type.Array(Type.String(), {
11
- description: "Array of quest descriptions (required for add action)",
5
+ export function createQuestParams(idLength: number) {
6
+ const pattern = `^[0-9a-f]{${idLength}}$`;
7
+ return Type.Object({
8
+ action: StringEnum(QUEST_ACTION_VALUES, {
9
+ description: "The quest action to perform",
12
10
  }),
13
- ),
14
- description: Type.Optional(
15
- Type.String({
16
- description: "New description (required for update action)",
17
- }),
18
- ),
19
- id: Type.Optional(
20
- Type.Number({
21
- description: "Quest ID (required for toggle, update, delete actions)",
22
- }),
23
- ),
24
- });
11
+ descriptions: Type.Optional(
12
+ Type.Array(Type.String(), {
13
+ description: "Array of quest descriptions (required for add action)",
14
+ }),
15
+ ),
16
+ description: Type.Optional(
17
+ Type.String({
18
+ description: "New description (required for update action)",
19
+ }),
20
+ ),
21
+ parentId: Type.Optional(
22
+ Type.String({
23
+ pattern,
24
+ description:
25
+ "Parent quest hex ID. Use this to break a large task into smaller, trackable steps. When provided, the added quest becomes a sub-quest under the parent.",
26
+ }),
27
+ ),
28
+ id: Type.Optional(
29
+ Type.String({
30
+ pattern,
31
+ description: `Quest ID (required for toggle, update, delete, reorder actions). ALWAYS use the ${idLength}-digit hex ID shown in brackets, never the positional number.`,
32
+ }),
33
+ ),
34
+ targetId: Type.Optional(
35
+ Type.String({
36
+ pattern,
37
+ description:
38
+ "Target quest ID for reorder action. The quest will be moved to just before the target quest.",
39
+ }),
40
+ ),
41
+ all: Type.Optional(
42
+ Type.Boolean({
43
+ description: "Clear all quests when true (defaults to clearing only completed quests)",
44
+ }),
45
+ ),
46
+ });
47
+ }
48
+
49
+ export type QuestParamsType = ReturnType<typeof createQuestParams>;