@yeaft/webchat-agent 1.0.53 → 1.0.55

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": "1.0.53",
3
+ "version": "1.0.55",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/engine.js CHANGED
@@ -34,6 +34,7 @@ import { readSummary as readScopeSummary } from './memory/store.js';
34
34
  import { runAdjust } from './memory/adjust.js';
35
35
  import { isVpSeedBackfillStub } from './memory/seed-backfill.js';
36
36
  import { runStopHooks } from './stop-hooks.js';
37
+ import { perfNowMs, recordAgentPerfTrace } from './perf-trace.js';
37
38
  // Default thread marker for legacy / non-group flows. Group VP runtime may
38
39
  // pass a real threadId per (sessionId, vpId, threadId) engine instance.
39
40
  const MAIN_THREAD_ID = 'main';
@@ -1720,6 +1721,22 @@ export class Engine {
1720
1721
  const runtimeThreadId = (typeof threadId === 'string' && threadId.trim())
1721
1722
  ? threadId.trim()
1722
1723
  : MAIN_THREAD_ID;
1724
+ const perfTraceId = typeof inboundEnvelope?._perfTraceId === 'string' && inboundEnvelope._perfTraceId.trim()
1725
+ ? inboundEnvelope._perfTraceId.trim()
1726
+ : (typeof inboundEnvelope?.perfTraceId === 'string' && inboundEnvelope.perfTraceId.trim() ? inboundEnvelope.perfTraceId.trim() : null);
1727
+ const traceRequest = (phase, extra = {}) => {
1728
+ if (!perfTraceId) return;
1729
+ recordAgentPerfTrace(this.#config, {
1730
+ traceId: perfTraceId,
1731
+ phase,
1732
+ sessionId: runtimeSessionId || null,
1733
+ vpId: this.#vpId || senderVpId || null,
1734
+ turnId: vpTurnId || null,
1735
+ threadId: runtimeThreadId,
1736
+ messageType: 'llm_request',
1737
+ ...extra,
1738
+ });
1739
+ };
1723
1740
 
1724
1741
  // ─── Pre-query: FTS5 Memory Recall + AMS snapshot ─────
1725
1742
  // Memory has a SINGLE render outlet now (DESIGN-PROMPT §3 ③):
@@ -2088,6 +2105,16 @@ export class Engine {
2088
2105
  });
2089
2106
 
2090
2107
  const startTime = Date.now();
2108
+ const requestPerfStart = perfNowMs();
2109
+ let firstEventTraced = false;
2110
+ traceRequest('llm.request_start', {
2111
+ detail: {
2112
+ model: currentModel,
2113
+ loop: turnNumber,
2114
+ messageCount: conversationMessages.length,
2115
+ toolDefCount: toolDefs.length,
2116
+ },
2117
+ });
2091
2118
  let ttfbMs = null; // Time to first token
2092
2119
  let responseText = '';
2093
2120
  const toolCalls = [];
@@ -2288,6 +2315,13 @@ export class Engine {
2288
2315
  if (signal?.aborted) {
2289
2316
  throw new LLMAbortError();
2290
2317
  }
2318
+ if (!firstEventTraced) {
2319
+ firstEventTraced = true;
2320
+ traceRequest('llm.first_event', {
2321
+ durationMs: perfNowMs() - requestPerfStart,
2322
+ detail: { eventType: event.type, model: currentModel },
2323
+ });
2324
+ }
2291
2325
  switch (event.type) {
2292
2326
  case 'text_delta':
2293
2327
  if (ttfbMs === null) ttfbMs = Date.now() - startTime;
@@ -2360,6 +2394,16 @@ export class Engine {
2360
2394
  }
2361
2395
  }
2362
2396
  }
2397
+ traceRequest('llm.request_complete', {
2398
+ durationMs: perfNowMs() - requestPerfStart,
2399
+ ok: true,
2400
+ detail: {
2401
+ model: currentModel,
2402
+ stopReason,
2403
+ inputTokens: totalUsage.inputTokens,
2404
+ outputTokens: totalUsage.outputTokens,
2405
+ },
2406
+ });
2363
2407
  // Stream completed without throwing — reset the retry counter so
2364
2408
  // the next turn starts with a clean budget. In-band adapter errors
2365
2409
  // are converted to throws above so they share the real error path.
@@ -2402,6 +2446,16 @@ export class Engine {
2402
2446
  || err?.name === 'LLMAbortError'
2403
2447
  || (signal?.aborted && /abort/i.test(err?.message || ''));
2404
2448
  if (earlyIsAbort || signal?.aborted) {
2449
+ traceRequest('llm.request_abort', {
2450
+ durationMs: perfNowMs() - requestPerfStart,
2451
+ ok: false,
2452
+ detail: {
2453
+ model: currentModel,
2454
+ abortReason: this.#abortReason || 'external',
2455
+ errorName: err?.name || null,
2456
+ signalAborted: !!signal?.aborted,
2457
+ },
2458
+ });
2405
2459
  endAttemptTrace('aborted');
2406
2460
  yield { type: 'aborted', reason: this.#abortReason || 'external', turnNumber, threadId };
2407
2461
  yield { type: 'turn_end', turnNumber, stopReason: 'aborted', threadId };
@@ -2418,6 +2472,18 @@ export class Engine {
2418
2472
  }
2419
2473
  }
2420
2474
 
2475
+ traceRequest('llm.request_error', {
2476
+ durationMs: perfNowMs() - requestPerfStart,
2477
+ ok: false,
2478
+ detail: {
2479
+ model: currentModel,
2480
+ errorName: err?.name || null,
2481
+ statusCode: err?.statusCode ?? null,
2482
+ retryable: err instanceof LLMRateLimitError || err instanceof LLMServerError,
2483
+ message: String(err?.message || '').slice(0, 200),
2484
+ },
2485
+ });
2486
+
2421
2487
  const earlyIsRateLimit = err instanceof LLMRateLimitError;
2422
2488
  const earlyIsTransient = err instanceof LLMServerError;
2423
2489
  if (earlyIsRateLimit || earlyIsTransient) {
@@ -1,7 +1,32 @@
1
- import { appendFileSync, mkdirSync } from 'fs';
1
+ import { appendFileSync, mkdirSync, readdirSync, rmSync } from 'fs';
2
2
  import { join } from 'path';
3
3
 
4
4
  const MAX_DETAIL_STRING = 512;
5
+ const DEFAULT_RETENTION_DAYS = 3;
6
+ let lastCleanupDay = null;
7
+
8
+ function retentionDays() {
9
+ const raw = Number(process.env.PERF_TRACE_RETENTION_DAYS || process.env.YEAFT_PERF_TRACE_RETENTION_DAYS || DEFAULT_RETENTION_DAYS);
10
+ return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : DEFAULT_RETENTION_DAYS;
11
+ }
12
+
13
+ function cleanupOldTraceFiles(root) {
14
+ const day = new Date().toISOString().slice(0, 10);
15
+ if (lastCleanupDay === day) return;
16
+ lastCleanupDay = day;
17
+ const keepMs = retentionDays() * 24 * 60 * 60 * 1000;
18
+ const cutoff = Date.now() - keepMs;
19
+ try {
20
+ for (const file of readdirSync(root)) {
21
+ const match = file.match(/^(\d{4}-\d{2}-\d{2})\.jsonl$/);
22
+ if (!match) continue;
23
+ const ts = Date.parse(`${match[1]}T00:00:00.000Z`);
24
+ if (Number.isFinite(ts) && ts < cutoff) rmSync(join(root, file), { force: true });
25
+ }
26
+ } catch {
27
+ // best-effort; trace writes must never break the agent
28
+ }
29
+ }
5
30
 
6
31
  function sanitizeString(value, max = MAX_DETAIL_STRING) {
7
32
  if (typeof value !== 'string') return value;
@@ -59,6 +84,7 @@ export function recordAgentPerfTrace(config, event = {}) {
59
84
  };
60
85
  try {
61
86
  mkdirSync(root, { recursive: true });
87
+ cleanupOldTraceFiles(root);
62
88
  appendFileSync(join(root, `${day}.jsonl`), `${JSON.stringify(row)}\n`);
63
89
  return true;
64
90
  } catch (err) {
@@ -71,4 +97,5 @@ export function recordAgentPerfTrace(config, event = {}) {
71
97
 
72
98
  export const __perfTraceForTest = {
73
99
  sanitizeValue,
100
+ cleanupOldTraceFiles,
74
101
  };
@@ -861,11 +861,12 @@ function projectVisibleHistoryChunkMessages(messages = []) {
861
861
  }));
862
862
  }
863
863
 
864
- function emitHistoryChunk({ sessionId, messages, mode = 'older', oldestSeq = null, hasMore = false, latestSeq = null, afterSeq = null, turns = null }) {
864
+ function emitHistoryChunk({ sessionId, messages, mode = 'older', oldestSeq = null, hasMore = false, latestSeq = null, afterSeq = null, turns = null, perfTraceId = null }) {
865
865
  const projectedMessages = projectVisibleHistoryChunkMessages(messages);
866
866
  sendToServer({
867
867
  type: 'yeaft_history_chunk',
868
868
  conversationId: yeaftConversationId,
869
+ ...(perfTraceId ? { perfTraceId } : {}),
869
870
  sessionId,
870
871
  mode,
871
872
  messages: projectedMessages,
@@ -921,7 +922,7 @@ function emitLegacyHistoryOutputFrames(replayEntries) {
921
922
  }
922
923
  }
923
924
 
924
- function emitVisibleHistoryReplay({ store, sessionId, limit, beforeSeq = null, mode = 'recent' }) {
925
+ function emitVisibleHistoryReplay({ store, sessionId, limit, beforeSeq = null, mode = 'recent', perfTraceId = null }) {
925
926
  const visiblePage = sessionId
926
927
  ? loadVisibleGroupHistoryPage(store, sessionId, limit, beforeSeq)
927
928
  : { messages: limit > 0 ? (store.loadRecent?.(limit) || []) : [], oldestSeq: null, hasMore: false };
@@ -943,6 +944,7 @@ function emitVisibleHistoryReplay({ store, sessionId, limit, beforeSeq = null, m
943
944
  hasMore: visiblePage.hasMore,
944
945
  latestSeq: Number.isFinite(latestSeq) ? latestSeq : null,
945
946
  turns: limit,
947
+ perfTraceId,
946
948
  });
947
949
  sendSessionEvent({
948
950
  type: 'history_loaded',
@@ -952,7 +954,7 @@ function emitVisibleHistoryReplay({ store, sessionId, limit, beforeSeq = null, m
952
954
  hasMore: visiblePage.hasMore,
953
955
  oldestSeq: visiblePage.oldestSeq,
954
956
  latestSeq: Number.isFinite(latestSeq) ? latestSeq : null,
955
- });
957
+ }, perfTraceId ? { sessionId, perfTraceId } : undefined);
956
958
  return;
957
959
  }
958
960
 
@@ -969,7 +971,7 @@ function emitVisibleHistoryReplay({ store, sessionId, limit, beforeSeq = null, m
969
971
  hasMore: visiblePage.hasMore,
970
972
  oldestSeq: visiblePage.oldestSeq,
971
973
  latestSeq: Number.isFinite(latestSeq) ? latestSeq : null,
972
- });
974
+ }, perfTraceId ? { sessionId, perfTraceId } : undefined);
973
975
  }
974
976
 
975
977
  /**
@@ -5041,6 +5043,27 @@ export function handleYeaftModelSwitch(msg) {
5041
5043
  */
5042
5044
  export async function handleYeaftLoadHistory(msg) {
5043
5045
  const sessionId = (msg && typeof msg.sessionId === 'string' && msg.sessionId) || null;
5046
+ const perfTraceId = typeof msg?.perfTraceId === 'string' && msg.perfTraceId.trim() ? msg.perfTraceId.trim() : null;
5047
+ const perfStart = perfNowMs();
5048
+ const tracePerf = (phase, extra = {}) => {
5049
+ if (!perfTraceId) return;
5050
+ recordAgentPerfTrace(ctx.CONFIG, {
5051
+ traceId: perfTraceId,
5052
+ phase,
5053
+ sessionId,
5054
+ messageType: msg?.type || 'yeaft_load_history',
5055
+ ...extra,
5056
+ });
5057
+ };
5058
+ const traceDuration = (phase, start, extra = {}) => tracePerf(phase, { durationMs: perfNowMs() - start, ...extra });
5059
+ tracePerf('history.received', {
5060
+ detail: {
5061
+ limit: Number.isFinite(msg?.limit) ? msg.limit : null,
5062
+ afterSeq: Number.isFinite(msg?.afterSeq) ? msg.afterSeq : null,
5063
+ metadataOnly: !!(msg && Number.isFinite(msg.limit) && msg.limit <= 0),
5064
+ sessionLoaded: !!session,
5065
+ },
5066
+ });
5044
5067
  const metadataOnly = msg && Number.isFinite(msg.limit) && msg.limit <= 0;
5045
5068
  // `lim` is now expressed in TURNS, not raw messages. `loadRecent` and
5046
5069
  // `loadRecentBySession` use turn-based slicing so the cut never lands
@@ -5063,14 +5086,19 @@ export async function handleYeaftLoadHistory(msg) {
5063
5086
  afterSeq = session.conversationStore.getMessageSeqById(afterMessageId);
5064
5087
  }
5065
5088
  if (sessionId && afterSeq !== null && typeof session.conversationStore.loadAfterSeqByGroup === 'function') {
5089
+ const loadStart = perfNowMs();
5066
5090
  const delta = session.conversationStore.loadAfterSeqByGroup(sessionId, afterSeq);
5091
+ traceDuration('history.store_load_delta', loadStart, { detail: { count: delta.messages?.length || 0, afterSeq } });
5092
+ const emitStart = perfNowMs();
5067
5093
  const projectedMessages = emitHistoryChunk({
5068
5094
  sessionId,
5069
5095
  messages: delta.messages,
5070
5096
  mode: 'delta',
5071
5097
  latestSeq: delta.latestSeq,
5072
5098
  afterSeq,
5099
+ perfTraceId,
5073
5100
  });
5101
+ traceDuration('history.emit_chunk', emitStart, { detail: { mode: 'delta', count: projectedMessages.length } });
5074
5102
  sendSessionEvent({
5075
5103
  type: 'history_loaded',
5076
5104
  mode: 'delta',
@@ -5078,7 +5106,7 @@ export async function handleYeaftLoadHistory(msg) {
5078
5106
  sessionId,
5079
5107
  latestSeq: delta.latestSeq,
5080
5108
  afterSeq,
5081
- });
5109
+ }, perfTraceId ? { sessionId, perfTraceId } : undefined);
5082
5110
  return;
5083
5111
  }
5084
5112
 
@@ -5087,9 +5115,11 @@ export async function handleYeaftLoadHistory(msg) {
5087
5115
  // opening a group can paint the latest messages quickly; older rows are
5088
5116
  // paged via `yeaft_load_more_history` when the user scrolls upward.
5089
5117
  const limit = (typeof msg.limit === 'number') ? msg.limit : 10;
5118
+ const loadStart = perfNowMs();
5090
5119
  const visiblePage = sessionId
5091
5120
  ? loadVisibleGroupHistoryPage(session.conversationStore, sessionId, limit)
5092
5121
  : { messages: limit > 0 ? pickRecent(session.conversationStore, limit) : [], oldestSeq: null, hasMore: false };
5122
+ traceDuration('history.store_load_recent', loadStart, { detail: { rawCount: visiblePage.messages?.length || 0, limit } });
5093
5123
  // Legacy compact.md is a non-group fallback only. For group replay, reading
5094
5124
  // it makes every group show "has compact" once any legacy/non-scoped compact
5095
5125
  // exists, even when this group has no scoped summary.
@@ -5107,6 +5137,7 @@ export async function handleYeaftLoadHistory(msg) {
5107
5137
  }
5108
5138
 
5109
5139
  if (sessionId) {
5140
+ const emitStart = perfNowMs();
5110
5141
  emitHistoryChunk({
5111
5142
  sessionId,
5112
5143
  messages: replayEntries,
@@ -5115,7 +5146,9 @@ export async function handleYeaftLoadHistory(msg) {
5115
5146
  hasMore: visiblePage.hasMore,
5116
5147
  latestSeq,
5117
5148
  turns: limit,
5149
+ perfTraceId,
5118
5150
  });
5151
+ traceDuration('history.emit_chunk', emitStart, { detail: { mode: 'recent', count: replayEntries.length } });
5119
5152
  } else {
5120
5153
  emitLegacyHistoryOutputFrames(replayEntries);
5121
5154
  }
@@ -5152,7 +5185,7 @@ export async function handleYeaftLoadHistory(msg) {
5152
5185
  hasMore,
5153
5186
  oldestSeq,
5154
5187
  latestSeq,
5155
- });
5188
+ }, perfTraceId ? { sessionId, perfTraceId } : undefined);
5156
5189
  };
5157
5190
 
5158
5191
  if (!session) {
@@ -5166,34 +5199,45 @@ export async function handleYeaftLoadHistory(msg) {
5166
5199
  // skill scans, memory index sync). The conversation markdown store is the
5167
5200
  // source of truth and can be opened cheaply, so replay the visible message
5168
5201
  // window immediately, then finish loadSession below for actual turns.
5202
+ const coldStoreStart = perfNowMs();
5169
5203
  const coldStore = new ConversationStore(yeaftDir);
5204
+ traceDuration('history.cold_store_open', coldStoreStart);
5170
5205
  if (sessionId && (afterSeqRaw !== null || afterMessageId)) {
5171
5206
  let afterSeq = afterSeqRaw;
5172
5207
  if (afterSeq === null && afterMessageId && typeof coldStore.getMessageSeqById === 'function') {
5173
5208
  afterSeq = coldStore.getMessageSeqById(afterMessageId);
5174
5209
  }
5210
+ const loadStart = perfNowMs();
5175
5211
  const delta = afterSeq !== null && typeof coldStore.loadAfterSeqByGroup === 'function'
5176
5212
  ? coldStore.loadAfterSeqByGroup(sessionId, afterSeq)
5177
5213
  : { messages: [], latestSeq: null };
5214
+ traceDuration('history.store_load_delta', loadStart, { detail: { count: delta.messages?.length || 0, afterSeq, cold: true } });
5215
+ const emitStart = perfNowMs();
5178
5216
  const projectedMessages = emitHistoryChunk({
5179
5217
  sessionId,
5180
5218
  messages: delta.messages,
5181
5219
  mode: 'delta',
5182
5220
  latestSeq: delta.latestSeq,
5183
5221
  afterSeq,
5222
+ perfTraceId,
5184
5223
  });
5185
- sendSessionEvent({ type: 'history_loaded', mode: 'delta', count: projectedMessages.length, sessionId, latestSeq: delta.latestSeq, afterSeq });
5224
+ traceDuration('history.emit_chunk', emitStart, { detail: { mode: 'delta', count: projectedMessages.length, cold: true } });
5225
+ sendSessionEvent({ type: 'history_loaded', mode: 'delta', count: projectedMessages.length, sessionId, latestSeq: delta.latestSeq, afterSeq }, perfTraceId ? { sessionId, perfTraceId } : undefined);
5186
5226
  } else if (!metadataOnly) {
5187
- emitVisibleHistoryReplay({ store: coldStore, sessionId, limit, mode: 'recent' });
5227
+ const replayStart = perfNowMs();
5228
+ emitVisibleHistoryReplay({ store: coldStore, sessionId, limit, mode: 'recent', perfTraceId });
5229
+ traceDuration('history.cold_replay', replayStart, { detail: { mode: 'recent', limit } });
5188
5230
  }
5189
5231
  historyAlreadyReplayed = true;
5190
5232
 
5233
+ const bootStart = perfNowMs();
5191
5234
  session = await loadSession({
5192
5235
  dir: yeaftDir,
5193
5236
  skipMCP: false,
5194
5237
  skipSkills: false,
5195
5238
  serverMode: true,
5196
5239
  });
5240
+ traceDuration('history.load_session_runtime', bootStart);
5197
5241
  installYeaftRuntimeBridge(session);
5198
5242
 
5199
5243
  // Per-group history hydrates lazily via getOrCreateSessionHistory.
@@ -5201,9 +5245,15 @@ export async function handleYeaftLoadHistory(msg) {
5201
5245
  // group's tape so the next user message sees on-disk state. When
5202
5246
  // it doesn't (legacy callers), do nothing — the per-group lazy
5203
5247
  // hydration handles it.
5204
- if (sessionId) setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
5248
+ if (sessionId) {
5249
+ const hydrateStart = perfNowMs();
5250
+ setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
5251
+ traceDuration('history.hydrate_group_history', hydrateStart);
5252
+ }
5205
5253
  } else {
5254
+ const replayStart = perfNowMs();
5206
5255
  replayHistoryFromStore();
5256
+ traceDuration('history.replay_from_store', replayStart);
5207
5257
  historyAlreadyReplayed = true;
5208
5258
  }
5209
5259
 
@@ -5211,7 +5261,9 @@ export async function handleYeaftLoadHistory(msg) {
5211
5261
  // Re-entering an existing session with a (possibly new) group filter:
5212
5262
  // re-seed THIS group's history from disk so it doesn't carry stale
5213
5263
  // in-memory state into the next turn's context.
5264
+ const hydrateStart = perfNowMs();
5214
5265
  setGroupHistory(sessionId, hydrateGroupHistory(sessionId));
5266
+ traceDuration('history.hydrate_group_history_final', hydrateStart);
5215
5267
  }
5216
5268
 
5217
5269
  // Always replay session_ready so refresh / reconnect rebuilds UI state, but
@@ -5220,9 +5272,15 @@ export async function handleYeaftLoadHistory(msg) {
5220
5272
  // tick so the browser can paint messages before VP/session/dream snapshots.
5221
5273
  scheduleYeaftLoadHistoryMetadataReplay(sessionId);
5222
5274
 
5223
- if (historyAlreadyReplayed) return;
5275
+ if (historyAlreadyReplayed) {
5276
+ traceDuration('history.handler_total', perfStart, { ok: true });
5277
+ return;
5278
+ }
5224
5279
 
5280
+ const replayStart = perfNowMs();
5225
5281
  replayHistoryFromStore();
5282
+ traceDuration('history.replay_from_store', replayStart);
5283
+ traceDuration('history.handler_total', perfStart, { ok: true });
5226
5284
  }
5227
5285
 
5228
5286
  /**
@@ -5240,8 +5298,17 @@ export async function handleYeaftLoadHistory(msg) {
5240
5298
  */
5241
5299
  export async function handleYeaftLoadMoreHistory(msg) {
5242
5300
  const sessionId = (msg && typeof msg.sessionId === 'string' && msg.sessionId) || null;
5301
+ const perfTraceId = typeof msg?.perfTraceId === 'string' && msg.perfTraceId.trim() ? msg.perfTraceId.trim() : null;
5302
+ const perfStart = perfNowMs();
5303
+ const tracePerf = (phase, extra = {}) => {
5304
+ if (!perfTraceId) return;
5305
+ recordAgentPerfTrace(ctx.CONFIG, { traceId: perfTraceId, phase, sessionId, messageType: msg?.type || 'yeaft_load_more_history', ...extra });
5306
+ };
5307
+ const traceDuration = (phase, start, extra = {}) => tracePerf(phase, { durationMs: perfNowMs() - start, ...extra });
5308
+ tracePerf('history_more.received');
5243
5309
  if (!session || !sessionId) {
5244
- emitHistoryChunk({ sessionId, messages: [], mode: 'older', oldestSeq: null, hasMore: false });
5310
+ emitHistoryChunk({ sessionId, messages: [], mode: 'older', oldestSeq: null, hasMore: false, perfTraceId });
5311
+ traceDuration('history_more.handler_total', perfStart, { ok: false, detail: { missingSession: !session, missingSessionId: !sessionId } });
5245
5312
  return;
5246
5313
  }
5247
5314
 
@@ -5250,16 +5317,20 @@ export async function handleYeaftLoadMoreHistory(msg) {
5250
5317
 
5251
5318
  let result;
5252
5319
  try {
5320
+ const loadStart = perfNowMs();
5253
5321
  result = loadVisibleGroupHistoryPage(session.conversationStore, sessionId, turns, beforeSeq);
5322
+ traceDuration('history_more.store_load', loadStart, { detail: { count: result.messages?.length || 0, beforeSeq, turns } });
5254
5323
  } catch (err) {
5255
5324
  console.error('[Yeaft] loadOlderBySession failed:', err.message);
5256
5325
  result = { messages: [], oldestSeq: null, hasMore: false };
5326
+ tracePerf('history_more.store_error', { ok: false, detail: { errorName: err?.name || null, errorMessage: err?.message || String(err) } });
5257
5327
  }
5258
5328
 
5259
5329
  // Wire shape mirrors handleYeaftLoadHistory's projection: only visible
5260
5330
  // user / assistant text rows. Internal reflection/system-only rows stay
5261
5331
  // server-side, and stable ids + speaker attribution ride with each row
5262
5332
  // so older-history prepend renders exactly like refresh replay.
5333
+ const emitStart = perfNowMs();
5263
5334
  emitHistoryChunk({
5264
5335
  sessionId,
5265
5336
  messages: result.messages || [],
@@ -5267,7 +5338,10 @@ export async function handleYeaftLoadMoreHistory(msg) {
5267
5338
  oldestSeq: result.oldestSeq,
5268
5339
  hasMore: !!result.hasMore,
5269
5340
  turns,
5341
+ perfTraceId,
5270
5342
  });
5343
+ traceDuration('history_more.emit_chunk', emitStart, { detail: { count: result.messages?.length || 0 } });
5344
+ traceDuration('history_more.handler_total', perfStart, { ok: true });
5271
5345
  }
5272
5346
 
5273
5347
  /**