@yeaft/webchat-agent 0.1.660 → 0.1.662

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/unify/session.js CHANGED
@@ -32,17 +32,21 @@ import { Engine } from './engine.js';
32
32
  // SegmentIndex (SQLite FTS5 over memory.md) and passes it to the
33
33
  // Engine. Engine.#recallMemory routes pre-turn recall through
34
34
  // groups/pre-flow.js → memory/preflow.js (the previous per-scope
35
- // file reader recall-v2.js has been deleted). Post-turn AMS
36
- // correction (memory/adjust.js) is implemented but not yet wired —
37
- // requires session-level AMS instance + scope resolution. Tracked
38
- // as a follow-up.
35
+ // file reader recall-v2.js has been deleted).
36
+ //
37
+ // GC.1 follow-up: when memoryIndex is wired we also open an
38
+ // AmsRegistry. The registry caches per-group ActiveMemorySet
39
+ // instances and persists their identity-only state under
40
+ // `~/.yeaft/memory/groups/<gid>/ams.json` so a deactivated group
41
+ // resumes with the same onDemand/recent membership it had on
42
+ // disconnect. Engine.#runQuery uses the registry to populate the
43
+ // AMS each turn and to run `memory/adjust.js` post-turn.
39
44
  import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
40
45
  import { seedDefaultVps } from './vp/seed-defaults.js';
41
- import { createDreamScheduler } from './memory/dream-scheduler.js';
42
46
  import { createV2DreamScheduler } from './dream-v2/session-wiring.js';
43
- import { getUserMemoryStore } from './memory/user-memory-store.js';
44
47
  import { openSegmentIndex } from './memory/index-db.js';
45
48
  import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
49
+ import { openAmsRegistry } from './memory/ams-registry.js';
46
50
  import { migrateR6toV2 } from './memory/migrate-r6-to-v2.js';
47
51
  import { join } from 'path';
48
52
  import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe, writeFileSync as writeFileSyncSafe } from 'fs';
@@ -252,6 +256,22 @@ export async function loadSession(options = {}) {
252
256
  }
253
257
  }
254
258
 
259
+ // ─── 5-ams. (GC.1 follow-up) Group-keyed AMS registry ────
260
+ // The registry caches one ActiveMemorySet per groupId and
261
+ // persists their state to disk so a deactivated group can be
262
+ // reactivated with the same onDemand/recent membership it had
263
+ // on disconnect. Without memoryIndex we have nothing to
264
+ // re-hydrate against, so the registry is left null in that case.
265
+ let amsRegistry = null;
266
+ if (memoryIndex && !config._readOnly) {
267
+ try {
268
+ amsRegistry = openAmsRegistry({ yeaftDir, memoryIndex, config });
269
+ } catch (err) {
270
+ console.warn(`[Yeaft] Failed to open AMS registry (adjust disabled): ${err?.message || err}`);
271
+ amsRegistry = null;
272
+ }
273
+ }
274
+
255
275
  // ─── 5a. Initialize feature store ──────────────────────
256
276
  initFeatureStore(yeaftDir, { readOnly: config._readOnly || false });
257
277
 
@@ -317,53 +337,28 @@ export async function loadSession(options = {}) {
317
337
  memoryStore,
318
338
  memoryShardStore,
319
339
  memoryIndex,
340
+ amsRegistry,
320
341
  toolRegistry,
321
342
  skillManager,
322
343
  mcpManager,
323
344
  yeaftDir,
324
345
  });
325
346
 
326
- // ─── 9a. Create dream scheduler (wave-6b / DESIGN-v2) ──
327
- // When config.memoryV2 is on, route through the v2 pipeline (per-scope
328
- // memory.md + summary.md). Otherwise keep the legacy R6 dream-scheduler.
329
- let dreamScheduler;
330
- if (config.memoryV2) {
331
- // Build a partial session reference so the v2 wiring can see adapter,
332
- // config, yeaftDir, engine, and trace. The scheduler's `run` closure
333
- // dereferences these lazily, so mutating the object after this line
334
- // (e.g. attaching engine) is safe.
335
- const partialSession = {
336
- yeaftDir,
337
- adapter,
338
- config,
339
- engine,
340
- trace,
341
- };
342
- dreamScheduler = createV2DreamScheduler(partialSession);
343
- } else {
344
- dreamScheduler = createDreamScheduler({
345
- memoryShardStore,
346
- userMemoryStore: getUserMemoryStore(),
347
- conversationStore,
348
- adapter,
349
- config,
350
- onDreamStart: (vpId) => {
351
- if (config.debug) console.log(`[Yeaft] Dream started for VP ${vpId}`);
352
- },
353
- onDreamEnd: (vpId, result) => {
354
- if (config.debug) console.log(`[Yeaft] Dream ended for VP ${vpId}:`, JSON.stringify({
355
- trigger: result.trigger,
356
- entriesMerged: result.entriesMerged,
357
- entriesPruned: result.entriesPruned,
358
- bytesReclaimed: result.bytesReclaimed,
359
- errors: result.errors?.length || 0,
360
- }));
361
- },
362
- onError: (vpId, err) => {
363
- console.warn(`[Yeaft] Dream error for VP ${vpId}:`, err?.message || err);
364
- },
365
- });
366
- }
347
+ // ─── 9a. Create dream scheduler (DESIGN-v2) ────────────
348
+ // The legacy R6 dream-scheduler was retired alongside recall-r6;
349
+ // dream-v2 is the only active path. The `memoryV2: false` opt-out
350
+ // no longer leaves a usable system, so we always wire v2 here.
351
+ // partialSession lets the v2 scheduler dereference adapter/config/
352
+ // engine/trace lazily safe because callers attach more fields
353
+ // after this line.
354
+ const partialSession = {
355
+ yeaftDir,
356
+ adapter,
357
+ config,
358
+ engine,
359
+ trace,
360
+ };
361
+ const dreamScheduler = createV2DreamScheduler(partialSession);
367
362
 
368
363
  // H2.f.5: thread engine registry, input queue, and dispatcher retired.
369
364
  // The session exposes a single `engine`; web-bridge calls engine.query()
@@ -400,6 +395,11 @@ export async function loadSession(options = {}) {
400
395
  } catch {
401
396
  // Best-effort cleanup
402
397
  }
398
+ try {
399
+ if (amsRegistry) amsRegistry.persistAll();
400
+ } catch {
401
+ // Best-effort cleanup
402
+ }
403
403
  }
404
404
 
405
405
  return {
@@ -416,6 +416,7 @@ export async function loadSession(options = {}) {
416
416
  trace,
417
417
  yeaftDir,
418
418
  status,
419
+ amsRegistry,
419
420
  shutdown,
420
421
  // task-325c: user-initiated abort API. Delegates to web-bridge which
421
422
  // owns the single AbortController. Lazy-imported to avoid a hard cycle
@@ -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
- */