@yeaft/webchat-agent 0.1.408 → 0.1.410

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/unify/prompts.js CHANGED
@@ -4,7 +4,11 @@
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
- * To add a new language: add a new key to PROMPTS with all required fields.
7
+ * Phase 2 additions:
8
+ * - Memory section (user profile + recalled entries)
9
+ * - Compact summary section (conversation history summary)
10
+ *
11
+ * Reference: yeaft-unify-system-prompt-budget.md — Static + Dynamic + Context layers
8
12
  */
9
13
 
10
14
  // ─── Prompt Templates ─────────────────────────────────────────
@@ -17,6 +21,10 @@ const PROMPTS = {
17
21
  work: 'You are in work mode. Break tasks into steps, execute them using tools, and report progress.',
18
22
  dream: 'You are in dream mode. Reflect on past conversations and consolidate memories.',
19
23
  tools: (names) => `Available tools: ${names}`,
24
+ memoryHeader: '## User Memory',
25
+ profileHeader: '### User Profile',
26
+ recalledHeader: '### Recalled Memories',
27
+ compactHeader: '## Conversation History Summary',
20
28
  },
21
29
  zh: {
22
30
  identity: '你是 Yeaft,一个有用的 AI 助手。',
@@ -25,6 +33,10 @@ const PROMPTS = {
25
33
  work: '你处于工作模式。将任务分解为步骤,使用工具执行,并报告进度。',
26
34
  dream: '你处于梦境模式。回顾过去的对话,整理和巩固记忆。',
27
35
  tools: (names) => `可用工具:${names}`,
36
+ memoryHeader: '## 用户记忆',
37
+ profileHeader: '### 用户画像',
38
+ recalledHeader: '### 相关记忆',
39
+ compactHeader: '## 对话历史摘要',
28
40
  },
29
41
  };
30
42
 
@@ -34,10 +46,22 @@ export const SUPPORTED_LANGUAGES = Object.keys(PROMPTS);
34
46
  /**
35
47
  * Build the system prompt for a given language and mode.
36
48
  *
37
- * @param {{ language?: string, mode?: string, toolNames?: string[] }} params
49
+ * @param {{
50
+ * language?: string,
51
+ * mode?: string,
52
+ * toolNames?: string[],
53
+ * memory?: { profile?: string, entries?: object[] },
54
+ * compactSummary?: string
55
+ * }} params
38
56
  * @returns {string}
39
57
  */
40
- export function buildSystemPrompt({ language = 'en', mode = 'chat', toolNames = [] } = {}) {
58
+ export function buildSystemPrompt({
59
+ language = 'en',
60
+ mode = 'chat',
61
+ toolNames = [],
62
+ memory,
63
+ compactSummary,
64
+ } = {}) {
41
65
  // Fallback to English for unknown languages
42
66
  const lang = PROMPTS[language] || PROMPTS.en;
43
67
 
@@ -57,5 +81,29 @@ export function buildSystemPrompt({ language = 'en', mode = 'chat', toolNames =
57
81
  parts.push(lang.tools(toolNames.join(', ')));
58
82
  }
59
83
 
84
+ // ─── Memory Section ─────────────────────────────────────
85
+ if (memory && (memory.profile || (memory.entries && memory.entries.length > 0))) {
86
+ const memoryParts = [lang.memoryHeader];
87
+
88
+ if (memory.profile) {
89
+ memoryParts.push(`${lang.profileHeader}\n${memory.profile}`);
90
+ }
91
+
92
+ if (memory.entries && memory.entries.length > 0) {
93
+ const entryLines = memory.entries.map(e => {
94
+ const tags = (e.tags && e.tags.length > 0) ? ` [${e.tags.join(', ')}]` : '';
95
+ return `- **${e.name}** (${e.kind}): ${e.content}${tags}`;
96
+ });
97
+ memoryParts.push(`${lang.recalledHeader}\n${entryLines.join('\n')}`);
98
+ }
99
+
100
+ parts.push(memoryParts.join('\n\n'));
101
+ }
102
+
103
+ // ─── Compact Summary Section ────────────────────────────
104
+ if (compactSummary) {
105
+ parts.push(`${lang.compactHeader}\n${compactSummary}`);
106
+ }
107
+
60
108
  return parts.join('\n\n');
61
109
  }
@@ -0,0 +1,315 @@
1
+ /**
2
+ * skills.js — Skill loading and management
3
+ *
4
+ * Skills are markdown files in ~/.yeaft/skills/ that define
5
+ * specialized behaviors or workflows. They are loaded at startup
6
+ * and injected into the system prompt when relevant.
7
+ *
8
+ * Skill format (skills/my-skill.md):
9
+ * ---
10
+ * name: my-skill
11
+ * description: Does something useful
12
+ * trigger: "when user asks about X"
13
+ * mode: chat | work | both
14
+ * ---
15
+ * # Skill instructions here...
16
+ *
17
+ * Reference: yeaft-unify-design.md §8, yeaft-unify-core-systems.md
18
+ */
19
+
20
+ import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync } from 'fs';
21
+ import { join, basename } from 'path';
22
+
23
+ // ─── Skill Parsing ─────────────────────────────────────────
24
+
25
+ /**
26
+ * @typedef {Object} Skill
27
+ * @property {string} name — unique skill name
28
+ * @property {string} description — human-readable description
29
+ * @property {string} trigger — when this skill should be invoked
30
+ * @property {string} mode — 'chat' | 'work' | 'both'
31
+ * @property {string} content — full skill instructions (markdown body)
32
+ * @property {string} _filename — source filename
33
+ */
34
+
35
+ /**
36
+ * Parse a skill .md file.
37
+ *
38
+ * @param {string} raw — raw file content
39
+ * @param {string} filename — source filename
40
+ * @returns {Skill|null}
41
+ */
42
+ export function parseSkill(raw, filename = '') {
43
+ if (!raw || !raw.startsWith('---')) return null;
44
+
45
+ const endIdx = raw.indexOf('\n---', 3);
46
+ if (endIdx === -1) return null;
47
+
48
+ const frontmatter = raw.slice(4, endIdx).trim();
49
+ const body = raw.slice(endIdx + 4).trim();
50
+
51
+ const skill = {
52
+ content: body,
53
+ _filename: filename,
54
+ mode: 'both',
55
+ };
56
+
57
+ for (const line of frontmatter.split('\n')) {
58
+ const colonIdx = line.indexOf(':');
59
+ if (colonIdx === -1) continue;
60
+ const key = line.slice(0, colonIdx).trim();
61
+ const value = line.slice(colonIdx + 1).trim();
62
+
63
+ switch (key) {
64
+ case 'name': skill.name = value; break;
65
+ case 'description': skill.description = value; break;
66
+ case 'trigger': skill.trigger = value; break;
67
+ case 'mode': skill.mode = value; break;
68
+ }
69
+ }
70
+
71
+ // Use filename as name fallback
72
+ if (!skill.name) {
73
+ skill.name = basename(filename, '.md');
74
+ }
75
+
76
+ return skill;
77
+ }
78
+
79
+ /**
80
+ * Serialize a skill to .md format.
81
+ *
82
+ * @param {Skill} skill
83
+ * @returns {string}
84
+ */
85
+ export function serializeSkill(skill) {
86
+ const fm = [
87
+ '---',
88
+ `name: ${skill.name}`,
89
+ `description: ${skill.description || ''}`,
90
+ `trigger: ${skill.trigger || ''}`,
91
+ `mode: ${skill.mode || 'both'}`,
92
+ '---',
93
+ ];
94
+
95
+ return fm.join('\n') + '\n\n' + (skill.content || '');
96
+ }
97
+
98
+ // ─── SkillManager ──────────────────────────────────────────
99
+
100
+ /**
101
+ * SkillManager — loads, indexes, and queries skills.
102
+ */
103
+ export class SkillManager {
104
+ /** @type {Map<string, Skill>} */
105
+ #skills = new Map();
106
+
107
+ /** @type {string} */
108
+ #skillsDir;
109
+
110
+ /**
111
+ * @param {string} yeaftDir — Yeaft root directory (e.g. ~/.yeaft)
112
+ */
113
+ constructor(yeaftDir) {
114
+ this.#skillsDir = join(yeaftDir, 'skills');
115
+ }
116
+
117
+ /**
118
+ * Load all skills from the skills directory.
119
+ *
120
+ * @returns {{ loaded: number, errors: string[] }}
121
+ */
122
+ load() {
123
+ this.#skills.clear();
124
+ const errors = [];
125
+
126
+ if (!existsSync(this.#skillsDir)) {
127
+ return { loaded: 0, errors: [] };
128
+ }
129
+
130
+ const files = readdirSync(this.#skillsDir).filter(f => f.endsWith('.md'));
131
+
132
+ for (const file of files) {
133
+ try {
134
+ const raw = readFileSync(join(this.#skillsDir, file), 'utf8');
135
+ const skill = parseSkill(raw, file);
136
+ if (skill && skill.name) {
137
+ this.#skills.set(skill.name, skill);
138
+ } else {
139
+ errors.push(`Failed to parse skill: ${file}`);
140
+ }
141
+ } catch (err) {
142
+ errors.push(`Error loading ${file}: ${err.message}`);
143
+ }
144
+ }
145
+
146
+ return { loaded: this.#skills.size, errors };
147
+ }
148
+
149
+ /**
150
+ * Get a skill by name.
151
+ *
152
+ * @param {string} name
153
+ * @returns {Skill|null}
154
+ */
155
+ get(name) {
156
+ return this.#skills.get(name) || null;
157
+ }
158
+
159
+ /**
160
+ * Check if a skill exists.
161
+ *
162
+ * @param {string} name
163
+ * @returns {boolean}
164
+ */
165
+ has(name) {
166
+ return this.#skills.has(name);
167
+ }
168
+
169
+ /**
170
+ * List all skills, optionally filtered by mode.
171
+ *
172
+ * @param {string} [mode] — 'chat' | 'work' | undefined (all)
173
+ * @returns {Skill[]}
174
+ */
175
+ list(mode) {
176
+ const skills = [...this.#skills.values()];
177
+ if (!mode) return skills;
178
+
179
+ return skills.filter(s => s.mode === 'both' || s.mode === mode);
180
+ }
181
+
182
+ /**
183
+ * Find skills relevant to a prompt (simple keyword matching).
184
+ *
185
+ * @param {string} prompt — user's prompt
186
+ * @param {string} [mode] — filter by mode
187
+ * @returns {Skill[]}
188
+ */
189
+ findRelevant(prompt, mode) {
190
+ if (!prompt) return [];
191
+
192
+ const lowerPrompt = prompt.toLowerCase();
193
+ // Strip punctuation and split on whitespace for clean word matching
194
+ const cleanPrompt = lowerPrompt.replace(/[^\w\s]/g, '');
195
+ const promptWords = cleanPrompt.split(/\s+/).filter(w => w.length > 2);
196
+ const skills = this.list(mode);
197
+
198
+ return skills.filter(skill => {
199
+ // Check trigger match — any trigger keyword found in prompt
200
+ if (skill.trigger) {
201
+ const triggerWords = skill.trigger.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2);
202
+ // Count matches: exact word, substring, or shared stem (first 4 chars)
203
+ const matchCount = triggerWords.filter(tw => {
204
+ if (cleanPrompt.includes(tw)) return true;
205
+ // Stem matching: if trigger word and prompt word share a 4+ char prefix
206
+ const twStem = tw.slice(0, Math.min(tw.length, 4));
207
+ return promptWords.some(pw => {
208
+ if (pw.includes(tw) || tw.includes(pw)) return true;
209
+ const pwStem = pw.slice(0, Math.min(pw.length, 4));
210
+ return twStem.length >= 4 && pwStem.length >= 4 && twStem === pwStem;
211
+ });
212
+ }).length;
213
+ // At least 1 meaningful match and ≥30% of trigger words
214
+ if (matchCount >= 1 && matchCount >= Math.ceil(triggerWords.length * 0.3)) {
215
+ return true;
216
+ }
217
+ }
218
+
219
+ // Check name match
220
+ if (lowerPrompt.includes(skill.name.toLowerCase())) {
221
+ return true;
222
+ }
223
+
224
+ // Check description match
225
+ if (skill.description && lowerPrompt.includes(skill.description.toLowerCase())) {
226
+ return true;
227
+ }
228
+
229
+ return false;
230
+ });
231
+ }
232
+
233
+ /**
234
+ * Add or update a skill.
235
+ *
236
+ * @param {Skill} skill
237
+ * @returns {string} — filename
238
+ */
239
+ save(skill) {
240
+ if (!skill.name) throw new Error('Skill must have a name');
241
+
242
+ const filename = `${skill.name}.md`;
243
+ const filePath = join(this.#skillsDir, filename);
244
+
245
+ writeFileSync(filePath, serializeSkill(skill), 'utf8');
246
+ this.#skills.set(skill.name, { ...skill, _filename: filename });
247
+
248
+ return filename;
249
+ }
250
+
251
+ /**
252
+ * Remove a skill.
253
+ *
254
+ * @param {string} name
255
+ * @returns {boolean}
256
+ */
257
+ remove(name) {
258
+ const skill = this.#skills.get(name);
259
+ if (!skill) return false;
260
+
261
+ const filePath = join(this.#skillsDir, skill._filename || `${name}.md`);
262
+ try {
263
+ unlinkSync(filePath);
264
+ } catch {
265
+ // File might not exist
266
+ }
267
+
268
+ this.#skills.delete(name);
269
+ return true;
270
+ }
271
+
272
+ /**
273
+ * Get the skill content formatted for system prompt injection.
274
+ *
275
+ * @param {string} name
276
+ * @returns {string}
277
+ */
278
+ getPromptContent(name) {
279
+ const skill = this.#skills.get(name);
280
+ if (!skill) return '';
281
+
282
+ return `## Skill: ${skill.name}\n\n${skill.content}`;
283
+ }
284
+
285
+ /**
286
+ * Get all relevant skill contents for a prompt.
287
+ *
288
+ * @param {string} prompt
289
+ * @param {string} [mode]
290
+ * @returns {string}
291
+ */
292
+ getRelevantPromptContent(prompt, mode) {
293
+ const relevant = this.findRelevant(prompt, mode);
294
+ if (relevant.length === 0) return '';
295
+
296
+ return relevant.map(s => this.getPromptContent(s.name)).join('\n\n');
297
+ }
298
+
299
+ /** Number of loaded skills. */
300
+ get size() {
301
+ return this.#skills.size;
302
+ }
303
+ }
304
+
305
+ /**
306
+ * Create a SkillManager and load skills.
307
+ *
308
+ * @param {string} yeaftDir
309
+ * @returns {SkillManager}
310
+ */
311
+ export function createSkillManager(yeaftDir) {
312
+ const manager = new SkillManager(yeaftDir);
313
+ manager.load();
314
+ return manager;
315
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * stop-hooks.js — Post-query lifecycle hooks
3
+ *
4
+ * Runs after each query loop completes:
5
+ * 1. Persist messages to conversation/messages/
6
+ * 2. Consolidate check (compact + extract) — only when budget exceeded
7
+ * 3. Dream gate check (background)
8
+ * 4. Increment dream query counter
9
+ *
10
+ * Reference: yeaft-unify-core-systems.md §4.4
11
+ */
12
+
13
+ import { shouldConsolidate, consolidate } from './memory/consolidate.js';
14
+ import { checkDreamGate, incrementQueryCount, dream } from './memory/dream.js';
15
+
16
+ /**
17
+ * Run all stop hooks after a query completes.
18
+ *
19
+ * @param {{
20
+ * yeaftDir: string,
21
+ * mode: string,
22
+ * conversationStore: import('./conversation/persist.js').ConversationStore,
23
+ * memoryStore: import('./memory/store.js').MemoryStore,
24
+ * adapter: object,
25
+ * config: object,
26
+ * messages?: object[],
27
+ * taskId?: string,
28
+ * workerId?: string,
29
+ * trace?: object,
30
+ * }} context
31
+ * @returns {Promise<StopHookResult>}
32
+ */
33
+ export async function runStopHooks(context) {
34
+ const {
35
+ yeaftDir,
36
+ mode,
37
+ conversationStore,
38
+ memoryStore,
39
+ adapter,
40
+ config,
41
+ messages = [],
42
+ taskId,
43
+ trace,
44
+ } = context;
45
+
46
+ const result = {
47
+ messagesPersisted: 0,
48
+ consolidated: false,
49
+ dreamTriggered: false,
50
+ errors: [],
51
+ };
52
+
53
+ // Workers don't run stop hooks (they persist to task workers/ dir)
54
+ if (mode === 'worker') {
55
+ return result;
56
+ }
57
+
58
+ // 1. Persist latest messages
59
+ try {
60
+ if (conversationStore && messages.length > 0) {
61
+ const recentMessages = messages.slice(-2); // last user + assistant pair
62
+ for (const msg of recentMessages) {
63
+ if (msg.role && msg.content) {
64
+ conversationStore.append({
65
+ role: msg.role,
66
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
67
+ mode,
68
+ model: config.model,
69
+ });
70
+ result.messagesPersisted++;
71
+ }
72
+ }
73
+ }
74
+ } catch (err) {
75
+ result.errors.push(`Persist failed: ${err.message}`);
76
+ }
77
+
78
+ // 2. Consolidate check (non-blocking, but awaited for correctness)
79
+ try {
80
+ if (conversationStore && memoryStore && adapter) {
81
+ if (shouldConsolidate(conversationStore, config.messageTokenBudget)) {
82
+ const consolidated = await consolidate({
83
+ conversationStore,
84
+ memoryStore,
85
+ adapter,
86
+ config,
87
+ budget: config.messageTokenBudget,
88
+ });
89
+ result.consolidated = true;
90
+ trace?.logEvent({
91
+ eventType: 'consolidate',
92
+ eventData: {
93
+ archivedCount: consolidated.archivedCount,
94
+ extractedEntries: consolidated.extractedEntries.length,
95
+ },
96
+ });
97
+ }
98
+ }
99
+ } catch (err) {
100
+ result.errors.push(`Consolidate failed: ${err.message}`);
101
+ }
102
+
103
+ // 3. Increment dream query counter
104
+ try {
105
+ if (yeaftDir) {
106
+ incrementQueryCount(yeaftDir);
107
+ }
108
+ } catch (err) {
109
+ result.errors.push(`Dream counter failed: ${err.message}`);
110
+ }
111
+
112
+ // 4. Dream gate check (fire-and-forget, background)
113
+ try {
114
+ if (yeaftDir && memoryStore && adapter) {
115
+ const gate = checkDreamGate(yeaftDir);
116
+ if (gate.shouldDream) {
117
+ result.dreamTriggered = true;
118
+ // Fire and forget — dream runs in background
119
+ dream({
120
+ yeaftDir,
121
+ memoryStore,
122
+ conversationStore,
123
+ adapter,
124
+ config,
125
+ }).catch(err => {
126
+ trace?.logEvent({
127
+ eventType: 'dream_error',
128
+ eventData: { error: err.message },
129
+ });
130
+ });
131
+ }
132
+ }
133
+ } catch (err) {
134
+ result.errors.push(`Dream gate check failed: ${err.message}`);
135
+ }
136
+
137
+ return result;
138
+ }
139
+
140
+ /**
141
+ * @typedef {Object} StopHookResult
142
+ * @property {number} messagesPersisted — how many messages were persisted
143
+ * @property {boolean} consolidated — whether consolidation ran
144
+ * @property {boolean} dreamTriggered — whether dream was triggered
145
+ * @property {string[]} errors — any non-fatal errors
146
+ */
@@ -0,0 +1,97 @@
1
+ /**
2
+ * enter-worktree.js — Create an isolated git worktree for development
3
+ *
4
+ * Creates a new git worktree with an isolated branch, useful for
5
+ * sub-agents that need to work on files without conflicting with
6
+ * the main working tree or other workers.
7
+ *
8
+ * Reference: yeaft-unify-design.md §8, yeaft-unify-core-systems.md §3.2
9
+ */
10
+
11
+ import { defineTool } from './types.js';
12
+ import { execSync } from 'child_process';
13
+ import { existsSync, mkdirSync } from 'fs';
14
+ import { join, resolve } from 'path';
15
+ import { randomUUID } from 'crypto';
16
+
17
+ export default defineTool({
18
+ name: 'EnterWorktree',
19
+ description: `Create an isolated git worktree for development.
20
+
21
+ Creates a new git worktree with a dedicated branch, allowing parallel
22
+ development without file conflicts. Useful for:
23
+ - Sub-agents working on independent subtasks
24
+ - Testing changes in isolation before merging
25
+ - Parallel feature development
26
+
27
+ The worktree is created in .yeaft/worktrees/ with a new branch based on HEAD.
28
+ Returns the worktree path and branch name.`,
29
+ parameters: {
30
+ type: 'object',
31
+ properties: {
32
+ name: {
33
+ type: 'string',
34
+ description: 'Name for the worktree (used in path and branch name). If omitted, a random name is generated.',
35
+ },
36
+ base_ref: {
37
+ type: 'string',
38
+ description: 'Git ref to base the worktree on (default: HEAD)',
39
+ },
40
+ },
41
+ },
42
+ modes: ['work'],
43
+ isDestructive: () => false,
44
+ async execute(input, ctx) {
45
+ const cwd = ctx?.cwd || process.cwd();
46
+
47
+ // Verify we're in a git repo
48
+ try {
49
+ execSync('git rev-parse --git-dir', { cwd, stdio: 'pipe' });
50
+ } catch {
51
+ return JSON.stringify({ error: 'Not in a git repository' });
52
+ }
53
+
54
+ // Generate worktree name and path
55
+ const name = input.name
56
+ ? input.name.replace(/[^a-zA-Z0-9._-]/g, '-').slice(0, 64)
57
+ : `wt-${randomUUID().slice(0, 8)}`;
58
+
59
+ const worktreeDir = join(cwd, '.yeaft', 'worktrees', name);
60
+ const branchName = `yeaft-wt/${name}`;
61
+ const baseRef = input.base_ref || 'HEAD';
62
+
63
+ // Check if worktree already exists
64
+ if (existsSync(worktreeDir)) {
65
+ return JSON.stringify({
66
+ error: `Worktree "${name}" already exists at ${worktreeDir}`,
67
+ path: worktreeDir,
68
+ branch: branchName,
69
+ });
70
+ }
71
+
72
+ // Ensure parent directory exists
73
+ const parentDir = join(cwd, '.yeaft', 'worktrees');
74
+ if (!existsSync(parentDir)) {
75
+ mkdirSync(parentDir, { recursive: true });
76
+ }
77
+
78
+ try {
79
+ // Create worktree with new branch
80
+ const cmd = `git worktree add -b "${branchName}" "${worktreeDir}" ${baseRef}`;
81
+ execSync(cmd, { cwd, stdio: 'pipe' });
82
+
83
+ return JSON.stringify({
84
+ success: true,
85
+ path: resolve(worktreeDir),
86
+ branch: branchName,
87
+ baseRef,
88
+ name,
89
+ message: `Created worktree "${name}" at ${worktreeDir} on branch ${branchName}`,
90
+ });
91
+ } catch (err) {
92
+ return JSON.stringify({
93
+ error: `Failed to create worktree: ${err.message}`,
94
+ });
95
+ }
96
+ },
97
+ });