@yeaft/webchat-agent 0.1.548 → 0.1.550
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 +20 -5
- package/unify/memory/dream-extract.js +381 -0
- package/unify/memory/dream-scheduler.js +23 -0
- package/unify/memory/user-memory-store.js +239 -9
- package/unify/session.js +3 -0
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -23,6 +23,7 @@ import { LLMContextError, LLMAbortError } from './llm/adapter.js';
|
|
|
23
23
|
import { recallR6, formatForInjection } from './memory/recall-r6.js';
|
|
24
24
|
import { shouldConsolidate, consolidate } from './memory/consolidate.js';
|
|
25
25
|
import { buildMemoryInjection } from './memory/layout.js';
|
|
26
|
+
import { buildUserProfile } from './memory/user-memory-store.js';
|
|
26
27
|
import { runStopHooks } from './stop-hooks.js';
|
|
27
28
|
import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
|
|
28
29
|
import { pickEffort, parseEffortPrefix } from './effort.js';
|
|
@@ -293,9 +294,10 @@ export class Engine {
|
|
|
293
294
|
* @param {string} [compactSummary]
|
|
294
295
|
* @param {string} [prompt] — user prompt (for skill relevance matching)
|
|
295
296
|
* @param {string} [memoryInjection] — task-287: prebuilt memory block (index + prefs + project)
|
|
297
|
+
* @param {string} [userProfile] — user profile from user-memory shard store
|
|
296
298
|
* @returns {string}
|
|
297
299
|
*/
|
|
298
|
-
#buildSystemPrompt(memory, compactSummary, prompt, memoryInjection) {
|
|
300
|
+
#buildSystemPrompt(memory, compactSummary, prompt, memoryInjection, userProfile) {
|
|
299
301
|
// Get relevant skill content if SkillManager is wired
|
|
300
302
|
let skillContent = '';
|
|
301
303
|
if (this.#skillManager && prompt) {
|
|
@@ -314,6 +316,7 @@ export class Engine {
|
|
|
314
316
|
memoryInjection,
|
|
315
317
|
compactSummary,
|
|
316
318
|
skillContent,
|
|
319
|
+
userProfile,
|
|
317
320
|
// task-334f: memory_trace tool is now registered (49 → 51 tools), so
|
|
318
321
|
// unlock the core_memory meta-line behind 334e's feature flag.
|
|
319
322
|
memoryTraceAvailable: true,
|
|
@@ -359,9 +362,20 @@ export class Engine {
|
|
|
359
362
|
async #recallMemory(prompt) {
|
|
360
363
|
const memory = { profile: '', entries: [], formatted: '' };
|
|
361
364
|
|
|
362
|
-
//
|
|
363
|
-
if
|
|
364
|
-
|
|
365
|
+
// Build user profile from user-memory shard store (R6 path),
|
|
366
|
+
// falling back to legacy readProfile if shard store unavailable.
|
|
367
|
+
try {
|
|
368
|
+
const profile = buildUserProfile(this.#memoryShardStore);
|
|
369
|
+
if (profile) {
|
|
370
|
+
memory.profile = profile;
|
|
371
|
+
} else if (this.#memoryStore) {
|
|
372
|
+
memory.profile = this.#memoryStore.readProfile();
|
|
373
|
+
}
|
|
374
|
+
} catch {
|
|
375
|
+
// Non-critical — fall through to legacy
|
|
376
|
+
if (this.#memoryStore) {
|
|
377
|
+
try { memory.profile = this.#memoryStore.readProfile(); } catch { /* */ }
|
|
378
|
+
}
|
|
365
379
|
}
|
|
366
380
|
|
|
367
381
|
// R6 shard-based recall (preferred path)
|
|
@@ -603,7 +617,8 @@ export class Engine {
|
|
|
603
617
|
}
|
|
604
618
|
|
|
605
619
|
const compactSummary = this.#getCompactSummary();
|
|
606
|
-
const
|
|
620
|
+
const userProfile = recallResult?.profile || '';
|
|
621
|
+
const systemPrompt = this.#buildSystemPrompt(undefined, compactSummary, prompt, memoryInjection, userProfile);
|
|
607
622
|
|
|
608
623
|
// Build conversation: existing messages + new user message
|
|
609
624
|
const conversationMessages = [
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-extract.js — R6 §Δ26 Phase A: Extract memories from conversation.
|
|
3
|
+
*
|
|
4
|
+
* Reads un-ingested messages from the group coordinator JSONL log,
|
|
5
|
+
* calls the LLM to extract memory-worthy candidates, then deduplicates
|
|
6
|
+
* each candidate against the existing shard store before writing.
|
|
7
|
+
*
|
|
8
|
+
* Watermark: tracks the last-ingested message id in a JSON file
|
|
9
|
+
* at `<memoryDir>/extract-watermark.json`.
|
|
10
|
+
*
|
|
11
|
+
* Dedup classification per candidate:
|
|
12
|
+
* - is_duplicate → skip (already exists)
|
|
13
|
+
* - is_update → supersede old entry with new
|
|
14
|
+
* - is_new → write fresh entry
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, readFileSync, mkdirSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { randomUUID } from 'crypto';
|
|
20
|
+
import { writeAtomic } from '../storage/index.js';
|
|
21
|
+
import { pickEffort } from '../effort.js';
|
|
22
|
+
import { classifyUserMemoryShard } from './user-memory-store.js';
|
|
23
|
+
|
|
24
|
+
// ─── Watermark ────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
const WATERMARK_FILE = 'extract-watermark.json';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Read the extract watermark (last processed message id).
|
|
30
|
+
* @param {string} memoryDir
|
|
31
|
+
* @returns {{ lastMsgId: string|null, lastTs: string|null }}
|
|
32
|
+
*/
|
|
33
|
+
export function readWatermark(memoryDir) {
|
|
34
|
+
const p = join(memoryDir, WATERMARK_FILE);
|
|
35
|
+
if (!existsSync(p)) return { lastMsgId: null, lastTs: null };
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
38
|
+
} catch {
|
|
39
|
+
return { lastMsgId: null, lastTs: null };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Write the extract watermark.
|
|
45
|
+
* @param {string} memoryDir
|
|
46
|
+
* @param {{ lastMsgId: string, lastTs: string }} wm
|
|
47
|
+
*/
|
|
48
|
+
export function writeWatermark(memoryDir, wm) {
|
|
49
|
+
if (!existsSync(memoryDir)) mkdirSync(memoryDir, { recursive: true });
|
|
50
|
+
writeAtomic(join(memoryDir, WATERMARK_FILE), JSON.stringify(wm, null, 2));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Message Collection ───────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Collect messages from the group log that are newer than the watermark.
|
|
57
|
+
*
|
|
58
|
+
* @param {import('../groups/group-store.js').GroupHandle} group
|
|
59
|
+
* @param {{ lastMsgId: string|null }} watermark
|
|
60
|
+
* @param {{ maxMessages?: number }} [opts]
|
|
61
|
+
* @returns {{ messages: object[], lastMsg: object|null }}
|
|
62
|
+
*/
|
|
63
|
+
export function collectNewMessages(group, watermark, opts = {}) {
|
|
64
|
+
const max = opts.maxMessages || 200;
|
|
65
|
+
const messages = [];
|
|
66
|
+
let pastWatermark = !watermark.lastMsgId; // if no watermark, take all
|
|
67
|
+
let lastMsg = null;
|
|
68
|
+
|
|
69
|
+
for (const msg of group.streamMessages()) {
|
|
70
|
+
if (!pastWatermark) {
|
|
71
|
+
if (msg.id === watermark.lastMsgId) {
|
|
72
|
+
pastWatermark = true;
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
// Only include user and assistant messages (skip system/meta)
|
|
77
|
+
if (msg.role === 'user' || msg.role === 'assistant') {
|
|
78
|
+
messages.push(msg);
|
|
79
|
+
lastMsg = msg;
|
|
80
|
+
}
|
|
81
|
+
if (messages.length >= max) break;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { messages, lastMsg };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── LLM Extraction ──────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Build the extraction prompt for dream-extract.
|
|
91
|
+
* Reuses the pattern from extract.js but outputs R6-compatible entries.
|
|
92
|
+
*/
|
|
93
|
+
function buildDreamExtractionPrompt(messages) {
|
|
94
|
+
const conversation = messages.map(m => {
|
|
95
|
+
const prefix = m.role === 'user' ? 'User' : 'Assistant';
|
|
96
|
+
const text = typeof m.text === 'string' ? m.text : String(m.text || '');
|
|
97
|
+
return `[${prefix}]: ${text}`;
|
|
98
|
+
}).join('\n\n');
|
|
99
|
+
|
|
100
|
+
return `Analyze the following conversation and extract any information worth saving to long-term memory.
|
|
101
|
+
|
|
102
|
+
For each memory, provide:
|
|
103
|
+
- **body**: 1-3 sentences describing the memory (concise, factual)
|
|
104
|
+
- **kind**: One of: fact, preference, skill, lesson, context, relation
|
|
105
|
+
- **tags**: Relevant keywords as a string array
|
|
106
|
+
- **importance**: "high", "normal", or "low"
|
|
107
|
+
|
|
108
|
+
Memory kinds:
|
|
109
|
+
- fact: Objective facts about the user or their work
|
|
110
|
+
- preference: User preferences (coding style, tools, habits)
|
|
111
|
+
- skill: Techniques or patterns the user uses or is learning
|
|
112
|
+
- lesson: Lessons learned, pitfalls, debugging insights
|
|
113
|
+
- context: Current project context, OKRs, deadlines
|
|
114
|
+
- relation: People, teams, roles the user mentions
|
|
115
|
+
|
|
116
|
+
Do NOT extract:
|
|
117
|
+
- Specific code snippets (too large, become stale)
|
|
118
|
+
- Temporary debugging info
|
|
119
|
+
- Trivial greetings or small talk
|
|
120
|
+
- Information already obviously known (like "user asked me a question")
|
|
121
|
+
|
|
122
|
+
Return a JSON array. If nothing is worth remembering, return [].
|
|
123
|
+
|
|
124
|
+
Conversation:
|
|
125
|
+
${conversation}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Call LLM to extract memory candidates from messages.
|
|
130
|
+
*
|
|
131
|
+
* @param {{ messages: object[], adapter: object, config: object }} params
|
|
132
|
+
* @returns {Promise<object[]>} — extracted candidates
|
|
133
|
+
*/
|
|
134
|
+
export async function extractCandidates({ messages, adapter, config }) {
|
|
135
|
+
if (!messages || messages.length === 0) return [];
|
|
136
|
+
|
|
137
|
+
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.';
|
|
138
|
+
const prompt = buildDreamExtractionPrompt(messages);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const result = await adapter.call({
|
|
142
|
+
model: config.model || config.primaryModel || 'default',
|
|
143
|
+
system,
|
|
144
|
+
messages: [{ role: 'user', content: prompt }],
|
|
145
|
+
maxTokens: 2048,
|
|
146
|
+
effort: pickEffort({ scenario: 'consolidate' }),
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
const text = (result.text || '').trim();
|
|
150
|
+
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
|
151
|
+
if (!jsonMatch) return [];
|
|
152
|
+
|
|
153
|
+
const entries = JSON.parse(jsonMatch[0]);
|
|
154
|
+
return entries
|
|
155
|
+
.filter(e => e && typeof e === 'object' && e.body)
|
|
156
|
+
.map(e => ({
|
|
157
|
+
body: String(e.body).slice(0, 500),
|
|
158
|
+
kind: ['fact', 'preference', 'skill', 'lesson', 'context', 'relation'].includes(e.kind) ? e.kind : 'fact',
|
|
159
|
+
tags: Array.isArray(e.tags) ? e.tags.map(String) : [],
|
|
160
|
+
importance: ['high', 'normal', 'low'].includes(e.importance) ? e.importance : 'normal',
|
|
161
|
+
}));
|
|
162
|
+
} catch {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Dedup / Similarity ──────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Classify a candidate against existing shard entries.
|
|
171
|
+
*
|
|
172
|
+
* Simple heuristic (no LLM): normalized text overlap.
|
|
173
|
+
* - Exact body match → is_duplicate
|
|
174
|
+
* - >60% word overlap with existing → is_update (supersede)
|
|
175
|
+
* - Otherwise → is_new
|
|
176
|
+
*
|
|
177
|
+
* @param {object} candidate — { body, kind, tags }
|
|
178
|
+
* @param {object[]} existingEntries — thin entries with body loaded
|
|
179
|
+
* @returns {{ action: 'is_duplicate'|'is_update'|'is_new', matchId?: string }}
|
|
180
|
+
*/
|
|
181
|
+
export function classifyCandidate(candidate, existingEntries) {
|
|
182
|
+
if (!candidate || !candidate.body) return { action: 'is_new' };
|
|
183
|
+
if (!existingEntries || existingEntries.length === 0) return { action: 'is_new' };
|
|
184
|
+
|
|
185
|
+
const candWords = normalizeWords(candidate.body);
|
|
186
|
+
if (candWords.size === 0) return { action: 'is_new' };
|
|
187
|
+
|
|
188
|
+
let bestOverlap = 0;
|
|
189
|
+
let bestId = null;
|
|
190
|
+
|
|
191
|
+
for (const entry of existingEntries) {
|
|
192
|
+
const entryBody = entry.body || '';
|
|
193
|
+
if (!entryBody) continue;
|
|
194
|
+
|
|
195
|
+
// Exact match
|
|
196
|
+
if (entryBody.trim().toLowerCase() === candidate.body.trim().toLowerCase()) {
|
|
197
|
+
return { action: 'is_duplicate', matchId: entry.id };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Word overlap
|
|
201
|
+
const entryWords = normalizeWords(entryBody);
|
|
202
|
+
if (entryWords.size === 0) continue;
|
|
203
|
+
let overlap = 0;
|
|
204
|
+
for (const w of candWords) {
|
|
205
|
+
if (entryWords.has(w)) overlap++;
|
|
206
|
+
}
|
|
207
|
+
const ratio = overlap / Math.max(candWords.size, entryWords.size);
|
|
208
|
+
if (ratio > bestOverlap) {
|
|
209
|
+
bestOverlap = ratio;
|
|
210
|
+
bestId = entry.id;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (bestOverlap > 0.6 && bestId) {
|
|
215
|
+
return { action: 'is_update', matchId: bestId };
|
|
216
|
+
}
|
|
217
|
+
return { action: 'is_new' };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeWords(text) {
|
|
221
|
+
return new Set(
|
|
222
|
+
text.toLowerCase().replace(/[^\w\s]/g, '').split(/\s+/).filter(w => w.length > 2)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ─── Main Entry Point ────────────────────────────────────────
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Run dream extract: read new messages, LLM extract, dedup, write.
|
|
230
|
+
*
|
|
231
|
+
* @param {{
|
|
232
|
+
* group: import('../groups/group-store.js').GroupHandle,
|
|
233
|
+
* shardStore: object,
|
|
234
|
+
* adapter: object,
|
|
235
|
+
* config: object,
|
|
236
|
+
* memoryDir: string,
|
|
237
|
+
* onPhase?: (phase: string, data: any) => void,
|
|
238
|
+
* maxMessages?: number,
|
|
239
|
+
* }} opts
|
|
240
|
+
* @returns {Promise<DreamExtractResult>}
|
|
241
|
+
*/
|
|
242
|
+
export async function dreamExtract(opts) {
|
|
243
|
+
const { group, shardStore, adapter, config, memoryDir, onPhase, maxMessages } = opts;
|
|
244
|
+
|
|
245
|
+
const result = {
|
|
246
|
+
messagesRead: 0,
|
|
247
|
+
candidatesExtracted: 0,
|
|
248
|
+
written: 0,
|
|
249
|
+
updated: 0,
|
|
250
|
+
duplicatesSkipped: 0,
|
|
251
|
+
errors: [],
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
if (!group || !shardStore || !adapter) {
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
// 1. Read watermark + collect new messages
|
|
260
|
+
onPhase?.('collect', 'starting');
|
|
261
|
+
const wm = readWatermark(memoryDir);
|
|
262
|
+
const { messages, lastMsg } = collectNewMessages(group, wm, { maxMessages });
|
|
263
|
+
result.messagesRead = messages.length;
|
|
264
|
+
onPhase?.('collect', { count: messages.length });
|
|
265
|
+
|
|
266
|
+
if (messages.length === 0) return result;
|
|
267
|
+
|
|
268
|
+
// 2. LLM extraction
|
|
269
|
+
onPhase?.('extract', 'starting');
|
|
270
|
+
const candidates = await extractCandidates({ messages, adapter, config });
|
|
271
|
+
result.candidatesExtracted = candidates.length;
|
|
272
|
+
onPhase?.('extract', { count: candidates.length });
|
|
273
|
+
|
|
274
|
+
if (candidates.length === 0) {
|
|
275
|
+
// Still advance watermark — we read the messages, just nothing to extract
|
|
276
|
+
if (lastMsg) {
|
|
277
|
+
writeWatermark(memoryDir, { lastMsgId: lastMsg.id, lastTs: lastMsg.ts || new Date().toISOString() });
|
|
278
|
+
}
|
|
279
|
+
return result;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// 3. Load existing entries for dedup comparison
|
|
283
|
+
const existingByBody = loadExistingBodies(shardStore);
|
|
284
|
+
|
|
285
|
+
// 4. Dedup + write each candidate
|
|
286
|
+
onPhase?.('dedup', 'starting');
|
|
287
|
+
for (const candidate of candidates) {
|
|
288
|
+
try {
|
|
289
|
+
const classification = classifyCandidate(candidate, existingByBody);
|
|
290
|
+
|
|
291
|
+
switch (classification.action) {
|
|
292
|
+
case 'is_duplicate':
|
|
293
|
+
result.duplicatesSkipped++;
|
|
294
|
+
break;
|
|
295
|
+
|
|
296
|
+
case 'is_update': {
|
|
297
|
+
// Supersede old entry
|
|
298
|
+
const shard = classifyUserMemoryShard(candidate.body, candidate.tags);
|
|
299
|
+
const newId = `de-${randomUUID().slice(0, 12)}`;
|
|
300
|
+
shardStore.supersede({
|
|
301
|
+
newEntry: {
|
|
302
|
+
id: newId,
|
|
303
|
+
shard,
|
|
304
|
+
kind: candidate.kind || 'fact',
|
|
305
|
+
body: candidate.body,
|
|
306
|
+
tags: candidate.tags || [],
|
|
307
|
+
sourceRef: { hint: 'dream-extract' },
|
|
308
|
+
authoredBy: 'dream:extract',
|
|
309
|
+
},
|
|
310
|
+
oldIds: [classification.matchId],
|
|
311
|
+
});
|
|
312
|
+
result.updated++;
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
case 'is_new':
|
|
317
|
+
default: {
|
|
318
|
+
const shard = classifyUserMemoryShard(candidate.body, candidate.tags);
|
|
319
|
+
const newId = `de-${randomUUID().slice(0, 12)}`;
|
|
320
|
+
shardStore.put({
|
|
321
|
+
id: newId,
|
|
322
|
+
shard,
|
|
323
|
+
kind: candidate.kind || 'fact',
|
|
324
|
+
body: candidate.body,
|
|
325
|
+
tags: candidate.tags || [],
|
|
326
|
+
sourceRef: { hint: 'dream-extract' },
|
|
327
|
+
authoredBy: 'dream:extract',
|
|
328
|
+
});
|
|
329
|
+
result.written++;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
} catch (err) {
|
|
334
|
+
result.errors.push(`write candidate: ${err.message}`);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
onPhase?.('dedup', { written: result.written, updated: result.updated, skipped: result.duplicatesSkipped });
|
|
338
|
+
|
|
339
|
+
// 5. Advance watermark
|
|
340
|
+
if (lastMsg) {
|
|
341
|
+
writeWatermark(memoryDir, { lastMsgId: lastMsg.id, lastTs: lastMsg.ts || new Date().toISOString() });
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
} catch (err) {
|
|
345
|
+
result.errors.push(err.message);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return result;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Load all existing entry bodies (for dedup comparison).
|
|
353
|
+
* Returns array of { id, body } objects.
|
|
354
|
+
*/
|
|
355
|
+
function loadExistingBodies(shardStore) {
|
|
356
|
+
const entries = [];
|
|
357
|
+
try {
|
|
358
|
+
const st = shardStore.stats();
|
|
359
|
+
for (const shardName of Object.keys(st.shards)) {
|
|
360
|
+
const { results } = shardStore.query({ shard: shardName });
|
|
361
|
+
for (const rec of results) {
|
|
362
|
+
if (rec.supersededBy) continue;
|
|
363
|
+
const full = shardStore.get(rec.id);
|
|
364
|
+
if (full && full.body) {
|
|
365
|
+
entries.push({ id: rec.id, body: full.body });
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
} catch { /* best effort */ }
|
|
370
|
+
return entries;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* @typedef {Object} DreamExtractResult
|
|
375
|
+
* @property {number} messagesRead
|
|
376
|
+
* @property {number} candidatesExtracted
|
|
377
|
+
* @property {number} written
|
|
378
|
+
* @property {number} updated
|
|
379
|
+
* @property {number} duplicatesSkipped
|
|
380
|
+
* @property {string[]} errors
|
|
381
|
+
*/
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
import { dreamShard } from './dream-shard.js';
|
|
25
25
|
import { checkRecompression } from './recompression.js';
|
|
26
|
+
import { runUserDreamJob } from './user-memory-store.js';
|
|
26
27
|
|
|
27
28
|
/** Default idle timeout before dream triggers (ms). */
|
|
28
29
|
export const DREAM_IDLE_MS = 30 * 60 * 1000; // 30 min
|
|
@@ -35,8 +36,12 @@ const MAX_CONCURRENT_DREAMS = 2;
|
|
|
35
36
|
*
|
|
36
37
|
* @param {{
|
|
37
38
|
* memoryShardStore: object | null,
|
|
39
|
+
* userMemoryStore: object | null,
|
|
40
|
+
* conversationStore: object | null,
|
|
38
41
|
* adapter: object | null,
|
|
39
42
|
* config: object,
|
|
43
|
+
* group?: import('../groups/group-store.js').GroupHandle | null,
|
|
44
|
+
* memoryDir?: string | null,
|
|
40
45
|
* idleMs?: number,
|
|
41
46
|
* onDreamStart?: (vpId: string) => void,
|
|
42
47
|
* onDreamEnd?: (vpId: string, result: object) => void,
|
|
@@ -47,8 +52,12 @@ const MAX_CONCURRENT_DREAMS = 2;
|
|
|
47
52
|
export function createDreamScheduler(opts = {}) {
|
|
48
53
|
const {
|
|
49
54
|
memoryShardStore,
|
|
55
|
+
userMemoryStore,
|
|
56
|
+
conversationStore,
|
|
50
57
|
adapter,
|
|
51
58
|
config,
|
|
59
|
+
group = null,
|
|
60
|
+
memoryDir = null,
|
|
52
61
|
idleMs = DREAM_IDLE_MS,
|
|
53
62
|
onDreamStart,
|
|
54
63
|
onDreamEnd,
|
|
@@ -118,6 +127,7 @@ export function createDreamScheduler(opts = {}) {
|
|
|
118
127
|
try {
|
|
119
128
|
onDreamStart?.(vpId);
|
|
120
129
|
|
|
130
|
+
// Shard-based compact/merge/prune
|
|
121
131
|
const result = await dreamShard({
|
|
122
132
|
shardStore: memoryShardStore,
|
|
123
133
|
adapter,
|
|
@@ -135,6 +145,19 @@ export function createDreamScheduler(opts = {}) {
|
|
|
135
145
|
// Non-fatal
|
|
136
146
|
}
|
|
137
147
|
|
|
148
|
+
// Post-dream: run user-memory extract + compact (334-w7b)
|
|
149
|
+
try {
|
|
150
|
+
const userDreamResult = await runUserDreamJob({
|
|
151
|
+
store: userMemoryStore || undefined,
|
|
152
|
+
conversationStore: conversationStore || undefined,
|
|
153
|
+
adapter: adapter || undefined,
|
|
154
|
+
config: { model: config?.primaryModel || config?.model || 'default' },
|
|
155
|
+
});
|
|
156
|
+
result.userDream = userDreamResult;
|
|
157
|
+
} catch {
|
|
158
|
+
// Non-fatal
|
|
159
|
+
}
|
|
160
|
+
|
|
138
161
|
messagesSinceLastDream = 0;
|
|
139
162
|
lastDreamAt = Date.now();
|
|
140
163
|
onDreamEnd?.(vpId, { ...result, trigger });
|
|
@@ -15,10 +15,11 @@
|
|
|
15
15
|
import { homedir } from 'os';
|
|
16
16
|
import { join } from 'path';
|
|
17
17
|
import { randomUUID } from 'crypto';
|
|
18
|
-
import { existsSync } from 'fs';
|
|
18
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
19
19
|
import { openMemoryShardStore } from './shard-store.js';
|
|
20
20
|
import { USER_SHARDS } from './schema.js';
|
|
21
21
|
import { scanShards, runCompactJob } from './dream-shard.js';
|
|
22
|
+
import { pickEffort } from '../effort.js';
|
|
22
23
|
|
|
23
24
|
/** Default storage root for user memory. */
|
|
24
25
|
export const USER_MEMORY_DIR = join(homedir(), '.yeaft', 'user', 'memory');
|
|
@@ -193,19 +194,45 @@ export function buildUserProfile(store, opts = {}) {
|
|
|
193
194
|
// ─── Dream Job ───────────────────────────────────────────────
|
|
194
195
|
|
|
195
196
|
/**
|
|
196
|
-
* Run user-memory dream maintenance
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
197
|
+
* Run user-memory dream maintenance: extract phase + compact.
|
|
198
|
+
* Extract reads conversation messages since the last watermark, uses LLM to
|
|
199
|
+
* identify user-relevant facts, then writes them to the appropriate shards.
|
|
200
|
+
* Compact phase reclaims superseded/removed tombstones (unchanged from 334g).
|
|
200
201
|
*
|
|
201
|
-
* @param {{
|
|
202
|
-
*
|
|
202
|
+
* @param {{
|
|
203
|
+
* store?: object,
|
|
204
|
+
* conversationStore?: object,
|
|
205
|
+
* adapter?: object,
|
|
206
|
+
* config?: object,
|
|
207
|
+
* onPhase?: (phase: string, data: any) => void,
|
|
208
|
+
* }} [opts]
|
|
209
|
+
* @returns {Promise<{ extract: object|null, scan: object, compact: object } | null>}
|
|
203
210
|
*/
|
|
204
|
-
export function runUserDreamJob(opts = {}) {
|
|
211
|
+
export async function runUserDreamJob(opts = {}) {
|
|
205
212
|
const store = 'store' in opts ? opts.store : getUserMemoryStore();
|
|
206
213
|
if (!store) return null;
|
|
207
214
|
|
|
215
|
+
let extractResult = null;
|
|
216
|
+
|
|
208
217
|
try {
|
|
218
|
+
// ── Phase 1: Extract (LLM) ─────────────────────────────
|
|
219
|
+
if (opts.conversationStore && opts.adapter && opts.config) {
|
|
220
|
+
opts.onPhase?.('extract', 'starting');
|
|
221
|
+
try {
|
|
222
|
+
extractResult = await dreamExtract({
|
|
223
|
+
store,
|
|
224
|
+
conversationStore: opts.conversationStore,
|
|
225
|
+
adapter: opts.adapter,
|
|
226
|
+
config: opts.config,
|
|
227
|
+
});
|
|
228
|
+
opts.onPhase?.('extract', extractResult);
|
|
229
|
+
} catch (err) {
|
|
230
|
+
console.warn('[user-memory-store] extract phase failed:', err.message);
|
|
231
|
+
extractResult = { error: err.message, extracted: 0 };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Phase 2: Compact ───────────────────────────────────
|
|
209
236
|
const scan = scanShards(store);
|
|
210
237
|
const compact = runCompactJob({
|
|
211
238
|
shardStore: store,
|
|
@@ -214,9 +241,212 @@ export function runUserDreamJob(opts = {}) {
|
|
|
214
241
|
? (shard, r) => opts.onPhase('compact', { shard, ...r })
|
|
215
242
|
: undefined,
|
|
216
243
|
});
|
|
217
|
-
return {
|
|
244
|
+
return {
|
|
245
|
+
extract: extractResult,
|
|
246
|
+
scan: { totalEntries: scan.totalEntries, totalBytes: scan.totalBytes },
|
|
247
|
+
compact,
|
|
248
|
+
};
|
|
218
249
|
} catch (err) {
|
|
219
250
|
console.warn('[user-memory-store] dream job failed:', err.message);
|
|
220
251
|
return null;
|
|
221
252
|
}
|
|
222
253
|
}
|
|
254
|
+
|
|
255
|
+
// ─── Watermark ──────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Watermark format (shared with 334-w7b):
|
|
259
|
+
* { lastMessageId: string, lastMessageTs: number, updatedAt: string }
|
|
260
|
+
*
|
|
261
|
+
* Stored at <storeDir>/.watermark.json (alongside shard files).
|
|
262
|
+
*/
|
|
263
|
+
|
|
264
|
+
const WATERMARK_FILE = '.watermark.json';
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Read the extract watermark for a user-memory store.
|
|
268
|
+
* Returns null if no watermark exists yet.
|
|
269
|
+
*
|
|
270
|
+
* @param {string} dir — store directory (e.g. ~/.yeaft/user/memory)
|
|
271
|
+
* @returns {{ lastMessageId: string, lastMessageTs: number, updatedAt: string } | null}
|
|
272
|
+
*/
|
|
273
|
+
export function readWatermark(dir) {
|
|
274
|
+
try {
|
|
275
|
+
const p = join(dir, WATERMARK_FILE);
|
|
276
|
+
if (!existsSync(p)) return null;
|
|
277
|
+
return JSON.parse(readFileSync(p, 'utf8'));
|
|
278
|
+
} catch {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Write the extract watermark.
|
|
285
|
+
*
|
|
286
|
+
* @param {string} dir
|
|
287
|
+
* @param {{ lastMessageId: string, lastMessageTs: number }} wm
|
|
288
|
+
*/
|
|
289
|
+
export function writeWatermark(dir, wm) {
|
|
290
|
+
try {
|
|
291
|
+
const p = join(dir, WATERMARK_FILE);
|
|
292
|
+
mkdirSync(dir, { recursive: true });
|
|
293
|
+
writeFileSync(p, JSON.stringify({
|
|
294
|
+
lastMessageId: wm.lastMessageId,
|
|
295
|
+
lastMessageTs: wm.lastMessageTs,
|
|
296
|
+
updatedAt: new Date().toISOString(),
|
|
297
|
+
}, null, 2));
|
|
298
|
+
} catch (err) {
|
|
299
|
+
console.warn('[user-memory-store] writeWatermark failed:', err.message);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ─── Extract Phase ──────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
/** Max messages to process in a single extract pass. */
|
|
306
|
+
const EXTRACT_MAX_MESSAGES = 50;
|
|
307
|
+
|
|
308
|
+
/** Min messages required to trigger an extract. */
|
|
309
|
+
const EXTRACT_MIN_MESSAGES = 3;
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Build the user-memory extraction prompt.
|
|
313
|
+
* Tailored for user-relevant facts (not VP/task memory).
|
|
314
|
+
*
|
|
315
|
+
* @param {object[]} messages
|
|
316
|
+
* @returns {string}
|
|
317
|
+
*/
|
|
318
|
+
export function buildUserExtractPrompt(messages) {
|
|
319
|
+
const conversation = messages.map(m => {
|
|
320
|
+
const prefix = m.role === 'user' ? 'User' : m.role === 'assistant' ? 'Assistant' : 'System';
|
|
321
|
+
return `[${prefix}]: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`;
|
|
322
|
+
}).join('\n\n');
|
|
323
|
+
|
|
324
|
+
return `Analyze the following conversation and extract facts about THE USER that are worth remembering long-term.
|
|
325
|
+
|
|
326
|
+
Focus on these categories:
|
|
327
|
+
- **profile**: Name, job title, company, location, background, expertise areas
|
|
328
|
+
- **preferences**: Coding style, tool preferences, language preferences, communication style
|
|
329
|
+
- **projects**: Projects they work on, tech stacks, repositories, products
|
|
330
|
+
- **goals**: Current goals, objectives, what they're trying to achieve
|
|
331
|
+
- **relations**: Team members, colleagues, managers, collaborators mentioned
|
|
332
|
+
|
|
333
|
+
For each fact, provide:
|
|
334
|
+
- **shard**: One of: profile, preferences, projects, goals, relations
|
|
335
|
+
- **body**: 1-2 sentences describing the fact clearly
|
|
336
|
+
- **tags**: 1-3 keyword tags as an array
|
|
337
|
+
|
|
338
|
+
Do NOT extract:
|
|
339
|
+
- Specific code snippets or technical instructions
|
|
340
|
+
- Temporary debugging context
|
|
341
|
+
- Facts about the assistant (only about the user)
|
|
342
|
+
- Information already implied by the conversation being about coding
|
|
343
|
+
|
|
344
|
+
Return a JSON array. If nothing about the user is worth remembering, return [].
|
|
345
|
+
|
|
346
|
+
Conversation:
|
|
347
|
+
${conversation}`;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Extract user-relevant facts from conversation messages and write to user-memory shards.
|
|
352
|
+
*
|
|
353
|
+
* @param {{
|
|
354
|
+
* store: object,
|
|
355
|
+
* conversationStore: object,
|
|
356
|
+
* adapter: object,
|
|
357
|
+
* config: object,
|
|
358
|
+
* dir?: string,
|
|
359
|
+
* }} params
|
|
360
|
+
* @returns {Promise<{ extracted: number, skipped: number, watermark: object|null }>}
|
|
361
|
+
*/
|
|
362
|
+
export async function dreamExtract({ store, conversationStore, adapter, config, dir }) {
|
|
363
|
+
const storeDir = dir || USER_MEMORY_DIR;
|
|
364
|
+
const wm = readWatermark(storeDir);
|
|
365
|
+
|
|
366
|
+
// Load all messages and filter to those after watermark
|
|
367
|
+
const allMessages = conversationStore.loadAll();
|
|
368
|
+
let newMessages;
|
|
369
|
+
|
|
370
|
+
if (wm && wm.lastMessageId) {
|
|
371
|
+
const idx = allMessages.findIndex(m => m.id === wm.lastMessageId);
|
|
372
|
+
newMessages = idx >= 0 ? allMessages.slice(idx + 1) : allMessages;
|
|
373
|
+
} else {
|
|
374
|
+
// No watermark — process all messages (first run)
|
|
375
|
+
newMessages = allMessages;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Filter to user + assistant messages only (skip system)
|
|
379
|
+
newMessages = newMessages.filter(m => m.role === 'user' || m.role === 'assistant');
|
|
380
|
+
|
|
381
|
+
if (newMessages.length < EXTRACT_MIN_MESSAGES) {
|
|
382
|
+
return { extracted: 0, skipped: 0, watermark: wm };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Cap to prevent huge LLM calls
|
|
386
|
+
const batch = newMessages.slice(-EXTRACT_MAX_MESSAGES);
|
|
387
|
+
|
|
388
|
+
// LLM extraction call
|
|
389
|
+
const system = 'You are a user profile extraction assistant. Analyze conversations and extract facts about the user. Return ONLY a valid JSON array, no other text.';
|
|
390
|
+
const prompt = buildUserExtractPrompt(batch);
|
|
391
|
+
|
|
392
|
+
let candidates = [];
|
|
393
|
+
try {
|
|
394
|
+
const result = await adapter.call({
|
|
395
|
+
model: config.model || config.primaryModel || 'default',
|
|
396
|
+
system,
|
|
397
|
+
messages: [{ role: 'user', content: prompt }],
|
|
398
|
+
maxTokens: 2048,
|
|
399
|
+
effort: pickEffort({ scenario: 'dream' }),
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
const text = result.text.trim();
|
|
403
|
+
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
|
404
|
+
if (jsonMatch) {
|
|
405
|
+
candidates = JSON.parse(jsonMatch[0]);
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
return { extracted: 0, skipped: 0, watermark: wm, error: 'llm_failed' };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (!Array.isArray(candidates)) {
|
|
412
|
+
return { extracted: 0, skipped: 0, watermark: wm };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Validate and write candidates
|
|
416
|
+
let extracted = 0;
|
|
417
|
+
let skipped = 0;
|
|
418
|
+
|
|
419
|
+
for (const c of candidates) {
|
|
420
|
+
if (!c || typeof c !== 'object' || !c.body) { skipped++; continue; }
|
|
421
|
+
|
|
422
|
+
// Use classifyUserMemoryShard if shard not provided or invalid
|
|
423
|
+
const shard = USER_SHARDS.includes(c.shard)
|
|
424
|
+
? c.shard
|
|
425
|
+
: classifyUserMemoryShard(c.body, c.tags);
|
|
426
|
+
|
|
427
|
+
const id = writeUserMemory(store, {
|
|
428
|
+
text: c.body,
|
|
429
|
+
tags: Array.isArray(c.tags) ? c.tags.map(String) : [],
|
|
430
|
+
sourceRef: { origin: 'dream-extract' },
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
if (id) {
|
|
434
|
+
extracted++;
|
|
435
|
+
} else {
|
|
436
|
+
skipped++;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Update watermark to last processed message
|
|
441
|
+
const lastMsg = batch[batch.length - 1];
|
|
442
|
+
if (lastMsg) {
|
|
443
|
+
const newWm = {
|
|
444
|
+
lastMessageId: lastMsg.id || '',
|
|
445
|
+
lastMessageTs: lastMsg.ts || Date.now(),
|
|
446
|
+
};
|
|
447
|
+
writeWatermark(storeDir, newWm);
|
|
448
|
+
return { extracted, skipped, watermark: newWm };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return { extracted, skipped, watermark: wm };
|
|
452
|
+
}
|
package/unify/session.js
CHANGED
|
@@ -34,6 +34,7 @@ import { initInputQueueStore } from './input-queue/store.js';
|
|
|
34
34
|
import { createDispatcher } from './pipeline/dispatcher.js';
|
|
35
35
|
import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
|
|
36
36
|
import { createDreamScheduler } from './memory/dream-scheduler.js';
|
|
37
|
+
import { getUserMemoryStore } from './memory/user-memory-store.js';
|
|
37
38
|
import { join } from 'path';
|
|
38
39
|
import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from 'fs';
|
|
39
40
|
|
|
@@ -236,6 +237,8 @@ export async function loadSession(options = {}) {
|
|
|
236
237
|
// ─── 9a. Create dream scheduler (wave-6b) ─────────────
|
|
237
238
|
const dreamScheduler = createDreamScheduler({
|
|
238
239
|
memoryShardStore,
|
|
240
|
+
userMemoryStore: getUserMemoryStore(),
|
|
241
|
+
conversationStore,
|
|
239
242
|
adapter,
|
|
240
243
|
config,
|
|
241
244
|
onDreamStart: (vpId) => {
|