c0de-agent 1.5.0 → 1.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/dist/core/agent.js +4 -0
- package/dist/core/config.js +1 -1
- package/dist/core/index.d.ts +1 -1
- package/dist/core/loop.js +9 -0
- package/dist/core/prompt-registry.d.ts +2 -2
- package/dist/core/prompt-registry.js +42 -3
- package/dist/core/slash.js +40 -6
- package/dist/core/types.d.ts +10 -1
- package/dist/core/workflow.d.ts +1 -1
- package/dist/core/workflow.js +54 -6
- package/dist/core/workflows/runtime.d.ts +3 -0
- package/dist/core/workflows/runtime.js +2 -2
- package/dist/project/resolve.d.ts +75 -0
- package/dist/project/resolve.js +253 -1
- package/dist/server/app.js +3 -0
- package/dist/server/context.js +2 -1
- package/dist/server/dev.js +3 -1
- package/dist/server/routes/chat.js +12 -0
- package/dist/server/routes/commands.js +1 -0
- package/dist/server/routes/files.js +252 -4
- package/dist/server/routes/terminal.js +2 -1
- package/dist/server/routes/todo.d.ts +4 -0
- package/dist/server/routes/todo.js +107 -0
- package/dist/server/routes/workflows.js +51 -11
- package/dist/server/server.d.ts +8 -1
- package/dist/server/server.js +113 -23
- package/dist/server/terminal/pty-manager.d.ts +14 -0
- package/dist/server/terminal/pty-manager.js +105 -7
- package/dist/shared/types/agent.d.ts +9 -0
- package/dist/shared/types/config.d.ts +6 -0
- package/dist/shared/types/tool.d.ts +13 -0
- package/dist/tools/builtin/todo.d.ts +67 -0
- package/dist/tools/builtin/todo.js +517 -0
- package/dist/tools/index.d.ts +2 -0
- package/dist/tools/index.js +3 -0
- package/dist/tools/types.d.ts +32 -1
- package/package.json +2 -1
|
@@ -206,6 +206,15 @@ type AgentState = {
|
|
|
206
206
|
tokenBudget: TokenBudget;
|
|
207
207
|
/** estimateTokens 的校准系数(由 calibrateEstimate 按真实 usage EMA 更新,默认 1.0)。 */
|
|
208
208
|
calibrationFactor: number;
|
|
209
|
+
/** 分阶段任务列表(todo 工具状态)。in-memory,通过 tool result metadata 持久化。
|
|
210
|
+
* createAgent 时从历史消息恢复;每次 todo 工具调用通过 todoState hook 更新。 */
|
|
211
|
+
todoPhases: {
|
|
212
|
+
name: string;
|
|
213
|
+
tasks: {
|
|
214
|
+
content: string;
|
|
215
|
+
status: string;
|
|
216
|
+
}[];
|
|
217
|
+
}[];
|
|
209
218
|
compactionModel?: {
|
|
210
219
|
provider: string;
|
|
211
220
|
model: string;
|
|
@@ -91,6 +91,12 @@ type Config = {
|
|
|
91
91
|
retryDelay: number;
|
|
92
92
|
};
|
|
93
93
|
compaction: CompactionConfig;
|
|
94
|
+
/** 一键提交使用的独立模型。未设置时回退到 defaultProvider/defaultModel。
|
|
95
|
+
* commit message 生成对推理能力要求低,可指定便宜/快速模型以降低成本。 */
|
|
96
|
+
commitModel?: {
|
|
97
|
+
provider: string;
|
|
98
|
+
model: string;
|
|
99
|
+
};
|
|
94
100
|
tools: {
|
|
95
101
|
enabled: string[];
|
|
96
102
|
disabled: string[];
|
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { JSONSchema, SessionRef } from './base.js';
|
|
2
|
+
export type TodoPhaseLike = {
|
|
3
|
+
name: string;
|
|
4
|
+
tasks: {
|
|
5
|
+
content: string;
|
|
6
|
+
status: string;
|
|
7
|
+
}[];
|
|
8
|
+
};
|
|
2
9
|
/** Permission level for tool execution. */
|
|
3
10
|
type ToolPermission = 'auto' | 'ask' | 'deny';
|
|
4
11
|
/** Result of a tool execution. Discriminated by `_tag`. */
|
|
@@ -66,6 +73,12 @@ type ToolContext = {
|
|
|
66
73
|
debugSpawn?: (config: unknown) => DebugTransport;
|
|
67
74
|
/** 子 agent 专用:yield 工具调用时收集结构化结果(runSubAgent 注入)。 */
|
|
68
75
|
collectYield?: (data: unknown) => void;
|
|
76
|
+
/** Todo state accessor (dependency-reversal for the `todo` tool).
|
|
77
|
+
* Host (agent loop) injects get/set backed by AgentState.todoPhases. */
|
|
78
|
+
todoState?: {
|
|
79
|
+
get: () => TodoPhaseLike[];
|
|
80
|
+
set: (phases: TodoPhaseLike[]) => void;
|
|
81
|
+
};
|
|
69
82
|
};
|
|
70
83
|
/** 单个并行子任务项(批量模式)。 */
|
|
71
84
|
type TaskItem = {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ToolDef } from '../../shared/types/tool.js';
|
|
2
|
+
type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'abandoned';
|
|
3
|
+
type TodoItem = {
|
|
4
|
+
content: string;
|
|
5
|
+
status: TodoStatus;
|
|
6
|
+
};
|
|
7
|
+
type TodoPhase = {
|
|
8
|
+
name: string;
|
|
9
|
+
tasks: TodoItem[];
|
|
10
|
+
};
|
|
11
|
+
/** A single todo operation entry (the tool's input params). */
|
|
12
|
+
type TodoInput = {
|
|
13
|
+
op: 'init';
|
|
14
|
+
list?: {
|
|
15
|
+
phase: string;
|
|
16
|
+
items: string[];
|
|
17
|
+
}[];
|
|
18
|
+
phase?: string;
|
|
19
|
+
items?: string[];
|
|
20
|
+
} | {
|
|
21
|
+
op: 'start';
|
|
22
|
+
task: string;
|
|
23
|
+
} | {
|
|
24
|
+
op: 'done';
|
|
25
|
+
task?: string;
|
|
26
|
+
phase?: string;
|
|
27
|
+
} | {
|
|
28
|
+
op: 'drop';
|
|
29
|
+
task?: string;
|
|
30
|
+
phase?: string;
|
|
31
|
+
} | {
|
|
32
|
+
op: 'rm';
|
|
33
|
+
task?: string;
|
|
34
|
+
phase?: string;
|
|
35
|
+
} | {
|
|
36
|
+
op: 'append';
|
|
37
|
+
phase: string;
|
|
38
|
+
items: string[];
|
|
39
|
+
} | {
|
|
40
|
+
op: 'view';
|
|
41
|
+
};
|
|
42
|
+
/** Deep-clone phases (mutation-safe). */
|
|
43
|
+
export declare function clonePhases(phases: TodoPhase[]): TodoPhase[];
|
|
44
|
+
/** Return the active todo task, preferring in_progress over the first pending. */
|
|
45
|
+
export declare function nextActionableTask(phases: readonly TodoPhase[]): TodoItem | undefined;
|
|
46
|
+
/** Report whether `content` likely names the same work as any entry in
|
|
47
|
+
* `descriptions`. Normalize-then-equal first, with a substring fallback
|
|
48
|
+
* in either direction (≥6 char overlap on the contained side). */
|
|
49
|
+
export declare function todoMatchesAnyDescription(content: string, descriptions: readonly string[]): boolean;
|
|
50
|
+
/** Render todo phases as a Markdown checklist suitable for editing/copying. */
|
|
51
|
+
export declare function phasesToMarkdown(phases: TodoPhase[]): string;
|
|
52
|
+
/** Parse a Markdown checklist back into todo phases. */
|
|
53
|
+
export declare function markdownToPhases(md: string): {
|
|
54
|
+
phases: TodoPhase[];
|
|
55
|
+
errors: string[];
|
|
56
|
+
};
|
|
57
|
+
/** Extract the latest todo phases from stored messages (tool results).
|
|
58
|
+
* Scans backwards for the most recent `todo` tool result with phases metadata. */
|
|
59
|
+
export declare function getLatestTodoPhasesFromMessages(messages: {
|
|
60
|
+
role: string;
|
|
61
|
+
content: unknown[];
|
|
62
|
+
}[]): TodoPhase[];
|
|
63
|
+
/** todo tool: phased task tracking with 7 operations.
|
|
64
|
+
* Permission: auto (no side effects beyond session state).
|
|
65
|
+
* State is held in-memory via ctx.todoState hook (dependency-reversal). */
|
|
66
|
+
export declare const todoTool: ToolDef;
|
|
67
|
+
export type { TodoInput, TodoItem, TodoPhase, TodoStatus };
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
// todo tool: phased task tracking with 7 operations, fuzzy matching, and
|
|
2
|
+
// Markdown round-trip. Logic ported from oh-my-pi, adapted to c0de-agent's
|
|
3
|
+
// stateless ToolDef + ToolContext.todoState hook pattern.
|
|
4
|
+
// =============================================================================
|
|
5
|
+
// State helpers
|
|
6
|
+
// =============================================================================
|
|
7
|
+
function findTaskByContent(phases, content) {
|
|
8
|
+
for (const phase of phases) {
|
|
9
|
+
const task = phase.tasks.find((t) => t.content === content);
|
|
10
|
+
if (task)
|
|
11
|
+
return { task, phase };
|
|
12
|
+
}
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
function findPhaseByName(phases, name) {
|
|
16
|
+
return phases.find((phase) => phase.name === name);
|
|
17
|
+
}
|
|
18
|
+
function cloneTask(task) {
|
|
19
|
+
return { content: task.content, status: task.status };
|
|
20
|
+
}
|
|
21
|
+
/** Deep-clone phases (mutation-safe). */
|
|
22
|
+
export function clonePhases(phases) {
|
|
23
|
+
return phases.map((phase) => ({ name: phase.name, tasks: phase.tasks.map(cloneTask) }));
|
|
24
|
+
}
|
|
25
|
+
function todoTransitionKey(phase, content) {
|
|
26
|
+
return `${phase}\u0000${content}`;
|
|
27
|
+
}
|
|
28
|
+
function getCompletionTransitions(previous, updated) {
|
|
29
|
+
const previousStatuses = new Map();
|
|
30
|
+
for (const phase of previous) {
|
|
31
|
+
for (const task of phase.tasks) {
|
|
32
|
+
previousStatuses.set(todoTransitionKey(phase.name, task.content), task.status);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
const transitions = [];
|
|
36
|
+
for (const phase of updated) {
|
|
37
|
+
for (const task of phase.tasks) {
|
|
38
|
+
if (task.status !== 'completed')
|
|
39
|
+
continue;
|
|
40
|
+
const prev = previousStatuses.get(todoTransitionKey(phase.name, task.content));
|
|
41
|
+
if (prev && prev !== 'completed') {
|
|
42
|
+
transitions.push({ phase: phase.name, content: task.content });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return transitions;
|
|
47
|
+
}
|
|
48
|
+
/** Ensure at most one in_progress task: demote extras, or auto-promote the
|
|
49
|
+
* first pending task if none is in_progress. Mutates in place. */
|
|
50
|
+
function normalizeInProgressTask(phases) {
|
|
51
|
+
const orderedTasks = phases.flatMap((phase) => phase.tasks);
|
|
52
|
+
if (orderedTasks.length === 0)
|
|
53
|
+
return;
|
|
54
|
+
const inProgressTasks = orderedTasks.filter((task) => task.status === 'in_progress');
|
|
55
|
+
if (inProgressTasks.length > 1) {
|
|
56
|
+
for (const task of inProgressTasks.slice(1)) {
|
|
57
|
+
task.status = 'pending';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (inProgressTasks.length > 0)
|
|
61
|
+
return;
|
|
62
|
+
const firstPending = orderedTasks.find((task) => task.status === 'pending');
|
|
63
|
+
if (firstPending)
|
|
64
|
+
firstPending.status = 'in_progress';
|
|
65
|
+
}
|
|
66
|
+
/** Return the active todo task, preferring in_progress over the first pending. */
|
|
67
|
+
export function nextActionableTask(phases) {
|
|
68
|
+
let firstPending;
|
|
69
|
+
for (const phase of phases) {
|
|
70
|
+
for (const task of phase.tasks) {
|
|
71
|
+
if (task.status === 'in_progress')
|
|
72
|
+
return task;
|
|
73
|
+
if (!firstPending && task.status === 'pending')
|
|
74
|
+
firstPending = task;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return firstPending;
|
|
78
|
+
}
|
|
79
|
+
// =============================================================================
|
|
80
|
+
// Fuzzy match
|
|
81
|
+
// =============================================================================
|
|
82
|
+
/** Minimum overlap (after normalization) required for a substring match.
|
|
83
|
+
* Six chars admits single-word identifiers like "review" / "Sonnet" without
|
|
84
|
+
* admitting tiny common substrings like "test" / "fix". */
|
|
85
|
+
const TODO_DESCRIPTION_MIN_OVERLAP = 6;
|
|
86
|
+
function normalizeForTodoMatch(value) {
|
|
87
|
+
return value
|
|
88
|
+
.toLowerCase()
|
|
89
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
90
|
+
.trim();
|
|
91
|
+
}
|
|
92
|
+
/** Report whether `content` likely names the same work as any entry in
|
|
93
|
+
* `descriptions`. Normalize-then-equal first, with a substring fallback
|
|
94
|
+
* in either direction (≥6 char overlap on the contained side). */
|
|
95
|
+
export function todoMatchesAnyDescription(content, descriptions) {
|
|
96
|
+
const target = normalizeForTodoMatch(content);
|
|
97
|
+
if (!target)
|
|
98
|
+
return false;
|
|
99
|
+
for (const desc of descriptions) {
|
|
100
|
+
const candidate = normalizeForTodoMatch(desc);
|
|
101
|
+
if (!candidate)
|
|
102
|
+
continue;
|
|
103
|
+
if (target === candidate)
|
|
104
|
+
return true;
|
|
105
|
+
if (target.length >= TODO_DESCRIPTION_MIN_OVERLAP && candidate.includes(target))
|
|
106
|
+
return true;
|
|
107
|
+
if (candidate.length >= TODO_DESCRIPTION_MIN_OVERLAP && target.includes(candidate))
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
// =============================================================================
|
|
113
|
+
// Resolution helpers
|
|
114
|
+
// =============================================================================
|
|
115
|
+
function resolveTaskOrError(phases, content, errors) {
|
|
116
|
+
if (!content) {
|
|
117
|
+
errors.push('Missing task content');
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
const hit = findTaskByContent(phases, content);
|
|
121
|
+
if (!hit) {
|
|
122
|
+
if (/^task-\d+$/.test(content)) {
|
|
123
|
+
errors.push(`Task "${content}" not found. Tasks are referenced by content, not by IDs — pass the task's full text from the previous result.`);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
const totalTasks = phases.reduce((sum, phase) => sum + phase.tasks.length, 0);
|
|
127
|
+
const hint = totalTasks === 0 ? ' (todo list is empty — was it replaced or not yet created?)' : '';
|
|
128
|
+
errors.push(`Task "${content}" not found${hint}`);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return hit;
|
|
132
|
+
}
|
|
133
|
+
function resolvePhaseOrError(phases, name, errors) {
|
|
134
|
+
if (!name) {
|
|
135
|
+
errors.push('Missing phase name');
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
138
|
+
const phase = findPhaseByName(phases, name);
|
|
139
|
+
if (!phase)
|
|
140
|
+
errors.push(`Phase "${name}" not found`);
|
|
141
|
+
return phase;
|
|
142
|
+
}
|
|
143
|
+
function getTaskTargets(phases, entry, errors) {
|
|
144
|
+
if ('task' in entry && entry.task) {
|
|
145
|
+
const hit = resolveTaskOrError(phases, entry.task, errors);
|
|
146
|
+
return hit ? [hit.task] : [];
|
|
147
|
+
}
|
|
148
|
+
if ('phase' in entry && entry.phase) {
|
|
149
|
+
const phase = resolvePhaseOrError(phases, entry.phase, errors);
|
|
150
|
+
return phase ? [...phase.tasks] : [];
|
|
151
|
+
}
|
|
152
|
+
return phases.flatMap((phase) => phase.tasks);
|
|
153
|
+
}
|
|
154
|
+
// =============================================================================
|
|
155
|
+
// Operations
|
|
156
|
+
// =============================================================================
|
|
157
|
+
/** Phase name for `init` given a flat `items` list with no explicit `phase`. */
|
|
158
|
+
const DEFAULT_INIT_PHASE = 'Tasks';
|
|
159
|
+
function initPhases(entry, errors) {
|
|
160
|
+
// Models routinely flatten single-phase init into {op:"init", items:[...]}
|
|
161
|
+
// instead of the canonical list: [{phase, items}]. Accept that shape.
|
|
162
|
+
const list = entry.list ??
|
|
163
|
+
(entry.items && entry.items.length > 0
|
|
164
|
+
? [{ phase: entry.phase ?? DEFAULT_INIT_PHASE, items: entry.items }]
|
|
165
|
+
: undefined);
|
|
166
|
+
if (!list) {
|
|
167
|
+
errors.push('Missing list for init operation');
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const seenPhases = new Set();
|
|
171
|
+
const seenTasks = new Set();
|
|
172
|
+
for (const listEntry of list) {
|
|
173
|
+
if (seenPhases.has(listEntry.phase)) {
|
|
174
|
+
errors.push(`Duplicate phase "${listEntry.phase}" in init list`);
|
|
175
|
+
}
|
|
176
|
+
seenPhases.add(listEntry.phase);
|
|
177
|
+
for (const content of listEntry.items) {
|
|
178
|
+
if (seenTasks.has(content)) {
|
|
179
|
+
errors.push(`Duplicate task "${content}" in init list`);
|
|
180
|
+
}
|
|
181
|
+
seenTasks.add(content);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
return list.map((listEntry) => ({
|
|
185
|
+
name: listEntry.phase,
|
|
186
|
+
tasks: listEntry.items.map((content) => ({ content, status: 'pending' })),
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
function appendItems(phases, entry, errors) {
|
|
190
|
+
if (!entry.items || entry.items.length === 0) {
|
|
191
|
+
errors.push('Missing items for append operation');
|
|
192
|
+
return phases;
|
|
193
|
+
}
|
|
194
|
+
// Validate the whole batch before mutating.
|
|
195
|
+
const seen = new Set();
|
|
196
|
+
let hasDuplicate = false;
|
|
197
|
+
for (const content of entry.items) {
|
|
198
|
+
if (seen.has(content) || findTaskByContent(phases, content)) {
|
|
199
|
+
errors.push(`Task "${content}" already exists`);
|
|
200
|
+
hasDuplicate = true;
|
|
201
|
+
}
|
|
202
|
+
seen.add(content);
|
|
203
|
+
}
|
|
204
|
+
if (hasDuplicate)
|
|
205
|
+
return phases;
|
|
206
|
+
let phase = findPhaseByName(phases, entry.phase);
|
|
207
|
+
if (!phase) {
|
|
208
|
+
phase = { name: entry.phase, tasks: [] };
|
|
209
|
+
phases.push(phase);
|
|
210
|
+
}
|
|
211
|
+
for (const content of entry.items) {
|
|
212
|
+
phase.tasks.push({ content, status: 'pending' });
|
|
213
|
+
}
|
|
214
|
+
return phases;
|
|
215
|
+
}
|
|
216
|
+
function removeTasks(phases, entry, errors) {
|
|
217
|
+
if (entry.task) {
|
|
218
|
+
const hit = resolveTaskOrError(phases, entry.task, errors);
|
|
219
|
+
if (!hit)
|
|
220
|
+
return phases;
|
|
221
|
+
hit.phase.tasks = hit.phase.tasks.filter((candidate) => candidate !== hit.task);
|
|
222
|
+
return phases;
|
|
223
|
+
}
|
|
224
|
+
if (entry.phase) {
|
|
225
|
+
const phase = resolvePhaseOrError(phases, entry.phase, errors);
|
|
226
|
+
if (!phase)
|
|
227
|
+
return phases;
|
|
228
|
+
phase.tasks = [];
|
|
229
|
+
return phases;
|
|
230
|
+
}
|
|
231
|
+
// No task or phase specified: clear all.
|
|
232
|
+
for (const phase of phases) {
|
|
233
|
+
phase.tasks = [];
|
|
234
|
+
}
|
|
235
|
+
return phases;
|
|
236
|
+
}
|
|
237
|
+
function applyEntry(phases, entry, errors) {
|
|
238
|
+
switch (entry.op) {
|
|
239
|
+
case 'init':
|
|
240
|
+
return initPhases(entry, errors);
|
|
241
|
+
case 'start': {
|
|
242
|
+
const hit = resolveTaskOrError(phases, entry.task, errors);
|
|
243
|
+
if (!hit)
|
|
244
|
+
return phases;
|
|
245
|
+
for (const phase of phases) {
|
|
246
|
+
for (const candidate of phase.tasks) {
|
|
247
|
+
if (candidate.status === 'in_progress' && candidate !== hit.task) {
|
|
248
|
+
candidate.status = 'pending';
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
hit.task.status = 'in_progress';
|
|
253
|
+
return phases;
|
|
254
|
+
}
|
|
255
|
+
case 'done': {
|
|
256
|
+
for (const task of getTaskTargets(phases, entry, errors)) {
|
|
257
|
+
task.status = 'completed';
|
|
258
|
+
}
|
|
259
|
+
return phases;
|
|
260
|
+
}
|
|
261
|
+
case 'drop': {
|
|
262
|
+
for (const task of getTaskTargets(phases, entry, errors)) {
|
|
263
|
+
task.status = 'abandoned';
|
|
264
|
+
}
|
|
265
|
+
return phases;
|
|
266
|
+
}
|
|
267
|
+
case 'rm':
|
|
268
|
+
return removeTasks(phases, entry, errors);
|
|
269
|
+
case 'append':
|
|
270
|
+
return appendItems(phases, entry, errors);
|
|
271
|
+
case 'view':
|
|
272
|
+
return phases;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
/** Apply a single todo op to existing phases. Returns new phases + errors. */
|
|
276
|
+
function applyParams(phases, params) {
|
|
277
|
+
const errors = [];
|
|
278
|
+
const next = applyEntry(phases, params, errors);
|
|
279
|
+
normalizeInProgressTask(next);
|
|
280
|
+
return { phases: next, errors };
|
|
281
|
+
}
|
|
282
|
+
// =============================================================================
|
|
283
|
+
// Markdown round-trip
|
|
284
|
+
// =============================================================================
|
|
285
|
+
const STATUS_TO_MARKER = {
|
|
286
|
+
pending: ' ',
|
|
287
|
+
in_progress: '/',
|
|
288
|
+
completed: 'x',
|
|
289
|
+
abandoned: '-',
|
|
290
|
+
};
|
|
291
|
+
/** Render todo phases as a Markdown checklist suitable for editing/copying. */
|
|
292
|
+
export function phasesToMarkdown(phases) {
|
|
293
|
+
if (phases.length === 0)
|
|
294
|
+
return '# Todos\n';
|
|
295
|
+
const out = [];
|
|
296
|
+
for (let i = 0; i < phases.length; i++) {
|
|
297
|
+
if (i > 0)
|
|
298
|
+
out.push('');
|
|
299
|
+
const phase = phases[i];
|
|
300
|
+
out.push(`# ${phase.name}`);
|
|
301
|
+
for (const task of phase.tasks) {
|
|
302
|
+
out.push(`- [${STATUS_TO_MARKER[task.status]}] ${task.content}`);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return `${out.join('\n')}\n`;
|
|
306
|
+
}
|
|
307
|
+
const MARKER_TO_STATUS = {
|
|
308
|
+
' ': 'pending',
|
|
309
|
+
'': 'pending',
|
|
310
|
+
x: 'completed',
|
|
311
|
+
X: 'completed',
|
|
312
|
+
'/': 'in_progress',
|
|
313
|
+
'>': 'in_progress',
|
|
314
|
+
'-': 'abandoned',
|
|
315
|
+
'~': 'abandoned',
|
|
316
|
+
};
|
|
317
|
+
/** Parse a Markdown checklist back into todo phases. */
|
|
318
|
+
export function markdownToPhases(md) {
|
|
319
|
+
const errors = [];
|
|
320
|
+
const phases = [];
|
|
321
|
+
let currentPhase;
|
|
322
|
+
const lines = md.split(/\r?\n/);
|
|
323
|
+
for (let lineNum = 0; lineNum < lines.length; lineNum++) {
|
|
324
|
+
const raw = lines[lineNum];
|
|
325
|
+
const trimmed = raw.trim();
|
|
326
|
+
if (!trimmed)
|
|
327
|
+
continue;
|
|
328
|
+
const headingMatch = /^#{1,6}\s+(.+?)\s*$/.exec(trimmed);
|
|
329
|
+
if (headingMatch) {
|
|
330
|
+
currentPhase = { name: headingMatch[1].trim(), tasks: [] };
|
|
331
|
+
phases.push(currentPhase);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
const taskMatch = /^[-*+]\s*\[(.?)\]\s+(.+?)\s*$/.exec(trimmed);
|
|
335
|
+
if (taskMatch) {
|
|
336
|
+
if (!currentPhase) {
|
|
337
|
+
currentPhase = { name: 'Todos', tasks: [] };
|
|
338
|
+
phases.push(currentPhase);
|
|
339
|
+
}
|
|
340
|
+
const marker = taskMatch[1] ?? '';
|
|
341
|
+
const status = MARKER_TO_STATUS[marker];
|
|
342
|
+
if (!status) {
|
|
343
|
+
errors.push(`Line ${lineNum + 1}: unknown status marker "[${marker}]" (use [ ], [x], [/], [-])`);
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
currentPhase.tasks.push({ content: taskMatch[2].trim(), status });
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
errors.push(`Line ${lineNum + 1}: unrecognized syntax "${trimmed}"`);
|
|
350
|
+
}
|
|
351
|
+
normalizeInProgressTask(phases);
|
|
352
|
+
return { phases, errors };
|
|
353
|
+
}
|
|
354
|
+
// =============================================================================
|
|
355
|
+
// Summary formatter
|
|
356
|
+
// =============================================================================
|
|
357
|
+
function formatSummary(phases, errors, readOnly = false) {
|
|
358
|
+
const tasks = phases.flatMap((phase) => phase.tasks);
|
|
359
|
+
if (tasks.length === 0) {
|
|
360
|
+
if (errors.length > 0)
|
|
361
|
+
return `Errors: ${errors.join('; ')}`;
|
|
362
|
+
return readOnly ? 'Todo list is empty.' : 'Todo list cleared.';
|
|
363
|
+
}
|
|
364
|
+
const remainingByPhase = phases
|
|
365
|
+
.map((phase) => ({
|
|
366
|
+
name: phase.name,
|
|
367
|
+
tasks: phase.tasks.filter((task) => task.status === 'pending' || task.status === 'in_progress'),
|
|
368
|
+
}))
|
|
369
|
+
.filter((phase) => phase.tasks.length > 0);
|
|
370
|
+
const remainingTasks = remainingByPhase.flatMap((phase) => phase.tasks.map((task) => ({ ...task, phase: phase.name })));
|
|
371
|
+
let currentIdx = phases.findIndex((phase) => phase.tasks.some((task) => task.status === 'pending' || task.status === 'in_progress'));
|
|
372
|
+
if (currentIdx === -1)
|
|
373
|
+
currentIdx = phases.length - 1;
|
|
374
|
+
const current = phases[currentIdx];
|
|
375
|
+
const done = current.tasks.filter((task) => task.status === 'completed' || task.status === 'abandoned').length;
|
|
376
|
+
const lines = [];
|
|
377
|
+
if (errors.length > 0)
|
|
378
|
+
lines.push(`Errors: ${errors.join('; ')}`);
|
|
379
|
+
if (remainingTasks.length === 0) {
|
|
380
|
+
lines.push('Remaining items: none.');
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
lines.push(`Remaining items (${remainingTasks.length}):`);
|
|
384
|
+
for (const task of remainingTasks) {
|
|
385
|
+
lines.push(` - ${task.content} [${task.status}] (${task.phase})`);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const closedAll = tasks.filter((task) => task.status === 'completed' || task.status === 'abandoned').length;
|
|
389
|
+
const workedAhead = phases.some((phase, idx) => idx > currentIdx &&
|
|
390
|
+
phase.tasks.some((task) => task.status === 'completed' || task.status === 'abandoned'));
|
|
391
|
+
lines.push(`Overall: ${closedAll}/${tasks.length} done, ${remainingTasks.length} open.`);
|
|
392
|
+
lines.push(`Active phase ${currentIdx + 1}/${phases.length} "${current.name}" (${done}/${current.tasks.length})${workedAhead
|
|
393
|
+
? ' — earliest phase with open tasks; the in-progress pointer auto-advances to the earliest open task on each completion, so it can sit behind out-of-order work (nothing was un-completed).'
|
|
394
|
+
: '.'}`);
|
|
395
|
+
for (const phase of phases) {
|
|
396
|
+
lines.push(` ${phase.name}:`);
|
|
397
|
+
for (const task of phase.tasks) {
|
|
398
|
+
const checkbox = task.status === 'completed' ? '[X]' : '[ ]';
|
|
399
|
+
const tag = task.status === 'in_progress'
|
|
400
|
+
? ' (in progress)'
|
|
401
|
+
: task.status === 'abandoned'
|
|
402
|
+
? ' (dropped)'
|
|
403
|
+
: '';
|
|
404
|
+
lines.push(` - ${checkbox} ${task.content}${tag}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return lines.join('\n');
|
|
408
|
+
}
|
|
409
|
+
// =============================================================================
|
|
410
|
+
// Session resume: extract latest phases from messages
|
|
411
|
+
// =============================================================================
|
|
412
|
+
/** Extract the latest todo phases from stored messages (tool results).
|
|
413
|
+
* Scans backwards for the most recent `todo` tool result with phases metadata. */
|
|
414
|
+
export function getLatestTodoPhasesFromMessages(messages) {
|
|
415
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
416
|
+
const msg = messages[i];
|
|
417
|
+
if (msg.role !== 'tool')
|
|
418
|
+
continue;
|
|
419
|
+
for (let j = msg.content.length - 1; j >= 0; j--) {
|
|
420
|
+
const part = msg.content[j];
|
|
421
|
+
if (!part || part._tag !== 'tool_result')
|
|
422
|
+
continue;
|
|
423
|
+
if (part.tool !== 'todo')
|
|
424
|
+
continue;
|
|
425
|
+
const output = part.output;
|
|
426
|
+
if (!output || output._tag !== 'success')
|
|
427
|
+
continue;
|
|
428
|
+
const metadata = output.metadata;
|
|
429
|
+
if (metadata && Array.isArray(metadata.phases)) {
|
|
430
|
+
return clonePhases(metadata.phases);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
// =============================================================================
|
|
437
|
+
// Schema
|
|
438
|
+
// =============================================================================
|
|
439
|
+
const todoParameters = {
|
|
440
|
+
type: 'object',
|
|
441
|
+
description: 'Apply a single todo operation',
|
|
442
|
+
properties: {
|
|
443
|
+
op: {
|
|
444
|
+
type: 'string',
|
|
445
|
+
enum: ['init', 'start', 'done', 'rm', 'drop', 'append', 'view'],
|
|
446
|
+
description: 'Operation to apply',
|
|
447
|
+
},
|
|
448
|
+
list: {
|
|
449
|
+
type: 'array',
|
|
450
|
+
description: 'Phased task list (init only). Each entry has a phase name and task items.',
|
|
451
|
+
items: {
|
|
452
|
+
type: 'object',
|
|
453
|
+
properties: {
|
|
454
|
+
phase: { type: 'string', description: 'Phase name' },
|
|
455
|
+
items: {
|
|
456
|
+
type: 'array',
|
|
457
|
+
items: { type: 'string' },
|
|
458
|
+
minItems: 1,
|
|
459
|
+
description: 'Task content strings for this phase',
|
|
460
|
+
},
|
|
461
|
+
},
|
|
462
|
+
required: ['phase', 'items'],
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
task: { type: 'string', description: 'Task content (for start/done/drop/rm)' },
|
|
466
|
+
phase: { type: 'string', description: 'Phase name (for done/drop/rm/append, or init flat mode)' },
|
|
467
|
+
items: {
|
|
468
|
+
type: 'array',
|
|
469
|
+
items: { type: 'string' },
|
|
470
|
+
description: 'Tasks to append (append), or flat init items (init)',
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
required: ['op'],
|
|
474
|
+
additionalProperties: false,
|
|
475
|
+
};
|
|
476
|
+
// =============================================================================
|
|
477
|
+
// Tool definition
|
|
478
|
+
// =============================================================================
|
|
479
|
+
/** todo tool: phased task tracking with 7 operations.
|
|
480
|
+
* Permission: auto (no side effects beyond session state).
|
|
481
|
+
* State is held in-memory via ctx.todoState hook (dependency-reversal). */
|
|
482
|
+
export const todoTool = {
|
|
483
|
+
name: 'todo',
|
|
484
|
+
description: 'Manage a phased task list to track progress within a session. 7 operations: init (create/replace list), start (mark in progress), done (mark completed), drop (mark abandoned), rm (remove task/phase), append (add tasks to a phase), view (read-only). Tasks auto-promote: completing a task moves the in-progress pointer to the next pending task. Pass the task content text (NOT an ID) to target a specific task.',
|
|
485
|
+
parameters: todoParameters,
|
|
486
|
+
permission: 'auto',
|
|
487
|
+
execute: async (input, ctx) => {
|
|
488
|
+
const params = input;
|
|
489
|
+
const todoState = ctx.todoState;
|
|
490
|
+
if (!todoState) {
|
|
491
|
+
return { _tag: 'error', error: 'Todo state not available in this context' };
|
|
492
|
+
}
|
|
493
|
+
const previousPhases = clonePhases(todoState.get());
|
|
494
|
+
const readOnly = params.op === 'view';
|
|
495
|
+
const { phases: updated, errors } = readOnly
|
|
496
|
+
? { phases: previousPhases, errors: [] }
|
|
497
|
+
: applyParams(clonePhases(previousPhases), params);
|
|
498
|
+
// A batch with any error is discarded wholesale: persisting a half-applied
|
|
499
|
+
// batch makes the natural retry hit "already exists" for the ops that did land.
|
|
500
|
+
const failed = errors.length > 0;
|
|
501
|
+
const effective = failed ? previousPhases : updated;
|
|
502
|
+
const completedTasks = readOnly || failed ? [] : getCompletionTransitions(previousPhases, updated);
|
|
503
|
+
if (!readOnly && !failed) {
|
|
504
|
+
todoState.set(updated);
|
|
505
|
+
}
|
|
506
|
+
const output = formatSummary(effective, errors, readOnly);
|
|
507
|
+
const metadata = { phases: effective };
|
|
508
|
+
if (completedTasks.length > 0) {
|
|
509
|
+
metadata.completedTasks = completedTasks;
|
|
510
|
+
}
|
|
511
|
+
// Always return success: errors are surfaced in the output text so the
|
|
512
|
+
// LLM can read them and retry. This avoids skewing tool metrics (a
|
|
513
|
+
// "task not found" is a user error, not a tool failure) and preserves
|
|
514
|
+
// metadata.phases for the LLM to reference on retry.
|
|
515
|
+
return { _tag: 'success', output, metadata };
|
|
516
|
+
},
|
|
517
|
+
};
|
package/dist/tools/index.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export { grepTool } from './builtin/grep.js';
|
|
|
9
9
|
export { readTool } from './builtin/read.js';
|
|
10
10
|
export { createDefaultURLRegistry, createFileResolver, createSkillResolver, } from './builtin/resolvers.js';
|
|
11
11
|
export { taskTool } from './builtin/task.js';
|
|
12
|
+
export { todoTool } from './builtin/todo.js';
|
|
13
|
+
export type { TodoInput, TodoItem, TodoPhase, TodoStatus } from './builtin/todo.js';
|
|
12
14
|
export { writeTool } from './builtin/write.js';
|
|
13
15
|
export { yieldTool } from './builtin/yield.js';
|
|
14
16
|
export { executeTool } from './executor.js';
|
package/dist/tools/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { readTool } from './builtin/read.js';
|
|
|
8
8
|
// ── Builtin tools ───────────────────────────────────────────
|
|
9
9
|
export { createDefaultURLRegistry, createFileResolver, createSkillResolver, } from './builtin/resolvers.js';
|
|
10
10
|
export { taskTool } from './builtin/task.js';
|
|
11
|
+
export { todoTool } from './builtin/todo.js';
|
|
11
12
|
export { writeTool } from './builtin/write.js';
|
|
12
13
|
export { yieldTool } from './builtin/yield.js';
|
|
13
14
|
export { executeTool } from './executor.js';
|
|
@@ -28,6 +29,7 @@ import { globTool } from './builtin/glob.js';
|
|
|
28
29
|
import { grepTool } from './builtin/grep.js';
|
|
29
30
|
import { readTool } from './builtin/read.js';
|
|
30
31
|
import { taskTool } from './builtin/task.js';
|
|
32
|
+
import { todoTool } from './builtin/todo.js';
|
|
31
33
|
import { writeTool } from './builtin/write.js';
|
|
32
34
|
import { yieldTool } from './builtin/yield.js';
|
|
33
35
|
import { createToolRegistry, registerTool } from './registry.js';
|
|
@@ -47,6 +49,7 @@ export function createDefaultRegistry(config = DEFAULT_CONFIG) {
|
|
|
47
49
|
registerTool(reg, grepTool);
|
|
48
50
|
registerTool(reg, bashTool);
|
|
49
51
|
registerTool(reg, taskTool);
|
|
52
|
+
registerTool(reg, todoTool);
|
|
50
53
|
registerTool(reg, yieldTool);
|
|
51
54
|
registerTool(reg, createWebSearchTool(config.websearch));
|
|
52
55
|
for (const tool of dapTools)
|