@yeaft/webchat-agent 0.1.446 → 0.1.447
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/connection/message-router.js +5 -1
- package/package.json +1 -1
- package/unify/session.js +4 -0
- package/unify/tasks/store.js +397 -0
- package/unify/tools/index.js +4 -0
- package/unify/tools/task-tools.js +203 -53
- package/unify/web-bridge.js +69 -0
|
@@ -25,7 +25,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
|
|
|
25
25
|
import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
|
|
26
26
|
import { loadMcpServers, updateMcpConfig } from '../mcp.js';
|
|
27
27
|
import { getLlmConfig, updateLlmConfig } from '../unify/config-api.js';
|
|
28
|
-
import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession } from '../unify/web-bridge.js';
|
|
28
|
+
import { handleUnifyChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory } from '../unify/web-bridge.js';
|
|
29
29
|
|
|
30
30
|
export async function handleMessage(msg) {
|
|
31
31
|
switch (msg.type) {
|
|
@@ -325,6 +325,10 @@ export async function handleMessage(msg) {
|
|
|
325
325
|
await handleUnifyChat(msg);
|
|
326
326
|
break;
|
|
327
327
|
|
|
328
|
+
case 'unify_load_history':
|
|
329
|
+
await handleUnifyLoadHistory(msg);
|
|
330
|
+
break;
|
|
331
|
+
|
|
328
332
|
case 'unify_mode_switch':
|
|
329
333
|
handleUnifyModeSwitch(msg);
|
|
330
334
|
break;
|
package/package.json
CHANGED
package/unify/session.js
CHANGED
|
@@ -22,6 +22,7 @@ import { MemoryStore } from './memory/store.js';
|
|
|
22
22
|
import { SkillManager, createSkillManager } from './skills.js';
|
|
23
23
|
import { MCPManager } from './mcp.js';
|
|
24
24
|
import { createFullRegistry } from './tools/index.js';
|
|
25
|
+
import { initTaskStore } from './tools/task-tools.js';
|
|
25
26
|
import { Engine } from './engine.js';
|
|
26
27
|
import { join } from 'path';
|
|
27
28
|
|
|
@@ -117,6 +118,9 @@ export async function loadSession(options = {}) {
|
|
|
117
118
|
const conversationStore = new ConversationStore(yeaftDir);
|
|
118
119
|
const memoryStore = new MemoryStore(yeaftDir);
|
|
119
120
|
|
|
121
|
+
// ─── 5a. Initialize task store ─────────────────────────
|
|
122
|
+
initTaskStore(yeaftDir, { readOnly: config._readOnly || false });
|
|
123
|
+
|
|
120
124
|
// ─── 6. Load skills ────────────────────────────────────
|
|
121
125
|
let skillManager;
|
|
122
126
|
if (skipSkills) {
|
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.js — File-system backed TaskStore for Yeaft Unify.
|
|
3
|
+
*
|
|
4
|
+
* Persists tasks to ~/.yeaft/tasks/ with one folder per task.
|
|
5
|
+
* Layout:
|
|
6
|
+
* ~/.yeaft/tasks/
|
|
7
|
+
* index.md — Task index (auto-generated overview)
|
|
8
|
+
* plan.md — Global plan text
|
|
9
|
+
* task-abc12345/ — One folder per task
|
|
10
|
+
* task.md — Task metadata (YAML frontmatter + description)
|
|
11
|
+
* progress.md — Progress log (append-only)
|
|
12
|
+
* memory.md — Task-specific context/notes
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync } from 'fs';
|
|
16
|
+
import { join } from 'path';
|
|
17
|
+
|
|
18
|
+
// ─── YAML Frontmatter helpers ────────────────────────────────
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Serialize a task object to YAML frontmatter + body for task.md.
|
|
22
|
+
* @param {object} task
|
|
23
|
+
* @returns {string}
|
|
24
|
+
*/
|
|
25
|
+
function serializeTask(task) {
|
|
26
|
+
const fm = [
|
|
27
|
+
'---',
|
|
28
|
+
`id: ${task.id}`,
|
|
29
|
+
`title: ${task.title}`,
|
|
30
|
+
`status: ${task.status}`,
|
|
31
|
+
`priority: ${task.priority || 'medium'}`,
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
if (task.parentId) fm.push(`parentId: ${task.parentId}`);
|
|
35
|
+
if (task.createdAt) fm.push(`createdAt: ${task.createdAt}`);
|
|
36
|
+
if (task.updatedAt) fm.push(`updatedAt: ${task.updatedAt}`);
|
|
37
|
+
|
|
38
|
+
fm.push('---');
|
|
39
|
+
fm.push('');
|
|
40
|
+
|
|
41
|
+
// Body: description + result
|
|
42
|
+
const parts = [];
|
|
43
|
+
if (task.description) parts.push(task.description);
|
|
44
|
+
if (task.result) {
|
|
45
|
+
parts.push('');
|
|
46
|
+
parts.push('## Result');
|
|
47
|
+
parts.push(task.result);
|
|
48
|
+
}
|
|
49
|
+
fm.push(parts.join('\n'));
|
|
50
|
+
|
|
51
|
+
return fm.join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a task.md file (YAML frontmatter + body) into a task object.
|
|
56
|
+
* @param {string} raw — File contents
|
|
57
|
+
* @returns {object|null}
|
|
58
|
+
*/
|
|
59
|
+
function parseTask(raw) {
|
|
60
|
+
if (!raw || !raw.startsWith('---')) return null;
|
|
61
|
+
|
|
62
|
+
const endIdx = raw.indexOf('---', 3);
|
|
63
|
+
if (endIdx === -1) return null;
|
|
64
|
+
|
|
65
|
+
const frontmatter = raw.slice(3, endIdx).trim();
|
|
66
|
+
const body = raw.slice(endIdx + 3).trim();
|
|
67
|
+
|
|
68
|
+
const task = {};
|
|
69
|
+
|
|
70
|
+
for (const line of frontmatter.split('\n')) {
|
|
71
|
+
const colonIdx = line.indexOf(':');
|
|
72
|
+
if (colonIdx === -1) continue;
|
|
73
|
+
const key = line.slice(0, colonIdx).trim();
|
|
74
|
+
const val = line.slice(colonIdx + 1).trim();
|
|
75
|
+
if (!key) continue;
|
|
76
|
+
|
|
77
|
+
if (key === 'createdAt' || key === 'updatedAt') {
|
|
78
|
+
task[key] = parseInt(val, 10) || 0;
|
|
79
|
+
} else {
|
|
80
|
+
task[key] = val;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!task.id) return null;
|
|
85
|
+
|
|
86
|
+
// Parse body: description and result
|
|
87
|
+
const resultIdx = body.indexOf('## Result');
|
|
88
|
+
if (resultIdx !== -1) {
|
|
89
|
+
task.description = body.slice(0, resultIdx).trim();
|
|
90
|
+
task.result = body.slice(resultIdx + '## Result'.length).trim();
|
|
91
|
+
} else {
|
|
92
|
+
task.description = body;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Normalize parentId
|
|
96
|
+
if (!task.parentId || task.parentId === 'null') {
|
|
97
|
+
task.parentId = null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return task;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Index generation ────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Generate index.md content from all tasks.
|
|
107
|
+
* @param {Map<string, object>} tasks
|
|
108
|
+
* @returns {string}
|
|
109
|
+
*/
|
|
110
|
+
function generateIndex(tasks) {
|
|
111
|
+
const now = new Date().toISOString();
|
|
112
|
+
const lines = [
|
|
113
|
+
'---',
|
|
114
|
+
`totalTasks: ${tasks.size}`,
|
|
115
|
+
`lastUpdated: ${now}`,
|
|
116
|
+
'---',
|
|
117
|
+
'# Task Index',
|
|
118
|
+
'',
|
|
119
|
+
'| ID | Title | Status | Priority | Updated |',
|
|
120
|
+
'|----|-------|--------|----------|---------|',
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
// Sort: in_progress first, then pending, then others
|
|
124
|
+
const ORDER = { in_progress: 0, pending: 1, blocked: 2, completed: 3, cancelled: 4 };
|
|
125
|
+
const sorted = [...tasks.values()].sort(
|
|
126
|
+
(a, b) => (ORDER[a.status] ?? 5) - (ORDER[b.status] ?? 5)
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
for (const t of sorted) {
|
|
130
|
+
const date = t.updatedAt ? new Date(t.updatedAt).toISOString().slice(0, 10) : '-';
|
|
131
|
+
lines.push(`| ${t.id} | ${t.title} | ${t.status} | ${t.priority || 'medium'} | ${date} |`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return lines.join('\n') + '\n';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ─── Progress log helpers ────────────────────────────────────
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Format a progress entry for appending to progress.md.
|
|
141
|
+
* @param {string} note
|
|
142
|
+
* @param {object} [meta]
|
|
143
|
+
* @returns {string}
|
|
144
|
+
*/
|
|
145
|
+
function formatProgressEntry(note, meta = {}) {
|
|
146
|
+
const now = new Date();
|
|
147
|
+
const ts = `${now.toISOString().slice(0, 10)} ${now.toISOString().slice(11, 16)}`;
|
|
148
|
+
const lines = [`## ${ts}`];
|
|
149
|
+
lines.push(`- ${note}`);
|
|
150
|
+
if (meta.status) lines.push(`- Status: ${meta.status}`);
|
|
151
|
+
if (meta.result) lines.push(`- Result: ${meta.result}`);
|
|
152
|
+
lines.push('');
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── TaskStore ───────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
export class TaskStore {
|
|
159
|
+
/** @type {string} */
|
|
160
|
+
#dir;
|
|
161
|
+
/** @type {string} */
|
|
162
|
+
#indexPath;
|
|
163
|
+
/** @type {string} */
|
|
164
|
+
#planPath;
|
|
165
|
+
/** @type {Map<string, object>} */
|
|
166
|
+
#tasks;
|
|
167
|
+
/** @type {boolean} */
|
|
168
|
+
#readOnly;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* @param {string} yeaftDir — Base ~/.yeaft directory
|
|
172
|
+
* @param {{ readOnly?: boolean }} [opts]
|
|
173
|
+
*/
|
|
174
|
+
constructor(yeaftDir, opts = {}) {
|
|
175
|
+
this.#dir = join(yeaftDir, 'tasks');
|
|
176
|
+
this.#indexPath = join(this.#dir, 'index.md');
|
|
177
|
+
this.#planPath = join(this.#dir, 'plan.md');
|
|
178
|
+
this.#tasks = new Map();
|
|
179
|
+
this.#readOnly = opts.readOnly || false;
|
|
180
|
+
|
|
181
|
+
// Ensure base directory exists
|
|
182
|
+
if (!this.#readOnly) {
|
|
183
|
+
if (!existsSync(this.#dir)) {
|
|
184
|
+
try {
|
|
185
|
+
mkdirSync(this.#dir, { recursive: true });
|
|
186
|
+
} catch {
|
|
187
|
+
this.#readOnly = true;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Load existing tasks from disk
|
|
193
|
+
this.#loadAll();
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Number of tasks in the store. */
|
|
197
|
+
get size() {
|
|
198
|
+
return this.#tasks.size;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Create a new task. Creates its folder with task.md, progress.md, memory.md.
|
|
203
|
+
* @param {object} task — Must have .id, .title, .status
|
|
204
|
+
* @returns {object} The created task
|
|
205
|
+
*/
|
|
206
|
+
create(task) {
|
|
207
|
+
this.#tasks.set(task.id, task);
|
|
208
|
+
|
|
209
|
+
if (!this.#readOnly) {
|
|
210
|
+
const taskDir = join(this.#dir, task.id);
|
|
211
|
+
try {
|
|
212
|
+
mkdirSync(taskDir, { recursive: true });
|
|
213
|
+
writeFileSync(join(taskDir, 'task.md'), serializeTask(task), 'utf8');
|
|
214
|
+
writeFileSync(join(taskDir, 'progress.md'), '# Progress Log\n\n', 'utf8');
|
|
215
|
+
writeFileSync(join(taskDir, 'memory.md'), '# Task Memory\n', 'utf8');
|
|
216
|
+
this.#appendProgressInternal(task.id, `Created task: ${task.title}`, { status: 'pending' });
|
|
217
|
+
this.#updateIndex();
|
|
218
|
+
} catch {
|
|
219
|
+
// Best-effort write
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return task;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Update an existing task.
|
|
228
|
+
* @param {string} id
|
|
229
|
+
* @param {object} updates
|
|
230
|
+
* @returns {object|null} Updated task, or null if not found
|
|
231
|
+
*/
|
|
232
|
+
update(id, updates) {
|
|
233
|
+
const task = this.#tasks.get(id);
|
|
234
|
+
if (!task) return null;
|
|
235
|
+
|
|
236
|
+
const oldStatus = task.status;
|
|
237
|
+
Object.assign(task, updates, { updatedAt: Date.now() });
|
|
238
|
+
|
|
239
|
+
if (!this.#readOnly) {
|
|
240
|
+
try {
|
|
241
|
+
const taskDir = join(this.#dir, id);
|
|
242
|
+
writeFileSync(join(taskDir, 'task.md'), serializeTask(task), 'utf8');
|
|
243
|
+
|
|
244
|
+
// Log progress on status change
|
|
245
|
+
if (updates.status && updates.status !== oldStatus) {
|
|
246
|
+
this.#appendProgressInternal(id, `Status changed: ${oldStatus} → ${updates.status}`, updates);
|
|
247
|
+
}
|
|
248
|
+
this.#updateIndex();
|
|
249
|
+
} catch {
|
|
250
|
+
// Best-effort
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return task;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Get a task by ID.
|
|
259
|
+
* @param {string} id
|
|
260
|
+
* @returns {object|null}
|
|
261
|
+
*/
|
|
262
|
+
get(id) {
|
|
263
|
+
return this.#tasks.get(id) || null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* List tasks with optional filters.
|
|
268
|
+
* @param {{ status?: string, priority?: string }} [filter]
|
|
269
|
+
* @returns {object[]}
|
|
270
|
+
*/
|
|
271
|
+
list(filter) {
|
|
272
|
+
let results = [...this.#tasks.values()];
|
|
273
|
+
if (filter?.status) results = results.filter(t => t.status === filter.status);
|
|
274
|
+
if (filter?.priority) results = results.filter(t => t.priority === filter.priority);
|
|
275
|
+
return results;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Get progress log for a task.
|
|
280
|
+
* @param {string} id
|
|
281
|
+
* @returns {string}
|
|
282
|
+
*/
|
|
283
|
+
getProgress(id) {
|
|
284
|
+
const path = join(this.#dir, id, 'progress.md');
|
|
285
|
+
try {
|
|
286
|
+
if (existsSync(path)) return readFileSync(path, 'utf8');
|
|
287
|
+
} catch { /* */ }
|
|
288
|
+
return '';
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Append a progress note to a task's progress log.
|
|
293
|
+
* @param {string} id
|
|
294
|
+
* @param {string} note
|
|
295
|
+
* @param {object} [meta]
|
|
296
|
+
*/
|
|
297
|
+
appendProgress(id, note, meta = {}) {
|
|
298
|
+
if (!this.#tasks.has(id)) return;
|
|
299
|
+
this.#appendProgressInternal(id, note, meta);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Get memory content for a task.
|
|
304
|
+
* @param {string} id
|
|
305
|
+
* @returns {string}
|
|
306
|
+
*/
|
|
307
|
+
getMemory(id) {
|
|
308
|
+
const path = join(this.#dir, id, 'memory.md');
|
|
309
|
+
try {
|
|
310
|
+
if (existsSync(path)) return readFileSync(path, 'utf8');
|
|
311
|
+
} catch { /* */ }
|
|
312
|
+
return '';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Update memory content for a task.
|
|
317
|
+
* @param {string} id
|
|
318
|
+
* @param {string} content
|
|
319
|
+
*/
|
|
320
|
+
updateMemory(id, content) {
|
|
321
|
+
if (this.#readOnly || !this.#tasks.has(id)) return;
|
|
322
|
+
try {
|
|
323
|
+
writeFileSync(join(this.#dir, id, 'memory.md'), content, 'utf8');
|
|
324
|
+
} catch { /* */ }
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Get current plan text.
|
|
329
|
+
* @returns {string}
|
|
330
|
+
*/
|
|
331
|
+
getPlan() {
|
|
332
|
+
try {
|
|
333
|
+
if (existsSync(this.#planPath)) return readFileSync(this.#planPath, 'utf8');
|
|
334
|
+
} catch { /* */ }
|
|
335
|
+
return '';
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Set plan text.
|
|
340
|
+
* @param {string} text
|
|
341
|
+
*/
|
|
342
|
+
setPlan(text) {
|
|
343
|
+
if (this.#readOnly) return;
|
|
344
|
+
try {
|
|
345
|
+
writeFileSync(this.#planPath, text, 'utf8');
|
|
346
|
+
} catch { /* */ }
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ─── Internal methods ──────────────────────────────────────
|
|
350
|
+
|
|
351
|
+
/** Load all task folders from disk. */
|
|
352
|
+
#loadAll() {
|
|
353
|
+
if (!existsSync(this.#dir)) return;
|
|
354
|
+
let entries;
|
|
355
|
+
try {
|
|
356
|
+
entries = readdirSync(this.#dir, { withFileTypes: true });
|
|
357
|
+
} catch {
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
for (const entry of entries) {
|
|
362
|
+
if (!entry.isDirectory() || !entry.name.startsWith('task-')) continue;
|
|
363
|
+
const taskMdPath = join(this.#dir, entry.name, 'task.md');
|
|
364
|
+
try {
|
|
365
|
+
if (!existsSync(taskMdPath)) continue;
|
|
366
|
+
const raw = readFileSync(taskMdPath, 'utf8');
|
|
367
|
+
const task = parseTask(raw);
|
|
368
|
+
if (task && task.id) {
|
|
369
|
+
this.#tasks.set(task.id, task);
|
|
370
|
+
}
|
|
371
|
+
} catch {
|
|
372
|
+
// Skip corrupt task folders
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Append to a task's progress.md. */
|
|
378
|
+
#appendProgressInternal(id, note, meta) {
|
|
379
|
+
if (this.#readOnly) return;
|
|
380
|
+
const path = join(this.#dir, id, 'progress.md');
|
|
381
|
+
try {
|
|
382
|
+
const existing = existsSync(path) ? readFileSync(path, 'utf8') : '# Progress Log\n\n';
|
|
383
|
+
writeFileSync(path, existing + formatProgressEntry(note, meta), 'utf8');
|
|
384
|
+
} catch { /* */ }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Regenerate index.md from all tasks. */
|
|
388
|
+
#updateIndex() {
|
|
389
|
+
if (this.#readOnly) return;
|
|
390
|
+
try {
|
|
391
|
+
writeFileSync(this.#indexPath, generateIndex(this.#tasks), 'utf8');
|
|
392
|
+
} catch { /* */ }
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Exported for testing
|
|
397
|
+
export { serializeTask as _serializeTask, parseTask as _parseTask };
|
package/unify/tools/index.js
CHANGED
|
@@ -48,6 +48,8 @@ import {
|
|
|
48
48
|
taskUpdate,
|
|
49
49
|
taskList,
|
|
50
50
|
taskGet,
|
|
51
|
+
taskProgress,
|
|
52
|
+
taskMemory,
|
|
51
53
|
followupTask,
|
|
52
54
|
updatePlan,
|
|
53
55
|
} from './task-tools.js';
|
|
@@ -104,6 +106,8 @@ export const allTools = [
|
|
|
104
106
|
taskUpdate,
|
|
105
107
|
taskList,
|
|
106
108
|
taskGet,
|
|
109
|
+
taskProgress,
|
|
110
|
+
taskMemory,
|
|
107
111
|
followupTask,
|
|
108
112
|
updatePlan,
|
|
109
113
|
|
|
@@ -1,26 +1,43 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Task management tools —
|
|
2
|
+
* Task management tools — persistent task tracking for work mode.
|
|
3
3
|
*
|
|
4
|
-
* Tasks are
|
|
5
|
-
*
|
|
4
|
+
* Tasks are persisted to ~/.yeaft/tasks/ via TaskStore (one folder per task).
|
|
5
|
+
* Call initTaskStore(yeaftDir) during session init before tools are used.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { defineTool } from './types.js';
|
|
9
9
|
import { randomUUID } from 'crypto';
|
|
10
|
+
import { TaskStore } from '../tasks/store.js';
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
12
|
+
/** @type {TaskStore|null} */
|
|
13
|
+
let taskStore = null;
|
|
13
14
|
|
|
14
|
-
/**
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Initialize the task store with the yeaft directory.
|
|
17
|
+
* Must be called during session startup before any task tools are used.
|
|
18
|
+
* @param {string} yeaftDir — Base ~/.yeaft directory
|
|
19
|
+
* @param {{ readOnly?: boolean }} [opts]
|
|
20
|
+
*/
|
|
21
|
+
export function initTaskStore(yeaftDir, opts) {
|
|
22
|
+
taskStore = new TaskStore(yeaftDir, opts);
|
|
23
|
+
}
|
|
16
24
|
|
|
17
|
-
/** Get task store for other tools. */
|
|
25
|
+
/** Get the task store instance (for other tools/tests). */
|
|
18
26
|
export function getTaskStore() {
|
|
19
|
-
return
|
|
27
|
+
return taskStore;
|
|
20
28
|
}
|
|
21
29
|
|
|
30
|
+
/** Get current plan text. */
|
|
22
31
|
export function getPlan() {
|
|
23
|
-
return
|
|
32
|
+
return taskStore ? taskStore.getPlan() : '';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Internal helper — ensure store is initialized. */
|
|
36
|
+
function requireStore() {
|
|
37
|
+
if (!taskStore) {
|
|
38
|
+
return '{"error":"Task store not initialized. Session may still be loading."}';
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
24
41
|
}
|
|
25
42
|
|
|
26
43
|
// ─── TaskCreate ─────────────────────────────────────────
|
|
@@ -30,6 +47,7 @@ export const taskCreate = defineTool({
|
|
|
30
47
|
description: `Create a new task for tracking work progress.
|
|
31
48
|
|
|
32
49
|
Tasks have a title, description, priority, and status.
|
|
50
|
+
Each task gets its own folder with task.md, progress.md, and memory.md.
|
|
33
51
|
Use this to break down complex work into trackable items.`,
|
|
34
52
|
parameters: {
|
|
35
53
|
type: 'object',
|
|
@@ -58,6 +76,9 @@ Use this to break down complex work into trackable items.`,
|
|
|
58
76
|
isConcurrencySafe: () => false,
|
|
59
77
|
isReadOnly: () => false,
|
|
60
78
|
async execute(input, ctx) {
|
|
79
|
+
const err = requireStore();
|
|
80
|
+
if (err) return err;
|
|
81
|
+
|
|
61
82
|
const { title, description, priority = 'medium', parent_id } = input;
|
|
62
83
|
if (!title) return JSON.stringify({ error: 'title is required' });
|
|
63
84
|
|
|
@@ -73,7 +94,7 @@ Use this to break down complex work into trackable items.`,
|
|
|
73
94
|
updatedAt: Date.now(),
|
|
74
95
|
};
|
|
75
96
|
|
|
76
|
-
|
|
97
|
+
taskStore.create(task);
|
|
77
98
|
|
|
78
99
|
return JSON.stringify({
|
|
79
100
|
success: true,
|
|
@@ -126,18 +147,21 @@ Status values: pending, in_progress, completed, blocked, cancelled`,
|
|
|
126
147
|
isConcurrencySafe: () => false,
|
|
127
148
|
isReadOnly: () => false,
|
|
128
149
|
async execute(input, ctx) {
|
|
150
|
+
const err = requireStore();
|
|
151
|
+
if (err) return err;
|
|
152
|
+
|
|
129
153
|
const { task_id, status, priority, title, description, result } = input;
|
|
130
154
|
if (!task_id) return JSON.stringify({ error: 'task_id is required' });
|
|
131
155
|
|
|
132
|
-
const
|
|
133
|
-
if (
|
|
156
|
+
const updates = {};
|
|
157
|
+
if (status) updates.status = status;
|
|
158
|
+
if (priority) updates.priority = priority;
|
|
159
|
+
if (title) updates.title = title;
|
|
160
|
+
if (description !== undefined) updates.description = description;
|
|
161
|
+
if (result) updates.result = result;
|
|
134
162
|
|
|
135
|
-
|
|
136
|
-
if (
|
|
137
|
-
if (title) task.title = title;
|
|
138
|
-
if (description !== undefined) task.description = description;
|
|
139
|
-
if (result) task.result = result;
|
|
140
|
-
task.updatedAt = Date.now();
|
|
163
|
+
const task = taskStore.update(task_id, updates);
|
|
164
|
+
if (!task) return JSON.stringify({ error: `Task not found: ${task_id}` });
|
|
141
165
|
|
|
142
166
|
return JSON.stringify({
|
|
143
167
|
success: true,
|
|
@@ -172,34 +196,37 @@ Shows task IDs, titles, status, and priority. Filter by status if needed.`,
|
|
|
172
196
|
isConcurrencySafe: () => true,
|
|
173
197
|
isReadOnly: () => true,
|
|
174
198
|
async execute(input, ctx) {
|
|
199
|
+
const err = requireStore();
|
|
200
|
+
if (err) return err;
|
|
201
|
+
|
|
175
202
|
const { status, include_completed = true } = input;
|
|
176
203
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (!include_completed && task.status === 'completed') continue;
|
|
181
|
-
taskList.push({
|
|
182
|
-
id: task.id,
|
|
183
|
-
title: task.title,
|
|
184
|
-
status: task.status,
|
|
185
|
-
priority: task.priority,
|
|
186
|
-
parentId: task.parentId,
|
|
187
|
-
hasResult: !!task.result,
|
|
188
|
-
});
|
|
204
|
+
let results = taskStore.list(status ? { status } : undefined);
|
|
205
|
+
if (!include_completed) {
|
|
206
|
+
results = results.filter(t => t.status !== 'completed');
|
|
189
207
|
}
|
|
190
208
|
|
|
209
|
+
const taskItems = results.map(t => ({
|
|
210
|
+
id: t.id,
|
|
211
|
+
title: t.title,
|
|
212
|
+
status: t.status,
|
|
213
|
+
priority: t.priority,
|
|
214
|
+
parentId: t.parentId,
|
|
215
|
+
hasResult: !!t.result,
|
|
216
|
+
}));
|
|
217
|
+
|
|
191
218
|
// Sort: in_progress first, then pending, then others
|
|
192
219
|
const ORDER = { in_progress: 0, pending: 1, blocked: 2, completed: 3, cancelled: 4 };
|
|
193
|
-
|
|
220
|
+
taskItems.sort((a, b) => (ORDER[a.status] ?? 5) - (ORDER[b.status] ?? 5));
|
|
194
221
|
|
|
195
222
|
return JSON.stringify({
|
|
196
|
-
tasks:
|
|
197
|
-
totalCount:
|
|
223
|
+
tasks: taskItems,
|
|
224
|
+
totalCount: taskItems.length,
|
|
198
225
|
summary: {
|
|
199
|
-
pending:
|
|
200
|
-
in_progress:
|
|
201
|
-
completed:
|
|
202
|
-
blocked:
|
|
226
|
+
pending: taskItems.filter(t => t.status === 'pending').length,
|
|
227
|
+
in_progress: taskItems.filter(t => t.status === 'in_progress').length,
|
|
228
|
+
completed: taskItems.filter(t => t.status === 'completed').length,
|
|
229
|
+
blocked: taskItems.filter(t => t.status === 'blocked').length,
|
|
203
230
|
},
|
|
204
231
|
}, null, 2);
|
|
205
232
|
},
|
|
@@ -209,7 +236,7 @@ Shows task IDs, titles, status, and priority. Filter by status if needed.`,
|
|
|
209
236
|
|
|
210
237
|
export const taskGet = defineTool({
|
|
211
238
|
name: 'TaskGet',
|
|
212
|
-
description: `Get detailed information about a specific task.`,
|
|
239
|
+
description: `Get detailed information about a specific task, including its progress log and memory.`,
|
|
213
240
|
parameters: {
|
|
214
241
|
type: 'object',
|
|
215
242
|
properties: {
|
|
@@ -224,27 +251,141 @@ export const taskGet = defineTool({
|
|
|
224
251
|
isConcurrencySafe: () => true,
|
|
225
252
|
isReadOnly: () => true,
|
|
226
253
|
async execute(input, ctx) {
|
|
254
|
+
const err = requireStore();
|
|
255
|
+
if (err) return err;
|
|
256
|
+
|
|
227
257
|
const { task_id } = input;
|
|
228
258
|
if (!task_id) return JSON.stringify({ error: 'task_id is required' });
|
|
229
259
|
|
|
230
|
-
const task =
|
|
260
|
+
const task = taskStore.get(task_id);
|
|
231
261
|
if (!task) return JSON.stringify({ error: `Task not found: ${task_id}` });
|
|
232
262
|
|
|
233
263
|
// Find subtasks
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
}
|
|
239
|
-
}
|
|
264
|
+
const allTasks = taskStore.list();
|
|
265
|
+
const subtasks = allTasks
|
|
266
|
+
.filter(t => t.parentId === task_id)
|
|
267
|
+
.map(t => ({ id: t.id, title: t.title, status: t.status }));
|
|
240
268
|
|
|
241
269
|
return JSON.stringify({
|
|
242
270
|
...task,
|
|
243
271
|
subtasks,
|
|
272
|
+
hasProgress: !!taskStore.getProgress(task_id),
|
|
273
|
+
hasMemory: !!taskStore.getMemory(task_id),
|
|
244
274
|
}, null, 2);
|
|
245
275
|
},
|
|
246
276
|
});
|
|
247
277
|
|
|
278
|
+
// ─── TaskProgress ───────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
export const taskProgress = defineTool({
|
|
281
|
+
name: 'TaskProgress',
|
|
282
|
+
description: `View or append to a task's progress log.
|
|
283
|
+
|
|
284
|
+
The progress log is an append-only timeline of what happened during task execution.
|
|
285
|
+
Use "view" to see the full log, or "append" to add a new entry.`,
|
|
286
|
+
parameters: {
|
|
287
|
+
type: 'object',
|
|
288
|
+
properties: {
|
|
289
|
+
task_id: {
|
|
290
|
+
type: 'string',
|
|
291
|
+
description: 'Task ID',
|
|
292
|
+
},
|
|
293
|
+
action: {
|
|
294
|
+
type: 'string',
|
|
295
|
+
enum: ['view', 'append'],
|
|
296
|
+
description: '"view" shows progress log, "append" adds an entry',
|
|
297
|
+
},
|
|
298
|
+
note: {
|
|
299
|
+
type: 'string',
|
|
300
|
+
description: 'Progress note to append (required for "append")',
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
required: ['task_id', 'action'],
|
|
304
|
+
},
|
|
305
|
+
modes: ['work'],
|
|
306
|
+
isConcurrencySafe: () => false,
|
|
307
|
+
isReadOnly: (input) => input?.action === 'view',
|
|
308
|
+
async execute(input, ctx) {
|
|
309
|
+
const err = requireStore();
|
|
310
|
+
if (err) return err;
|
|
311
|
+
|
|
312
|
+
const { task_id, action, note } = input;
|
|
313
|
+
if (!task_id) return JSON.stringify({ error: 'task_id is required' });
|
|
314
|
+
|
|
315
|
+
const task = taskStore.get(task_id);
|
|
316
|
+
if (!task) return JSON.stringify({ error: `Task not found: ${task_id}` });
|
|
317
|
+
|
|
318
|
+
switch (action) {
|
|
319
|
+
case 'view':
|
|
320
|
+
return taskStore.getProgress(task_id) || '(No progress entries yet)';
|
|
321
|
+
|
|
322
|
+
case 'append':
|
|
323
|
+
if (!note) return JSON.stringify({ error: 'note is required for "append"' });
|
|
324
|
+
taskStore.appendProgress(task_id, note, { status: task.status });
|
|
325
|
+
return JSON.stringify({ success: true, message: `Progress noted for "${task.title}"` });
|
|
326
|
+
|
|
327
|
+
default:
|
|
328
|
+
return JSON.stringify({ error: `Unknown action: ${action}` });
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
// ─── TaskMemory ─────────────────────────────────────────
|
|
334
|
+
|
|
335
|
+
export const taskMemory = defineTool({
|
|
336
|
+
name: 'TaskMemory',
|
|
337
|
+
description: `View or update a task's memory (context notes, key decisions, references).
|
|
338
|
+
|
|
339
|
+
Task memory stores persistent context relevant to the task — key decisions,
|
|
340
|
+
references to files, architectural notes, etc. Unlike progress (append-only),
|
|
341
|
+
memory can be rewritten to keep it current.`,
|
|
342
|
+
parameters: {
|
|
343
|
+
type: 'object',
|
|
344
|
+
properties: {
|
|
345
|
+
task_id: {
|
|
346
|
+
type: 'string',
|
|
347
|
+
description: 'Task ID',
|
|
348
|
+
},
|
|
349
|
+
action: {
|
|
350
|
+
type: 'string',
|
|
351
|
+
enum: ['view', 'update'],
|
|
352
|
+
description: '"view" shows memory, "update" replaces it',
|
|
353
|
+
},
|
|
354
|
+
content: {
|
|
355
|
+
type: 'string',
|
|
356
|
+
description: 'New memory content (required for "update")',
|
|
357
|
+
},
|
|
358
|
+
},
|
|
359
|
+
required: ['task_id', 'action'],
|
|
360
|
+
},
|
|
361
|
+
modes: ['work'],
|
|
362
|
+
isConcurrencySafe: () => false,
|
|
363
|
+
isReadOnly: (input) => input?.action === 'view',
|
|
364
|
+
async execute(input, ctx) {
|
|
365
|
+
const err = requireStore();
|
|
366
|
+
if (err) return err;
|
|
367
|
+
|
|
368
|
+
const { task_id, action, content } = input;
|
|
369
|
+
if (!task_id) return JSON.stringify({ error: 'task_id is required' });
|
|
370
|
+
|
|
371
|
+
const task = taskStore.get(task_id);
|
|
372
|
+
if (!task) return JSON.stringify({ error: `Task not found: ${task_id}` });
|
|
373
|
+
|
|
374
|
+
switch (action) {
|
|
375
|
+
case 'view':
|
|
376
|
+
return taskStore.getMemory(task_id) || '(No memory entries yet)';
|
|
377
|
+
|
|
378
|
+
case 'update':
|
|
379
|
+
if (!content) return JSON.stringify({ error: 'content is required for "update"' });
|
|
380
|
+
taskStore.updateMemory(task_id, content);
|
|
381
|
+
return JSON.stringify({ success: true, message: `Memory updated for "${task.title}"` });
|
|
382
|
+
|
|
383
|
+
default:
|
|
384
|
+
return JSON.stringify({ error: `Unknown action: ${action}` });
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
});
|
|
388
|
+
|
|
248
389
|
// ─── FollowupTask ───────────────────────────────────────
|
|
249
390
|
|
|
250
391
|
export const followupTask = defineTool({
|
|
@@ -279,11 +420,14 @@ The new task is linked as a child of the original.`,
|
|
|
279
420
|
isConcurrencySafe: () => false,
|
|
280
421
|
isReadOnly: () => false,
|
|
281
422
|
async execute(input, ctx) {
|
|
423
|
+
const err = requireStore();
|
|
424
|
+
if (err) return err;
|
|
425
|
+
|
|
282
426
|
const { parent_task_id, title, description, priority = 'medium' } = input;
|
|
283
427
|
if (!parent_task_id) return JSON.stringify({ error: 'parent_task_id is required' });
|
|
284
428
|
if (!title) return JSON.stringify({ error: 'title is required' });
|
|
285
429
|
|
|
286
|
-
const parent =
|
|
430
|
+
const parent = taskStore.get(parent_task_id);
|
|
287
431
|
if (!parent) return JSON.stringify({ error: `Parent task not found: ${parent_task_id}` });
|
|
288
432
|
|
|
289
433
|
const id = `task-${randomUUID().slice(0, 8)}`;
|
|
@@ -298,7 +442,7 @@ The new task is linked as a child of the original.`,
|
|
|
298
442
|
updatedAt: Date.now(),
|
|
299
443
|
};
|
|
300
444
|
|
|
301
|
-
|
|
445
|
+
taskStore.create(task);
|
|
302
446
|
|
|
303
447
|
return JSON.stringify({
|
|
304
448
|
success: true,
|
|
@@ -335,21 +479,27 @@ approach, steps, and status of the current work.`,
|
|
|
335
479
|
isConcurrencySafe: () => false,
|
|
336
480
|
isReadOnly: (input) => input?.action === 'view',
|
|
337
481
|
async execute(input, ctx) {
|
|
482
|
+
const err = requireStore();
|
|
483
|
+
if (err) return err;
|
|
484
|
+
|
|
338
485
|
const { action, content } = input;
|
|
339
486
|
|
|
340
487
|
switch (action) {
|
|
341
488
|
case 'view':
|
|
342
|
-
return
|
|
489
|
+
return taskStore.getPlan() || '(No plan set yet)';
|
|
343
490
|
|
|
344
491
|
case 'update':
|
|
345
492
|
if (!content) return JSON.stringify({ error: 'content is required for "update"' });
|
|
346
|
-
|
|
493
|
+
taskStore.setPlan(content);
|
|
347
494
|
return JSON.stringify({ success: true, message: 'Plan updated', length: content.length });
|
|
348
495
|
|
|
349
|
-
case 'append':
|
|
496
|
+
case 'append': {
|
|
350
497
|
if (!content) return JSON.stringify({ error: 'content is required for "append"' });
|
|
351
|
-
|
|
352
|
-
|
|
498
|
+
const existing = taskStore.getPlan();
|
|
499
|
+
const newPlan = existing ? `${existing}\n\n${content}` : content;
|
|
500
|
+
taskStore.setPlan(newPlan);
|
|
501
|
+
return JSON.stringify({ success: true, message: 'Plan updated (appended)', length: newPlan.length });
|
|
502
|
+
}
|
|
353
503
|
|
|
354
504
|
default:
|
|
355
505
|
return JSON.stringify({ error: `Unknown action: ${action}` });
|
package/unify/web-bridge.js
CHANGED
|
@@ -103,6 +103,12 @@ export async function handleUnifyChat(msg) {
|
|
|
103
103
|
// Create a stable conversationId for the Unify session
|
|
104
104
|
unifyConversationId = `unify-${Date.now()}`;
|
|
105
105
|
|
|
106
|
+
// Restore conversationMessages from persisted history for LLM context
|
|
107
|
+
const recent = session.conversationStore.loadRecent(50);
|
|
108
|
+
conversationMessages = recent
|
|
109
|
+
.filter(m => m.role === 'user' || m.role === 'assistant')
|
|
110
|
+
.map(m => ({ role: m.role, content: m.content }));
|
|
111
|
+
|
|
106
112
|
// Notify UI: session is ready with model info + conversationId
|
|
107
113
|
sendUnifyEvent({
|
|
108
114
|
type: 'session_ready',
|
|
@@ -433,6 +439,69 @@ export function handleUnifyModelSwitch(msg) {
|
|
|
433
439
|
});
|
|
434
440
|
}
|
|
435
441
|
|
|
442
|
+
/**
|
|
443
|
+
* Handle history load request from the web UI.
|
|
444
|
+
* Loads recent messages from ConversationStore and sends them through
|
|
445
|
+
* the standard claude_output rendering pipeline (sendUnifyOutput).
|
|
446
|
+
*
|
|
447
|
+
* @param {{ limit?: number }} msg
|
|
448
|
+
*/
|
|
449
|
+
export async function handleUnifyLoadHistory(msg) {
|
|
450
|
+
// Lazy-init session if needed (same logic as handleUnifyChat)
|
|
451
|
+
if (!session) {
|
|
452
|
+
const yeaftDir = ctx.CONFIG?.yeaftDir;
|
|
453
|
+
session = await loadSession({
|
|
454
|
+
...(yeaftDir && { dir: yeaftDir }),
|
|
455
|
+
skipMCP: false,
|
|
456
|
+
skipSkills: false,
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
unifyConversationId = `unify-${Date.now()}`;
|
|
460
|
+
|
|
461
|
+
// Restore conversationMessages from persisted history for LLM context
|
|
462
|
+
const recent = session.conversationStore.loadRecent(50);
|
|
463
|
+
conversationMessages = recent
|
|
464
|
+
.filter(m => m.role === 'user' || m.role === 'assistant')
|
|
465
|
+
.map(m => ({ role: m.role, content: m.content }));
|
|
466
|
+
|
|
467
|
+
sendUnifyEvent({
|
|
468
|
+
type: 'session_ready',
|
|
469
|
+
conversationId: unifyConversationId,
|
|
470
|
+
model: session.config.model,
|
|
471
|
+
availableModels: session.config.availableModels || [],
|
|
472
|
+
skills: session.status.skills,
|
|
473
|
+
mcpServers: session.status.mcpServers,
|
|
474
|
+
tools: session.status.tools,
|
|
475
|
+
});
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const limit = msg.limit || 50;
|
|
479
|
+
const messages = session.conversationStore.loadRecent(limit);
|
|
480
|
+
const compactSummary = session.conversationStore.readCompactSummary();
|
|
481
|
+
|
|
482
|
+
// Send each message through standard claude_output rendering pipeline
|
|
483
|
+
for (const m of messages) {
|
|
484
|
+
if (m.role === 'user') {
|
|
485
|
+
sendUnifyOutput({ type: 'user', message: { content: m.content } });
|
|
486
|
+
} else if (m.role === 'assistant') {
|
|
487
|
+
sendUnifyOutput({
|
|
488
|
+
type: 'assistant',
|
|
489
|
+
message: { content: [{ type: 'text', text: m.content }] },
|
|
490
|
+
});
|
|
491
|
+
sendUnifyOutput({ type: 'result', result_text: '' });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// Signal history loading complete
|
|
496
|
+
sendUnifyEvent({
|
|
497
|
+
type: 'history_loaded',
|
|
498
|
+
count: messages.length,
|
|
499
|
+
hasCompactSummary: !!compactSummary,
|
|
500
|
+
totalHot: session.conversationStore.countHot(),
|
|
501
|
+
totalCold: session.conversationStore.countCold(),
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
|
|
436
505
|
/**
|
|
437
506
|
* Reset Unify session (for clear messages).
|
|
438
507
|
*/
|