@yeaft/webchat-agent 0.1.629 → 0.1.631
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 +9 -0
- package/unify/dream-v2/apply.js +271 -0
- package/unify/dream-v2/limits.js +46 -0
- package/unify/dream-v2/merge.js +87 -0
- package/unify/dream-v2/runner.js +267 -0
- package/unify/dream-v2/schedule.js +73 -0
- package/unify/dream-v2/segment.js +191 -0
- package/unify/dream-v2/snapshot.js +80 -0
- package/unify/dream-v2/state.js +177 -0
- package/unify/dream-v2/triage.js +287 -0
- package/unify/engine.js +38 -4
- package/unify/memory/recall-v2.js +258 -0
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/state.js — DESIGN-v2 §11.
|
|
3
|
+
*
|
|
4
|
+
* Two pieces of state, tracked separately:
|
|
5
|
+
*
|
|
6
|
+
* 1. Per-group control state (used to decide whether a group enters
|
|
7
|
+
* triage and how far to advance the cursor):
|
|
8
|
+
*
|
|
9
|
+
* ~/.yeaft/memory/group/<id>/.dream-state
|
|
10
|
+
*
|
|
11
|
+
* A 3-line text file:
|
|
12
|
+
*
|
|
13
|
+
* lastDreamMessageId: m-1024
|
|
14
|
+
* lastDreamAt: 2026-04-28T03:07:00Z
|
|
15
|
+
* messageCount: 491
|
|
16
|
+
*
|
|
17
|
+
* Fields are independent of each other; missing fields default to
|
|
18
|
+
* empty / null / 0. The file is rewritten atomically every dream.
|
|
19
|
+
*
|
|
20
|
+
* The virtual `_no-group/` group lives at the same path layout
|
|
21
|
+
* (`group/_no-group/.dream-state`) and uses the same accessor.
|
|
22
|
+
*
|
|
23
|
+
* 2. Per-scope observability marker, embedded inside the scope's
|
|
24
|
+
* `memory.md` between two HTML comments at the file's tail:
|
|
25
|
+
*
|
|
26
|
+
* <!-- dream-state -->
|
|
27
|
+
* lastDreamAt: 2026-04-28T03:07:00Z
|
|
28
|
+
* <!-- /dream-state -->
|
|
29
|
+
*
|
|
30
|
+
* Read for the debug panel only; it does NOT participate in any
|
|
31
|
+
* control-flow decision. We update it by replacing the existing
|
|
32
|
+
* block (if any) or appending a new one to the end of the file.
|
|
33
|
+
*
|
|
34
|
+
* Both helpers are pure I/O; no LLM, no logic beyond parsing.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { promises as fsp, existsSync } from 'fs';
|
|
38
|
+
import { join, dirname } from 'path';
|
|
39
|
+
|
|
40
|
+
const STATE_FILE = '.dream-state';
|
|
41
|
+
const DREAM_BLOCK_OPEN = '<!-- dream-state -->';
|
|
42
|
+
const DREAM_BLOCK_CLOSE = '<!-- /dream-state -->';
|
|
43
|
+
|
|
44
|
+
// ─── per-group ────────────────────────────────────────────────
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Read a group's .dream-state. Missing file → defaults.
|
|
48
|
+
*
|
|
49
|
+
* @param {string} root — memory root, e.g. ~/.yeaft/memory
|
|
50
|
+
* @param {string} groupId
|
|
51
|
+
* @returns {Promise<{ lastDreamMessageId: string|null, lastDreamAt: string|null, messageCount: number }>}
|
|
52
|
+
*/
|
|
53
|
+
export async function readGroupState(root, groupId) {
|
|
54
|
+
const abs = join(root, 'group', groupId, STATE_FILE);
|
|
55
|
+
const empty = { lastDreamMessageId: null, lastDreamAt: null, messageCount: 0 };
|
|
56
|
+
let raw;
|
|
57
|
+
try { raw = await fsp.readFile(abs, 'utf8'); }
|
|
58
|
+
catch (err) { if (err && err.code === 'ENOENT') return empty; throw err; }
|
|
59
|
+
return parseGroupState(raw);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Atomically rewrite a group's .dream-state. Creates the group dir if
|
|
64
|
+
* absent. Unknown fields are ignored.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} root
|
|
67
|
+
* @param {string} groupId
|
|
68
|
+
* @param {{ lastDreamMessageId?: string|null, lastDreamAt?: string|null, messageCount?: number }} state
|
|
69
|
+
*/
|
|
70
|
+
export async function writeGroupState(root, groupId, state) {
|
|
71
|
+
const dir = join(root, 'group', groupId);
|
|
72
|
+
await fsp.mkdir(dir, { recursive: true });
|
|
73
|
+
const abs = join(dir, STATE_FILE);
|
|
74
|
+
const body =
|
|
75
|
+
`lastDreamMessageId: ${state.lastDreamMessageId == null ? '' : state.lastDreamMessageId}\n` +
|
|
76
|
+
`lastDreamAt: ${state.lastDreamAt == null ? '' : state.lastDreamAt}\n` +
|
|
77
|
+
`messageCount: ${Number.isFinite(state.messageCount) ? state.messageCount : 0}\n`;
|
|
78
|
+
await atomicWrite(abs, body);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Parse the 3-line key:value format. Tolerant of stray whitespace and
|
|
83
|
+
* empty values.
|
|
84
|
+
* @param {string} raw
|
|
85
|
+
*/
|
|
86
|
+
function parseGroupState(raw) {
|
|
87
|
+
const out = { lastDreamMessageId: null, lastDreamAt: null, messageCount: 0 };
|
|
88
|
+
const lines = String(raw || '').split(/\r?\n/);
|
|
89
|
+
for (const ln of lines) {
|
|
90
|
+
const m = /^(\w[\w-]*)\s*:\s*(.*)$/.exec(ln);
|
|
91
|
+
if (!m) continue;
|
|
92
|
+
const k = m[1];
|
|
93
|
+
const v = m[2].trim();
|
|
94
|
+
if (k === 'lastDreamMessageId') out.lastDreamMessageId = v || null;
|
|
95
|
+
else if (k === 'lastDreamAt') out.lastDreamAt = v || null;
|
|
96
|
+
else if (k === 'messageCount') {
|
|
97
|
+
const n = Number(v);
|
|
98
|
+
out.messageCount = Number.isFinite(n) ? n : 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ─── per-scope marker (memory.md tail block) ───────────────────
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Read the lastDreamAt timestamp from a scope's memory.md, or null if
|
|
108
|
+
* the file or the dream-state block is absent.
|
|
109
|
+
*
|
|
110
|
+
* @param {string} memoryMdAbsPath
|
|
111
|
+
* @returns {Promise<string|null>}
|
|
112
|
+
*/
|
|
113
|
+
export async function readScopeDreamMarker(memoryMdAbsPath) {
|
|
114
|
+
let raw;
|
|
115
|
+
try { raw = await fsp.readFile(memoryMdAbsPath, 'utf8'); }
|
|
116
|
+
catch (err) { if (err && err.code === 'ENOENT') return null; throw err; }
|
|
117
|
+
const block = extractDreamBlock(raw);
|
|
118
|
+
if (!block) return null;
|
|
119
|
+
const m = /^lastDreamAt:\s*(.*)$/m.exec(block);
|
|
120
|
+
return m ? (m[1].trim() || null) : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Replace or append the per-scope dream-state block in memory.md.
|
|
125
|
+
* Returns the new file body (caller decides how to persist).
|
|
126
|
+
*
|
|
127
|
+
* @param {string} memoryMd — current full file content
|
|
128
|
+
* @param {{ lastDreamAt: string }} fields
|
|
129
|
+
* @returns {string}
|
|
130
|
+
*/
|
|
131
|
+
export function withDreamMarker(memoryMd, fields) {
|
|
132
|
+
const block = renderDreamBlock(fields);
|
|
133
|
+
const body = String(memoryMd || '');
|
|
134
|
+
if (body.includes(DREAM_BLOCK_OPEN) && body.includes(DREAM_BLOCK_CLOSE)) {
|
|
135
|
+
// Replace existing block.
|
|
136
|
+
return body.replace(
|
|
137
|
+
new RegExp(`${escapeRe(DREAM_BLOCK_OPEN)}[\\s\\S]*?${escapeRe(DREAM_BLOCK_CLOSE)}`),
|
|
138
|
+
block,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
// Append. Ensure exactly one newline before the block.
|
|
142
|
+
const trimmed = body.replace(/\s+$/, '');
|
|
143
|
+
const sep = trimmed.length === 0 ? '' : '\n\n';
|
|
144
|
+
return `${trimmed}${sep}${block}\n`;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Extract the contents of the dream-state block (between the two HTML
|
|
149
|
+
* comments). Returns null if the block isn't present.
|
|
150
|
+
* @param {string} body
|
|
151
|
+
*/
|
|
152
|
+
function extractDreamBlock(body) {
|
|
153
|
+
const re = new RegExp(`${escapeRe(DREAM_BLOCK_OPEN)}([\\s\\S]*?)${escapeRe(DREAM_BLOCK_CLOSE)}`);
|
|
154
|
+
const m = re.exec(String(body || ''));
|
|
155
|
+
return m ? m[1].trim() : null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function renderDreamBlock(fields) {
|
|
159
|
+
const lines = [DREAM_BLOCK_OPEN];
|
|
160
|
+
if (fields.lastDreamAt) lines.push(`lastDreamAt: ${fields.lastDreamAt}`);
|
|
161
|
+
lines.push(DREAM_BLOCK_CLOSE);
|
|
162
|
+
return lines.join('\n');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function escapeRe(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
|
|
166
|
+
|
|
167
|
+
// ─── shared atomic writer ─────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
async function atomicWrite(absPath, content) {
|
|
170
|
+
await fsp.mkdir(dirname(absPath), { recursive: true });
|
|
171
|
+
const tmp = `${absPath}.tmp.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
172
|
+
await fsp.writeFile(tmp, content, 'utf8');
|
|
173
|
+
await fsp.rename(tmp, absPath);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// re-exported for tests
|
|
177
|
+
export const _internals = { parseGroupState, extractDreamBlock };
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dream-v2/triage.js — DESIGN-v2 §14.
|
|
3
|
+
*
|
|
4
|
+
* Decide, for one group's diff, which scopes should be touched by Apply.
|
|
5
|
+
* The decision is two-staged on purpose:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Hard rules** (this module, no LLM): everything we can determine
|
|
8
|
+
* from message metadata. Always include the active group, every VP
|
|
9
|
+
* that spoke as an assistant in the diff, every feature referenced
|
|
10
|
+
* via `featureId`, and `user` (so painted-over user-profile signals
|
|
11
|
+
* can't be missed).
|
|
12
|
+
*
|
|
13
|
+
* 2. **Soft classification** (LLM, two passes):
|
|
14
|
+
* Pass-1: high-recall — does the diff carry user-profile signal?
|
|
15
|
+
* what topics (category-level) does it touch?
|
|
16
|
+
* Pass-2: high-precision — for each topic Pass-1 surfaced, bind
|
|
17
|
+
* it to an exact existing path or propose
|
|
18
|
+
* a new ≤2-level path.
|
|
19
|
+
*
|
|
20
|
+
* VP / group / feature are deliberately NOT asked of the LLM —
|
|
21
|
+
* Hard Rules already cover them, and giving the LLM a chance to
|
|
22
|
+
* drop a structurally-required scope would weaken the contract.
|
|
23
|
+
*
|
|
24
|
+
* `user_profile_signals === true` does not need Pass-2 either: the
|
|
25
|
+
* Hard Rule already added `user` and Apply itself decides whether
|
|
26
|
+
* to actually rewrite anything (an UPDATE with no relevant content
|
|
27
|
+
* reads as a no-op rewrite of the existing memory).
|
|
28
|
+
*
|
|
29
|
+
* The LLM is injected as a callable: `llm({ pass, prompt, system })` →
|
|
30
|
+
* Promise<string>. Tests pass a stub; runner injects the real adapter.
|
|
31
|
+
*
|
|
32
|
+
* The triage actions are unioned across segments when one group's diff
|
|
33
|
+
* is split in segment.js — segment-level triage runs N times and the
|
|
34
|
+
* caller dedupes. (Implemented as a thin wrapper `triageGroupSegments`
|
|
35
|
+
* below.)
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
import { isValidTopic } from '../memory/store-v2.js';
|
|
39
|
+
import { truncateMessage } from './segment.js';
|
|
40
|
+
|
|
41
|
+
const SYSTEM = `You are the Triage stage of a dream pipeline that decides which scopes a recent group conversation should affect. Reply with strict JSON only — no prose, no markdown fences.`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Hard rules: deterministically derive must-include scopes from the
|
|
45
|
+
* structure of the diff.
|
|
46
|
+
*
|
|
47
|
+
* Inputs:
|
|
48
|
+
* - groupId: the active group ('_no-group' is allowed and skips the
|
|
49
|
+
* `group/<id>` entry — by convention the virtual group has no scope
|
|
50
|
+
* of its own).
|
|
51
|
+
* - messages: the diff (already overlap-prefixed if applicable).
|
|
52
|
+
*
|
|
53
|
+
* @param {{ groupId: string, messages: Array<object> }} args
|
|
54
|
+
* @returns {Array<{ kind: 'update', scope: string }>}
|
|
55
|
+
*/
|
|
56
|
+
export function applyHardRules({ groupId, messages }) {
|
|
57
|
+
const out = new Map();
|
|
58
|
+
const add = (scope) => { if (!out.has(scope)) out.set(scope, { kind: 'update', scope }); };
|
|
59
|
+
|
|
60
|
+
// user is always in.
|
|
61
|
+
add('user');
|
|
62
|
+
|
|
63
|
+
// active group, except the virtual _no-group bucket.
|
|
64
|
+
if (groupId && groupId !== '_no-group') add(`group/${groupId}`);
|
|
65
|
+
|
|
66
|
+
for (const m of (messages || [])) {
|
|
67
|
+
if (!m || typeof m !== 'object') continue;
|
|
68
|
+
// Active VP: any assistant message's vpId.
|
|
69
|
+
if (m.role === 'assistant') {
|
|
70
|
+
const vp = m.vpId || (m.author && /^vp:(.+)$/.exec(m.author)?.[1]);
|
|
71
|
+
if (vp && /^[A-Za-z0-9_\-.一-鿿]+$/.test(vp)) add(`vp/${vp}`);
|
|
72
|
+
}
|
|
73
|
+
// Active feature: explicit metadata.
|
|
74
|
+
if (m.featureId && /^[A-Za-z0-9_\-.一-鿿]+$/.test(m.featureId)) {
|
|
75
|
+
add(`feature/${m.featureId}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return Array.from(out.values());
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ─── soft classification ──────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Build the prompt used for Pass-1.
|
|
86
|
+
*
|
|
87
|
+
* @param {{ groupId: string, messages: Array<object>, topicSummaries: Array<{ path: string, summary: string }> }} ctx
|
|
88
|
+
*/
|
|
89
|
+
export function buildPass1Prompt(ctx) {
|
|
90
|
+
const lines = [
|
|
91
|
+
'You are deciding whether a recent group conversation carries:',
|
|
92
|
+
' - signals that should update the USER profile, and/or',
|
|
93
|
+
' - signals that should update one or more TOPIC scopes.',
|
|
94
|
+
'',
|
|
95
|
+
'Do NOT mention vp/, group/, or feature/ scopes — those are handled by hard rules.',
|
|
96
|
+
'',
|
|
97
|
+
`Group: ${ctx.groupId}`,
|
|
98
|
+
'',
|
|
99
|
+
'Existing topic scopes (path — summary):',
|
|
100
|
+
];
|
|
101
|
+
if (!ctx.topicSummaries || ctx.topicSummaries.length === 0) {
|
|
102
|
+
lines.push(' (none)');
|
|
103
|
+
} else {
|
|
104
|
+
for (const t of ctx.topicSummaries) {
|
|
105
|
+
lines.push(` - ${t.path} — ${oneLine(t.summary)}`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
lines.push('', 'Conversation:');
|
|
109
|
+
for (const m of (ctx.messages || [])) {
|
|
110
|
+
const head = `[${m.role || 'message'}${m.kind === 'overlap' ? ' (already processed)' : ''}]`;
|
|
111
|
+
lines.push(head);
|
|
112
|
+
lines.push(truncateMessage(m.body || ''));
|
|
113
|
+
lines.push('');
|
|
114
|
+
}
|
|
115
|
+
lines.push('Respond with strict JSON of the shape:');
|
|
116
|
+
lines.push('{');
|
|
117
|
+
lines.push(' "user_profile_signals": boolean,');
|
|
118
|
+
lines.push(' "topics": [ "<short category description>", ... ],');
|
|
119
|
+
lines.push(' "trivial_only": boolean');
|
|
120
|
+
lines.push('}');
|
|
121
|
+
return lines.join('\n');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Build the Pass-2 prompt for a single topic description.
|
|
126
|
+
*
|
|
127
|
+
* @param {{ description: string, existingTopics: Array<{ path: string, summary: string }> }} ctx
|
|
128
|
+
*/
|
|
129
|
+
export function buildPass2Prompt(ctx) {
|
|
130
|
+
const lines = [
|
|
131
|
+
'Bind a free-form topic description to an exact path under topic/.',
|
|
132
|
+
'Rules:',
|
|
133
|
+
' - At most TWO path segments. Reject any third level.',
|
|
134
|
+
' - Segments may contain letters, digits, dashes, underscores, dots, CJK.',
|
|
135
|
+
' - Prefer matching an existing path if the description fits.',
|
|
136
|
+
'',
|
|
137
|
+
`Description: ${ctx.description}`,
|
|
138
|
+
'',
|
|
139
|
+
'Existing topics:',
|
|
140
|
+
];
|
|
141
|
+
if (!ctx.existingTopics || ctx.existingTopics.length === 0) {
|
|
142
|
+
lines.push(' (none)');
|
|
143
|
+
} else {
|
|
144
|
+
for (const t of ctx.existingTopics) {
|
|
145
|
+
lines.push(` - ${t.path} — ${oneLine(t.summary)}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push('Reply with strict JSON, exactly one of:');
|
|
150
|
+
lines.push(' { "decision": "match", "path": "<existing path>" }');
|
|
151
|
+
lines.push(' { "decision": "new", "path": "<new ≤2-segment path>" }');
|
|
152
|
+
lines.push(' { "decision": "none" }');
|
|
153
|
+
return lines.join('\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Run soft classification for one segment of one group's diff.
|
|
158
|
+
*
|
|
159
|
+
* @param {{
|
|
160
|
+
* groupId: string,
|
|
161
|
+
* messages: Array<object>,
|
|
162
|
+
* topicSummaries: Array<{ path: string, summary: string }>,
|
|
163
|
+
* llm: (req: { pass: string, prompt: string, system: string }) => Promise<string>,
|
|
164
|
+
* }} args
|
|
165
|
+
* @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
|
|
166
|
+
*/
|
|
167
|
+
export async function classifySoft({ groupId, messages, topicSummaries, llm }) {
|
|
168
|
+
if (!llm) throw new Error('triage.classifySoft: llm callable required');
|
|
169
|
+
const pass1Prompt = buildPass1Prompt({ groupId, messages, topicSummaries });
|
|
170
|
+
const pass1Raw = await llm({ pass: 'triage-pass1', prompt: pass1Prompt, system: SYSTEM });
|
|
171
|
+
const pass1 = parseJsonSafe(pass1Raw);
|
|
172
|
+
const out = [];
|
|
173
|
+
|
|
174
|
+
// user_profile_signals: covered by hard rules; we only emit explicit
|
|
175
|
+
// user action here when Pass-1 says yes (idempotent if hard rules
|
|
176
|
+
// already added it).
|
|
177
|
+
if (pass1 && pass1.user_profile_signals === true) {
|
|
178
|
+
out.push({ kind: 'update', scope: 'user' });
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const topicDescriptions = (pass1 && Array.isArray(pass1.topics)) ? pass1.topics : [];
|
|
182
|
+
for (const description of topicDescriptions) {
|
|
183
|
+
if (typeof description !== 'string' || !description.trim()) continue;
|
|
184
|
+
const pass2Prompt = buildPass2Prompt({
|
|
185
|
+
description: description.trim(),
|
|
186
|
+
existingTopics: topicSummaries || [],
|
|
187
|
+
});
|
|
188
|
+
const pass2Raw = await llm({ pass: 'triage-pass2', prompt: pass2Prompt, system: SYSTEM });
|
|
189
|
+
const pass2 = parseJsonSafe(pass2Raw);
|
|
190
|
+
if (!pass2 || !pass2.decision) continue;
|
|
191
|
+
if (pass2.decision === 'none') continue;
|
|
192
|
+
const path = String(pass2.path || '').trim();
|
|
193
|
+
if (!path) continue;
|
|
194
|
+
const segs = path.split('/').filter(Boolean);
|
|
195
|
+
if (!isValidTopic({ kind: 'topic', path: segs })) continue;
|
|
196
|
+
const scope = `topic/${segs.join('/')}`;
|
|
197
|
+
if (pass2.decision === 'match') {
|
|
198
|
+
out.push({ kind: 'update', scope });
|
|
199
|
+
} else if (pass2.decision === 'new') {
|
|
200
|
+
out.push({ kind: 'create', scope });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return out;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Combine hard-rule and soft-classification results for one segment.
|
|
208
|
+
* Dedupes by scope — `update` wins if any source said update.
|
|
209
|
+
*
|
|
210
|
+
* @param {{
|
|
211
|
+
* groupId: string,
|
|
212
|
+
* messages: Array<object>,
|
|
213
|
+
* topicSummaries: Array<{ path: string, summary: string }>,
|
|
214
|
+
* llm: (req: { pass: string, prompt: string, system: string }) => Promise<string>,
|
|
215
|
+
* }} args
|
|
216
|
+
* @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
|
|
217
|
+
*/
|
|
218
|
+
export async function triageOneSegment(args) {
|
|
219
|
+
const hard = applyHardRules({ groupId: args.groupId, messages: args.messages });
|
|
220
|
+
const soft = await classifySoft(args);
|
|
221
|
+
return dedupeActions([...hard, ...soft]);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Triage a group's diff that has already been split into N segments.
|
|
226
|
+
* Runs each segment serially, accumulates and dedupes actions.
|
|
227
|
+
*
|
|
228
|
+
* @param {{
|
|
229
|
+
* groupId: string,
|
|
230
|
+
* segments: Array<{ messages: Array<object> }>,
|
|
231
|
+
* topicSummaries: Array<{ path: string, summary: string }>,
|
|
232
|
+
* llm: (req: { pass: string, prompt: string, system: string }) => Promise<string>,
|
|
233
|
+
* onProgress?: (event: object) => void,
|
|
234
|
+
* }} args
|
|
235
|
+
* @returns {Promise<Array<{ kind: 'update'|'create', scope: string }>>}
|
|
236
|
+
*/
|
|
237
|
+
export async function triageGroupSegments({ groupId, segments, topicSummaries, llm, onProgress }) {
|
|
238
|
+
let acc = [];
|
|
239
|
+
let i = 0;
|
|
240
|
+
for (const seg of (segments || [])) {
|
|
241
|
+
i += 1;
|
|
242
|
+
if (onProgress) onProgress({ phase: 'triage', groupId, segment: i, total: segments.length });
|
|
243
|
+
const segActions = await triageOneSegment({
|
|
244
|
+
groupId,
|
|
245
|
+
messages: seg.messages,
|
|
246
|
+
topicSummaries,
|
|
247
|
+
llm,
|
|
248
|
+
});
|
|
249
|
+
acc = dedupeActions([...acc, ...segActions]);
|
|
250
|
+
}
|
|
251
|
+
return acc;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── helpers ──────────────────────────────────────────────────
|
|
255
|
+
|
|
256
|
+
function dedupeActions(list) {
|
|
257
|
+
const map = new Map();
|
|
258
|
+
for (const a of list) {
|
|
259
|
+
if (!a || !a.scope) continue;
|
|
260
|
+
const cur = map.get(a.scope);
|
|
261
|
+
if (!cur) { map.set(a.scope, { ...a }); continue; }
|
|
262
|
+
if (a.kind === 'update') cur.kind = 'update';
|
|
263
|
+
}
|
|
264
|
+
return Array.from(map.values());
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function oneLine(s) {
|
|
268
|
+
return String(s || '').replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Lenient JSON parse: tolerate fenced ```json blocks. Returns null on failure. */
|
|
272
|
+
export function parseJsonSafe(raw) {
|
|
273
|
+
if (typeof raw !== 'string') return null;
|
|
274
|
+
let s = raw.trim();
|
|
275
|
+
// Strip markdown fences if present.
|
|
276
|
+
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/.exec(s);
|
|
277
|
+
if (fenced) s = fenced[1].trim();
|
|
278
|
+
try { return JSON.parse(s); }
|
|
279
|
+
catch { /* try to recover the first {...} block */ }
|
|
280
|
+
const start = s.indexOf('{');
|
|
281
|
+
const end = s.lastIndexOf('}');
|
|
282
|
+
if (start >= 0 && end > start) {
|
|
283
|
+
try { return JSON.parse(s.slice(start, end + 1)); }
|
|
284
|
+
catch { return null; }
|
|
285
|
+
}
|
|
286
|
+
return null;
|
|
287
|
+
}
|
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
|