@yeaft/webchat-agent 0.1.467 → 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 +1 -1
- package/unify/engine.js +25 -6
- package/unify/memory/dream.js +308 -0
- package/unify/memory/layout.js +358 -0
- package/unify/prompts.js +15 -1
- package/unify/tools/agent.js +51 -0
- package/unify/tools/index.js +2 -0
- package/unify/tools/memory-query.js +133 -0
- package/unify/tools/memory-search.js +70 -69
package/package.json
CHANGED
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:
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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,
|
|
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 = [
|
package/unify/memory/dream.js
CHANGED
|
@@ -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 (
|
|
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) {
|
package/unify/tools/agent.js
CHANGED
|
@@ -122,6 +122,56 @@ export function budgetExceededResult(agent, reason) {
|
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Apply an incremental delta to an agent's usage, then check budget.
|
|
127
|
+
* If exceeded: abort the agent's signal, set result to the budget envelope,
|
|
128
|
+
* flip status to 'completed', and return the envelope. Otherwise returns null.
|
|
129
|
+
*
|
|
130
|
+
* Call this at each turn boundary inside the sub-agent's execution loop.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} agentId
|
|
133
|
+
* @param {{ tokens?: number, turns?: number, partial_output?: string }} [delta]
|
|
134
|
+
* @param {number} [now=Date.now()]
|
|
135
|
+
* @returns {object|null} — budget envelope if exceeded, else null
|
|
136
|
+
*/
|
|
137
|
+
export function tickAgent(agentId, delta = {}, now = Date.now()) {
|
|
138
|
+
const agent = agents.get(agentId);
|
|
139
|
+
if (!agent) return null;
|
|
140
|
+
if (agent.status === 'completed' || agent.status === 'closed') return null;
|
|
141
|
+
|
|
142
|
+
if (typeof delta.tokens === 'number' && delta.tokens > 0) {
|
|
143
|
+
agent.usage.tokens += delta.tokens;
|
|
144
|
+
}
|
|
145
|
+
if (typeof delta.turns === 'number' && delta.turns > 0) {
|
|
146
|
+
agent.usage.turns += delta.turns;
|
|
147
|
+
}
|
|
148
|
+
if (typeof delta.partial_output === 'string' && delta.partial_output) {
|
|
149
|
+
agent.partial_output = delta.partial_output;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const check = checkBudget(agent, now);
|
|
153
|
+
if (!check.exceeded) return null;
|
|
154
|
+
|
|
155
|
+
const envelope = budgetExceededResult(agent, check.reason);
|
|
156
|
+
agent.result = envelope;
|
|
157
|
+
agent.status = 'completed';
|
|
158
|
+
agent.diagnostics.push({
|
|
159
|
+
type: 'budget_exceeded',
|
|
160
|
+
limit: check.limit,
|
|
161
|
+
reason: check.reason,
|
|
162
|
+
at: now,
|
|
163
|
+
});
|
|
164
|
+
// Signal any in-flight sub-agent work to stop
|
|
165
|
+
if (agent.abortController && !agent.abortController.signal.aborted) {
|
|
166
|
+
try {
|
|
167
|
+
agent.abortController.abort(check.reason);
|
|
168
|
+
} catch {
|
|
169
|
+
// ignore double-abort
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return envelope;
|
|
173
|
+
}
|
|
174
|
+
|
|
125
175
|
export default defineTool({
|
|
126
176
|
name: 'Agent',
|
|
127
177
|
description: `Create a sub-agent to work on an independent task in parallel.
|
|
@@ -219,6 +269,7 @@ Guidelines:
|
|
|
219
269
|
usage: { tokens: 0, turns: 0, startedAt: now },
|
|
220
270
|
createdAt: now,
|
|
221
271
|
trace: [],
|
|
272
|
+
abortController: new AbortController(),
|
|
222
273
|
};
|
|
223
274
|
|
|
224
275
|
agents.set(agentId, agent);
|
package/unify/tools/index.js
CHANGED
|
@@ -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 —
|
|
2
|
+
* memory-search.js — Load memory classification files by path.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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: '
|
|
11
|
-
description: `
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: '
|
|
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
|
|
52
|
-
if (!
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
results = results.filter(e => e.kind === input.kind);
|
|
69
|
-
}
|
|
68
|
+
const results = [];
|
|
69
|
+
const errors = [];
|
|
70
70
|
|
|
71
|
-
|
|
72
|
-
if (
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
});
|