@yeaft/webchat-agent 1.0.679 → 1.0.681
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/local-runtime/version.json +1 -1
- package/package.json +1 -1
- package/yeaft/conversation/persist.js +140 -134
- package/yeaft/conversation/session-history.js +85 -1
- package/yeaft/engine.js +2 -26
- package/yeaft/person/task-host.js +3 -1
- package/yeaft/sub-agent/tool-access.js +1 -0
- package/yeaft/templates/core.md +0 -2
- package/yeaft/tools/activation.js +0 -1
- package/yeaft/tools/index.js +2 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.681"}
|
package/package.json
CHANGED
|
@@ -17,9 +17,10 @@
|
|
|
17
17
|
* Reference: yeaft-yeaft-core-systems.md §4.1, yeaft-yeaft-brainstorm-v5.1.md
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync, rmSync } from 'fs';
|
|
20
|
+
import { accessSync, constants, existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync, statSync, appendFileSync, rmSync } from 'fs';
|
|
21
21
|
import { createHash, randomUUID } from 'node:crypto';
|
|
22
22
|
import { hostname } from 'node:os';
|
|
23
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
23
24
|
import { join, basename, resolve } from 'path';
|
|
24
25
|
import { isPermissionError } from '../init.js';
|
|
25
26
|
import { writeAtomic } from '../storage/atomic.js';
|
|
@@ -33,7 +34,7 @@ import {
|
|
|
33
34
|
} from './visible-entry.js';
|
|
34
35
|
import { copyPersistedSessionAttachments, readPersistedAttachmentFile, persistYeaftAttachments, MAX_TOTAL_BYTES } from '../attachments.js';
|
|
35
36
|
import { markConversationDirty } from './history-index-state.js';
|
|
36
|
-
import { historyError, isHumanRequest, messageOrigin,
|
|
37
|
+
import { historyError, isHumanRequest, messageOrigin, planSessionMerge, materializeSessionMerge, materializeSessionCopy, sessionCopyOrigins } from './session-history.js';
|
|
37
38
|
|
|
38
39
|
/**
|
|
39
40
|
* Default cold-start "recent window" size, expressed in TURNS (not raw
|
|
@@ -84,6 +85,7 @@ const SEGMENT_TARGET_BYTES = 1024 * 1024;
|
|
|
84
85
|
const SEGMENT_FIRST_NAME = '000001.jsonl';
|
|
85
86
|
const MESSAGE_SEQUENCE_FILE = 'message-sequence.json';
|
|
86
87
|
const MESSAGE_SEQUENCE_LOCK_DIR = 'message-sequence.lock';
|
|
88
|
+
const MESSAGE_SEQUENCE_MUTEX_FILE = 'message-sequence-lock.db';
|
|
87
89
|
const MESSAGE_SEQUENCE_LOCK_STALE_MS = 30_000;
|
|
88
90
|
const MESSAGE_SEQUENCE_LOCK_ATTEMPTS = 200;
|
|
89
91
|
const MESSAGE_SEQUENCE_LOCK_RETRY_MS = 5;
|
|
@@ -142,38 +144,42 @@ function sleepSync(ms) {
|
|
|
142
144
|
Atomics.wait(buffer, 0, 0, ms);
|
|
143
145
|
}
|
|
144
146
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
+
// The SQLite transaction serializes BOTH reservation and abandoned-directory
|
|
148
|
+
// recovery. Kernel locks disappear on process exit; unlike rename-after-check,
|
|
149
|
+
// no contender can replace the observed directory while another reclaims it.
|
|
150
|
+
function withMessageSequenceMutex(root, callback) {
|
|
151
|
+
const path = join(root, MESSAGE_SEQUENCE_MUTEX_FILE);
|
|
152
|
+
let db;
|
|
147
153
|
try {
|
|
148
|
-
|
|
149
|
-
|
|
154
|
+
db = new DatabaseSync(path);
|
|
155
|
+
db.exec('PRAGMA busy_timeout=1000; BEGIN IMMEDIATE;');
|
|
156
|
+
return callback();
|
|
150
157
|
} catch (error) {
|
|
151
|
-
|
|
158
|
+
// SQLite wraps OS permission failures. Only normalize a confirmed read-only
|
|
159
|
+
// path; busy/corrupt/missing-parent errors remain real failures.
|
|
160
|
+
if (error?.code === 'ERR_SQLITE_ERROR' && [8, 14].includes(error.errcode & 255)) {
|
|
161
|
+
try {
|
|
162
|
+
accessSync(root, constants.W_OK);
|
|
163
|
+
if (existsSync(path)) accessSync(path, constants.W_OK);
|
|
164
|
+
} catch (permissionError) {
|
|
165
|
+
if (isPermissionError(permissionError)) throw permissionError;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
} finally {
|
|
170
|
+
try { db?.exec('ROLLBACK;'); } catch {}
|
|
171
|
+
db?.close();
|
|
152
172
|
}
|
|
153
173
|
}
|
|
154
174
|
|
|
155
|
-
function
|
|
175
|
+
function abandonedMessageSequenceLock(lockDir) {
|
|
156
176
|
const details = statSync(lockDir);
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
177
|
+
let owner;
|
|
178
|
+
try { owner = JSON.parse(readFileSync(join(lockDir, 'owner.json'), 'utf8')); } catch {}
|
|
179
|
+
if (owner?.host === hostname() && Number.isInteger(owner.pid) && owner.pid > 0) {
|
|
180
|
+
try { process.kill(owner.pid, 0); return false; }
|
|
181
|
+
catch (error) { return error?.code === 'ESRCH'; }
|
|
161
182
|
}
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
function messageSequenceLockIdentity(owner) {
|
|
165
|
-
if (!owner) return null;
|
|
166
|
-
if (typeof owner.token === 'string' && owner.token) return `token:${owner.token}`;
|
|
167
|
-
return `legacy:${owner.host || ''}:${Number(owner.pid) || 0}:${Number(owner.startedAt) || 0}`;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
function messageSequenceLockCanBeTaken(lockDir) {
|
|
171
|
-
const { owner, details } = readMessageSequenceLock(lockDir);
|
|
172
|
-
if (owner?.host === hostname()) return !processIsAlive(Number(owner.pid));
|
|
173
|
-
// Yeaft data roots are local, but a hostname can change after restoring a
|
|
174
|
-
// machine image. Never steal a live local owner's lock based on age alone;
|
|
175
|
-
// an unverifiable remote/legacy owner is reclaimable only after the stale
|
|
176
|
-
// window and only if takeMessageSequenceLock observes the same identity.
|
|
177
183
|
return Date.now() - details.mtimeMs > MESSAGE_SEQUENCE_LOCK_STALE_MS;
|
|
178
184
|
}
|
|
179
185
|
|
|
@@ -203,29 +209,6 @@ function releaseMessageSequenceLock(lockDir, token) {
|
|
|
203
209
|
return true;
|
|
204
210
|
}
|
|
205
211
|
|
|
206
|
-
function takeMessageSequenceLock(lockDir) {
|
|
207
|
-
const observed = readMessageSequenceLock(lockDir).owner;
|
|
208
|
-
if (!messageSequenceLockCanBeTaken(lockDir)) return false;
|
|
209
|
-
const observedIdentity = messageSequenceLockIdentity(observed);
|
|
210
|
-
const claimed = `${lockDir}.stale-${randomUUID()}`;
|
|
211
|
-
try {
|
|
212
|
-
renameSync(lockDir, claimed);
|
|
213
|
-
} catch (error) {
|
|
214
|
-
if (error?.code === 'ENOENT') return true;
|
|
215
|
-
return false;
|
|
216
|
-
}
|
|
217
|
-
const claimedOwner = readMessageSequenceLock(claimed).owner;
|
|
218
|
-
const ownerChanged = messageSequenceLockIdentity(claimedOwner) !== observedIdentity;
|
|
219
|
-
const ownerRevived = claimedOwner?.host === hostname()
|
|
220
|
-
&& processIsAlive(Number(claimedOwner.pid));
|
|
221
|
-
if (ownerChanged || ownerRevived) {
|
|
222
|
-
try { renameSync(claimed, lockDir); } catch {}
|
|
223
|
-
return false;
|
|
224
|
-
}
|
|
225
|
-
rmSync(claimed, { recursive: true, force: true });
|
|
226
|
-
return true;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
212
|
function acquireMessageSequenceLock(lockDir) {
|
|
230
213
|
for (let attempt = 0; attempt < MESSAGE_SEQUENCE_LOCK_ATTEMPTS; attempt += 1) {
|
|
231
214
|
const token = randomUUID();
|
|
@@ -233,8 +216,13 @@ function acquireMessageSequenceLock(lockDir) {
|
|
|
233
216
|
mkdirSync(lockDir, { mode: 0o700 });
|
|
234
217
|
} catch (error) {
|
|
235
218
|
if (error?.code !== 'EEXIST') throw error;
|
|
219
|
+
// Caller holds the kernel-backed mutex, including throughout recovery.
|
|
220
|
+
// All current writers take it before touching the directory/sidecar.
|
|
236
221
|
try {
|
|
237
|
-
if (
|
|
222
|
+
if (abandonedMessageSequenceLock(lockDir)) {
|
|
223
|
+
rmSync(lockDir, { recursive: true });
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
238
226
|
} catch (inspectionError) {
|
|
239
227
|
if (inspectionError?.code === 'ENOENT') continue;
|
|
240
228
|
throw inspectionError;
|
|
@@ -255,7 +243,7 @@ function acquireMessageSequenceLock(lockDir) {
|
|
|
255
243
|
throw error;
|
|
256
244
|
}
|
|
257
245
|
}
|
|
258
|
-
throw
|
|
246
|
+
throw historyError('message_sequence_locked', `Timed out reserving conversation message ids: ${lockDir}`);
|
|
259
247
|
}
|
|
260
248
|
|
|
261
249
|
function latestTodoWriteSnapshot(toolCalls) {
|
|
@@ -1119,14 +1107,14 @@ class SegmentStore {
|
|
|
1119
1107
|
* Read physical rows without applying reflection tombstones. Session cloning
|
|
1120
1108
|
* needs the complete durable transcript, including rows hidden by folding.
|
|
1121
1109
|
*/
|
|
1122
|
-
readAllRaw({ includeCold = false } = {}) {
|
|
1110
|
+
readAllRaw({ includeCold = false, appendOrder = false } = {}) {
|
|
1123
1111
|
if (!this.hasData()) return [];
|
|
1124
1112
|
const idx = this.loadIndex();
|
|
1125
|
-
|
|
1113
|
+
const rows = (idx.segments || [])
|
|
1126
1114
|
.slice()
|
|
1127
|
-
.sort((a, b) => (a.firstSeq || 0) - (b.firstSeq || 0))
|
|
1128
|
-
.flatMap(segment => this.#readSegment(segment.file, { includeCold }))
|
|
1129
|
-
|
|
1115
|
+
.sort((a, b) => appendOrder ? a.file.localeCompare(b.file) : (a.firstSeq || 0) - (b.firstSeq || 0))
|
|
1116
|
+
.flatMap(segment => this.#readSegment(segment.file, { includeCold, appendOrder }));
|
|
1117
|
+
return appendOrder ? rows : rows.sort(compareMessagesBySeq);
|
|
1130
1118
|
}
|
|
1131
1119
|
|
|
1132
1120
|
*scan({ beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, scanStats = null, projection = 'context' } = {}) {
|
|
@@ -1353,10 +1341,10 @@ class SegmentStore {
|
|
|
1353
1341
|
out.streamId = typeof out.streamId === 'string' && out.streamId ? out.streamId : null;
|
|
1354
1342
|
out.revision = Number.isFinite(Number(out.revision)) ? Math.max(0, Number(out.revision)) : 0;
|
|
1355
1343
|
out.segments = Array.isArray(out.segments) ? out.segments.filter(s => s && s.file) : [];
|
|
1356
|
-
out.segments.sort((a, b) =>
|
|
1344
|
+
out.segments.sort((a, b) => a.file.localeCompare(b.file));
|
|
1357
1345
|
const maxSeq = out.segments.reduce((max, seg) => Math.max(max, Number(seg.lastSeq) || 0), 0);
|
|
1358
1346
|
out.nextSeq = Math.max(Number(out.nextSeq) || 1, maxSeq + 1);
|
|
1359
|
-
out.activeSegment = out.
|
|
1347
|
+
out.activeSegment = out.segments[out.segments.length - 1]?.file || out.activeSegment || SEGMENT_FIRST_NAME;
|
|
1360
1348
|
out.totalMessages = Number(out.totalMessages) || out.segments.reduce((sum, seg) => sum + (Number(seg.count) || 0), 0);
|
|
1361
1349
|
out.foldedMessageIds = Array.isArray(out.foldedMessageIds)
|
|
1362
1350
|
? Array.from(new Set(out.foldedMessageIds.filter(id => typeof id === 'string' && id)))
|
|
@@ -1369,7 +1357,7 @@ class SegmentStore {
|
|
|
1369
1357
|
if (!existsSync(this.segmentDir)) return idx;
|
|
1370
1358
|
const files = readdirSync(this.segmentDir).filter(f => f.endsWith('.jsonl')).sort();
|
|
1371
1359
|
for (const file of files) {
|
|
1372
|
-
const rows = this.#readSegment(file, { includeCold: true });
|
|
1360
|
+
const rows = this.#readSegment(file, { includeCold: true, appendOrder: true });
|
|
1373
1361
|
if (rows.length === 0) continue;
|
|
1374
1362
|
const seqs = rows.map(r => parseSeqFromId(r.id)).filter(Number.isFinite);
|
|
1375
1363
|
const path = join(this.segmentDir, file);
|
|
@@ -1399,7 +1387,7 @@ class SegmentStore {
|
|
|
1399
1387
|
return true;
|
|
1400
1388
|
}
|
|
1401
1389
|
|
|
1402
|
-
#readSegment(file, { beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false } = {}) {
|
|
1390
|
+
#readSegment(file, { beforeSeq = Infinity, afterSeq = -Infinity, desc = false, includeCold = false, appendOrder = false } = {}) {
|
|
1403
1391
|
const path = join(this.segmentDir, file);
|
|
1404
1392
|
if (!existsSync(path)) return [];
|
|
1405
1393
|
const raw = readFileSync(path, 'utf8');
|
|
@@ -1413,7 +1401,7 @@ class SegmentStore {
|
|
|
1413
1401
|
if (!includeCold && msg.cold === true) continue;
|
|
1414
1402
|
rows.push(msg);
|
|
1415
1403
|
}
|
|
1416
|
-
rows.sort(compareMessagesBySeq);
|
|
1404
|
+
if (!appendOrder) rows.sort(compareMessagesBySeq);
|
|
1417
1405
|
return desc ? rows.reverse() : rows;
|
|
1418
1406
|
}
|
|
1419
1407
|
}
|
|
@@ -1575,6 +1563,7 @@ export class ConversationStore {
|
|
|
1575
1563
|
return {
|
|
1576
1564
|
...msg,
|
|
1577
1565
|
id: `m${String(seq).padStart(4, '0')}`,
|
|
1566
|
+
seq,
|
|
1578
1567
|
time: msg.time || new Date().toISOString(),
|
|
1579
1568
|
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
1580
1569
|
};
|
|
@@ -1583,6 +1572,7 @@ export class ConversationStore {
|
|
|
1583
1572
|
const fullMsg = {
|
|
1584
1573
|
...msg,
|
|
1585
1574
|
id,
|
|
1575
|
+
seq,
|
|
1586
1576
|
time: msg.time || new Date().toISOString(),
|
|
1587
1577
|
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
1588
1578
|
};
|
|
@@ -1637,6 +1627,7 @@ export class ConversationStore {
|
|
|
1637
1627
|
return rows.map((msg, index) => ({
|
|
1638
1628
|
...msg,
|
|
1639
1629
|
id: `m${String(firstSeq + index).padStart(4, '0')}`,
|
|
1630
|
+
seq: firstSeq + index,
|
|
1640
1631
|
time: msg.time || new Date().toISOString(),
|
|
1641
1632
|
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
1642
1633
|
}));
|
|
@@ -1644,6 +1635,7 @@ export class ConversationStore {
|
|
|
1644
1635
|
const persisted = rows.map((msg, index) => ({
|
|
1645
1636
|
...msg,
|
|
1646
1637
|
id: `m${String(firstSeq + index).padStart(4, '0')}`,
|
|
1638
|
+
seq: firstSeq + index,
|
|
1647
1639
|
time: msg.time || new Date().toISOString(),
|
|
1648
1640
|
tokens_est: msg.tokens_est || estimateTokens(msg.content || ''),
|
|
1649
1641
|
}));
|
|
@@ -2042,7 +2034,7 @@ export class ConversationStore {
|
|
|
2042
2034
|
if (typeof sessionId !== 'string' || !sessionId) {
|
|
2043
2035
|
throw historyError('invalid_fork_boundary', 'Session identity is required');
|
|
2044
2036
|
}
|
|
2045
|
-
const { rows: durableRows, streamId } = this.#loadDurableSession(sessionId);
|
|
2037
|
+
const { rows: durableRows, streamId } = this.#loadDurableSession(sessionId, { allowDuplicateIds: true });
|
|
2046
2038
|
const rows = Object.hasOwn(options, 'beforeMessageId')
|
|
2047
2039
|
? durableRows.slice(0, this.#sessionCopyBeforeMessageCutoff(durableRows, streamId, options.beforeMessageId))
|
|
2048
2040
|
: durableRows;
|
|
@@ -2060,6 +2052,9 @@ export class ConversationStore {
|
|
|
2060
2052
|
const fence = typeof beforeMessageId === 'string' ? beforeMessageId.trim() : '';
|
|
2061
2053
|
if (!fence) throw historyError('invalid_fork_boundary', 'beforeMessageId must be a nonempty string');
|
|
2062
2054
|
const index = rows.findIndex(row => row.id === fence);
|
|
2055
|
+
if (rows.filter(row => row.id === fence).length > 1) {
|
|
2056
|
+
throw historyError('ambiguous_fork_boundary', 'Message boundary identifies multiple durable rows');
|
|
2057
|
+
}
|
|
2063
2058
|
if (index < 0) throw historyError('fork_boundary_not_found', `Human message not found: ${fence}`);
|
|
2064
2059
|
if (!isHumanRequest(rows[index])) throw historyError('invalid_fork_boundary', 'beforeMessageId must identify a durable human request');
|
|
2065
2060
|
const user = rows[index];
|
|
@@ -2142,13 +2137,15 @@ export class ConversationStore {
|
|
|
2142
2137
|
* 不会把活动 turn 截短伪装成完整 turn。前缀删改拒绝为 invalid_fork_boundary。
|
|
2143
2138
|
* 兼容旧 inclusive throughMessageId(null 为空)和 exclusive beforeMessageId;
|
|
2144
2139
|
* 所有已提供边界均验证,取较早 cutoff。runtime 负责在最早活动输入前捕获。
|
|
2140
|
+
* 重复的旧 ID 按物理记录逐条复制;idMap 仅包含可唯一映射的旧 ID。
|
|
2141
|
+
* 歧义引用保存在目标行 unresolvedMessageReferences 中,不连错、不丢消息。
|
|
2145
2142
|
* @returns {{ copiedCount: number, idMap: Map<string, string> }}
|
|
2146
2143
|
*/
|
|
2147
2144
|
copySession(sourceSessionId, targetSessionId, options = {}) {
|
|
2148
2145
|
if (!sourceSessionId || !targetSessionId || sourceSessionId === targetSessionId) {
|
|
2149
2146
|
return { copiedCount: 0, idMap: new Map() };
|
|
2150
2147
|
}
|
|
2151
|
-
const { rows: durableRows, streamId } = this.#loadDurableSession(sourceSessionId);
|
|
2148
|
+
const { rows: durableRows, streamId } = this.#loadDurableSession(sourceSessionId, { allowDuplicateIds: true });
|
|
2152
2149
|
let rows = durableRows;
|
|
2153
2150
|
let cutoff = rows.length;
|
|
2154
2151
|
let capturedCutoff = null;
|
|
@@ -2172,6 +2169,9 @@ export class ConversationStore {
|
|
|
2172
2169
|
const fence = typeof options.throughMessageId === 'string' ? options.throughMessageId.trim() : '';
|
|
2173
2170
|
if (!fence) throw historyError('invalid_fork_boundary', 'throughMessageId must be a nonempty string or null');
|
|
2174
2171
|
const index = rows.findIndex(row => row.id === fence);
|
|
2172
|
+
if (rows.filter(row => row.id === fence).length > 1) {
|
|
2173
|
+
throw historyError('ambiguous_fork_boundary', 'Message boundary identifies multiple durable rows');
|
|
2174
|
+
}
|
|
2175
2175
|
if (index < 0) throw historyError('fork_boundary_not_found', `Durable message not found: ${fence}`);
|
|
2176
2176
|
cutoff = Math.min(cutoff, index + 1);
|
|
2177
2177
|
}
|
|
@@ -2223,29 +2223,11 @@ export class ConversationStore {
|
|
|
2223
2223
|
if (rows.length === 0) return { copiedCount: 0, idMap: new Map() };
|
|
2224
2224
|
|
|
2225
2225
|
const firstSeq = this.#reserveSeqRange(rows.length);
|
|
2226
|
-
const idMap = new Map(rows.map((row, index) => [
|
|
2227
|
-
row.id,
|
|
2228
|
-
`m${String(firstSeq + index).padStart(4, '0')}`,
|
|
2229
|
-
]));
|
|
2230
|
-
const remapId = id => idMap.get(id) || id;
|
|
2231
2226
|
const assetRows = copyPersistedSessionAttachments(rows, { yeaftDir: this.#dir, sourceSessionId, targetSessionId });
|
|
2232
|
-
const copies = assetRows
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
sessionId: targetSessionId,
|
|
2237
|
-
origin: messageOrigin(row, sourceSessionId, streamId),
|
|
2238
|
-
};
|
|
2239
|
-
delete copy.seq;
|
|
2240
|
-
if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remapId(row.quote.messageId) };
|
|
2241
|
-
if (idMap.has(row.causalRootId)) copy.causalRootId = remapId(row.causalRootId);
|
|
2242
|
-
if (Array.isArray(row.foldedMessageIds)) {
|
|
2243
|
-
copy.foldedMessageIds = row.foldedMessageIds.map(remapId);
|
|
2244
|
-
}
|
|
2245
|
-
if (Array.isArray(row.sourceMessageIds)) {
|
|
2246
|
-
copy.sourceMessageIds = row.sourceMessageIds.map(remapId);
|
|
2247
|
-
}
|
|
2248
|
-
return copy;
|
|
2227
|
+
const { rows: copies, idMap } = materializeSessionCopy(assetRows, {
|
|
2228
|
+
sourceSessionId, streamId, targetSessionId, firstSeq,
|
|
2229
|
+
sourceRows: durableRows,
|
|
2230
|
+
origins: sessionCopyOrigins(durableRows, sourceSessionId, streamId),
|
|
2249
2231
|
});
|
|
2250
2232
|
const target = this.#segmentStoreForConversationDir(this.#sessionConversationDir(targetSessionId));
|
|
2251
2233
|
const written = target.appendBatch(copies);
|
|
@@ -2256,7 +2238,7 @@ export class ConversationStore {
|
|
|
2256
2238
|
// Session irrecoverably incomplete. Verify the physical target rows before
|
|
2257
2239
|
// the higher-level CRUD operation commits the clone.
|
|
2258
2240
|
const persistedIds = new Set(target.readAllRaw({ includeCold: true }).map(row => row.id));
|
|
2259
|
-
const expectedIds =
|
|
2241
|
+
const expectedIds = copies.map(row => row.id);
|
|
2260
2242
|
if (written.length !== rows.length
|
|
2261
2243
|
|| persistedIds.size !== expectedIds.length
|
|
2262
2244
|
|| expectedIds.some(id => !persistedIds.has(id))) {
|
|
@@ -2265,10 +2247,12 @@ export class ConversationStore {
|
|
|
2265
2247
|
return { copiedCount: written.length, idMap };
|
|
2266
2248
|
}
|
|
2267
2249
|
|
|
2268
|
-
#loadDurableSession(sourceSessionId) {
|
|
2250
|
+
#loadDurableSession(sourceSessionId, { allowDuplicateIds = false } = {}) {
|
|
2269
2251
|
const primary = this.#segmentStoreForConversationDir(this.#sessionConversationDir(sourceSessionId));
|
|
2270
|
-
const segmentedRows = primary.readAllRaw({ includeCold: true });
|
|
2271
|
-
|
|
2252
|
+
const segmentedRows = primary.readAllRaw({ includeCold: true, appendOrder: true });
|
|
2253
|
+
const segmentIds = new Set(segmentedRows.map(row => row.id));
|
|
2254
|
+
const hasDuplicates = segmentIds.size !== segmentedRows.length;
|
|
2255
|
+
if (hasDuplicates && !allowDuplicateIds) {
|
|
2272
2256
|
throw historyError('ambiguous_merge_history', 'Duplicate persisted identity within one Session');
|
|
2273
2257
|
}
|
|
2274
2258
|
const legacyRows = this.#sessionFileEntries('all', sourceSessionId)
|
|
@@ -2279,15 +2263,25 @@ export class ConversationStore {
|
|
|
2279
2263
|
}
|
|
2280
2264
|
})
|
|
2281
2265
|
.filter(Boolean);
|
|
2282
|
-
//
|
|
2283
|
-
//
|
|
2284
|
-
|
|
2285
|
-
const
|
|
2286
|
-
for (const row of
|
|
2266
|
+
// A shadow is collapsible only when its legacy-representable payload is
|
|
2267
|
+
// identical, not merely its ID. Distinct hot/cold/segment rows all survive.
|
|
2268
|
+
const shadowKey = row => serializeMessage({ ...row, time: row.time || '<missing>' });
|
|
2269
|
+
const shadows = new Map();
|
|
2270
|
+
for (const row of segmentedRows) {
|
|
2271
|
+
const key = shadowKey(row);
|
|
2272
|
+
shadows.set(key, (shadows.get(key) || 0) + 1);
|
|
2273
|
+
}
|
|
2274
|
+
const legacy = [];
|
|
2275
|
+
for (const row of legacyRows) {
|
|
2287
2276
|
if (row?.sessionId !== sourceSessionId || typeof row.id !== 'string' || !row.id) continue;
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2277
|
+
const key = shadowKey(row);
|
|
2278
|
+
if (shadows.get(key)) { shadows.set(key, shadows.get(key) - 1); continue; }
|
|
2279
|
+
legacy.push(row);
|
|
2280
|
+
}
|
|
2281
|
+
// Legacy-only rows predate the segmented transcript; each block has its
|
|
2282
|
+
// own stable order. Never switch order after an append introduces a clash.
|
|
2283
|
+
const rows = [...legacy.sort(compareMessagesBySeq),
|
|
2284
|
+
...segmentedRows.filter(row => row?.sessionId === sourceSessionId)];
|
|
2291
2285
|
return { sessionId: sourceSessionId, streamId: rows.length || primary.hasData() ? primary.metadata().streamId : null, rows };
|
|
2292
2286
|
}
|
|
2293
2287
|
|
|
@@ -3785,16 +3779,21 @@ export class ConversationStore {
|
|
|
3785
3779
|
* that did not maintain an allocator high-water mark.
|
|
3786
3780
|
* @returns {number}
|
|
3787
3781
|
*/
|
|
3788
|
-
#scanNextSeq() {
|
|
3782
|
+
#scanNextSeq({ verifyRows = false } = {}) {
|
|
3789
3783
|
let maxSeq = 0;
|
|
3790
|
-
for (const dir of [this.#chatDir, ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
3784
|
+
for (const dir of [this.#chatDir, ...this.#chatConversationDirs(), ...this.#sessionConversationDirs({ primaryOnly: true })]) {
|
|
3791
3785
|
const store = this.#segmentStoreForConversationDir(dir);
|
|
3792
3786
|
if (store.hasData()) {
|
|
3793
3787
|
const idx = store.loadIndex();
|
|
3794
3788
|
maxSeq = Math.max(maxSeq, (Number(idx.nextSeq) || 1) - 1);
|
|
3789
|
+
if (verifyRows) {
|
|
3790
|
+
for (const row of store.readAllRaw({ includeCold: true, appendOrder: true })) {
|
|
3791
|
+
maxSeq = Math.max(maxSeq, parseSeqFromId(row.id) || 0, Number(row.seq) || 0);
|
|
3792
|
+
}
|
|
3793
|
+
}
|
|
3795
3794
|
}
|
|
3796
3795
|
}
|
|
3797
|
-
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
3796
|
+
for (const dir of [this.#chatMsgDir, this.#chatColdDir, ...this.#chatMessageDirs('messages'), ...this.#chatMessageDirs('cold'), ...this.#sessionMessageDirs('messages'), ...this.#sessionMessageDirs('cold'), this.#legacyMsgDir, this.#legacyColdDir]) {
|
|
3798
3797
|
if (!existsSync(dir)) continue;
|
|
3799
3798
|
for (const file of readdirSync(dir)) {
|
|
3800
3799
|
const match = file.match(/^m(\d+)\.md$/);
|
|
@@ -3807,17 +3806,19 @@ export class ConversationStore {
|
|
|
3807
3806
|
/**
|
|
3808
3807
|
* Reserve one non-overlapping range of global message ids.
|
|
3809
3808
|
*
|
|
3810
|
-
*
|
|
3811
|
-
*
|
|
3812
|
-
*
|
|
3813
|
-
*
|
|
3814
|
-
* and
|
|
3809
|
+
* SQLite BEGIN IMMEDIATE owns the cross-process mutex and crash recovery;
|
|
3810
|
+
* the directory lock also fences earlier writers during rolling upgrades. The atomic high-water sidecar makes a whole transcript
|
|
3811
|
+
* reservation cheap after the first verified bootstrap. A legacy/missing
|
|
3812
|
+
* sidecar requires actual transcript ID verification, not only derived seq
|
|
3813
|
+
* indexes. Restore/import is offline-only and must invalidate the sidecar;
|
|
3814
|
+
* neither a cache epoch nor a scan can fence already in-flight writers.
|
|
3815
3815
|
*
|
|
3816
3816
|
* @param {number} count
|
|
3817
3817
|
* @returns {number} first reserved sequence
|
|
3818
3818
|
*/
|
|
3819
3819
|
#reserveSeqRange(count) {
|
|
3820
3820
|
const size = Math.max(1, Math.floor(Number(count) || 1));
|
|
3821
|
+
if (!Number.isSafeInteger(size)) throw new Error('Invalid conversation message id reservation');
|
|
3821
3822
|
const rootKey = resolve(this.#dir);
|
|
3822
3823
|
const sequencePath = join(this.#dir, MESSAGE_SEQUENCE_FILE);
|
|
3823
3824
|
const cached = messageSequenceReservations.get(rootKey);
|
|
@@ -3827,6 +3828,7 @@ export class ConversationStore {
|
|
|
3827
3828
|
const durableNext = Number(recorded?.nextSeq);
|
|
3828
3829
|
if (Number.isSafeInteger(durableNext)
|
|
3829
3830
|
&& durableNext >= cached.end
|
|
3831
|
+
&& recorded.transcriptIdsVerified === true
|
|
3830
3832
|
&& typeof recorded?.epoch === 'string'
|
|
3831
3833
|
&& recorded.epoch === cached.epoch) {
|
|
3832
3834
|
const firstSeq = cached.next;
|
|
@@ -3843,35 +3845,39 @@ export class ConversationStore {
|
|
|
3843
3845
|
// monotonic identities, not a promise of contiguous durable rows.
|
|
3844
3846
|
const durableSize = Math.max(size, MESSAGE_SEQUENCE_RESERVATION_SIZE);
|
|
3845
3847
|
const lockDir = join(this.#dir, MESSAGE_SEQUENCE_LOCK_DIR);
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
try {
|
|
3849
|
-
let recordedNext = null;
|
|
3850
|
-
let epoch = null;
|
|
3848
|
+
return withMessageSequenceMutex(this.#dir, () => {
|
|
3849
|
+
const lockToken = acquireMessageSequenceLock(lockDir);
|
|
3851
3850
|
try {
|
|
3852
|
-
|
|
3853
|
-
|
|
3854
|
-
|
|
3855
|
-
|
|
3856
|
-
|
|
3851
|
+
let recordedNext = null;
|
|
3852
|
+
let epoch = null;
|
|
3853
|
+
let transcriptIdsVerified = false;
|
|
3854
|
+
try {
|
|
3855
|
+
const recorded = JSON.parse(readFileSync(sequencePath, 'utf8'));
|
|
3856
|
+
const nextSeq = Number(recorded?.nextSeq);
|
|
3857
|
+
if (Number.isSafeInteger(nextSeq) && nextSeq > 0) {
|
|
3858
|
+
recordedNext = nextSeq;
|
|
3859
|
+
transcriptIdsVerified = recorded.transcriptIdsVerified === true;
|
|
3860
|
+
if (typeof recorded.epoch === 'string' && recorded.epoch) epoch = recorded.epoch;
|
|
3861
|
+
}
|
|
3862
|
+
} catch (error) {
|
|
3863
|
+
if (error?.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
|
|
3857
3864
|
}
|
|
3858
|
-
|
|
3859
|
-
|
|
3860
|
-
|
|
3861
|
-
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
|
|
3865
|
-
|
|
3866
|
-
|
|
3867
|
-
|
|
3868
|
-
|
|
3869
|
-
|
|
3870
|
-
|
|
3871
|
-
}
|
|
3872
|
-
|
|
3873
|
-
|
|
3874
|
-
|
|
3875
|
-
}
|
|
3865
|
+
// A syntactically valid sidecar can still be stale after a partial restore
|
|
3866
|
+
// or crash. Never trust it below the durable transcript high-water mark.
|
|
3867
|
+
const scannedNext = this.#scanNextSeq({ verifyRows: !transcriptIdsVerified });
|
|
3868
|
+
const firstSeq = Math.max(recordedNext || 1, scannedNext);
|
|
3869
|
+
if (!Number.isSafeInteger(firstSeq + durableSize)) throw new Error('Conversation message id sequence exhausted');
|
|
3870
|
+
epoch ||= randomUUID();
|
|
3871
|
+
writeAtomic(sequencePath, `${JSON.stringify({ version: 1, epoch, transcriptIdsVerified: true, nextSeq: firstSeq + durableSize })}\n`, { mode: 0o600 });
|
|
3872
|
+
messageSequenceReservations.set(rootKey, {
|
|
3873
|
+
epoch,
|
|
3874
|
+
next: firstSeq + size,
|
|
3875
|
+
end: firstSeq + durableSize,
|
|
3876
|
+
});
|
|
3877
|
+
return firstSeq;
|
|
3878
|
+
} finally {
|
|
3879
|
+
releaseMessageSequenceLock(lockDir, lockToken);
|
|
3880
|
+
}
|
|
3881
|
+
});
|
|
3876
3882
|
}
|
|
3877
3883
|
}
|
|
@@ -35,7 +35,81 @@ export function historicalReplayRow(row) {
|
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
function originKey(origin) {
|
|
38
|
-
return JSON.stringify([origin.streamId, origin.messageId]);
|
|
38
|
+
return JSON.stringify([origin.streamId, origin.messageId, origin.rowOccurrence ?? null, origin.copyOccurrence ?? null]);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 在完整源上分配稳定 lineage,再应用复制边界;保留旧 occurrence 命名空间。 */
|
|
42
|
+
export function sessionCopyOrigins(rows, sourceSessionId, streamId) {
|
|
43
|
+
const origins = rows.map(row => messageOrigin(row, sourceSessionId, streamId));
|
|
44
|
+
const reserved = new Set(origins.map(originKey));
|
|
45
|
+
const used = new Set();
|
|
46
|
+
return origins.map(origin => {
|
|
47
|
+
if (!used.has(originKey(origin))) { used.add(originKey(origin)); return origin; }
|
|
48
|
+
let occurrence = 2;
|
|
49
|
+
let candidate;
|
|
50
|
+
do {
|
|
51
|
+
candidate = { ...origin, copyOccurrence: [...(origin.copyOccurrence || []), occurrence++] };
|
|
52
|
+
} while (reserved.has(originKey(candidate)) || used.has(originKey(candidate)));
|
|
53
|
+
used.add(originKey(candidate));
|
|
54
|
+
return candidate;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 按源记录位置分配复制身份,不能用 Map<旧 ID, 新 ID> 覆盖冲突记录。
|
|
60
|
+
* 无法唯一识别目标的引用从可执行引用字段移到诊断元数据;保留
|
|
61
|
+
* 原引用及所有目标候选,不阻断复制,也不猜测折叠/因果关系。源行不变。
|
|
62
|
+
* idMap 只提供唯一旧 ID 的兼容查询,绝不返回任意一个冲突候选。
|
|
63
|
+
*/
|
|
64
|
+
export function materializeSessionCopy(rows, { sourceSessionId, streamId, targetSessionId, firstSeq, sourceRows = rows, origins = sessionCopyOrigins(sourceRows, sourceSessionId, streamId) }) {
|
|
65
|
+
const ids = rows.map((_, index) => `m${String(firstSeq + index).padStart(4, '0')}`);
|
|
66
|
+
const occurrences = new Map();
|
|
67
|
+
sourceRows.forEach((row, index) => {
|
|
68
|
+
const positions = occurrences.get(row.id) || [];
|
|
69
|
+
positions.push(index);
|
|
70
|
+
occurrences.set(row.id, positions);
|
|
71
|
+
});
|
|
72
|
+
const idMap = new Map([...occurrences].filter(([, positions]) => positions.length === 1 && positions[0] < rows.length)
|
|
73
|
+
.map(([id, positions]) => [id, ids[positions[0]]]));
|
|
74
|
+
const copies = rows.map((row, index) => {
|
|
75
|
+
const copy = { ...historicalReplayRow(row), id: ids[index], seq: firstSeq + index,
|
|
76
|
+
sessionId: targetSessionId, origin: origins[index] };
|
|
77
|
+
// Existing diagnostics also belong to the copied namespace on nested forks.
|
|
78
|
+
const unresolved = (row.unresolvedMessageReferences || []).map(reference => ({ ...reference,
|
|
79
|
+
candidateMessageIds: (reference.candidateMessageIds || []).flatMap(id =>
|
|
80
|
+
occurrences.has(id) ? occurrences.get(id).map(candidate => ids[candidate]).filter(Boolean) : [id]),
|
|
81
|
+
}));
|
|
82
|
+
const remap = (id, field, referenceIndex) => {
|
|
83
|
+
const positions = occurrences.get(id);
|
|
84
|
+
if (!positions) return id; // External/client identities are not persisted row IDs.
|
|
85
|
+
// Duplicate bare IDs contain no record-level target identity. Role,
|
|
86
|
+
// proximity and a matching VP turn cannot prove the intended record
|
|
87
|
+
// (and may select this very row). Never infer a live reference from them.
|
|
88
|
+
if (positions.length === 1) return ids[positions[0]] || id;
|
|
89
|
+
unresolved.push({ field, ...(referenceIndex === undefined ? {} : { index: referenceIndex }),
|
|
90
|
+
messageId: id, candidateMessageIds: positions.map(position => ids[position]).filter(Boolean),
|
|
91
|
+
candidateOrigins: positions.map(position => origins[position]) });
|
|
92
|
+
return undefined;
|
|
93
|
+
};
|
|
94
|
+
if (row.causalRootId) {
|
|
95
|
+
const value = remap(row.causalRootId, 'causalRootId');
|
|
96
|
+
if (value === undefined) delete copy.causalRootId;
|
|
97
|
+
else copy.causalRootId = value;
|
|
98
|
+
}
|
|
99
|
+
if (row.quote?.messageId) {
|
|
100
|
+
copy.quote = { ...row.quote };
|
|
101
|
+
const value = remap(row.quote.messageId, 'quote.messageId');
|
|
102
|
+
if (value === undefined) delete copy.quote.messageId;
|
|
103
|
+
else copy.quote.messageId = value;
|
|
104
|
+
}
|
|
105
|
+
for (const field of ['foldedMessageIds', 'sourceMessageIds']) {
|
|
106
|
+
if (Array.isArray(row[field])) copy[field] = row[field]
|
|
107
|
+
.map((id, referenceIndex) => remap(id, field, referenceIndex)).filter(id => id !== undefined);
|
|
108
|
+
}
|
|
109
|
+
if (unresolved.length) copy.unresolvedMessageReferences = unresolved;
|
|
110
|
+
return copy;
|
|
111
|
+
});
|
|
112
|
+
return { rows: copies, idMap };
|
|
39
113
|
}
|
|
40
114
|
|
|
41
115
|
function stableJson(value) {
|
|
@@ -218,6 +292,14 @@ function equivalentRows(left, right, aliases) {
|
|
|
218
292
|
if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
|
|
219
293
|
}
|
|
220
294
|
if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
|
|
295
|
+
if (row.unresolvedMessageReferences) copy.unresolvedMessageReferences = row.unresolvedMessageReferences
|
|
296
|
+
.map(reference => {
|
|
297
|
+
if (reference.candidateOrigins) {
|
|
298
|
+
const { candidateMessageIds, ...identity } = reference;
|
|
299
|
+
return identity; // Scope-independent evidence includes excluded suffix candidates.
|
|
300
|
+
}
|
|
301
|
+
return { ...reference, candidateMessageIds: reference.candidateMessageIds.map(remap) };
|
|
302
|
+
});
|
|
221
303
|
if (Array.isArray(row.attachments)) copy.attachments = row.attachments.map(att => {
|
|
222
304
|
const { path, ...metadata } = att;
|
|
223
305
|
return metadata;
|
|
@@ -326,6 +408,8 @@ export function materializeSessionMerge(plan, targetSessionId, firstSeq) {
|
|
|
326
408
|
if (Array.isArray(row[key])) copy[key] = row[key].map(remap);
|
|
327
409
|
}
|
|
328
410
|
if (row.quote?.messageId) copy.quote = { ...row.quote, messageId: remap(row.quote.messageId) };
|
|
411
|
+
if (row.unresolvedMessageReferences) copy.unresolvedMessageReferences = row.unresolvedMessageReferences
|
|
412
|
+
.map(reference => ({ ...reference, candidateMessageIds: reference.candidateMessageIds.map(remap) }));
|
|
329
413
|
if (row.toolCalls) copy.toolCalls = row.toolCalls.map(call => ({ ...call, id: calls.get(toolScope(row, call.id)) }));
|
|
330
414
|
if (turn.links.results.has(index)) copy.toolCallId = callId(turn.links.results.get(index));
|
|
331
415
|
if (calls.has(toolScope(row, row.presentationCallId))) copy.presentationCallId = calls.get(toolScope(row, row.presentationCallId));
|
package/yeaft/engine.js
CHANGED
|
@@ -17,7 +17,6 @@
|
|
|
17
17
|
* Reference: yeaft-yeaft-implementation-plan.md §3.1, §4 (Phase 2)
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { enhanceResponse } from './llm/response-enhance.js';
|
|
21
20
|
import { randomUUID } from 'crypto';
|
|
22
21
|
import { QueryProgress, progressReminder } from './query-progress.js';
|
|
23
22
|
import { createWebResearchState } from './web-research.js';
|
|
@@ -1397,7 +1396,6 @@ export class Engine {
|
|
|
1397
1396
|
skillManager: this.#skillManager,
|
|
1398
1397
|
conversationStore: this.#conversationStore,
|
|
1399
1398
|
adapter: this.#adapter,
|
|
1400
|
-
responseEnhance: vpCtx?.responseEnhance,
|
|
1401
1399
|
config: this.#config,
|
|
1402
1400
|
discoverTools: vpCtx?.discoverTools,
|
|
1403
1401
|
taskManager: this.#taskManager,
|
|
@@ -2532,8 +2530,6 @@ export class Engine {
|
|
|
2532
2530
|
let primaryModelAtLastBoundary = currentModel;
|
|
2533
2531
|
let cumulativeInputTokens = 0;
|
|
2534
2532
|
let cumulativeOutputTokens = 0;
|
|
2535
|
-
let enhancementCalls = 0;
|
|
2536
|
-
const enhancementUsageEvents = [];
|
|
2537
2533
|
let maxContextOccupancyRatio = 0;
|
|
2538
2534
|
let peakContextTokens = 0;
|
|
2539
2535
|
let peakContextWindow = 0;
|
|
@@ -4098,23 +4094,6 @@ export class Engine {
|
|
|
4098
4094
|
queryTurnId,
|
|
4099
4095
|
causalRootId,
|
|
4100
4096
|
webResearch,
|
|
4101
|
-
responseEnhance: (input, { signal: toolSignal } = {}) => enhanceResponse(input, {
|
|
4102
|
-
adapter: this.#adapter, model: currentModel, config: requestConfig,
|
|
4103
|
-
signal: toolSignal ?? signal, requestIdentity,
|
|
4104
|
-
effort: requestEffortDecision.effective ?? requestEffortDecision.requested ?? undefined,
|
|
4105
|
-
effortSource: requestEffortDecision.source ?? 'auto', effortConstraint,
|
|
4106
|
-
onStart: () => {
|
|
4107
|
-
if (isSubAgent) this.#toolRegistry?.reserveProviderRequest?.();
|
|
4108
|
-
enhancementCalls++;
|
|
4109
|
-
queryProgress.requestStarted();
|
|
4110
|
-
},
|
|
4111
|
-
onUsage: event => {
|
|
4112
|
-
cumulativeInputTokens += (event.inputTokens || 0) + (event.cacheTokensAreIncludedInInput ? 0 : (event.cacheReadTokens || 0) + (event.cacheWriteTokens || 0));
|
|
4113
|
-
cumulativeOutputTokens += event.outputTokens || 0;
|
|
4114
|
-
queryProgress.usageObserved(event);
|
|
4115
|
-
enhancementUsageEvents.push({ ...event, source: 'ResponseEnhance' });
|
|
4116
|
-
},
|
|
4117
|
-
}),
|
|
4118
4097
|
historyBeforeSeq: Number.isFinite(persistedQueryUser?.seq) ? persistedQueryUser.seq : parseSeqFromId(persistedQueryUser?.id),
|
|
4119
4098
|
router,
|
|
4120
4099
|
senderVpId,
|
|
@@ -4797,9 +4776,6 @@ export class Engine {
|
|
|
4797
4776
|
}));
|
|
4798
4777
|
queryToolCount += 1;
|
|
4799
4778
|
}
|
|
4800
|
-
// Surface side-call usage once, separately from main-request context
|
|
4801
|
-
// occupancy, so child token budgets and stream consumers also see it.
|
|
4802
|
-
for (const usage of enhancementUsageEvents.splice(0)) yield usage;
|
|
4803
4779
|
if (fatalToolError) throw fatalToolError;
|
|
4804
4780
|
}
|
|
4805
4781
|
|
|
@@ -4949,7 +4925,7 @@ export class Engine {
|
|
|
4949
4925
|
const llmCountMessage = lastPersistedAssistantTextMessage || lastPersistedAssistantMessage;
|
|
4950
4926
|
if (llmCountMessage && typeof this.#conversationStore?.update === 'function') {
|
|
4951
4927
|
const updated = this.#conversationStore.update(llmCountMessage, {
|
|
4952
|
-
llmCallCount: turnNumber
|
|
4928
|
+
llmCallCount: turnNumber,
|
|
4953
4929
|
inputTokens: cumulativeInputTokens,
|
|
4954
4930
|
outputTokens: cumulativeOutputTokens,
|
|
4955
4931
|
totalTokens,
|
|
@@ -4974,7 +4950,7 @@ export class Engine {
|
|
|
4974
4950
|
outputTokens: cumulativeOutputTokens,
|
|
4975
4951
|
totalTokens,
|
|
4976
4952
|
loopCount: turnNumber,
|
|
4977
|
-
modelRequestCount: turnNumber
|
|
4953
|
+
modelRequestCount: turnNumber,
|
|
4978
4954
|
...(lastSuccessfulModel ? { model: lastSuccessfulModel } : {}),
|
|
4979
4955
|
...(lastSuccessfulEffort ? { effort: lastSuccessfulEffort } : {}),
|
|
4980
4956
|
};
|
|
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { TaskManager } from '../tasks/manager.js';
|
|
5
5
|
import { TASK_RESULT_DELIVERY, isTerminalTaskStatus } from '../tasks/store.js';
|
|
6
6
|
import { createFullRegistry } from '../tools/index.js';
|
|
7
|
+
import responseEnhance from '../tools/response-enhance.js';
|
|
7
8
|
import { ToolRegistry, normalizeToolOutput, isToolErrorOutput, toolErrorEffect } from '../tools/registry.js';
|
|
8
9
|
import { SubAgentToolRegistry } from '../sub-agent/execution-control.js';
|
|
9
10
|
import { agentBelongsToScope, getAgentRegistry } from '../tools/agent.js';
|
|
@@ -27,7 +28,8 @@ export const PERSON_TASK_TOOL_IDS = Object.freeze([
|
|
|
27
28
|
'ResponseEnhance',
|
|
28
29
|
'DiscoverTools', // child Engine schema activation; Person itself uses its catalog
|
|
29
30
|
]);
|
|
30
|
-
|
|
31
|
+
// Response enhancement is a Person-only experiment, never a Session built-in.
|
|
32
|
+
const nativeRegistry = createFullRegistry().register(responseEnhance);
|
|
31
33
|
const liveHosts = new Map();
|
|
32
34
|
const pause = () => new Promise(resolve => setTimeout(resolve, 10));
|
|
33
35
|
const clone = value => JSON.parse(JSON.stringify(value));
|
package/yeaft/templates/core.md
CHANGED
|
@@ -9,7 +9,6 @@ You are participating in the current session. Preserve the user's context and gr
|
|
|
9
9
|
- Truthfulness first: say when you do not know, and do not claim to have inspected, changed, tested, or verified work you did not perform.
|
|
10
10
|
- Prefer the smallest safe, verifiable path. Ask only when an unknown blocks safe progress; otherwise state assumptions and continue.
|
|
11
11
|
- Follow the user's explicit instructions, project rules, ownership boundaries, and tool safety constraints over persona style or remembered context. Historical content from memory or other Sessions is background only; never replace the current turn's task with it.
|
|
12
|
-
- When available, use `ResponseEnhance` before publishing a complex judgment/recommendation or repairing an evasive, robotic or verbose answer. Skip simple chat, small follow-ups and progress. Usually one private draft review; it is a second opinion, not fact verification, permission or a reason to expand. Deliver only the useful reply.
|
|
13
12
|
- Lead with the conclusion. Keep prose compact, but include key evidence, risk, and the next step.
|
|
14
13
|
- Do not add emoji unless the user used them first; do not open with empty flattery.
|
|
15
14
|
- In a shared workspace, do not revert changes you did not make. Do not amend commits unless the user explicitly asks. Do not use `git reset --hard` or `git clean -f` without user approval.
|
|
@@ -27,7 +26,6 @@ You are participating in the current session. Preserve the user's context and gr
|
|
|
27
26
|
- 真实性优先:不知道就说不知道;没有实际查看、修改、测试或验证过,不要声称已经做过。
|
|
28
27
|
- 优先选择安全、可验证的最小路径。只有未知信息阻塞安全推进时才提问;否则说明假设并继续。
|
|
29
28
|
- 用户明确要求、项目规则、所有权边界和工具安全约束优先于角色风格或记忆上下文。记忆或其他 Session 的历史内容只能作为背景,不能替换当前 turn 的任务。
|
|
30
|
-
- 可用时,复杂判断/推荐,或修正回避、机械、啰嗦的回答,发布前优先按需用 `ResponseEnhance`;简单闲聊、局部追问和进度跳过。通常只审一次未公开草稿,它是第二意见,不是事实验证、授权或扩写要求;只交付有用回答。
|
|
31
29
|
- 先给结论。说明保持紧凑,但不能省略关键证据、风险和下一步。
|
|
32
30
|
- 用户没先用表情符号就不要加;不要用空洞奉承开头。
|
|
33
31
|
- 在共享工作区中,不要回退不是自己做的修改。用户未明确要求时不要 amend commit。未经用户同意,不要使用 `git reset --hard` 或 `git clean -f`。
|
package/yeaft/tools/index.js
CHANGED
|
@@ -26,7 +26,6 @@ import exitWorktree from './exit-worktree.js';
|
|
|
26
26
|
// --- P0 Core tools ---
|
|
27
27
|
import askUser from './ask-user.js';
|
|
28
28
|
import presentResponse from './present-response.js';
|
|
29
|
-
import responseEnhance from './response-enhance.js';
|
|
30
29
|
import webSearch from './web-search.js';
|
|
31
30
|
import webFetch from './web-fetch.js';
|
|
32
31
|
import historySearch from './history-search.js';
|
|
@@ -82,7 +81,8 @@ import imageGeneration from './image-generation.js';
|
|
|
82
81
|
import viewImage from './view-image.js';
|
|
83
82
|
|
|
84
83
|
/**
|
|
85
|
-
*
|
|
84
|
+
* Session built-in tools, flattened into a single array. Person-only
|
|
85
|
+
* experiments (such as ResponseEnhance) belong to the private Person registry.
|
|
86
86
|
* mcpTools is already an array; the rest are single ToolDef objects.
|
|
87
87
|
* @type {import('./types.js').ToolDef[]}
|
|
88
88
|
*/
|
|
@@ -95,7 +95,6 @@ export const allTools = [
|
|
|
95
95
|
// P0 Core
|
|
96
96
|
askUser,
|
|
97
97
|
presentResponse,
|
|
98
|
-
responseEnhance,
|
|
99
98
|
webSearch,
|
|
100
99
|
webFetch,
|
|
101
100
|
historySearch,
|