@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.496",
3
+ "version": "0.1.498",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -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); }
@@ -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
- /** @type {AbortController | null} */
25
- let currentAbort = null;
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
- /** Accumulated conversation messages for context continuity across queries.
34
- * Each entry is { role: 'user'|'assistant', content: string|Array }.
35
- * Cleared on session reset or consolidation. */
36
- let conversationMessages = [];
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
- conversationMessages = [];
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 conversationMessages from persisted history for LLM context
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
- conversationMessages = recent
446
- .filter(m => m.role === 'user' || m.role === 'assistant')
447
- .map(m => ({ role: m.role, content: m.content }));
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
- // ─── Cancel any in-flight query ──
465
- if (currentAbort) {
466
- currentAbort.abort();
467
- currentAbort = null;
468
- }
469
-
470
- currentAbort = new AbortController();
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 (currentAbort) {
582
+ if (!abortCtrl.signal.aborted) {
479
583
  console.error(`[Unify] query timeout after ${QUERY_TIMEOUT_MS / 1000}s of silence — aborting`);
480
- currentAbort.abort();
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: currentAbort.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
- conversationMessages.push({ role: 'user', content: cleanedPrompt });
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
- conversationMessages.push({ role: 'assistant', content: fullText });
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
- // Don't report abort errors but still send result to unblock frontend
553
- if (err.name === 'AbortError') {
554
- sendUnifyOutput({
555
- type: 'assistant',
556
- message: {
557
- content: [{
558
- type: 'text',
559
- text: '⚠️ Query timed out no response from LLM. Please try again.',
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
- currentAbort = null;
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 conversationMessages from persisted history for LLM context
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
- conversationMessages = recent
794
- .filter(m => m.role === 'user' || m.role === 'assistant')
795
- .map(m => ({ role: m.role, content: m.content }));
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
- if (currentAbort) {
844
- currentAbort.abort();
845
- currentAbort = null;
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
- conversationMessages = [];
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 conversation history for LLM context
1003
+ // Restore per-thread history for LLM context (task-320).
868
1004
  const recent = session.conversationStore.loadRecent(50);
869
- conversationMessages = recent
870
- .filter(m => m.role === 'user' || m.role === 'assistant')
871
- .map(m => ({ role: m.role, content: m.content }));
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',