@yeaft/webchat-agent 0.1.723 → 0.1.726
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/engine.js +74 -21
- package/unify/memory/seed-backfill.js +129 -12
- package/unify/stop-hooks.js +10 -0
- package/unify/vp/vp-crud.js +21 -10
- package/unify/web-bridge.js +146 -0
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -28,6 +28,7 @@ import { archiveTurn } from './archive/turn-archive.js';
|
|
|
28
28
|
import { archiveToolResults } from './archive/tool-results.js';
|
|
29
29
|
import { readSummary as readScopeSummary } from './memory/store-v2.js';
|
|
30
30
|
import { runAdjust } from './memory/adjust.js';
|
|
31
|
+
import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
|
|
31
32
|
import { runStopHooks } from './stop-hooks.js';
|
|
32
33
|
// H2.f.5: threads/ retired. Persisted messages still carry a `threadId`
|
|
33
34
|
// field for back-compat with old conversation files; new writes always use
|
|
@@ -176,6 +177,45 @@ export function estimateMessagesTokens(system, messages) {
|
|
|
176
177
|
|
|
177
178
|
// ─── Engine ──────────────────────────────────────────────────────
|
|
178
179
|
|
|
180
|
+
/**
|
|
181
|
+
* buildResidentEntries — pure helper that builds the AMS Resident entry
|
|
182
|
+
* list from the per-turn Layer-A summaries.
|
|
183
|
+
*
|
|
184
|
+
* Encodes one non-trivial rule on top of "push if non-empty":
|
|
185
|
+
*
|
|
186
|
+
* The `vp/<ownVpId>` summary is skipped when it carries the
|
|
187
|
+
* seed-backfill stub marker. The persona body is already rendered as
|
|
188
|
+
* Section 1 of the system prompt by `renderVpPersona`; surfacing the
|
|
189
|
+
* stub's `# Name / Role` line as a Resident entry would re-label the
|
|
190
|
+
* same identity in Section 6 ("Active Memory Set") with no added
|
|
191
|
+
* information — the visible follow-up to the persona-dup bug fixed in
|
|
192
|
+
* PR #722. Once Dream-v2 writes a real summary for this scope it
|
|
193
|
+
* lacks the marker and is surfaced normally.
|
|
194
|
+
*
|
|
195
|
+
* Other-VP entries (group collaborators) are NOT considered here — only
|
|
196
|
+
* the local VP's summary is loaded into `summaries.vp` upstream by
|
|
197
|
+
* `#loadLayerASummaries`. Cross-VP context flows through onDemand recall.
|
|
198
|
+
*
|
|
199
|
+
* @param {{
|
|
200
|
+
* groupId?: string|null,
|
|
201
|
+
* ownVpId?: string|null,
|
|
202
|
+
* summaries: { user?: string, group?: string, vp?: string }
|
|
203
|
+
* }} args
|
|
204
|
+
* @returns {Array<{scope: string, summary: string}>}
|
|
205
|
+
*/
|
|
206
|
+
export function buildResidentEntries(args) {
|
|
207
|
+
const summaries = (args && args.summaries) || {};
|
|
208
|
+
const out = [];
|
|
209
|
+
if (summaries.user) out.push({ scope: 'user', summary: summaries.user });
|
|
210
|
+
if (args.groupId && summaries.group) {
|
|
211
|
+
out.push({ scope: `group/${args.groupId}`, summary: summaries.group });
|
|
212
|
+
}
|
|
213
|
+
if (args.ownVpId && summaries.vp && !isVpSeedBackfillStub(summaries.vp)) {
|
|
214
|
+
out.push({ scope: `vp/${args.ownVpId}`, summary: summaries.vp });
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
179
219
|
export class Engine {
|
|
180
220
|
/** @type {import('./llm/adapter.js').LLMAdapter} */
|
|
181
221
|
#adapter;
|
|
@@ -505,14 +545,11 @@ export class Engine {
|
|
|
505
545
|
|
|
506
546
|
// (a) Resident: rebuild from the same scope summaries the worker
|
|
507
547
|
// prompt is already going to see.
|
|
508
|
-
const residentEntries =
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
}
|
|
513
|
-
if (ownVpId && args.summaries?.vp) {
|
|
514
|
-
residentEntries.push({ scope: `vp/${ownVpId}`, summary: args.summaries.vp });
|
|
515
|
-
}
|
|
548
|
+
const residentEntries = buildResidentEntries({
|
|
549
|
+
groupId: args.groupId,
|
|
550
|
+
ownVpId,
|
|
551
|
+
summaries: args.summaries || {},
|
|
552
|
+
});
|
|
516
553
|
ams.setResident(residentEntries);
|
|
517
554
|
|
|
518
555
|
// (b) onDemand: replace with this turn's FTS hits.
|
|
@@ -836,11 +873,19 @@ export class Engine {
|
|
|
836
873
|
* Persist user message and assistant response to conversation store.
|
|
837
874
|
* Skipped in read-only mode (config._readOnly).
|
|
838
875
|
*
|
|
876
|
+
* Multi-VP fan-out (Bug 1): when several engines run the same user
|
|
877
|
+
* prompt in parallel, we must NOT each write our own copy of the user
|
|
878
|
+
* message — `coord.ingest`/the orchestrator already wrote it once. Pass
|
|
879
|
+
* `userAlreadyPersisted: true` from the caller to skip the user-row
|
|
880
|
+
* append while still persisting the assistant + tool rows.
|
|
881
|
+
*
|
|
839
882
|
* @param {string} userContent
|
|
840
883
|
* @param {string} assistantContent
|
|
841
884
|
* @param {object[]} [toolCalls]
|
|
885
|
+
* @param {string} [groupId]
|
|
886
|
+
* @param {boolean} [userAlreadyPersisted]
|
|
842
887
|
*/
|
|
843
|
-
#persistMessages(userContent, assistantContent, toolCalls, groupId) {
|
|
888
|
+
#persistMessages(userContent, assistantContent, toolCalls, groupId, userAlreadyPersisted = false) {
|
|
844
889
|
if (!this.#conversationStore) return;
|
|
845
890
|
if (this.#config._readOnly) return;
|
|
846
891
|
|
|
@@ -848,14 +893,17 @@ export class Engine {
|
|
|
848
893
|
// for back-compat with old conversation files; new writes always use 'main'.
|
|
849
894
|
const threadId = MAIN_THREAD_ID;
|
|
850
895
|
|
|
851
|
-
// Persist user message
|
|
852
|
-
this
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
896
|
+
// Persist user message — unless an upstream caller (e.g. the group
|
|
897
|
+
// coordinator) has already done so for this turn.
|
|
898
|
+
if (!userAlreadyPersisted) {
|
|
899
|
+
this.#conversationStore.append({
|
|
900
|
+
role: 'user',
|
|
901
|
+
content: userContent,
|
|
902
|
+
threadId,
|
|
903
|
+
// Bug 6: stamp groupId so history replay can route by group.
|
|
904
|
+
...(groupId ? { groupId } : {}),
|
|
905
|
+
});
|
|
906
|
+
}
|
|
859
907
|
|
|
860
908
|
// Persist assistant message
|
|
861
909
|
const assistantMsg = {
|
|
@@ -1003,7 +1051,7 @@ export class Engine {
|
|
|
1003
1051
|
* string-prompt shape (no regression for existing callers).
|
|
1004
1052
|
* @yields {EngineEvent}
|
|
1005
1053
|
*/
|
|
1006
|
-
async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement } = {}) {
|
|
1054
|
+
async *query({ prompt, promptParts = null, messages = [], signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false } = {}) {
|
|
1007
1055
|
if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
|
|
1008
1056
|
yield {
|
|
1009
1057
|
type: 'error',
|
|
@@ -1062,7 +1110,7 @@ export class Engine {
|
|
|
1062
1110
|
const runSignal = abortCtrl.signal;
|
|
1063
1111
|
|
|
1064
1112
|
try {
|
|
1065
|
-
yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement });
|
|
1113
|
+
yield* this.#runQuery({ prompt: effectivePrompt, promptParts, messages, signal: runSignal, userEffort: effectiveUserEffort, scenario, vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted });
|
|
1066
1114
|
} finally {
|
|
1067
1115
|
if (signal) {
|
|
1068
1116
|
try { signal.removeEventListener('abort', onExternalAbort); } catch { /* ignore */ }
|
|
@@ -1080,7 +1128,7 @@ export class Engine {
|
|
|
1080
1128
|
* in a try/finally without indenting the whole loop.
|
|
1081
1129
|
* @private
|
|
1082
1130
|
*/
|
|
1083
|
-
async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement }) {
|
|
1131
|
+
async *#runQuery({ prompt, promptParts = null, messages, signal, userEffort = null, scenario = 'chat', vpPersona, router, senderVpId, inboundEnvelope, taskId, taskMembers, groupId, vpPlan, groupAnnouncement, userAlreadyPersisted = false }) {
|
|
1084
1132
|
|
|
1085
1133
|
// ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
|
|
1086
1134
|
// Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
|
|
@@ -1691,6 +1739,11 @@ export class Engine {
|
|
|
1691
1739
|
// Bug 6: tag persisted messages with the originating group so
|
|
1692
1740
|
// history replay can re-stamp them on reload.
|
|
1693
1741
|
groupId,
|
|
1742
|
+
// Multi-VP fan-out (history-dedup): skip the user-row append
|
|
1743
|
+
// in stop-hooks when the orchestrator already wrote it once
|
|
1744
|
+
// for this turn. The hook still persists assistant + tool
|
|
1745
|
+
// rows for THIS VP's contribution.
|
|
1746
|
+
userAlreadyPersisted,
|
|
1694
1747
|
});
|
|
1695
1748
|
|
|
1696
1749
|
if (hookResult.consolidated) {
|
|
@@ -1698,7 +1751,7 @@ export class Engine {
|
|
|
1698
1751
|
}
|
|
1699
1752
|
} else {
|
|
1700
1753
|
// Legacy path (no yeaftDir → use old behavior)
|
|
1701
|
-
this.#persistMessages(prompt, fullResponseText, assistantMsg.toolCalls, groupId);
|
|
1754
|
+
this.#persistMessages(prompt, fullResponseText, assistantMsg.toolCalls, groupId, userAlreadyPersisted);
|
|
1702
1755
|
|
|
1703
1756
|
const consolidated = await this.#maybeConsolidate();
|
|
1704
1757
|
if (consolidated && consolidated.archivedCount > 0) {
|
|
@@ -22,6 +22,42 @@ import { parseRoleMd } from '../vp/vp-store.js';
|
|
|
22
22
|
|
|
23
23
|
const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Marker stamped into every VP summary written by this module.
|
|
27
|
+
*
|
|
28
|
+
* Two consumers care about it:
|
|
29
|
+
* 1. `engine.#prepareAms` uses `isVpSeedBackfillStub` to skip the
|
|
30
|
+
* `vp/<ownVpId>` Resident entry when its summary is just our stub —
|
|
31
|
+
* the real persona is already rendered as Section 1 of the system
|
|
32
|
+
* prompt by `renderVpPersona`. Without the skip, AMS Resident dups
|
|
33
|
+
* Section 1 with redundant `name + role` labels.
|
|
34
|
+
* 2. `migrateLegacyVpSummaries` uses absence-of-marker + presence of
|
|
35
|
+
* `**Persona:**` to identify pre-fix summary.md files (which copied
|
|
36
|
+
* up to 800 chars of `role.md` body) and rewrite them as stubs.
|
|
37
|
+
*
|
|
38
|
+
* Bump the version suffix when the stub format changes meaningfully so
|
|
39
|
+
* old stamps can be re-migrated if needed.
|
|
40
|
+
*/
|
|
41
|
+
export const VP_STUB_MARKER = '<!-- seed-backfill:vp-stub v1 -->';
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* True iff the given summary text was produced by this module's VP stub
|
|
45
|
+
* writer (i.e. carries the marker comment). Whitespace-tolerant.
|
|
46
|
+
*
|
|
47
|
+
* Used by `engine.#prepareAms` to decide whether to surface the
|
|
48
|
+
* `vp/<ownVpId>` summary as a Resident AMS entry. Stubs are skipped so
|
|
49
|
+
* Section 1 (`renderVpPersona`) is the sole rendering of own-VP identity;
|
|
50
|
+
* Dream-v2's eventual real summary will lack the marker and be surfaced
|
|
51
|
+
* normally.
|
|
52
|
+
*
|
|
53
|
+
* @param {string|null|undefined} text
|
|
54
|
+
* @returns {boolean}
|
|
55
|
+
*/
|
|
56
|
+
export function isVpSeedBackfillStub(text) {
|
|
57
|
+
if (typeof text !== 'string' || text.length === 0) return false;
|
|
58
|
+
return text.includes(VP_STUB_MARKER);
|
|
59
|
+
}
|
|
60
|
+
|
|
25
61
|
function readIfPresent(path) {
|
|
26
62
|
try {
|
|
27
63
|
if (!existsSync(path)) return '';
|
|
@@ -39,10 +75,25 @@ function writeAtomicSync(path, body) {
|
|
|
39
75
|
/**
|
|
40
76
|
* Build a synthetic VP summary from the on-disk role.md.
|
|
41
77
|
*
|
|
78
|
+
* IMPORTANT — this is a STUB that lives until Dream-v2 writes a real
|
|
79
|
+
* per-scope summary. Earlier versions copied up to 800 chars of the
|
|
80
|
+
* `role.md` body into `summary.md`. That body is *also* rendered as
|
|
81
|
+
* Section 1 of the system prompt (`renderVpPersona` in `prompts.js`),
|
|
82
|
+
* so the same persona text reappeared in `## Active Memory Set →
|
|
83
|
+
* Resident → vp/<id>` — the user-visible "Why is the persona defined
|
|
84
|
+
* twice?" bug.
|
|
85
|
+
*
|
|
86
|
+
* The summary.md placeholder is therefore deliberately minimal: just
|
|
87
|
+
* the VP's display name + role label. Layer-A AMS still sees a
|
|
88
|
+
* non-empty `vp/<id>` resident entry (so adjust/recall scope wiring
|
|
89
|
+
* stays unchanged), but the persona body is rendered exactly once,
|
|
90
|
+
* by Section 1.
|
|
91
|
+
*
|
|
92
|
+
* Once Dream-v2 produces a real summary for this scope it overwrites
|
|
93
|
+
* this stub — see `idempotency` note at the top of the file.
|
|
94
|
+
*
|
|
42
95
|
* Delegates frontmatter parsing to `vp-store.js#parseRoleMd` so the
|
|
43
|
-
* backfill stays in sync with the production loader.
|
|
44
|
-
* rolled regex parser silently dropped quoted multi-line scalars and
|
|
45
|
-
* list-shaped fields — `parseRoleMd` covers both.
|
|
96
|
+
* backfill stays in sync with the production loader.
|
|
46
97
|
*
|
|
47
98
|
* @param {string} libDir
|
|
48
99
|
* @param {string} vpId
|
|
@@ -54,17 +105,12 @@ function readVpRoleSummary(libDir, vpId) {
|
|
|
54
105
|
let raw = '';
|
|
55
106
|
try { raw = readFileSync(rolePath, 'utf-8'); } catch { return null; }
|
|
56
107
|
|
|
57
|
-
const { meta
|
|
108
|
+
const { meta } = parseRoleMd(raw);
|
|
58
109
|
const name = String(meta.name || vpId).trim() || vpId;
|
|
59
110
|
const role = typeof meta.role === 'string' ? meta.role.trim() : '';
|
|
60
111
|
|
|
61
|
-
const
|
|
62
|
-
const lines = [`# ${name}`];
|
|
112
|
+
const lines = [VP_STUB_MARKER, '', `# ${name}`];
|
|
63
113
|
if (role) lines.push('', `**Role:** ${role}`);
|
|
64
|
-
if (persona) {
|
|
65
|
-
const truncated = persona.length > 800 ? persona.slice(0, 800).trim() + '…' : persona;
|
|
66
|
-
lines.push('', '**Persona:**', '', truncated);
|
|
67
|
-
}
|
|
68
114
|
return lines.join('\n').trim();
|
|
69
115
|
}
|
|
70
116
|
|
|
@@ -123,6 +169,64 @@ function readGroupSummaryBody(groupDir) {
|
|
|
123
169
|
return lines.join('\n').trim();
|
|
124
170
|
}
|
|
125
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Detect the *legacy* (pre-stamp) VP summary shape: a body that lacks
|
|
174
|
+
* `VP_STUB_MARKER` AND contains the `**Persona:**` block written by the
|
|
175
|
+
* older stub. Tight signature on purpose — we don't want to clobber
|
|
176
|
+
* hand-edited or Dream-v2-produced summaries that happen to be missing
|
|
177
|
+
* the marker for unrelated reasons.
|
|
178
|
+
*
|
|
179
|
+
* @param {string} body
|
|
180
|
+
* @returns {boolean}
|
|
181
|
+
*/
|
|
182
|
+
function isLegacyVpSummary(body) {
|
|
183
|
+
if (typeof body !== 'string' || body.length === 0) return false;
|
|
184
|
+
if (body.includes(VP_STUB_MARKER)) return false;
|
|
185
|
+
return body.includes('**Persona:**');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* One-shot migration: walk `<root>/vp/<id>/summary.md` and rewrite any
|
|
190
|
+
* file matching the legacy shape (`isLegacyVpSummary`) into the current
|
|
191
|
+
* stamped stub. Idempotent — a stamped or Dream-v2-produced file is left
|
|
192
|
+
* untouched. Safe to run on every session boot.
|
|
193
|
+
*
|
|
194
|
+
* Existing users whose `summary.md` was written by the pre-stamp stub
|
|
195
|
+
* carry the persona body forever, because `backfillVpSummaries` only
|
|
196
|
+
* writes when the file is empty/missing. This pass closes that gap.
|
|
197
|
+
*
|
|
198
|
+
* @param {{ libDir: string, root?: string }} opts
|
|
199
|
+
* @returns {{ scanned: number, migrated: number }}
|
|
200
|
+
*/
|
|
201
|
+
export function migrateLegacyVpSummaries({ libDir, root = DEFAULT_MEMORY_ROOT }) {
|
|
202
|
+
let scanned = 0;
|
|
203
|
+
let migrated = 0;
|
|
204
|
+
const vpRoot = join(root, 'vp');
|
|
205
|
+
if (!existsSync(vpRoot)) return { scanned, migrated };
|
|
206
|
+
let entries;
|
|
207
|
+
try { entries = readdirSync(vpRoot); } catch { return { scanned, migrated }; }
|
|
208
|
+
for (const name of entries) {
|
|
209
|
+
if (name.startsWith('.')) continue;
|
|
210
|
+
const summaryPath = join(vpRoot, name, 'summary.md');
|
|
211
|
+
let body = '';
|
|
212
|
+
try {
|
|
213
|
+
if (!existsSync(summaryPath)) continue;
|
|
214
|
+
body = readFileSync(summaryPath, 'utf-8');
|
|
215
|
+
} catch { continue; }
|
|
216
|
+
scanned++;
|
|
217
|
+
if (!isLegacyVpSummary(body)) continue;
|
|
218
|
+
const stub = readVpRoleSummary(libDir, name);
|
|
219
|
+
if (!stub) continue;
|
|
220
|
+
try {
|
|
221
|
+
writeAtomicSync(summaryPath, stub);
|
|
222
|
+
migrated++;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
console.warn(`[seed-backfill] migrate vp ${name}: ${err?.message || err}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { scanned, migrated };
|
|
228
|
+
}
|
|
229
|
+
|
|
126
230
|
/**
|
|
127
231
|
* Walk groups/ and seed `summary.md` for every group without one.
|
|
128
232
|
*
|
|
@@ -161,17 +265,30 @@ export function backfillGroupSummaries({ yeaftDir, root = DEFAULT_MEMORY_ROOT })
|
|
|
161
265
|
* Run all backfills sequentially. Best-effort — any per-step error is
|
|
162
266
|
* logged and the next step still runs.
|
|
163
267
|
*
|
|
268
|
+
* Order:
|
|
269
|
+
* 1. Migrate legacy VP summaries (rewrite pre-stamp persona-body stubs
|
|
270
|
+
* to current-format stamped stubs). Runs FIRST so that
|
|
271
|
+
* `backfillVpSummaries` sees consistent on-disk state and any
|
|
272
|
+
* future logic that distinguishes "stamped" vs "free-form" works
|
|
273
|
+
* uniformly downstream.
|
|
274
|
+
* 2. Backfill missing VP summaries.
|
|
275
|
+
* 3. Backfill missing group summaries.
|
|
276
|
+
*
|
|
164
277
|
* @param {{ yeaftDir: string, libDir: string, root?: string }} opts
|
|
165
|
-
* @returns {{ vp: {scanned:number, seeded:number}, group: {scanned:number, seeded:number} }}
|
|
278
|
+
* @returns {{ migrate: {scanned:number, migrated:number}, vp: {scanned:number, seeded:number}, group: {scanned:number, seeded:number} }}
|
|
166
279
|
*/
|
|
167
280
|
export function runSummaryBackfill({ yeaftDir, libDir, root = DEFAULT_MEMORY_ROOT }) {
|
|
281
|
+
let migrate = { scanned: 0, migrated: 0 };
|
|
168
282
|
let vp = { scanned: 0, seeded: 0 };
|
|
169
283
|
let group = { scanned: 0, seeded: 0 };
|
|
284
|
+
try { migrate = migrateLegacyVpSummaries({ libDir, root }); } catch (err) {
|
|
285
|
+
console.warn('[seed-backfill] vp migrate failed:', err?.message || err);
|
|
286
|
+
}
|
|
170
287
|
try { vp = backfillVpSummaries({ libDir, root }); } catch (err) {
|
|
171
288
|
console.warn('[seed-backfill] vp pass failed:', err?.message || err);
|
|
172
289
|
}
|
|
173
290
|
try { group = backfillGroupSummaries({ yeaftDir, root }); } catch (err) {
|
|
174
291
|
console.warn('[seed-backfill] group pass failed:', err?.message || err);
|
|
175
292
|
}
|
|
176
|
-
return { vp, group };
|
|
293
|
+
return { migrate, vp, group };
|
|
177
294
|
}
|
package/unify/stop-hooks.js
CHANGED
|
@@ -51,6 +51,11 @@ export async function runStopHooks(context) {
|
|
|
51
51
|
// history replay can route messages back into the originating group.
|
|
52
52
|
groupId,
|
|
53
53
|
threadId,
|
|
54
|
+
// Multi-VP fan-out (history-dedup): when several engines run the
|
|
55
|
+
// same user prompt in parallel, the orchestrator persists the user
|
|
56
|
+
// row exactly once before fan-out. Each VP's stop-hook then skips
|
|
57
|
+
// the user record but still writes its own assistant + tool rows.
|
|
58
|
+
userAlreadyPersisted = false,
|
|
54
59
|
} = context;
|
|
55
60
|
|
|
56
61
|
// Model name for persisted messages: use primaryModel if provided, else config.model
|
|
@@ -90,6 +95,11 @@ export async function runStopHooks(context) {
|
|
|
90
95
|
const recentMessages = messages.slice(turnStart);
|
|
91
96
|
for (const msg of recentMessages) {
|
|
92
97
|
if (!msg || !msg.role) continue;
|
|
98
|
+
// Skip the user row if the orchestrator already wrote it once for
|
|
99
|
+
// this turn (multi-VP fan-out: every VP's engine sees the same
|
|
100
|
+
// user prompt at conversationMessages[turnStart] but only the
|
|
101
|
+
// first writer should land on disk).
|
|
102
|
+
if (userAlreadyPersisted && msg.role === 'user') continue;
|
|
93
103
|
// Allow empty assistant content when toolCalls are present;
|
|
94
104
|
// tool messages have content by construction.
|
|
95
105
|
const hasContent =
|
package/unify/vp/vp-crud.js
CHANGED
|
@@ -23,6 +23,7 @@ import { homedir } from 'os';
|
|
|
23
23
|
import { validateVpId } from '../groups/ids.js';
|
|
24
24
|
import { DEFAULT_VP_LIB_DIR, parseRoleMd } from './vp-store.js';
|
|
25
25
|
import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store-v2.js';
|
|
26
|
+
import { VP_STUB_MARKER } from '../memory/seed-backfill.js';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* Default memory root used when callers don't pass `options.memoryRoot`.
|
|
@@ -40,26 +41,36 @@ const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
|
|
|
40
41
|
* @param {object} payload same shape as createVp
|
|
41
42
|
* @returns {string}
|
|
42
43
|
*/
|
|
44
|
+
/**
|
|
45
|
+
* Build the seed body for a freshly-created VP's `<root>/vp/<id>/summary.md`.
|
|
46
|
+
*
|
|
47
|
+
* IMPORTANT — this is a STUB that mirrors `seed-backfill.js#readVpRoleSummary`.
|
|
48
|
+
* Earlier versions of both writers embedded up to 800 chars of `persona`
|
|
49
|
+
* here. That body is *also* rendered as Section 1 of the system prompt by
|
|
50
|
+
* `renderVpPersona`, so the same persona text reappeared in the AMS
|
|
51
|
+
* Resident block — the user-visible "persona defined twice" bug. PR #722
|
|
52
|
+
* fixed `seed-backfill.js`; this writer is the create-time twin.
|
|
53
|
+
*
|
|
54
|
+
* The seed is therefore deliberately minimal (name + role + traits) and
|
|
55
|
+
* stamped with `VP_STUB_MARKER` so `engine.buildResidentEntries` knows to
|
|
56
|
+
* skip the own-VP Resident push (Section 1 is already the source of truth
|
|
57
|
+
* for own-VP identity). Once Dream-v2 writes a real summary it overwrites
|
|
58
|
+
* this stub and lacks the marker, so it surfaces normally.
|
|
59
|
+
*
|
|
60
|
+
* @param {object} payload same shape as createVp
|
|
61
|
+
* @returns {string}
|
|
62
|
+
*/
|
|
43
63
|
export function buildVpSeedSummary(payload) {
|
|
44
64
|
const id = String(payload?.vpId || '').trim();
|
|
45
65
|
const name = (payload?.displayName != null ? String(payload.displayName) : id).trim();
|
|
46
66
|
const role = (payload?.role != null ? String(payload.role) : '').trim();
|
|
47
|
-
const persona = (typeof payload?.persona === 'string' ? payload.persona : '').trim();
|
|
48
67
|
const traits = Array.isArray(payload?.traits)
|
|
49
68
|
? payload.traits.map(t => String(t)).filter(Boolean)
|
|
50
69
|
: [];
|
|
51
70
|
|
|
52
|
-
const lines = [];
|
|
53
|
-
lines.push(`# ${name}`);
|
|
71
|
+
const lines = [VP_STUB_MARKER, '', `# ${name}`];
|
|
54
72
|
if (role) lines.push('', `**Role:** ${role}`);
|
|
55
73
|
if (traits.length > 0) lines.push('', `**Traits:** ${traits.join(', ')}`);
|
|
56
|
-
if (persona) {
|
|
57
|
-
// Keep the persona body terse — first 800 chars is plenty for an
|
|
58
|
-
// initial Layer-A resident summary; Dream-v2 will rewrite it as
|
|
59
|
-
// memory accumulates.
|
|
60
|
-
const truncated = persona.length > 800 ? persona.slice(0, 800).trim() + '…' : persona;
|
|
61
|
-
lines.push('', '**Persona:**', '', truncated);
|
|
62
|
-
}
|
|
63
74
|
return lines.join('\n').trim();
|
|
64
75
|
}
|
|
65
76
|
|
package/unify/web-bridge.js
CHANGED
|
@@ -399,6 +399,30 @@ function ensureDriverRunning(groupId, vpId) {
|
|
|
399
399
|
const promptParts = inboundParts.length > 0
|
|
400
400
|
? [...inboundParts, { type: 'text', text: prompt }]
|
|
401
401
|
: null;
|
|
402
|
+
|
|
403
|
+
// Multi-VP fan-out — history dedup. Persist the user row exactly
|
|
404
|
+
// once per envelope (keyed by coordinator-minted msg.id). The
|
|
405
|
+
// first driver to pick up an envelope writes; later drivers
|
|
406
|
+
// (other VPs in the same fan-out, or downstream route_forward
|
|
407
|
+
// targets sharing an msg.id) become no-ops. Each VP's engine
|
|
408
|
+
// runs with `userAlreadyPersisted: true` so its stop-hook never
|
|
409
|
+
// tries to write the user row a second time.
|
|
410
|
+
//
|
|
411
|
+
// Belt-and-suspenders against the handleUnifyGroupChat call: in
|
|
412
|
+
// the common path that one runs first and this is a no-op; in
|
|
413
|
+
// edge paths (route_forward without an upstream user write) this
|
|
414
|
+
// is the writer.
|
|
415
|
+
try {
|
|
416
|
+
const envMsgId = envelope?.msg?.id;
|
|
417
|
+
if (envMsgId && text) {
|
|
418
|
+
persistUserMessageOnceByMsgId({
|
|
419
|
+
msgId: envMsgId,
|
|
420
|
+
text,
|
|
421
|
+
groupId,
|
|
422
|
+
});
|
|
423
|
+
}
|
|
424
|
+
} catch { /* never crash WS pipeline */ }
|
|
425
|
+
|
|
402
426
|
try {
|
|
403
427
|
await runVpTurn({
|
|
404
428
|
prompt,
|
|
@@ -1411,6 +1435,36 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1411
1435
|
return;
|
|
1412
1436
|
}
|
|
1413
1437
|
|
|
1438
|
+
// Multi-VP fan-out — history dedup (PR-fix-unify-group-history-dedup):
|
|
1439
|
+
// persist the user row EXACTLY ONCE per turn. We do it AFTER
|
|
1440
|
+
// `coord.ingest` so we can key dedup on the coordinator-minted
|
|
1441
|
+
// `report.message.id` — the same id the per-VP driver will see on
|
|
1442
|
+
// `envelope.msg.id`, which lets a route_forward injection that lands
|
|
1443
|
+
// on the same id be a no-op.
|
|
1444
|
+
//
|
|
1445
|
+
// Each VP's engine then runs with `userAlreadyPersisted: true` (see
|
|
1446
|
+
// runVpTurn's vpEngine.query call) so its stop-hook skips the
|
|
1447
|
+
// user-row append while still writing assistant + tool rows.
|
|
1448
|
+
//
|
|
1449
|
+
// We persist the canonical `text` (no `@vp-X ` prefix) because that's
|
|
1450
|
+
// what the user actually typed. Clean text matches what `loadHistory`
|
|
1451
|
+
// replays back to the frontend on refresh.
|
|
1452
|
+
try {
|
|
1453
|
+
const persistedMsgId = report?.message?.id;
|
|
1454
|
+
if (persistedMsgId) {
|
|
1455
|
+
persistUserMessageOnceByMsgId({
|
|
1456
|
+
msgId: persistedMsgId,
|
|
1457
|
+
text,
|
|
1458
|
+
groupId,
|
|
1459
|
+
});
|
|
1460
|
+
}
|
|
1461
|
+
} catch (err) {
|
|
1462
|
+
console.warn(
|
|
1463
|
+
'[Unify] unify_group_chat: persistUserMessageOnceByMsgId failed',
|
|
1464
|
+
err?.message || err,
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1414
1468
|
const dispatchedIds = Array.isArray(report?.dispatched) ? report.dispatched : [];
|
|
1415
1469
|
const fallbackId = typeof report?.fallback === 'string' ? report.fallback : null;
|
|
1416
1470
|
if (dispatchedIds.length === 0 && !fallbackId) {
|
|
@@ -1792,6 +1846,14 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1792
1846
|
promptParts,
|
|
1793
1847
|
messages: trimmedMessages,
|
|
1794
1848
|
signal: vpAbort.signal,
|
|
1849
|
+
// Multi-VP fan-out (history-dedup): the user row was persisted
|
|
1850
|
+
// ONCE by handleUnifyGroupChat → persistUserMessageOnce before
|
|
1851
|
+
// fan-out. Tell the engine's stop-hook to skip the user-row
|
|
1852
|
+
// append for THIS VP's turn (it still writes assistant + tool
|
|
1853
|
+
// rows for this VP). Without this the magnet of N engines would
|
|
1854
|
+
// each write a copy of the user message, and history replay
|
|
1855
|
+
// would render the user's prompt N times.
|
|
1856
|
+
userAlreadyPersisted: true,
|
|
1795
1857
|
...queryOpts,
|
|
1796
1858
|
})) {
|
|
1797
1859
|
resetQueryTimer();
|
|
@@ -1916,6 +1978,87 @@ function appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolRes
|
|
|
1916
1978
|
}
|
|
1917
1979
|
}
|
|
1918
1980
|
|
|
1981
|
+
/**
|
|
1982
|
+
* Persist the user row to disk EXACTLY ONCE per coordinator-ingest call,
|
|
1983
|
+
* keyed by the coordinator-assigned `msgId`. Both `handleUnifyGroupChat`
|
|
1984
|
+
* (real user input) and `enqueueForVp`'s driver loop (route_forward
|
|
1985
|
+
* synthetic injections) call this — the Set guard makes either path
|
|
1986
|
+
* the writer, whichever runs first, while the other becomes a no-op.
|
|
1987
|
+
*
|
|
1988
|
+
* Without this dedup, a 2-VP group prompt produces TWO `m{NNNN}.md`
|
|
1989
|
+
* user rows (one per engine) — `handleUnifyLoadHistory` then replays
|
|
1990
|
+
* the user's prompt twice and sandwiches one VP's reply between two
|
|
1991
|
+
* copies of the user message. Visually this reads as "messages out of
|
|
1992
|
+
* order" because the second copy of the user prompt sits BETWEEN the
|
|
1993
|
+
* two VPs' replies.
|
|
1994
|
+
*
|
|
1995
|
+
* Best-effort: a write failure does NOT abort the turn — engines can
|
|
1996
|
+
* still run, and the next user message will trigger another append.
|
|
1997
|
+
*
|
|
1998
|
+
* Note: we mirror `engine.#persistMessages`'s schema for the user row
|
|
1999
|
+
* exactly (role/content/threadId/groupId), so existing parsers /
|
|
2000
|
+
* loaders see no schema drift. Attachments are not part of the on-disk
|
|
2001
|
+
* schema today (the engine never wrote them either) — they live on the
|
|
2002
|
+
* coordinator's jsonl-log under group meta.
|
|
2003
|
+
*
|
|
2004
|
+
* @param {{ msgId:string, text:string, groupId:string }} args
|
|
2005
|
+
* @returns {boolean} true if this call wrote the row, false if a prior
|
|
2006
|
+
* call already wrote it (dedup hit).
|
|
2007
|
+
*/
|
|
2008
|
+
function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
|
|
2009
|
+
if (!session?.conversationStore) return false;
|
|
2010
|
+
// No msgId means no dedup key — caller is responsible for guarding.
|
|
2011
|
+
// Both call sites already do (`if (envMsgId && text)` and
|
|
2012
|
+
// `if (persistedMsgId)`); refusing here keeps the helper's contract
|
|
2013
|
+
// clean. A synthetic-id fallback (Date.now+random) would defeat dedup —
|
|
2014
|
+
// every call would mint a unique id and write a duplicate row, which
|
|
2015
|
+
// is the exact bug this helper exists to prevent.
|
|
2016
|
+
if (!msgId || typeof msgId !== 'string') return false;
|
|
2017
|
+
if (_persistedUserMsgIds.has(msgId)) return false;
|
|
2018
|
+
// Mark BEFORE the empty-text bail. If a later same-id call arrives
|
|
2019
|
+
// with non-empty text (e.g. a route_forward injection that the first
|
|
2020
|
+
// caller passed in with empty text), the Set must already remember
|
|
2021
|
+
// this id so the second call dedups instead of writing.
|
|
2022
|
+
_persistedUserMsgIds.add(msgId);
|
|
2023
|
+
if (!text || typeof text !== 'string') return false;
|
|
2024
|
+
// Bound the Set so it doesn't grow unbounded over a long session.
|
|
2025
|
+
// 4096 msg-ids is well past any realistic "messages in flight"
|
|
2026
|
+
// window — once N drivers have observed the id, the rest can fall
|
|
2027
|
+
// back to "write again" without harm (the second writer would be a
|
|
2028
|
+
// duplicate, but it requires both: (a) the Set evicting an id AND
|
|
2029
|
+
// (b) a still-running driver getting around to its first persist).
|
|
2030
|
+
if (_persistedUserMsgIds.size > 4096) {
|
|
2031
|
+
const iter = _persistedUserMsgIds.values();
|
|
2032
|
+
for (let i = 0; i < 1024; i++) {
|
|
2033
|
+
const v = iter.next();
|
|
2034
|
+
if (v.done) break;
|
|
2035
|
+
_persistedUserMsgIds.delete(v.value);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
try {
|
|
2039
|
+
const record = {
|
|
2040
|
+
role: 'user',
|
|
2041
|
+
content: text,
|
|
2042
|
+
threadId: 'main',
|
|
2043
|
+
};
|
|
2044
|
+
if (groupId) record.groupId = groupId;
|
|
2045
|
+
session.conversationStore.append(record);
|
|
2046
|
+
return true;
|
|
2047
|
+
} catch (err) {
|
|
2048
|
+
console.warn(
|
|
2049
|
+
'[Unify] persistUserMessageOnceByMsgId failed (non-fatal):',
|
|
2050
|
+
err?.message || err,
|
|
2051
|
+
);
|
|
2052
|
+
return false;
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
/**
|
|
2057
|
+
* Cleared on session reset (resetUnifySession) so a fresh session
|
|
2058
|
+
* starts with no stale msg-ids.
|
|
2059
|
+
*/
|
|
2060
|
+
const _persistedUserMsgIds = new Set();
|
|
2061
|
+
|
|
1919
2062
|
/**
|
|
1920
2063
|
* In-flight compact promise. Set by `scheduleCompactAfterTurn` when a
|
|
1921
2064
|
* turn ends and triggers compaction; awaited by the next
|
|
@@ -2501,6 +2644,9 @@ export async function resetUnifySession() {
|
|
|
2501
2644
|
vpDrivers.clear();
|
|
2502
2645
|
vpEngines.clear();
|
|
2503
2646
|
groupContexts.clear();
|
|
2647
|
+
// History-dedup cache is keyed by per-session coordinator msg ids;
|
|
2648
|
+
// a fresh session resets the id space, so clear the cache too.
|
|
2649
|
+
_persistedUserMsgIds.clear();
|
|
2504
2650
|
|
|
2505
2651
|
try {
|
|
2506
2652
|
const yeaftDir = ctx.CONFIG?.yeaftDir;
|