@yeaft/webchat-agent 0.1.496 → 0.1.498
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/threads/store.js +38 -0
- package/unify/web-bridge.js +187 -49
package/package.json
CHANGED
package/unify/threads/store.js
CHANGED
|
@@ -518,6 +518,44 @@ export class ThreadStore {
|
|
|
518
518
|
this.#idleArchiveDays = normaliseIdleDays(days);
|
|
519
519
|
}
|
|
520
520
|
|
|
521
|
+
/**
|
|
522
|
+
* task-317: auto-archive pass. Scans every non-archived thread (except
|
|
523
|
+
* `main`, which is never auto-archived) and archives those whose
|
|
524
|
+
* `lastMessageAt` (fallback: `lastActivityAt`, fallback: `createdAt`)
|
|
525
|
+
* is older than `now - idleArchiveDays * 86400000 ms`.
|
|
526
|
+
*
|
|
527
|
+
* Returns the list of newly-archived thread ids so callers can decide
|
|
528
|
+
* whether to broadcast a UI update (no archived → no broadcast).
|
|
529
|
+
*
|
|
530
|
+
* Constraints:
|
|
531
|
+
* - `idleArchiveDays === 0` disables the feature entirely (returns []).
|
|
532
|
+
* - The main thread is NEVER archived regardless of its activity.
|
|
533
|
+
* - Already-archived threads are skipped (idempotent).
|
|
534
|
+
* - Threads with no recorded activity fall back to `createdAt`; a
|
|
535
|
+
* thread created 100 days ago with zero messages IS archived when
|
|
536
|
+
* idleArchiveDays ≤ 100 — silent threads aren't a special case.
|
|
537
|
+
*
|
|
538
|
+
* @param {number} [now] — override the clock for tests
|
|
539
|
+
* @returns {{ archived: string[] }}
|
|
540
|
+
*/
|
|
541
|
+
runArchivePass(now = Date.now()) {
|
|
542
|
+
if (this.#idleArchiveDays <= 0) return { archived: [] };
|
|
543
|
+
const cutoff = now - this.#idleArchiveDays * 86400000;
|
|
544
|
+
const archived = [];
|
|
545
|
+
for (const t of this.#threads.values()) {
|
|
546
|
+
if (t.id === MAIN_THREAD_ID) continue;
|
|
547
|
+
if (t.archived || t.status === 'archived') continue;
|
|
548
|
+
const ref = t.lastMessageAt ?? t.lastActivityAt ?? t.createdAt ?? now;
|
|
549
|
+
if (ref > cutoff) continue;
|
|
550
|
+
t.status = 'archived';
|
|
551
|
+
t.archived = true;
|
|
552
|
+
t.updatedAt = now;
|
|
553
|
+
this.#markDirty(t.id);
|
|
554
|
+
archived.push(t.id);
|
|
555
|
+
}
|
|
556
|
+
return { archived };
|
|
557
|
+
}
|
|
558
|
+
|
|
521
559
|
get(id) { return this.#threads.get(id) || null; }
|
|
522
560
|
list() { return [...this.#threads.values()]; }
|
|
523
561
|
has(id) { return this.#threads.has(id); }
|
package/unify/web-bridge.js
CHANGED
|
@@ -16,13 +16,22 @@
|
|
|
16
16
|
import { loadSession } from './session.js';
|
|
17
17
|
import { sendToServer } from '../connection/buffer.js';
|
|
18
18
|
import ctx from '../context.js';
|
|
19
|
-
import { getThreadStore } from './threads/store.js';
|
|
19
|
+
import { getThreadStore, MAIN_THREAD_ID } from './threads/store.js';
|
|
20
20
|
|
|
21
21
|
/** @type {import('./session.js').Session | null} */
|
|
22
22
|
let session = null;
|
|
23
23
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
24
|
+
/**
|
|
25
|
+
* task-320: per-thread in-flight AbortController registry.
|
|
26
|
+
*
|
|
27
|
+
* A new message only cancels the prior round on the SAME thread; a message
|
|
28
|
+
* routed to a different thread runs concurrently without aliasing. Keyed by
|
|
29
|
+
* the resolved `targetThreadId` from the dispatcher's `routing_decision`
|
|
30
|
+
* event (we don't know the thread until the router has classified).
|
|
31
|
+
*
|
|
32
|
+
* @type {Map<string, AbortController>}
|
|
33
|
+
*/
|
|
34
|
+
const abortByThread = new Map();
|
|
26
35
|
|
|
27
36
|
/** Query timeout in ms — abort if LLM doesn't respond within this window */
|
|
28
37
|
const QUERY_TIMEOUT_MS = 120_000;
|
|
@@ -30,10 +39,22 @@ const QUERY_TIMEOUT_MS = 120_000;
|
|
|
30
39
|
/** Virtual conversationId for the Unify session */
|
|
31
40
|
let unifyConversationId = null;
|
|
32
41
|
|
|
33
|
-
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
|
|
42
|
+
/**
|
|
43
|
+
* task-320: per-thread accumulated conversation messages for context
|
|
44
|
+
* continuity. Previously a single flat array — which cross-contaminated
|
|
45
|
+
* history across threads. Keyed by threadId. Cleared on session reset or
|
|
46
|
+
* by a `consolidate` event for that thread only.
|
|
47
|
+
*
|
|
48
|
+
* @type {Map<string, Array<{role: 'user'|'assistant', content: string|Array}>>}
|
|
49
|
+
*/
|
|
50
|
+
const messagesByThread = new Map();
|
|
51
|
+
|
|
52
|
+
function getThreadMessages(threadId) {
|
|
53
|
+
if (!threadId) return [];
|
|
54
|
+
let arr = messagesByThread.get(threadId);
|
|
55
|
+
if (!arr) { arr = []; messagesByThread.set(threadId, arr); }
|
|
56
|
+
return arr;
|
|
57
|
+
}
|
|
37
58
|
|
|
38
59
|
/** Whether we've already sent a permission warning to the UI */
|
|
39
60
|
let _permissionDiagnosticSent = false;
|
|
@@ -101,10 +122,78 @@ export function installUnifyRuntimeBridge(s) {
|
|
|
101
122
|
if (typeof s.threadStore?.setIdleArchiveDays === 'function') {
|
|
102
123
|
s.threadStore.setIdleArchiveDays(v);
|
|
103
124
|
}
|
|
125
|
+
// task-317: re-sweep right after the cap changes so a user who
|
|
126
|
+
// lowers the threshold sees stale threads disappear immediately
|
|
127
|
+
// rather than having to wait for the hourly tick.
|
|
128
|
+
runAutoArchiveSweep(s);
|
|
104
129
|
},
|
|
105
130
|
};
|
|
106
131
|
}
|
|
107
132
|
|
|
133
|
+
/**
|
|
134
|
+
* task-317: idle thread auto-archive.
|
|
135
|
+
*
|
|
136
|
+
* A single sweep = ask the ThreadStore to archive every non-main,
|
|
137
|
+
* non-archived thread whose last activity predates the configured idle
|
|
138
|
+
* window. When any thread is archived we push a fresh `thread_list_updated`
|
|
139
|
+
* so the sidebar reflects reality within the same tick.
|
|
140
|
+
*
|
|
141
|
+
* Safe on stores with `idleArchiveDays === 0` (returns no-op) and on
|
|
142
|
+
* sessions missing a threadStore handle (defensive; should never happen
|
|
143
|
+
* once `installUnifyRuntimeBridge` has run).
|
|
144
|
+
*
|
|
145
|
+
* @param {import('./session.js').Session|null} s
|
|
146
|
+
* @returns {string[]} archived thread ids (empty when nothing changed)
|
|
147
|
+
*/
|
|
148
|
+
export function runAutoArchiveSweep(s) {
|
|
149
|
+
try {
|
|
150
|
+
const store = s?.threadStore ?? (typeof getThreadStore === 'function' ? getThreadStore() : null);
|
|
151
|
+
if (!store || typeof store.runArchivePass !== 'function') return [];
|
|
152
|
+
const { archived } = store.runArchivePass();
|
|
153
|
+
if (archived && archived.length > 0) {
|
|
154
|
+
sendThreadListUpdate();
|
|
155
|
+
}
|
|
156
|
+
return archived || [];
|
|
157
|
+
} catch (err) {
|
|
158
|
+
console.warn('[Unify] runAutoArchiveSweep failed:', err?.message || err);
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* task-317: schedule the hourly auto-archive tick bound to the given
|
|
165
|
+
* session. Returns the `Timeout` handle so tests can assert / clear it.
|
|
166
|
+
* Re-calling replaces any prior timer (idempotent per-session).
|
|
167
|
+
*
|
|
168
|
+
* The timer is `unref()`'d so a pending tick never keeps the Node loop
|
|
169
|
+
* alive during shutdown; an explicit `clearAutoArchiveSchedule()` is
|
|
170
|
+
* provided for tests.
|
|
171
|
+
*/
|
|
172
|
+
let autoArchiveTimer = null;
|
|
173
|
+
const AUTO_ARCHIVE_TICK_MS = 60 * 60 * 1000; // 1h
|
|
174
|
+
|
|
175
|
+
export function scheduleAutoArchive(s, { intervalMs = AUTO_ARCHIVE_TICK_MS } = {}) {
|
|
176
|
+
if (autoArchiveTimer) {
|
|
177
|
+
clearInterval(autoArchiveTimer);
|
|
178
|
+
autoArchiveTimer = null;
|
|
179
|
+
}
|
|
180
|
+
if (!s) return null;
|
|
181
|
+
autoArchiveTimer = setInterval(() => {
|
|
182
|
+
runAutoArchiveSweep(s);
|
|
183
|
+
}, intervalMs);
|
|
184
|
+
if (autoArchiveTimer && typeof autoArchiveTimer.unref === 'function') {
|
|
185
|
+
autoArchiveTimer.unref();
|
|
186
|
+
}
|
|
187
|
+
return autoArchiveTimer;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function clearAutoArchiveSchedule() {
|
|
191
|
+
if (autoArchiveTimer) {
|
|
192
|
+
clearInterval(autoArchiveTimer);
|
|
193
|
+
autoArchiveTimer = null;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
108
197
|
/**
|
|
109
198
|
* task-301 Part 2: push the full thread list snapshot to the web client.
|
|
110
199
|
* Called after any ThreadStore-mutating tool completes and at turn_end so
|
|
@@ -327,8 +416,13 @@ function handleEngineEvent(event, threadId, hctx) {
|
|
|
327
416
|
break;
|
|
328
417
|
|
|
329
418
|
case 'consolidate':
|
|
330
|
-
// Engine compressed the context — clear our accumulated history
|
|
331
|
-
|
|
419
|
+
// Engine compressed the context — clear our accumulated history for
|
|
420
|
+
// THIS thread only (task-320: per-thread history map).
|
|
421
|
+
if (threadId) {
|
|
422
|
+
messagesByThread.set(threadId, []);
|
|
423
|
+
} else {
|
|
424
|
+
messagesByThread.clear();
|
|
425
|
+
}
|
|
332
426
|
sendUnifyEvent({
|
|
333
427
|
type: 'consolidate',
|
|
334
428
|
archivedCount: event.archivedCount,
|
|
@@ -436,15 +530,24 @@ export async function handleUnifyChat(msg) {
|
|
|
436
530
|
// code — the config file was updated on disk but the running
|
|
437
531
|
// session continued with the old caps until next restart.
|
|
438
532
|
installUnifyRuntimeBridge(session);
|
|
533
|
+
// task-317: run one idle-archive sweep at bootstrap, then schedule
|
|
534
|
+
// the hourly tick bound to this session.
|
|
535
|
+
runAutoArchiveSweep(session);
|
|
536
|
+
scheduleAutoArchive(session);
|
|
439
537
|
|
|
440
538
|
// Create a stable conversationId for the Unify session
|
|
441
539
|
unifyConversationId = `unify-${Date.now()}`;
|
|
442
540
|
|
|
443
|
-
// Restore
|
|
541
|
+
// Restore per-thread history from persisted conversation store.
|
|
542
|
+
// task-320: bucket by threadId so each thread keeps its own context.
|
|
543
|
+
messagesByThread.clear();
|
|
444
544
|
const recent = session.conversationStore.loadRecent(50);
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
545
|
+
for (const m of recent) {
|
|
546
|
+
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
547
|
+
const tid = m.threadId || MAIN_THREAD_ID;
|
|
548
|
+
const bucket = getThreadMessages(tid);
|
|
549
|
+
bucket.push({ role: m.role, content: m.content });
|
|
550
|
+
}
|
|
448
551
|
|
|
449
552
|
// Notify UI: session is ready with model info + conversationId
|
|
450
553
|
sendUnifyEvent({
|
|
@@ -461,13 +564,14 @@ export async function handleUnifyChat(msg) {
|
|
|
461
564
|
sendThreadListUpdate();
|
|
462
565
|
}
|
|
463
566
|
|
|
464
|
-
// ───
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
567
|
+
// ─── Per-call AbortController (task-320) ──
|
|
568
|
+
// Each call owns its own controller. Only once the router resolves the
|
|
569
|
+
// target thread do we register it into `abortByThread` and abort any
|
|
570
|
+
// prior controller on THAT same thread. Messages routed to different
|
|
571
|
+
// threads never alias each other's signals.
|
|
572
|
+
const abortCtrl = new AbortController();
|
|
573
|
+
/** @type {string | null} — set on routing_decision */
|
|
574
|
+
let resolvedThreadId = null;
|
|
471
575
|
|
|
472
576
|
// ─── Timeout guard: abort query if LLM hangs beyond threshold ──
|
|
473
577
|
// Resets on every event — fires only after prolonged silence.
|
|
@@ -475,9 +579,9 @@ export async function handleUnifyChat(msg) {
|
|
|
475
579
|
const resetQueryTimer = () => {
|
|
476
580
|
if (queryTimer) clearTimeout(queryTimer);
|
|
477
581
|
queryTimer = setTimeout(() => {
|
|
478
|
-
if (
|
|
582
|
+
if (!abortCtrl.signal.aborted) {
|
|
479
583
|
console.error(`[Unify] query timeout after ${QUERY_TIMEOUT_MS / 1000}s of silence — aborting`);
|
|
480
|
-
|
|
584
|
+
abortCtrl.abort();
|
|
481
585
|
}
|
|
482
586
|
}, QUERY_TIMEOUT_MS);
|
|
483
587
|
};
|
|
@@ -518,17 +622,31 @@ export async function handleUnifyChat(msg) {
|
|
|
518
622
|
onError: (err) => { throw err; },
|
|
519
623
|
};
|
|
520
624
|
|
|
521
|
-
for await (const pev of session.dispatcher.drain({ signal:
|
|
625
|
+
for await (const pev of session.dispatcher.drain({ signal: abortCtrl.signal })) {
|
|
522
626
|
resetQueryTimer();
|
|
627
|
+
// task-320: on routing_decision, bind this abort controller to the
|
|
628
|
+
// resolved target thread and abort any prior in-flight controller
|
|
629
|
+
// owned by that thread. Different threads don't alias.
|
|
630
|
+
if (pev && pev.type === 'routing_decision' && pev.targetThreadId && !resolvedThreadId) {
|
|
631
|
+
resolvedThreadId = pev.targetThreadId;
|
|
632
|
+
const prior = abortByThread.get(resolvedThreadId);
|
|
633
|
+
if (prior && prior !== abortCtrl) {
|
|
634
|
+
prior.abort();
|
|
635
|
+
}
|
|
636
|
+
abortByThread.set(resolvedThreadId, abortCtrl);
|
|
637
|
+
}
|
|
523
638
|
forwardPipelineEvent(pev, pipelineCtx);
|
|
524
639
|
}
|
|
525
640
|
|
|
526
641
|
// ─── Query complete — accumulate messages for context continuity ──
|
|
527
|
-
|
|
642
|
+
// task-320: per-thread history (no cross-thread contamination).
|
|
643
|
+
const historyThread = resolvedThreadId || MAIN_THREAD_ID;
|
|
644
|
+
const threadMessages = getThreadMessages(historyThread);
|
|
645
|
+
threadMessages.push({ role: 'user', content: cleanedPrompt });
|
|
528
646
|
|
|
529
647
|
const fullText = assistantTextParts.join('');
|
|
530
648
|
if (fullText) {
|
|
531
|
-
|
|
649
|
+
threadMessages.push({ role: 'assistant', content: fullText });
|
|
532
650
|
}
|
|
533
651
|
|
|
534
652
|
// ─── Signal turn end to UI ──
|
|
@@ -549,17 +667,14 @@ export async function handleUnifyChat(msg) {
|
|
|
549
667
|
}
|
|
550
668
|
|
|
551
669
|
} catch (err) {
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
}],
|
|
561
|
-
},
|
|
562
|
-
});
|
|
670
|
+
// task-320: classify both DOM AbortError and LLMAbortError as
|
|
671
|
+
// "aborted" — LLMAbortError is thrown by the LLM adapters when the
|
|
672
|
+
// signal trips and must NOT render as a session error bubble.
|
|
673
|
+
const isAbort = err && (err.name === 'AbortError' || err.name === 'LLMAbortError');
|
|
674
|
+
if (isAbort) {
|
|
675
|
+
// Silent abort — the new in-flight round (on the same thread) will
|
|
676
|
+
// produce its own output. Still send `result` so the frontend's
|
|
677
|
+
// processing spinner for this exact send clears.
|
|
563
678
|
sendUnifyOutput({
|
|
564
679
|
type: 'result',
|
|
565
680
|
result_text: '',
|
|
@@ -600,7 +715,12 @@ export async function handleUnifyChat(msg) {
|
|
|
600
715
|
result_text: '',
|
|
601
716
|
});
|
|
602
717
|
} finally {
|
|
603
|
-
|
|
718
|
+
// task-320: only clear the per-thread slot if THIS controller is still
|
|
719
|
+
// the registered one. If a newer message already overwrote it, leaving
|
|
720
|
+
// the newer controller in the map is the correct state.
|
|
721
|
+
if (resolvedThreadId && abortByThread.get(resolvedThreadId) === abortCtrl) {
|
|
722
|
+
abortByThread.delete(resolvedThreadId);
|
|
723
|
+
}
|
|
604
724
|
}
|
|
605
725
|
}
|
|
606
726
|
|
|
@@ -785,14 +905,22 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
785
905
|
});
|
|
786
906
|
// task-318 rev-1 fix: wire live setters; see handleUnifyChat.
|
|
787
907
|
installUnifyRuntimeBridge(session);
|
|
908
|
+
// task-317: sweep + schedule auto-archive on history-load path too.
|
|
909
|
+
runAutoArchiveSweep(session);
|
|
910
|
+
scheduleAutoArchive(session);
|
|
788
911
|
|
|
789
912
|
unifyConversationId = `unify-${Date.now()}`;
|
|
790
913
|
|
|
791
|
-
// Restore
|
|
914
|
+
// Restore per-thread history from persisted conversation store.
|
|
915
|
+
// task-320: bucket by threadId so each thread keeps its own context.
|
|
916
|
+
messagesByThread.clear();
|
|
792
917
|
const recent = session.conversationStore.loadRecent(50);
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
918
|
+
for (const m of recent) {
|
|
919
|
+
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
920
|
+
const tid = m.threadId || MAIN_THREAD_ID;
|
|
921
|
+
const bucket = getThreadMessages(tid);
|
|
922
|
+
bucket.push({ role: m.role, content: m.content });
|
|
923
|
+
}
|
|
796
924
|
|
|
797
925
|
sendUnifyEvent({
|
|
798
926
|
type: 'session_ready',
|
|
@@ -840,16 +968,19 @@ export async function handleUnifyLoadHistory(msg) {
|
|
|
840
968
|
* session_ready so the frontend picks up updated models/config.
|
|
841
969
|
*/
|
|
842
970
|
export async function resetUnifySession() {
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
971
|
+
// task-320: abort ALL in-flight controllers (every thread) before
|
|
972
|
+
// tearing down the session. Leaves no dangling round still writing
|
|
973
|
+
// to stdout after shutdown.
|
|
974
|
+
for (const ctrl of abortByThread.values()) {
|
|
975
|
+
try { ctrl.abort(); } catch { /* ignore */ }
|
|
846
976
|
}
|
|
977
|
+
abortByThread.clear();
|
|
847
978
|
if (session) {
|
|
848
979
|
await session.shutdown();
|
|
849
980
|
session = null;
|
|
850
981
|
}
|
|
851
982
|
unifyConversationId = null;
|
|
852
|
-
|
|
983
|
+
messagesByThread.clear();
|
|
853
984
|
|
|
854
985
|
// Re-initialize session immediately so frontend gets updated config
|
|
855
986
|
try {
|
|
@@ -861,14 +992,21 @@ export async function resetUnifySession() {
|
|
|
861
992
|
});
|
|
862
993
|
// task-318 rev-1 fix: wire live setters; see handleUnifyChat.
|
|
863
994
|
installUnifyRuntimeBridge(session);
|
|
995
|
+
// task-317: sweep + re-schedule auto-archive on reset too (the old
|
|
996
|
+
// interval was bound to the previous session; reschedule against the
|
|
997
|
+
// fresh one so timer references don't dangle).
|
|
998
|
+
runAutoArchiveSweep(session);
|
|
999
|
+
scheduleAutoArchive(session);
|
|
864
1000
|
|
|
865
1001
|
unifyConversationId = `unify-${Date.now()}`;
|
|
866
1002
|
|
|
867
|
-
// Restore
|
|
1003
|
+
// Restore per-thread history for LLM context (task-320).
|
|
868
1004
|
const recent = session.conversationStore.loadRecent(50);
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
1005
|
+
for (const m of recent) {
|
|
1006
|
+
if (m.role !== 'user' && m.role !== 'assistant') continue;
|
|
1007
|
+
const tid = m.threadId || MAIN_THREAD_ID;
|
|
1008
|
+
getThreadMessages(tid).push({ role: m.role, content: m.content });
|
|
1009
|
+
}
|
|
872
1010
|
|
|
873
1011
|
sendUnifyEvent({
|
|
874
1012
|
type: 'session_ready',
|