@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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.446",
3
+ "version": "0.1.447",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
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 };
@@ -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 — in-memory task tracking for work mode.
2
+ * Task management tools — persistent task tracking for work mode.
3
3
  *
4
- * Tasks are organized in a simple flat list with status tracking.
5
- * Persisted only in memory for the session duration.
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
- /** In-memory task store. */
12
- const tasks = new Map();
12
+ /** @type {TaskStore|null} */
13
+ let taskStore = null;
13
14
 
14
- /** Plan text (free-form markdown). */
15
- let currentPlan = '';
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 tasks;
27
+ return taskStore;
20
28
  }
21
29
 
30
+ /** Get current plan text. */
22
31
  export function getPlan() {
23
- return currentPlan;
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
- tasks.set(id, task);
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 task = tasks.get(task_id);
133
- if (!task) return JSON.stringify({ error: `Task not found: ${task_id}` });
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
- if (status) task.status = status;
136
- if (priority) task.priority = priority;
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
- const taskList = [];
178
- for (const [, task] of tasks) {
179
- if (status && task.status !== status) continue;
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
- taskList.sort((a, b) => (ORDER[a.status] ?? 5) - (ORDER[b.status] ?? 5));
220
+ taskItems.sort((a, b) => (ORDER[a.status] ?? 5) - (ORDER[b.status] ?? 5));
194
221
 
195
222
  return JSON.stringify({
196
- tasks: taskList,
197
- totalCount: taskList.length,
223
+ tasks: taskItems,
224
+ totalCount: taskItems.length,
198
225
  summary: {
199
- pending: taskList.filter(t => t.status === 'pending').length,
200
- in_progress: taskList.filter(t => t.status === 'in_progress').length,
201
- completed: taskList.filter(t => t.status === 'completed').length,
202
- blocked: taskList.filter(t => t.status === 'blocked').length,
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 = tasks.get(task_id);
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 subtasks = [];
235
- for (const [, t] of tasks) {
236
- if (t.parentId === task_id) {
237
- subtasks.push({ id: t.id, title: t.title, status: t.status });
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 = tasks.get(parent_task_id);
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
- tasks.set(id, task);
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 currentPlan || '(No plan set yet)';
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
- currentPlan = content;
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
- currentPlan = currentPlan ? `${currentPlan}\n\n${content}` : content;
352
- return JSON.stringify({ success: true, message: 'Plan updated (appended)', length: currentPlan.length });
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}` });
@@ -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
  */