@yeaft/webchat-agent 0.1.891 → 0.1.893

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.891",
3
+ "version": "0.1.893",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -966,6 +966,46 @@ export class ConversationStore {
966
966
  };
967
967
  }
968
968
 
969
+ /**
970
+ * Load messages strictly after a seq cursor, ordered by seq ascending.
971
+ * Used by the web client to fetch "everything new since my latest known
972
+ * message" when re-entering a session — the delta path.
973
+ *
974
+ * @param {string} sessionId
975
+ * @param {number|null} afterSeq — exclusive lower bound
976
+ * @param {{ limit?: number }} [opts]
977
+ * @returns {{ messages: object[], latestSeq: number|null }}
978
+ */
979
+ loadAfterSeqByGroup(sessionId, afterSeq, opts = {}) {
980
+ if (!sessionId) return { messages: [], latestSeq: null };
981
+ const limit = Number.isFinite(opts.limit) && opts.limit > 0 ? opts.limit : 500;
982
+ const cutoff = Number.isFinite(afterSeq) && afterSeq >= 0 ? afterSeq : null;
983
+ if (cutoff === null) return { messages: [], latestSeq: null };
984
+ const hot = this.#loadGroupHotMessages(sessionId);
985
+ const cold = this.#loadGroupColdMessages(sessionId);
986
+ const all = [...cold, ...hot].sort(compareMessagesBySeq);
987
+ const after = all.filter((m) => {
988
+ if (!m || m.sessionId !== sessionId) return false;
989
+ const seq = parseSeqFromId(m.id);
990
+ return Number.isFinite(seq) && seq > cutoff;
991
+ });
992
+ const sliced = pairSanitize(after.slice(0, limit));
993
+ const lastSeq = sliced.length ? parseSeqFromId(sliced[sliced.length - 1].id) : null;
994
+ return { messages: sliced, latestSeq: Number.isFinite(lastSeq) ? lastSeq : null };
995
+ }
996
+
997
+ /**
998
+ * Convenience: extract the numeric seq embedded in a message id.
999
+ *
1000
+ * @param {string} messageId
1001
+ * @returns {number|null}
1002
+ */
1003
+ getMessageSeqById(messageId) {
1004
+ if (!messageId || typeof messageId !== 'string') return null;
1005
+ const seq = parseSeqFromId(messageId);
1006
+ return Number.isFinite(seq) ? seq : null;
1007
+ }
1008
+
969
1009
  /**
970
1010
  * Count hot messages.
971
1011
  *
package/yeaft/session.js CHANGED
@@ -323,11 +323,15 @@ export async function loadSession(options = {}) {
323
323
  } catch (err) {
324
324
  console.warn(`[Yeaft] topUpDefaultVps failed: ${err?.message || err}`);
325
325
  }
326
- try {
327
- ensureDefaultSessionIfEmpty(yeaftDir, { memoryRoot: join(yeaftDir, 'memory') });
328
- } catch (err) {
329
- console.warn(`[Yeaft] ensureDefaultSessionIfEmpty failed: ${err?.message || err}`);
330
- }
326
+ // fix-yeaft-session-server-persistence: stop auto-seeding a
327
+ // `grp_default` per agent. Previously every agent that booted with
328
+ // zero sessions would manufacture an empty default group, which on
329
+ // the unified sidebar shows up as a phantom row distinct from the
330
+ // user's real session — and on agent switch it stole the active-
331
+ // session slot. With server-side persistence the user's actual
332
+ // yeaft sessions are now hydrated from the DB; if they have none,
333
+ // the sidebar shows the empty state + "create session" CTA, which
334
+ // is the explicit behaviour the user asked for.
331
335
 
332
336
  // task-fix-memory-load: backfill summary.md for VPs / groups created
333
337
  // before the create-time seed was added. Without this, an existing
@@ -1928,12 +1928,12 @@ function handleEngineEvent(event, hctx) {
1928
1928
  is_error: event.isError || false,
1929
1929
  }],
1930
1930
  }, envelope);
1931
- // Tool finished. The engine may either (a) emit more text-deltas
1932
- // before end_turn, or (b) go straight to end_turn. Settle the
1933
- // row back to 'thinking' if (a), the next text_delta will flip
1934
- // it to 'streaming'; if (b), runVpTurn's finally will flip it to
1935
- // 'idle'. Either way we never strand the row in 'tool'.
1936
- maybeTransitionVpStatus(hctx, 'thinking');
1931
+ // Tool finished. Do NOT speculatively flip to 'thinking' the
1932
+ // engine may emit more text-deltas (→ 'streaming') OR go straight
1933
+ // to end_turn (→ 'idle' via runVpTurn's finally). The old
1934
+ // speculative transition caused a visible 'tool thinking
1935
+ // streaming' flicker on every tool call. Hold the 'tool' state
1936
+ // until the next real event arrives.
1937
1937
  break;
1938
1938
 
1939
1939
  case 'turn_start':
@@ -1971,9 +1971,11 @@ function handleEngineEvent(event, hctx) {
1971
1971
  threadId: hctx.threadId || event.threadId || 'main',
1972
1972
  turnId: hctx.turnId,
1973
1973
  stopReason: event.stopReason,
1974
+ reason: 'route_forward',
1974
1975
  detail: event.detail || null,
1975
1976
  ts: Date.now(),
1976
1977
  }, envelope);
1978
+ if (typeof hctx.markTurnEnd === 'function') hctx.markTurnEnd('route_forward');
1977
1979
  }
1978
1980
  break;
1979
1981
 
@@ -2221,15 +2223,13 @@ export async function handleYeaftSessionSend(msg) {
2221
2223
  const dir = join(sessionRoot, sessionId);
2222
2224
  if (existsSync(dir) && loadSessionMeta(dir)) {
2223
2225
  sessionHandle = openSession(sessionRoot, sessionId);
2224
- } else if (sessionId === 'grp_default') {
2225
- try {
2226
- const seeded = seedDefaultSession(groupYeaftDir, { memoryRoot: join(groupYeaftDir, 'memory') });
2227
- sessionHandle = seeded.group;
2228
- } catch (seedErr) {
2229
- seedFailed = true;
2230
- console.warn('[Yeaft] yeaft_group_chat: seedDefaultSession failed', seedErr?.message || seedErr);
2231
- }
2232
2226
  } else {
2227
+ // fix-yeaft-session-server-persistence: the `grp_default` on-the-
2228
+ // fly seed used to manufacture a missing session here. That
2229
+ // hid the "session not found" error and re-created the phantom
2230
+ // default-group row across agents. Now we surface the not-found
2231
+ // case so the web can show an "agent offline / session missing"
2232
+ // hint instead of silently creating a different session.
2233
2233
  console.warn('[Yeaft] yeaft_group_chat: sessionId %s not found', sessionId);
2234
2234
  }
2235
2235
  } catch (err) {
@@ -2709,6 +2709,38 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2709
2709
 
2710
2710
  const envelope = { sessionId, vpId, threadId, turnId };
2711
2711
 
2712
+ // Per-message turn lifecycle: track start ts + which terminal reason
2713
+ // we'll emit. `emitVpTurnEnd` is idempotent (route_forward emits inside
2714
+ // the engine loop; normal end_turn / abort / error emit at runVpTurn
2715
+ // boundaries — without idempotency a route_forward turn would emit
2716
+ // twice). `markTurnEnd` lets the engine-event handler tell us that
2717
+ // it already emitted, so we don't emit a duplicate at the runVpTurn
2718
+ // normal-completion path.
2719
+ const turnStartAt = Date.now();
2720
+ let turnEndReason = 'end_turn';
2721
+ let turnEndEmitted = false;
2722
+ let turnEndDetail = null;
2723
+ const markTurnEnd = (reason) => { turnEndEmitted = true; turnEndReason = reason; };
2724
+ const emitVpTurnEnd = (reason, detail = null) => {
2725
+ if (turnEndEmitted) return;
2726
+ turnEndEmitted = true;
2727
+ try {
2728
+ sendYeaftEvent({
2729
+ type: 'vp_turn_end',
2730
+ sessionId,
2731
+ vpId,
2732
+ threadId: threadId || 'main',
2733
+ turnId,
2734
+ reason,
2735
+ durationMs: Date.now() - turnStartAt,
2736
+ detail: detail || null,
2737
+ ts: Date.now(),
2738
+ }, envelope);
2739
+ } catch (err) {
2740
+ console.warn('[Yeaft] vp_turn_end emit failed:', err?.message || err);
2741
+ }
2742
+ };
2743
+
2712
2744
  try {
2713
2745
  if (session?.dreamScheduler) {
2714
2746
  session.dreamScheduler.noteUserMessage();
@@ -2773,6 +2805,7 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2773
2805
  threadId,
2774
2806
  thread,
2775
2807
  appendedUserPrompts,
2808
+ markTurnEnd,
2776
2809
  };
2777
2810
  // Always trim the snapshot before passing to engine.query. This is
2778
2811
  // the second-line defense (history-compact only fires above 30K
@@ -2816,6 +2849,10 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2816
2849
  type: 'result',
2817
2850
  result_text: '',
2818
2851
  }, envelope);
2852
+ // Normal end-of-turn (no route_forward, no abort, no error). Emit
2853
+ // the message-status terminal so the web client can flip the
2854
+ // assistant message status from 'pending' → 'completed'.
2855
+ emitVpTurnEnd('end_turn');
2819
2856
  } finally {
2820
2857
  if (queryTimer) clearTimeout(queryTimer);
2821
2858
  }
@@ -2827,10 +2864,13 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2827
2864
  result_text: '',
2828
2865
  stopped: true,
2829
2866
  }, envelope);
2867
+ emitVpTurnEnd('aborted');
2830
2868
  return;
2831
2869
  }
2832
2870
 
2833
2871
  console.error('[Yeaft] query error:', err);
2872
+ turnEndReason = 'errored';
2873
+ turnEndDetail = { message: err?.message || String(err) };
2834
2874
 
2835
2875
  // vp-status: surface a transient `error` state so the row's status
2836
2876
  // label flips red for the brief window before the outer finally
@@ -2872,14 +2912,23 @@ async function runVpTurn({ prompt, promptParts = null, sessionId, vpId, threadId
2872
2912
  result_text: '',
2873
2913
  }, envelope);
2874
2914
  } finally {
2915
+ // Emit terminal vp_turn_end for the error path (normal + abort + route
2916
+ // already emitted above). Done before settleIdle so the web client
2917
+ // sees status flip BEFORE the broker's idle event lands.
2918
+ if (turnEndReason === 'errored') emitVpTurnEnd('errored', turnEndDetail);
2875
2919
  // vp-status: guaranteed-settle. Regardless of how the turn exited
2876
2920
  // (normal completion, AbortError early-return, caught exception),
2877
- // the row must drop back to 'idle'. Wrapped in its own try so a
2878
- // broker bug can't mask the original error.
2879
- try {
2880
- getVpStatusBroker().settleIdle({ sessionId, vpId, threadId: threadId || 'main', title: thread?.title || '' });
2881
- } catch (err) {
2882
- console.warn('[Yeaft] vp-status settleIdle failed:', err?.message || err);
2921
+ // the row must drop back to 'idle'. EXCEPTION: when the turn errored,
2922
+ // we keep the broker's 'error' state visible until the next turn
2923
+ // starts, so the user can see something failed instead of a silent
2924
+ // green-state turn end. Wrapped in its own try so a broker bug
2925
+ // can't mask the original error.
2926
+ if (turnEndReason !== 'errored') {
2927
+ try {
2928
+ getVpStatusBroker().settleIdle({ sessionId, vpId, threadId: threadId || 'main', title: thread?.title || '' });
2929
+ } catch (err) {
2930
+ console.warn('[Yeaft] vp-status settleIdle failed:', err?.message || err);
2931
+ }
2883
2932
  }
2884
2933
  // fix-vp-multi-thread (bug 2): the bridge tracks per-thread status
2885
2934
  // on `thread.status` separately from the broker. Multiple sites
@@ -3614,6 +3663,55 @@ export async function handleYeaftLoadHistory(msg) {
3614
3663
  console.warn('[Yeaft] vp-status snapshot broadcast (replay) failed:', err?.message || err);
3615
3664
  }
3616
3665
 
3666
+ // Delta path: caller knows the latest seq (or message id) it has cached
3667
+ // and wants only the messages that arrived after that cursor. Returns
3668
+ // early with mode:'delta' so the frontend can append+dedupe instead of
3669
+ // replacing the pane.
3670
+ const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
3671
+ const afterMessageId = (msg && typeof msg.afterMessageId === 'string') ? msg.afterMessageId : null;
3672
+ let afterSeq = afterSeqRaw;
3673
+ if (afterSeq === null && afterMessageId && typeof session.conversationStore.getMessageSeqById === 'function') {
3674
+ afterSeq = session.conversationStore.getMessageSeqById(afterMessageId);
3675
+ }
3676
+ if (sessionId && afterSeq !== null && typeof session.conversationStore.loadAfterSeqByGroup === 'function') {
3677
+ const delta = session.conversationStore.loadAfterSeqByGroup(sessionId, afterSeq);
3678
+ for (const entry of delta.messages) {
3679
+ if (entry.role === 'user') {
3680
+ sendYeaftOutput({
3681
+ type: 'user',
3682
+ message: {
3683
+ content: entry.content,
3684
+ id: entry.id || null,
3685
+ ...(Array.isArray(entry.attachments) && entry.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(entry.attachments) } : {}),
3686
+ },
3687
+ ts: entry.ts || null,
3688
+ }, { sessionId: entry.sessionId || null, threadId: entry.threadId || 'main', turnId: entry.turnId || entry.threadId || 'main' });
3689
+ } else if (entry.role === 'assistant') {
3690
+ const envelopeOpts = {
3691
+ sessionId: entry.sessionId || null,
3692
+ threadId: entry.threadId || 'main',
3693
+ turnId: entry.turnId || entry.threadId || 'main',
3694
+ };
3695
+ if (entry.speakerVpId) envelopeOpts.vpId = entry.speakerVpId;
3696
+ sendYeaftOutput({
3697
+ type: 'assistant',
3698
+ message: { id: entry.id || null, content: [{ type: 'text', text: entry.content }] },
3699
+ ts: entry.ts || null,
3700
+ }, envelopeOpts);
3701
+ sendYeaftOutput({ type: 'result', result_text: '' }, envelopeOpts);
3702
+ }
3703
+ }
3704
+ sendYeaftEvent({
3705
+ type: 'history_loaded',
3706
+ mode: 'delta',
3707
+ count: delta.messages.length,
3708
+ sessionId,
3709
+ latestSeq: delta.latestSeq,
3710
+ afterSeq,
3711
+ });
3712
+ return;
3713
+ }
3714
+
3617
3715
  // `msg.limit` is the replay-scrollback request from the frontend (UI
3618
3716
  // history pane, not engine context). Keep the bootstrap window small so
3619
3717
  // opening a group can paint the latest messages quickly; older rows are
@@ -3684,8 +3782,18 @@ export async function handleYeaftLoadHistory(msg) {
3684
3782
  hasCompactSummaryFlag = session.conversationStore.hasAnyCompactSummaryForGroup(sessionId);
3685
3783
  }
3686
3784
 
3785
+ // Latest seq cursor in the recent-mode reply lets the frontend stamp its
3786
+ // delta cursor on first paint, so the next session-switch can ask for
3787
+ // afterSeq instead of a full recent-N replay.
3788
+ let latestSeq = null;
3789
+ if (replayEntries.length > 0 && typeof session.conversationStore.getMessageSeqById === 'function') {
3790
+ const last = replayEntries[replayEntries.length - 1];
3791
+ if (last && last.id) latestSeq = session.conversationStore.getMessageSeqById(last.id);
3792
+ }
3793
+
3687
3794
  sendYeaftEvent({
3688
3795
  type: 'history_loaded',
3796
+ mode: 'recent',
3689
3797
  count: replayEntries.length,
3690
3798
  hasCompactSummary: hasCompactSummaryFlag,
3691
3799
  totalHot: session.conversationStore.countHot(),
@@ -3693,6 +3801,7 @@ export async function handleYeaftLoadHistory(msg) {
3693
3801
  sessionId,
3694
3802
  hasMore,
3695
3803
  oldestSeq,
3804
+ latestSeq,
3696
3805
  });
3697
3806
  }
3698
3807