@yeaft/webchat-agent 0.1.659 → 0.1.661

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,381 +0,0 @@
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
- */
@@ -1,212 +0,0 @@
1
- /**
2
- * dream-scheduler.js — wave-6b: idle-timer dream trigger + per-VP orchestration.
3
- *
4
- * Responsibilities:
5
- * 1. Idle timer: fires after 30 min of no user messages if there are
6
- * un-ingested (new since last dream) messages in the session.
7
- * 2. Per-VP dream orchestration: runs dreamShard() per VP, serial within
8
- * each VP, max 2 VPs concurrently.
9
- * 3. Integrates the 334f recompression hook post-dream.
10
- * 4. Exposes `triggerDreamNow()` for manual "Run dream now" from the UI.
11
- *
12
- * Usage (from session.js / web-bridge.js):
13
- * const scheduler = createDreamScheduler({ session });
14
- * scheduler.noteUserMessage(); // reset idle timer on each user msg
15
- * scheduler.triggerDreamNow(); // manual trigger from WS event
16
- * scheduler.shutdown(); // cleanup on session close
17
- *
18
- * References:
19
- * - 334g dream-shard.js: dreamShard(), scanShards(), runCompactJob()
20
- * - 334f recompression.js: checkRecompression()
21
- * - PM spec: "30min 无消息 AND 有未 ingest message → 触发"
22
- */
23
-
24
- import { dreamShard } from './dream-shard.js';
25
- import { checkRecompression } from './recompression.js';
26
- import { runUserDreamJob } from './user-memory-store.js';
27
-
28
- /** Default idle timeout before dream triggers (ms). */
29
- export const DREAM_IDLE_MS = 30 * 60 * 1000; // 30 min
30
-
31
- /** Max concurrent VP dream runs. */
32
- const MAX_CONCURRENT_DREAMS = 2;
33
-
34
- /**
35
- * Create a dream scheduler instance.
36
- *
37
- * @param {{
38
- * memoryShardStore: object | null,
39
- * userMemoryStore: object | null,
40
- * conversationStore: object | null,
41
- * adapter: object | null,
42
- * config: object,
43
- * group?: import('../groups/group-store.js').GroupHandle | null,
44
- * memoryDir?: string | null,
45
- * idleMs?: number,
46
- * onDreamStart?: (vpId: string) => void,
47
- * onDreamEnd?: (vpId: string, result: object) => void,
48
- * onError?: (vpId: string, err: Error) => void,
49
- * }} opts
50
- * @returns {DreamScheduler}
51
- */
52
- export function createDreamScheduler(opts = {}) {
53
- const {
54
- memoryShardStore,
55
- userMemoryStore,
56
- conversationStore,
57
- adapter,
58
- config,
59
- group = null,
60
- memoryDir = null,
61
- idleMs = DREAM_IDLE_MS,
62
- onDreamStart,
63
- onDreamEnd,
64
- onError,
65
- } = opts;
66
-
67
- let idleTimer = null;
68
- let messagesSinceLastDream = 0;
69
- let dreamRunning = false;
70
- let lastDreamAt = 0;
71
- let destroyed = false;
72
-
73
- // ── Idle timer management ────────────────────────────────
74
-
75
- function resetIdleTimer() {
76
- if (destroyed) return;
77
- if (idleTimer) {
78
- clearTimeout(idleTimer);
79
- idleTimer = null;
80
- }
81
- if (messagesSinceLastDream > 0) {
82
- idleTimer = setTimeout(() => {
83
- idleTimer = null;
84
- if (messagesSinceLastDream > 0 && !dreamRunning && !destroyed) {
85
- runDream('idle').catch(() => {});
86
- }
87
- }, idleMs);
88
- if (idleTimer && typeof idleTimer.unref === 'function') {
89
- idleTimer.unref();
90
- }
91
- }
92
- }
93
-
94
- /**
95
- * Call on every user message to reset the idle timer and increment
96
- * the un-ingested message counter.
97
- */
98
- function noteUserMessage() {
99
- if (destroyed) return;
100
- messagesSinceLastDream++;
101
- resetIdleTimer();
102
- }
103
-
104
- // ── Dream execution ──────────────────────────────────────
105
-
106
- /**
107
- * Run a dream cycle. Currently single-VP (Unify default VP), but
108
- * structured for future multi-VP with maxConcurrent=2.
109
- *
110
- * @param {'idle'|'manual'} trigger
111
- * @returns {Promise<object>} dream result
112
- */
113
- async function runDream(trigger = 'manual') {
114
- if (dreamRunning) {
115
- return { skipped: true, reason: 'already_running' };
116
- }
117
- if (!memoryShardStore) {
118
- return { skipped: true, reason: 'no_shard_store' };
119
- }
120
- if (!adapter) {
121
- return { skipped: true, reason: 'no_adapter' };
122
- }
123
-
124
- dreamRunning = true;
125
- const vpId = 'default'; // single-VP Unify mode
126
-
127
- try {
128
- onDreamStart?.(vpId);
129
-
130
- // Shard-based compact/merge/prune
131
- const result = await dreamShard({
132
- shardStore: memoryShardStore,
133
- adapter,
134
- config: { model: config?.primaryModel || config?.model || 'default' },
135
- onPhase: (phase, data) => {
136
- // Could forward to UI in future
137
- },
138
- });
139
-
140
- // Post-dream: run recompression hook (334f)
141
- try {
142
- const recompResult = checkRecompression(memoryShardStore);
143
- result.recompression = recompResult;
144
- } catch {
145
- // Non-fatal
146
- }
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
-
161
- // Phase 8 PR-F dream-v2 diff-gated scope-summary refresh has been
162
- // removed (DESIGN-v2 §13: replaced by 12h / manual trigger model).
163
- // The new v2 dream pipeline lives in dream-v2/runner.js, wired via
164
- // dream-v2/session-wiring.js when config.memoryV2 is on. This
165
- // legacy scheduler only runs for memoryV2=false and is itself
166
- // slated for deletion alongside the rest of the R6 stack.
167
-
168
- messagesSinceLastDream = 0;
169
- lastDreamAt = Date.now();
170
- onDreamEnd?.(vpId, { ...result, trigger });
171
-
172
- return result;
173
- } catch (err) {
174
- onError?.(vpId, err);
175
- return { error: err.message, trigger };
176
- } finally {
177
- dreamRunning = false;
178
- }
179
- }
180
-
181
- /**
182
- * Manual trigger from UI ("Run dream now" button).
183
- * @returns {Promise<object>}
184
- */
185
- function triggerDreamNow() {
186
- return runDream('manual');
187
- }
188
-
189
- /**
190
- * Cleanup: clear timers, prevent further runs.
191
- */
192
- function shutdown() {
193
- destroyed = true;
194
- if (idleTimer) {
195
- clearTimeout(idleTimer);
196
- idleTimer = null;
197
- }
198
- }
199
-
200
- return {
201
- noteUserMessage,
202
- triggerDreamNow,
203
- shutdown,
204
- get isRunning() { return dreamRunning; },
205
- get messagesSinceLastDream() { return messagesSinceLastDream; },
206
- get lastDreamAt() { return lastDreamAt; },
207
- };
208
- }
209
-
210
- /**
211
- * @typedef {ReturnType<typeof createDreamScheduler>} DreamScheduler
212
- */