@yeaft/webchat-agent 0.1.661 → 0.1.663

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.
@@ -1,783 +0,0 @@
1
- /**
2
- * dream.js — Auto Dream system (memory maintenance)
3
- *
4
- * Dream is a background process that maintains memory quality.
5
- * 5 phases: Orient → Gather → Merge → Prune → Promote
6
- *
7
- * Gate conditions (all must be true):
8
- * 1. Time gate: ≥24h since last dream
9
- * 2. Activity gate: ≥5 queries since last dream
10
- * 3. Mutex: dream.lock not held
11
- *
12
- * Reference: yeaft-unify-core-systems.md §3.3
13
- */
14
-
15
- import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlinkSync } from 'fs';
16
- import { join } from 'path';
17
- import { scanEntries, findStaleEntries, findDuplicateGroups, summarizeScan } from './scan.js';
18
- import { MAX_ENTRIES } from './store.js';
19
- import { pickEffort } from '../effort.js';
20
- import {
21
- ensureLayout,
22
- renderIndex,
23
- readMemoryFile,
24
- writeMemoryFile,
25
- memoryDir,
26
- } from './layout.js';
27
- import {
28
- buildOrientPrompt,
29
- buildGatherPrompt,
30
- buildMergePrompt,
31
- buildPrunePrompt,
32
- buildPromotePrompt,
33
- } from './dream-prompt.js';
34
-
35
- // ─── Constants ──────────────────────────────────────────────
36
-
37
- /** Minimum hours between dreams. */
38
- const DREAM_INTERVAL_HOURS = 24;
39
-
40
- /** Minimum queries before a dream can trigger. */
41
- const DREAM_MIN_QUERIES = 5;
42
-
43
- /** Maximum LLM calls per dream (budget control). */
44
- const MAX_DREAM_LLM_CALLS = 5;
45
-
46
- // ─── Dream State Management ─────────────────────────────────
47
-
48
- /**
49
- * Read dream state from dream/state.md.
50
- *
51
- * @param {string} yeaftDir — e.g. ~/.yeaft
52
- * @returns {{ lastDreamAt: string|null, queriesSinceDream: number, dreamCount: number }}
53
- */
54
- export function readDreamState(yeaftDir) {
55
- const statePath = join(yeaftDir, 'dream', 'state.md');
56
-
57
- if (!existsSync(statePath)) {
58
- return { lastDreamAt: null, queriesSinceDream: 0, dreamCount: 0 };
59
- }
60
-
61
- const raw = readFileSync(statePath, 'utf8');
62
- const state = { lastDreamAt: null, queriesSinceDream: 0, dreamCount: 0 };
63
-
64
- for (const line of raw.split('\n')) {
65
- const colonIdx = line.indexOf(':');
66
- if (colonIdx === -1) continue;
67
- const key = line.slice(0, colonIdx).trim();
68
- const value = line.slice(colonIdx + 1).trim();
69
-
70
- switch (key) {
71
- case 'last_dream_at': state.lastDreamAt = value || null; break;
72
- case 'queries_since_dream': state.queriesSinceDream = parseInt(value, 10) || 0; break;
73
- case 'dream_count': state.dreamCount = parseInt(value, 10) || 0; break;
74
- }
75
- }
76
-
77
- return state;
78
- }
79
-
80
- /**
81
- * Write dream state to dream/state.md.
82
- *
83
- * @param {string} yeaftDir
84
- * @param {object} state
85
- */
86
- export function writeDreamState(yeaftDir, state) {
87
- const dreamDir = join(yeaftDir, 'dream');
88
- if (!existsSync(dreamDir)) mkdirSync(dreamDir, { recursive: true });
89
-
90
- const content = [
91
- '---',
92
- `last_dream_at: ${state.lastDreamAt || ''}`,
93
- `queries_since_dream: ${state.queriesSinceDream || 0}`,
94
- `dream_count: ${state.dreamCount || 0}`,
95
- '---',
96
- '',
97
- '# Dream State',
98
- '',
99
- 'This file tracks the dream system state. Do not edit manually.',
100
- ].join('\n');
101
-
102
- writeFileSync(join(dreamDir, 'state.md'), content, 'utf8');
103
- }
104
-
105
- /**
106
- * Increment the query counter (called after each query).
107
- *
108
- * @param {string} yeaftDir
109
- */
110
- export function incrementQueryCount(yeaftDir) {
111
- const state = readDreamState(yeaftDir);
112
- state.queriesSinceDream++;
113
- writeDreamState(yeaftDir, state);
114
- }
115
-
116
- // ─── Gate Check ─────────────────────────────────────────────
117
-
118
- /**
119
- * Check if dream should run.
120
- *
121
- * @param {string} yeaftDir
122
- * @returns {{ shouldDream: boolean, reason: string }}
123
- */
124
- export function checkDreamGate(yeaftDir) {
125
- const state = readDreamState(yeaftDir);
126
-
127
- // Activity gate
128
- if (state.queriesSinceDream < DREAM_MIN_QUERIES) {
129
- return {
130
- shouldDream: false,
131
- reason: `Only ${state.queriesSinceDream}/${DREAM_MIN_QUERIES} queries since last dream`,
132
- };
133
- }
134
-
135
- // Time gate
136
- if (state.lastDreamAt) {
137
- const lastDream = new Date(state.lastDreamAt).getTime();
138
- const hoursSince = (Date.now() - lastDream) / (1000 * 60 * 60);
139
- if (hoursSince < DREAM_INTERVAL_HOURS) {
140
- return {
141
- shouldDream: false,
142
- reason: `Only ${Math.round(hoursSince)}h/${DREAM_INTERVAL_HOURS}h since last dream`,
143
- };
144
- }
145
- }
146
-
147
- // Mutex check
148
- const lockPath = join(yeaftDir, 'dream', 'dream.lock');
149
- if (existsSync(lockPath)) {
150
- // Check if lock is stale (> 30 min)
151
- try {
152
- const lockContent = readFileSync(lockPath, 'utf8');
153
- const lockTime = new Date(lockContent.trim()).getTime();
154
- if (Date.now() - lockTime < 30 * 60 * 1000) {
155
- return { shouldDream: false, reason: 'Dream is already running (lock held)' };
156
- }
157
- // Stale lock — proceed
158
- } catch {
159
- // Can't read lock — proceed
160
- }
161
- }
162
-
163
- return { shouldDream: true, reason: 'All gates passed' };
164
- }
165
-
166
- // ─── Dream Execution ────────────────────────────────────────
167
-
168
- /**
169
- * Run the full Dream pipeline.
170
- *
171
- * @param {{
172
- * yeaftDir: string,
173
- * memoryStore: import('./store.js').MemoryStore,
174
- * conversationStore?: import('../conversation/persist.js').ConversationStore,
175
- * adapter: object,
176
- * config: object,
177
- * onPhase?: (phase: string, result: any) => void,
178
- * }} params
179
- * @returns {Promise<DreamResult>}
180
- */
181
- export async function dream({ yeaftDir, memoryStore, conversationStore, adapter, config, onPhase }) {
182
- const lockPath = join(yeaftDir, 'dream', 'dream.lock');
183
- const dreamDir = join(yeaftDir, 'dream');
184
-
185
- // Acquire lock
186
- if (!existsSync(dreamDir)) mkdirSync(dreamDir, { recursive: true });
187
- writeFileSync(lockPath, new Date().toISOString(), 'utf8');
188
-
189
- const result = {
190
- phases: {},
191
- entriesCreated: 0,
192
- entriesDeleted: 0,
193
- entriesMerged: 0,
194
- profileUpdated: false,
195
- errors: [],
196
- };
197
-
198
- try {
199
- // ── Phase 1: Orient ──────────────────────────────────
200
- onPhase?.('orient', 'starting');
201
- const scan = scanEntries(memoryStore);
202
- const memorySummary = summarizeScan(scan);
203
- const profileContent = memoryStore.readProfile();
204
-
205
- const orientResult = await llmCall(adapter, config,
206
- 'You are a memory maintenance assistant. Analyze memory state and return assessment as JSON.',
207
- buildOrientPrompt({ memorySummary, profileContent, entryCount: scan.totalEntries }),
208
- );
209
- result.phases.orient = orientResult;
210
- onPhase?.('orient', orientResult);
211
-
212
- // ── Phase 2: Gather ──────────────────────────────────
213
- onPhase?.('gather', 'starting');
214
- const recentCompact = conversationStore?.readCompactSummary() || '';
215
-
216
- // Load completed tasks (simplified — read from tasks/ if available)
217
- const completedTasks = loadCompletedTasks(yeaftDir);
218
-
219
- const gatherResult = await llmCall(adapter, config,
220
- 'You are a memory gathering assistant. Identify new information to remember. Return JSON.',
221
- buildGatherPrompt({ recentCompact, completedTasks, orientResult }),
222
- );
223
- result.phases.gather = gatherResult;
224
- onPhase?.('gather', gatherResult);
225
-
226
- // ── Phase 3: Merge ───────────────────────────────────
227
- onPhase?.('merge', 'starting');
228
- const duplicateGroups = findDuplicateGroups(scan.entries);
229
-
230
- const mergeResult = await llmCall(adapter, config,
231
- 'You are a memory merge assistant. Combine duplicate entries. Return JSON.',
232
- buildMergePrompt({ duplicateGroups, gatherResult }),
233
- );
234
- result.phases.merge = mergeResult;
235
-
236
- // Apply merges
237
- if (mergeResult?.merges) {
238
- for (const merge of mergeResult.merges) {
239
- if (merge.merged) {
240
- memoryStore.writeEntry(merge.merged);
241
- result.entriesCreated++;
242
- }
243
- if (merge.deleteOriginals) {
244
- for (const name of merge.deleteOriginals) {
245
- memoryStore.deleteEntry(name);
246
- result.entriesDeleted++;
247
- }
248
- result.entriesMerged += (merge.deleteOriginals?.length || 0);
249
- }
250
- }
251
- }
252
-
253
- // Write new entries from gather/merge
254
- if (mergeResult?.newEntries) {
255
- for (const entry of mergeResult.newEntries) {
256
- memoryStore.writeEntry(entry);
257
- result.entriesCreated++;
258
- }
259
- }
260
-
261
- // Apply updates
262
- if (mergeResult?.updates) {
263
- for (const update of mergeResult.updates) {
264
- const existing = memoryStore.readEntry(update.entryName);
265
- if (existing) {
266
- memoryStore.writeEntry({ ...existing, ...update.updates });
267
- }
268
- }
269
- }
270
- onPhase?.('merge', mergeResult);
271
-
272
- // ── Phase 4: Prune ───────────────────────────────────
273
- onPhase?.('prune', 'starting');
274
- const staleEntries = findStaleEntries(scan.entries);
275
- const currentCount = memoryStore.listEntries().length;
276
-
277
- const pruneResult = await llmCall(adapter, config,
278
- 'You are a memory pruning assistant. Remove stale/low-value entries. Return JSON.',
279
- buildPrunePrompt({ staleEntries, entryCount: currentCount, maxEntries: MAX_ENTRIES }),
280
- );
281
- result.phases.prune = pruneResult;
282
-
283
- if (pruneResult?.toDelete) {
284
- for (const name of pruneResult.toDelete) {
285
- if (memoryStore.deleteEntry(name)) {
286
- result.entriesDeleted++;
287
- }
288
- }
289
- }
290
- onPhase?.('prune', pruneResult);
291
-
292
- // ── Phase 5: Promote ─────────────────────────────────
293
- onPhase?.('promote', 'starting');
294
- const updatedEntries = memoryStore.listEntries();
295
- const scopesSummary = summarizeScan(scanEntries(memoryStore));
296
-
297
- const promoteResult = await llmCall(adapter, config,
298
- 'You are a memory promotion assistant. Find patterns and update profile. Return JSON.',
299
- buildPromotePrompt({ entries: updatedEntries, profileContent, scopesSummary }),
300
- );
301
- result.phases.promote = promoteResult;
302
-
303
- // Apply profile updates
304
- if (promoteResult?.profileUpdates) {
305
- for (const [section, lines] of Object.entries(promoteResult.profileUpdates)) {
306
- if (Array.isArray(lines)) {
307
- for (const line of lines) {
308
- memoryStore.addToSection(section, line);
309
- }
310
- }
311
- }
312
- result.profileUpdated = true;
313
- }
314
-
315
- // Write promoted entries
316
- if (promoteResult?.promotedEntries) {
317
- for (const entry of promoteResult.promotedEntries) {
318
- memoryStore.writeEntry(entry);
319
- result.entriesCreated++;
320
- }
321
- }
322
-
323
- // Delete entries that were promoted to profile
324
- if (promoteResult?.entriesToDelete) {
325
- for (const name of promoteResult.entriesToDelete) {
326
- if (memoryStore.deleteEntry(name)) {
327
- result.entriesDeleted++;
328
- }
329
- }
330
- }
331
- onPhase?.('promote', promoteResult);
332
-
333
- // Rebuild scopes after all changes
334
- memoryStore.rebuildScopes();
335
-
336
- // ── Phase 6: Classify (task-287) ─────────────────────
337
- // Maintain the new-layout classification files:
338
- // - index.md (auto-regenerate from disk state)
339
- // - user-preferences.md (merge gather/promote preferences)
340
- // - by-project/<slug>.md (narrative summary per project)
341
- // - by-topic/<slug>.md (narrative summary per topic)
342
- // - timeline/<YYYY-MM>.md (monthly narrative digest)
343
- onPhase?.('classify', 'starting');
344
- try {
345
- ensureLayout(yeaftDir);
346
- const classifyResult = await runClassifyPhase({
347
- yeaftDir,
348
- memoryStore,
349
- adapter,
350
- config,
351
- gatherResult,
352
- promoteResult,
353
- });
354
- result.phases.classify = classifyResult;
355
- result.classified = classifyResult;
356
- onPhase?.('classify', classifyResult);
357
- } catch (err) {
358
- result.errors.push(`classify: ${err.message}`);
359
- }
360
-
361
- // Update dream state
362
- const state = readDreamState(yeaftDir);
363
- state.lastDreamAt = new Date().toISOString();
364
- state.queriesSinceDream = 0;
365
- state.dreamCount = (state.dreamCount || 0) + 1;
366
- writeDreamState(yeaftDir, state);
367
-
368
- // Write dream log
369
- writeDreamLog(yeaftDir, result);
370
-
371
- } catch (err) {
372
- result.errors.push(err.message);
373
- } finally {
374
- // Release lock
375
- try {
376
- if (existsSync(lockPath)) unlinkSync(lockPath);
377
- } catch {
378
- // ignore
379
- }
380
- }
381
-
382
- return result;
383
- }
384
-
385
- // ─── Helpers ────────────────────────────────────────────────
386
-
387
- /**
388
- * Make an LLM call and parse the JSON response.
389
- *
390
- * @param {object} adapter
391
- * @param {object} config
392
- * @param {string} system
393
- * @param {string} prompt
394
- * @returns {Promise<object|null>}
395
- */
396
- async function llmCall(adapter, config, system, prompt) {
397
- try {
398
- const result = await adapter.call({
399
- model: config.model,
400
- system,
401
- messages: [{ role: 'user', content: prompt }],
402
- maxTokens: 4096,
403
- // task-327c: dream is self-reflective memory maintenance — flag 'max'
404
- // so supported models use the full thinking budget.
405
- effort: pickEffort({ scenario: 'dream' }),
406
- });
407
-
408
- const text = result.text.trim();
409
- const jsonMatch = text.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
410
- if (jsonMatch) {
411
- return JSON.parse(jsonMatch[0]);
412
- }
413
- return null;
414
- } catch {
415
- return null;
416
- }
417
- }
418
-
419
- /**
420
- * Load completed tasks without summaries (for Dream Phase 2).
421
- *
422
- * @param {string} yeaftDir
423
- * @returns {object[]}
424
- */
425
- function loadCompletedTasks(yeaftDir) {
426
- const tasksDir = join(yeaftDir, 'tasks');
427
- if (!existsSync(tasksDir)) return [];
428
-
429
- const tasks = [];
430
- try {
431
- const dirs = readdirSync(tasksDir, { withFileTypes: true });
432
-
433
- for (const dir of dirs) {
434
- if (!dir.isDirectory()) continue;
435
-
436
- const metaPath = join(tasksDir, dir.name, 'meta.md');
437
- if (!existsSync(metaPath)) continue;
438
-
439
- const raw = readFileSync(metaPath, 'utf8');
440
- // Quick parse for status and description
441
- if (raw.includes('status: completed')) {
442
- const descMatch = raw.match(/description:\s*(.+)/);
443
- const summaryPath = join(tasksDir, dir.name, 'summary.md');
444
- const hasSummary = existsSync(summaryPath);
445
-
446
- tasks.push({
447
- id: dir.name,
448
- description: descMatch ? descMatch[1].trim() : dir.name,
449
- hasSummary,
450
- summary: hasSummary ? readFileSync(summaryPath, 'utf8').slice(0, 500) : null,
451
- });
452
- }
453
- }
454
- } catch {
455
- // Tasks directory may not exist yet
456
- }
457
-
458
- return tasks;
459
- }
460
-
461
- /**
462
- * Write a dream log entry for debugging.
463
- *
464
- * @param {string} yeaftDir
465
- * @param {object} result
466
- */
467
- function writeDreamLog(yeaftDir, result) {
468
- const logPath = join(yeaftDir, 'dream', 'last-dream.md');
469
- const content = [
470
- '---',
471
- `timestamp: ${new Date().toISOString()}`,
472
- `entries_created: ${result.entriesCreated}`,
473
- `entries_deleted: ${result.entriesDeleted}`,
474
- `entries_merged: ${result.entriesMerged}`,
475
- `profile_updated: ${result.profileUpdated}`,
476
- `errors: ${result.errors.length}`,
477
- '---',
478
- '',
479
- '# Last Dream Log',
480
- '',
481
- `Ran at ${new Date().toISOString()}`,
482
- '',
483
- '## Results',
484
- '',
485
- `- Created: ${result.entriesCreated} entries`,
486
- `- Deleted: ${result.entriesDeleted} entries`,
487
- `- Merged: ${result.entriesMerged} entries`,
488
- `- Profile updated: ${result.profileUpdated}`,
489
- '',
490
- result.errors.length > 0 ? `## Errors\n\n${result.errors.map(e => `- ${e}`).join('\n')}` : '',
491
- ].filter(Boolean).join('\n');
492
-
493
- writeFileSync(logPath, content, 'utf8');
494
- }
495
-
496
- /**
497
- * @typedef {Object} DreamResult
498
- * @property {object} phases — results of each phase
499
- * @property {number} entriesCreated
500
- * @property {number} entriesDeleted
501
- * @property {number} entriesMerged
502
- * @property {boolean} profileUpdated
503
- * @property {string[]} errors
504
- */
505
-
506
- // ─── Phase 6: Classify (task-287) ───────────────────────────
507
-
508
- /**
509
- * Regenerate index.md, merge user-preferences.md, and generate narrative
510
- * classification files (by-project, by-topic, timeline).
511
- *
512
- * Strategy:
513
- * 1. Always regenerate index.md from current on-disk layout (cheap, no LLM).
514
- * 2. Extract preferences from gather/promote results and merge (deduped)
515
- * into user-preferences.md. No LLM call — trust structured output.
516
- * 3. Group entries by scope (project slug), topic tag, and YYYY-MM of
517
- * updated_at. For each group with ≥3 entries and no up-to-date file,
518
- * call the main model once to produce a narrative summary. Caps:
519
- * MAX_CLASSIFY_LLM_CALLS = 3 per dream.
520
- *
521
- * @param {{
522
- * yeaftDir: string,
523
- * memoryStore: import('./store.js').MemoryStore,
524
- * adapter: object,
525
- * config: object,
526
- * gatherResult?: object,
527
- * promoteResult?: object,
528
- * }} params
529
- * @returns {Promise<{ indexBytes: number, preferencesMerged: number, narrativeFiles: string[] }>}
530
- */
531
- const MAX_CLASSIFY_LLM_CALLS = 3;
532
-
533
- async function runClassifyPhase({ yeaftDir, memoryStore, adapter, config, gatherResult, promoteResult }) {
534
- const summary = { indexBytes: 0, preferencesMerged: 0, narrativeFiles: [] };
535
-
536
- // 1. Regenerate index.md
537
- const entryCount = memoryStore.listEntries().length;
538
- const indexText = renderIndex(yeaftDir, entryCount);
539
- writeMemoryFile(yeaftDir, 'index.md', indexText);
540
- summary.indexBytes = indexText.length;
541
-
542
- // 2. Merge preferences into user-preferences.md (deduped, no LLM call)
543
- const newPreferences = extractPreferences(gatherResult, promoteResult);
544
- if (newPreferences.length > 0) {
545
- const merged = mergePreferences(readMemoryFile(yeaftDir, 'user-preferences.md'), newPreferences);
546
- if (merged.changed) {
547
- writeMemoryFile(yeaftDir, 'user-preferences.md', merged.text);
548
- summary.preferencesMerged = merged.addedCount;
549
- }
550
- }
551
-
552
- // 3. Group entries for narrative generation
553
- const entries = memoryStore.listEntries();
554
- const byProject = groupByProject(entries);
555
- const byTopic = groupByTopic(entries);
556
- const byMonth = groupByMonth(entries);
557
-
558
- let llmCallsLeft = MAX_CLASSIFY_LLM_CALLS;
559
-
560
- for (const [slug, group] of Object.entries(byProject)) {
561
- if (llmCallsLeft <= 0) break;
562
- if (group.length < 3) continue;
563
- const relPath = `by-project/${slug}.md`;
564
- if (isFresh(yeaftDir, relPath, group)) continue;
565
- const narrative = await generateNarrative({ adapter, config, category: 'project', label: slug, entries: group });
566
- if (narrative) {
567
- writeMemoryFile(yeaftDir, relPath, narrative);
568
- summary.narrativeFiles.push(relPath);
569
- llmCallsLeft--;
570
- }
571
- }
572
-
573
- for (const [tag, group] of Object.entries(byTopic)) {
574
- if (llmCallsLeft <= 0) break;
575
- if (group.length < 3) continue;
576
- const relPath = `by-topic/${tag}.md`;
577
- if (isFresh(yeaftDir, relPath, group)) continue;
578
- const narrative = await generateNarrative({ adapter, config, category: 'topic', label: tag, entries: group });
579
- if (narrative) {
580
- writeMemoryFile(yeaftDir, relPath, narrative);
581
- summary.narrativeFiles.push(relPath);
582
- llmCallsLeft--;
583
- }
584
- }
585
-
586
- for (const [ym, group] of Object.entries(byMonth)) {
587
- if (llmCallsLeft <= 0) break;
588
- if (group.length < 3) continue;
589
- const relPath = `timeline/${ym}.md`;
590
- if (isFresh(yeaftDir, relPath, group)) continue;
591
- const narrative = await generateNarrative({ adapter, config, category: 'timeline', label: ym, entries: group });
592
- if (narrative) {
593
- writeMemoryFile(yeaftDir, relPath, narrative);
594
- summary.narrativeFiles.push(relPath);
595
- llmCallsLeft--;
596
- }
597
- }
598
-
599
- // Regenerate index once more so new narrative files appear in it
600
- const finalIndex = renderIndex(yeaftDir, entryCount);
601
- writeMemoryFile(yeaftDir, 'index.md', finalIndex);
602
- summary.indexBytes = finalIndex.length;
603
-
604
- return summary;
605
- }
606
-
607
- /**
608
- * Extract preference-like strings from gather/promote results.
609
- * @returns {string[]}
610
- */
611
- function extractPreferences(gatherResult, promoteResult) {
612
- const out = [];
613
- const pushFrom = (arr) => {
614
- if (!Array.isArray(arr)) return;
615
- for (const e of arr) {
616
- if (!e) continue;
617
- if (e.kind === 'preference' && typeof e.content === 'string' && e.content.trim()) {
618
- out.push(e.content.trim());
619
- }
620
- }
621
- };
622
- pushFrom(gatherResult?.newEntries);
623
- pushFrom(promoteResult?.promotedEntries);
624
- // profileUpdates section `preferences`, if present
625
- const prefUpdates = promoteResult?.profileUpdates?.preferences;
626
- if (Array.isArray(prefUpdates)) {
627
- for (const line of prefUpdates) {
628
- if (typeof line === 'string' && line.trim()) out.push(line.trim());
629
- }
630
- }
631
- return out;
632
- }
633
-
634
- /**
635
- * Merge new preference lines into the existing user-preferences.md content.
636
- * Dedupes on normalized text (lowercase + collapsed whitespace).
637
- * @returns {{ text: string, changed: boolean, addedCount: number }}
638
- */
639
- function mergePreferences(existing, newLines) {
640
- const header = '# User Preferences\n\n';
641
- const body = existing.trim().startsWith('# ')
642
- ? existing.replace(/^# [^\n]*\n+/, '')
643
- : existing;
644
-
645
- const existingLines = body.split('\n').map(l => l.trim()).filter(l => l.startsWith('- '));
646
- const norm = (s) => s.replace(/^[-*]\s*/, '').toLowerCase().replace(/\s+/g, ' ').trim();
647
- const seen = new Set(existingLines.map(norm));
648
-
649
- let addedCount = 0;
650
- const added = [];
651
- for (const line of newLines) {
652
- const key = norm(line);
653
- if (!key || seen.has(key)) continue;
654
- seen.add(key);
655
- added.push(`- ${line.replace(/^[-*]\s*/, '')}`);
656
- addedCount++;
657
- }
658
-
659
- if (addedCount === 0) return { text: existing, changed: false, addedCount: 0 };
660
-
661
- const allLines = [...existingLines, ...added];
662
- const text = header + allLines.join('\n') + '\n';
663
- return { text, changed: true, addedCount };
664
- }
665
-
666
- /**
667
- * Group entries by project scope (first segment, or last if starts with 'work/').
668
- */
669
- function groupByProject(entries) {
670
- const out = {};
671
- for (const e of entries) {
672
- const scope = e.scope || '';
673
- if (!scope || scope === 'global') continue;
674
- const parts = scope.split('/').filter(Boolean);
675
- const slug = parts[parts.length - 1];
676
- if (!slug) continue;
677
- const safe = slug.toLowerCase().replace(/[^a-z0-9._-]/g, '-');
678
- if (!out[safe]) out[safe] = [];
679
- out[safe].push(e);
680
- }
681
- return out;
682
- }
683
-
684
- function groupByTopic(entries) {
685
- const out = {};
686
- for (const e of entries) {
687
- if (!Array.isArray(e.tags)) continue;
688
- for (const t of e.tags) {
689
- if (typeof t !== 'string' || !t.trim()) continue;
690
- const safe = t.toLowerCase().replace(/[^a-z0-9._-]/g, '-');
691
- if (!out[safe]) out[safe] = [];
692
- out[safe].push(e);
693
- }
694
- }
695
- return out;
696
- }
697
-
698
- function groupByMonth(entries) {
699
- const out = {};
700
- for (const e of entries) {
701
- const ts = e.updated_at || e.created_at;
702
- if (!ts) continue;
703
- const d = new Date(ts);
704
- if (Number.isNaN(d.getTime())) continue;
705
- const ym = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
706
- if (!out[ym]) out[ym] = [];
707
- out[ym].push(e);
708
- }
709
- return out;
710
- }
711
-
712
- /**
713
- * Returns true if the existing classification file is newer than all entries
714
- * in the group (within 1 hour tolerance). Avoids regenerating recently-written files.
715
- */
716
- function isFresh(yeaftDir, relPath, group) {
717
- const fp = join(memoryDir(yeaftDir), relPath);
718
- if (!existsSync(fp)) return false;
719
- try {
720
- const stat = readFileSync(fp); // read to check existence, statless
721
- // Use latest entry updated_at as the "need" mark
722
- const latest = group.reduce((max, e) => {
723
- const t = new Date(e.updated_at || e.created_at || 0).getTime();
724
- return t > max ? t : max;
725
- }, 0);
726
- // Compare to file mtime via readdir; fallback to always-stale if unavailable
727
- // Simpler: read first line of file and see if "updated: <ts>" is after latest
728
- const firstLine = stat.toString('utf8').split('\n').slice(0, 5).join('\n');
729
- const m = firstLine.match(/updated:\s*(\S+)/);
730
- if (!m) return false;
731
- const fileTime = new Date(m[1]).getTime();
732
- return fileTime >= latest;
733
- } catch {
734
- return false;
735
- }
736
- }
737
-
738
- /**
739
- * Call the main model to produce a narrative summary of a group of entries.
740
- * @returns {Promise<string|null>}
741
- */
742
- async function generateNarrative({ adapter, config, category, label, entries }) {
743
- const entryLines = entries.slice(0, 40).map(e => {
744
- const tags = (e.tags && e.tags.length) ? ` [${e.tags.join(', ')}]` : '';
745
- return `- (${e.kind}) ${e.name}${tags}: ${String(e.content || '').slice(0, 300)}`;
746
- }).join('\n');
747
-
748
- const system = `You are a memory classifier for an AI assistant. Write a concise narrative summary (Markdown, 200-600 words) of the given memory entries grouped by ${category}. Focus on patterns, user preferences, decisions, and lessons — not on listing each entry.`;
749
-
750
- const prompt = `Category: ${category}
751
- Label: ${label}
752
- Entry count: ${entries.length}
753
-
754
- Entries:
755
- ${entryLines}
756
-
757
- Write the narrative as Markdown with:
758
- - A top heading (e.g. "# ${label}")
759
- - A metadata line: \`updated: ${new Date().toISOString()}\`
760
- - Then the narrative prose with short sub-sections as useful.`;
761
-
762
- try {
763
- const result = await adapter.call({
764
- model: config.model,
765
- system,
766
- messages: [{ role: 'user', content: prompt }],
767
- maxTokens: 2048,
768
- // task-327c: dream narrative synthesis — same 'max' tier as the
769
- // dream phase above; both pass through dream's self-reflection loop.
770
- effort: pickEffort({ scenario: 'dream' }),
771
- });
772
- const text = (result?.text || '').trim();
773
- if (!text) return null;
774
- // Ensure the `updated:` marker is present so isFresh() can parse it
775
- if (!/updated:/.test(text.split('\n').slice(0, 5).join('\n'))) {
776
- return `# ${label}\n\nupdated: ${new Date().toISOString()}\n\n${text}\n`;
777
- }
778
- return text.endsWith('\n') ? text : text + '\n';
779
- } catch {
780
- return null;
781
- }
782
- }
783
-