@yeaft/webchat-agent 0.1.660 → 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.
- package/package.json +1 -1
- package/unify/config.js +7 -6
- package/unify/groups/pre-flow.js +2 -3
- package/unify/memory/keywords.js +2 -3
- package/unify/session.js +15 -43
- 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/package.json
CHANGED
package/unify/config.js
CHANGED
|
@@ -315,12 +315,13 @@ export function loadConfig(overrides = {}) {
|
|
|
315
315
|
// don't pollute the flat config namespace used by chat/crew code.
|
|
316
316
|
unify: normaliseUnifySection(jsonConfig.unify),
|
|
317
317
|
|
|
318
|
-
// DESIGN-v2 feature flag. When true the session
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
//
|
|
318
|
+
// DESIGN-v2 feature flag. When true the session opens the FTS5
|
|
319
|
+
// SegmentIndex (used by groups/pre-flow.js → memory/preflow.js
|
|
320
|
+
// for pre-turn recall) and wires the v2 dream pipeline
|
|
321
|
+
// (dream-v2/runner.js). When false both are skipped — no recall,
|
|
322
|
+
// no dream — turns still work but without memory injection. The
|
|
323
|
+
// legacy R6 recall + dream-scheduler paths have been deleted, so
|
|
324
|
+
// `false` is now a "memory off" kill switch rather than a fallback.
|
|
324
325
|
memoryV2: overrides.memoryV2 !== undefined ? !!overrides.memoryV2
|
|
325
326
|
: (jsonConfig.memoryV2 !== undefined ? !!jsonConfig.memoryV2 : true),
|
|
326
327
|
|
package/unify/groups/pre-flow.js
CHANGED
|
@@ -158,9 +158,8 @@ export function selectRespondingVps(input) {
|
|
|
158
158
|
/**
|
|
159
159
|
* Build the heading for a single scope's formatted memory block.
|
|
160
160
|
*
|
|
161
|
-
*
|
|
162
|
-
* prompt
|
|
163
|
-
* (here) or from per-scope file reads (recall-v2).
|
|
161
|
+
* Heading style is the original recall-v2 format, kept so the system
|
|
162
|
+
* prompt the LLM sees stays stable across the FTS migration.
|
|
164
163
|
*
|
|
165
164
|
* @param {string} scope
|
|
166
165
|
* @returns {string}
|
package/unify/memory/keywords.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* keywords.js — pure-rule keyword extraction shared by memory recall paths.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Pure CPU, no LLM, <1ms.
|
|
4
|
+
* Pure CPU, no LLM, <1ms. Used by `groups/pre-flow.js` to derive FTS
|
|
5
|
+
* query terms from the user message before hitting `memory/preflow.js`.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
/** Common stop words filtered out before frequency counting. */
|
package/unify/session.js
CHANGED
|
@@ -38,9 +38,7 @@ import { Engine } from './engine.js';
|
|
|
38
38
|
// as a follow-up.
|
|
39
39
|
import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
|
|
40
40
|
import { seedDefaultVps } from './vp/seed-defaults.js';
|
|
41
|
-
import { createDreamScheduler } from './memory/dream-scheduler.js';
|
|
42
41
|
import { createV2DreamScheduler } from './dream-v2/session-wiring.js';
|
|
43
|
-
import { getUserMemoryStore } from './memory/user-memory-store.js';
|
|
44
42
|
import { openSegmentIndex } from './memory/index-db.js';
|
|
45
43
|
import { syncAll as syncSegmentIndex } from './memory/segment-sync.js';
|
|
46
44
|
import { migrateR6toV2 } from './memory/migrate-r6-to-v2.js';
|
|
@@ -323,47 +321,21 @@ export async function loadSession(options = {}) {
|
|
|
323
321
|
yeaftDir,
|
|
324
322
|
});
|
|
325
323
|
|
|
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
|
-
}
|
|
324
|
+
// ─── 9a. Create dream scheduler (DESIGN-v2) ────────────
|
|
325
|
+
// The legacy R6 dream-scheduler was retired alongside recall-r6;
|
|
326
|
+
// dream-v2 is the only active path. The `memoryV2: false` opt-out
|
|
327
|
+
// no longer leaves a usable system, so we always wire v2 here.
|
|
328
|
+
// partialSession lets the v2 scheduler dereference adapter/config/
|
|
329
|
+
// engine/trace lazily — safe because callers attach more fields
|
|
330
|
+
// after this line.
|
|
331
|
+
const partialSession = {
|
|
332
|
+
yeaftDir,
|
|
333
|
+
adapter,
|
|
334
|
+
config,
|
|
335
|
+
engine,
|
|
336
|
+
trace,
|
|
337
|
+
};
|
|
338
|
+
const dreamScheduler = createV2DreamScheduler(partialSession);
|
|
367
339
|
|
|
368
340
|
// H2.f.5: thread engine registry, input queue, and dispatcher retired.
|
|
369
341
|
// The session exposes a single `engine`; web-bridge calls engine.query()
|
|
@@ -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
|
-
*/
|
package/unify/memory/recall.js
DELETED
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* recall.js — 3-step memory recall with fingerprint cache
|
|
3
|
-
*
|
|
4
|
-
* Recall flow (per design doc):
|
|
5
|
-
* Step 1: Keyword extraction (pure rules, <1ms)
|
|
6
|
-
* Step 2: Scope + Tags filter (read scopes.md, <5ms) → top 15 candidates
|
|
7
|
-
* Step 3: LLM select (side-query via adapter.call) → ≤7 most relevant
|
|
8
|
-
*
|
|
9
|
-
* Fingerprint cache:
|
|
10
|
-
* fingerprint = hash(scope, top 5 keywords, task_id)
|
|
11
|
-
* Same fingerprint → skip recall, reuse last result
|
|
12
|
-
*
|
|
13
|
-
* Reference: yeaft-unify-core-systems.md §3.2, yeaft-unify-design.md §5.1
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { createHash } from 'crypto';
|
|
17
|
-
import { pickEffort } from '../effort.js';
|
|
18
|
-
|
|
19
|
-
// ─── Constants ──────────────────────────────────────────────────
|
|
20
|
-
|
|
21
|
-
/** Max entries returned by recall. */
|
|
22
|
-
const MAX_RECALL_RESULTS = 7;
|
|
23
|
-
|
|
24
|
-
/** Max candidates passed to LLM select (Step 2 → Step 3). */
|
|
25
|
-
const MAX_CANDIDATES = 15;
|
|
26
|
-
|
|
27
|
-
// ─── Step 1: Keyword Extraction (pure rules, <1ms) ──────────────
|
|
28
|
-
|
|
29
|
-
/** Common stop words to filter out. */
|
|
30
|
-
const STOP_WORDS = new Set([
|
|
31
|
-
'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been', 'being',
|
|
32
|
-
'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could',
|
|
33
|
-
'should', 'may', 'might', 'can', 'shall', 'to', 'of', 'in', 'for',
|
|
34
|
-
'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during',
|
|
35
|
-
'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over',
|
|
36
|
-
'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when',
|
|
37
|
-
'where', 'why', 'how', 'all', 'both', 'each', 'few', 'more', 'most',
|
|
38
|
-
'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same',
|
|
39
|
-
'so', 'than', 'too', 'very', 'just', 'because', 'but', 'and', 'or',
|
|
40
|
-
'if', 'while', 'about', 'up', 'it', 'its', 'my', 'me', 'i', 'you',
|
|
41
|
-
'your', 'we', 'our', 'they', 'them', 'their', 'this', 'that', 'what',
|
|
42
|
-
'which', 'who', 'whom', 'these', 'those',
|
|
43
|
-
// Chinese stop words
|
|
44
|
-
'的', '了', '在', '是', '我', '有', '和', '就', '不', '人', '都',
|
|
45
|
-
'一', '一个', '上', '也', '很', '到', '说', '要', '去', '你', '会',
|
|
46
|
-
'着', '没有', '看', '好', '自己', '这', '他', '她', '吗', '呢', '吧',
|
|
47
|
-
'把', '被', '那', '它', '让', '给', '可以', '什么', '怎么', '帮',
|
|
48
|
-
'帮我', '请', '能', '想',
|
|
49
|
-
]);
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Extract keywords from a prompt (pure rules, no LLM).
|
|
53
|
-
*
|
|
54
|
-
* @param {string} prompt
|
|
55
|
-
* @returns {string[]} — keywords sorted by relevance (simple freq)
|
|
56
|
-
*/
|
|
57
|
-
export function extractKeywords(prompt) {
|
|
58
|
-
if (!prompt || !prompt.trim()) return [];
|
|
59
|
-
|
|
60
|
-
// Tokenize: split on whitespace and punctuation (keep CJK chars)
|
|
61
|
-
const tokens = prompt
|
|
62
|
-
.toLowerCase()
|
|
63
|
-
.replace(/[^\w\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff]+/g, ' ')
|
|
64
|
-
.split(/\s+/)
|
|
65
|
-
.filter(t => t.length > 1 && !STOP_WORDS.has(t));
|
|
66
|
-
|
|
67
|
-
// Count frequencies
|
|
68
|
-
const freq = new Map();
|
|
69
|
-
for (const t of tokens) {
|
|
70
|
-
freq.set(t, (freq.get(t) || 0) + 1);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Sort by frequency descending, then alphabetically
|
|
74
|
-
return [...freq.entries()]
|
|
75
|
-
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
76
|
-
.map(([word]) => word);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
// ─── Fingerprint Cache ──────────────────────────────────────────
|
|
80
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Compute a recall fingerprint for cache checking.
|
|
83
|
-
*
|
|
84
|
-
* @param {{ scope?: string, keywords: string[], taskId?: string }} params
|
|
85
|
-
* @returns {string} — hex hash
|
|
86
|
-
*/
|
|
87
|
-
export function computeFingerprint({ scope = '', keywords, taskId = '' }) {
|
|
88
|
-
const top5 = keywords.slice(0, 5).join(',');
|
|
89
|
-
const input = `${scope}|${top5}|${taskId}`;
|
|
90
|
-
return createHash('sha256').update(input).digest('hex').slice(0, 16);
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ─── Step 2: Scope + Tags Filter ────────────────────────────────
|
|
94
|
-
|
|
95
|
-
/**
|
|
96
|
-
* Filter entries by scope and tags (in-memory, no LLM).
|
|
97
|
-
* Uses MemoryStore.findByFilter internally.
|
|
98
|
-
*
|
|
99
|
-
* @param {import('./store.js').MemoryStore} memoryStore
|
|
100
|
-
* @param {{ scope?: string, keywords: string[] }} params
|
|
101
|
-
* @returns {object[]} — top MAX_CANDIDATES entries
|
|
102
|
-
*/
|
|
103
|
-
function filterCandidates(memoryStore, { scope, keywords }) {
|
|
104
|
-
return memoryStore.findByFilter({
|
|
105
|
-
scope,
|
|
106
|
-
tags: keywords,
|
|
107
|
-
limit: MAX_CANDIDATES,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// ─── Step 3: LLM Select ────────────────────────────────────────
|
|
112
|
-
|
|
113
|
-
/**
|
|
114
|
-
* Use LLM side-query to select the most relevant entries.
|
|
115
|
-
*
|
|
116
|
-
* @param {object} adapter — LLM adapter with .call() method
|
|
117
|
-
* @param {object} config — { model }
|
|
118
|
-
* @param {string} prompt — user's prompt
|
|
119
|
-
* @param {object[]} candidates — entries with frontmatter
|
|
120
|
-
* @returns {Promise<string[]>} — selected entry names
|
|
121
|
-
*/
|
|
122
|
-
async function llmSelect(adapter, config, prompt, candidates) {
|
|
123
|
-
if (candidates.length <= MAX_RECALL_RESULTS) {
|
|
124
|
-
// No need to filter if already under limit
|
|
125
|
-
return candidates.map(c => c.name);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const candidateList = candidates.map((c, i) =>
|
|
129
|
-
`${i + 1}. [${c.name}] kind=${c.kind}, scope=${c.scope}, tags=[${(c.tags || []).join(', ')}]`
|
|
130
|
-
).join('\n');
|
|
131
|
-
|
|
132
|
-
const system = `You are a memory retrieval assistant. Given a user's prompt and a list of memory entries, select the most relevant ones (up to ${MAX_RECALL_RESULTS}).
|
|
133
|
-
Return ONLY a JSON array of entry names, like: ["entry-name-1", "entry-name-2"]
|
|
134
|
-
No explanation, just the JSON array.`;
|
|
135
|
-
|
|
136
|
-
const messages = [{
|
|
137
|
-
role: 'user',
|
|
138
|
-
content: `User prompt: "${prompt}"
|
|
139
|
-
|
|
140
|
-
Memory entries:
|
|
141
|
-
${candidateList}
|
|
142
|
-
|
|
143
|
-
Select the ${MAX_RECALL_RESULTS} most relevant entries. Return a JSON array of entry names.`,
|
|
144
|
-
}];
|
|
145
|
-
|
|
146
|
-
try {
|
|
147
|
-
const result = await adapter.call({
|
|
148
|
-
model: config.model,
|
|
149
|
-
system,
|
|
150
|
-
messages,
|
|
151
|
-
maxTokens: 512,
|
|
152
|
-
// task-327c: recall step-3 is a cheap classifier pass (pick N out of
|
|
153
|
-
// 15 candidates). Flag 'low' so supported models skip deep reasoning.
|
|
154
|
-
effort: pickEffort({ scenario: 'recall' }),
|
|
155
|
-
});
|
|
156
|
-
|
|
157
|
-
// Parse the JSON array from the response
|
|
158
|
-
const text = result.text.trim();
|
|
159
|
-
const jsonMatch = text.match(/\[[\s\S]*\]/);
|
|
160
|
-
if (jsonMatch) {
|
|
161
|
-
const names = JSON.parse(jsonMatch[0]);
|
|
162
|
-
return names.filter(n => typeof n === 'string');
|
|
163
|
-
}
|
|
164
|
-
} catch {
|
|
165
|
-
// Fallback: return all candidates if LLM fails
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
return candidates.slice(0, MAX_RECALL_RESULTS).map(c => c.name);
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
// ─── Main Recall Function ───────────────────────────────────────
|
|
172
|
-
|
|
173
|
-
/** @type {Map<string, { entries: object[], timestamp: number }>} */
|
|
174
|
-
const _cache = new Map();
|
|
175
|
-
|
|
176
|
-
/** Cache TTL — 5 minutes. */
|
|
177
|
-
const CACHE_TTL = 5 * 60 * 1000;
|
|
178
|
-
|
|
179
|
-
/**
|
|
180
|
-
* Recall relevant memory entries for a given prompt.
|
|
181
|
-
*
|
|
182
|
-
* 3-step process:
|
|
183
|
-
* 1. Extract keywords (rules, <1ms)
|
|
184
|
-
* 2. Scope + Tags filter → top 15 candidates
|
|
185
|
-
* 3. LLM select → ≤7 entries (skipped if ≤7 candidates)
|
|
186
|
-
*
|
|
187
|
-
* Uses fingerprint cache to skip repeat recalls.
|
|
188
|
-
*
|
|
189
|
-
* @param {{ prompt: string, adapter: object, config: object, memoryStore: import('./store.js').MemoryStore, scope?: string, taskId?: string }} params
|
|
190
|
-
* @returns {Promise<{ entries: object[], keywords: string[], fingerprint: string, cached: boolean }>}
|
|
191
|
-
*/
|
|
192
|
-
export async function recall({ prompt, adapter, config, memoryStore, scope, taskId }) {
|
|
193
|
-
// Step 1: Extract keywords
|
|
194
|
-
const keywords = extractKeywords(prompt);
|
|
195
|
-
|
|
196
|
-
if (keywords.length === 0) {
|
|
197
|
-
return { entries: [], keywords: [], fingerprint: '', cached: false };
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// Check fingerprint cache
|
|
201
|
-
const fingerprint = computeFingerprint({ scope, keywords, taskId });
|
|
202
|
-
|
|
203
|
-
const cached = _cache.get(fingerprint);
|
|
204
|
-
if (cached && Date.now() - cached.timestamp < CACHE_TTL) {
|
|
205
|
-
return { entries: cached.entries, keywords, fingerprint, cached: true };
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
// Step 2: Scope + Tags filter
|
|
209
|
-
const candidates = filterCandidates(memoryStore, { scope, keywords });
|
|
210
|
-
|
|
211
|
-
if (candidates.length === 0) {
|
|
212
|
-
_cache.set(fingerprint, { entries: [], timestamp: Date.now() });
|
|
213
|
-
return { entries: [], keywords, fingerprint, cached: false };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
// Step 3: LLM select (only if > MAX_RECALL_RESULTS candidates)
|
|
217
|
-
let selectedNames;
|
|
218
|
-
if (candidates.length <= MAX_RECALL_RESULTS) {
|
|
219
|
-
selectedNames = candidates.map(c => c.name);
|
|
220
|
-
} else {
|
|
221
|
-
selectedNames = await llmSelect(adapter, config, prompt, candidates);
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
// Load full entries for selected names
|
|
225
|
-
const entries = [];
|
|
226
|
-
for (const name of selectedNames) {
|
|
227
|
-
const slug = name.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fff-]+/g, '-').replace(/^-+|-+$/g, '');
|
|
228
|
-
const entry = memoryStore.readEntry(slug) || memoryStore.readEntry(name);
|
|
229
|
-
if (entry) {
|
|
230
|
-
entries.push(entry);
|
|
231
|
-
// Bump frequency
|
|
232
|
-
memoryStore.bumpFrequency(slug || name);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// Update cache
|
|
237
|
-
_cache.set(fingerprint, { entries, timestamp: Date.now() });
|
|
238
|
-
|
|
239
|
-
return { entries, keywords, fingerprint, cached: false };
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
/**
|
|
243
|
-
* Clear the recall cache. Useful for testing.
|
|
244
|
-
*/
|
|
245
|
-
export function clearRecallCache() {
|
|
246
|
-
_cache.clear();
|
|
247
|
-
}
|
|
@@ -1,122 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* recompression.js — task-334f Re-compression hook.
|
|
3
|
-
*
|
|
4
|
-
* Provides `checkRecompression(memoryShardStore)` which inspects each shard's
|
|
5
|
-
* utilization (live entry bytes vs total shard file bytes). When utilization
|
|
6
|
-
* drops below 50% (configurable), the shard is compacted in-place.
|
|
7
|
-
*
|
|
8
|
-
* This is designed to be called:
|
|
9
|
-
* - After `remove()` calls (which leave tombstone gaps)
|
|
10
|
-
* - After `supersede()` chains (old entries inflate shard size)
|
|
11
|
-
* - By dream (334g) on its periodic sweep
|
|
12
|
-
*
|
|
13
|
-
* The hook does NOT make deletion decisions — it only reclaims dead space.
|
|
14
|
-
* Dream owns the "what to delete" logic; this module owns "when to defrag".
|
|
15
|
-
*
|
|
16
|
-
* Reference: §Δ17.5 Compact job / §Δ26.3 soft-cap semantics.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
/** Default utilization threshold below which a shard gets compacted. */
|
|
20
|
-
export const DEFAULT_UTILIZATION_THRESHOLD = 0.5;
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* Inspect all shards in a memory shard store and compact any whose
|
|
24
|
-
* utilization ratio (live entry bytes / total shard bytes) is below
|
|
25
|
-
* the threshold.
|
|
26
|
-
*
|
|
27
|
-
* @param {object} store — opened via `openMemoryShardStore()`
|
|
28
|
-
* @param {{ threshold?: number }} [opts]
|
|
29
|
-
* @returns {{ compacted: string[], skipped: string[], stats: Record<string, { entries: number, bytes: number, liveBytes: number, utilization: number }> }}
|
|
30
|
-
*/
|
|
31
|
-
export function checkRecompression(store, opts = {}) {
|
|
32
|
-
if (!store || typeof store.stats !== 'function') {
|
|
33
|
-
return { compacted: [], skipped: [], stats: {} };
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
const threshold = opts.threshold ?? DEFAULT_UTILIZATION_THRESHOLD;
|
|
37
|
-
const { shards, count } = store.stats();
|
|
38
|
-
const compacted = [];
|
|
39
|
-
const skipped = [];
|
|
40
|
-
const shardStats = {};
|
|
41
|
-
|
|
42
|
-
for (const [name, bucket] of Object.entries(shards)) {
|
|
43
|
-
const totalBytes = bucket.bytes || 0;
|
|
44
|
-
const entryCount = bucket.entries || 0;
|
|
45
|
-
|
|
46
|
-
// Estimate live bytes from the index: sum of all entry byteLen for this shard.
|
|
47
|
-
// The inner store's query returns records with meta but not byteLen directly.
|
|
48
|
-
// Use the stats bucket which tracks entry count and total file bytes.
|
|
49
|
-
// A shard with 0 entries but >0 bytes is 0% utilization → compact.
|
|
50
|
-
// A shard with entries but totalBytes=0 is fine (no file yet).
|
|
51
|
-
if (totalBytes === 0) {
|
|
52
|
-
shardStats[name] = { entries: entryCount, bytes: 0, liveBytes: 0, utilization: 1.0 };
|
|
53
|
-
skipped.push(name);
|
|
54
|
-
continue;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// For utilization, we use inner store's index to sum live entry byte lengths.
|
|
58
|
-
const inner = store._innerForTest;
|
|
59
|
-
let liveBytes = 0;
|
|
60
|
-
if (inner && typeof inner.getIndex === 'function') {
|
|
61
|
-
const index = inner.getIndex();
|
|
62
|
-
for (const rec of index.entries) {
|
|
63
|
-
if (rec.shard === name) liveBytes += (rec.byteLen || 0);
|
|
64
|
-
}
|
|
65
|
-
} else {
|
|
66
|
-
// Fallback: assume fully utilized if we can't inspect
|
|
67
|
-
liveBytes = totalBytes;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const utilization = liveBytes / totalBytes;
|
|
71
|
-
shardStats[name] = { entries: entryCount, bytes: totalBytes, liveBytes, utilization };
|
|
72
|
-
|
|
73
|
-
if (utilization < threshold && entryCount > 0) {
|
|
74
|
-
// Compact via the underlying shard store
|
|
75
|
-
if (inner && typeof inner.compact === 'function') {
|
|
76
|
-
inner.compact(name);
|
|
77
|
-
compacted.push(name);
|
|
78
|
-
}
|
|
79
|
-
} else {
|
|
80
|
-
skipped.push(name);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
return { compacted, skipped, stats: shardStats };
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
/**
|
|
88
|
-
* Check if any shard needs recompression without actually doing it.
|
|
89
|
-
* Returns the list of shard names that would be compacted.
|
|
90
|
-
*
|
|
91
|
-
* @param {object} store
|
|
92
|
-
* @param {{ threshold?: number }} [opts]
|
|
93
|
-
* @returns {string[]} — shard names below utilization threshold
|
|
94
|
-
*/
|
|
95
|
-
export function needsRecompression(store, opts = {}) {
|
|
96
|
-
if (!store || typeof store.stats !== 'function') return [];
|
|
97
|
-
|
|
98
|
-
const threshold = opts.threshold ?? DEFAULT_UTILIZATION_THRESHOLD;
|
|
99
|
-
const { shards } = store.stats();
|
|
100
|
-
const result = [];
|
|
101
|
-
|
|
102
|
-
const inner = store._innerForTest;
|
|
103
|
-
if (!inner || typeof inner.getIndex !== 'function') return [];
|
|
104
|
-
|
|
105
|
-
const index = inner.getIndex();
|
|
106
|
-
|
|
107
|
-
for (const [name, bucket] of Object.entries(shards)) {
|
|
108
|
-
const totalBytes = bucket.bytes || 0;
|
|
109
|
-
if (totalBytes === 0 || (bucket.entries || 0) === 0) continue;
|
|
110
|
-
|
|
111
|
-
let liveBytes = 0;
|
|
112
|
-
for (const rec of index.entries) {
|
|
113
|
-
if (rec.shard === name) liveBytes += (rec.byteLen || 0);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (liveBytes / totalBytes < threshold) {
|
|
117
|
-
result.push(name);
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
return result;
|
|
122
|
-
}
|