@yeaft/webchat-agent 0.1.630 → 0.1.632
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 +10 -0
- package/unify/dream-v2/session-wiring.js +171 -0
- package/unify/engine.js +38 -4
- package/unify/memory/recall-v2.js +258 -0
- package/unify/session.js +42 -23
- package/unify/web-bridge.js +11 -0
package/package.json
CHANGED
package/unify/config.js
CHANGED
|
@@ -217,6 +217,8 @@ function loadLegacyConfig(dir, overrides) {
|
|
|
217
217
|
maxContinueTurns: overrides.maxContinueTurns ?? fileConfig.maxContinueTurns ?? DEFAULTS.maxContinueTurns,
|
|
218
218
|
// task-318: legacy path never had the `unify` section — defaults.
|
|
219
219
|
unify: normaliseUnifySection(null),
|
|
220
|
+
// DESIGN-v2 feature flag. Default true (PR-E flipped). Override wins.
|
|
221
|
+
memoryV2: overrides.memoryV2 !== undefined ? !!overrides.memoryV2 : true,
|
|
220
222
|
providers: null,
|
|
221
223
|
primaryModel: null,
|
|
222
224
|
fastModel: null,
|
|
@@ -313,6 +315,14 @@ export function loadConfig(overrides = {}) {
|
|
|
313
315
|
// don't pollute the flat config namespace used by chat/crew code.
|
|
314
316
|
unify: normaliseUnifySection(jsonConfig.unify),
|
|
315
317
|
|
|
318
|
+
// DESIGN-v2 feature flag. When true the engine routes recall through
|
|
319
|
+
// memory/recall-v2.js (per-scope memory.md + summary.md) and the
|
|
320
|
+
// session wires the v2 dream pipeline (dream-v2/runner.js). PR-E
|
|
321
|
+
// flipped the default to true; users who need the legacy R6 paths
|
|
322
|
+
// can opt out via `"memoryV2": false` in ~/.yeaft/config.json.
|
|
323
|
+
memoryV2: overrides.memoryV2 !== undefined ? !!overrides.memoryV2
|
|
324
|
+
: (jsonConfig.memoryV2 !== undefined ? !!jsonConfig.memoryV2 : true),
|
|
325
|
+
|
|
316
326
|
// Legacy fields (null when using config.json)
|
|
317
327
|
apiKey: overrides.apiKey || null,
|
|
318
328
|
openaiApiKey: null,
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/session-wiring.js — DESIGN-v2 §13 wire-up for session.js.
|
|
3
|
+
*
|
|
4
|
+
* Bridges the framework-agnostic `runDream` orchestrator (dream-v2/runner.js)
|
|
5
|
+
* to the live yeaft session: groups store, conversation log, LLM adapter,
|
|
6
|
+
* and the engine's progress event sink.
|
|
7
|
+
*
|
|
8
|
+
* The dream pipeline only activates when `config.memoryV2 === true`. When
|
|
9
|
+
* the flag is off, this module is a no-op — the legacy R6 dream-scheduler
|
|
10
|
+
* keeps running as before (session.js still wires it).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { join } from 'path';
|
|
14
|
+
import { runDream } from './runner.js';
|
|
15
|
+
import { createDreamScheduler } from './schedule.js';
|
|
16
|
+
import { listGroups, openGroup } from '../groups/group-store.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Build the per-call options for runDream. Pure: takes a session and returns
|
|
20
|
+
* the closures runDream needs (listGroups, countMessages, loadGroupDiff, etc.).
|
|
21
|
+
*
|
|
22
|
+
* @param {Object} session — the live Session object from loadSession()
|
|
23
|
+
* @param {(event: object) => void} [onProgress]
|
|
24
|
+
* @returns {Object}
|
|
25
|
+
*/
|
|
26
|
+
export function buildRunDreamOpts(session, onProgress) {
|
|
27
|
+
const yeaftDir = session.yeaftDir;
|
|
28
|
+
const memoryRoot = join(yeaftDir, 'memory');
|
|
29
|
+
const groupsRoot = join(yeaftDir, 'groups');
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
root: memoryRoot,
|
|
33
|
+
llm: makeLlm(session),
|
|
34
|
+
listGroups: async () => {
|
|
35
|
+
try { return listGroups(groupsRoot).map(g => g.id); }
|
|
36
|
+
catch { return []; }
|
|
37
|
+
},
|
|
38
|
+
countMessages: async (gid) => {
|
|
39
|
+
try {
|
|
40
|
+
const h = openGroup(groupsRoot, gid);
|
|
41
|
+
let n = 0;
|
|
42
|
+
for (const _m of h.streamMessages()) n += 1;
|
|
43
|
+
return n;
|
|
44
|
+
} catch { return 0; }
|
|
45
|
+
},
|
|
46
|
+
loadGroupDiff: async (gid, sinceId) => {
|
|
47
|
+
try {
|
|
48
|
+
const h = openGroup(groupsRoot, gid);
|
|
49
|
+
const out = [];
|
|
50
|
+
let started = !sinceId;
|
|
51
|
+
for (const m of h.streamMessages()) {
|
|
52
|
+
if (!started) {
|
|
53
|
+
if (m.id === sinceId) started = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
out.push(translateGroupMessage(m));
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
} catch { return []; }
|
|
60
|
+
},
|
|
61
|
+
loadOverlapPreamble: async (gid, beforeId, n) => {
|
|
62
|
+
try {
|
|
63
|
+
const h = openGroup(groupsRoot, gid);
|
|
64
|
+
const buf = [];
|
|
65
|
+
for (const m of h.streamMessages()) {
|
|
66
|
+
if (m.id === beforeId) break;
|
|
67
|
+
buf.push(m);
|
|
68
|
+
}
|
|
69
|
+
return buf.slice(-n).map(translateGroupMessage);
|
|
70
|
+
} catch { return []; }
|
|
71
|
+
},
|
|
72
|
+
onProgress,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Translate a group-store message record (id, from, role, text, ...) into
|
|
78
|
+
* the shape runDream expects (id, role, body, vpId, author, featureId).
|
|
79
|
+
*
|
|
80
|
+
* @param {Object} m
|
|
81
|
+
*/
|
|
82
|
+
function translateGroupMessage(m) {
|
|
83
|
+
const role = m.role || (m.from === 'user' ? 'user' : 'assistant');
|
|
84
|
+
const out = {
|
|
85
|
+
id: m.id,
|
|
86
|
+
role,
|
|
87
|
+
body: m.text || '',
|
|
88
|
+
};
|
|
89
|
+
if (role === 'assistant' && m.from && m.from !== 'user') {
|
|
90
|
+
out.vpId = m.from;
|
|
91
|
+
}
|
|
92
|
+
if (m.meta && typeof m.meta === 'object' && m.meta.featureId) {
|
|
93
|
+
out.featureId = m.meta.featureId;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Build the LLM callable that runDream's triage/apply prompts use.
|
|
100
|
+
*
|
|
101
|
+
* @param {Object} session
|
|
102
|
+
*/
|
|
103
|
+
function makeLlm(session) {
|
|
104
|
+
return async ({ pass, prompt, system }) => {
|
|
105
|
+
const adapter = session.adapter;
|
|
106
|
+
const model = session.config?.fastModelId || session.config?.model;
|
|
107
|
+
if (!adapter || typeof adapter.call !== 'function') {
|
|
108
|
+
throw new Error(`dream-v2: no adapter.call available (pass=${pass})`);
|
|
109
|
+
}
|
|
110
|
+
const r = await adapter.call({
|
|
111
|
+
model,
|
|
112
|
+
system: system || `You are the dream pipeline — pass: ${pass}.`,
|
|
113
|
+
messages: [{ role: 'user', content: prompt }],
|
|
114
|
+
maxTokens: 2048,
|
|
115
|
+
});
|
|
116
|
+
return (r && r.text) ? r.text : '';
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Create a v2 dream scheduler bound to a session and wire its progress
|
|
122
|
+
* events into the engine's sub-agent event sink (web-bridge translates
|
|
123
|
+
* `dream_progress` into `unify_output` for the debug panel).
|
|
124
|
+
*
|
|
125
|
+
* @param {Object} session
|
|
126
|
+
* @returns {Object} — scheduler (start/stop/triggerNow)
|
|
127
|
+
*/
|
|
128
|
+
export function createV2DreamScheduler(session) {
|
|
129
|
+
const onProgress = (evt) => {
|
|
130
|
+
try {
|
|
131
|
+
const sink = session.engine?.subAgentEventSink || null;
|
|
132
|
+
// We don't have a sub-agent id; emit on the engine's standard
|
|
133
|
+
// event channel instead. session.engine exposes setSubAgentEventSink
|
|
134
|
+
// for nested events; for top-level dream, we route through trace.
|
|
135
|
+
if (typeof session.trace?.event === 'function') {
|
|
136
|
+
session.trace.event('dream_progress', evt);
|
|
137
|
+
}
|
|
138
|
+
if (typeof session._dreamProgressSink === 'function') {
|
|
139
|
+
session._dreamProgressSink(evt);
|
|
140
|
+
}
|
|
141
|
+
// Best-effort console for debug builds.
|
|
142
|
+
if (session.config?.debug) {
|
|
143
|
+
// eslint-disable-next-line no-console
|
|
144
|
+
console.log('[dream-v2]', evt);
|
|
145
|
+
}
|
|
146
|
+
} catch { /* never let progress reporting kill the run */ }
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const run = (opts = {}) => runDream({
|
|
150
|
+
...buildRunDreamOpts(session, onProgress),
|
|
151
|
+
manual: !!opts.manual,
|
|
152
|
+
scopeFilter: Array.isArray(opts.scopeFilter) ? opts.scopeFilter : undefined,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const v2 = createDreamScheduler({
|
|
156
|
+
run,
|
|
157
|
+
logger: session.config?.debug ? console : undefined,
|
|
158
|
+
});
|
|
159
|
+
// Auto-start the timer.
|
|
160
|
+
v2.start();
|
|
161
|
+
// Adapter shim: legacy callers (web-bridge) call .noteUserMessage() and
|
|
162
|
+
// .triggerDreamNow() / .shutdown(). Map them onto the v2 API.
|
|
163
|
+
return {
|
|
164
|
+
noteUserMessage() { /* v2 doesn't gate on user-message count */ },
|
|
165
|
+
triggerDreamNow() { return v2.triggerNow(); },
|
|
166
|
+
shutdown() { v2.stop(); },
|
|
167
|
+
get isRunning() { return v2.isRunning(); },
|
|
168
|
+
// Preserve direct access for tests.
|
|
169
|
+
_v2: v2,
|
|
170
|
+
};
|
|
171
|
+
}
|
package/unify/engine.js
CHANGED
|
@@ -21,6 +21,7 @@ import { randomUUID } from 'crypto';
|
|
|
21
21
|
import { buildSystemPrompt, buildWorkerPrompt } from './prompts.js';
|
|
22
22
|
import { LLMContextError, LLMAbortError } from './llm/adapter.js';
|
|
23
23
|
import { recallR6, formatForInjection } from './memory/recall-r6.js';
|
|
24
|
+
import { recallV2 } from './memory/recall-v2.js';
|
|
24
25
|
import { shouldConsolidate, consolidate, partitionMessages } from './memory/consolidate.js';
|
|
25
26
|
import { extractMemories } from './memory/extract.js';
|
|
26
27
|
import { runCompact as runCompactOrchestrator } from './compact/orchestrator.js';
|
|
@@ -489,15 +490,40 @@ export class Engine {
|
|
|
489
490
|
|
|
490
491
|
/**
|
|
491
492
|
* Perform memory recall for a given prompt.
|
|
492
|
-
*
|
|
493
|
-
*
|
|
493
|
+
*
|
|
494
|
+
* Routes:
|
|
495
|
+
* - config.memoryV2 === true → recall-v2 (per-scope memory.md + summary.md)
|
|
496
|
+
* - else → R6 shard-based recall (legacy)
|
|
494
497
|
*
|
|
495
498
|
* @param {string} prompt
|
|
499
|
+
* @param {{ groupId?: string, vpId?: string, featureId?: string }} [ctx]
|
|
496
500
|
* @returns {Promise<{ profile: string, entries: object[], formatted: string }|null>}
|
|
497
501
|
*/
|
|
498
|
-
async #recallMemory(prompt) {
|
|
502
|
+
async #recallMemory(prompt, ctx = {}) {
|
|
499
503
|
const memory = { profile: '', entries: [], formatted: '' };
|
|
500
504
|
|
|
505
|
+
// ─── v2 path (DESIGN-v2) ───────────────────────────────────
|
|
506
|
+
if (this.#config && this.#config.memoryV2 && this.#yeaftDir) {
|
|
507
|
+
try {
|
|
508
|
+
const result = await recallV2({
|
|
509
|
+
prompt,
|
|
510
|
+
root: `${this.#yeaftDir}/memory`,
|
|
511
|
+
groupId: ctx.groupId,
|
|
512
|
+
vpId: ctx.vpId,
|
|
513
|
+
featureId: ctx.featureId,
|
|
514
|
+
});
|
|
515
|
+
memory.entries = result.sections || [];
|
|
516
|
+
memory.formatted = result.formatted || '';
|
|
517
|
+
// Profile concept: in v2 the user/memory.md IS the profile.
|
|
518
|
+
const userSec = (result.sections || []).find(s => s.kind === 'user');
|
|
519
|
+
memory.profile = userSec ? (userSec.summary || '') : '';
|
|
520
|
+
} catch {
|
|
521
|
+
// Fail soft — empty injection.
|
|
522
|
+
}
|
|
523
|
+
return memory;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// ─── R6 legacy path ────────────────────────────────────────
|
|
501
527
|
// Build user profile from user-memory shard store (R6 path),
|
|
502
528
|
// falling back to legacy readProfile if shard store unavailable.
|
|
503
529
|
try {
|
|
@@ -885,7 +911,15 @@ export class Engine {
|
|
|
885
911
|
}
|
|
886
912
|
|
|
887
913
|
// R6 recall: append shard-based recall results to memory injection
|
|
888
|
-
const recallResult = await this.#recallMemory(prompt
|
|
914
|
+
const recallResult = await this.#recallMemory(prompt, {
|
|
915
|
+
groupId,
|
|
916
|
+
vpId: vpPersona && typeof vpPersona === 'object' && typeof vpPersona.vpId === 'string'
|
|
917
|
+
? vpPersona.vpId
|
|
918
|
+
: (typeof senderVpId === 'string' ? senderVpId : undefined),
|
|
919
|
+
featureId: typeof inboundEnvelope === 'object' && inboundEnvelope
|
|
920
|
+
? inboundEnvelope.featureId
|
|
921
|
+
: undefined,
|
|
922
|
+
});
|
|
889
923
|
if (recallResult && recallResult.formatted) {
|
|
890
924
|
memoryInjection = memoryInjection
|
|
891
925
|
? memoryInjection + '\n\n' + recallResult.formatted
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memory/recall-v2.js — DESIGN-v2 Part II: scope-based memory recall.
|
|
3
|
+
*
|
|
4
|
+
* Recall under v2 is structurally different from R6: instead of selecting
|
|
5
|
+
* individual entry shards by tag/keyword, we assemble per-scope `memory.md`
|
|
6
|
+
* + `summary.md` for the scopes that are *known* to be relevant from the
|
|
7
|
+
* current turn's context (always-include rules) plus the topic scopes whose
|
|
8
|
+
* summary best matches the user's prompt keywords.
|
|
9
|
+
*
|
|
10
|
+
* Always-include scopes (no LLM):
|
|
11
|
+
* - user (every turn)
|
|
12
|
+
* - group/<groupId> (when groupId is provided)
|
|
13
|
+
* - vp/<vpId> (when vpId is provided AND not a foreign vp)
|
|
14
|
+
* - feature/<featureId> (when featureId is provided)
|
|
15
|
+
*
|
|
16
|
+
* Topic scopes:
|
|
17
|
+
* - Score each topic by simple keyword overlap between the prompt's
|
|
18
|
+
* extracted keywords (via recall.js → extractKeywords) and the topic's
|
|
19
|
+
* `summary.md` body. Top-N by score join the bundle.
|
|
20
|
+
* - This is a heuristic — no LLM call. Topics that the dream pipeline
|
|
21
|
+
* created already correlate with the conversation's natural language,
|
|
22
|
+
* so a cheap keyword overlap is a good first cut.
|
|
23
|
+
*
|
|
24
|
+
* What this module deliberately does NOT do:
|
|
25
|
+
* - No LLM side-query. R6's recall.js does a 3rd-step LLM-select; v2
|
|
26
|
+
* skips it because the unit of selection is now whole scopes (5 + N
|
|
27
|
+
* topics) instead of dozens of individual entries.
|
|
28
|
+
* - No frontmatter parsing. memory.md is markdown; the dream-state tail
|
|
29
|
+
* marker is stripped before injection (so the LLM doesn't see internal
|
|
30
|
+
* bookkeeping bytes).
|
|
31
|
+
* - No write side effects. Pure read.
|
|
32
|
+
*
|
|
33
|
+
* Reference: agent/unify/memory/DESIGN-v2.md §6 (recall surface).
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import { join } from 'path';
|
|
37
|
+
import { promises as fsp, existsSync } from 'fs';
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
DEFAULT_MEMORY_ROOT, scopeDir, readMemory, readSummary,
|
|
41
|
+
} from './store-v2.js';
|
|
42
|
+
import { extractKeywords } from './recall.js';
|
|
43
|
+
|
|
44
|
+
/** Default cap for how many topic scopes recall pulls in. */
|
|
45
|
+
export const DEFAULT_TOPIC_LIMIT = 3;
|
|
46
|
+
|
|
47
|
+
/** Marker block written by dream-v2/state.js — stripped from injection. */
|
|
48
|
+
const DREAM_MARKER_RE = /\n*<!-- dream-state -->[\s\S]*?<!-- \/dream-state -->\s*$/;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Strip the trailing dream-state marker block (if any) from a memory.md body.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} body
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
export function stripDreamMarker(body) {
|
|
57
|
+
if (!body || typeof body !== 'string') return '';
|
|
58
|
+
return body.replace(DREAM_MARKER_RE, '').trimEnd();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* List all topic scopes present under <root>/topic/. Returns paths like
|
|
63
|
+
* ['science', 'physics'] (level 1) or ['life', 'parenting'] (level 2).
|
|
64
|
+
*
|
|
65
|
+
* @param {string} root
|
|
66
|
+
* @returns {Promise<string[][]>}
|
|
67
|
+
*/
|
|
68
|
+
async function listTopicPaths(root) {
|
|
69
|
+
const out = [];
|
|
70
|
+
const topicRoot = join(root, 'topic');
|
|
71
|
+
if (!existsSync(topicRoot)) return out;
|
|
72
|
+
let l1Names;
|
|
73
|
+
try { l1Names = await fsp.readdir(topicRoot, { withFileTypes: true }); }
|
|
74
|
+
catch { return out; }
|
|
75
|
+
for (const e1 of l1Names) {
|
|
76
|
+
if (!e1.isDirectory()) continue;
|
|
77
|
+
if (e1.name.startsWith('.')) continue;
|
|
78
|
+
// Level-1 topic is itself a scope (memory.md may sit at this level).
|
|
79
|
+
out.push([e1.name]);
|
|
80
|
+
// Walk one more level.
|
|
81
|
+
let l2Names;
|
|
82
|
+
try { l2Names = await fsp.readdir(join(topicRoot, e1.name), { withFileTypes: true }); }
|
|
83
|
+
catch { continue; }
|
|
84
|
+
for (const e2 of l2Names) {
|
|
85
|
+
if (!e2.isDirectory()) continue;
|
|
86
|
+
if (e2.name.startsWith('.')) continue;
|
|
87
|
+
out.push([e1.name, e2.name]);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Score a topic by how many of its summary's tokens overlap the prompt's
|
|
95
|
+
* keyword set. Topics with no summary score 0.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} summary
|
|
98
|
+
* @param {Set<string>} keywordSet
|
|
99
|
+
* @returns {number}
|
|
100
|
+
*/
|
|
101
|
+
function scoreTopic(summary, keywordSet) {
|
|
102
|
+
if (!summary || keywordSet.size === 0) return 0;
|
|
103
|
+
const tokens = (summary.toLowerCase()
|
|
104
|
+
.match(/[\p{L}\p{N}_-]+/gu) || [])
|
|
105
|
+
.filter(t => t.length > 1);
|
|
106
|
+
if (tokens.length === 0) return 0;
|
|
107
|
+
let hits = 0;
|
|
108
|
+
for (const t of tokens) {
|
|
109
|
+
if (keywordSet.has(t)) hits += 1;
|
|
110
|
+
}
|
|
111
|
+
return hits;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @typedef {Object} RecallV2Section
|
|
116
|
+
* @property {string} scope — human label, e.g. "user", "group/g-eng"
|
|
117
|
+
* @property {string} kind — 'user' | 'vp' | 'group' | 'feature' | 'topic'
|
|
118
|
+
* @property {string} memory — memory.md body (dream marker stripped)
|
|
119
|
+
* @property {string} summary — summary.md body
|
|
120
|
+
*/
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @typedef {Object} RecallV2Result
|
|
124
|
+
* @property {RecallV2Section[]} sections
|
|
125
|
+
* @property {string[]} keywords
|
|
126
|
+
* @property {string} formatted — ready to splice into the system prompt
|
|
127
|
+
*/
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Build a scope label suitable for the formatted block heading.
|
|
131
|
+
*
|
|
132
|
+
* @param {import('./store-v2.js').Scope} scope
|
|
133
|
+
* @returns {string}
|
|
134
|
+
*/
|
|
135
|
+
export function scopeLabel(scope) {
|
|
136
|
+
if (scope.kind === 'user') return 'user';
|
|
137
|
+
if (scope.kind === 'topic') return `topic/${(scope.path || []).join('/')}`;
|
|
138
|
+
return `${scope.kind}/${scope.id || ''}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Format the bundle for direct injection into the system prompt.
|
|
143
|
+
*
|
|
144
|
+
* @param {RecallV2Section[]} sections
|
|
145
|
+
* @returns {string}
|
|
146
|
+
*/
|
|
147
|
+
export function formatRecallV2(sections) {
|
|
148
|
+
if (!sections || sections.length === 0) return '';
|
|
149
|
+
const blocks = [];
|
|
150
|
+
for (const s of sections) {
|
|
151
|
+
const memBlock = s.memory ? s.memory.trim() : '';
|
|
152
|
+
const sumBlock = s.summary ? s.summary.trim() : '';
|
|
153
|
+
if (!memBlock && !sumBlock) continue;
|
|
154
|
+
const parts = [`### ${s.scope}`];
|
|
155
|
+
if (sumBlock) parts.push(`**Summary**\n${sumBlock}`);
|
|
156
|
+
if (memBlock) parts.push(`**Memory**\n${memBlock}`);
|
|
157
|
+
blocks.push(parts.join('\n\n'));
|
|
158
|
+
}
|
|
159
|
+
if (blocks.length === 0) return '';
|
|
160
|
+
return ['## Recalled Memory (v2)', ...blocks].join('\n\n');
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Read one scope's pair (memory.md + summary.md) and translate to a section.
|
|
165
|
+
* Returns null when both files are empty/missing or VP ACL refuses.
|
|
166
|
+
*
|
|
167
|
+
* @param {import('./store-v2.js').Scope} scope
|
|
168
|
+
* @param {{ root: string, currentVpId?: string }} opts
|
|
169
|
+
* @returns {Promise<RecallV2Section|null>}
|
|
170
|
+
*/
|
|
171
|
+
async function readScopeSection(scope, opts) {
|
|
172
|
+
let memory = '';
|
|
173
|
+
let summary = '';
|
|
174
|
+
try { memory = stripDreamMarker(await readMemory(scope, opts)); }
|
|
175
|
+
catch { return null; } // VP ACL or other → skip silently
|
|
176
|
+
try { summary = await readSummary(scope, opts); } catch { /* */ }
|
|
177
|
+
if (!memory && !summary) return null;
|
|
178
|
+
return {
|
|
179
|
+
scope: scopeLabel(scope),
|
|
180
|
+
kind: scope.kind,
|
|
181
|
+
memory,
|
|
182
|
+
summary,
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Recall v2: assemble per-scope memory.md + summary.md for the current turn.
|
|
188
|
+
*
|
|
189
|
+
* @param {Object} params
|
|
190
|
+
* @param {string} params.prompt — the user's turn prompt
|
|
191
|
+
* @param {string} [params.root] — memory root (defaults to DEFAULT_MEMORY_ROOT)
|
|
192
|
+
* @param {string} [params.groupId] — active group, if any
|
|
193
|
+
* @param {string} [params.vpId] — active VP for this turn (NOT used as ACL)
|
|
194
|
+
* @param {string} [params.currentVpId] — current session's VP, gates vp/<other> reads
|
|
195
|
+
* @param {string} [params.featureId] — active feature, if any
|
|
196
|
+
* @param {number} [params.topicLimit] — cap on topic scopes (default DEFAULT_TOPIC_LIMIT)
|
|
197
|
+
* @returns {Promise<RecallV2Result>}
|
|
198
|
+
*/
|
|
199
|
+
export async function recallV2({
|
|
200
|
+
prompt,
|
|
201
|
+
root = DEFAULT_MEMORY_ROOT,
|
|
202
|
+
groupId,
|
|
203
|
+
vpId,
|
|
204
|
+
currentVpId,
|
|
205
|
+
featureId,
|
|
206
|
+
topicLimit = DEFAULT_TOPIC_LIMIT,
|
|
207
|
+
} = {}) {
|
|
208
|
+
const sections = [];
|
|
209
|
+
const opts = { root, currentVpId };
|
|
210
|
+
const keywords = extractKeywords(prompt || '');
|
|
211
|
+
|
|
212
|
+
// Always: user.
|
|
213
|
+
const userSec = await readScopeSection({ kind: 'user' }, opts);
|
|
214
|
+
if (userSec) sections.push(userSec);
|
|
215
|
+
|
|
216
|
+
// Conditional: group/<groupId>
|
|
217
|
+
if (groupId && typeof groupId === 'string' && groupId !== '_no-group') {
|
|
218
|
+
const sec = await readScopeSection({ kind: 'group', id: groupId }, opts);
|
|
219
|
+
if (sec) sections.push(sec);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Conditional: vp/<vpId>
|
|
223
|
+
if (vpId && typeof vpId === 'string') {
|
|
224
|
+
const sec = await readScopeSection({ kind: 'vp', id: vpId }, opts);
|
|
225
|
+
if (sec) sections.push(sec);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Conditional: feature/<featureId>
|
|
229
|
+
if (featureId && typeof featureId === 'string') {
|
|
230
|
+
const sec = await readScopeSection({ kind: 'feature', id: featureId }, opts);
|
|
231
|
+
if (sec) sections.push(sec);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Topics: rank by keyword overlap on summary.
|
|
235
|
+
if (topicLimit > 0 && keywords.length > 0) {
|
|
236
|
+
const keywordSet = new Set(keywords.map(k => k.toLowerCase()));
|
|
237
|
+
const candidates = [];
|
|
238
|
+
const paths = await listTopicPaths(root);
|
|
239
|
+
for (const path of paths) {
|
|
240
|
+
const scope = { kind: 'topic', path };
|
|
241
|
+
let summary = '';
|
|
242
|
+
try { summary = await readSummary(scope, opts); } catch { /* */ }
|
|
243
|
+
const score = scoreTopic(summary, keywordSet);
|
|
244
|
+
if (score > 0) candidates.push({ scope, score });
|
|
245
|
+
}
|
|
246
|
+
candidates.sort((a, b) => b.score - a.score);
|
|
247
|
+
for (const c of candidates.slice(0, topicLimit)) {
|
|
248
|
+
const sec = await readScopeSection(c.scope, opts);
|
|
249
|
+
if (sec) sections.push(sec);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return {
|
|
254
|
+
sections,
|
|
255
|
+
keywords,
|
|
256
|
+
formatted: formatRecallV2(sections),
|
|
257
|
+
};
|
|
258
|
+
}
|
package/unify/session.js
CHANGED
|
@@ -35,6 +35,7 @@ import { createDispatcher } from './pipeline/dispatcher.js';
|
|
|
35
35
|
import { ensureDefaultGroupIfEmpty } from './groups/group-crud.js';
|
|
36
36
|
import { seedDefaultVps } from './vp/seed-defaults.js';
|
|
37
37
|
import { createDreamScheduler } from './memory/dream-scheduler.js';
|
|
38
|
+
import { createV2DreamScheduler } from './dream-v2/session-wiring.js';
|
|
38
39
|
import { getUserMemoryStore } from './memory/user-memory-store.js';
|
|
39
40
|
import { join } from 'path';
|
|
40
41
|
import { existsSync as existsSyncSafe, readFileSync as readFileSyncSafe } from 'fs';
|
|
@@ -246,29 +247,47 @@ export async function loadSession(options = {}) {
|
|
|
246
247
|
yeaftDir,
|
|
247
248
|
});
|
|
248
249
|
|
|
249
|
-
// ─── 9a. Create dream scheduler (wave-6b)
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
adapter,
|
|
255
|
-
config,
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
250
|
+
// ─── 9a. Create dream scheduler (wave-6b / DESIGN-v2) ──
|
|
251
|
+
// When config.memoryV2 is on, route through the v2 pipeline (per-scope
|
|
252
|
+
// memory.md + summary.md). Otherwise keep the legacy R6 dream-scheduler.
|
|
253
|
+
let dreamScheduler;
|
|
254
|
+
if (config.memoryV2) {
|
|
255
|
+
// Build a partial session reference so the v2 wiring can see adapter,
|
|
256
|
+
// config, yeaftDir, engine, and trace. The scheduler's `run` closure
|
|
257
|
+
// dereferences these lazily, so mutating the object after this line
|
|
258
|
+
// (e.g. attaching engine) is safe.
|
|
259
|
+
const partialSession = {
|
|
260
|
+
yeaftDir,
|
|
261
|
+
adapter,
|
|
262
|
+
config,
|
|
263
|
+
engine,
|
|
264
|
+
trace,
|
|
265
|
+
};
|
|
266
|
+
dreamScheduler = createV2DreamScheduler(partialSession);
|
|
267
|
+
} else {
|
|
268
|
+
dreamScheduler = createDreamScheduler({
|
|
269
|
+
memoryShardStore,
|
|
270
|
+
userMemoryStore: getUserMemoryStore(),
|
|
271
|
+
conversationStore,
|
|
272
|
+
adapter,
|
|
273
|
+
config,
|
|
274
|
+
onDreamStart: (vpId) => {
|
|
275
|
+
if (config.debug) console.log(`[Yeaft] Dream started for VP ${vpId}`);
|
|
276
|
+
},
|
|
277
|
+
onDreamEnd: (vpId, result) => {
|
|
278
|
+
if (config.debug) console.log(`[Yeaft] Dream ended for VP ${vpId}:`, JSON.stringify({
|
|
279
|
+
trigger: result.trigger,
|
|
280
|
+
entriesMerged: result.entriesMerged,
|
|
281
|
+
entriesPruned: result.entriesPruned,
|
|
282
|
+
bytesReclaimed: result.bytesReclaimed,
|
|
283
|
+
errors: result.errors?.length || 0,
|
|
284
|
+
}));
|
|
285
|
+
},
|
|
286
|
+
onError: (vpId, err) => {
|
|
287
|
+
console.warn(`[Yeaft] Dream error for VP ${vpId}:`, err?.message || err);
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
}
|
|
272
291
|
|
|
273
292
|
// task-308 Phase 2: thread-aware engine registry.
|
|
274
293
|
// Each thread gets its own EngineInstance (lazy-created) that owns its
|
package/unify/web-bridge.js
CHANGED
|
@@ -532,6 +532,17 @@ export function installUnifyRuntimeBridge(s) {
|
|
|
532
532
|
if (!s) return;
|
|
533
533
|
const initialMax = s.engineRegistry?.maxConcurrent ?? null;
|
|
534
534
|
const initialIdle = s.threadStore?.idleArchiveDays ?? 0;
|
|
535
|
+
|
|
536
|
+
// DESIGN-v2 §19.4: forward dream pipeline progress events to the web
|
|
537
|
+
// client so the debug panel can render live state. Events flow through
|
|
538
|
+
// the same `unify_output` channel; no new WebSocket message type is
|
|
539
|
+
// introduced.
|
|
540
|
+
s._dreamProgressSink = (evt) => {
|
|
541
|
+
try {
|
|
542
|
+
sendUnifyEvent({ type: 'dream_progress', ...evt });
|
|
543
|
+
} catch { /* never let event delivery throw */ }
|
|
544
|
+
};
|
|
545
|
+
|
|
535
546
|
ctx.unifyRuntimeSettings = {
|
|
536
547
|
get maxConcurrentThreads() { return s.engineRegistry?.maxConcurrent ?? initialMax; },
|
|
537
548
|
set maxConcurrentThreads(v) {
|