@yeaft/webchat-agent 0.1.747 → 0.1.748
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 +11 -0
- package/unify/stop-hooks.js +51 -12
- package/unify/web-bridge.js +322 -105
package/package.json
CHANGED
package/unify/engine.js
CHANGED
|
@@ -1755,6 +1755,17 @@ export class Engine {
|
|
|
1755
1755
|
config: this.#fastConfig,
|
|
1756
1756
|
primaryModel: this.#config.model,
|
|
1757
1757
|
messages: conversationMessages,
|
|
1758
|
+
// Reflect-persist fix: tell stop-hooks the EXACT turn boundary
|
|
1759
|
+
// instead of letting it heuristically scan back to the last
|
|
1760
|
+
// role:'user'. With T1/T2 reflection collapse, the last
|
|
1761
|
+
// role:'user' is the synthetic reflection message — not the
|
|
1762
|
+
// original user prompt — so the heuristic was dropping
|
|
1763
|
+
// earlier reflection messages and the original prompt off
|
|
1764
|
+
// the persistence window. `turnStartIdx` is the index of
|
|
1765
|
+
// the original user prompt (set at query() entry); slicing
|
|
1766
|
+
// from there persists the full collapsed turn including all
|
|
1767
|
+
// reflection messages and the trailing assistant response.
|
|
1768
|
+
turnStartIdx,
|
|
1758
1769
|
trace: this.#trace,
|
|
1759
1770
|
// Bug 6: tag persisted messages with the originating group so
|
|
1760
1771
|
// history replay can re-stamp them on reload.
|
package/unify/stop-hooks.js
CHANGED
|
@@ -30,6 +30,7 @@ let _permissionWarned = false;
|
|
|
30
30
|
* config: object,
|
|
31
31
|
* primaryModel?: string,
|
|
32
32
|
* messages?: object[],
|
|
33
|
+
* turnStartIdx?: number,
|
|
33
34
|
* taskId?: string,
|
|
34
35
|
* workerId?: string,
|
|
35
36
|
* trace?: object,
|
|
@@ -45,6 +46,19 @@ export async function runStopHooks(context) {
|
|
|
45
46
|
config,
|
|
46
47
|
primaryModel,
|
|
47
48
|
messages = [],
|
|
49
|
+
// Reflect-persist fix: when the engine knows the exact turn boundary
|
|
50
|
+
// (it does — `turnStartIdx` is set at query() entry as
|
|
51
|
+
// `conversationMessages.length - 1`), pass it in. The legacy
|
|
52
|
+
// heuristic of "scan back for the last role:'user'" is wrong once
|
|
53
|
+
// T1/T2 reflection has collapsed the tool arc into a synthetic
|
|
54
|
+
// role:'user' message — that synthetic message would be picked as
|
|
55
|
+
// the turn start, dropping the original prompt AND any earlier
|
|
56
|
+
// reflection messages from the persistence window.
|
|
57
|
+
//
|
|
58
|
+
// When undefined, falls back to the legacy heuristic so older
|
|
59
|
+
// callers (sub-agents, workers) that don't pass it continue to
|
|
60
|
+
// work.
|
|
61
|
+
turnStartIdx,
|
|
48
62
|
taskId,
|
|
49
63
|
trace,
|
|
50
64
|
// Bug 6: groupId/threadId stamped on every persisted message so
|
|
@@ -77,19 +91,33 @@ export async function runStopHooks(context) {
|
|
|
77
91
|
// assistant's `toolCalls` and each paired `role:'tool'` result —
|
|
78
92
|
// otherwise restoring history on session reload drops the pairing
|
|
79
93
|
// and causes "No tool output found for function call" 400s on the
|
|
80
|
-
// next chat-completions request.
|
|
81
|
-
//
|
|
82
|
-
//
|
|
94
|
+
// next chat-completions request.
|
|
95
|
+
//
|
|
96
|
+
// Reflect-persist fix: prefer the explicit `turnStartIdx` from the
|
|
97
|
+
// engine when given. The legacy heuristic of "find the last
|
|
98
|
+
// role:'user'" is broken in the presence of T1/T2 reflection
|
|
99
|
+
// collapse, because the collapsed reflection is itself a
|
|
100
|
+
// role:'user' message — using it as the turn start would drop the
|
|
101
|
+
// real user prompt and any earlier reflections.
|
|
83
102
|
try {
|
|
84
103
|
if (conversationStore && messages.length > 0) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
104
|
+
let turnStart;
|
|
105
|
+
if (typeof turnStartIdx === 'number'
|
|
106
|
+
&& Number.isFinite(turnStartIdx)
|
|
107
|
+
&& turnStartIdx >= 0
|
|
108
|
+
&& turnStartIdx < messages.length) {
|
|
109
|
+
// Engine-supplied exact turn boundary — preferred.
|
|
110
|
+
turnStart = turnStartIdx;
|
|
111
|
+
} else {
|
|
112
|
+
// Legacy heuristic — find the last role:'user' message.
|
|
113
|
+
// Used by sub-agent / worker callers that don't compute the
|
|
114
|
+
// boundary explicitly.
|
|
115
|
+
turnStart = messages.length - 1;
|
|
116
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
117
|
+
if (messages[i] && messages[i].role === 'user') {
|
|
118
|
+
turnStart = i;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
93
121
|
}
|
|
94
122
|
}
|
|
95
123
|
const recentMessages = messages.slice(turnStart);
|
|
@@ -99,7 +127,18 @@ export async function runStopHooks(context) {
|
|
|
99
127
|
// this turn (multi-VP fan-out: every VP's engine sees the same
|
|
100
128
|
// user prompt at conversationMessages[turnStart] but only the
|
|
101
129
|
// first writer should land on disk).
|
|
102
|
-
|
|
130
|
+
//
|
|
131
|
+
// With the engine-supplied turnStartIdx, `messages[turnStart]`
|
|
132
|
+
// is the ORIGINAL user prompt (not a reflection placeholder),
|
|
133
|
+
// so the first message in `recentMessages` is the one that
|
|
134
|
+
// gets skipped on subsequent VPs. Reflection messages that
|
|
135
|
+
// come AFTER turnStart still have role:'user' — they are NOT
|
|
136
|
+
// skipped because `userAlreadyPersisted` only suppresses the
|
|
137
|
+
// first user-prompt copy; reflections are per-VP outputs and
|
|
138
|
+
// each VP's reflections are valid contributions to the
|
|
139
|
+
// shared history (this matches today's per-VP fan-out where
|
|
140
|
+
// each VP appends its own assistant + tool rows).
|
|
141
|
+
if (userAlreadyPersisted && msg.role === 'user' && msg === recentMessages[0]) continue;
|
|
103
142
|
// Allow empty assistant content when toolCalls are present;
|
|
104
143
|
// tool messages have content by construction.
|
|
105
144
|
const hasContent =
|
package/unify/web-bridge.js
CHANGED
|
@@ -236,36 +236,192 @@ let unifyConversationId = null;
|
|
|
236
236
|
let _vpUnsubscribe = null;
|
|
237
237
|
|
|
238
238
|
/**
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
239
|
+
* Per-group conversation history lives on the GroupContext entry
|
|
240
|
+
* (`groupContexts.get(groupId).history`). The pre-refactor module-level
|
|
241
|
+
* `conversationMessages` was a single array shared across every group —
|
|
242
|
+
* a user prompt in group-A would leak into group-B's next-turn snapshot
|
|
243
|
+
* because the bridge appended every turn to the same array regardless
|
|
244
|
+
* of which group it belonged to. Disk was group-tagged correctly, but
|
|
245
|
+
* the in-memory tape was unified.
|
|
246
|
+
*
|
|
247
|
+
* Post-refactor: each GroupContext owns its own `history`, lazily
|
|
248
|
+
* hydrated from `conversationStore.loadRecentByGroup(groupId)` on first
|
|
249
|
+
* access. Group-A and group-B are isolated.
|
|
250
|
+
*
|
|
251
|
+
* @typedef {Array<{role:'user'|'assistant'|'tool', content:string|Array, toolCalls?:Array, toolCallId?:string, isError?:boolean}>} GroupHistory
|
|
252
|
+
*/
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* @typedef {Object} GroupContextEntry
|
|
256
|
+
* @property {object|null} coord — group coordinator (lazily built by getOrCreateGroupContext)
|
|
257
|
+
* @property {object|null} router — message router (lazily built by getOrCreateGroupContext)
|
|
258
|
+
* @property {object|null} groupHandle — opened group handle (lazily built by getOrCreateGroupContext)
|
|
259
|
+
* @property {GroupHistory} history — per-group conversation tape
|
|
260
|
+
* @property {boolean} historyHydrated — true once history has been loaded
|
|
261
|
+
* from disk (or explicitly assigned). The flag is required because an
|
|
262
|
+
* empty array is legitimate post-consolidate / post-clear state and
|
|
263
|
+
* MUST NOT trigger a re-hydrate. Without the flag, a partial entry
|
|
264
|
+
* seeded by `getCompactState` (which only needs `_compact`) would
|
|
265
|
+
* short-circuit `getOrCreateGroupHistory` on truthy `[]` and skip
|
|
266
|
+
* the disk load.
|
|
267
|
+
* @property {{inFlight: Promise<void>|null, pending: boolean}} [_compact]
|
|
268
|
+
* per-group compact state, lazily attached.
|
|
242
269
|
*/
|
|
243
|
-
|
|
270
|
+
|
|
271
|
+
/** Build a fresh stub entry with no coord/router/history loaded. */
|
|
272
|
+
function makeGroupContextStub() {
|
|
273
|
+
return {
|
|
274
|
+
coord: null,
|
|
275
|
+
router: null,
|
|
276
|
+
groupHandle: null,
|
|
277
|
+
history: [],
|
|
278
|
+
historyHydrated: false,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
244
281
|
|
|
245
282
|
/**
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
* serialization includes paired tool messages
|
|
249
|
-
* for function call" 400s).
|
|
283
|
+
* Project a persisted message record into the in-memory history shape.
|
|
284
|
+
* Accepts `role:'tool'` and preserves `toolCalls`/`toolCallId` so the
|
|
285
|
+
* next chat-completions serialization includes paired tool messages
|
|
286
|
+
* (avoids "No tool output found for function call" 400s).
|
|
250
287
|
*
|
|
251
|
-
* @param {
|
|
288
|
+
* @param {object} m — record from conversationStore.loadRecent*()
|
|
289
|
+
* @returns {object|null} history-shape entry, or null to skip
|
|
252
290
|
*/
|
|
253
|
-
function
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
}));
|
|
265
|
-
}
|
|
266
|
-
if (m.isError) entry.isError = true;
|
|
267
|
-
conversationMessages.push(entry);
|
|
291
|
+
function projectPersistedToHistoryEntry(m) {
|
|
292
|
+
if (!m) return null;
|
|
293
|
+
if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') return null;
|
|
294
|
+
const entry = { role: m.role, content: m.content };
|
|
295
|
+
if (m.toolCallId) entry.toolCallId = m.toolCallId;
|
|
296
|
+
if (Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
|
|
297
|
+
entry.toolCalls = m.toolCalls.map(tc => ({
|
|
298
|
+
id: tc.id,
|
|
299
|
+
name: tc.name,
|
|
300
|
+
input: tc.input,
|
|
301
|
+
}));
|
|
268
302
|
}
|
|
303
|
+
if (m.isError) entry.isError = true;
|
|
304
|
+
return entry;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Hydrate a freshly-created GroupContext's history from the on-disk
|
|
309
|
+
* conversation store. Returns an empty array if the session isn't
|
|
310
|
+
* loaded yet (sub-agent / test paths) or if the load throws.
|
|
311
|
+
*
|
|
312
|
+
* @param {string} groupId
|
|
313
|
+
* @returns {GroupHistory}
|
|
314
|
+
*/
|
|
315
|
+
function hydrateGroupHistory(groupId) {
|
|
316
|
+
if (!session?.conversationStore || !groupId) return [];
|
|
317
|
+
let recent;
|
|
318
|
+
try {
|
|
319
|
+
recent = session.conversationStore.loadRecentByGroup(groupId);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
console.warn('[Unify] hydrateGroupHistory failed (groupId=%s):', groupId, err?.message || err);
|
|
322
|
+
return [];
|
|
323
|
+
}
|
|
324
|
+
const out = [];
|
|
325
|
+
for (const m of recent || []) {
|
|
326
|
+
const entry = projectPersistedToHistoryEntry(m);
|
|
327
|
+
if (entry) out.push(entry);
|
|
328
|
+
}
|
|
329
|
+
return out;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get-or-create the per-group history array. Used everywhere the bridge
|
|
334
|
+
* needs to read/append/snapshot a group's conversation tape. Lazily
|
|
335
|
+
* inserts an entry into `groupContexts` on first access — no
|
|
336
|
+
* `groupHandle` required (history is independent of coord/router
|
|
337
|
+
* lifecycle, so a sub-agent / route_forward path that hasn't yet
|
|
338
|
+
* opened the group can still read history).
|
|
339
|
+
*
|
|
340
|
+
* Returns the SAME array reference across calls within the same
|
|
341
|
+
* lifecycle, so consumers can mutate-in-place. Reassigned only by
|
|
342
|
+
* compact (race guard checks reference equality), `consolidate`
|
|
343
|
+
* events, and session reset.
|
|
344
|
+
*
|
|
345
|
+
* @param {string} groupId
|
|
346
|
+
* @returns {GroupHistory}
|
|
347
|
+
*/
|
|
348
|
+
function getOrCreateGroupHistory(groupId) {
|
|
349
|
+
if (!groupId) return [];
|
|
350
|
+
let entry = groupContexts.get(groupId);
|
|
351
|
+
// Use `historyHydrated` rather than truthiness on `history` itself —
|
|
352
|
+
// an empty array (post-consolidate, post-clear, or a partial entry
|
|
353
|
+
// seeded by `getCompactState` before any data was loaded) is
|
|
354
|
+
// legitimate state that does NOT mean "needs hydration"... unless we
|
|
355
|
+
// never loaded from disk in the first place. The flag separates the
|
|
356
|
+
// two cases.
|
|
357
|
+
if (entry && entry.historyHydrated) return entry.history;
|
|
358
|
+
if (!entry) {
|
|
359
|
+
entry = makeGroupContextStub();
|
|
360
|
+
groupContexts.set(groupId, entry);
|
|
361
|
+
}
|
|
362
|
+
entry.history = hydrateGroupHistory(groupId);
|
|
363
|
+
entry.historyHydrated = true;
|
|
364
|
+
return entry.history;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Reassign a group's history reference. Used by compact + consolidate +
|
|
369
|
+
* clear paths that need to swap the array (not just mutate it). Returns
|
|
370
|
+
* the new reference. Idempotent if the entry doesn't exist (creates one).
|
|
371
|
+
*
|
|
372
|
+
* Sets `historyHydrated = true` because an explicit assignment is itself
|
|
373
|
+
* a hydration — even setting `[]` after `consolidate` means "this is the
|
|
374
|
+
* canonical state right now, don't re-load from disk".
|
|
375
|
+
*
|
|
376
|
+
* @param {string} groupId
|
|
377
|
+
* @param {GroupHistory} next
|
|
378
|
+
*/
|
|
379
|
+
function setGroupHistory(groupId, next) {
|
|
380
|
+
if (!groupId) return;
|
|
381
|
+
let entry = groupContexts.get(groupId);
|
|
382
|
+
if (!entry) {
|
|
383
|
+
entry = makeGroupContextStub();
|
|
384
|
+
groupContexts.set(groupId, entry);
|
|
385
|
+
}
|
|
386
|
+
entry.history = next;
|
|
387
|
+
entry.historyHydrated = true;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Test-only access to a group's history array. Re-exported below as
|
|
392
|
+
* `__testGroupHistory`. Lets tests pin the per-group isolation contract
|
|
393
|
+
* without booting a full session.
|
|
394
|
+
*
|
|
395
|
+
* @param {string} groupId
|
|
396
|
+
*/
|
|
397
|
+
export function __testGroupHistory(groupId) {
|
|
398
|
+
return getOrCreateGroupHistory(groupId);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Test-only: install a minimal `session` so `hydrateGroupHistory` can
|
|
403
|
+
* read from a real `ConversationStore`. Pass `null` to clear.
|
|
404
|
+
*
|
|
405
|
+
* Tests that need to verify the hydrate-from-disk path can construct a
|
|
406
|
+
* `ConversationStore` against a tmp dir, write per-group records via
|
|
407
|
+
* `store.append({groupId, ...})`, then call this helper to wire the
|
|
408
|
+
* store into the bridge before calling `__testGroupHistory(groupId)`.
|
|
409
|
+
*
|
|
410
|
+
* @param {{ conversationStore: object } | null} sessionLike
|
|
411
|
+
*/
|
|
412
|
+
export function __testSetSession(sessionLike) {
|
|
413
|
+
session = sessionLike;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Test-only: peek at the GroupContext entry for a group (or undefined
|
|
418
|
+
* if never seeded). Lets tests assert the `historyHydrated` flag without
|
|
419
|
+
* exporting the entire `groupContexts` Map.
|
|
420
|
+
*
|
|
421
|
+
* @param {string} groupId
|
|
422
|
+
*/
|
|
423
|
+
export function __testGroupContextEntry(groupId) {
|
|
424
|
+
return groupContexts.get(groupId);
|
|
269
425
|
}
|
|
270
426
|
|
|
271
427
|
/** Whether we've already sent a permission warning to the UI */
|
|
@@ -334,13 +490,29 @@ function getOrCreateVpEngine(groupId, vpId) {
|
|
|
334
490
|
*/
|
|
335
491
|
function getOrCreateGroupContext(groupId, groupHandle) {
|
|
336
492
|
let entry = groupContexts.get(groupId);
|
|
337
|
-
if (entry) return entry;
|
|
493
|
+
if (entry && entry.coord && entry.router) return entry;
|
|
494
|
+
// Either no entry, or a partial entry seeded by `getOrCreateGroupHistory`
|
|
495
|
+
// / `getCompactState` (no coord/router yet). Build the coord/router and
|
|
496
|
+
// merge into the existing record so the per-group history reference and
|
|
497
|
+
// hydration flag are preserved.
|
|
338
498
|
const coord = createCoordinator(groupHandle, {
|
|
339
499
|
deliver: (vpId, envelope) => enqueueForVp(groupId, vpId, envelope),
|
|
340
500
|
});
|
|
341
501
|
const router = createRouter({ coordinator: coord });
|
|
342
|
-
entry
|
|
343
|
-
|
|
502
|
+
if (!entry) {
|
|
503
|
+
entry = makeGroupContextStub();
|
|
504
|
+
groupContexts.set(groupId, entry);
|
|
505
|
+
}
|
|
506
|
+
entry.coord = coord;
|
|
507
|
+
entry.router = router;
|
|
508
|
+
entry.groupHandle = groupHandle;
|
|
509
|
+
// Defend against a future caller that builds a coord/router without
|
|
510
|
+
// having gone through `getOrCreateGroupHistory` first: a partial entry
|
|
511
|
+
// could exist with `historyHydrated:false`, so do the load now.
|
|
512
|
+
if (!entry.historyHydrated) {
|
|
513
|
+
entry.history = hydrateGroupHistory(groupId);
|
|
514
|
+
entry.historyHydrated = true;
|
|
515
|
+
}
|
|
344
516
|
return entry;
|
|
345
517
|
}
|
|
346
518
|
|
|
@@ -411,8 +583,9 @@ function ensureDriverRunning(groupId, vpId) {
|
|
|
411
583
|
turnAbortCtrls.set(turnId, vpAbort);
|
|
412
584
|
// Snapshot history at the moment this turn starts. Later turns in
|
|
413
585
|
// the same driver loop see updated history (post-append from the
|
|
414
|
-
// previous turn).
|
|
415
|
-
|
|
586
|
+
// previous turn). Per-group: each driver only sees its own group's
|
|
587
|
+
// tape, so cross-group prompts never leak into a VP's snapshot.
|
|
588
|
+
const baseSnapshot = [...getOrCreateGroupHistory(groupId)];
|
|
416
589
|
const trigger = envelope?.trigger || 'fallback';
|
|
417
590
|
// Synthesize the prompt. For coordinator-emitted envelopes the
|
|
418
591
|
// text lives at envelope.msg.text. We prefix `@vp-<id>` to mirror
|
|
@@ -1081,8 +1254,9 @@ function handleEngineEvent(event, hctx) {
|
|
|
1081
1254
|
break;
|
|
1082
1255
|
|
|
1083
1256
|
case 'consolidate':
|
|
1084
|
-
// Engine compressed the context — clear
|
|
1085
|
-
|
|
1257
|
+
// Engine compressed the context — clear THIS group's accumulated
|
|
1258
|
+
// history. Other groups' histories stay intact.
|
|
1259
|
+
if (hctx.groupId) setGroupHistory(hctx.groupId, []);
|
|
1086
1260
|
sendUnifyEvent({
|
|
1087
1261
|
type: 'consolidate',
|
|
1088
1262
|
archivedCount: event.archivedCount,
|
|
@@ -1254,13 +1428,15 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1254
1428
|
? msg.groupId.trim()
|
|
1255
1429
|
: 'grp_default';
|
|
1256
1430
|
|
|
1257
|
-
// Entry gate: if a compact is in flight from the previous turn
|
|
1258
|
-
// wait for it to finish before reading
|
|
1259
|
-
// runs at turn END (post-fanout) so it does not
|
|
1260
|
-
// current message latency, but a fast double-send
|
|
1261
|
-
// not race with the swap.
|
|
1262
|
-
|
|
1263
|
-
|
|
1431
|
+
// Entry gate: if a compact is in flight from the previous turn IN
|
|
1432
|
+
// THIS GROUP, wait for it to finish before reading the group's
|
|
1433
|
+
// history. Compact runs at turn END (post-fanout) so it does not
|
|
1434
|
+
// block the user's current message latency, but a fast double-send
|
|
1435
|
+
// from the user must not race with the swap. Other groups' compacts
|
|
1436
|
+
// never block this gate.
|
|
1437
|
+
const _entryCompactState = getCompactState(groupId);
|
|
1438
|
+
if (_entryCompactState.inFlight) {
|
|
1439
|
+
try { await _entryCompactState.inFlight; } catch { /* first caller logs */ }
|
|
1264
1440
|
}
|
|
1265
1441
|
|
|
1266
1442
|
// yeaftDir is a hard prerequisite for both session boot and group seeding;
|
|
@@ -1524,10 +1700,11 @@ export async function handleUnifyGroupChat(msg) {
|
|
|
1524
1700
|
await waitForVpDrivers(groupId, primaryTargets);
|
|
1525
1701
|
|
|
1526
1702
|
// Post-turn compaction. Triggers when the JUST-APPENDED turn pushed
|
|
1527
|
-
// history past 20 turns / 80K tokens. Runs in the
|
|
1528
|
-
// not block the response to this message. The
|
|
1529
|
-
//
|
|
1530
|
-
//
|
|
1703
|
+
// history past 20 turns / 80K tokens for THIS group. Runs in the
|
|
1704
|
+
// background — does not block the response to this message. The
|
|
1705
|
+
// next user message in the same group awaits its per-group
|
|
1706
|
+
// `_compact.inFlight` at the entry gate (handleUnifyGroupChat top),
|
|
1707
|
+
// so the swap is guaranteed to be observed before the next
|
|
1531
1708
|
// baseSnapshot capture. Errors are swallowed; next turn retries.
|
|
1532
1709
|
scheduleCompactAfterTurn(groupId);
|
|
1533
1710
|
}
|
|
@@ -1696,7 +1873,8 @@ async function ensureSessionLoaded() {
|
|
|
1696
1873
|
|
|
1697
1874
|
unifyConversationId = `unify-${Date.now()}`;
|
|
1698
1875
|
|
|
1699
|
-
|
|
1876
|
+
// Per-group history is hydrated lazily on first `getOrCreateGroupHistory`
|
|
1877
|
+
// — there's no global "all conversations" tape any more.
|
|
1700
1878
|
|
|
1701
1879
|
sendUnifyEvent({
|
|
1702
1880
|
type: 'session_ready',
|
|
@@ -1902,7 +2080,7 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1902
2080
|
}
|
|
1903
2081
|
|
|
1904
2082
|
// Turn completed — atomically append this VP's output to shared history.
|
|
1905
|
-
|
|
2083
|
+
appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCallsAccum, toolResultsAccum);
|
|
1906
2084
|
|
|
1907
2085
|
sendUnifyOutput({
|
|
1908
2086
|
type: 'assistant',
|
|
@@ -1960,11 +2138,21 @@ async function runVpTurn({ prompt, promptParts = null, groupId, vpId, turnId, en
|
|
|
1960
2138
|
}
|
|
1961
2139
|
|
|
1962
2140
|
/**
|
|
1963
|
-
* Atomically append a completed VP-turn's messages to the
|
|
2141
|
+
* Atomically append a completed VP-turn's messages to the GROUP'S
|
|
1964
2142
|
* conversation history. Called once at turn end (not during streaming).
|
|
2143
|
+
*
|
|
2144
|
+
* Note: this does NOT see the engine's collapsed form — it appends the
|
|
2145
|
+
* raw user prompt + the per-VP assistant text + tool results. The
|
|
2146
|
+
* engine's own `conversationMessages` (with T1/T2 collapse applied)
|
|
2147
|
+
* is persisted to disk via stop-hooks, so the next turn's history is
|
|
2148
|
+
* read from disk via `loadRecentByGroup` on next session boot. Within
|
|
2149
|
+
* a session, this in-memory tape carries the un-collapsed form — which
|
|
2150
|
+
* is fine because each VP turn's `engine.query` re-collapses on the fly.
|
|
1965
2151
|
*/
|
|
1966
|
-
function
|
|
1967
|
-
|
|
2152
|
+
function appendTurnToGroupHistory(groupId, prompt, assistantTextParts, toolCallsAccum, toolResultsAccum) {
|
|
2153
|
+
if (!groupId) return;
|
|
2154
|
+
const history = getOrCreateGroupHistory(groupId);
|
|
2155
|
+
history.push({ role: 'user', content: prompt });
|
|
1968
2156
|
|
|
1969
2157
|
const fullText = assistantTextParts.join('');
|
|
1970
2158
|
if (fullText || toolCallsAccum.length > 0) {
|
|
@@ -1976,10 +2164,10 @@ function appendTurnToHistory(prompt, assistantTextParts, toolCallsAccum, toolRes
|
|
|
1976
2164
|
input: tc.input,
|
|
1977
2165
|
}));
|
|
1978
2166
|
}
|
|
1979
|
-
|
|
2167
|
+
history.push(assistantMsg);
|
|
1980
2168
|
|
|
1981
2169
|
for (const tr of toolResultsAccum) {
|
|
1982
|
-
|
|
2170
|
+
history.push({
|
|
1983
2171
|
role: 'tool',
|
|
1984
2172
|
toolCallId: tr.toolCallId,
|
|
1985
2173
|
content: tr.content,
|
|
@@ -2071,47 +2259,53 @@ function persistUserMessageOnceByMsgId({ msgId, text, groupId }) {
|
|
|
2071
2259
|
const _persistedUserMsgIds = new Set();
|
|
2072
2260
|
|
|
2073
2261
|
/**
|
|
2074
|
-
*
|
|
2075
|
-
*
|
|
2076
|
-
*
|
|
2077
|
-
*
|
|
2262
|
+
* Per-group compact state. Each group has its own in-flight promise +
|
|
2263
|
+
* pending flag so a compact in group-A doesn't block a compact in
|
|
2264
|
+
* group-B (and so the entry-gate await in `handleUnifyGroupChat` only
|
|
2265
|
+
* blocks on its own group's compact, not unrelated groups').
|
|
2078
2266
|
*
|
|
2079
|
-
*
|
|
2080
|
-
* latency to the user's current message. The trade-off: the next user
|
|
2081
|
-
* message may have to wait briefly for the compact to finish — but
|
|
2082
|
-
* compact uses the fast model and typically completes in 1–3s.
|
|
2267
|
+
* Lives off `groupContexts.get(groupId)._compact = { inFlight, pending }`.
|
|
2083
2268
|
*
|
|
2084
|
-
* @
|
|
2269
|
+
* @typedef {{ inFlight: Promise<void>|null, pending: boolean }} CompactState
|
|
2085
2270
|
*/
|
|
2086
|
-
let _compactInFlight = null;
|
|
2087
2271
|
|
|
2088
|
-
/**
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2272
|
+
/** Get-or-create the per-group compact state. */
|
|
2273
|
+
function getCompactState(groupId) {
|
|
2274
|
+
if (!groupId) return { inFlight: null, pending: false };
|
|
2275
|
+
let entry = groupContexts.get(groupId);
|
|
2276
|
+
if (!entry) {
|
|
2277
|
+
// Don't pre-hydrate history here. The stub leaves
|
|
2278
|
+
// `historyHydrated:false`, so the first `getOrCreateGroupHistory`
|
|
2279
|
+
// call still triggers disk hydration. (Pre-fix this seeded
|
|
2280
|
+
// `history: []` and `getOrCreateGroupHistory` short-circuited on
|
|
2281
|
+
// the truthy empty array, leaving the group amnesiac for any
|
|
2282
|
+
// entry path that didn't first go through `handleUnifyLoadHistory`.)
|
|
2283
|
+
entry = makeGroupContextStub();
|
|
2284
|
+
groupContexts.set(groupId, entry);
|
|
2285
|
+
}
|
|
2286
|
+
if (!entry._compact) entry._compact = { inFlight: null, pending: false };
|
|
2287
|
+
return entry._compact;
|
|
2288
|
+
}
|
|
2097
2289
|
|
|
2098
2290
|
/**
|
|
2099
2291
|
* Fire-and-forget post-turn compaction. Called once at the end of each
|
|
2100
2292
|
* `handleUnifyGroupChat` after `Promise.all(runVpTurn)` resolves. If a
|
|
2101
|
-
* compaction is still in flight from an earlier turn, we
|
|
2102
|
-
* `
|
|
2293
|
+
* compaction is still in flight from an earlier turn IN THIS GROUP, we
|
|
2294
|
+
* set `_compact.pending` so the running compact chains a follow-up on
|
|
2103
2295
|
* completion (anti-starvation).
|
|
2104
2296
|
*
|
|
2105
|
-
* The promise is stored
|
|
2106
|
-
* can await it before reading
|
|
2297
|
+
* The promise is stored on the per-group state so the next user message
|
|
2298
|
+
* in the SAME group can await it before reading the group's history.
|
|
2107
2299
|
*
|
|
2108
2300
|
* @param {string} groupId — for envelope tagging on the emitted event
|
|
2109
2301
|
*/
|
|
2110
2302
|
function scheduleCompactAfterTurn(groupId) {
|
|
2111
|
-
if (
|
|
2303
|
+
if (!groupId) return;
|
|
2304
|
+
const cs = getCompactState(groupId);
|
|
2305
|
+
if (cs.inFlight) {
|
|
2112
2306
|
// A compact is already running. Mark a follow-up so when it
|
|
2113
2307
|
// finishes, it re-evaluates and runs again if still triggered.
|
|
2114
|
-
|
|
2308
|
+
cs.pending = true;
|
|
2115
2309
|
return;
|
|
2116
2310
|
}
|
|
2117
2311
|
// Cheap O(n) precheck so we don't bother engaging the LLM at all
|
|
@@ -2124,30 +2318,30 @@ function scheduleCompactAfterTurn(groupId) {
|
|
|
2124
2318
|
typeof session?.config?.maxContextTokens === 'number'
|
|
2125
2319
|
? session.config.maxContextTokens
|
|
2126
2320
|
: undefined;
|
|
2127
|
-
const triage = shouldCompactHistory(
|
|
2321
|
+
const triage = shouldCompactHistory(getOrCreateGroupHistory(groupId), { maxContextTokens });
|
|
2128
2322
|
if (!triage.trigger) return;
|
|
2129
2323
|
if (!session?.engine || typeof session.engine.summarizeForCompact !== 'function') {
|
|
2130
2324
|
console.warn('[Unify] history compact: engine.summarizeForCompact unavailable — skipping');
|
|
2131
2325
|
return;
|
|
2132
2326
|
}
|
|
2133
2327
|
|
|
2134
|
-
|
|
2135
|
-
|
|
2328
|
+
cs.inFlight = runCompactNow(groupId).finally(() => {
|
|
2329
|
+
cs.inFlight = null;
|
|
2136
2330
|
// If turns piled up while we were running and compaction is still
|
|
2137
2331
|
// needed, chain a follow-up. Use a microtask so the .finally chain
|
|
2138
2332
|
// settles cleanly before the next promise is created.
|
|
2139
|
-
if (
|
|
2140
|
-
|
|
2333
|
+
if (cs.pending) {
|
|
2334
|
+
cs.pending = false;
|
|
2141
2335
|
queueMicrotask(() => scheduleCompactAfterTurn(groupId));
|
|
2142
2336
|
}
|
|
2143
2337
|
});
|
|
2144
2338
|
}
|
|
2145
2339
|
|
|
2146
2340
|
/**
|
|
2147
|
-
* Run the in-memory history compactor. Replaces the older
|
|
2148
|
-
*
|
|
2149
|
-
* preserving the recent tail verbatim. Mutates the
|
|
2150
|
-
*
|
|
2341
|
+
* Run the in-memory history compactor for ONE group. Replaces the older
|
|
2342
|
+
* prefix of the group's history with a single user-role summary
|
|
2343
|
+
* message, preserving the recent tail verbatim. Mutates the per-group
|
|
2344
|
+
* array via reassignment (`setGroupHistory`).
|
|
2151
2345
|
*
|
|
2152
2346
|
* Behaviour:
|
|
2153
2347
|
* - If summarization fails, leaves history untouched.
|
|
@@ -2155,12 +2349,17 @@ function scheduleCompactAfterTurn(groupId) {
|
|
|
2155
2349
|
* can show what happened (frontend currently ignores it).
|
|
2156
2350
|
*
|
|
2157
2351
|
* Race safety:
|
|
2158
|
-
* - Single-flight via `
|
|
2159
|
-
*
|
|
2160
|
-
*
|
|
2161
|
-
*
|
|
2162
|
-
*
|
|
2163
|
-
*
|
|
2352
|
+
* - Single-flight via per-group `_compact.inFlight` (only one runs
|
|
2353
|
+
* at a time per group).
|
|
2354
|
+
* - Captures the array reference AND its length on entry. If anything
|
|
2355
|
+
* reassigns the group's history during the await (`consolidate` event
|
|
2356
|
+
* from the engine, `clearUnifyMessages`, `resetUnifySession`), we
|
|
2357
|
+
* detect the swap by reference comparison. If a `route_forward`
|
|
2358
|
+
* driver path appends new messages in place during the await
|
|
2359
|
+
* (push-mutate, not reassignment), the length grew — also bail,
|
|
2360
|
+
* because writing back the stale compacted view would silently drop
|
|
2361
|
+
* the in-flight messages. (Disk persistence is independent — the
|
|
2362
|
+
* stop-hooks already wrote those messages to disk.)
|
|
2164
2363
|
*
|
|
2165
2364
|
* @param {string} groupId
|
|
2166
2365
|
* @returns {Promise<void>}
|
|
@@ -2169,11 +2368,15 @@ async function runCompactNow(groupId) {
|
|
|
2169
2368
|
const summarize = ({ system, prompt }) =>
|
|
2170
2369
|
session.engine.summarizeForCompact({ system, prompt, maxTokens: 1024 });
|
|
2171
2370
|
|
|
2172
|
-
// Capture the current array reference. If anyone
|
|
2173
|
-
//
|
|
2174
|
-
// event, session reset, manual clear), the reference will
|
|
2175
|
-
//
|
|
2176
|
-
|
|
2371
|
+
// Capture the current array reference AND its length. If anyone
|
|
2372
|
+
// reassigns the group's history while we're summarizing (engine
|
|
2373
|
+
// consolidate event, session reset, manual clear), the reference will
|
|
2374
|
+
// differ. If a driver path push-mutates new messages in place
|
|
2375
|
+
// (route_forward turning into a new VP turn during compact), the
|
|
2376
|
+
// reference is the same but the length grew. Both cases mean the
|
|
2377
|
+
// snapshot we summarized is no longer the canonical state — bail.
|
|
2378
|
+
const snapshot = getOrCreateGroupHistory(groupId);
|
|
2379
|
+
const snapshotLen = snapshot.length;
|
|
2177
2380
|
|
|
2178
2381
|
// Pull the user-configured context width so the 40 %-of-context
|
|
2179
2382
|
// threshold auto-adjusts to whatever model they're on. Falls back to
|
|
@@ -2194,14 +2397,20 @@ async function runCompactNow(groupId) {
|
|
|
2194
2397
|
}
|
|
2195
2398
|
return;
|
|
2196
2399
|
}
|
|
2197
|
-
// Race guard: if
|
|
2198
|
-
// await (e.g. consolidate / reset),
|
|
2199
|
-
//
|
|
2200
|
-
|
|
2400
|
+
// Race guard: if the group's history was reassigned during the
|
|
2401
|
+
// await (e.g. consolidate / reset), or push-mutated by a driver
|
|
2402
|
+
// path (e.g. a route_forward triggered VP turn appending), do NOT
|
|
2403
|
+
// overwrite the fresh state with our stale compacted snapshot.
|
|
2404
|
+
const current = getOrCreateGroupHistory(groupId);
|
|
2405
|
+
if (current !== snapshot) {
|
|
2201
2406
|
console.log('[Unify] history compact: history was reset during compact — discarding stale summary');
|
|
2202
2407
|
return;
|
|
2203
2408
|
}
|
|
2204
|
-
|
|
2409
|
+
if (current.length !== snapshotLen) {
|
|
2410
|
+
console.log('[Unify] history compact: history was appended-to during compact — discarding stale summary');
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
setGroupHistory(groupId, result.messages);
|
|
2205
2414
|
console.log(
|
|
2206
2415
|
`[Unify] history compacted (reason=${result.reason}): ` +
|
|
2207
2416
|
`turns ${result.beforeTurns}→${result.afterTurns}, ` +
|
|
@@ -2593,12 +2802,17 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
2593
2802
|
|
|
2594
2803
|
unifyConversationId = `unify-${Date.now()}`;
|
|
2595
2804
|
|
|
2596
|
-
|
|
2805
|
+
// Per-group history hydrates lazily via getOrCreateGroupHistory.
|
|
2806
|
+
// When the load-history call carries a groupId, force-refresh THAT
|
|
2807
|
+
// group's tape so the next user message sees on-disk state. When
|
|
2808
|
+
// it doesn't (legacy callers), do nothing — the per-group lazy
|
|
2809
|
+
// hydration handles it.
|
|
2810
|
+
if (groupId) setGroupHistory(groupId, hydrateGroupHistory(groupId));
|
|
2597
2811
|
} else if (groupId) {
|
|
2598
2812
|
// Re-entering an existing session with a (possibly new) group filter:
|
|
2599
|
-
// re-seed
|
|
2600
|
-
//
|
|
2601
|
-
|
|
2813
|
+
// re-seed THIS group's history from disk so it doesn't carry stale
|
|
2814
|
+
// in-memory state into the next turn's context.
|
|
2815
|
+
setGroupHistory(groupId, hydrateGroupHistory(groupId));
|
|
2602
2816
|
}
|
|
2603
2817
|
|
|
2604
2818
|
// Always replay session_ready so refresh / reconnect rebuilds UI state.
|
|
@@ -2753,7 +2967,9 @@ export async function resetUnifySession() {
|
|
|
2753
2967
|
session = null;
|
|
2754
2968
|
}
|
|
2755
2969
|
unifyConversationId = null;
|
|
2756
|
-
|
|
2970
|
+
// Per-group histories live on groupContexts entries — clearing the
|
|
2971
|
+
// map (a few lines below) drops every group's history with it. No
|
|
2972
|
+
// separate global tape to clear.
|
|
2757
2973
|
// Re-arm the permission warning. The user might have fixed the
|
|
2758
2974
|
// ~/.yeaft/ permissions in the interim and is now restarting the
|
|
2759
2975
|
// session — they should see the diagnostic again if it still fails.
|
|
@@ -2786,7 +3002,8 @@ export async function resetUnifySession() {
|
|
|
2786
3002
|
|
|
2787
3003
|
unifyConversationId = `unify-${Date.now()}`;
|
|
2788
3004
|
|
|
2789
|
-
|
|
3005
|
+
// Per-group history hydrates lazily via getOrCreateGroupHistory on
|
|
3006
|
+
// first read. Nothing to seed here.
|
|
2790
3007
|
|
|
2791
3008
|
sendUnifyEvent({
|
|
2792
3009
|
type: 'session_ready',
|