@yeaft/webchat-agent 1.0.52 → 1.0.54

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.52",
3
+ "version": "1.0.54",
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
  };
package/yeaft/prompts.js CHANGED
@@ -26,7 +26,6 @@ import { readFileSync, existsSync } from 'fs';
26
26
  import { join, dirname } from 'path';
27
27
  import { fileURLToPath } from 'url';
28
28
  import { getRuntimePlatformInfo, renderRuntimePlatformPrompt } from './runtime-platform.js';
29
- import { DEFAULT_VPS } from './vp/seed-defaults.js';
30
29
 
31
30
  // ─── Template Loading (one-time at startup) ──────────────────────
32
31
 
@@ -463,50 +462,17 @@ function selectVpPersonaName(vpPersona, effectiveLang) {
463
462
  return typeof vpPersona.displayName === 'string' ? vpPersona.displayName.trim() : '';
464
463
  }
465
464
 
466
- function normalizePersonaBodyForMatch(value) {
467
- return typeof value === 'string' ? value.trim() : '';
468
- }
469
-
470
- function selectMigratedStockPersonaBody(vpPersona, body, effectiveLang) {
471
- const vpId = typeof vpPersona?.vpId === 'string' ? vpPersona.vpId.trim() : '';
472
- const persistedBody = normalizePersonaBodyForMatch(body);
473
- if (!vpId || !persistedBody) return null;
474
-
475
- const stock = DEFAULT_VPS.find(vp => vp.vpId === vpId);
476
- if (!stock) return null;
477
-
478
- const acceptedBodies = [
479
- stock.legacyPersonaEn,
480
- stock.legacyPersona,
481
- stock.personaEn,
482
- ...(Array.isArray(stock.legacyPersonas) ? stock.legacyPersonas : []),
483
- ]
484
- .map(normalizePersonaBodyForMatch)
485
- .filter(Boolean);
486
-
487
- if (!acceptedBodies.includes(persistedBody)) return null;
488
- const selected = effectiveLang === 'zh' ? stock.personaZh : stock.personaEn;
489
- return normalizePersonaBodyForMatch(selected) || null;
490
- }
491
-
492
465
  function selectVpPersonaBody(vpPersona, effectiveLang) {
493
466
  const body = typeof vpPersona.persona === 'string' ? vpPersona.persona.trim() : '';
494
467
 
495
- // role.md persona is the canonical soul. Exact stock legacy bodies are mapped
496
- // to the current authored source so old seeded role.md files do not leak
497
- // bilingual/English souls into localized system prompts before top-up runs.
498
- if (body) {
499
- const migratedStockBody = selectMigratedStockPersonaBody(vpPersona, body, effectiveLang);
500
- if (migratedStockBody) return migratedStockBody;
501
-
502
- if (body.includes('<!-- lang:')) {
503
- const selected = extractExactLangSection(body, effectiveLang);
504
- return selected !== null ? selected : body;
505
- }
506
- return body;
468
+ // role.md is the canonical soul source. Stock legacy migration belongs in
469
+ // seed-topup, not in prompt rendering; prompt rendering should only select
470
+ // authored language sections when they are present.
471
+ if (body && body.includes('<!-- lang:')) {
472
+ const selected = extractExactLangSection(body, effectiveLang);
473
+ return selected !== null ? selected : body;
507
474
  }
508
-
509
- return '';
475
+ return body;
510
476
  }
511
477
 
512
478
 
@@ -9,24 +9,30 @@
9
9
  * (b) a forced overwrite that would clobber their hand edits.
10
10
  *
11
11
  * This module runs on every agent start alongside `seedDefaultVps` and does
12
- * two minimal, additive things:
12
+ * three safe stock-maintenance things:
13
13
  *
14
14
  * 1. **Top-up missing stock VPs**. If a vpId from `DEFAULT_VPS` is not
15
15
  * on disk, `createVp()` it. This keeps product-owned defaults such as
16
16
  * Omni and the expanded role roster visible in session/member pickers.
17
17
  *
18
- * 2. **Backfill the `area` frontmatter line** on existing seeded VPs whose
19
- * role.md predates the area field. The body is left BYTE-IDENTICAL —
20
- * we splice a single `area: <bucket>` line into the YAML frontmatter
21
- * and write nothing else. If the user has authored their own `area`,
22
- * we keep theirs.
18
+ * 2. **Backfill missing stock frontmatter** (`area`, `nameZh`, `roleZh`) on
19
+ * existing seeded VPs whose role.md predates those fields. The persona body
20
+ * is left BYTE-IDENTICAL for these metadata backfills. If the user has
21
+ * authored their own value, we keep theirs.
22
+ *
23
+ * 3. **Migrate exact historical stock bodies to canonical localized bodies**.
24
+ * This is intentionally a write-time migration, not a prompt-rendering
25
+ * fallback: once top-up sees an old shipped Omni/Linus/etc. body, role.md is
26
+ * rewritten to the current `<!-- lang:en -->` / `<!-- lang:zh -->` stock
27
+ * persona. User-edited bodies are not touched.
23
28
  *
24
29
  * Hard rules:
25
- * - **Never** overwrite a VP that is on disk. The user might have edited
26
- * persona/role/traits; that is their truth, not ours.
27
- * - **Keep stock defaults available.** If a shipped stock VP is missing,
28
- * recreate it, but never overwrite an on-disk VP. The session/member picker
29
- * depends on these product-owned defaults being present.
30
+ * - **Never** overwrite a user-edited VP. A persona body is only rewritten when
31
+ * it exactly matches a historical body shipped by us.
32
+ * - **Keep stock defaults canonical and available.** If a shipped stock VP is
33
+ * missing, recreate it. If it exists with an old exact stock body, rewrite
34
+ * that body to the current canonical localized body. The session/member
35
+ * picker depends on these product-owned defaults being present and current.
30
36
  * - Best-effort: any failure is logged, never thrown.
31
37
  *
32
38
  * Pre-ledger deletion caveat: on the very first top-up against an existing
@@ -47,8 +53,9 @@
47
53
  * }
48
54
  * }
49
55
  *
50
- * The hash is reserved for future "the default persona changed; offer the
51
- * user a migration" semantics. We do not auto-upgrade today.
56
+ * The hash tracks the canonical stock persona currently written by top-up. Exact
57
+ * historical stock bodies are auto-migrated to this canonical localized body;
58
+ * user-edited bodies are deliberately left alone.
52
59
  */
53
60
 
54
61
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync } from 'fs';
@@ -231,7 +238,7 @@ function replaceRoleBody(source, body) {
231
238
  return `${match[1]}${String(body || '').trim()}\n`;
232
239
  }
233
240
 
234
- function backfillLocalizedPersonaBody(source, vp) {
241
+ export function backfillLocalizedPersonaBody(source, vp) {
235
242
  const body = roleBodyOf(source).trim();
236
243
  const nextBody = typeof vp.persona === 'string' ? vp.persona.trim() : '';
237
244
  if (!body || !nextBody || body === nextBody) return null;
@@ -301,9 +308,9 @@ export function topUpDefaultVps(libDir = DEFAULT_VP_LIB_DIR) {
301
308
  const inLedger = Object.prototype.hasOwnProperty.call(versions.seeded, vpId);
302
309
 
303
310
  if (onDisk) {
304
- // Already there — never overwrite. Possibly backfill `area` /
305
- // `nameZh`. Each backfill is independent: a role.md with `area`
306
- // already set but no `nameZh` should still get `nameZh`.
311
+ // Already there — preserve user-authored content, but keep shipped stock
312
+ // defaults canonical. Metadata backfills only add missing frontmatter;
313
+ // persona migration only rewrites exact historical stock bodies.
307
314
  skippedExisting.push(vpId);
308
315
  let currentSrc = null;
309
316
  const rolePath = join(libDir, vpId, 'role.md');
@@ -388,6 +395,7 @@ export function topUpDefaultVps(libDir = DEFAULT_VP_LIB_DIR) {
388
395
  if (patched != null && patched !== src) {
389
396
  writeFileSync(rolePath, patched, 'utf-8');
390
397
  currentSrc = patched;
398
+ versions.seeded[vpId] = personaHash(vp.persona);
391
399
  personaBackfilled.push(vpId);
392
400
  }
393
401
  }
@@ -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
  /**