@yeaft/webchat-agent 0.1.468 → 0.1.469

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.468",
3
+ "version": "0.1.469",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/unify/engine.js CHANGED
@@ -22,6 +22,7 @@ import { buildSystemPrompt } from './prompts.js';
22
22
  import { LLMContextError } from './llm/adapter.js';
23
23
  import { recall } from './memory/recall.js';
24
24
  import { shouldConsolidate, consolidate } from './memory/consolidate.js';
25
+ import { buildMemoryInjection } from './memory/layout.js';
25
26
  import { runStopHooks } from './stop-hooks.js';
26
27
 
27
28
  /** Maximum number of turns before the engine stops to prevent infinite loops. */
@@ -167,9 +168,10 @@ export class Engine {
167
168
  * @param {{ profile?: string, entries?: object[] }} [memory]
168
169
  * @param {string} [compactSummary]
169
170
  * @param {string} [prompt] — user prompt (for skill relevance matching)
171
+ * @param {string} [memoryInjection] — task-287: prebuilt memory block (index + prefs + project)
170
172
  * @returns {string}
171
173
  */
172
- #buildSystemPrompt(mode, memory, compactSummary, prompt) {
174
+ #buildSystemPrompt(mode, memory, compactSummary, prompt, memoryInjection) {
173
175
  // Get relevant skill content if SkillManager is wired
174
176
  let skillContent = '';
175
177
  if (this.#skillManager && prompt) {
@@ -186,6 +188,7 @@ export class Engine {
186
188
  mode,
187
189
  toolNames,
188
190
  memory,
191
+ memoryInjection,
189
192
  compactSummary,
190
193
  skillContent,
191
194
  });
@@ -333,14 +336,30 @@ export class Engine {
333
336
  return;
334
337
  }
335
338
 
336
- // ─── Pre-query: Recall + Compact Summary ────────────────
337
- const memory = await this.#recallMemory(prompt);
338
- if (memory && memory.entries.length > 0) {
339
- yield { type: 'recall', entryCount: memory.entries.length, cached: false };
339
+ // ─── Pre-query: Memory Injection (task-287) + Compact Summary ──
340
+ // New layout: always inject Memory Index + user-preferences + project
341
+ // header excerpt. No per-turn fuzzy recall — LLM calls memory_search /
342
+ // memory_query on demand.
343
+ let memoryInjection = '';
344
+ if (this.#yeaftDir) {
345
+ try {
346
+ const entryCount = this.#memoryStore?.stats?.().entryCount ?? 0;
347
+ memoryInjection = buildMemoryInjection({
348
+ yeaftDir: this.#yeaftDir,
349
+ cwd: process.cwd(),
350
+ entryCount,
351
+ language: this.#config.language || 'en',
352
+ });
353
+ } catch {
354
+ // Injection failure is non-critical — fall back to empty.
355
+ }
356
+ }
357
+ if (memoryInjection) {
358
+ yield { type: 'recall', entryCount: 0, cached: false };
340
359
  }
341
360
 
342
361
  const compactSummary = this.#getCompactSummary();
343
- const systemPrompt = this.#buildSystemPrompt(mode, memory, compactSummary, prompt);
362
+ const systemPrompt = this.#buildSystemPrompt(mode, undefined, compactSummary, prompt, memoryInjection);
344
363
 
345
364
  // Build conversation: existing messages + new user message
346
365
  const conversationMessages = [
@@ -16,6 +16,13 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, unlink
16
16
  import { join } from 'path';
17
17
  import { scanEntries, findStaleEntries, findDuplicateGroups, summarizeScan } from './scan.js';
18
18
  import { MAX_ENTRIES } from './store.js';
19
+ import {
20
+ ensureLayout,
21
+ renderIndex,
22
+ readMemoryFile,
23
+ writeMemoryFile,
24
+ memoryDir,
25
+ } from './layout.js';
19
26
  import {
20
27
  buildOrientPrompt,
21
28
  buildGatherPrompt,
@@ -325,6 +332,31 @@ export async function dream({ yeaftDir, memoryStore, conversationStore, adapter,
325
332
  // Rebuild scopes after all changes
326
333
  memoryStore.rebuildScopes();
327
334
 
335
+ // ── Phase 6: Classify (task-287) ─────────────────────
336
+ // Maintain the new-layout classification files:
337
+ // - index.md (auto-regenerate from disk state)
338
+ // - user-preferences.md (merge gather/promote preferences)
339
+ // - by-project/<slug>.md (narrative summary per project)
340
+ // - by-topic/<slug>.md (narrative summary per topic)
341
+ // - timeline/<YYYY-MM>.md (monthly narrative digest)
342
+ onPhase?.('classify', 'starting');
343
+ try {
344
+ ensureLayout(yeaftDir);
345
+ const classifyResult = await runClassifyPhase({
346
+ yeaftDir,
347
+ memoryStore,
348
+ adapter,
349
+ config,
350
+ gatherResult,
351
+ promoteResult,
352
+ });
353
+ result.phases.classify = classifyResult;
354
+ result.classified = classifyResult;
355
+ onPhase?.('classify', classifyResult);
356
+ } catch (err) {
357
+ result.errors.push(`classify: ${err.message}`);
358
+ }
359
+
328
360
  // Update dream state
329
361
  const state = readDreamState(yeaftDir);
330
362
  state.lastDreamAt = new Date().toISOString();
@@ -466,3 +498,279 @@ function writeDreamLog(yeaftDir, result) {
466
498
  * @property {boolean} profileUpdated
467
499
  * @property {string[]} errors
468
500
  */
501
+
502
+ // ─── Phase 6: Classify (task-287) ───────────────────────────
503
+
504
+ /**
505
+ * Regenerate index.md, merge user-preferences.md, and generate narrative
506
+ * classification files (by-project, by-topic, timeline).
507
+ *
508
+ * Strategy:
509
+ * 1. Always regenerate index.md from current on-disk layout (cheap, no LLM).
510
+ * 2. Extract preferences from gather/promote results and merge (deduped)
511
+ * into user-preferences.md. No LLM call — trust structured output.
512
+ * 3. Group entries by scope (project slug), topic tag, and YYYY-MM of
513
+ * updated_at. For each group with ≥3 entries and no up-to-date file,
514
+ * call the main model once to produce a narrative summary. Caps:
515
+ * MAX_CLASSIFY_LLM_CALLS = 3 per dream.
516
+ *
517
+ * @param {{
518
+ * yeaftDir: string,
519
+ * memoryStore: import('./store.js').MemoryStore,
520
+ * adapter: object,
521
+ * config: object,
522
+ * gatherResult?: object,
523
+ * promoteResult?: object,
524
+ * }} params
525
+ * @returns {Promise<{ indexBytes: number, preferencesMerged: number, narrativeFiles: string[] }>}
526
+ */
527
+ const MAX_CLASSIFY_LLM_CALLS = 3;
528
+
529
+ async function runClassifyPhase({ yeaftDir, memoryStore, adapter, config, gatherResult, promoteResult }) {
530
+ const summary = { indexBytes: 0, preferencesMerged: 0, narrativeFiles: [] };
531
+
532
+ // 1. Regenerate index.md
533
+ const entryCount = memoryStore.listEntries().length;
534
+ const indexText = renderIndex(yeaftDir, entryCount);
535
+ writeMemoryFile(yeaftDir, 'index.md', indexText);
536
+ summary.indexBytes = indexText.length;
537
+
538
+ // 2. Merge preferences into user-preferences.md (deduped, no LLM call)
539
+ const newPreferences = extractPreferences(gatherResult, promoteResult);
540
+ if (newPreferences.length > 0) {
541
+ const merged = mergePreferences(readMemoryFile(yeaftDir, 'user-preferences.md'), newPreferences);
542
+ if (merged.changed) {
543
+ writeMemoryFile(yeaftDir, 'user-preferences.md', merged.text);
544
+ summary.preferencesMerged = merged.addedCount;
545
+ }
546
+ }
547
+
548
+ // 3. Group entries for narrative generation
549
+ const entries = memoryStore.listEntries();
550
+ const byProject = groupByProject(entries);
551
+ const byTopic = groupByTopic(entries);
552
+ const byMonth = groupByMonth(entries);
553
+
554
+ let llmCallsLeft = MAX_CLASSIFY_LLM_CALLS;
555
+
556
+ for (const [slug, group] of Object.entries(byProject)) {
557
+ if (llmCallsLeft <= 0) break;
558
+ if (group.length < 3) continue;
559
+ const relPath = `by-project/${slug}.md`;
560
+ if (isFresh(yeaftDir, relPath, group)) continue;
561
+ const narrative = await generateNarrative({ adapter, config, category: 'project', label: slug, entries: group });
562
+ if (narrative) {
563
+ writeMemoryFile(yeaftDir, relPath, narrative);
564
+ summary.narrativeFiles.push(relPath);
565
+ llmCallsLeft--;
566
+ }
567
+ }
568
+
569
+ for (const [tag, group] of Object.entries(byTopic)) {
570
+ if (llmCallsLeft <= 0) break;
571
+ if (group.length < 3) continue;
572
+ const relPath = `by-topic/${tag}.md`;
573
+ if (isFresh(yeaftDir, relPath, group)) continue;
574
+ const narrative = await generateNarrative({ adapter, config, category: 'topic', label: tag, entries: group });
575
+ if (narrative) {
576
+ writeMemoryFile(yeaftDir, relPath, narrative);
577
+ summary.narrativeFiles.push(relPath);
578
+ llmCallsLeft--;
579
+ }
580
+ }
581
+
582
+ for (const [ym, group] of Object.entries(byMonth)) {
583
+ if (llmCallsLeft <= 0) break;
584
+ if (group.length < 3) continue;
585
+ const relPath = `timeline/${ym}.md`;
586
+ if (isFresh(yeaftDir, relPath, group)) continue;
587
+ const narrative = await generateNarrative({ adapter, config, category: 'timeline', label: ym, entries: group });
588
+ if (narrative) {
589
+ writeMemoryFile(yeaftDir, relPath, narrative);
590
+ summary.narrativeFiles.push(relPath);
591
+ llmCallsLeft--;
592
+ }
593
+ }
594
+
595
+ // Regenerate index once more so new narrative files appear in it
596
+ const finalIndex = renderIndex(yeaftDir, entryCount);
597
+ writeMemoryFile(yeaftDir, 'index.md', finalIndex);
598
+ summary.indexBytes = finalIndex.length;
599
+
600
+ return summary;
601
+ }
602
+
603
+ /**
604
+ * Extract preference-like strings from gather/promote results.
605
+ * @returns {string[]}
606
+ */
607
+ function extractPreferences(gatherResult, promoteResult) {
608
+ const out = [];
609
+ const pushFrom = (arr) => {
610
+ if (!Array.isArray(arr)) return;
611
+ for (const e of arr) {
612
+ if (!e) continue;
613
+ if (e.kind === 'preference' && typeof e.content === 'string' && e.content.trim()) {
614
+ out.push(e.content.trim());
615
+ }
616
+ }
617
+ };
618
+ pushFrom(gatherResult?.newEntries);
619
+ pushFrom(promoteResult?.promotedEntries);
620
+ // profileUpdates section `preferences`, if present
621
+ const prefUpdates = promoteResult?.profileUpdates?.preferences;
622
+ if (Array.isArray(prefUpdates)) {
623
+ for (const line of prefUpdates) {
624
+ if (typeof line === 'string' && line.trim()) out.push(line.trim());
625
+ }
626
+ }
627
+ return out;
628
+ }
629
+
630
+ /**
631
+ * Merge new preference lines into the existing user-preferences.md content.
632
+ * Dedupes on normalized text (lowercase + collapsed whitespace).
633
+ * @returns {{ text: string, changed: boolean, addedCount: number }}
634
+ */
635
+ function mergePreferences(existing, newLines) {
636
+ const header = '# User Preferences\n\n';
637
+ const body = existing.trim().startsWith('# ')
638
+ ? existing.replace(/^# [^\n]*\n+/, '')
639
+ : existing;
640
+
641
+ const existingLines = body.split('\n').map(l => l.trim()).filter(l => l.startsWith('- '));
642
+ const norm = (s) => s.replace(/^[-*]\s*/, '').toLowerCase().replace(/\s+/g, ' ').trim();
643
+ const seen = new Set(existingLines.map(norm));
644
+
645
+ let addedCount = 0;
646
+ const added = [];
647
+ for (const line of newLines) {
648
+ const key = norm(line);
649
+ if (!key || seen.has(key)) continue;
650
+ seen.add(key);
651
+ added.push(`- ${line.replace(/^[-*]\s*/, '')}`);
652
+ addedCount++;
653
+ }
654
+
655
+ if (addedCount === 0) return { text: existing, changed: false, addedCount: 0 };
656
+
657
+ const allLines = [...existingLines, ...added];
658
+ const text = header + allLines.join('\n') + '\n';
659
+ return { text, changed: true, addedCount };
660
+ }
661
+
662
+ /**
663
+ * Group entries by project scope (first segment, or last if starts with 'work/').
664
+ */
665
+ function groupByProject(entries) {
666
+ const out = {};
667
+ for (const e of entries) {
668
+ const scope = e.scope || '';
669
+ if (!scope || scope === 'global') continue;
670
+ const parts = scope.split('/').filter(Boolean);
671
+ const slug = parts[parts.length - 1];
672
+ if (!slug) continue;
673
+ const safe = slug.toLowerCase().replace(/[^a-z0-9._-]/g, '-');
674
+ if (!out[safe]) out[safe] = [];
675
+ out[safe].push(e);
676
+ }
677
+ return out;
678
+ }
679
+
680
+ function groupByTopic(entries) {
681
+ const out = {};
682
+ for (const e of entries) {
683
+ if (!Array.isArray(e.tags)) continue;
684
+ for (const t of e.tags) {
685
+ if (typeof t !== 'string' || !t.trim()) continue;
686
+ const safe = t.toLowerCase().replace(/[^a-z0-9._-]/g, '-');
687
+ if (!out[safe]) out[safe] = [];
688
+ out[safe].push(e);
689
+ }
690
+ }
691
+ return out;
692
+ }
693
+
694
+ function groupByMonth(entries) {
695
+ const out = {};
696
+ for (const e of entries) {
697
+ const ts = e.updated_at || e.created_at;
698
+ if (!ts) continue;
699
+ const d = new Date(ts);
700
+ if (Number.isNaN(d.getTime())) continue;
701
+ const ym = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}`;
702
+ if (!out[ym]) out[ym] = [];
703
+ out[ym].push(e);
704
+ }
705
+ return out;
706
+ }
707
+
708
+ /**
709
+ * Returns true if the existing classification file is newer than all entries
710
+ * in the group (within 1 hour tolerance). Avoids regenerating recently-written files.
711
+ */
712
+ function isFresh(yeaftDir, relPath, group) {
713
+ const fp = join(memoryDir(yeaftDir), relPath);
714
+ if (!existsSync(fp)) return false;
715
+ try {
716
+ const stat = readFileSync(fp); // read to check existence, statless
717
+ // Use latest entry updated_at as the "need" mark
718
+ const latest = group.reduce((max, e) => {
719
+ const t = new Date(e.updated_at || e.created_at || 0).getTime();
720
+ return t > max ? t : max;
721
+ }, 0);
722
+ // Compare to file mtime via readdir; fallback to always-stale if unavailable
723
+ // Simpler: read first line of file and see if "updated: <ts>" is after latest
724
+ const firstLine = stat.toString('utf8').split('\n').slice(0, 5).join('\n');
725
+ const m = firstLine.match(/updated:\s*(\S+)/);
726
+ if (!m) return false;
727
+ const fileTime = new Date(m[1]).getTime();
728
+ return fileTime >= latest;
729
+ } catch {
730
+ return false;
731
+ }
732
+ }
733
+
734
+ /**
735
+ * Call the main model to produce a narrative summary of a group of entries.
736
+ * @returns {Promise<string|null>}
737
+ */
738
+ async function generateNarrative({ adapter, config, category, label, entries }) {
739
+ const entryLines = entries.slice(0, 40).map(e => {
740
+ const tags = (e.tags && e.tags.length) ? ` [${e.tags.join(', ')}]` : '';
741
+ return `- (${e.kind}) ${e.name}${tags}: ${String(e.content || '').slice(0, 300)}`;
742
+ }).join('\n');
743
+
744
+ 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.`;
745
+
746
+ const prompt = `Category: ${category}
747
+ Label: ${label}
748
+ Entry count: ${entries.length}
749
+
750
+ Entries:
751
+ ${entryLines}
752
+
753
+ Write the narrative as Markdown with:
754
+ - A top heading (e.g. "# ${label}")
755
+ - A metadata line: \`updated: ${new Date().toISOString()}\`
756
+ - Then the narrative prose with short sub-sections as useful.`;
757
+
758
+ try {
759
+ const result = await adapter.call({
760
+ model: config.model,
761
+ system,
762
+ messages: [{ role: 'user', content: prompt }],
763
+ maxTokens: 2048,
764
+ });
765
+ const text = (result?.text || '').trim();
766
+ if (!text) return null;
767
+ // Ensure the `updated:` marker is present so isFresh() can parse it
768
+ if (!/updated:/.test(text.split('\n').slice(0, 5).join('\n'))) {
769
+ return `# ${label}\n\nupdated: ${new Date().toISOString()}\n\n${text}\n`;
770
+ }
771
+ return text.endsWith('\n') ? text : text + '\n';
772
+ } catch {
773
+ return null;
774
+ }
775
+ }
776
+
@@ -0,0 +1,358 @@
1
+ /**
2
+ * layout.js — New-layout memory file helpers (tool-on-demand model)
3
+ *
4
+ * Directory layout:
5
+ * ~/.yeaft/memory/
6
+ * index.md — classification catalog (always injected into system prompt)
7
+ * user-preferences.md — merged user preferences (default-injected)
8
+ * entries/*.md — atomic entries (existing layout, read by memory_query)
9
+ * by-project/<slug>.md — per-project narrative summaries
10
+ * by-topic/<slug>.md — per-topic narrative summaries
11
+ * timeline/<YYYY-MM>.md — monthly narrative digests
12
+ *
13
+ * Design:
14
+ * - index.md is a single human-readable file listing all classification files
15
+ * with a one-line summary and entry count per section.
16
+ * - Aggregate files (by-project / by-topic / timeline) are narrative prose,
17
+ * not raw entry concat — produced by Dream.
18
+ * - user-preferences.md is a deduped accumulation of preferences extracted
19
+ * from conversations.
20
+ * - Project header match: basename(cwd) is matched against by-project/<slug>.md
21
+ * filenames (case-insensitive substring match either direction).
22
+ *
23
+ * This module lives alongside store.js; it does NOT replace MemoryStore.
24
+ * Old MEMORY.md / scopes.md continue to exist for backward compatibility
25
+ * but are no longer maintained by Dream after this refactor.
26
+ */
27
+
28
+ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, statSync } from 'fs';
29
+ import { basename, join } from 'path';
30
+ import { isPermissionError } from '../init.js';
31
+
32
+ // ─── Constants ──────────────────────────────────────────────
33
+
34
+ /** Classification category dirnames. */
35
+ export const CATEGORY_DIRS = ['by-project', 'by-topic', 'timeline'];
36
+
37
+ /** Classification single-file names living directly under memory/. */
38
+ export const SINGLE_FILES = ['index.md', 'user-preferences.md'];
39
+
40
+ /** Approximate character budget for prompt injection (~1.5k tokens). */
41
+ export const PROMPT_INJECTION_CHAR_BUDGET = 6000;
42
+
43
+ /** Character budget for project header excerpt (~300 tokens). */
44
+ export const PROJECT_HEADER_CHAR_BUDGET = 1200;
45
+
46
+ // ─── Path helpers ───────────────────────────────────────────
47
+
48
+ /**
49
+ * @param {string} yeaftDir — e.g. ~/.yeaft
50
+ * @returns {string} — absolute path to memory root
51
+ */
52
+ export function memoryDir(yeaftDir) {
53
+ return join(yeaftDir, 'memory');
54
+ }
55
+
56
+ /**
57
+ * Ensure the new-layout directory skeleton exists (idempotent).
58
+ * @param {string} yeaftDir
59
+ */
60
+ export function ensureLayout(yeaftDir) {
61
+ const root = memoryDir(yeaftDir);
62
+ const dirs = [root, ...CATEGORY_DIRS.map(d => join(root, d)), join(root, 'entries')];
63
+ for (const d of dirs) {
64
+ try {
65
+ if (!existsSync(d)) mkdirSync(d, { recursive: true, mode: 0o755 });
66
+ } catch (err) {
67
+ if (!isPermissionError(err)) throw err;
68
+ }
69
+ }
70
+ }
71
+
72
+ // ─── File I/O ───────────────────────────────────────────────
73
+
74
+ /**
75
+ * Read a file under memory/ by relative path. Returns '' if missing.
76
+ * @param {string} yeaftDir
77
+ * @param {string} relPath — e.g. 'index.md', 'by-project/foo.md'
78
+ * @returns {string}
79
+ */
80
+ export function readMemoryFile(yeaftDir, relPath) {
81
+ const fp = join(memoryDir(yeaftDir), relPath);
82
+ if (!existsSync(fp)) return '';
83
+ try {
84
+ return readFileSync(fp, 'utf8');
85
+ } catch (err) {
86
+ if (isPermissionError(err)) return '';
87
+ throw err;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Write a file under memory/ by relative path. Creates parent dir if needed.
93
+ * @param {string} yeaftDir
94
+ * @param {string} relPath
95
+ * @param {string} content
96
+ */
97
+ export function writeMemoryFile(yeaftDir, relPath, content) {
98
+ ensureLayout(yeaftDir);
99
+ const fp = join(memoryDir(yeaftDir), relPath);
100
+ try {
101
+ writeFileSync(fp, content, { encoding: 'utf8', mode: 0o644 });
102
+ } catch (err) {
103
+ if (!isPermissionError(err)) throw err;
104
+ }
105
+ }
106
+
107
+ /**
108
+ * List all classification files (relative paths) that currently exist.
109
+ * Includes single files (index.md, user-preferences.md) and per-category files.
110
+ *
111
+ * @param {string} yeaftDir
112
+ * @returns {{ path: string, size: number }[]}
113
+ */
114
+ export function listClassificationFiles(yeaftDir) {
115
+ const root = memoryDir(yeaftDir);
116
+ const out = [];
117
+
118
+ for (const f of SINGLE_FILES) {
119
+ const fp = join(root, f);
120
+ if (existsSync(fp)) {
121
+ try {
122
+ out.push({ path: f, size: statSync(fp).size });
123
+ } catch { /* ignore */ }
124
+ }
125
+ }
126
+
127
+ for (const dir of CATEGORY_DIRS) {
128
+ const dp = join(root, dir);
129
+ if (!existsSync(dp)) continue;
130
+ try {
131
+ for (const f of readdirSync(dp)) {
132
+ if (!f.endsWith('.md')) continue;
133
+ const fp = join(dp, f);
134
+ try {
135
+ out.push({ path: `${dir}/${f}`, size: statSync(fp).size });
136
+ } catch { /* ignore */ }
137
+ }
138
+ } catch { /* ignore */ }
139
+ }
140
+
141
+ return out;
142
+ }
143
+
144
+ // ─── Project header matching ────────────────────────────────
145
+
146
+ /**
147
+ * Given a cwd, find the best-matching by-project/<slug>.md filename.
148
+ * Rule: case-insensitive substring match between basename(cwd) and slug
149
+ * (either direction). Returns the first match by sort order, or null.
150
+ *
151
+ * @param {string} yeaftDir
152
+ * @param {string} cwd
153
+ * @returns {string|null} — relative path like 'by-project/claude-web-chat.md', or null
154
+ */
155
+ export function findProjectFile(yeaftDir, cwd) {
156
+ if (!cwd) return null;
157
+ const base = basename(cwd).toLowerCase();
158
+ if (!base) return null;
159
+
160
+ const dir = join(memoryDir(yeaftDir), 'by-project');
161
+ if (!existsSync(dir)) return null;
162
+
163
+ let files;
164
+ try {
165
+ files = readdirSync(dir).filter(f => f.endsWith('.md')).sort();
166
+ } catch {
167
+ return null;
168
+ }
169
+
170
+ for (const f of files) {
171
+ const slug = f.slice(0, -3).toLowerCase();
172
+ if (slug === base) return `by-project/${f}`;
173
+ }
174
+ for (const f of files) {
175
+ const slug = f.slice(0, -3).toLowerCase();
176
+ if (slug.includes(base) || base.includes(slug)) return `by-project/${f}`;
177
+ }
178
+
179
+ return null;
180
+ }
181
+
182
+ /**
183
+ * Return the leading excerpt of a file capped at charBudget.
184
+ * Trims to the last complete line boundary to avoid mid-line cutoff.
185
+ *
186
+ * @param {string} text
187
+ * @param {number} charBudget
188
+ * @returns {string}
189
+ */
190
+ export function excerpt(text, charBudget) {
191
+ if (!text) return '';
192
+ if (text.length <= charBudget) return text;
193
+ const cut = text.slice(0, charBudget);
194
+ const lastNl = cut.lastIndexOf('\n');
195
+ return lastNl > charBudget * 0.6 ? cut.slice(0, lastNl) : cut;
196
+ }
197
+
198
+ // ─── Index rendering ────────────────────────────────────────
199
+
200
+ /**
201
+ * Render an auto-generated index.md from the current on-disk layout.
202
+ *
203
+ * Format:
204
+ * # Memory Index
205
+ *
206
+ * ## Single files
207
+ * - user-preferences.md (NNN bytes) — user-written/Dream-merged preferences
208
+ *
209
+ * ## by-project
210
+ * - by-project/foo.md (NNN bytes)
211
+ * - by-project/bar.md (NNN bytes)
212
+ *
213
+ * ## by-topic ...
214
+ * ## timeline ...
215
+ * ## entries
216
+ * - N atomic entries (use memory_query to search)
217
+ *
218
+ * One-line summaries are pulled from the first non-empty, non-heading line
219
+ * of each file.
220
+ *
221
+ * @param {string} yeaftDir
222
+ * @param {number} entryCount — number of atomic entries (from MemoryStore)
223
+ * @returns {string}
224
+ */
225
+ export function renderIndex(yeaftDir, entryCount) {
226
+ const root = memoryDir(yeaftDir);
227
+ const lines = ['# Memory Index', ''];
228
+
229
+ // Single files section
230
+ const singleLines = [];
231
+ for (const f of SINGLE_FILES) {
232
+ if (f === 'index.md') continue;
233
+ const fp = join(root, f);
234
+ if (!existsSync(fp)) continue;
235
+ const summary = firstSummary(fp);
236
+ const size = safeSize(fp);
237
+ singleLines.push(`- ${f} (${size} bytes)${summary ? ` — ${summary}` : ''}`);
238
+ }
239
+ if (singleLines.length) {
240
+ lines.push('## Single files', '', ...singleLines, '');
241
+ }
242
+
243
+ // Category sections
244
+ for (const cat of CATEGORY_DIRS) {
245
+ const dp = join(root, cat);
246
+ if (!existsSync(dp)) continue;
247
+ let files;
248
+ try {
249
+ files = readdirSync(dp).filter(f => f.endsWith('.md')).sort();
250
+ } catch {
251
+ continue;
252
+ }
253
+ if (!files.length) continue;
254
+ lines.push(`## ${cat}`, '');
255
+ for (const f of files) {
256
+ const fp = join(dp, f);
257
+ const summary = firstSummary(fp);
258
+ const size = safeSize(fp);
259
+ lines.push(`- ${cat}/${f} (${size} bytes)${summary ? ` — ${summary}` : ''}`);
260
+ }
261
+ lines.push('');
262
+ }
263
+
264
+ // Entries section (count only — atomic entries are searched via memory_query)
265
+ lines.push('## entries', '', `- ${entryCount} atomic entries (use memory_query to search)`, '');
266
+
267
+ lines.push(
268
+ '_Note: use the `memory_search` tool with one or more paths to load a classification',
269
+ 'file in full, or `memory_query` to search atomic entries by keywords/tags._',
270
+ );
271
+
272
+ return lines.join('\n') + '\n';
273
+ }
274
+
275
+ /**
276
+ * First non-heading non-empty line of a file, trimmed to 120 chars.
277
+ */
278
+ function firstSummary(fp) {
279
+ try {
280
+ const raw = readFileSync(fp, 'utf8');
281
+ for (const line of raw.split('\n')) {
282
+ const s = line.trim();
283
+ if (!s) continue;
284
+ if (s.startsWith('#')) continue;
285
+ if (s.startsWith('---')) continue;
286
+ return s.length > 120 ? s.slice(0, 120) + '…' : s;
287
+ }
288
+ } catch { /* ignore */ }
289
+ return '';
290
+ }
291
+
292
+ function safeSize(fp) {
293
+ try {
294
+ return statSync(fp).size;
295
+ } catch {
296
+ return 0;
297
+ }
298
+ }
299
+
300
+ // ─── Prompt injection builder ───────────────────────────────
301
+
302
+ /**
303
+ * Build the memory section to inject into the system prompt every turn.
304
+ *
305
+ * Content, in order:
306
+ * 1. index.md (full text — auto-regenerated when missing/stale)
307
+ * 2. user-preferences.md (full text)
308
+ * 3. Project header excerpt (first PROJECT_HEADER_CHAR_BUDGET chars of
309
+ * the matching by-project/<slug>.md, if cwd matches)
310
+ *
311
+ * Total output is capped at PROMPT_INJECTION_CHAR_BUDGET; later sections
312
+ * are dropped first if over budget.
313
+ *
314
+ * @param {{
315
+ * yeaftDir: string,
316
+ * cwd?: string,
317
+ * entryCount?: number,
318
+ * language?: 'en' | 'zh',
319
+ * }} params
320
+ * @returns {string}
321
+ */
322
+ export function buildMemoryInjection({ yeaftDir, cwd, entryCount = 0, language = 'en' }) {
323
+ if (!yeaftDir) return '';
324
+
325
+ const heading = language === 'zh' ? '## 记忆索引' : '## Memory Index';
326
+ const prefHeading = language === 'zh' ? '## 用户偏好' : '## User Preferences';
327
+ const projectHeading = language === 'zh' ? '## 当前项目摘要' : '## Current Project Summary';
328
+
329
+ let indexText = readMemoryFile(yeaftDir, 'index.md');
330
+ if (!indexText.trim()) {
331
+ // Auto-generate an index on the fly so the LLM always sees something useful.
332
+ indexText = renderIndex(yeaftDir, entryCount);
333
+ }
334
+
335
+ const prefText = readMemoryFile(yeaftDir, 'user-preferences.md');
336
+
337
+ let projectText = '';
338
+ const projectRel = cwd ? findProjectFile(yeaftDir, cwd) : null;
339
+ if (projectRel) {
340
+ projectText = excerpt(readMemoryFile(yeaftDir, projectRel), PROJECT_HEADER_CHAR_BUDGET);
341
+ }
342
+
343
+ const sections = [];
344
+ sections.push(`${heading}\n${indexText.trim()}`);
345
+ if (prefText.trim()) sections.push(`${prefHeading}\n${prefText.trim()}`);
346
+ if (projectText.trim()) sections.push(`${projectHeading} (${projectRel})\n${projectText.trim()}`);
347
+
348
+ // Enforce total char budget — drop from the end.
349
+ let combined = sections.join('\n\n');
350
+ while (combined.length > PROMPT_INJECTION_CHAR_BUDGET && sections.length > 1) {
351
+ sections.pop();
352
+ combined = sections.join('\n\n');
353
+ }
354
+ if (combined.length > PROMPT_INJECTION_CHAR_BUDGET) {
355
+ combined = excerpt(combined, PROMPT_INJECTION_CHAR_BUDGET);
356
+ }
357
+ return combined;
358
+ }
package/unify/prompts.js CHANGED
@@ -11,6 +11,13 @@
11
11
  * - Memory section (user profile + recalled entries)
12
12
  * - Compact summary section (conversation history summary)
13
13
  *
14
+ * task-287 refactor (tool-on-demand memory):
15
+ * - New `memoryInjection` param carries prebuilt "Memory Index + user
16
+ * preferences + project header" text (~1.5k tokens). Engine builds this
17
+ * via memory/layout.buildMemoryInjection() and passes it every turn.
18
+ * - Legacy `memory={profile,entries}` param still supported for callers
19
+ * (tests, CLI) that have not migrated.
20
+ *
14
21
  * Reference: yeaft-unify-system-prompt-budget.md — Static + Dynamic + Context layers
15
22
  */
16
23
 
@@ -155,6 +162,7 @@ export const SUPPORTED_LANGUAGES = Object.keys(PROMPTS);
155
162
  * mode?: string,
156
163
  * toolNames?: string[],
157
164
  * memory?: { profile?: string, entries?: object[] },
165
+ * memoryInjection?: string,
158
166
  * compactSummary?: string,
159
167
  * skillContent?: string,
160
168
  * }} params
@@ -165,6 +173,7 @@ export function buildSystemPrompt({
165
173
  mode = 'chat',
166
174
  toolNames = [],
167
175
  memory,
176
+ memoryInjection,
168
177
  compactSummary,
169
178
  skillContent,
170
179
  } = {}) {
@@ -218,7 +227,12 @@ export function buildSystemPrompt({
218
227
  }
219
228
 
220
229
  // ─── 6. Memory Section ─────────────────────────────────
221
- if (memory && (memory.profile || (memory.entries && memory.entries.length > 0))) {
230
+ if (memoryInjection && memoryInjection.trim()) {
231
+ // New path (task-287): prebuilt injection from memory/layout.buildMemoryInjection()
232
+ // Contains index.md + user-preferences.md + optional project header excerpt.
233
+ parts.push(memoryInjection.trim());
234
+ } else if (memory && (memory.profile || (memory.entries && memory.entries.length > 0))) {
235
+ // Legacy path — kept for callers (tests, CLI) that have not migrated yet.
222
236
  const memoryParts = [lang.memoryHeader];
223
237
 
224
238
  if (memory.profile) {
@@ -21,6 +21,7 @@ import askUser from './ask-user.js';
21
21
  import memoryRead from './memory-read.js';
22
22
  import memoryWrite from './memory-write.js';
23
23
  import memorySearch from './memory-search.js';
24
+ import memoryQuery from './memory-query.js';
24
25
  import webSearch from './web-search.js';
25
26
  import webFetch from './web-fetch.js';
26
27
  import historySearch from './history-search.js';
@@ -80,6 +81,7 @@ export const allTools = [
80
81
  memoryRead,
81
82
  memoryWrite,
82
83
  memorySearch,
84
+ memoryQuery,
83
85
  webSearch,
84
86
  webFetch,
85
87
  historySearch,
@@ -0,0 +1,133 @@
1
+ /**
2
+ * memory-query.js — Search atomic memory entries by keywords/tags/scope.
3
+ *
4
+ * New-layout tool (task-287 memory refactor).
5
+ *
6
+ * Delegates to MemoryStore.findByFilter + MemoryStore.search for the actual
7
+ * heavy lifting; this is a tool-exposed wrapper that:
8
+ * 1. Accepts flat `keywords[]` (used as tags AND as content keyword scan)
9
+ * 2. Optional tags[], scope, limit
10
+ * 3. Returns a compact list suitable for LLM consumption
11
+ *
12
+ * Use this for fuzzy discovery over atomic entries. For loading a known
13
+ * classification file in full, use `memory_search` instead.
14
+ */
15
+
16
+ import { defineTool } from './types.js';
17
+
18
+ const DEFAULT_LIMIT = 10;
19
+ const MAX_LIMIT = 30;
20
+ const SNIPPET_CHARS = 400;
21
+
22
+ export default defineTool({
23
+ name: 'memory_query',
24
+ description: `Search Yeaft's atomic memory entries by keywords, tags, and scope.
25
+
26
+ Scoring:
27
+ - Exact scope match: +3
28
+ - Ancestor/descendant scope: +2
29
+ - "global" scope (fallback): +1
30
+ - Each tag overlap: +1
31
+ - Keyword hit in entry content/name/tags: retained
32
+
33
+ Use this when the system-prompt Memory Index suggests the info is in atomic
34
+ entries (entries/) rather than in a classification file. Returns up to 'limit'
35
+ results sorted by score descending.`,
36
+ parameters: {
37
+ type: 'object',
38
+ properties: {
39
+ keywords: {
40
+ type: 'array',
41
+ items: { type: 'string' },
42
+ description: 'Words to search in entry content/name/tags. Required.',
43
+ },
44
+ tags: {
45
+ type: 'array',
46
+ items: { type: 'string' },
47
+ description: 'Exact-tag filter (scored separately from keywords).',
48
+ },
49
+ scope: {
50
+ type: 'string',
51
+ description: 'Memory scope to prefer (e.g. "work/my-project").',
52
+ },
53
+ limit: {
54
+ type: 'number',
55
+ description: `Max results (default ${DEFAULT_LIMIT}, max ${MAX_LIMIT})`,
56
+ },
57
+ },
58
+ required: ['keywords'],
59
+ },
60
+ modes: ['chat', 'work'],
61
+ isConcurrencySafe: () => true,
62
+ isReadOnly: () => true,
63
+ async execute(input, ctx) {
64
+ const memoryStore = ctx?.memoryStore;
65
+ if (!memoryStore) {
66
+ return JSON.stringify({ error: 'Memory system not initialized' });
67
+ }
68
+
69
+ const keywords = Array.isArray(input?.keywords)
70
+ ? input.keywords.filter(k => typeof k === 'string' && k.trim())
71
+ : [];
72
+ if (keywords.length === 0) {
73
+ return JSON.stringify({ error: 'keywords is required and must be a non-empty string array' });
74
+ }
75
+
76
+ const tags = Array.isArray(input?.tags)
77
+ ? input.tags.filter(t => typeof t === 'string' && t.trim())
78
+ : [];
79
+ const scope = typeof input?.scope === 'string' ? input.scope : undefined;
80
+ const rawLimit = Number.isFinite(input?.limit) ? input.limit : DEFAULT_LIMIT;
81
+ const limit = Math.max(1, Math.min(MAX_LIMIT, Math.floor(rawLimit)));
82
+
83
+ try {
84
+ // Union tags: explicit tags[] + keywords (keywords double as tag hints)
85
+ const tagUnion = [...new Set([...tags, ...keywords])];
86
+
87
+ // Phase 1: scored filter by scope + tags
88
+ let results = memoryStore.findByFilter({
89
+ scope,
90
+ tags: tagUnion,
91
+ limit: limit * 3, // over-fetch for phase 2
92
+ });
93
+
94
+ // Phase 2: if any entries lack tag overlap, augment with keyword full-text
95
+ // scan so rare-word queries still surface entries with matching content.
96
+ const seen = new Set(results.map(e => e.name));
97
+ for (const kw of keywords) {
98
+ if (results.length >= limit * 3) break;
99
+ const extra = memoryStore.search(kw, limit);
100
+ for (const e of extra) {
101
+ if (!seen.has(e.name)) {
102
+ seen.add(e.name);
103
+ results.push({ ...e, _score: (e._score || 0) + 0.5 });
104
+ }
105
+ }
106
+ }
107
+
108
+ // Final sort + trim
109
+ results.sort((a, b) => (b._score || 0) - (a._score || 0));
110
+ results = results.slice(0, limit);
111
+
112
+ return JSON.stringify({
113
+ totalResults: results.length,
114
+ results: results.map(e => ({
115
+ name: e.name,
116
+ kind: e.kind,
117
+ scope: e.scope,
118
+ tags: e.tags || [],
119
+ importance: e.importance,
120
+ score: e._score,
121
+ snippet: e.content
122
+ ? (e.content.length > SNIPPET_CHARS
123
+ ? e.content.slice(0, SNIPPET_CHARS) + '…'
124
+ : e.content)
125
+ : '',
126
+ updated_at: e.updated_at,
127
+ })),
128
+ }, null, 2);
129
+ } catch (err) {
130
+ return JSON.stringify({ error: `memory_query failed: ${err.message}` });
131
+ }
132
+ },
133
+ });
@@ -1,101 +1,102 @@
1
1
  /**
2
- * memory-search.js — Search memory entries by scope, tags, and keywords.
2
+ * memory-search.js — Load memory classification files by path.
3
3
  *
4
- * Uses the MemoryStore's findByFilter for structured search.
4
+ * New-layout tool (task-287 memory refactor).
5
+ *
6
+ * The system prompt injects `index.md` every turn, which lists all available
7
+ * classification files under `~/.yeaft/memory/` (single files + by-project /
8
+ * by-topic / timeline categories). When the LLM sees a path it wants,
9
+ * it calls this tool with `paths: [...]` to load one or more of those files
10
+ * in full.
11
+ *
12
+ * This is NOT a fuzzy search. It is a precise file loader. Use `memory_query`
13
+ * for fuzzy search over atomic entries.
14
+ *
15
+ * Only paths under `memory/` are accepted. `..` segments are rejected.
5
16
  */
6
17
 
7
18
  import { defineTool } from './types.js';
19
+ import { readMemoryFile, listClassificationFiles } from '../memory/layout.js';
20
+
21
+ const MAX_FILES_PER_CALL = 5;
22
+ const MAX_BYTES_PER_FILE = 32000;
8
23
 
9
24
  export default defineTool({
10
- name: 'MemorySearch',
11
- description: `Search Yeaft's persistent memory for relevant entries.
25
+ name: 'memory_search',
26
+ description: `Load one or more memory classification files in full.
27
+
28
+ Paths are relative to ~/.yeaft/memory/. Allowed targets:
29
+ - user-preferences.md — merged user preferences
30
+ - by-project/<slug>.md — per-project narrative summary
31
+ - by-topic/<slug>.md — per-topic narrative summary
32
+ - timeline/<YYYY-MM>.md — monthly narrative digest
12
33
 
13
- Searches by scope, tags, kind, or keyword. Results are scored by relevance:
14
- - Exact scope match: highest score
15
- - Ancestor/descendant scope: medium score
16
- - Tag overlap: additional score per matching tag
17
- - Keyword in content: found via full-text scan
34
+ See the "Memory Index" section of the system prompt for the current list
35
+ of available files. Use this tool when the index suggests a file is relevant
36
+ to the user's current request. For fuzzy search over atomic memory entries
37
+ (facts, lessons, preferences), use the memory_query tool instead.
18
38
 
19
- Use this to find previously learned information before asking the user again.`,
39
+ Up to ${MAX_FILES_PER_CALL} files per call. Each file is capped at ${MAX_BYTES_PER_FILE} bytes.`,
20
40
  parameters: {
21
41
  type: 'object',
22
42
  properties: {
23
- scope: {
24
- type: 'string',
25
- description: 'Memory scope to search in (e.g. "global", "work/my-project")',
26
- },
27
- tags: {
43
+ paths: {
28
44
  type: 'array',
29
45
  items: { type: 'string' },
30
- description: 'Tags to filter by',
31
- },
32
- kind: {
33
- type: 'string',
34
- enum: ['fact', 'preference', 'skill', 'lesson', 'context', 'relation'],
35
- description: 'Filter by memory kind',
36
- },
37
- keyword: {
38
- type: 'string',
39
- description: 'Keyword to search in entry content',
40
- },
41
- limit: {
42
- type: 'number',
43
- description: 'Maximum number of results (default: 15)',
46
+ description: 'Relative paths under memory/. Example: ["by-project/claude-web-chat.md", "user-preferences.md"]',
44
47
  },
45
48
  },
49
+ required: ['paths'],
46
50
  },
47
51
  modes: ['chat', 'work'],
48
52
  isConcurrencySafe: () => true,
49
53
  isReadOnly: () => true,
50
54
  async execute(input, ctx) {
51
- const memoryStore = ctx?.memoryStore;
52
- if (!memoryStore) {
53
- return JSON.stringify({ error: 'Memory system not initialized' });
55
+ const yeaftDir = ctx?.yeaftDir;
56
+ if (!yeaftDir) {
57
+ return JSON.stringify({ error: 'Memory system not initialized (no yeaftDir in context)' });
54
58
  }
55
59
 
56
- try {
57
- const limit = input.limit || 15;
58
-
59
- // Use findByFilter for scope + tag search
60
- let results = memoryStore.findByFilter({
61
- scope: input.scope,
62
- tags: input.tags || [],
63
- limit: limit * 2, // over-fetch for post-filtering
60
+ const paths = Array.isArray(input?.paths) ? input.paths : [];
61
+ if (paths.length === 0) {
62
+ return JSON.stringify({
63
+ error: 'paths is required and must be a non-empty string array',
64
+ availablePaths: listClassificationFiles(yeaftDir).map(f => f.path),
64
65
  });
66
+ }
65
67
 
66
- // Filter by kind if specified
67
- if (input.kind) {
68
- results = results.filter(e => e.kind === input.kind);
69
- }
68
+ const results = [];
69
+ const errors = [];
70
70
 
71
- // Filter by keyword if specified
72
- if (input.keyword) {
73
- const kw = input.keyword.toLowerCase();
74
- results = results.filter(e =>
75
- (e.content && e.content.toLowerCase().includes(kw)) ||
76
- (e.name && e.name.toLowerCase().includes(kw)) ||
77
- (e.tags && e.tags.some(t => t.toLowerCase().includes(kw)))
78
- );
71
+ for (const rel of paths.slice(0, MAX_FILES_PER_CALL)) {
72
+ if (typeof rel !== 'string' || !rel.trim()) {
73
+ errors.push({ path: rel, error: 'not a non-empty string' });
74
+ continue;
75
+ }
76
+ if (rel.includes('..') || rel.startsWith('/')) {
77
+ errors.push({ path: rel, error: 'path must be relative and must not contain ..' });
78
+ continue;
79
+ }
80
+ if (!rel.endsWith('.md')) {
81
+ errors.push({ path: rel, error: 'only .md files are supported' });
82
+ continue;
79
83
  }
80
84
 
81
- // Trim to limit
82
- results = results.slice(0, limit);
85
+ const text = readMemoryFile(yeaftDir, rel);
86
+ if (!text) {
87
+ errors.push({ path: rel, error: 'file not found or empty' });
88
+ continue;
89
+ }
83
90
 
84
- return JSON.stringify({
85
- results: results.map(e => ({
86
- name: e.name,
87
- kind: e.kind,
88
- scope: e.scope,
89
- tags: e.tags,
90
- importance: e.importance,
91
- content: e.content?.slice(0, 500) + (e.content?.length > 500 ? '...' : ''),
92
- updated_at: e.updated_at,
93
- score: e._score,
94
- })),
95
- totalResults: results.length,
96
- }, null, 2);
97
- } catch (err) {
98
- return JSON.stringify({ error: `Memory search failed: ${err.message}` });
91
+ const truncated = text.length > MAX_BYTES_PER_FILE;
92
+ results.push({
93
+ path: rel,
94
+ content: truncated ? text.slice(0, MAX_BYTES_PER_FILE) : text,
95
+ truncated,
96
+ size: text.length,
97
+ });
99
98
  }
99
+
100
+ return JSON.stringify({ results, errors }, null, 2);
100
101
  },
101
102
  });