@yeaft/webchat-agent 0.1.408 → 0.1.409

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.
@@ -0,0 +1,507 @@
1
+ /**
2
+ * store.js — Memory CRUD (read/write entries/*.md + MEMORY.md + scopes.md)
3
+ *
4
+ * Memory 3D Model:
5
+ * Kind = WHAT — 6 types: fact, preference, skill, lesson, context, relation
6
+ * Scope = WHERE — dynamic tree path: global / work/project / tech/typescript
7
+ * Tags = HOW — free keywords: [typescript, generics, covariance]
8
+ *
9
+ * Entry format (entries/*.md):
10
+ * ---
11
+ * name: auth-null-check-pattern
12
+ * kind: lesson
13
+ * scope: work/claude-web-chat/auth
14
+ * tags: [null-check, typescript, auth]
15
+ * importance: high
16
+ * frequency: 1
17
+ * created_at: 2026-04-09T14:30:00Z
18
+ * updated_at: 2026-04-09T15:00:00Z
19
+ * ---
20
+ * # Auth Null Check Pattern
21
+ * ...content...
22
+ *
23
+ * Reference: yeaft-unify-design.md §5.1, yeaft-unify-core-systems.md §2.2
24
+ */
25
+
26
+ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, unlinkSync } from 'fs';
27
+ import { join, basename } from 'path';
28
+
29
+ // ─── Constants ──────────────────────────────────────────────────
30
+
31
+ /** Valid memory kinds. */
32
+ export const MEMORY_KINDS = ['fact', 'preference', 'skill', 'lesson', 'context', 'relation'];
33
+
34
+ /** Maximum entries allowed (Dream prunes beyond this). */
35
+ export const MAX_ENTRIES = 200;
36
+
37
+ /** Maximum MEMORY.md line count. */
38
+ export const MAX_MEMORY_LINES = 200;
39
+
40
+ // ─── Entry Parsing ──────────────────────────────────────────────
41
+
42
+ /**
43
+ * Parse a memory entry .md file into an object.
44
+ * @param {string} raw — raw file content
45
+ * @returns {object|null}
46
+ */
47
+ export function parseEntry(raw) {
48
+ if (!raw || !raw.startsWith('---')) return null;
49
+
50
+ const endIdx = raw.indexOf('\n---', 3);
51
+ if (endIdx === -1) return null;
52
+
53
+ const frontmatter = raw.slice(4, endIdx).trim();
54
+ const body = raw.slice(endIdx + 4).trim();
55
+
56
+ const entry = { content: body };
57
+
58
+ for (const line of frontmatter.split('\n')) {
59
+ const colonIdx = line.indexOf(':');
60
+ if (colonIdx === -1) continue;
61
+
62
+ const key = line.slice(0, colonIdx).trim();
63
+ let value = line.slice(colonIdx + 1).trim();
64
+
65
+ switch (key) {
66
+ case 'name': entry.name = value; break;
67
+ case 'kind': entry.kind = value; break;
68
+ case 'scope': entry.scope = value; break;
69
+ case 'importance': entry.importance = value; break;
70
+ case 'frequency': entry.frequency = parseInt(value, 10); break;
71
+ case 'created_at': entry.created_at = value; break;
72
+ case 'updated_at': entry.updated_at = value; break;
73
+ case 'tags': {
74
+ // Parse [tag1, tag2, tag3] or tag1, tag2, tag3
75
+ value = value.replace(/^\[|\]$/g, '');
76
+ entry.tags = value.split(',').map(t => t.trim()).filter(Boolean);
77
+ break;
78
+ }
79
+ case 'related': {
80
+ value = value.replace(/^\[|\]$/g, '');
81
+ entry.related = value.split(',').map(t => t.trim()).filter(Boolean);
82
+ break;
83
+ }
84
+ }
85
+ }
86
+
87
+ return entry;
88
+ }
89
+
90
+ /**
91
+ * Serialize a memory entry to .md format.
92
+ * @param {object} entry
93
+ * @returns {string}
94
+ */
95
+ export function serializeEntry(entry) {
96
+ const fm = [
97
+ '---',
98
+ `name: ${entry.name}`,
99
+ `kind: ${entry.kind || 'fact'}`,
100
+ `scope: ${entry.scope || 'global'}`,
101
+ `tags: [${(entry.tags || []).join(', ')}]`,
102
+ `importance: ${entry.importance || 'normal'}`,
103
+ `frequency: ${entry.frequency || 1}`,
104
+ ];
105
+
106
+ if (entry.related && entry.related.length > 0) {
107
+ fm.push(`related: [${entry.related.join(', ')}]`);
108
+ }
109
+
110
+ fm.push(`created_at: ${entry.created_at || new Date().toISOString()}`);
111
+ fm.push(`updated_at: ${entry.updated_at || new Date().toISOString()}`);
112
+ fm.push('---');
113
+ fm.push('');
114
+ fm.push(entry.content || '');
115
+
116
+ return fm.join('\n');
117
+ }
118
+
119
+ /**
120
+ * Generate a filename-safe slug from a name.
121
+ * @param {string} name
122
+ * @returns {string}
123
+ */
124
+ export function slugify(name) {
125
+ return name
126
+ .toLowerCase()
127
+ .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-') // allow CJK chars
128
+ .replace(/^-+|-+$/g, '')
129
+ .slice(0, 60);
130
+ }
131
+
132
+ // ─── MemoryStore ────────────────────────────────────────────────
133
+
134
+ /**
135
+ * MemoryStore — CRUD for memory entries, MEMORY.md, and scopes.md.
136
+ *
137
+ * Directory layout:
138
+ * memory/
139
+ * MEMORY.md — user profile / knowledge map (<200 lines)
140
+ * scopes.md — scope index (markdown table)
141
+ * entries/ — individual memory entries (flat)
142
+ */
143
+ export class MemoryStore {
144
+ #dir; // root dir (e.g. ~/.yeaft)
145
+ #memoryDir; // ~/.yeaft/memory
146
+ #entriesDir; // ~/.yeaft/memory/entries
147
+ #memoryPath; // ~/.yeaft/memory/MEMORY.md
148
+ #scopesPath; // ~/.yeaft/memory/scopes.md
149
+
150
+ /**
151
+ * @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
152
+ */
153
+ constructor(dir) {
154
+ this.#dir = dir;
155
+ this.#memoryDir = join(dir, 'memory');
156
+ this.#entriesDir = join(dir, 'memory', 'entries');
157
+ this.#memoryPath = join(dir, 'memory', 'MEMORY.md');
158
+ this.#scopesPath = join(dir, 'memory', 'scopes.md');
159
+
160
+ // Ensure directories exist
161
+ for (const d of [this.#memoryDir, this.#entriesDir]) {
162
+ if (!existsSync(d)) mkdirSync(d, { recursive: true });
163
+ }
164
+ }
165
+
166
+ // ─── MEMORY.md (User Profile / Knowledge Map) ──────────
167
+
168
+ /**
169
+ * Read the full MEMORY.md content.
170
+ * @returns {string}
171
+ */
172
+ readProfile() {
173
+ if (!existsSync(this.#memoryPath)) return '';
174
+ return readFileSync(this.#memoryPath, 'utf8');
175
+ }
176
+
177
+ /**
178
+ * Write (overwrite) MEMORY.md.
179
+ * @param {string} content
180
+ */
181
+ writeProfile(content) {
182
+ writeFileSync(this.#memoryPath, content, 'utf8');
183
+ }
184
+
185
+ /**
186
+ * Read a specific section from MEMORY.md.
187
+ * Sections are delimited by ## headers.
188
+ * @param {string} section — e.g. "Facts", "Preferences"
189
+ * @returns {string}
190
+ */
191
+ readSection(section) {
192
+ const content = this.readProfile();
193
+ if (!content) return '';
194
+
195
+ const regex = new RegExp(`^## ${section}\\b[^\\n]*\\n`, 'im');
196
+ const match = content.match(regex);
197
+ if (!match) return '';
198
+
199
+ const startIdx = match.index + match[0].length;
200
+ const nextSection = content.indexOf('\n## ', startIdx);
201
+ const endIdx = nextSection !== -1 ? nextSection : content.length;
202
+
203
+ return content.slice(startIdx, endIdx).trim();
204
+ }
205
+
206
+ /**
207
+ * Add a line to a section in MEMORY.md. Creates the section if it doesn't exist.
208
+ * @param {string} section — e.g. "Facts"
209
+ * @param {string} line — e.g. "- User prefers TypeScript"
210
+ */
211
+ addToSection(section, line) {
212
+ let content = this.readProfile();
213
+
214
+ const sectionHeader = `## ${section}`;
215
+ const headerIdx = content.indexOf(sectionHeader);
216
+
217
+ if (headerIdx === -1) {
218
+ // Section doesn't exist — append it
219
+ content = content.trimEnd() + `\n\n${sectionHeader}\n\n${line}\n`;
220
+ } else {
221
+ // Find end of section
222
+ const afterHeader = headerIdx + sectionHeader.length;
223
+ const nextSectionIdx = content.indexOf('\n## ', afterHeader);
224
+ const insertIdx = nextSectionIdx !== -1 ? nextSectionIdx : content.length;
225
+
226
+ // Insert before next section
227
+ content = content.slice(0, insertIdx).trimEnd() + '\n' + line + '\n' + content.slice(insertIdx);
228
+ }
229
+
230
+ this.writeProfile(content);
231
+ }
232
+
233
+ // ─── Scopes Index ─────────────────────────────────────
234
+
235
+ /**
236
+ * Read scopes.md as a list of { scope, count, lastUpdated }.
237
+ * @returns {object[]}
238
+ */
239
+ readScopes() {
240
+ if (!existsSync(this.#scopesPath)) return [];
241
+
242
+ const content = readFileSync(this.#scopesPath, 'utf8');
243
+ const lines = content.split('\n');
244
+ const scopes = [];
245
+
246
+ for (const line of lines) {
247
+ // Parse markdown table rows: | scope | count | lastUpdated |
248
+ const match = line.match(/^\|\s*([^|]+)\s*\|\s*(\d+)\s*\|\s*([^|]+)\s*\|$/);
249
+ if (match && match[1].trim() !== 'scope' && !match[1].includes('---')) {
250
+ scopes.push({
251
+ scope: match[1].trim(),
252
+ count: parseInt(match[2].trim(), 10),
253
+ lastUpdated: match[3].trim(),
254
+ });
255
+ }
256
+ }
257
+
258
+ return scopes;
259
+ }
260
+
261
+ /**
262
+ * Rebuild scopes.md from current entries.
263
+ */
264
+ rebuildScopes() {
265
+ const entries = this.listEntries();
266
+ const scopeMap = new Map();
267
+
268
+ for (const entry of entries) {
269
+ const scope = entry.scope || 'global';
270
+ const existing = scopeMap.get(scope) || { count: 0, lastUpdated: '' };
271
+ existing.count++;
272
+ if (entry.updated_at > existing.lastUpdated) {
273
+ existing.lastUpdated = entry.updated_at;
274
+ }
275
+ scopeMap.set(scope, existing);
276
+ }
277
+
278
+ const lines = [
279
+ '# Scope Index',
280
+ '',
281
+ '| scope | count | lastUpdated |',
282
+ '| --- | --- | --- |',
283
+ ];
284
+
285
+ for (const [scope, info] of [...scopeMap.entries()].sort()) {
286
+ lines.push(`| ${scope} | ${info.count} | ${info.lastUpdated} |`);
287
+ }
288
+
289
+ writeFileSync(this.#scopesPath, lines.join('\n') + '\n', 'utf8');
290
+ }
291
+
292
+ // ─── Entries CRUD ─────────────────────────────────────
293
+
294
+ /**
295
+ * List all entries with their frontmatter (no content body).
296
+ * @returns {object[]}
297
+ */
298
+ listEntries() {
299
+ if (!existsSync(this.#entriesDir)) return [];
300
+
301
+ const files = readdirSync(this.#entriesDir).filter(f => f.endsWith('.md')).sort();
302
+ const entries = [];
303
+
304
+ for (const file of files) {
305
+ const raw = readFileSync(join(this.#entriesDir, file), 'utf8');
306
+ const entry = parseEntry(raw);
307
+ if (entry) {
308
+ entry._filename = file;
309
+ entries.push(entry);
310
+ }
311
+ }
312
+
313
+ return entries;
314
+ }
315
+
316
+ /**
317
+ * Read a specific entry by name (slug).
318
+ * @param {string} name — entry name slug (without .md)
319
+ * @returns {object|null}
320
+ */
321
+ readEntry(name) {
322
+ const filePath = join(this.#entriesDir, `${name}.md`);
323
+ if (!existsSync(filePath)) return null;
324
+ const raw = readFileSync(filePath, 'utf8');
325
+ return parseEntry(raw);
326
+ }
327
+
328
+ /**
329
+ * Write (create or overwrite) an entry.
330
+ * @param {object} entry — { name, kind, scope, tags, importance, content, ... }
331
+ * @returns {string} — the filename slug used
332
+ */
333
+ writeEntry(entry) {
334
+ const slug = entry.name ? slugify(entry.name) : `entry-${Date.now()}`;
335
+ const now = new Date().toISOString();
336
+
337
+ const fullEntry = {
338
+ ...entry,
339
+ name: entry.name || slug,
340
+ created_at: entry.created_at || now,
341
+ updated_at: now,
342
+ };
343
+
344
+ const filePath = join(this.#entriesDir, `${slug}.md`);
345
+ writeFileSync(filePath, serializeEntry(fullEntry), 'utf8');
346
+
347
+ return slug;
348
+ }
349
+
350
+ /**
351
+ * Write multiple entries at once.
352
+ * @param {object[]} entries
353
+ * @returns {string[]} — slugs
354
+ */
355
+ writeEntries(entries) {
356
+ return entries.map(e => this.writeEntry(e));
357
+ }
358
+
359
+ /**
360
+ * Delete an entry by name (slug).
361
+ * @param {string} name — entry slug (without .md)
362
+ * @returns {boolean} — true if deleted
363
+ */
364
+ deleteEntry(name) {
365
+ const filePath = join(this.#entriesDir, `${name}.md`);
366
+ if (!existsSync(filePath)) return false;
367
+ unlinkSync(filePath);
368
+ return true;
369
+ }
370
+
371
+ /**
372
+ * Increment the frequency counter of an entry.
373
+ * @param {string} name — entry slug
374
+ */
375
+ bumpFrequency(name) {
376
+ const entry = this.readEntry(name);
377
+ if (!entry) return;
378
+ entry.frequency = (entry.frequency || 1) + 1;
379
+ entry.updated_at = new Date().toISOString();
380
+ const filePath = join(this.#entriesDir, `${name}.md`);
381
+ writeFileSync(filePath, serializeEntry(entry), 'utf8');
382
+ }
383
+
384
+ // ─── Search / Filter ──────────────────────────────────
385
+
386
+ /**
387
+ * Find entries matching scope + tags.
388
+ * Scoring: exact scope match = 3, ancestor scope = 2, tag overlap = 1 per tag.
389
+ *
390
+ * @param {{ scope?: string, tags?: string[], limit?: number }} filters
391
+ * @returns {object[]} — entries sorted by score descending
392
+ */
393
+ findByFilter({ scope, tags = [], limit = 15 } = {}) {
394
+ const entries = this.listEntries();
395
+
396
+ const scored = entries.map(entry => {
397
+ let score = 0;
398
+
399
+ // Scope scoring
400
+ if (scope && entry.scope) {
401
+ if (entry.scope === scope) {
402
+ score += 3; // exact match
403
+ } else if (scope.startsWith(entry.scope + '/') || entry.scope.startsWith(scope + '/')) {
404
+ score += 2; // ancestor or descendant
405
+ } else if (entry.scope === 'global') {
406
+ score += 1; // global always partially relevant
407
+ }
408
+ }
409
+
410
+ // Tag scoring
411
+ if (tags.length > 0 && entry.tags) {
412
+ const entryTagSet = new Set(entry.tags.map(t => t.toLowerCase()));
413
+ for (const tag of tags) {
414
+ if (entryTagSet.has(tag.toLowerCase())) {
415
+ score += 1;
416
+ }
417
+ }
418
+ }
419
+
420
+ return { ...entry, _score: score };
421
+ });
422
+
423
+ return scored
424
+ .filter(e => e._score > 0)
425
+ .sort((a, b) => b._score - a._score)
426
+ .slice(0, limit);
427
+ }
428
+
429
+ /**
430
+ * Keyword search across all entries.
431
+ * @param {string} keyword
432
+ * @param {number} [limit=20]
433
+ * @returns {object[]}
434
+ */
435
+ search(keyword, limit = 20) {
436
+ if (!keyword || !keyword.trim()) return [];
437
+
438
+ const lowerKeyword = keyword.toLowerCase();
439
+ const entries = this.listEntries();
440
+ const results = [];
441
+
442
+ for (const entry of entries) {
443
+ if (results.length >= limit) break;
444
+
445
+ const searchable = [
446
+ entry.name,
447
+ entry.kind,
448
+ entry.scope,
449
+ (entry.tags || []).join(' '),
450
+ entry.content,
451
+ ].join(' ').toLowerCase();
452
+
453
+ if (searchable.includes(lowerKeyword)) {
454
+ results.push(entry);
455
+ }
456
+ }
457
+
458
+ return results;
459
+ }
460
+
461
+ // ─── Stats ────────────────────────────────────────────
462
+
463
+ /**
464
+ * Get memory statistics.
465
+ * @returns {{ entryCount: number, scopes: string[], kinds: object }}
466
+ */
467
+ stats() {
468
+ const entries = this.listEntries();
469
+ const kinds = {};
470
+ const scopeSet = new Set();
471
+
472
+ for (const entry of entries) {
473
+ kinds[entry.kind] = (kinds[entry.kind] || 0) + 1;
474
+ if (entry.scope) scopeSet.add(entry.scope);
475
+ }
476
+
477
+ return {
478
+ entryCount: entries.length,
479
+ scopes: [...scopeSet].sort(),
480
+ kinds,
481
+ };
482
+ }
483
+
484
+ /**
485
+ * Clear all memory data.
486
+ */
487
+ clear() {
488
+ // Clear entries
489
+ if (existsSync(this.#entriesDir)) {
490
+ for (const file of readdirSync(this.#entriesDir)) {
491
+ if (file.endsWith('.md')) {
492
+ unlinkSync(join(this.#entriesDir, file));
493
+ }
494
+ }
495
+ }
496
+
497
+ // Clear MEMORY.md
498
+ if (existsSync(this.#memoryPath)) {
499
+ writeFileSync(this.#memoryPath, '', 'utf8');
500
+ }
501
+
502
+ // Clear scopes.md
503
+ if (existsSync(this.#scopesPath)) {
504
+ unlinkSync(this.#scopesPath);
505
+ }
506
+ }
507
+ }
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
  }