@yeaft/webchat-agent 0.1.408 → 0.1.410
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/cli.js +214 -16
- package/unify/config.js +13 -0
- package/unify/conversation/persist.js +436 -0
- package/unify/conversation/search.js +65 -0
- package/unify/engine.js +210 -18
- package/unify/index.js +18 -0
- package/unify/mcp.js +433 -0
- package/unify/memory/consolidate.js +187 -0
- package/unify/memory/dream-prompt.js +272 -0
- package/unify/memory/dream.js +468 -0
- package/unify/memory/extract.js +97 -0
- package/unify/memory/recall.js +243 -0
- package/unify/memory/scan.js +273 -0
- package/unify/memory/store.js +507 -0
- package/unify/memory/types.js +139 -0
- package/unify/prompts.js +51 -3
- package/unify/skills.js +315 -0
- package/unify/stop-hooks.js +146 -0
- package/unify/tools/enter-worktree.js +97 -0
- package/unify/tools/exit-worktree.js +131 -0
- package/unify/tools/mcp-tools.js +133 -0
- package/unify/tools/registry.js +146 -0
- package/unify/tools/skill.js +107 -0
- package/unify/tools/types.js +71 -0
|
@@ -0,0 +1,468 @@
|
|
|
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 {
|
|
20
|
+
buildOrientPrompt,
|
|
21
|
+
buildGatherPrompt,
|
|
22
|
+
buildMergePrompt,
|
|
23
|
+
buildPrunePrompt,
|
|
24
|
+
buildPromotePrompt,
|
|
25
|
+
} from './dream-prompt.js';
|
|
26
|
+
|
|
27
|
+
// ─── Constants ──────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Minimum hours between dreams. */
|
|
30
|
+
const DREAM_INTERVAL_HOURS = 24;
|
|
31
|
+
|
|
32
|
+
/** Minimum queries before a dream can trigger. */
|
|
33
|
+
const DREAM_MIN_QUERIES = 5;
|
|
34
|
+
|
|
35
|
+
/** Maximum LLM calls per dream (budget control). */
|
|
36
|
+
const MAX_DREAM_LLM_CALLS = 5;
|
|
37
|
+
|
|
38
|
+
// ─── Dream State Management ─────────────────────────────────
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Read dream state from dream/state.md.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} yeaftDir — e.g. ~/.yeaft
|
|
44
|
+
* @returns {{ lastDreamAt: string|null, queriesSinceDream: number, dreamCount: number }}
|
|
45
|
+
*/
|
|
46
|
+
export function readDreamState(yeaftDir) {
|
|
47
|
+
const statePath = join(yeaftDir, 'dream', 'state.md');
|
|
48
|
+
|
|
49
|
+
if (!existsSync(statePath)) {
|
|
50
|
+
return { lastDreamAt: null, queriesSinceDream: 0, dreamCount: 0 };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const raw = readFileSync(statePath, 'utf8');
|
|
54
|
+
const state = { lastDreamAt: null, queriesSinceDream: 0, dreamCount: 0 };
|
|
55
|
+
|
|
56
|
+
for (const line of raw.split('\n')) {
|
|
57
|
+
const colonIdx = line.indexOf(':');
|
|
58
|
+
if (colonIdx === -1) continue;
|
|
59
|
+
const key = line.slice(0, colonIdx).trim();
|
|
60
|
+
const value = line.slice(colonIdx + 1).trim();
|
|
61
|
+
|
|
62
|
+
switch (key) {
|
|
63
|
+
case 'last_dream_at': state.lastDreamAt = value || null; break;
|
|
64
|
+
case 'queries_since_dream': state.queriesSinceDream = parseInt(value, 10) || 0; break;
|
|
65
|
+
case 'dream_count': state.dreamCount = parseInt(value, 10) || 0; break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return state;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Write dream state to dream/state.md.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} yeaftDir
|
|
76
|
+
* @param {object} state
|
|
77
|
+
*/
|
|
78
|
+
export function writeDreamState(yeaftDir, state) {
|
|
79
|
+
const dreamDir = join(yeaftDir, 'dream');
|
|
80
|
+
if (!existsSync(dreamDir)) mkdirSync(dreamDir, { recursive: true });
|
|
81
|
+
|
|
82
|
+
const content = [
|
|
83
|
+
'---',
|
|
84
|
+
`last_dream_at: ${state.lastDreamAt || ''}`,
|
|
85
|
+
`queries_since_dream: ${state.queriesSinceDream || 0}`,
|
|
86
|
+
`dream_count: ${state.dreamCount || 0}`,
|
|
87
|
+
'---',
|
|
88
|
+
'',
|
|
89
|
+
'# Dream State',
|
|
90
|
+
'',
|
|
91
|
+
'This file tracks the dream system state. Do not edit manually.',
|
|
92
|
+
].join('\n');
|
|
93
|
+
|
|
94
|
+
writeFileSync(join(dreamDir, 'state.md'), content, 'utf8');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Increment the query counter (called after each query).
|
|
99
|
+
*
|
|
100
|
+
* @param {string} yeaftDir
|
|
101
|
+
*/
|
|
102
|
+
export function incrementQueryCount(yeaftDir) {
|
|
103
|
+
const state = readDreamState(yeaftDir);
|
|
104
|
+
state.queriesSinceDream++;
|
|
105
|
+
writeDreamState(yeaftDir, state);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ─── Gate Check ─────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if dream should run.
|
|
112
|
+
*
|
|
113
|
+
* @param {string} yeaftDir
|
|
114
|
+
* @returns {{ shouldDream: boolean, reason: string }}
|
|
115
|
+
*/
|
|
116
|
+
export function checkDreamGate(yeaftDir) {
|
|
117
|
+
const state = readDreamState(yeaftDir);
|
|
118
|
+
|
|
119
|
+
// Activity gate
|
|
120
|
+
if (state.queriesSinceDream < DREAM_MIN_QUERIES) {
|
|
121
|
+
return {
|
|
122
|
+
shouldDream: false,
|
|
123
|
+
reason: `Only ${state.queriesSinceDream}/${DREAM_MIN_QUERIES} queries since last dream`,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Time gate
|
|
128
|
+
if (state.lastDreamAt) {
|
|
129
|
+
const lastDream = new Date(state.lastDreamAt).getTime();
|
|
130
|
+
const hoursSince = (Date.now() - lastDream) / (1000 * 60 * 60);
|
|
131
|
+
if (hoursSince < DREAM_INTERVAL_HOURS) {
|
|
132
|
+
return {
|
|
133
|
+
shouldDream: false,
|
|
134
|
+
reason: `Only ${Math.round(hoursSince)}h/${DREAM_INTERVAL_HOURS}h since last dream`,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Mutex check
|
|
140
|
+
const lockPath = join(yeaftDir, 'dream', 'dream.lock');
|
|
141
|
+
if (existsSync(lockPath)) {
|
|
142
|
+
// Check if lock is stale (> 30 min)
|
|
143
|
+
try {
|
|
144
|
+
const lockContent = readFileSync(lockPath, 'utf8');
|
|
145
|
+
const lockTime = new Date(lockContent.trim()).getTime();
|
|
146
|
+
if (Date.now() - lockTime < 30 * 60 * 1000) {
|
|
147
|
+
return { shouldDream: false, reason: 'Dream is already running (lock held)' };
|
|
148
|
+
}
|
|
149
|
+
// Stale lock — proceed
|
|
150
|
+
} catch {
|
|
151
|
+
// Can't read lock — proceed
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return { shouldDream: true, reason: 'All gates passed' };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── Dream Execution ────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Run the full Dream pipeline.
|
|
162
|
+
*
|
|
163
|
+
* @param {{
|
|
164
|
+
* yeaftDir: string,
|
|
165
|
+
* memoryStore: import('./store.js').MemoryStore,
|
|
166
|
+
* conversationStore?: import('../conversation/persist.js').ConversationStore,
|
|
167
|
+
* adapter: object,
|
|
168
|
+
* config: object,
|
|
169
|
+
* onPhase?: (phase: string, result: any) => void,
|
|
170
|
+
* }} params
|
|
171
|
+
* @returns {Promise<DreamResult>}
|
|
172
|
+
*/
|
|
173
|
+
export async function dream({ yeaftDir, memoryStore, conversationStore, adapter, config, onPhase }) {
|
|
174
|
+
const lockPath = join(yeaftDir, 'dream', 'dream.lock');
|
|
175
|
+
const dreamDir = join(yeaftDir, 'dream');
|
|
176
|
+
|
|
177
|
+
// Acquire lock
|
|
178
|
+
if (!existsSync(dreamDir)) mkdirSync(dreamDir, { recursive: true });
|
|
179
|
+
writeFileSync(lockPath, new Date().toISOString(), 'utf8');
|
|
180
|
+
|
|
181
|
+
const result = {
|
|
182
|
+
phases: {},
|
|
183
|
+
entriesCreated: 0,
|
|
184
|
+
entriesDeleted: 0,
|
|
185
|
+
entriesMerged: 0,
|
|
186
|
+
profileUpdated: false,
|
|
187
|
+
errors: [],
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
// ── Phase 1: Orient ──────────────────────────────────
|
|
192
|
+
onPhase?.('orient', 'starting');
|
|
193
|
+
const scan = scanEntries(memoryStore);
|
|
194
|
+
const memorySummary = summarizeScan(scan);
|
|
195
|
+
const profileContent = memoryStore.readProfile();
|
|
196
|
+
|
|
197
|
+
const orientResult = await llmCall(adapter, config,
|
|
198
|
+
'You are a memory maintenance assistant. Analyze memory state and return assessment as JSON.',
|
|
199
|
+
buildOrientPrompt({ memorySummary, profileContent, entryCount: scan.totalEntries }),
|
|
200
|
+
);
|
|
201
|
+
result.phases.orient = orientResult;
|
|
202
|
+
onPhase?.('orient', orientResult);
|
|
203
|
+
|
|
204
|
+
// ── Phase 2: Gather ──────────────────────────────────
|
|
205
|
+
onPhase?.('gather', 'starting');
|
|
206
|
+
const recentCompact = conversationStore?.readCompactSummary() || '';
|
|
207
|
+
|
|
208
|
+
// Load completed tasks (simplified — read from tasks/ if available)
|
|
209
|
+
const completedTasks = loadCompletedTasks(yeaftDir);
|
|
210
|
+
|
|
211
|
+
const gatherResult = await llmCall(adapter, config,
|
|
212
|
+
'You are a memory gathering assistant. Identify new information to remember. Return JSON.',
|
|
213
|
+
buildGatherPrompt({ recentCompact, completedTasks, orientResult }),
|
|
214
|
+
);
|
|
215
|
+
result.phases.gather = gatherResult;
|
|
216
|
+
onPhase?.('gather', gatherResult);
|
|
217
|
+
|
|
218
|
+
// ── Phase 3: Merge ───────────────────────────────────
|
|
219
|
+
onPhase?.('merge', 'starting');
|
|
220
|
+
const duplicateGroups = findDuplicateGroups(scan.entries);
|
|
221
|
+
|
|
222
|
+
const mergeResult = await llmCall(adapter, config,
|
|
223
|
+
'You are a memory merge assistant. Combine duplicate entries. Return JSON.',
|
|
224
|
+
buildMergePrompt({ duplicateGroups, gatherResult }),
|
|
225
|
+
);
|
|
226
|
+
result.phases.merge = mergeResult;
|
|
227
|
+
|
|
228
|
+
// Apply merges
|
|
229
|
+
if (mergeResult?.merges) {
|
|
230
|
+
for (const merge of mergeResult.merges) {
|
|
231
|
+
if (merge.merged) {
|
|
232
|
+
memoryStore.writeEntry(merge.merged);
|
|
233
|
+
result.entriesCreated++;
|
|
234
|
+
}
|
|
235
|
+
if (merge.deleteOriginals) {
|
|
236
|
+
for (const name of merge.deleteOriginals) {
|
|
237
|
+
memoryStore.deleteEntry(name);
|
|
238
|
+
result.entriesDeleted++;
|
|
239
|
+
}
|
|
240
|
+
result.entriesMerged += (merge.deleteOriginals?.length || 0);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Write new entries from gather/merge
|
|
246
|
+
if (mergeResult?.newEntries) {
|
|
247
|
+
for (const entry of mergeResult.newEntries) {
|
|
248
|
+
memoryStore.writeEntry(entry);
|
|
249
|
+
result.entriesCreated++;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Apply updates
|
|
254
|
+
if (mergeResult?.updates) {
|
|
255
|
+
for (const update of mergeResult.updates) {
|
|
256
|
+
const existing = memoryStore.readEntry(update.entryName);
|
|
257
|
+
if (existing) {
|
|
258
|
+
memoryStore.writeEntry({ ...existing, ...update.updates });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
onPhase?.('merge', mergeResult);
|
|
263
|
+
|
|
264
|
+
// ── Phase 4: Prune ───────────────────────────────────
|
|
265
|
+
onPhase?.('prune', 'starting');
|
|
266
|
+
const staleEntries = findStaleEntries(scan.entries);
|
|
267
|
+
const currentCount = memoryStore.listEntries().length;
|
|
268
|
+
|
|
269
|
+
const pruneResult = await llmCall(adapter, config,
|
|
270
|
+
'You are a memory pruning assistant. Remove stale/low-value entries. Return JSON.',
|
|
271
|
+
buildPrunePrompt({ staleEntries, entryCount: currentCount, maxEntries: MAX_ENTRIES }),
|
|
272
|
+
);
|
|
273
|
+
result.phases.prune = pruneResult;
|
|
274
|
+
|
|
275
|
+
if (pruneResult?.toDelete) {
|
|
276
|
+
for (const name of pruneResult.toDelete) {
|
|
277
|
+
if (memoryStore.deleteEntry(name)) {
|
|
278
|
+
result.entriesDeleted++;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
onPhase?.('prune', pruneResult);
|
|
283
|
+
|
|
284
|
+
// ── Phase 5: Promote ─────────────────────────────────
|
|
285
|
+
onPhase?.('promote', 'starting');
|
|
286
|
+
const updatedEntries = memoryStore.listEntries();
|
|
287
|
+
const scopesSummary = summarizeScan(scanEntries(memoryStore));
|
|
288
|
+
|
|
289
|
+
const promoteResult = await llmCall(adapter, config,
|
|
290
|
+
'You are a memory promotion assistant. Find patterns and update profile. Return JSON.',
|
|
291
|
+
buildPromotePrompt({ entries: updatedEntries, profileContent, scopesSummary }),
|
|
292
|
+
);
|
|
293
|
+
result.phases.promote = promoteResult;
|
|
294
|
+
|
|
295
|
+
// Apply profile updates
|
|
296
|
+
if (promoteResult?.profileUpdates) {
|
|
297
|
+
for (const [section, lines] of Object.entries(promoteResult.profileUpdates)) {
|
|
298
|
+
if (Array.isArray(lines)) {
|
|
299
|
+
for (const line of lines) {
|
|
300
|
+
memoryStore.addToSection(section, line);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
result.profileUpdated = true;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Write promoted entries
|
|
308
|
+
if (promoteResult?.promotedEntries) {
|
|
309
|
+
for (const entry of promoteResult.promotedEntries) {
|
|
310
|
+
memoryStore.writeEntry(entry);
|
|
311
|
+
result.entriesCreated++;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Delete entries that were promoted to profile
|
|
316
|
+
if (promoteResult?.entriesToDelete) {
|
|
317
|
+
for (const name of promoteResult.entriesToDelete) {
|
|
318
|
+
if (memoryStore.deleteEntry(name)) {
|
|
319
|
+
result.entriesDeleted++;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
onPhase?.('promote', promoteResult);
|
|
324
|
+
|
|
325
|
+
// Rebuild scopes after all changes
|
|
326
|
+
memoryStore.rebuildScopes();
|
|
327
|
+
|
|
328
|
+
// Update dream state
|
|
329
|
+
const state = readDreamState(yeaftDir);
|
|
330
|
+
state.lastDreamAt = new Date().toISOString();
|
|
331
|
+
state.queriesSinceDream = 0;
|
|
332
|
+
state.dreamCount = (state.dreamCount || 0) + 1;
|
|
333
|
+
writeDreamState(yeaftDir, state);
|
|
334
|
+
|
|
335
|
+
// Write dream log
|
|
336
|
+
writeDreamLog(yeaftDir, result);
|
|
337
|
+
|
|
338
|
+
} catch (err) {
|
|
339
|
+
result.errors.push(err.message);
|
|
340
|
+
} finally {
|
|
341
|
+
// Release lock
|
|
342
|
+
try {
|
|
343
|
+
if (existsSync(lockPath)) unlinkSync(lockPath);
|
|
344
|
+
} catch {
|
|
345
|
+
// ignore
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
return result;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ─── Helpers ────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Make an LLM call and parse the JSON response.
|
|
356
|
+
*
|
|
357
|
+
* @param {object} adapter
|
|
358
|
+
* @param {object} config
|
|
359
|
+
* @param {string} system
|
|
360
|
+
* @param {string} prompt
|
|
361
|
+
* @returns {Promise<object|null>}
|
|
362
|
+
*/
|
|
363
|
+
async function llmCall(adapter, config, system, prompt) {
|
|
364
|
+
try {
|
|
365
|
+
const result = await adapter.call({
|
|
366
|
+
model: config.model,
|
|
367
|
+
system,
|
|
368
|
+
messages: [{ role: 'user', content: prompt }],
|
|
369
|
+
maxTokens: 4096,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const text = result.text.trim();
|
|
373
|
+
const jsonMatch = text.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
|
|
374
|
+
if (jsonMatch) {
|
|
375
|
+
return JSON.parse(jsonMatch[0]);
|
|
376
|
+
}
|
|
377
|
+
return null;
|
|
378
|
+
} catch {
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Load completed tasks without summaries (for Dream Phase 2).
|
|
385
|
+
*
|
|
386
|
+
* @param {string} yeaftDir
|
|
387
|
+
* @returns {object[]}
|
|
388
|
+
*/
|
|
389
|
+
function loadCompletedTasks(yeaftDir) {
|
|
390
|
+
const tasksDir = join(yeaftDir, 'tasks');
|
|
391
|
+
if (!existsSync(tasksDir)) return [];
|
|
392
|
+
|
|
393
|
+
const tasks = [];
|
|
394
|
+
try {
|
|
395
|
+
const dirs = readdirSync(tasksDir, { withFileTypes: true });
|
|
396
|
+
|
|
397
|
+
for (const dir of dirs) {
|
|
398
|
+
if (!dir.isDirectory()) continue;
|
|
399
|
+
|
|
400
|
+
const metaPath = join(tasksDir, dir.name, 'meta.md');
|
|
401
|
+
if (!existsSync(metaPath)) continue;
|
|
402
|
+
|
|
403
|
+
const raw = readFileSync(metaPath, 'utf8');
|
|
404
|
+
// Quick parse for status and description
|
|
405
|
+
if (raw.includes('status: completed')) {
|
|
406
|
+
const descMatch = raw.match(/description:\s*(.+)/);
|
|
407
|
+
const summaryPath = join(tasksDir, dir.name, 'summary.md');
|
|
408
|
+
const hasSummary = existsSync(summaryPath);
|
|
409
|
+
|
|
410
|
+
tasks.push({
|
|
411
|
+
id: dir.name,
|
|
412
|
+
description: descMatch ? descMatch[1].trim() : dir.name,
|
|
413
|
+
hasSummary,
|
|
414
|
+
summary: hasSummary ? readFileSync(summaryPath, 'utf8').slice(0, 500) : null,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
} catch {
|
|
419
|
+
// Tasks directory may not exist yet
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
return tasks;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Write a dream log entry for debugging.
|
|
427
|
+
*
|
|
428
|
+
* @param {string} yeaftDir
|
|
429
|
+
* @param {object} result
|
|
430
|
+
*/
|
|
431
|
+
function writeDreamLog(yeaftDir, result) {
|
|
432
|
+
const logPath = join(yeaftDir, 'dream', 'last-dream.md');
|
|
433
|
+
const content = [
|
|
434
|
+
'---',
|
|
435
|
+
`timestamp: ${new Date().toISOString()}`,
|
|
436
|
+
`entries_created: ${result.entriesCreated}`,
|
|
437
|
+
`entries_deleted: ${result.entriesDeleted}`,
|
|
438
|
+
`entries_merged: ${result.entriesMerged}`,
|
|
439
|
+
`profile_updated: ${result.profileUpdated}`,
|
|
440
|
+
`errors: ${result.errors.length}`,
|
|
441
|
+
'---',
|
|
442
|
+
'',
|
|
443
|
+
'# Last Dream Log',
|
|
444
|
+
'',
|
|
445
|
+
`Ran at ${new Date().toISOString()}`,
|
|
446
|
+
'',
|
|
447
|
+
'## Results',
|
|
448
|
+
'',
|
|
449
|
+
`- Created: ${result.entriesCreated} entries`,
|
|
450
|
+
`- Deleted: ${result.entriesDeleted} entries`,
|
|
451
|
+
`- Merged: ${result.entriesMerged} entries`,
|
|
452
|
+
`- Profile updated: ${result.profileUpdated}`,
|
|
453
|
+
'',
|
|
454
|
+
result.errors.length > 0 ? `## Errors\n\n${result.errors.map(e => `- ${e}`).join('\n')}` : '',
|
|
455
|
+
].filter(Boolean).join('\n');
|
|
456
|
+
|
|
457
|
+
writeFileSync(logPath, content, 'utf8');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* @typedef {Object} DreamResult
|
|
462
|
+
* @property {object} phases — results of each phase
|
|
463
|
+
* @property {number} entriesCreated
|
|
464
|
+
* @property {number} entriesDeleted
|
|
465
|
+
* @property {number} entriesMerged
|
|
466
|
+
* @property {boolean} profileUpdated
|
|
467
|
+
* @property {string[]} errors
|
|
468
|
+
*/
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* extract.js — Extract memory-worthy entries from conversation
|
|
3
|
+
*
|
|
4
|
+
* Called by consolidate.js during the Consolidate lifecycle.
|
|
5
|
+
* Uses a single LLM call to identify facts, preferences, skills,
|
|
6
|
+
* lessons, contexts, and relations from conversation messages.
|
|
7
|
+
*
|
|
8
|
+
* Reference: yeaft-unify-core-systems.md §3.1, yeaft-unify-design.md §6.1
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { MEMORY_KINDS } from './store.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Build the extraction prompt.
|
|
15
|
+
* @param {object[]} messages — conversation messages to analyze
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
function buildExtractionPrompt(messages) {
|
|
19
|
+
const conversation = messages.map(m => {
|
|
20
|
+
const prefix = m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : 'System';
|
|
21
|
+
return `[${prefix}]: ${m.content}`;
|
|
22
|
+
}).join('\n\n');
|
|
23
|
+
|
|
24
|
+
return `Analyze the following conversation and extract any memorable information worth saving to long-term memory.
|
|
25
|
+
|
|
26
|
+
For each memory, provide:
|
|
27
|
+
- **name**: A short slug-friendly name (e.g., "user-prefers-typescript", "project-uses-vue3")
|
|
28
|
+
- **kind**: One of: ${MEMORY_KINDS.join(', ')}
|
|
29
|
+
- **scope**: A tree path (e.g., "global", "tech/typescript", "work/project-name")
|
|
30
|
+
- **tags**: Relevant keywords as an array
|
|
31
|
+
- **importance**: "high", "normal", or "low"
|
|
32
|
+
- **content**: 1-3 sentences describing the memory
|
|
33
|
+
|
|
34
|
+
Memory kinds explained:
|
|
35
|
+
- fact: Objective facts (project structure, tech stack)
|
|
36
|
+
- preference: User preferences (coding style, tools)
|
|
37
|
+
- skill: How to do something (patterns, techniques)
|
|
38
|
+
- lesson: Lessons learned (bugs, pitfalls)
|
|
39
|
+
- context: Temporal context (current OKR, progress)
|
|
40
|
+
- relation: People and relationships (teammates, roles)
|
|
41
|
+
|
|
42
|
+
Do NOT extract:
|
|
43
|
+
- Specific code snippets (too large, will become stale)
|
|
44
|
+
- Temporary debugging information
|
|
45
|
+
- Trivial greetings or small talk
|
|
46
|
+
|
|
47
|
+
Return a JSON array of memory objects. If nothing is worth remembering, return an empty array [].
|
|
48
|
+
|
|
49
|
+
Conversation:
|
|
50
|
+
${conversation}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Extract memory entries from a set of conversation messages.
|
|
55
|
+
*
|
|
56
|
+
* @param {{ messages: object[], adapter: object, config: object }} params
|
|
57
|
+
* @returns {Promise<object[]>} — extracted memory entries
|
|
58
|
+
*/
|
|
59
|
+
export async function extractMemories({ messages, adapter, config }) {
|
|
60
|
+
if (!messages || messages.length === 0) return [];
|
|
61
|
+
|
|
62
|
+
const system = 'You are a memory extraction assistant. Analyze conversations and extract important facts, preferences, and lessons. Return ONLY a valid JSON array, no other text.';
|
|
63
|
+
|
|
64
|
+
const extractionPrompt = buildExtractionPrompt(messages);
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const result = await adapter.call({
|
|
68
|
+
model: config.model,
|
|
69
|
+
system,
|
|
70
|
+
messages: [{ role: 'user', content: extractionPrompt }],
|
|
71
|
+
maxTokens: 2048,
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const text = result.text.trim();
|
|
75
|
+
|
|
76
|
+
// Try to parse JSON array from the response
|
|
77
|
+
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
|
78
|
+
if (!jsonMatch) return [];
|
|
79
|
+
|
|
80
|
+
const entries = JSON.parse(jsonMatch[0]);
|
|
81
|
+
|
|
82
|
+
// Validate and normalize entries
|
|
83
|
+
return entries
|
|
84
|
+
.filter(e => e && typeof e === 'object' && e.name && e.content)
|
|
85
|
+
.map(e => ({
|
|
86
|
+
name: String(e.name).slice(0, 80),
|
|
87
|
+
kind: MEMORY_KINDS.includes(e.kind) ? e.kind : 'fact',
|
|
88
|
+
scope: String(e.scope || 'global'),
|
|
89
|
+
tags: Array.isArray(e.tags) ? e.tags.map(String) : [],
|
|
90
|
+
importance: ['high', 'normal', 'low'].includes(e.importance) ? e.importance : 'normal',
|
|
91
|
+
content: String(e.content),
|
|
92
|
+
}));
|
|
93
|
+
} catch {
|
|
94
|
+
// LLM failure — return empty (non-critical operation)
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
}
|