@yeaft/webchat-agent 1.0.641 → 1.0.643

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.641",
3
+ "version": "1.0.643",
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",
@@ -31,8 +31,9 @@ import {
31
31
  iterateCanonicalVisibleEntriesNewestFirst,
32
32
  normalizeLiteralSearch,
33
33
  } from './visible-entry.js';
34
- import { copyPersistedSessionAttachments } from '../attachments.js';
34
+ import { copyPersistedSessionAttachments, readPersistedAttachmentFile, persistYeaftAttachments, MAX_TOTAL_BYTES } from '../attachments.js';
35
35
  import { markConversationDirty } from './history-index-state.js';
36
+ import { historyError, isHumanRequest, messageOrigin, historicalReplayRow, planSessionMerge, materializeSessionMerge } from './session-history.js';
36
37
 
37
38
  /**
38
39
  * Default cold-start "recent window" size, expressed in TURNS (not raw
@@ -92,6 +93,49 @@ const MESSAGE_SEQUENCE_RESERVATION_SIZE = 256;
92
93
  // by this process, so long-lived stores cannot diverge or reuse one another's ids.
93
94
  const messageSequenceReservations = new Map();
94
95
 
96
+ /** Validate every shared local blob before choosing a source; metadata is not content identity. */
97
+ function copyMergeAttachments(plan, yeaftDir, targetSessionId) {
98
+ const copiedPaths = new Map();
99
+ for (const turn of plan.turns) {
100
+ const alternatives = [...plan.aliases].filter(([, source]) => source === turn).map(([other]) => other);
101
+ turn.rows = turn.rows.map((row, index) => {
102
+ if (!Array.isArray(row.attachments)) return row;
103
+ return { ...row, attachments: row.attachments.map((att, attachmentIndex) => {
104
+ // Server-owned IDs are metadata, not permission to read another owner.
105
+ const candidates = [turn, ...alternatives];
106
+ if (!candidates.some(candidate => candidate.rows[index].attachments[attachmentIndex]?.path)) return att;
107
+ let chosen = null;
108
+ for (const candidate of candidates) {
109
+ const source = candidate.rows[index].attachments[attachmentIndex];
110
+ let payload;
111
+ try {
112
+ payload = readPersistedAttachmentFile(source.path, {
113
+ yeaftDir, sessionId: candidate.input.sessionId,
114
+ }, MAX_TOTAL_BYTES);
115
+ } catch { continue; } // Another verified alias may still own the blob.
116
+ if (chosen && !chosen.buffer.equals(payload.buffer)) {
117
+ throw historyError('merge_attachment_conflict', `Shared attachment has different bytes: ${att.name || 'file'}`);
118
+ }
119
+ chosen ||= payload;
120
+ }
121
+ if (!chosen) {
122
+ throw historyError('merge_attachment_unavailable', `Required attachment is missing or inaccessible: ${att.name || 'file'}`);
123
+ }
124
+ if (!copiedPaths.has(chosen.path)) {
125
+ const bundle = persistYeaftAttachments([{ ...att, data: chosen.buffer.toString('base64') }], {
126
+ yeaftDir, sessionId: targetSessionId,
127
+ });
128
+ if (bundle.failed.length || bundle.promptAttachments.length !== 1) {
129
+ throw historyError('merge_attachment_copy_failed', `Session attachment copy failed: ${bundle.failed[0]?.error || 'missing output'}`);
130
+ }
131
+ copiedPaths.set(chosen.path, bundle.promptAttachments[0].path);
132
+ }
133
+ return { ...att, path: copiedPaths.get(chosen.path) };
134
+ }) };
135
+ });
136
+ }
137
+ }
138
+
95
139
  function sleepSync(ms) {
96
140
  if (ms <= 0) return;
97
141
  const buffer = new Int32Array(new SharedArrayBuffer(4));
@@ -1544,7 +1588,9 @@ export class ConversationStore {
1544
1588
  };
1545
1589
 
1546
1590
  try {
1547
- this.#segmentStoreFor(fullMsg, { create: true }).append(fullMsg);
1591
+ const segment = this.#segmentStoreFor(fullMsg, { create: true });
1592
+ if (fullMsg.sessionId) fullMsg.origin = messageOrigin(fullMsg, fullMsg.sessionId, segment.metadata().streamId);
1593
+ segment.append(fullMsg);
1548
1594
  } catch (err) {
1549
1595
  if (!strict && isPermissionError(err)) {
1550
1596
  if (!_permissionWarned) {
@@ -1611,7 +1657,12 @@ export class ConversationStore {
1611
1657
 
1612
1658
  for (const batch of batches.values()) {
1613
1659
  try {
1614
- this.#segmentStoreFor(batch[0], { create: true }).appendBatch(batch);
1660
+ const segment = this.#segmentStoreFor(batch[0], { create: true });
1661
+ if (batch[0].sessionId) {
1662
+ const { streamId } = segment.metadata();
1663
+ for (const row of batch) row.origin = messageOrigin(row, row.sessionId, streamId);
1664
+ }
1665
+ segment.appendBatch(batch);
1615
1666
  } catch (err) {
1616
1667
  if (isPermissionError(err)) {
1617
1668
  if (!_permissionWarned) {
@@ -1975,6 +2026,61 @@ export class ConversationStore {
1975
2026
  return this.loadRecentBySession(sessionId, Infinity);
1976
2027
  }
1977
2028
 
2029
+ /**
2030
+ * 同步捕获完整 durable raw transcript 的不透明、可 JSON 序列化前缀边界。
2031
+ * 包含 cold、legacy、internal 和 folded source rows;空历史也返回显式边界。
2032
+ * 调用者须在输入 enqueue 的第一个 await 前捕获,并在整个活动生命周期
2033
+ * 保留最早边界。捕获不代表请求完成;Store 不判断 runtime activity。
2034
+ * copySession 验证前缀未被替换、删改或插入;仅追加新行不会使边界失效。
2035
+ *
2036
+ * @param {string} sessionId Agent-owned Session identity
2037
+ * @returns {object} Opaque serializable boundary for options.throughBoundary
2038
+ * @throws {Error} invalid_fork_boundary for a missing Session identity
2039
+ */
2040
+ captureSessionCopyBoundary(sessionId) {
2041
+ if (typeof sessionId !== 'string' || !sessionId) {
2042
+ throw historyError('invalid_fork_boundary', 'Session identity is required');
2043
+ }
2044
+ const { rows, streamId } = this.#loadDurableSession(sessionId);
2045
+ return {
2046
+ version: 1,
2047
+ sessionId,
2048
+ streamId,
2049
+ rowCount: rows.length,
2050
+ lastMessageId: rows.at(-1)?.id ?? null,
2051
+ digest: this.#sessionCopyPrefixDigest(rows),
2052
+ };
2053
+ }
2054
+
2055
+ #sessionCopyPrefixDigest(rows) {
2056
+ const hash = createHash('sha256');
2057
+ for (const row of rows) hash.update(JSON.stringify(row)).update('\n');
2058
+ return hash.digest('hex');
2059
+ }
2060
+
2061
+ /**
2062
+ * Compatibility fence reader. New runtime callers should use the opaque
2063
+ * captureSessionCopyBoundary / throughBoundary API instead.
2064
+ * Capture the newest durable raw row synchronously, before routing/input awaits.
2065
+ * Includes cold, internal and folded source rows, not just visible history.
2066
+ * A caller may retain this fence across awaits and pass its messageId (or null
2067
+ * for an empty Session) to copySession's inclusive throughMessageId option.
2068
+ * Active-input exclusion is the caller's responsibility: capture before enqueue
2069
+ * and clamp to the fence preceding the earliest still-active input envelope.
2070
+ *
2071
+ * @param {string} sessionId Agent-owned Session identity
2072
+ * @returns {{messageId: string, seq: number}|null} Newest raw row, or null if empty
2073
+ */
2074
+ getSessionCopyFence(sessionId) {
2075
+ if (!sessionId) return null;
2076
+ const primary = this.#segmentStoreForConversationDir(this.#sessionConversationDir(sessionId));
2077
+ if (!primary.hasData() && this.#sessionFileEntries('all', sessionId).length === 0) return null;
2078
+ for (const row of this.#iterateSessionRows(sessionId, { desc: true, projection: 'transcript' })) {
2079
+ if (row?.sessionId === sessionId) return { messageId: row.id, seq: parseSeqFromId(row.id) };
2080
+ }
2081
+ return null;
2082
+ }
2083
+
1978
2084
  /**
1979
2085
  * Copy the complete durable transcript to a new Session identity.
1980
2086
  *
@@ -1982,36 +2088,89 @@ export class ConversationStore {
1982
2088
  * reflections, and the rows hidden by reflection tombstones. New persisted
1983
2089
  * message ids are allocated in original order. References that point to a
1984
2090
  * copied persisted message are remapped; external/client/tool identities are
1985
- * intentionally preserved.
2091
+ * intentionally preserved. Provider-native continuation/thinking payloads
2092
+ * stay in the source transcript; the target replays canonical text/tools,
2093
+ * with origin linking back to the untouched provider provenance.
1986
2094
  *
1987
2095
  * @param {string} sourceSessionId
1988
2096
  * @param {string} targetSessionId
1989
- * @param {{throughTurnId?: string}} [options]
2097
+ * @param {{throughBoundary?: object, throughTurnId?: string, throughMessageId?: string|null, beforeMessageId?: string}} [options]
2098
+ * throughBoundary 是 captureSessionCopyBoundary 返回的原始前缀边界;可跨
2099
+ * await/重启传递但不能手工构造。显式空边界复制空历史。组合 throughTurnId
2100
+ * 时,其完整历史 endpoint 必须在边界之内,否则 incomplete_fork_boundary;
2101
+ * 不会把活动 turn 截短伪装成完整 turn。前缀删改拒绝为 invalid_fork_boundary。
2102
+ * 兼容旧 inclusive throughMessageId(null 为空)和 exclusive beforeMessageId;
2103
+ * 所有已提供边界均验证,取较早 cutoff。runtime 负责在最早活动输入前捕获。
1990
2104
  * @returns {{ copiedCount: number, idMap: Map<string, string> }}
1991
2105
  */
1992
2106
  copySession(sourceSessionId, targetSessionId, options = {}) {
1993
2107
  if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId) {
1994
2108
  return { copiedCount: 0, idMap: new Map() };
1995
2109
  }
1996
- const primary = this.#segmentStoreForConversationDir(this.#sessionConversationDir(sourceSessionId));
1997
- const segmentedRows = primary.readAllRaw({ includeCold: true });
1998
- const legacyRows = this.#sessionFileEntries('all', sourceSessionId)
1999
- .map(entry => {
2000
- try { return this.readMessageFile(entry.path); } catch (err) {
2001
- if (isPermissionError(err)) return null;
2002
- throw err;
2110
+ const { rows: durableRows, streamId } = this.#loadDurableSession(sourceSessionId);
2111
+ let rows = durableRows;
2112
+ let cutoff = rows.length;
2113
+ let capturedCutoff = null;
2114
+ if (Object.prototype.hasOwnProperty.call(options, 'throughBoundary')) {
2115
+ const boundary = options.throughBoundary;
2116
+ if (!boundary || typeof boundary !== 'object' || boundary.version !== 1
2117
+ || boundary.sessionId !== sourceSessionId
2118
+ || (boundary.rowCount !== 0 && boundary.streamId !== streamId)
2119
+ || !Number.isSafeInteger(boundary.rowCount) || boundary.rowCount < 0
2120
+ || boundary.rowCount > rows.length || typeof boundary.digest !== 'string'
2121
+ || boundary.lastMessageId !== (rows[boundary.rowCount - 1]?.id ?? null)
2122
+ || boundary.digest !== this.#sessionCopyPrefixDigest(rows.slice(0, boundary.rowCount))) {
2123
+ throw historyError('invalid_fork_boundary', 'Captured Session prefix is invalid or has changed');
2124
+ }
2125
+ capturedCutoff = boundary.rowCount;
2126
+ cutoff = capturedCutoff;
2127
+ }
2128
+ if (Object.prototype.hasOwnProperty.call(options, 'throughMessageId')) {
2129
+ if (options.throughMessageId === null) cutoff = 0;
2130
+ else {
2131
+ const fence = typeof options.throughMessageId === 'string' ? options.throughMessageId.trim() : '';
2132
+ if (!fence) throw historyError('invalid_fork_boundary', 'throughMessageId must be a nonempty string or null');
2133
+ const index = rows.findIndex(row => row.id === fence);
2134
+ if (index < 0) throw historyError('fork_boundary_not_found', `Durable message not found: ${fence}`);
2135
+ cutoff = Math.min(cutoff, index + 1);
2136
+ }
2137
+ }
2138
+ if (Object.prototype.hasOwnProperty.call(options, 'beforeMessageId')) {
2139
+ const fence = typeof options.beforeMessageId === 'string' ? options.beforeMessageId.trim() : '';
2140
+ if (!fence) throw historyError('invalid_fork_boundary', 'beforeMessageId must be a nonempty string');
2141
+ const index = rows.findIndex(row => row.id === fence);
2142
+ if (index < 0) throw historyError('fork_boundary_not_found', `Human message not found: ${fence}`);
2143
+ if (!isHumanRequest(rows[index])) throw historyError('invalid_fork_boundary', 'beforeMessageId must identify a durable human request');
2144
+ const user = rows[index];
2145
+ // A multi-VP legacy request can have several persisted human variants.
2146
+ // Fence before the first variant, never before just the running VP.
2147
+ const originStream = row => row.origin?.streamId || streamId;
2148
+ const variant = row => isHumanRequest(row) && user.clientMessageId
2149
+ && row.clientMessageId === user.clientMessageId && originStream(row) === originStream(user);
2150
+ let humanCutoff = user.clientMessageId ? rows.findIndex(variant) : index;
2151
+ // Follow only this request's causal closure, not every turn in the
2152
+ // suffix: a late answer to an older request must not pull the fence back
2153
+ // into (and truncate) that older request. Client/VP identities are scoped
2154
+ // by origin stream: a merged Session may contain independent collisions.
2155
+ const vpTurnKey = row => row.turnId
2156
+ ? JSON.stringify([originStream(row), row.speakerVpId || row.vpId || '', row.turnId]) : null;
2157
+ const clientKey = (row, id) => JSON.stringify([originStream(row), id]);
2158
+ const excludedRows = new Set(rows.filter(row => row === user || variant(row)));
2159
+ let previousSize;
2160
+ do {
2161
+ previousSize = excludedRows.size;
2162
+ const excluded = new Set([...excludedRows].map(row => row.id));
2163
+ const clients = new Set([...excludedRows].filter(row => row.clientMessageId)
2164
+ .map(row => clientKey(row, row.clientMessageId)));
2165
+ const turns = new Set([...excludedRows].map(vpTurnKey).filter(Boolean));
2166
+ for (const row of rows) {
2167
+ if (excluded.has(row.causalRootId) || clients.has(clientKey(row, row.causalRootId))
2168
+ || turns.has(vpTurnKey(row))) excludedRows.add(row);
2003
2169
  }
2004
- })
2005
- .filter(Boolean);
2006
- // Migration can temporarily leave the same durable row in both the legacy
2007
- // markdown layout and the segment store. Preserve legacy-only rows, but let
2008
- // the canonical segment copy win when both contain the same persisted id.
2009
- const rowsById = new Map();
2010
- for (const row of [...legacyRows, ...segmentedRows]) {
2011
- if (row?.sessionId !== sourceSessionId || typeof row.id !== 'string' || !row.id) continue;
2012
- rowsById.set(row.id, row);
2170
+ } while (excludedRows.size > previousSize);
2171
+ humanCutoff = Math.min(humanCutoff, rows.findIndex(row => excludedRows.has(row)));
2172
+ cutoff = Math.min(cutoff, humanCutoff);
2013
2173
  }
2014
- let rows = [...rowsById.values()].sort(compareMessagesBySeq);
2015
2174
  if (Object.prototype.hasOwnProperty.call(options, 'throughTurnId')) {
2016
2175
  const throughTurnId = typeof options.throughTurnId === 'string'
2017
2176
  ? options.throughTurnId.trim()
@@ -2035,6 +2194,9 @@ export class ConversationStore {
2035
2194
  throw error;
2036
2195
  }
2037
2196
  const endpoint = rows.reduce((last, row, index) => row?.turnId === throughTurnId ? index : last, -1);
2197
+ if (capturedCutoff !== null && endpoint >= capturedCutoff) {
2198
+ throw historyError('incomplete_fork_boundary', `Assistant turn exceeds captured Session prefix: ${throughTurnId}`);
2199
+ }
2038
2200
  const prefix = rows.slice(0, endpoint + 1);
2039
2201
  const sanitized = pairSanitize(prefix);
2040
2202
  const pairShape = row => ({
@@ -2047,8 +2209,9 @@ export class ConversationStore {
2047
2209
  error.code = 'incomplete_fork_boundary';
2048
2210
  throw error;
2049
2211
  }
2050
- rows = prefix;
2212
+ cutoff = Math.min(cutoff, prefix.length);
2051
2213
  }
2214
+ rows = rows.slice(0, cutoff);
2052
2215
  if (rows.length === 0) return { copiedCount: 0, idMap: new Map() };
2053
2216
 
2054
2217
  const firstSeq = this.#reserveSeqRange(rows.length);
@@ -2060,10 +2223,13 @@ export class ConversationStore {
2060
2223
  const assetRows = copyPersistedSessionAttachments(rows, { yeaftDir: this.#dir, sourceSessionId, targetSessionId });
2061
2224
  const copies = assetRows.map(row => {
2062
2225
  const copy = {
2063
- ...row,
2226
+ ...historicalReplayRow(row),
2064
2227
  id: idMap.get(row.id),
2065
2228
  sessionId: targetSessionId,
2229
+ origin: messageOrigin(row, sourceSessionId, streamId),
2066
2230
  };
2231
+ delete copy.seq;
2232
+ if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remapId(row.quote.messageId) };
2067
2233
  if (idMap.has(row.causalRootId)) copy.causalRootId = remapId(row.causalRootId);
2068
2234
  if (Array.isArray(row.foldedMessageIds)) {
2069
2235
  copy.foldedMessageIds = row.foldedMessageIds.map(remapId);
@@ -2091,6 +2257,88 @@ export class ConversationStore {
2091
2257
  return { copiedCount: written.length, idMap };
2092
2258
  }
2093
2259
 
2260
+ #loadDurableSession(sourceSessionId) {
2261
+ const primary = this.#segmentStoreForConversationDir(this.#sessionConversationDir(sourceSessionId));
2262
+ const segmentedRows = primary.readAllRaw({ includeCold: true });
2263
+ if (new Set(segmentedRows.map(row => row.id)).size !== segmentedRows.length) {
2264
+ throw historyError('ambiguous_merge_history', 'Duplicate persisted identity within one Session');
2265
+ }
2266
+ const legacyRows = this.#sessionFileEntries('all', sourceSessionId)
2267
+ .map(entry => {
2268
+ try { return this.readMessageFile(entry.path); } catch (err) {
2269
+ if (isPermissionError(err)) return null;
2270
+ throw err;
2271
+ }
2272
+ })
2273
+ .filter(Boolean);
2274
+ // Migration can temporarily leave the same durable row in both the legacy
2275
+ // markdown layout and the segment store. Preserve legacy-only rows, but let
2276
+ // the canonical segment copy win when both contain the same persisted id.
2277
+ const rowsById = new Map();
2278
+ for (const row of [...legacyRows, ...segmentedRows]) {
2279
+ if (row?.sessionId !== sourceSessionId || typeof row.id !== 'string' || !row.id) continue;
2280
+ rowsById.set(row.id, row);
2281
+ }
2282
+ const rows = [...rowsById.values()].sort(compareMessagesBySeq);
2283
+ return { sessionId: sourceSessionId, streamId: rows.length || primary.hasData() ? primary.metadata().streamId : null, rows };
2284
+ }
2285
+
2286
+ /**
2287
+ * Merge two idle, Agent-owned Session transcripts into an EMPTY target.
2288
+ * Sources are never rewritten. Shared whole human requests are deduplicated
2289
+ * by origin (or guarded legacy client/VP-turn identities); divergent copies
2290
+ * of a shared request reject with `ambiguous_merge_history`. Common requests
2291
+ * precede timestamp-sorted branch requests, with source/order tie breaks.
2292
+ * Message/tool references are namespaced per source; local attachments are
2293
+ * independently copied and Server-owned attachment metadata stays unchanged.
2294
+ * CRUD owns target metadata/manifest rollback; this method rolls back its
2295
+ * newly-created conversation and attachment directories on write failure.
2296
+ *
2297
+ * @param {string} sourceSessionId Source of config/project/workdir semantics
2298
+ * @param {string} otherSessionId Other idle Session in the same data root
2299
+ * @param {string} targetSessionId New Session, distinct from both sources
2300
+ * @param {object} [options] Reserved; caller guarantees both sources are idle
2301
+ * @returns {{copiedCount:number, sharedTurnCount:number, idMaps:Map<string,Map<string,string>>}}
2302
+ */
2303
+ mergeSessions(sourceSessionId, otherSessionId, targetSessionId, options = {}) {
2304
+ if (![sourceSessionId, otherSessionId, targetSessionId].every(id =>
2305
+ typeof id === 'string' && /^[A-Za-z0-9_-]+$/.test(id))
2306
+ || new Set([sourceSessionId, otherSessionId, targetSessionId]).size !== 3) {
2307
+ throw historyError('invalid_merge_source', 'Merge requires three distinct Session identities');
2308
+ }
2309
+ const targetDir = this.#sessionConversationDir(targetSessionId);
2310
+ const attachmentDir = join(this.#dir, 'sessions', targetSessionId, 'attachments');
2311
+ if (existsSync(targetDir) || existsSync(attachmentDir)) {
2312
+ throw historyError('duplicate', 'Merge target must have no conversation or attachments');
2313
+ }
2314
+ const inputs = [sourceSessionId, otherSessionId].map(id => this.#loadDurableSession(id));
2315
+ const plan = planSessionMerge(inputs);
2316
+ const count = plan.turns.reduce((sum, turn) => sum + turn.rows.length, 0);
2317
+ const firstSeq = count ? this.#reserveSeqRange(count) : 0;
2318
+ try {
2319
+ // Ownership is taken from every actual source, including deduplicated
2320
+ // aliases. Missing primary blobs may use an available verified alias;
2321
+ // byte conflicts or entirely missing local blobs reject atomically.
2322
+ copyMergeAttachments(plan, this.#dir, targetSessionId);
2323
+ const { rows, idMaps } = materializeSessionMerge(plan, targetSessionId, firstSeq);
2324
+ if (rows.length) {
2325
+ const target = this.#segmentStoreForConversationDir(targetDir);
2326
+ const written = target.appendBatch(rows);
2327
+ const persisted = target.readAllRaw({ includeCold: true });
2328
+ if (written.length !== rows.length || persisted.length !== rows.length
2329
+ || persisted.some((row, index) => row.id !== rows[index].id)) {
2330
+ throw new Error('Session transcript merge incomplete');
2331
+ }
2332
+ this.#markDirty({ sessionId: targetSessionId }, 'merge-sessions');
2333
+ }
2334
+ return { copiedCount: count, sharedTurnCount: plan.sharedTurnCount, idMaps };
2335
+ } catch (error) {
2336
+ rmSync(targetDir, { recursive: true, force: true });
2337
+ rmSync(attachmentDir, { recursive: true, force: true });
2338
+ throw error;
2339
+ }
2340
+ }
2341
+
2094
2342
  /**
2095
2343
  * VP-scoped view of Session history, used to build a pair-safe provider
2096
2344
  * snapshot for one VP. It operates on what the VP can actually see, not