@yeaft/webchat-agent 0.1.665 → 0.1.667

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.
Files changed (41) hide show
  1. package/connection/message-router.js +1 -24
  2. package/package.json +1 -1
  3. package/unify/cli.js +5 -84
  4. package/unify/config.js +2 -2
  5. package/unify/dream-v2/apply.js +1 -1
  6. package/unify/dream-v2/limits.js +1 -1
  7. package/unify/dream-v2/merge.js +1 -1
  8. package/unify/dream-v2/runner.js +2 -2
  9. package/unify/dream-v2/schedule.js +2 -2
  10. package/unify/dream-v2/segment.js +1 -1
  11. package/unify/dream-v2/session-wiring.js +2 -3
  12. package/unify/dream-v2/snapshot.js +1 -1
  13. package/unify/dream-v2/state.js +1 -1
  14. package/unify/dream-v2/triage.js +1 -1
  15. package/unify/engine.js +23 -133
  16. package/unify/eval/cases/memory.js +9 -142
  17. package/unify/features/summary.js +15 -98
  18. package/unify/index.js +0 -2
  19. package/unify/memory/ams.js +1 -1
  20. package/unify/memory/consolidate.js +10 -125
  21. package/unify/memory/segment-store.js +1 -1
  22. package/unify/memory/store-v2.js +6 -9
  23. package/unify/prompts.js +8 -50
  24. package/unify/session.js +2 -22
  25. package/unify/stop-hooks.js +8 -44
  26. package/unify/tools/index.js +0 -11
  27. package/unify/web-bridge.js +0 -98
  28. package/unify/memory/dream-shard.js +0 -722
  29. package/unify/memory/extract.js +0 -101
  30. package/unify/memory/layout.js +0 -358
  31. package/unify/memory/schema.js +0 -166
  32. package/unify/memory/shard-store.js +0 -373
  33. package/unify/memory/store.js +0 -578
  34. package/unify/memory/types.js +0 -139
  35. package/unify/memory/user-memory-store.js +0 -452
  36. package/unify/tools/memory-query.js +0 -134
  37. package/unify/tools/memory-read.js +0 -90
  38. package/unify/tools/memory-search.js +0 -140
  39. package/unify/tools/memory-trace.js +0 -135
  40. package/unify/tools/memory-write.js +0 -113
  41. package/unify/user-memory.js +0 -107
@@ -1,578 +0,0 @@
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
- import { isPermissionError } from '../init.js';
29
-
30
- // ─── Constants ──────────────────────────────────────────────────
31
-
32
- /** Valid memory kinds. */
33
- export const MEMORY_KINDS = ['fact', 'preference', 'skill', 'lesson', 'context', 'relation'];
34
-
35
- /** Maximum entries allowed (Dream prunes beyond this). */
36
- export const MAX_ENTRIES = 200;
37
-
38
- /** Maximum MEMORY.md line count. */
39
- export const MAX_MEMORY_LINES = 200;
40
-
41
- /** Whether a permission warning has been logged for this store. */
42
- let _permissionWarned = false;
43
-
44
- // ─── Entry Parsing ──────────────────────────────────────────────
45
-
46
- /**
47
- * Parse a memory entry .md file into an object.
48
- * @param {string} raw — raw file content
49
- * @returns {object|null}
50
- */
51
- export function parseEntry(raw) {
52
- if (!raw || !raw.startsWith('---')) return null;
53
-
54
- const endIdx = raw.indexOf('\n---', 3);
55
- if (endIdx === -1) return null;
56
-
57
- const frontmatter = raw.slice(4, endIdx).trim();
58
- const body = raw.slice(endIdx + 4).trim();
59
-
60
- const entry = { content: body };
61
-
62
- for (const line of frontmatter.split('\n')) {
63
- const colonIdx = line.indexOf(':');
64
- if (colonIdx === -1) continue;
65
-
66
- const key = line.slice(0, colonIdx).trim();
67
- let value = line.slice(colonIdx + 1).trim();
68
-
69
- switch (key) {
70
- case 'name': entry.name = value; break;
71
- case 'kind': entry.kind = value; break;
72
- case 'scope': entry.scope = value; break;
73
- case 'importance': entry.importance = value; break;
74
- case 'frequency': entry.frequency = parseInt(value, 10); break;
75
- case 'created_at': entry.created_at = value; break;
76
- case 'updated_at': entry.updated_at = value; break;
77
- case 'tags': {
78
- // Parse [tag1, tag2, tag3] or tag1, tag2, tag3
79
- value = value.replace(/^\[|\]$/g, '');
80
- entry.tags = value.split(',').map(t => t.trim()).filter(Boolean);
81
- break;
82
- }
83
- case 'related': {
84
- value = value.replace(/^\[|\]$/g, '');
85
- entry.related = value.split(',').map(t => t.trim()).filter(Boolean);
86
- break;
87
- }
88
- }
89
- }
90
-
91
- return entry;
92
- }
93
-
94
- /**
95
- * Serialize a memory entry to .md format.
96
- * @param {object} entry
97
- * @returns {string}
98
- */
99
- export function serializeEntry(entry) {
100
- const fm = [
101
- '---',
102
- `name: ${entry.name}`,
103
- `kind: ${entry.kind || 'fact'}`,
104
- `scope: ${entry.scope || 'global'}`,
105
- `tags: [${(entry.tags || []).join(', ')}]`,
106
- `importance: ${entry.importance || 'normal'}`,
107
- `frequency: ${entry.frequency || 1}`,
108
- ];
109
-
110
- if (entry.related && entry.related.length > 0) {
111
- fm.push(`related: [${entry.related.join(', ')}]`);
112
- }
113
-
114
- fm.push(`created_at: ${entry.created_at || new Date().toISOString()}`);
115
- fm.push(`updated_at: ${entry.updated_at || new Date().toISOString()}`);
116
- fm.push('---');
117
- fm.push('');
118
- fm.push(entry.content || '');
119
-
120
- return fm.join('\n');
121
- }
122
-
123
- /**
124
- * Generate a filename-safe slug from a name.
125
- * @param {string} name
126
- * @returns {string}
127
- */
128
- export function slugify(name) {
129
- return name
130
- .toLowerCase()
131
- .replace(/[^a-z0-9\u4e00-\u9fff]+/g, '-') // allow CJK chars
132
- .replace(/^-+|-+$/g, '')
133
- .slice(0, 60);
134
- }
135
-
136
- // ─── MemoryStore ────────────────────────────────────────────────
137
-
138
- /**
139
- * MemoryStore — CRUD for memory entries, MEMORY.md, and scopes.md.
140
- *
141
- * Directory layout:
142
- * memory/
143
- * MEMORY.md — user profile / knowledge map (<200 lines)
144
- * scopes.md — scope index (markdown table)
145
- * entries/ — individual memory entries (flat)
146
- */
147
- export class MemoryStore {
148
- #dir; // root dir (e.g. ~/.yeaft)
149
- #memoryDir; // ~/.yeaft/memory
150
- #entriesDir; // ~/.yeaft/memory/entries
151
- #memoryPath; // ~/.yeaft/memory/MEMORY.md
152
- #scopesPath; // ~/.yeaft/memory/scopes.md
153
-
154
- /**
155
- * @param {string} dir — Yeaft root directory (e.g. ~/.yeaft)
156
- */
157
- constructor(dir) {
158
- this.#dir = dir;
159
- this.#memoryDir = join(dir, 'memory');
160
- this.#entriesDir = join(dir, 'memory', 'entries');
161
- this.#memoryPath = join(dir, 'memory', 'MEMORY.md');
162
- this.#scopesPath = join(dir, 'memory', 'scopes.md');
163
-
164
- // Ensure directories exist (graceful on permission errors)
165
- for (const d of [this.#memoryDir, this.#entriesDir]) {
166
- try {
167
- if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
168
- } catch (err) {
169
- if (isPermissionError(err)) {
170
- if (!_permissionWarned) {
171
- console.warn(`[Yeaft] Cannot create directory ${d}: ${err.code} — memory persistence disabled`);
172
- _permissionWarned = true;
173
- }
174
- } else {
175
- throw err;
176
- }
177
- }
178
- }
179
- }
180
-
181
- // ─── MEMORY.md (User Profile / Knowledge Map) ──────────
182
-
183
- /**
184
- * Read the full MEMORY.md content.
185
- * @returns {string}
186
- */
187
- readProfile() {
188
- if (!existsSync(this.#memoryPath)) return '';
189
- return readFileSync(this.#memoryPath, 'utf8');
190
- }
191
-
192
- /**
193
- * Write (overwrite) MEMORY.md.
194
- * @param {string} content
195
- */
196
- writeProfile(content) {
197
- try {
198
- writeFileSync(this.#memoryPath, content, { encoding: 'utf8', mode: 0o644 });
199
- } catch (err) {
200
- if (isPermissionError(err)) {
201
- if (!_permissionWarned) {
202
- console.warn(`[Yeaft] Cannot write MEMORY.md: ${err.code}`);
203
- _permissionWarned = true;
204
- }
205
- } else {
206
- throw err;
207
- }
208
- }
209
- }
210
-
211
- /**
212
- * Read a specific section from MEMORY.md.
213
- * Sections are delimited by ## headers.
214
- * @param {string} section — e.g. "Facts", "Preferences"
215
- * @returns {string}
216
- */
217
- readSection(section) {
218
- const content = this.readProfile();
219
- if (!content) return '';
220
-
221
- const regex = new RegExp(`^## ${section}\\b[^\\n]*\\n`, 'im');
222
- const match = content.match(regex);
223
- if (!match) return '';
224
-
225
- const startIdx = match.index + match[0].length;
226
- const nextSection = content.indexOf('\n## ', startIdx);
227
- const endIdx = nextSection !== -1 ? nextSection : content.length;
228
-
229
- return content.slice(startIdx, endIdx).trim();
230
- }
231
-
232
- /**
233
- * Add a line to a section in MEMORY.md. Creates the section if it doesn't exist.
234
- * @param {string} section — e.g. "Facts"
235
- * @param {string} line — e.g. "- User prefers TypeScript"
236
- */
237
- addToSection(section, line) {
238
- let content = this.readProfile();
239
-
240
- const sectionHeader = `## ${section}`;
241
- const headerIdx = content.indexOf(sectionHeader);
242
-
243
- if (headerIdx === -1) {
244
- // Section doesn't exist — append it
245
- content = content.trimEnd() + `\n\n${sectionHeader}\n\n${line}\n`;
246
- } else {
247
- // Find end of section
248
- const afterHeader = headerIdx + sectionHeader.length;
249
- const nextSectionIdx = content.indexOf('\n## ', afterHeader);
250
- const insertIdx = nextSectionIdx !== -1 ? nextSectionIdx : content.length;
251
-
252
- // Insert before next section
253
- content = content.slice(0, insertIdx).trimEnd() + '\n' + line + '\n' + content.slice(insertIdx);
254
- }
255
-
256
- this.writeProfile(content);
257
- }
258
-
259
- // ─── Scopes Index ─────────────────────────────────────
260
-
261
- /**
262
- * Read scopes.md as a list of { scope, count, lastUpdated }.
263
- * @returns {object[]}
264
- */
265
- readScopes() {
266
- if (!existsSync(this.#scopesPath)) return [];
267
-
268
- const content = readFileSync(this.#scopesPath, 'utf8');
269
- const lines = content.split('\n');
270
- const scopes = [];
271
-
272
- for (const line of lines) {
273
- // Parse markdown table rows: | scope | count | lastUpdated |
274
- const match = line.match(/^\|\s*([^|]+)\s*\|\s*(\d+)\s*\|\s*([^|]+)\s*\|$/);
275
- if (match && match[1].trim() !== 'scope' && !match[1].includes('---')) {
276
- scopes.push({
277
- scope: match[1].trim(),
278
- count: parseInt(match[2].trim(), 10),
279
- lastUpdated: match[3].trim(),
280
- });
281
- }
282
- }
283
-
284
- return scopes;
285
- }
286
-
287
- /**
288
- * Rebuild scopes.md from current entries.
289
- */
290
- rebuildScopes() {
291
- const entries = this.listEntries();
292
- const scopeMap = new Map();
293
-
294
- for (const entry of entries) {
295
- const scope = entry.scope || 'global';
296
- const existing = scopeMap.get(scope) || { count: 0, lastUpdated: '' };
297
- existing.count++;
298
- if (entry.updated_at > existing.lastUpdated) {
299
- existing.lastUpdated = entry.updated_at;
300
- }
301
- scopeMap.set(scope, existing);
302
- }
303
-
304
- const lines = [
305
- '# Scope Index',
306
- '',
307
- '| scope | count | lastUpdated |',
308
- '| --- | --- | --- |',
309
- ];
310
-
311
- for (const [scope, info] of [...scopeMap.entries()].sort()) {
312
- lines.push(`| ${scope} | ${info.count} | ${info.lastUpdated} |`);
313
- }
314
-
315
- try {
316
- writeFileSync(this.#scopesPath, lines.join('\n') + '\n', { encoding: 'utf8', mode: 0o644 });
317
- } catch (err) {
318
- if (isPermissionError(err)) {
319
- if (!_permissionWarned) {
320
- console.warn(`[Yeaft] Cannot write scopes.md: ${err.code}`);
321
- _permissionWarned = true;
322
- }
323
- } else {
324
- throw err;
325
- }
326
- }
327
- }
328
-
329
- // ─── Entries CRUD ─────────────────────────────────────
330
-
331
- /**
332
- * List all entries with their frontmatter (no content body).
333
- * @returns {object[]}
334
- */
335
- listEntries() {
336
- if (!existsSync(this.#entriesDir)) return [];
337
-
338
- const files = readdirSync(this.#entriesDir).filter(f => f.endsWith('.md')).sort();
339
- const entries = [];
340
-
341
- for (const file of files) {
342
- const raw = readFileSync(join(this.#entriesDir, file), 'utf8');
343
- const entry = parseEntry(raw);
344
- if (entry) {
345
- entry._filename = file;
346
- entries.push(entry);
347
- }
348
- }
349
-
350
- return entries;
351
- }
352
-
353
- /**
354
- * Read a specific entry by name (slug).
355
- * @param {string} name — entry name slug (without .md)
356
- * @returns {object|null}
357
- */
358
- readEntry(name) {
359
- const filePath = join(this.#entriesDir, `${name}.md`);
360
- if (!existsSync(filePath)) return null;
361
- const raw = readFileSync(filePath, 'utf8');
362
- return parseEntry(raw);
363
- }
364
-
365
- /**
366
- * Write (create or overwrite) an entry.
367
- * @param {object} entry — { name, kind, scope, tags, importance, content, ... }
368
- * @returns {string} — the filename slug used
369
- */
370
- writeEntry(entry) {
371
- const slug = entry.name ? slugify(entry.name) : `entry-${Date.now()}`;
372
- const now = new Date().toISOString();
373
-
374
- const fullEntry = {
375
- ...entry,
376
- name: entry.name || slug,
377
- created_at: entry.created_at || now,
378
- updated_at: now,
379
- };
380
-
381
- const filePath = join(this.#entriesDir, `${slug}.md`);
382
- try {
383
- writeFileSync(filePath, serializeEntry(fullEntry), { encoding: 'utf8', mode: 0o644 });
384
- } catch (err) {
385
- if (isPermissionError(err)) {
386
- if (!_permissionWarned) {
387
- console.warn(`[Yeaft] Cannot write memory entry ${slug}: ${err.code}`);
388
- _permissionWarned = true;
389
- }
390
- return slug; // Return slug but don't persist
391
- }
392
- throw err;
393
- }
394
-
395
- return slug;
396
- }
397
-
398
- /**
399
- * Write multiple entries at once.
400
- * @param {object[]} entries
401
- * @returns {string[]} — slugs
402
- */
403
- writeEntries(entries) {
404
- return entries.map(e => this.writeEntry(e));
405
- }
406
-
407
- /**
408
- * Delete an entry by name (slug).
409
- * @param {string} name — entry slug (without .md)
410
- * @returns {boolean} — true if deleted
411
- */
412
- deleteEntry(name) {
413
- const filePath = join(this.#entriesDir, `${name}.md`);
414
- if (!existsSync(filePath)) return false;
415
- unlinkSync(filePath);
416
- return true;
417
- }
418
-
419
- /**
420
- * Increment the frequency counter of an entry.
421
- * @param {string} name — entry slug
422
- */
423
- bumpFrequency(name) {
424
- const entry = this.readEntry(name);
425
- if (!entry) return;
426
- entry.frequency = (entry.frequency || 1) + 1;
427
- entry.updated_at = new Date().toISOString();
428
- const filePath = join(this.#entriesDir, `${name}.md`);
429
- try {
430
- writeFileSync(filePath, serializeEntry(entry), { encoding: 'utf8', mode: 0o644 });
431
- } catch (err) {
432
- if (isPermissionError(err)) {
433
- if (!_permissionWarned) {
434
- console.warn(`[Yeaft] Cannot bump frequency for ${name}: ${err.code}`);
435
- _permissionWarned = true;
436
- }
437
- } else {
438
- throw err;
439
- }
440
- }
441
- }
442
-
443
- // ─── Search / Filter ──────────────────────────────────
444
-
445
- /**
446
- * Find entries matching scope + tags.
447
- * Scoring: exact scope match = 3, ancestor scope = 2, tag overlap = 1 per tag.
448
- *
449
- * @param {{ scope?: string, tags?: string[], limit?: number }} filters
450
- * @returns {object[]} — entries sorted by score descending
451
- */
452
- findByFilter({ scope, tags = [], limit = 15 } = {}) {
453
- const entries = this.listEntries();
454
-
455
- const scored = entries.map(entry => {
456
- let score = 0;
457
-
458
- // Scope scoring
459
- if (scope && entry.scope) {
460
- if (entry.scope === scope) {
461
- score += 3; // exact match
462
- } else if (scope.startsWith(entry.scope + '/') || entry.scope.startsWith(scope + '/')) {
463
- score += 2; // ancestor or descendant
464
- } else if (entry.scope === 'global') {
465
- score += 1; // global always partially relevant
466
- }
467
- }
468
-
469
- // Tag scoring
470
- if (tags.length > 0 && entry.tags) {
471
- const entryTagSet = new Set(entry.tags.map(t => t.toLowerCase()));
472
- for (const tag of tags) {
473
- if (entryTagSet.has(tag.toLowerCase())) {
474
- score += 1;
475
- }
476
- }
477
- }
478
-
479
- return { ...entry, _score: score };
480
- });
481
-
482
- return scored
483
- .filter(e => e._score > 0)
484
- .sort((a, b) => b._score - a._score)
485
- .slice(0, limit);
486
- }
487
-
488
- /**
489
- * Keyword search across all entries.
490
- * @param {string} keyword
491
- * @param {number} [limit=20]
492
- * @returns {object[]}
493
- */
494
- search(keyword, limit = 20) {
495
- if (!keyword || !keyword.trim()) return [];
496
-
497
- const lowerKeyword = keyword.toLowerCase();
498
- const entries = this.listEntries();
499
- const results = [];
500
-
501
- for (const entry of entries) {
502
- if (results.length >= limit) break;
503
-
504
- const searchable = [
505
- entry.name,
506
- entry.kind,
507
- entry.scope,
508
- (entry.tags || []).join(' '),
509
- entry.content,
510
- ].join(' ').toLowerCase();
511
-
512
- if (searchable.includes(lowerKeyword)) {
513
- results.push(entry);
514
- }
515
- }
516
-
517
- return results;
518
- }
519
-
520
- // ─── Stats ────────────────────────────────────────────
521
-
522
- /**
523
- * Get memory statistics.
524
- * @returns {{ entryCount: number, scopes: string[], kinds: object }}
525
- */
526
- stats() {
527
- const entries = this.listEntries();
528
- const kinds = {};
529
- const scopeSet = new Set();
530
-
531
- for (const entry of entries) {
532
- kinds[entry.kind] = (kinds[entry.kind] || 0) + 1;
533
- if (entry.scope) scopeSet.add(entry.scope);
534
- }
535
-
536
- return {
537
- entryCount: entries.length,
538
- scopes: [...scopeSet].sort(),
539
- kinds,
540
- };
541
- }
542
-
543
- /**
544
- * Clear all memory data.
545
- */
546
- clear() {
547
- // Clear entries
548
- if (existsSync(this.#entriesDir)) {
549
- for (const file of readdirSync(this.#entriesDir)) {
550
- if (file.endsWith('.md')) {
551
- try {
552
- unlinkSync(join(this.#entriesDir, file));
553
- } catch (err) {
554
- if (!isPermissionError(err)) throw err;
555
- }
556
- }
557
- }
558
- }
559
-
560
- // Clear MEMORY.md
561
- if (existsSync(this.#memoryPath)) {
562
- try {
563
- writeFileSync(this.#memoryPath, '', { encoding: 'utf8', mode: 0o644 });
564
- } catch (err) {
565
- if (!isPermissionError(err)) throw err;
566
- }
567
- }
568
-
569
- // Clear scopes.md
570
- if (existsSync(this.#scopesPath)) {
571
- try {
572
- unlinkSync(this.#scopesPath);
573
- } catch (err) {
574
- if (!isPermissionError(err)) throw err;
575
- }
576
- }
577
- }
578
- }
@@ -1,139 +0,0 @@
1
- /**
2
- * types.js — Memory type definitions and constants
3
- *
4
- * Defines the 3D memory 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 for retrieval
8
- *
9
- * Reference: yeaft-unify-core-systems.md §2.2, yeaft-unify-brainstorm-v3.md
10
- */
11
-
12
- // ─── Kind ────────────────────────────────────────────────────
13
-
14
- /** All valid memory kinds. */
15
- export const KINDS = ['fact', 'preference', 'skill', 'lesson', 'context', 'relation'];
16
-
17
- /** Kind descriptions for prompt context. */
18
- export const KIND_DESCRIPTIONS = {
19
- fact: 'Objective facts (project structure, tech stack, verified information)',
20
- preference: 'User preferences (coding style, tools, communication style)',
21
- skill: 'How to do something (patterns, techniques, workflows, commands)',
22
- lesson: 'Lessons learned (bugs, pitfalls, effective alternatives)',
23
- context: 'Temporal context (current OKR, project progress, deadlines)',
24
- relation: 'People and relationships (teammates, roles, responsibilities)',
25
- };
26
-
27
- /** Kind priority for dream consolidation (higher = more important). */
28
- export const KIND_PRIORITY = {
29
- fact: 6,
30
- preference: 5,
31
- skill: 4,
32
- lesson: 3,
33
- context: 2,
34
- relation: 1,
35
- };
36
-
37
- // ─── Scope ──────────────────────────────────────────────────
38
-
39
- /**
40
- * Parse a scope path into segments.
41
- * @param {string} scope — e.g. "work/claude-web-chat/auth"
42
- * @returns {string[]} — e.g. ["work", "claude-web-chat", "auth"]
43
- */
44
- export function parseScopePath(scope) {
45
- if (!scope) return ['global'];
46
- return scope.split('/').filter(Boolean);
47
- }
48
-
49
- /**
50
- * Get all ancestor scopes (including the scope itself and 'global').
51
- * @param {string} scope — e.g. "work/claude-web-chat/auth"
52
- * @returns {string[]} — e.g. ["global", "work", "work/claude-web-chat", "work/claude-web-chat/auth"]
53
- */
54
- export function getAncestorScopes(scope) {
55
- if (!scope || scope === 'global') return ['global'];
56
-
57
- const segments = parseScopePath(scope);
58
- const ancestors = ['global'];
59
-
60
- for (let i = 0; i < segments.length; i++) {
61
- ancestors.push(segments.slice(0, i + 1).join('/'));
62
- }
63
-
64
- return ancestors;
65
- }
66
-
67
- /**
68
- * Check if two scopes are related (one is ancestor/descendant of the other).
69
- * @param {string} a
70
- * @param {string} b
71
- * @returns {boolean}
72
- */
73
- export function areScopesRelated(a, b) {
74
- if (!a || !b || a === 'global' || b === 'global') return true;
75
- return a.startsWith(b + '/') || b.startsWith(a + '/') || a === b;
76
- }
77
-
78
- // ─── Importance ─────────────────────────────────────────────
79
-
80
- /** Valid importance levels. */
81
- export const IMPORTANCE_LEVELS = ['high', 'normal', 'low'];
82
-
83
- /** Importance weight for scoring. */
84
- export const IMPORTANCE_WEIGHT = {
85
- high: 3,
86
- normal: 2,
87
- low: 1,
88
- };
89
-
90
- // ─── Entry Schema ──────────────────────────────────────────
91
-
92
- /**
93
- * @typedef {Object} MemoryEntry
94
- * @property {string} name — unique slug name
95
- * @property {string} kind — one of KINDS
96
- * @property {string} scope — tree path (e.g. "global", "tech/typescript")
97
- * @property {string[]} tags — free keywords
98
- * @property {string} importance — "high" | "normal" | "low"
99
- * @property {number} frequency — how often this entry is recalled
100
- * @property {string} content — the actual memory content
101
- * @property {string[]} [related] — related entry names
102
- * @property {string} [created_at] — ISO timestamp
103
- * @property {string} [updated_at] — ISO timestamp
104
- */
105
-
106
- /**
107
- * Validate a memory entry object.
108
- * @param {object} entry
109
- * @returns {{ valid: boolean, errors: string[] }}
110
- */
111
- export function validateEntry(entry) {
112
- const errors = [];
113
-
114
- if (!entry || typeof entry !== 'object') {
115
- return { valid: false, errors: ['Entry must be an object'] };
116
- }
117
-
118
- if (!entry.name || typeof entry.name !== 'string') {
119
- errors.push('Entry must have a string "name"');
120
- }
121
-
122
- if (entry.kind && !KINDS.includes(entry.kind)) {
123
- errors.push(`Invalid kind "${entry.kind}". Must be one of: ${KINDS.join(', ')}`);
124
- }
125
-
126
- if (entry.importance && !IMPORTANCE_LEVELS.includes(entry.importance)) {
127
- errors.push(`Invalid importance "${entry.importance}". Must be one of: ${IMPORTANCE_LEVELS.join(', ')}`);
128
- }
129
-
130
- if (!entry.content || typeof entry.content !== 'string') {
131
- errors.push('Entry must have string "content"');
132
- }
133
-
134
- if (entry.tags && !Array.isArray(entry.tags)) {
135
- errors.push('"tags" must be an array');
136
- }
137
-
138
- return { valid: errors.length === 0, errors };
139
- }