@yeaft/webchat-agent 1.0.571 → 1.0.573

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.571",
3
+ "version": "1.0.573",
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",
@@ -14,6 +14,9 @@ import {
14
14
  import { join, relative } from 'node:path';
15
15
  import { writeAtomic } from '../storage/atomic.js';
16
16
 
17
+ // v3 indexes the visible transcript, including rows replaced only in model context.
18
+ export const HISTORY_INDEX_SCHEMA_VERSION = 3;
19
+
17
20
  const STATE_VERSION = 1;
18
21
  const INDEX_DIR = 'conversation-index';
19
22
  const STATE_FILE = 'mutation-state.json';
@@ -9,10 +9,10 @@ import {
9
9
  findLiteralSearch,
10
10
  normalizeLiteralSearch,
11
11
  } from './visible-entry.js';
12
- import { fingerprintConversationSources } from './history-index-state.js';
12
+ import { fingerprintConversationSources, HISTORY_INDEX_SCHEMA_VERSION } from './history-index-state.js';
13
13
  import { extractRecallTerms, scoreRecallTurn, normalizeRecallLimit, RECALL_LIMITS } from './recall-relevance.js';
14
14
 
15
- const INDEX_SCHEMA_VERSION = 2;
15
+ const INDEX_SCHEMA_VERSION = HISTORY_INDEX_SCHEMA_VERSION;
16
16
  const SHORT_BLOOM_BYTES = 256;
17
17
  const BUILD_YIELD_INTERVAL = 64;
18
18
  const QUERY_BATCH_ROWS = 128;
@@ -4,6 +4,7 @@ import { dirname } from 'node:path';
4
4
  import { writeAtomic } from '../storage/atomic.js';
5
5
  import { extractRecallTerms, scoreRecallTurn, normalizeRecallLimit } from './recall-relevance.js';
6
6
  import {
7
+ HISTORY_INDEX_SCHEMA_VERSION,
7
8
  conversationIndexDatabasePath,
8
9
  conversationIndexManifestPath,
9
10
  flushConversationIndexMutations,
@@ -47,7 +48,9 @@ function readManifest(ownerRoot, sessionId) {
47
48
  if (!existsSync(path)) return null;
48
49
  try {
49
50
  const value = JSON.parse(readFileSync(path, 'utf8'));
50
- if (Number(value?.indexSchemaVersion) !== 2) return null;
51
+ // Retain the previous generation across projection/schema upgrades, but
52
+ // never activate its database until it has been rebuilt.
53
+ if (!Number.isInteger(value?.indexSchemaVersion)) return null;
51
54
  const generation = Number(value?.generation);
52
55
  if (!Number.isInteger(generation) || generation < 1) return null;
53
56
  const databasePath = conversationIndexDatabasePath(ownerRoot, sessionId, generation);
@@ -232,7 +235,7 @@ class SessionHistoryIndex {
232
235
  const manifest = readManifest(this.ownerRoot, this.sessionId);
233
236
  if (!manifest?.databasePath || !existsSync(manifest.databasePath)) return { needs: true, manifest };
234
237
  const revision = readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId);
235
- if (Number(manifest.sourceRevision) !== revision) return { needs: true, manifest };
238
+ if (manifest.indexSchemaVersion !== HISTORY_INDEX_SCHEMA_VERSION || Number(manifest.sourceRevision) !== revision) return { needs: true, manifest };
236
239
  if (this.active?.databasePath === manifest.databasePath) return { needs: false, manifest };
237
240
  const source = await spawnOneShot('fingerprint', {
238
241
  ownerRoot: this.ownerRoot,
@@ -297,7 +300,7 @@ class SessionHistoryIndex {
297
300
 
298
301
  const manifest = readManifest(this.ownerRoot, this.sessionId);
299
302
  if (manifest?.databasePath && existsSync(manifest.databasePath)) {
300
- if (allowStale) {
303
+ if (allowStale && manifest.indexSchemaVersion === HISTORY_INDEX_SCHEMA_VERSION) {
301
304
  await this.#activate(manifest);
302
305
  return this.active;
303
306
  }
@@ -350,7 +353,7 @@ class SessionHistoryIndex {
350
353
  }
351
354
  const manifest = {
352
355
  version: 1,
353
- indexSchemaVersion: 2,
356
+ indexSchemaVersion: HISTORY_INDEX_SCHEMA_VERSION,
354
357
  sessionId: this.sessionId,
355
358
  generation,
356
359
  databasePath,
@@ -74,6 +74,9 @@ const DELTA_TOOL_PAIR_EXTENSION_CAP = 500;
74
74
 
75
75
  const SEGMENT_INDEX_FILE = 'index.json';
76
76
  const SEGMENT_LINEAGE_FILE = 'lineage.json';
77
+ // Browser cursors also identify the visible projection. Version 1 (unversioned)
78
+ // omitted folded rows; its cached head cannot be repaired by an append delta.
79
+ const VISIBLE_HISTORY_PROJECTION_VERSION = 2;
77
80
  const SEGMENT_DIR = 'segments';
78
81
  const SEGMENT_TARGET_BYTES = 1024 * 1024;
79
82
  const SEGMENT_FIRST_NAME = '000001.jsonl';
@@ -490,7 +493,7 @@ export function projectVisibleSessionMessages(messages) {
490
493
  const { providerState, thinkingBlocks, ...row } = sourceRow;
491
494
  if (!isVisibleConversationRow(row)) continue;
492
495
  if (row.role !== 'assistant' || !Array.isArray(row.toolCalls) || row.toolCalls.length === 0) {
493
- if (row.role === 'assistant' && !row.content && !row.attachments && !row.images
496
+ if (row.role === 'assistant' && !row.content && !row.attachments && !row.images && !row.imageAssetIds?.length
494
497
  && !row.todos && !row.askUserResults) continue;
495
498
  const responseKind = row.role === 'assistant' ? projectedResponseKind(row) : null;
496
499
  visible.push(responseKind && row.responseKind !== responseKind
@@ -520,7 +523,7 @@ export function projectVisibleSessionMessages(messages) {
520
523
  ...(visibleToolCalls.length > 0 ? { toolCalls: visibleToolCalls } : {}),
521
524
  ...(askUserResults.length > 0 ? { askUserResults } : {}),
522
525
  };
523
- if (!projected.content && !projected.attachments && !projected.images
526
+ if (!projected.content && !projected.attachments && !projected.images && !projected.imageAssetIds?.length
524
527
  && !projected.todos && !projected.toolCalls && !projected.askUserResults) continue;
525
528
  visible.push(projected);
526
529
  }
@@ -1031,7 +1034,12 @@ class SegmentStore {
1031
1034
  return rows;
1032
1035
  }
1033
1036
 
1034
- readAll({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
1037
+ /**
1038
+ * Context applies fold replacements; transcript preserves durable source rows.
1039
+ * Transcript is NOT a public projection: callers must still filter control
1040
+ * rows and sensitive fields before exposing it to users.
1041
+ */
1042
+ readAll({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, projection = 'context' } = {}) {
1035
1043
  if (!this.hasData()) return [];
1036
1044
  const idx = this.loadIndex();
1037
1045
  const segments = (idx.segments || [])
@@ -1041,7 +1049,7 @@ class SegmentStore {
1041
1049
  const out = [];
1042
1050
  for (const seg of segments) {
1043
1051
  const rows = this.#readSegment(seg.file, { beforeSeq, afterSeq, desc, includeCold });
1044
- out.push(...applyFoldedMessageTombstones(rows, idx.foldedMessageIds));
1052
+ out.push(...(projection === 'transcript' ? rows : applyFoldedMessageTombstones(rows, idx.foldedMessageIds)));
1045
1053
  }
1046
1054
  return desc
1047
1055
  ? out.sort((a, b) => parseSeqFromId(b.id) - parseSeqFromId(a.id))
@@ -1062,7 +1070,7 @@ class SegmentStore {
1062
1070
  .sort(compareMessagesBySeq);
1063
1071
  }
1064
1072
 
1065
- *scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, scanStats = null } = {}) {
1073
+ *scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, scanStats = null, projection = 'context' } = {}) {
1066
1074
  if (!this.hasData()) return;
1067
1075
  const idx = this.loadIndex();
1068
1076
  const segments = (idx.segments || [])
@@ -1074,7 +1082,7 @@ class SegmentStore {
1074
1082
  addScanMetric(scanStats, 'segments');
1075
1083
  addScanMetric(scanStats, 'bytes', Number(seg.bytes) || 0);
1076
1084
  addScanMetric(scanStats, 'rows', rows.length);
1077
- yield* applyFoldedMessageTombstones(rows, idx.foldedMessageIds);
1085
+ yield* projection === 'transcript' ? rows : applyFoldedMessageTombstones(rows, idx.foldedMessageIds);
1078
1086
  }
1079
1087
  }
1080
1088
 
@@ -1650,8 +1658,9 @@ export class ConversationStore {
1650
1658
  * Atomically publish a logical range replacement for tool folding.
1651
1659
  *
1652
1660
  * The original rows stay append-only on disk. A single reflection row owns
1653
- * their ids as tombstones, so readers either observe the complete old arc or
1654
- * the complete reflection — never a half-rewritten tool pair.
1661
+ * their ids as context-only tombstones, so model readers observe the complete
1662
+ * old arc or its reflection — never a half-rewritten tool pair. User-visible
1663
+ * history always projects the original transcript, not these replacements.
1655
1664
  *
1656
1665
  * @param {object[]} messages — persisted rows being folded
1657
1666
  * @param {object} reflection — synthetic `_reflection` user row
@@ -1792,15 +1801,20 @@ export class ConversationStore {
1792
1801
  // ─── Read API ───────────────────────────────────────────
1793
1802
 
1794
1803
  /**
1795
- * Return the durable identity of one Session transcript. `streamId`
1796
- * changes on clear/recreate; `revision` changes on append/update/fold.
1797
- * Browser caches use both values to detect non-append mutations before
1798
- * trusting an `afterSeq` delta cursor.
1804
+ * Return the cache identity of one Session's user-visible history. `streamId`
1805
+ * includes the projection version and changes on clear/recreate; `revision`
1806
+ * changes on append/update/fold. Browser caches must match both before
1807
+ * trusting an `afterSeq` delta cursor, including across projection upgrades.
1808
+ * The durable stream identity and model context are not modified.
1799
1809
  */
1800
1810
  getSessionHistoryMetadata(sessionId) {
1801
1811
  if (!sessionId) return null;
1802
1812
  const store = this.#segmentStoreForConversationDir(this.#sessionConversationDir(sessionId));
1803
- return store.metadata();
1813
+ const metadata = store.metadata();
1814
+ return {
1815
+ ...metadata,
1816
+ streamId: `${metadata.streamId}:visible-v${VISIBLE_HISTORY_PROJECTION_VERSION}`,
1817
+ };
1804
1818
  }
1805
1819
 
1806
1820
  /**
@@ -2119,7 +2133,7 @@ export class ConversationStore {
2119
2133
  loadOlderBySession(sessionId, beforeSeq, turnsLimit = DEFAULT_RECENT_TURNS) {
2120
2134
  if (!sessionId) return { messages: [], oldestSeq: null, hasMore: false };
2121
2135
  const cutoff = Number.isFinite(beforeSeq) ? beforeSeq : Infinity;
2122
- const prefix = this.#readSessionRows(sessionId, { beforeSeq: cutoff })
2136
+ const prefix = this.#readSessionRows(sessionId, { beforeSeq: cutoff, projection: 'transcript' })
2123
2137
  .filter(m => m && m.sessionId === sessionId && isVisibleConversationRow(m));
2124
2138
  if (prefix.length === 0) return { messages: [], oldestSeq: null, hasMore: false };
2125
2139
  const sliced = pairSanitize(sliceLastNTurns(prefix, turnsLimit));
@@ -2215,7 +2229,7 @@ export class ConversationStore {
2215
2229
  const completedBeforeCursor = new Set();
2216
2230
  const boundaryAssistants = [];
2217
2231
  let boundaryLookbackRows = 0;
2218
- for (const previous of this.#iterateSessionRows(sessionId, { beforeSeq: cutoff + 1, desc: true })) {
2232
+ for (const previous of this.#iterateSessionRows(sessionId, { beforeSeq: cutoff + 1, desc: true, projection: 'transcript' })) {
2219
2233
  if (!previous || previous.sessionId !== sessionId) continue;
2220
2234
  boundaryLookbackRows += 1;
2221
2235
  if (boundaryLookbackRows > DELTA_TOOL_PAIR_EXTENSION_CAP) break;
@@ -2250,7 +2264,7 @@ export class ConversationStore {
2250
2264
  let visibleBytes = after.reduce((sum, message) => sum + Buffer.byteLength(JSON.stringify(message)), 0);
2251
2265
  let stoppedAtBudget = false;
2252
2266
  let extensionRows = 0;
2253
- const deltaRows = this.#iterateSessionRows(sessionId, { afterSeq: cutoff, desc: false });
2267
+ const deltaRows = this.#iterateSessionRows(sessionId, { afterSeq: cutoff, desc: false, projection: 'transcript' });
2254
2268
  while (true) {
2255
2269
  const step = deltaRows.next();
2256
2270
  if (step.done) break;
@@ -2465,7 +2479,7 @@ export class ConversationStore {
2465
2479
  const seen = new Set(messages.map(message => message?.id).filter(Boolean));
2466
2480
  let followingUserTurns = 0;
2467
2481
 
2468
- for (const message of this.#iterateSessionRows(sessionId, { afterSeq: anchorSeq, desc: false })) {
2482
+ for (const message of this.#iterateSessionRows(sessionId, { afterSeq: anchorSeq, desc: false, projection: 'transcript' })) {
2469
2483
  if (!message || message.sessionId !== sessionId || !isVisibleConversationRow(message)) continue;
2470
2484
  if (message.role === 'user') {
2471
2485
  followingUserTurns += 1;
@@ -2480,6 +2494,7 @@ export class ConversationStore {
2480
2494
  const entryStartSeq = Number.isFinite(opts.entryStartSeq) ? opts.entryStartSeq : anchorSeq;
2481
2495
  const entryEndSeq = Number.isFinite(opts.entryEndSeq) ? opts.entryEndSeq : anchorSeq;
2482
2496
  for (const message of this.#iterateSessionRows(sessionId, {
2497
+ projection: 'transcript',
2483
2498
  afterSeq: entryStartSeq - 1,
2484
2499
  beforeSeq: entryEndSeq + 1,
2485
2500
  desc: false,
@@ -2674,7 +2689,7 @@ export class ConversationStore {
2674
2689
  for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
2675
2690
  const store = this.#segmentStoreForConversationDir(dir);
2676
2691
  if (!store.hasData()) continue;
2677
- const rows = store.readAll({ includeCold: true });
2692
+ const rows = store.readAll({ includeCold: true, projection: 'transcript' });
2678
2693
  let keepRows = [];
2679
2694
  let dirty = false;
2680
2695
  for (const msg of rows) {
@@ -2744,7 +2759,7 @@ export class ConversationStore {
2744
2759
  for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
2745
2760
  const store = this.#segmentStoreForConversationDir(dir);
2746
2761
  if (!store.hasData()) continue;
2747
- const rows = store.readAll();
2762
+ const rows = store.readAll({ includeCold: true, projection: 'transcript' });
2748
2763
  let dirty = false;
2749
2764
  for (const msg of rows) {
2750
2765
  if (!msg || msg.threadId !== sourceId) continue;
@@ -3159,7 +3174,9 @@ export class ConversationStore {
3159
3174
  // and stop after the requested turn window is complete. Hidden/internal and
3160
3175
  // non-turn rows are not allowed to force an unbounded scan; a hard parse cap
3161
3176
  // conservatively marks the page truncated.
3162
- for (const m of this.#iterateSessionRows(sessionId, { beforeSeq, afterSeq, desc: true })) {
3177
+ for (const m of this.#iterateSessionRows(sessionId, {
3178
+ beforeSeq, afterSeq, desc: true, projection: visibleOnly ? 'transcript' : 'context',
3179
+ })) {
3163
3180
  if (parsed >= scanCap) {
3164
3181
  truncated = true;
3165
3182
  scanCapped = true;
@@ -3232,6 +3249,7 @@ export class ConversationStore {
3232
3249
  beforeSeq,
3233
3250
  desc: true,
3234
3251
  scanStats: opts.scanStats,
3252
+ projection: 'transcript',
3235
3253
  });
3236
3254
  yield* iterateCanonicalVisibleEntriesNewestFirst(rows, sessionId);
3237
3255
  }
@@ -3271,15 +3289,35 @@ export class ConversationStore {
3271
3289
  *#iterateSessionRows(sessionId, opts = {}) {
3272
3290
  const primaryDir = this.#sessionConversationDir(sessionId);
3273
3291
  const segmentStore = this.#segmentStoreForConversationDir(primaryDir);
3274
- yield* segmentStore.scan({ includeCold: true, ...opts });
3275
- for (const entry of this.#sessionFileEntries('all', sessionId, opts)) {
3292
+ const legacyEntries = this.#sessionFileEntries('all', sessionId, opts);
3293
+ // Interrupted migrations may leave a markdown copy of a folded JSONL row.
3294
+ // Apply the index's context-only tombstones to that copy too, including
3295
+ // when its reflection lies outside this page's sequence bounds. Legacy
3296
+ // markdown itself predates foldedMessageIds and has no fold metadata.
3297
+ const foldedIds = new Set(opts.projection === 'transcript'
3298
+ ? [] : segmentStore.loadIndex().foldedMessageIds || []);
3299
+ const segments = segmentStore.scan({ includeCold: true, ...opts });
3300
+ let segment = segments.next();
3301
+ let legacyIndex = 0;
3302
+ // Merge by sequence rather than concatenating formats. A delta cursor must
3303
+ // not pass a legacy-only row, and the canonical JSONL copy wins duplicates.
3304
+ while (!segment.done || legacyIndex < legacyEntries.length) {
3305
+ const entry = legacyEntries[legacyIndex];
3306
+ const segmentSeq = segment.done ? null : parseSeqFromId(segment.value.id);
3307
+ if (!segment.done && (!entry || (opts.desc ? segmentSeq >= entry.seq : segmentSeq <= entry.seq))) {
3308
+ while (legacyEntries[legacyIndex]?.seq === segmentSeq) legacyIndex += 1;
3309
+ if (!foldedIds.has(segment.value.id)) yield segment.value;
3310
+ segment = segments.next();
3311
+ continue;
3312
+ }
3313
+ legacyIndex += 1;
3276
3314
  try {
3277
3315
  const msg = this.readMessageFile(entry.path);
3278
3316
  addScanMetric(opts.scanStats, 'legacyFiles');
3279
3317
  try { addScanMetric(opts.scanStats, 'bytes', statSync(entry.path).size); } catch {}
3280
3318
  if (msg) {
3281
3319
  addScanMetric(opts.scanStats, 'rows');
3282
- yield msg;
3320
+ if (!foldedIds.has(msg.id)) yield msg;
3283
3321
  }
3284
3322
  } catch (err) {
3285
3323
  if (isPermissionError(err)) continue;
@@ -7,6 +7,7 @@
7
7
  import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
8
8
  import { join } from 'path';
9
9
  import { parseMessage, parseSeqFromId } from './persist.js';
10
+ import { isVisibleConversationRow } from './internal-control.js';
10
11
 
11
12
  function parseJsonLine(line) {
12
13
  if (!line || !line.trim()) return null;
@@ -38,7 +39,7 @@ function searchableContent(msg) {
38
39
  }
39
40
 
40
41
  function matchesMessage(msg, terms) {
41
- if (!msg || msg.role === 'tool') return false;
42
+ if (!msg || msg.role === 'tool' || !isVisibleConversationRow(msg)) return false;
42
43
  const content = searchableContent(msg).toLocaleLowerCase();
43
44
  return content.length > 0 && terms.every(term => content.includes(term));
44
45
  }
@@ -7167,7 +7167,8 @@ export async function handleYeaftLoadHistory(msg) {
7167
7167
  const afterSeqRaw = cacheIdentityMatches && msg && Number.isFinite(msg.afterSeq) ? msg.afterSeq : null;
7168
7168
  const afterMessageId = (msg && typeof msg.afterMessageId === 'string') ? msg.afterMessageId : null;
7169
7169
  let afterSeq = afterSeqRaw;
7170
- if (afterSeq === null && afterMessageId && typeof session.conversationStore.getMessageSeqById === 'function') {
7170
+ // A message-id cursor cannot bypass a stream/revision reset either.
7171
+ if (cacheIdentityMatches && afterSeq === null && afterMessageId && typeof session.conversationStore.getMessageSeqById === 'function') {
7171
7172
  afterSeq = session.conversationStore.getMessageSeqById(afterMessageId);
7172
7173
  }
7173
7174
  if (sessionId && afterSeq !== null && typeof session.conversationStore.loadAfterSeqByGroup === 'function') {
@@ -7317,18 +7318,18 @@ export async function handleYeaftLoadHistory(msg) {
7317
7318
  );
7318
7319
  const afterSeqRaw = cacheIdentityMatches && msg && Number.isFinite(msg.afterSeq) ? msg.afterSeq : null;
7319
7320
  traceDuration('history.cold_store_open', coldStoreStart);
7320
- if (sessionId && (afterSeqRaw !== null || afterMessageId)) {
7321
- let afterSeq = afterSeqRaw;
7322
- if (afterSeq === null && afterMessageId && typeof coldStore.getMessageSeqById === 'function') {
7323
- afterSeq = coldStore.getMessageSeqById(afterMessageId);
7324
- }
7321
+ let afterSeq = afterSeqRaw;
7322
+ if (cacheIdentityMatches && afterSeq === null && afterMessageId && typeof coldStore.getMessageSeqById === 'function') {
7323
+ afterSeq = coldStore.getMessageSeqById(afterMessageId);
7324
+ }
7325
+ // Match the warm path: stale or unresolvable cursors require a recent
7326
+ // replay, not an empty delta that leaves a partial browser cache intact.
7327
+ if (sessionId && afterSeq !== null && typeof coldStore.loadAfterSeqByGroup === 'function') {
7325
7328
  const loadStart = perfNowMs();
7326
- const delta = afterSeq !== null && typeof coldStore.loadAfterSeqByGroup === 'function'
7327
- ? coldStore.loadAfterSeqByGroup(sessionId, afterSeq, {
7328
- limit: deltaLimit,
7329
- maxBytes: deltaMaxBytes,
7330
- })
7331
- : { messages: [], latestSeq: null, hasMoreAfter: false };
7329
+ const delta = coldStore.loadAfterSeqByGroup(sessionId, afterSeq, {
7330
+ limit: deltaLimit,
7331
+ maxBytes: deltaMaxBytes,
7332
+ });
7332
7333
  traceDuration('history.store_load_delta', loadStart, { detail: { count: delta.messages?.length || 0, afterSeq, cold: true } });
7333
7334
  const emitStart = perfNowMs();
7334
7335
  const projectedMessages = emitHistoryChunk({