@yeaft/webchat-agent 0.1.734 → 0.1.735

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.
@@ -36,7 +36,7 @@ import { sendToServer, flushMessageBuffer } from './buffer.js';
36
36
  import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
37
37
  import { loadMcpServers, updateMcpConfig } from '../mcp.js';
38
38
  import { getLlmConfig, updateLlmConfig, getUnifySettings, updateUnifySettings, getSearchSettings, updateSearchSettings, fetchTavilyUsage } from '../unify/config-api.js';
39
- import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, broadcastLanguageChange } from '../unify/web-bridge.js';
39
+ import { handleUnifyGroupChat, handleUnifyModeSwitch, handleUnifyModelSwitch, resetUnifySession, handleUnifyLoadHistory, handleUnifyLoadMoreHistory, handleUnifyAbortThread, handleUnifyAbortAll, handleUnifyAbortTurn, handleUnifyVpSubscribe, handleUnifyVpCreate, handleUnifyVpUpdate, handleUnifyVpDelete, handleUnifyVpRead, handleUnifyFetchSummaryHistory, handleUnifyFeatureCrud, handleUnifyListGroups, handleUnifyCreateGroup, handleUnifyRenameGroup, handleUnifyUpdateGroup, handleUnifyArchiveGroup, handleUnifyDeleteGroup, handleUnifyAddMember, handleUnifyRemoveMember, handleUnifySetDefaultVp, handleUnifyDreamTrigger, broadcastLanguageChange } from '../unify/web-bridge.js';
40
40
 
41
41
  export async function handleMessage(msg) {
42
42
  switch (msg.type) {
@@ -403,6 +403,10 @@ export async function handleMessage(msg) {
403
403
  await handleUnifyLoadHistory(msg);
404
404
  break;
405
405
 
406
+ case 'unify_load_more_history':
407
+ await handleUnifyLoadMoreHistory(msg);
408
+ break;
409
+
406
410
  case 'unify_mode_switch':
407
411
  handleUnifyModeSwitch(msg);
408
412
  break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.734",
3
+ "version": "0.1.735",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -55,6 +55,20 @@ export function estimateTokens(text) {
55
55
  return Math.ceil(text.length / 4);
56
56
  }
57
57
 
58
+ /**
59
+ * Parse the global monotonic sequence number out of a message id of the
60
+ * form `m####`. Returns NaN for malformed ids. Used by the pagination
61
+ * cursor (`loadOlderByGroup`) to compare ids numerically without having
62
+ * to trust file-system sort order.
63
+ *
64
+ * @param {string} id
65
+ * @returns {number}
66
+ */
67
+ export function parseSeqFromId(id) {
68
+ const m = String(id || '').match(/^m(\d+)$/);
69
+ return m ? parseInt(m[1], 10) : NaN;
70
+ }
71
+
58
72
  // ─── Frontmatter helpers ─────────────────────────────────────
59
73
 
60
74
  /**
@@ -527,6 +541,66 @@ export class ConversationStore {
527
541
  return this.loadRecentByGroup(groupId, Infinity);
528
542
  }
529
543
 
544
+ /**
545
+ * Pagination-cursor read: load the page of `turnsLimit` TURNS that ends
546
+ * just before `beforeSeq` (exclusive) for the given `groupId`. Used by
547
+ * the Unify "Load older messages" UI to walk backwards through history
548
+ * one click at a time.
549
+ *
550
+ * Crucially, this scans BOTH hot (`messages/`) and cold (`cold/`) dirs
551
+ * — `#getNextSeq` is global across both, and `moveToCold` is a `rename`
552
+ * that never reseqs, so cold ids are strictly < hot ids and a flat
553
+ * `[...cold, ...hot]` concat is already chronological. Crossing the
554
+ * hot→cold boundary is therefore transparent to the caller.
555
+ *
556
+ * `hasMore` is computed in TURNS (not raw message count). It's true iff
557
+ * the slice we returned still leaves an earlier turn boundary unread in
558
+ * the filtered prefix — i.e. there's at least one more page to fetch.
559
+ *
560
+ * `pairSanitize` runs as a defensive secondary pass. Turn-boundary cuts
561
+ * are already pair-safe, but historical / hand-edited stores may
562
+ * contain orphan tool_use/tool_result pairs.
563
+ *
564
+ * @param {string} groupId — required; null/empty returns empty result
565
+ * @param {number|null} beforeSeq — exclusive upper bound on message
566
+ * sequence id. Special cases:
567
+ * - `null` / `undefined` / non-finite (e.g. `Infinity`, `NaN`) → start
568
+ * from the newest (no upper bound).
569
+ * - `0` is a VALID finite cutoff that excludes everything (since seqs
570
+ * start at 1). Distinct from `null`. A caller writing
571
+ * `loadOlderByGroup(g, store.firstSeq || 0, ...)` will silently get
572
+ * an empty page — pass `null` if you mean "from newest".
573
+ * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS] — max turns per page
574
+ * @returns {{ messages: object[], oldestSeq: number|null, hasMore: boolean }}
575
+ */
576
+ loadOlderByGroup(groupId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
577
+ if (!groupId) return { messages: [], oldestSeq: null, hasMore: false };
578
+ const hot = this.#loadFromDir(this.#msgDir, Infinity);
579
+ const cold = this.#loadFromDir(this.#coldDir, Infinity);
580
+ // Cold ids strictly < hot ids by construction → chronological concat.
581
+ const all = [...cold, ...hot];
582
+ const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
583
+ const prefix = all.filter(m => m && m.groupId === groupId
584
+ && parseSeqFromId(m.id) < cutoff);
585
+ if (prefix.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
586
+ const sliced = pairSanitize(sliceLastNTurns(prefix, turnsLimit));
587
+ // Turn-based hasMore: there's an EARLIER turn boundary we didn't keep.
588
+ // Compare seqs (not object identity) — pairSanitize / sliceLastNTurns
589
+ // return references today, but a future normalization pass that clones
590
+ // rows would silently flip identity-compare to always-true.
591
+ const oldestSlicedSeq = sliced.length ? parseSeqFromId(sliced[0].id) : NaN;
592
+ const oldestPrefixSeq = parseSeqFromId(prefix[0].id);
593
+ const hasMore = sliced.length > 0
594
+ && Number.isFinite(oldestSlicedSeq)
595
+ && Number.isFinite(oldestPrefixSeq)
596
+ && oldestSlicedSeq > oldestPrefixSeq;
597
+ // Defend the cursor at the source: a malformed id surfaces as NaN here
598
+ // and a NaN cursor would round-trip back as a poison `beforeSeq` that
599
+ // degrades to "give me the newest page again".
600
+ const oldestSeq = Number.isFinite(oldestSlicedSeq) ? oldestSlicedSeq : null;
601
+ return { messages: sliced, oldestSeq, hasMore };
602
+ }
603
+
530
604
  /**
531
605
  * Count hot messages.
532
606
  *
@@ -53,6 +53,7 @@ import {
53
53
  import { createFeatureArc } from './feature-arc.js';
54
54
  import { getFeatureStore } from './tools/feature-tools.js';
55
55
  import { persistUnifyAttachments, attachmentsForPersistence } from './attachments.js';
56
+ import { parseSeqFromId } from './conversation/persist.js';
56
57
 
57
58
  /** @type {import('./session.js').Session | null} */
58
59
  let session = null;
@@ -2594,6 +2595,32 @@ export async function handleUnifyLoadHistory(msg) {
2594
2595
  }
2595
2596
  }
2596
2597
 
2598
+ // Compute the pagination cursor for the bootstrap load so the frontend
2599
+ // knows whether a "Load older messages" hint should be shown and where
2600
+ // to start the next page. The cursor is the seq of the oldest replayed
2601
+ // message; `hasMore` is true iff there's an earlier message in the
2602
+ // group that we did NOT replay.
2603
+ let hasMore = false;
2604
+ let oldestSeq = null;
2605
+ if (groupId && messages.length > 0) {
2606
+ const firstId = messages[0].id;
2607
+ const seq = parseSeqFromId(firstId);
2608
+ // Defend against malformed ids: a NaN cursor would round-trip back as
2609
+ // a poison `beforeSeq` and degrade subsequent paginations to "give me
2610
+ // the newest page again". Surface as null instead.
2611
+ oldestSeq = Number.isFinite(seq) ? seq : null;
2612
+ if (oldestSeq != null) {
2613
+ // Consult the store for whether anything older exists in the same
2614
+ // group. Cheap: a single extra `loadOlderByGroup` with turns=1.
2615
+ try {
2616
+ const probe = session.conversationStore.loadOlderByGroup(groupId, oldestSeq, 1);
2617
+ hasMore = probe.messages.length > 0;
2618
+ } catch (err) {
2619
+ console.error('[Unify] history-load probe failed:', err.message);
2620
+ }
2621
+ }
2622
+ }
2623
+
2597
2624
  sendUnifyEvent({
2598
2625
  type: 'history_loaded',
2599
2626
  count: messages.length,
@@ -2601,6 +2628,71 @@ export async function handleUnifyLoadHistory(msg) {
2601
2628
  totalHot: session.conversationStore.countHot(),
2602
2629
  totalCold: session.conversationStore.countCold(),
2603
2630
  groupId,
2631
+ hasMore,
2632
+ oldestSeq,
2633
+ });
2634
+ }
2635
+
2636
+ /**
2637
+ * Handle a "load older messages" pagination request. Reads `turns` more
2638
+ * turns of history strictly older than `beforeSeq` for `groupId`, and
2639
+ * emits them in a single `unify_history_chunk` envelope (NOT a
2640
+ * `unify_output` — that pipeline appends, but the frontend needs to
2641
+ * PREPEND these older messages above what it already has).
2642
+ *
2643
+ * Tool replay is NOT included in this PR — same projection as
2644
+ * `handleUnifyLoadHistory` (user / assistant text only). On any internal
2645
+ * failure we still emit an empty chunk so the spinner clears.
2646
+ *
2647
+ * @param {object} msg — { groupId, beforeSeq, turns }
2648
+ */
2649
+ export async function handleUnifyLoadMoreHistory(msg) {
2650
+ const groupId = (msg && typeof msg.groupId === 'string' && msg.groupId) || null;
2651
+ const emit = (payload) => sendToServer({
2652
+ type: 'unify_history_chunk',
2653
+ conversationId: unifyConversationId,
2654
+ groupId,
2655
+ ...payload,
2656
+ });
2657
+
2658
+ if (!session || !groupId) {
2659
+ emit({ messages: [], oldestSeq: null, hasMore: false });
2660
+ return;
2661
+ }
2662
+
2663
+ const beforeSeq = (typeof msg.beforeSeq === 'number') ? msg.beforeSeq : null;
2664
+ const turns = (typeof msg.turns === 'number' && msg.turns > 0) ? msg.turns : 20;
2665
+
2666
+ let result;
2667
+ try {
2668
+ result = session.conversationStore.loadOlderByGroup(groupId, beforeSeq, turns);
2669
+ } catch (err) {
2670
+ console.error('[Unify] loadOlderByGroup failed:', err.message);
2671
+ result = { messages: [], oldestSeq: null, hasMore: false };
2672
+ }
2673
+
2674
+ // Wire shape mirrors handleUnifyLoadHistory's projection: only user /
2675
+ // assistant text rows. Tool_use / tool_result replay is out of scope
2676
+ // for this PR (today's bootstrap path drops them too).
2677
+ //
2678
+ // We intentionally do NOT ship `id` or `time` over the wire:
2679
+ // `handleUnifyHistoryChunk` reads only role / content / groupId. The
2680
+ // pagination cursor (`oldestSeq`) is sent once at the envelope level,
2681
+ // so per-row ids are dead weight. `time` would be useful if we render
2682
+ // "5 days ago" stamps on older history rows — when that ships, add it
2683
+ // back here and consume it in conversationHandler.
2684
+ const projected = (result.messages || [])
2685
+ .filter(m => m && (m.role === 'user' || m.role === 'assistant'))
2686
+ .map(m => ({
2687
+ role: m.role,
2688
+ content: m.content,
2689
+ groupId: m.groupId || null,
2690
+ }));
2691
+
2692
+ emit({
2693
+ messages: projected,
2694
+ oldestSeq: result.oldestSeq,
2695
+ hasMore: !!result.hasMore,
2604
2696
  });
2605
2697
  }
2606
2698