@yeaft/webchat-agent 1.0.335 → 1.0.337

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.
@@ -57,6 +57,11 @@ import { seedDefaultSession, DEFAULT_SESSION_ID } from './seed-default.js';
57
57
  import { nextSessionId, validateVpId, isReservedVpId } from './ids.js';
58
58
  import { scanVpLibrary, DEFAULT_VP_LIB_DIR } from '../vp/vp-store.js';
59
59
  import { seedSummaryIfMissingSync, removeScopeDirSync } from '../memory/store.js';
60
+ import {
61
+ markConversationDirty,
62
+ removeConversationIndexScope,
63
+ } from '../conversation/history-index-state.js';
64
+ import { retireConversationHistoryIndex } from '../conversation/history-index.js';
60
65
  import { ensureSessionConfigFile, saveSessionConfig, loadSessionConfig } from './session-config.js';
61
66
  import { repairSessionStore } from './recovery.js';
62
67
  import {
@@ -75,6 +80,40 @@ import {
75
80
  */
76
81
  const DEFAULT_MEMORY_ROOT = join(homedir(), '.yeaft', 'memory');
77
82
 
83
+ function markSessionConversationDirty(ownerRoot, sessionId, reason, paths = {}) {
84
+ try {
85
+ markConversationDirty({
86
+ ownerRoot,
87
+ scopeKind: 'session',
88
+ scopeId: sessionId,
89
+ reason,
90
+ ...paths,
91
+ });
92
+ } catch (error) {
93
+ console.warn(`[session-crud] failed to mark conversation index dirty for ${sessionId}:`, error?.message || error);
94
+ }
95
+ }
96
+
97
+ function invalidateSessionConversationIndex(ownerRoots, sessionId, reason, paths = {}) {
98
+ for (const ownerRoot of new Set((ownerRoots || []).filter(Boolean))) {
99
+ retireConversationHistoryIndex(ownerRoot, sessionId).catch(error => {
100
+ console.warn(
101
+ `[session-crud] failed to retire conversation index for ${sessionId}:`,
102
+ error?.message || error,
103
+ );
104
+ });
105
+ try {
106
+ removeConversationIndexScope(ownerRoot, sessionId);
107
+ } catch (error) {
108
+ console.warn(
109
+ `[session-crud] failed to remove conversation index for ${sessionId}:`,
110
+ error?.message || error,
111
+ );
112
+ }
113
+ markSessionConversationDirty(ownerRoot, sessionId, reason, paths);
114
+ }
115
+ }
116
+
78
117
  /**
79
118
  * Build the group seed summary body. Uses the group display name + roster
80
119
  * so even an empty conversation has SOMETHING for engine.#prepareAms to
@@ -353,6 +392,12 @@ export function restoreSessionToRegistry(defaultYeaftDir, sessionId, workDir) {
353
392
  handle.close();
354
393
  }
355
394
  copySessionExtras(projectYeaftDir, defaultYeaftDir, sessionId);
395
+ invalidateSessionConversationIndex(
396
+ [defaultYeaftDir],
397
+ sessionId,
398
+ 'restore-session',
399
+ { oldPath: sourceDir, newPath: destDir },
400
+ );
356
401
  addOrUpdateManifestSession(defaultYeaftDir, importedMeta, destDir);
357
402
  unregisterSessionWorkDir(defaultYeaftDir, sessionId);
358
403
  return importedMeta;
@@ -598,9 +643,15 @@ export function archiveSession(yeaftDir, sessionId) {
598
643
  const groupYeaftDir = resolveSessionYeaftDir(yeaftDir, sessionId);
599
644
  const root = sessionsRoot(groupYeaftDir);
600
645
  const srcDir = join(root, sessionId);
601
- // Idempotent nothing on disk, nothing to archive. Workdir-registry is
602
- // still cleared in case it points at a stale row.
646
+ // Idempotent source deletion must still invalidate every derived generation:
647
+ // an active query worker may otherwise serve history after the directory is gone.
603
648
  if (!existsSync(srcDir) || !loadSessionMeta(srcDir)) {
649
+ invalidateSessionConversationIndex(
650
+ [groupYeaftDir, yeaftDir],
651
+ sessionId,
652
+ 'archive-session',
653
+ { oldPath: srcDir },
654
+ );
604
655
  unregisterSessionWorkDir(yeaftDir, sessionId);
605
656
  removeManifestSession(yeaftDir, sessionId);
606
657
  return { sessionId, archivedAs: null, alreadyGone: true };
@@ -610,6 +661,12 @@ export function archiveSession(yeaftDir, sessionId) {
610
661
  const suffix = randomBytes(2).toString('hex');
611
662
  const dstDir = join(root, `.archived-${ts}-${suffix}-${sessionId}`);
612
663
  renameSync(srcDir, dstDir);
664
+ invalidateSessionConversationIndex(
665
+ [groupYeaftDir, yeaftDir],
666
+ sessionId,
667
+ 'archive-session',
668
+ { oldPath: srcDir, newPath: dstDir },
669
+ );
613
670
  unregisterSessionWorkDir(yeaftDir, sessionId);
614
671
  removeManifestSession(yeaftDir, sessionId);
615
672
  return { sessionId, archivedAs: dstDir, alreadyGone: false };
@@ -669,6 +726,12 @@ export function deleteSession(yeaftDir, sessionId, options = {}) {
669
726
  console.warn(`[session-crud] failed to remove memory dir for ${sessionId}:`, err?.message || err);
670
727
  }
671
728
 
729
+ invalidateSessionConversationIndex(
730
+ [groupYeaftDir, yeaftDir],
731
+ sessionId,
732
+ 'delete-session',
733
+ { oldPath: srcDir },
734
+ );
672
735
  unregisterSessionWorkDir(yeaftDir, sessionId);
673
736
  removeManifestSession(yeaftDir, sessionId);
674
737
  return {
@@ -63,6 +63,11 @@ import {
63
63
  import { persistYeaftAttachments, attachmentsForPersistence, persistedAttachmentPreviewPayload } from './attachments.js';
64
64
  import { normalizeSessionMessageQuote, sessionMessageQuotePrompt } from './session-message-quote.js';
65
65
  import { ConversationStore, parseSeqFromId, projectVisibleSessionMessages } from './conversation/persist.js';
66
+ import {
67
+ loadConversationOutlineFromIndex,
68
+ readConversationIndexWindow,
69
+ searchConversationIndex,
70
+ } from './conversation/history-index.js';
66
71
  import { isHiddenConversationRow, isVisibleConversationRow } from './conversation/internal-control.js';
67
72
  import {
68
73
  ProjectStoreError,
@@ -1319,16 +1324,17 @@ function hydrateHistoryAttachmentPreviews(attachments) {
1319
1324
  });
1320
1325
  }
1321
1326
 
1322
- function loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq = null) {
1327
+ function loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq = null, opts = {}) {
1323
1328
  if (!store || !sessionId || !(limit > 0)) return { messages: [], oldestSeq: null, hasMore: false };
1324
1329
 
1325
1330
  let rows = [];
1326
1331
  try {
1327
1332
  if (typeof store.loadVisibleBySession === 'function') {
1328
- const page = store.loadVisibleBySession(sessionId, beforeSeq, limit);
1333
+ const page = store.loadVisibleBySession(sessionId, beforeSeq, limit, opts);
1329
1334
  return {
1330
1335
  messages: (page.messages || []).map(projectPersistedToVisibleHistoryEntry).filter(Boolean),
1331
1336
  oldestSeq: (typeof page.oldestSeq === 'number') ? page.oldestSeq : null,
1337
+ nextBeforeSeq: (typeof page.nextBeforeSeq === 'number') ? page.nextBeforeSeq : null,
1332
1338
  hasMore: !!page.hasMore,
1333
1339
  };
1334
1340
  } else if (typeof store.loadOlderBySession === 'function') {
@@ -1364,6 +1370,7 @@ function loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq = null)
1364
1370
  return {
1365
1371
  messages,
1366
1372
  oldestSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
1373
+ nextBeforeSeq: Number.isFinite(oldestSeq) ? oldestSeq : null,
1367
1374
  hasMore,
1368
1375
  };
1369
1376
  }
@@ -1382,6 +1389,7 @@ function projectVisibleHistoryChunkMessages(messages = []) {
1382
1389
  .filter(Boolean)
1383
1390
  .map(m => ({
1384
1391
  ...(m.id ? { id: m.id } : {}),
1392
+ ...(Number.isFinite(m.seq) ? { seq: m.seq } : {}),
1385
1393
  role: m.role,
1386
1394
  content: m.content,
1387
1395
  ts: m.ts || null,
@@ -1405,7 +1413,7 @@ function projectVisibleHistoryChunkMessages(messages = []) {
1405
1413
  }));
1406
1414
  }
1407
1415
 
1408
- function emitHistoryChunk({ sessionId, messages, mode = 'older', oldestSeq = null, hasMore = false, latestSeq = null, afterSeq = null, turns = null, requestId = null, requestClientId = null, perfTraceId = null }) {
1416
+ function emitHistoryChunk({ sessionId, messages, mode = 'older', oldestSeq = null, nextBeforeSeq = null, hasMore = false, latestSeq = null, afterSeq = null, turns = null, pageKind = null, gapStopAtSeq = null, cacheEpoch = null, requestId = null, requestClientId = null, perfTraceId = null }) {
1409
1417
  const projectedMessages = projectVisibleHistoryChunkMessages(messages);
1410
1418
  // Empty deltas still carry the authoritative safe cursor and clear the
1411
1419
  // browser's syncingAfterSeq fence. Dropping this envelope leaves Session
@@ -1420,10 +1428,14 @@ function emitHistoryChunk({ sessionId, messages, mode = 'older', oldestSeq = nul
1420
1428
  mode,
1421
1429
  messages: projectedMessages,
1422
1430
  oldestSeq,
1431
+ nextBeforeSeq: Number.isFinite(nextBeforeSeq) ? nextBeforeSeq : oldestSeq,
1423
1432
  hasMore: !!hasMore,
1424
1433
  latestSeq,
1425
1434
  afterSeq,
1426
1435
  turns,
1436
+ ...(pageKind ? { pageKind } : {}),
1437
+ ...(Number.isFinite(gapStopAtSeq) ? { gapStopAtSeq } : {}),
1438
+ ...(Number.isFinite(cacheEpoch) ? { cacheEpoch } : {}),
1427
1439
  });
1428
1440
  return projectedMessages;
1429
1441
  }
@@ -1497,6 +1509,7 @@ function emitVisibleHistoryReplay({ store, sessionId, limit, beforeSeq = null, m
1497
1509
  messages: replayEntries,
1498
1510
  mode,
1499
1511
  oldestSeq: visiblePage.oldestSeq,
1512
+ nextBeforeSeq: visiblePage.nextBeforeSeq,
1500
1513
  hasMore: visiblePage.hasMore,
1501
1514
  latestSeq: Number.isFinite(latestSeq) ? latestSeq : null,
1502
1515
  turns: limit,
@@ -1512,6 +1525,7 @@ function emitVisibleHistoryReplay({ store, sessionId, limit, beforeSeq = null, m
1512
1525
  requestId,
1513
1526
  hasMore: visiblePage.hasMore,
1514
1527
  oldestSeq: visiblePage.oldestSeq,
1528
+ nextBeforeSeq: visiblePage.nextBeforeSeq,
1515
1529
  latestSeq: Number.isFinite(latestSeq) ? latestSeq : null,
1516
1530
  }, { sessionId, requestId, requestClientId, perfTraceId });
1517
1531
  return;
@@ -6800,6 +6814,7 @@ export async function handleYeaftLoadHistory(msg) {
6800
6814
  messages: replayEntries,
6801
6815
  mode: 'recent',
6802
6816
  oldestSeq: visiblePage.oldestSeq,
6817
+ nextBeforeSeq: visiblePage.nextBeforeSeq,
6803
6818
  hasMore: visiblePage.hasMore,
6804
6819
  latestSeq,
6805
6820
  turns: limit,
@@ -6842,6 +6857,7 @@ export async function handleYeaftLoadHistory(msg) {
6842
6857
  requestId,
6843
6858
  hasMore,
6844
6859
  oldestSeq,
6860
+ nextBeforeSeq: visiblePage.nextBeforeSeq,
6845
6861
  latestSeq,
6846
6862
  }, { sessionId, requestId, requestClientId, perfTraceId });
6847
6863
  };
@@ -6942,13 +6958,45 @@ export async function handleYeaftLoadHistory(msg) {
6942
6958
  traceDuration('history.handler_total', perfStart, { ok: true });
6943
6959
  }
6944
6960
 
6961
+ function traceHistoryScan({ perfTraceId, phase, sessionId, messageType, start, scanStats, detail = null }) {
6962
+ if (!perfTraceId) return;
6963
+ recordAgentPerfTrace(ctx.CONFIG, {
6964
+ traceId: perfTraceId,
6965
+ phase,
6966
+ sessionId,
6967
+ messageType,
6968
+ durationMs: perfNowMs() - start,
6969
+ bytes: Number(scanStats?.bytes) || 0,
6970
+ detail: {
6971
+ segments: Number(scanStats?.segments) || 0,
6972
+ rows: Number(scanStats?.rows) || 0,
6973
+ legacyFiles: Number(scanStats?.legacyFiles) || 0,
6974
+ ...(detail || {}),
6975
+ },
6976
+ });
6977
+ }
6978
+
6979
+ function scheduleHistoryEventLoopTrace({ perfTraceId, phase, sessionId, messageType }) {
6980
+ if (!perfTraceId) return;
6981
+ const scheduledAt = perfNowMs();
6982
+ setImmediate(() => {
6983
+ recordAgentPerfTrace(ctx.CONFIG, {
6984
+ traceId: perfTraceId,
6985
+ phase,
6986
+ sessionId,
6987
+ messageType,
6988
+ durationMs: perfNowMs() - scheduledAt,
6989
+ });
6990
+ });
6991
+ }
6992
+
6945
6993
  /**
6946
6994
  * Load one lightweight Conversation Outline page. The response contains only
6947
6995
  * visible user/assistant metadata and bounded snippets; full message bodies and
6948
6996
  * tool payloads stay on the Agent. `beforeSeq` is an exclusive older-page
6949
- * cursor, and `includeTotal` avoids recounting after the first page.
6997
+ * cursor, and total counting is explicit because it scans the full transcript.
6950
6998
  *
6951
- * @param {object} msg — { sessionId, beforeSeq, limit, includeTotal }
6999
+ * @param {object} msg — { sessionId, beforeSeq, limit, includeTotal, perfTraceId }
6952
7000
  */
6953
7001
  export async function handleYeaftLoadHistoryOutline(msg) {
6954
7002
  const sessionId = typeof msg?.sessionId === 'string' ? msg.sessionId.trim() : '';
@@ -6971,16 +7019,59 @@ export async function handleYeaftLoadHistoryOutline(msg) {
6971
7019
  return;
6972
7020
  }
6973
7021
 
7022
+ const perfTraceId = typeof msg?.perfTraceId === 'string' && msg.perfTraceId.trim() ? msg.perfTraceId.trim() : null;
7023
+ const scanStats = {};
7024
+ scheduleHistoryEventLoopTrace({
7025
+ perfTraceId,
7026
+ phase: 'history_outline.event_loop_delay',
7027
+ sessionId,
7028
+ messageType: msg?.type || 'yeaft_load_history_outline',
7029
+ });
7030
+ const scanStart = perfNowMs();
6974
7031
  try {
6975
7032
  const defaultYeaftDir = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
6976
7033
  const storeDir = resolveSessionYeaftDir(defaultYeaftDir, sessionId);
6977
- const store = new ConversationStore(storeDir);
6978
- const result = store.loadVisibleOutlineBySession(sessionId, {
6979
- limit,
6980
- beforeSeq,
6981
- includeTotal: msg?.includeTotal !== false,
7034
+ let result;
7035
+ let indexFallback = false;
7036
+ try {
7037
+ result = await loadConversationOutlineFromIndex(storeDir, sessionId, {
7038
+ limit,
7039
+ beforeSeq,
7040
+ cursor: msg?.cursor || null,
7041
+ includeTotal: msg?.includeTotal === true,
7042
+ _waitForBuild: false,
7043
+ });
7044
+ } catch (indexError) {
7045
+ sendToServer({
7046
+ ...response,
7047
+ error: indexError?.code === 'stale_result'
7048
+ ? 'stale_result'
7049
+ : (indexError?.code === 'index_building' ? 'index_building' : 'index_unavailable'),
7050
+ ...(perfTraceId ? { perfTraceId } : {}),
7051
+ });
7052
+ return;
7053
+ }
7054
+ if (!indexFallback) {
7055
+ scanStats.segments = Number(result.indexSourceFiles) || 0;
7056
+ scanStats.bytes = Number(result.indexSourceBytes) || 0;
7057
+ scanStats.rows = Number(result.indexEntryCount) || 0;
7058
+ }
7059
+ traceHistoryScan({
7060
+ perfTraceId,
7061
+ phase: 'history_outline.store_scan',
7062
+ sessionId,
7063
+ messageType: msg?.type || 'yeaft_load_history_outline',
7064
+ start: scanStart,
7065
+ scanStats,
7066
+ detail: {
7067
+ limit,
7068
+ includeTotal: msg?.includeTotal === true,
7069
+ resultCount: result.results.length,
7070
+ indexGeneration: result.indexGeneration || null,
7071
+ indexFallback,
7072
+ },
6982
7073
  });
6983
- sendToServer({ ...response, ...result });
7074
+ sendToServer({ ...response, ...result, ...(perfTraceId ? { perfTraceId } : {}) });
6984
7075
  } catch (err) {
6985
7076
  console.error('[Yeaft] Session history outline failed:', err?.message || err);
6986
7077
  sendToServer({ ...response, error: 'outline_failed' });
@@ -7008,17 +7099,65 @@ export async function handleYeaftSearchHistory(msg) {
7008
7099
  _requestClientId: msg?._requestClientId || null,
7009
7100
  };
7010
7101
 
7011
- if (!sessionId || (query.length < 2 && !senderKey)) {
7102
+ if (!sessionId || (Array.from(query).length < 1 && !senderKey)) {
7012
7103
  sendToServer(response);
7013
7104
  return;
7014
7105
  }
7015
7106
 
7107
+ const perfTraceId = typeof msg?.perfTraceId === 'string' && msg.perfTraceId.trim() ? msg.perfTraceId.trim() : null;
7108
+ const scanStats = {};
7109
+ scheduleHistoryEventLoopTrace({
7110
+ perfTraceId,
7111
+ phase: 'history_search.event_loop_delay',
7112
+ sessionId,
7113
+ messageType: msg?.type || 'yeaft_search_history',
7114
+ });
7115
+ const scanStart = perfNowMs();
7016
7116
  try {
7017
7117
  const defaultYeaftDir = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
7018
7118
  const storeDir = resolveSessionYeaftDir(defaultYeaftDir, sessionId);
7019
- const store = new ConversationStore(storeDir);
7020
- const result = store.searchVisibleBySession(sessionId, query, { limit, beforeSeq, senderKey });
7021
- sendToServer({ ...response, ...result });
7119
+ let result;
7120
+ let indexFallback = false;
7121
+ try {
7122
+ result = await searchConversationIndex(storeDir, sessionId, query, {
7123
+ limit,
7124
+ beforeSeq,
7125
+ cursor: msg?.cursor || null,
7126
+ senderKey,
7127
+ _waitForBuild: false,
7128
+ });
7129
+ } catch (indexError) {
7130
+ sendToServer({
7131
+ ...response,
7132
+ error: indexError?.code === 'stale_result'
7133
+ ? 'stale_result'
7134
+ : (indexError?.code === 'index_building' ? 'index_building' : 'index_unavailable'),
7135
+ ...(perfTraceId ? { perfTraceId } : {}),
7136
+ });
7137
+ return;
7138
+ }
7139
+ if (!indexFallback) {
7140
+ scanStats.segments = Number(result.indexSourceFiles) || 0;
7141
+ scanStats.bytes = Number(result.indexSourceBytes) || 0;
7142
+ scanStats.rows = Number(result.indexEntryCount) || 0;
7143
+ }
7144
+ traceHistoryScan({
7145
+ perfTraceId,
7146
+ phase: 'history_search.store_scan',
7147
+ sessionId,
7148
+ messageType: msg?.type || 'yeaft_search_history',
7149
+ start: scanStart,
7150
+ scanStats,
7151
+ detail: {
7152
+ limit,
7153
+ queryLength: Array.from(query).length,
7154
+ senderFilter: !!senderKey,
7155
+ resultCount: result.results.length,
7156
+ indexGeneration: result.indexGeneration || null,
7157
+ indexFallback,
7158
+ },
7159
+ });
7160
+ sendToServer({ ...response, ...result, ...(perfTraceId ? { perfTraceId } : {}) });
7022
7161
  } catch (err) {
7023
7162
  console.error('[Yeaft] Session history search failed:', err?.message || err);
7024
7163
  sendToServer({ ...response, error: 'search_failed' });
@@ -7030,11 +7169,17 @@ export async function handleYeaftLoadHistoryWindow(msg) {
7030
7169
  const requestId = typeof msg?.requestId === 'string' ? msg.requestId : null;
7031
7170
  const anchorSeq = Number(msg?.anchorSeq);
7032
7171
  const anchorMessageId = typeof msg?.anchorMessageId === 'string' ? msg.anchorMessageId : null;
7172
+ const entryId = typeof msg?.entryId === 'string' ? msg.entryId : null;
7173
+ const indexGeneration = Number(msg?.indexGeneration);
7174
+ const entryStartSeq = Number(msg?.entryStartSeq);
7033
7175
  const response = {
7034
7176
  type: 'yeaft_history_window',
7035
7177
  requestId,
7036
7178
  conversationId: ensureYeaftConversationId(),
7037
7179
  sessionId: sessionId || null,
7180
+ entryId,
7181
+ indexGeneration: Number.isFinite(indexGeneration) ? indexGeneration : null,
7182
+ entryStartSeq: Number.isFinite(entryStartSeq) ? entryStartSeq : null,
7038
7183
  anchorMessageId,
7039
7184
  anchorSeq: Number.isFinite(anchorSeq) ? anchorSeq : null,
7040
7185
  messages: [],
@@ -7043,7 +7188,10 @@ export async function handleYeaftLoadHistoryWindow(msg) {
7043
7188
  _requestClientId: msg?._requestClientId || null,
7044
7189
  };
7045
7190
 
7046
- if (!sessionId || !Number.isFinite(anchorSeq)) {
7191
+ if (!sessionId || !entryId || !anchorMessageId
7192
+ || !Number.isFinite(indexGeneration)
7193
+ || !Number.isFinite(entryStartSeq)
7194
+ || !Number.isFinite(anchorSeq)) {
7047
7195
  sendToServer({ ...response, error: 'invalid_anchor' });
7048
7196
  return;
7049
7197
  }
@@ -7051,19 +7199,34 @@ export async function handleYeaftLoadHistoryWindow(msg) {
7051
7199
  try {
7052
7200
  const defaultYeaftDir = ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR;
7053
7201
  const storeDir = resolveSessionYeaftDir(defaultYeaftDir, sessionId);
7054
- const store = new ConversationStore(storeDir);
7055
- const window = store.loadVisibleWindowBySession(sessionId, anchorSeq, {
7202
+ const loaded = await readConversationIndexWindow(storeDir, sessionId, {
7203
+ indexGeneration,
7204
+ entryId,
7205
+ entryStartSeq,
7206
+ anchorMessageId,
7207
+ anchorSeq,
7056
7208
  beforeTurns: msg?.beforeTurns,
7057
7209
  afterTurns: msg?.afterTurns,
7210
+ maxRows: msg?.maxRows,
7211
+ maxBytes: msg?.maxBytes,
7058
7212
  });
7213
+ if (!loaded?.ok) {
7214
+ sendToServer({ ...response, error: 'stale_result' });
7215
+ return;
7216
+ }
7059
7217
  sendToServer({
7060
7218
  ...response,
7061
- ...window,
7062
- messages: projectVisibleHistoryChunkMessages(window.messages),
7219
+ ...loaded.window,
7220
+ entryEndSeq: loaded.entry.entryEndSeq,
7221
+ sourceMessageIds: loaded.entry.sourceMessageIds,
7222
+ messages: projectVisibleHistoryChunkMessages(loaded.window.messages),
7063
7223
  });
7064
7224
  } catch (err) {
7065
7225
  console.error('[Yeaft] Session history anchor load failed:', err?.message || err);
7066
- sendToServer({ ...response, error: 'window_load_failed' });
7226
+ sendToServer({
7227
+ ...response,
7228
+ error: err?.code === 'stale_result' ? 'stale_result' : 'window_load_failed',
7229
+ });
7067
7230
  }
7068
7231
  }
7069
7232
 
@@ -7086,6 +7249,11 @@ export async function handleYeaftLoadMoreHistory(msg) {
7086
7249
  }
7087
7250
 
7088
7251
  const beforeSeq = (typeof msg.beforeSeq === 'number') ? msg.beforeSeq : null;
7252
+ const pageKind = msg.pageKind === 'gap' ? 'gap' : 'server';
7253
+ const gapStopAtSeq = pageKind === 'gap' && Number.isFinite(msg.gapStopAtSeq)
7254
+ ? msg.gapStopAtSeq
7255
+ : null;
7256
+ const cacheEpoch = Number.isFinite(msg.cacheEpoch) ? msg.cacheEpoch : 0;
7089
7257
  const turns = Math.min(50, (typeof msg.turns === 'number' && msg.turns > 0) ? Math.floor(msg.turns) : 20);
7090
7258
 
7091
7259
  let result;
@@ -7094,8 +7262,12 @@ export async function handleYeaftLoadMoreHistory(msg) {
7094
7262
  const store = session?.conversationStore || new ConversationStore(
7095
7263
  resolveSessionYeaftDir(ctx.CONFIG?.yeaftDir || DEFAULT_YEAFT_DIR, sessionId),
7096
7264
  );
7097
- result = loadVisibleGroupHistoryPage(store, sessionId, turns, beforeSeq);
7098
- traceDuration('history_more.store_load', loadStart, { detail: { count: result.messages?.length || 0, beforeSeq, turns } });
7265
+ result = loadVisibleGroupHistoryPage(store, sessionId, turns, beforeSeq, {
7266
+ stopAtSeq: gapStopAtSeq,
7267
+ });
7268
+ traceDuration('history_more.store_load', loadStart, {
7269
+ detail: { count: result.messages?.length || 0, beforeSeq, turns, pageKind, gapStopAtSeq },
7270
+ });
7099
7271
  } catch (err) {
7100
7272
  console.error('[Yeaft] loadOlderBySession failed:', err.message);
7101
7273
  result = { messages: [], oldestSeq: null, hasMore: false };
@@ -7112,8 +7284,12 @@ export async function handleYeaftLoadMoreHistory(msg) {
7112
7284
  messages: result.messages || [],
7113
7285
  mode: 'older',
7114
7286
  oldestSeq: result.oldestSeq,
7287
+ nextBeforeSeq: result.nextBeforeSeq,
7115
7288
  hasMore: !!result.hasMore,
7116
7289
  turns,
7290
+ pageKind,
7291
+ gapStopAtSeq,
7292
+ cacheEpoch,
7117
7293
  requestId,
7118
7294
  requestClientId,
7119
7295
  perfTraceId,