@yeaft/webchat-agent 0.1.445 → 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/prompts.js +142 -12
- 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/prompts.js
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* Single source of truth for system prompts. Both engine.js and cli.js
|
|
5
5
|
* import buildSystemPrompt() from here. Supports 'en' and 'zh'.
|
|
6
6
|
*
|
|
7
|
+
* Template files from agent/unify/templates/ are loaded once at startup
|
|
8
|
+
* and used to enrich the system prompt beyond the hardcoded fallbacks.
|
|
9
|
+
*
|
|
7
10
|
* Phase 2 additions:
|
|
8
11
|
* - Memory section (user profile + recalled entries)
|
|
9
12
|
* - Compact summary section (conversation history summary)
|
|
@@ -11,7 +14,99 @@
|
|
|
11
14
|
* Reference: yeaft-unify-system-prompt-budget.md — Static + Dynamic + Context layers
|
|
12
15
|
*/
|
|
13
16
|
|
|
14
|
-
|
|
17
|
+
import { readFileSync, existsSync } from 'fs';
|
|
18
|
+
import { join, dirname } from 'path';
|
|
19
|
+
import { fileURLToPath } from 'url';
|
|
20
|
+
|
|
21
|
+
// ─── Template Loading (one-time at startup) ──────────────────────
|
|
22
|
+
|
|
23
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
const TEMPLATES_DIR = join(__dirname, 'templates');
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Read a template file from the templates/ directory.
|
|
28
|
+
* Returns empty string if file doesn't exist or can't be read.
|
|
29
|
+
* @param {string} name — filename (e.g. 'base.md')
|
|
30
|
+
* @returns {string}
|
|
31
|
+
*/
|
|
32
|
+
function readTemplate(name) {
|
|
33
|
+
const path = join(TEMPLATES_DIR, name);
|
|
34
|
+
if (!existsSync(path)) return '';
|
|
35
|
+
try {
|
|
36
|
+
return readFileSync(path, 'utf8').trim();
|
|
37
|
+
} catch {
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Extract the section for a given language from a bilingual template.
|
|
44
|
+
* Templates use `<!-- lang:en -->` / `<!-- lang:zh -->` HTML comment markers
|
|
45
|
+
* to delimit language sections. Returns the content between the matching
|
|
46
|
+
* marker and the next marker (or EOF).
|
|
47
|
+
*
|
|
48
|
+
* If no markers exist, returns the full content regardless of language.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} content — full template content
|
|
51
|
+
* @param {string} language — 'en' or 'zh'
|
|
52
|
+
* @returns {string}
|
|
53
|
+
*/
|
|
54
|
+
function extractLangSection(content, language) {
|
|
55
|
+
if (!content) return '';
|
|
56
|
+
|
|
57
|
+
const marker = `<!-- lang:${language} -->`;
|
|
58
|
+
const markerIdx = content.indexOf(marker);
|
|
59
|
+
|
|
60
|
+
if (markerIdx === -1) {
|
|
61
|
+
// No marker for this language — if language is 'zh', try 'en' fallback
|
|
62
|
+
if (language === 'zh') {
|
|
63
|
+
const enMarker = '<!-- lang:en -->';
|
|
64
|
+
const enIdx = content.indexOf(enMarker);
|
|
65
|
+
if (enIdx !== -1) {
|
|
66
|
+
// Has en marker but no zh — return en section as fallback
|
|
67
|
+
return extractLangSection(content, 'en');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// No markers at all — return full content
|
|
71
|
+
if (!content.includes('<!-- lang:')) return content;
|
|
72
|
+
// Has markers but not for this language — fallback to en
|
|
73
|
+
return extractLangSection(content, 'en');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Extract from after the marker to the next <!-- lang: marker or EOF
|
|
77
|
+
const sectionStart = markerIdx + marker.length;
|
|
78
|
+
const nextMarkerIdx = content.indexOf('<!-- lang:', sectionStart);
|
|
79
|
+
|
|
80
|
+
if (nextMarkerIdx === -1) {
|
|
81
|
+
// This is the last section — take everything after the marker
|
|
82
|
+
return content.slice(sectionStart).trim();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return content.slice(sectionStart, nextMarkerIdx).trim();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Loaded templates — read once at module load time. */
|
|
89
|
+
const RAW_TEMPLATES = {
|
|
90
|
+
base: readTemplate('base.md'),
|
|
91
|
+
modeChat: readTemplate('mode-chat.md'),
|
|
92
|
+
modeWorker: readTemplate('mode-worker.md'),
|
|
93
|
+
modeDream: readTemplate('mode-dream.md'),
|
|
94
|
+
toolGuidance: readTemplate('tool-guidance.md'),
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Get a template section for the given language.
|
|
99
|
+
* @param {string} key — template key (e.g. 'base', 'modeChat')
|
|
100
|
+
* @param {string} language — 'en' or 'zh'
|
|
101
|
+
* @returns {string}
|
|
102
|
+
*/
|
|
103
|
+
function getTemplate(key, language) {
|
|
104
|
+
const raw = RAW_TEMPLATES[key];
|
|
105
|
+
if (!raw) return '';
|
|
106
|
+
return extractLangSection(raw, language);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// ─── Prompt Templates (hardcoded fallbacks) ──────────────────────
|
|
15
110
|
|
|
16
111
|
const PROMPTS = {
|
|
17
112
|
en: {
|
|
@@ -46,12 +141,22 @@ export const SUPPORTED_LANGUAGES = Object.keys(PROMPTS);
|
|
|
46
141
|
/**
|
|
47
142
|
* Build the system prompt for a given language and mode.
|
|
48
143
|
*
|
|
144
|
+
* Prompt structure:
|
|
145
|
+
* 1. Core identity (from template or fallback)
|
|
146
|
+
* 2. Mode + date metadata
|
|
147
|
+
* 3. Mode-specific behavioral instructions (from template or fallback)
|
|
148
|
+
* 4. Tool list + tool guidance (from template)
|
|
149
|
+
* 5. Skills section
|
|
150
|
+
* 6. Memory section
|
|
151
|
+
* 7. Compact summary section
|
|
152
|
+
*
|
|
49
153
|
* @param {{
|
|
50
154
|
* language?: string,
|
|
51
155
|
* mode?: string,
|
|
52
156
|
* toolNames?: string[],
|
|
53
157
|
* memory?: { profile?: string, entries?: object[] },
|
|
54
|
-
* compactSummary?: string
|
|
158
|
+
* compactSummary?: string,
|
|
159
|
+
* skillContent?: string,
|
|
55
160
|
* }} params
|
|
56
161
|
* @returns {string}
|
|
57
162
|
*/
|
|
@@ -65,29 +170,54 @@ export function buildSystemPrompt({
|
|
|
65
170
|
} = {}) {
|
|
66
171
|
// Fallback to English for unknown languages
|
|
67
172
|
const lang = PROMPTS[language] || PROMPTS.en;
|
|
173
|
+
const effectiveLang = PROMPTS[language] ? language : 'en';
|
|
68
174
|
|
|
69
|
-
const parts = [
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
175
|
+
const parts = [];
|
|
176
|
+
|
|
177
|
+
// ─── 1. Core Identity ──────────────────────────────────
|
|
178
|
+
// Use template if available, otherwise fallback to hardcoded one-liner
|
|
179
|
+
const baseTemplate = getTemplate('base', effectiveLang);
|
|
180
|
+
if (baseTemplate) {
|
|
181
|
+
parts.push(baseTemplate);
|
|
182
|
+
} else {
|
|
183
|
+
parts.push(lang.identity);
|
|
184
|
+
}
|
|
74
185
|
|
|
186
|
+
// ─── 2. Mode + Date Metadata ───────────────────────────
|
|
187
|
+
parts.push(lang.mode(mode));
|
|
188
|
+
parts.push(lang.date(new Date().toISOString().split('T')[0]));
|
|
189
|
+
|
|
190
|
+
// ─── 3. Mode-Specific Instructions ─────────────────────
|
|
75
191
|
if (mode === 'work') {
|
|
76
|
-
|
|
192
|
+
const workerTemplate = getTemplate('modeWorker', effectiveLang);
|
|
193
|
+
parts.push(workerTemplate || lang.work);
|
|
77
194
|
} else if (mode === 'dream') {
|
|
78
|
-
|
|
195
|
+
const dreamTemplate = getTemplate('modeDream', effectiveLang);
|
|
196
|
+
parts.push(dreamTemplate || lang.dream);
|
|
197
|
+
} else if (mode === 'chat') {
|
|
198
|
+
const chatTemplate = getTemplate('modeChat', effectiveLang);
|
|
199
|
+
if (chatTemplate) {
|
|
200
|
+
parts.push(chatTemplate);
|
|
201
|
+
}
|
|
202
|
+
// No fallback needed — chat mode previously had no instructions
|
|
79
203
|
}
|
|
80
204
|
|
|
205
|
+
// ─── 4. Tools + Tool Guidance ──────────────────────────
|
|
81
206
|
if (toolNames.length > 0) {
|
|
82
207
|
parts.push(lang.tools(toolNames.join(', ')));
|
|
208
|
+
|
|
209
|
+
const toolGuidanceTemplate = getTemplate('toolGuidance', effectiveLang);
|
|
210
|
+
if (toolGuidanceTemplate) {
|
|
211
|
+
parts.push(toolGuidanceTemplate);
|
|
212
|
+
}
|
|
83
213
|
}
|
|
84
214
|
|
|
85
|
-
// ─── Skills Section
|
|
215
|
+
// ─── 5. Skills Section ─────────────────────────────────
|
|
86
216
|
if (skillContent) {
|
|
87
217
|
parts.push(skillContent);
|
|
88
218
|
}
|
|
89
219
|
|
|
90
|
-
// ─── Memory Section
|
|
220
|
+
// ─── 6. Memory Section ─────────────────────────────────
|
|
91
221
|
if (memory && (memory.profile || (memory.entries && memory.entries.length > 0))) {
|
|
92
222
|
const memoryParts = [lang.memoryHeader];
|
|
93
223
|
|
|
@@ -106,7 +236,7 @@ export function buildSystemPrompt({
|
|
|
106
236
|
parts.push(memoryParts.join('\n\n'));
|
|
107
237
|
}
|
|
108
238
|
|
|
109
|
-
// ─── Compact Summary Section
|
|
239
|
+
// ─── 7. Compact Summary Section ────────────────────────
|
|
110
240
|
if (compactSummary) {
|
|
111
241
|
parts.push(`${lang.compactHeader}\n${compactSummary}`);
|
|
112
242
|
}
|
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
|
*/
|