@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.
@@ -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
- */
@@ -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
- }