@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/package.json +1 -1
- package/unify/config.js +7 -6
- package/unify/engine.js +223 -2
- package/unify/groups/pre-flow.js +2 -3
- package/unify/memory/ams-registry.js +233 -0
- package/unify/memory/keywords.js +2 -3
- package/unify/session.js +48 -47
- package/unify/memory/dream-extract.js +0 -381
- package/unify/memory/dream-scheduler.js +0 -212
- package/unify/memory/recall.js +0 -247
- package/unify/memory/recompression.js +0 -122
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).
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
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 (
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
*/
|