@yeaft/webchat-agent 0.1.1090 → 0.1.1091

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.1090",
3
+ "version": "0.1.1091",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -27,7 +27,7 @@ import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, rename
27
27
  import { join, basename } from 'path';
28
28
  import { isPermissionError } from '../init.js';
29
29
  import { pairSanitize } from '../pair-sanitize.js';
30
- import { countTurns, indexOfNthTurnFromEnd, sliceLastNTurns } from '../turn-utils.js';
30
+ import { countTurns, indexOfNthTurnFromEnd, sliceLastNTurns, stripVpMentionPrefix } from '../turn-utils.js';
31
31
 
32
32
  /**
33
33
  * Default cold-start "recent window" size, expressed in TURNS (not raw
@@ -167,6 +167,17 @@ function compareMessagesBySeq(a, b) {
167
167
  return String(a?.time || '').localeCompare(String(b?.time || ''));
168
168
  }
169
169
 
170
+ function canonicalUserTurnContent(content) {
171
+ if (typeof content === 'string') return stripVpMentionPrefix(content);
172
+ if (!Array.isArray(content)) return null;
173
+ const text = content
174
+ .filter(part => part && typeof part === 'object' && part.type === 'text')
175
+ .map(part => typeof part.text === 'string' ? part.text : '')
176
+ .join('\n')
177
+ .trim();
178
+ return text ? stripVpMentionPrefix(text) : null;
179
+ }
180
+
170
181
  // ─── Frontmatter helpers ─────────────────────────────────────
171
182
 
172
183
  /**
@@ -1057,19 +1068,24 @@ export class ConversationStore {
1057
1068
  if (!sessionId || !(turnsLimit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
1058
1069
 
1059
1070
  const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
1060
- const hot = this.#loadVisibleFromDirsBySession([...this.#sessionMessageDirs('messages', sessionId), this.#legacyMsgDir], sessionId, cutoff);
1061
- const cold = this.#loadVisibleFromDirsBySession([...this.#sessionMessageDirs('cold', sessionId), this.#legacyColdDir], sessionId, cutoff);
1062
- const visible = [...cold, ...hot];
1063
- if (visible.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
1071
+ const page = this.#loadVisibleWindowBySession(
1072
+ [
1073
+ ...this.#sessionMessageDirs('messages', sessionId),
1074
+ this.#legacyMsgDir,
1075
+ ...this.#sessionMessageDirs('cold', sessionId),
1076
+ this.#legacyColdDir,
1077
+ ],
1078
+ sessionId,
1079
+ cutoff,
1080
+ turnsLimit
1081
+ );
1064
1082
 
1065
- const startIdx = indexOfNthTurnFromEnd(visible, turnsLimit);
1066
- const start = startIdx === -1 ? 0 : startIdx;
1067
1083
  // Visible history is for UI replay, not LLM context. The visible loader
1068
1084
  // already excludes tool-result rows, so running pairSanitize here can
1069
1085
  // incorrectly treat tool-using assistant replies as orphaned tool arcs and
1070
1086
  // drop/trim VP messages. Strip tool-call metadata instead and keep the
1071
1087
  // user-visible assistant text for the conversation pane.
1072
- const messages = visible.slice(start).map(m => {
1088
+ const messages = page.messages.map(m => {
1073
1089
  if (m && m.role === 'assistant' && Array.isArray(m.toolCalls) && m.toolCalls.length > 0) {
1074
1090
  const { toolCalls, ...rest } = m;
1075
1091
  return rest;
@@ -1077,16 +1093,11 @@ export class ConversationStore {
1077
1093
  return m;
1078
1094
  });
1079
1095
  const oldestSeq = messages.length ? parseSeqFromId(messages[0].id) : null;
1080
- const firstVisibleSeq = parseSeqFromId(visible[0].id);
1081
- const hasMore = messages.length > 0
1082
- && Number.isFinite(oldestSeq)
1083
- && Number.isFinite(firstVisibleSeq)
1084
- && oldestSeq > firstVisibleSeq;
1085
1096
 
1086
1097
  return {
1087
1098
  messages,
1088
1099
  oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
1089
- hasMore,
1100
+ hasMore: page.hasMore,
1090
1101
  };
1091
1102
  }
1092
1103
 
@@ -1733,9 +1744,85 @@ export class ConversationStore {
1733
1744
  return total;
1734
1745
  }
1735
1746
 
1736
- #loadVisibleFromDirsBySession(dirs, sessionId, beforeSeq) {
1737
- return dirs.flatMap(dir => this.#loadVisibleFromDirBySession(dir, sessionId, beforeSeq))
1738
- .sort(compareMessagesBySeq);
1747
+ #loadVisibleWindowBySession(dirs, sessionId, beforeSeq, turnsLimit) {
1748
+ const candidates = [];
1749
+ const seen = new Set();
1750
+ for (const dir of dirs) {
1751
+ if (!existsSync(dir)) continue;
1752
+ let files = [];
1753
+ try {
1754
+ files = readdirSync(dir);
1755
+ } catch (err) {
1756
+ if (!isPermissionError(err)) throw err;
1757
+ continue;
1758
+ }
1759
+ for (const file of files) {
1760
+ if (!file.endsWith('.md')) continue;
1761
+ const seq = parseSeqFromId(basename(file, '.md'));
1762
+ if (!Number.isFinite(seq) || seq >= beforeSeq) continue;
1763
+ const path = join(dir, file);
1764
+ if (seen.has(path)) continue;
1765
+ seen.add(path);
1766
+ candidates.push({ seq, path });
1767
+ }
1768
+ }
1769
+
1770
+ candidates.sort((a, b) => b.seq - a.seq);
1771
+
1772
+ const selected = [];
1773
+ const pendingBoundaryTail = [];
1774
+ let turnsSeen = 0;
1775
+ let openCanonical = null;
1776
+ let boundaryCanonical = null;
1777
+ let hasMore = false;
1778
+
1779
+ for (const candidate of candidates) {
1780
+ let raw = '';
1781
+ try {
1782
+ raw = readFileSync(candidate.path, 'utf8');
1783
+ } catch (err) {
1784
+ if (!isPermissionError(err)) throw err;
1785
+ continue;
1786
+ }
1787
+ if (!raw.includes(`sessionId: ${sessionId}`)) continue;
1788
+ if (!raw.includes('role: user') && !raw.includes('role: assistant')) continue;
1789
+
1790
+ const parsed = parseMessage(raw);
1791
+ if (!parsed || parsed.sessionId !== sessionId) continue;
1792
+ if (parsed._reflection || parsed.internal || parsed.systemOnly || parsed.systemOnlyMessage) continue;
1793
+ if (parsed.role !== 'user' && parsed.role !== 'assistant') continue;
1794
+
1795
+ if (boundaryCanonical !== null) {
1796
+ if (parsed.role !== 'user') {
1797
+ pendingBoundaryTail.push(parsed);
1798
+ continue;
1799
+ }
1800
+
1801
+ const canonical = canonicalUserTurnContent(parsed.content);
1802
+ if (canonical !== boundaryCanonical) {
1803
+ hasMore = true;
1804
+ break;
1805
+ }
1806
+
1807
+ if (pendingBoundaryTail.length > 0) {
1808
+ selected.push(...pendingBoundaryTail.splice(0));
1809
+ }
1810
+ selected.push(parsed);
1811
+ continue;
1812
+ }
1813
+
1814
+ selected.push(parsed);
1815
+ if (parsed.role === 'user') {
1816
+ const canonical = canonicalUserTurnContent(parsed.content);
1817
+ if (canonical != null && canonical !== openCanonical) {
1818
+ turnsSeen += 1;
1819
+ openCanonical = canonical;
1820
+ if (turnsSeen === turnsLimit) boundaryCanonical = canonical;
1821
+ }
1822
+ }
1823
+ }
1824
+
1825
+ return { messages: selected.reverse(), hasMore };
1739
1826
  }
1740
1827
 
1741
1828
  // task-314: per-thread sub-directory for forked threads.
@@ -1796,32 +1883,6 @@ export class ConversationStore {
1796
1883
  return messages;
1797
1884
  }
1798
1885
 
1799
- #loadVisibleFromDirBySession(dir, sessionId, beforeSeq) {
1800
- if (!existsSync(dir)) return [];
1801
-
1802
- const files = readdirSync(dir)
1803
- .filter(f => f.endsWith('.md'))
1804
- .sort();
1805
-
1806
- const out = [];
1807
- for (const file of files) {
1808
- const seq = parseSeqFromId(basename(file, '.md'));
1809
- if (!Number.isFinite(seq) || seq >= beforeSeq) continue;
1810
-
1811
- const raw = readFileSync(join(dir, file), 'utf8');
1812
- if (!raw.includes(`sessionId: ${sessionId}`)) continue;
1813
- if (!raw.includes('role: user') && !raw.includes('role: assistant')) continue;
1814
-
1815
- const parsed = parseMessage(raw);
1816
- if (!parsed || parsed.sessionId !== sessionId) continue;
1817
- if (parsed._reflection || parsed.internal || parsed.systemOnly || parsed.systemOnlyMessage) continue;
1818
- if (parsed.role !== 'user' && parsed.role !== 'assistant') continue;
1819
- out.push(parsed);
1820
- }
1821
-
1822
- return out;
1823
- }
1824
-
1825
1886
  /**
1826
1887
  * Determine the next sequence number by scanning existing files.
1827
1888
  * @returns {number}
@@ -22,6 +22,7 @@ import { join } from 'node:path';
22
22
  import { COLLAB_TOOL_POLICY } from './tools/registry.js';
23
23
  import { existsSync } from 'node:fs';
24
24
  import { randomUUID } from 'node:crypto';
25
+ import { DEFAULT_YEAFT_DIR } from './init.js';
25
26
  import { buildDreamOutputSnapshot } from './dream/output-snapshot.js';
26
27
  import { Engine } from './engine.js';
27
28
  import { loadSession } from './session.js';
@@ -60,7 +61,7 @@ import {
60
61
  trimSnapshotForBudget,
61
62
  } from './history-compact.js';
62
63
  import { persistYeaftAttachments, attachmentsForPersistence, persistedAttachmentPreviewPayload } from './attachments.js';
63
- import { parseSeqFromId } from './conversation/persist.js';
64
+ import { ConversationStore, parseSeqFromId } from './conversation/persist.js';
64
65
  import { sliceLastNTurns } from './turn-utils.js';
65
66
  import { pairSanitize } from './pair-sanitize.js';
66
67
  import { filterSnapshotForVp } from './snapshot-filter.js';
@@ -766,6 +767,76 @@ function loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq = null)
766
767
  };
767
768
  }
768
769
 
770
+ function ensureYeaftConversationId() {
771
+ if (!yeaftConversationId) yeaftConversationId = `yeaft-${Date.now()}`;
772
+ return yeaftConversationId;
773
+ }
774
+
775
+ function emitVisibleHistoryReplay({ store, sessionId, limit, beforeSeq = null, mode = 'recent' }) {
776
+ const visiblePage = sessionId
777
+ ? loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq)
778
+ : { messages: limit > 0 ? (store.loadRecent?.(limit) || []) : [], oldestSeq: null, hasMore: false };
779
+ const replayEntries = sessionId
780
+ ? visiblePage.messages
781
+ : visiblePage.messages
782
+ .map(projectPersistedToVisibleHistoryEntry)
783
+ .filter(Boolean);
784
+
785
+ for (const entry of replayEntries) {
786
+ if (entry.role === 'user') {
787
+ sendSessionOutputFrame({
788
+ type: 'user',
789
+ message: {
790
+ content: entry.content,
791
+ id: entry.id || null,
792
+ ...(Array.isArray(entry.attachments) && entry.attachments.length > 0 ? { attachments: hydrateHistoryAttachmentPreviews(entry.attachments) } : {}),
793
+ },
794
+ ts: entry.ts || null,
795
+ }, { sessionId: entry.sessionId || null, threadId: entry.threadId || 'main', turnId: entry.turnId || entry.threadId || 'main' });
796
+ } else if (entry.role === 'assistant') {
797
+ const envelopeOpts = {
798
+ sessionId: entry.sessionId || null,
799
+ threadId: entry.threadId || 'main',
800
+ turnId: entry.turnId || entry.threadId || 'main',
801
+ };
802
+ if (entry.speakerVpId) envelopeOpts.vpId = entry.speakerVpId;
803
+ sendSessionOutputFrame({
804
+ type: 'assistant',
805
+ message: { id: entry.id || null, content: [{ type: 'text', text: entry.content }] },
806
+ ts: entry.ts || null,
807
+ }, envelopeOpts);
808
+ if (Array.isArray(entry.toolCalls) && entry.toolCalls.length > 0) {
809
+ sendSessionOutputFrame({
810
+ type: 'assistant',
811
+ message: {
812
+ content: [{
813
+ type: 'tool_summary',
814
+ count: entry.toolCalls.length,
815
+ omittedCount: entry.toolCalls.length,
816
+ source: 'history',
817
+ }],
818
+ },
819
+ ts: entry.ts || null,
820
+ }, envelopeOpts);
821
+ }
822
+ sendSessionOutputFrame({ type: 'result', result_text: '' }, envelopeOpts);
823
+ }
824
+ }
825
+
826
+ const latestSeq = replayEntries.length
827
+ ? parseSeqFromId(replayEntries[replayEntries.length - 1]?.id)
828
+ : null;
829
+ sendSessionEvent({
830
+ type: 'history_loaded',
831
+ mode,
832
+ count: replayEntries.length,
833
+ sessionId,
834
+ hasMore: visiblePage.hasMore,
835
+ oldestSeq: visiblePage.oldestSeq,
836
+ latestSeq: Number.isFinite(latestSeq) ? latestSeq : null,
837
+ });
838
+ }
839
+
769
840
  /**
770
841
  * Hydrate a freshly-created GroupContext's history from the on-disk
771
842
  * conversation store. Returns an empty array if the session isn't
@@ -873,6 +944,7 @@ export function __testGroupHistory(sessionId) {
873
944
  */
874
945
  export function __testSetSession(sessionLike) {
875
946
  session = sessionLike;
947
+ if (!sessionLike) yeaftConversationId = null;
876
948
  }
877
949
 
878
950
  /**
@@ -4303,18 +4375,60 @@ export async function handleYeaftLoadHistory(msg) {
4303
4375
  // (DEFAULT_RECENT_TURNS = 20 turns).
4304
4376
  const pickRecent = (store, lim) =>
4305
4377
  sessionId ? store.loadRecentBySession(sessionId, lim) : store.loadRecent(lim);
4378
+ let historyAlreadyReplayed = false;
4306
4379
 
4307
4380
  if (!session) {
4308
- const yeaftDir = ctx.CONFIG?.yeaftDir;
4381
+ const yeaftDir = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
4382
+ const afterSeqRaw = (msg && Number.isFinite(msg.afterSeq)) ? msg.afterSeq : null;
4383
+ const afterMessageId = (msg && typeof msg.afterMessageId === 'string') ? msg.afterMessageId : null;
4384
+ const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
4385
+ ensureYeaftConversationId();
4386
+
4387
+ // First paint must not wait for full Yeaft runtime boot (MCP connects,
4388
+ // skill scans, memory index sync). The conversation markdown store is the
4389
+ // source of truth and can be opened cheaply, so replay the visible message
4390
+ // window immediately, then finish loadSession below for actual turns.
4391
+ const coldStore = new ConversationStore(yeaftDir);
4392
+ if (sessionId && (afterSeqRaw !== null || afterMessageId)) {
4393
+ let afterSeq = afterSeqRaw;
4394
+ if (afterSeq === null && afterMessageId && typeof coldStore.getMessageSeqById === 'function') {
4395
+ afterSeq = coldStore.getMessageSeqById(afterMessageId);
4396
+ }
4397
+ const delta = afterSeq !== null && typeof coldStore.loadAfterSeqByGroup === 'function'
4398
+ ? coldStore.loadAfterSeqByGroup(sessionId, afterSeq)
4399
+ : { messages: [], latestSeq: null };
4400
+ for (const entry of delta.messages) {
4401
+ if (entry.role === 'user') {
4402
+ sendSessionOutputFrame({
4403
+ type: 'user',
4404
+ message: { content: entry.content, id: entry.id || null },
4405
+ ts: entry.ts || null,
4406
+ }, { sessionId: entry.sessionId || null, threadId: entry.threadId || 'main', turnId: entry.turnId || entry.threadId || 'main' });
4407
+ } else if (entry.role === 'assistant') {
4408
+ const envelopeOpts = { sessionId: entry.sessionId || null, threadId: entry.threadId || 'main', turnId: entry.turnId || entry.threadId || 'main' };
4409
+ if (entry.speakerVpId) envelopeOpts.vpId = entry.speakerVpId;
4410
+ sendSessionOutputFrame({
4411
+ type: 'assistant',
4412
+ message: { id: entry.id || null, content: [{ type: 'text', text: entry.content }] },
4413
+ ts: entry.ts || null,
4414
+ }, envelopeOpts);
4415
+ sendSessionOutputFrame({ type: 'result', result_text: '' }, envelopeOpts);
4416
+ }
4417
+ }
4418
+ sendSessionEvent({ type: 'history_loaded', mode: 'delta', count: delta.messages.length, sessionId, latestSeq: delta.latestSeq, afterSeq });
4419
+ } else {
4420
+ emitVisibleHistoryReplay({ store: coldStore, sessionId, limit, mode: 'recent' });
4421
+ }
4422
+ historyAlreadyReplayed = true;
4423
+
4309
4424
  session = await loadSession({
4310
- ...(yeaftDir && { dir: yeaftDir }),
4425
+ dir: yeaftDir,
4311
4426
  skipMCP: false,
4312
4427
  skipSkills: false,
4313
4428
  serverMode: true,
4314
4429
  });
4315
4430
  installYeaftRuntimeBridge(session);
4316
4431
 
4317
- yeaftConversationId = `yeaft-${Date.now()}`;
4318
4432
  refreshLiveSessionConfig();
4319
4433
  hydrateYeaftStatusFromSession(session, { reason: 'history_load', emitEvent: true });
4320
4434
 
@@ -4362,6 +4476,8 @@ export async function handleYeaftLoadHistory(msg) {
4362
4476
  console.warn('[Yeaft] vp-status snapshot broadcast (replay) failed:', err?.message || err);
4363
4477
  }
4364
4478
 
4479
+ if (historyAlreadyReplayed) return;
4480
+
4365
4481
  // Delta path: caller knows the latest seq (or message id) it has cached
4366
4482
  // and wants only the messages that arrived after that cursor. Returns
4367
4483
  // early with mode:'delta' so the frontend can append+dedupe instead of