@yeaft/webchat-agent 0.1.609 → 0.1.612

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": "0.1.609",
3
+ "version": "0.1.612",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -13,7 +13,7 @@
13
13
  "dev": "nodemon index.js"
14
14
  },
15
15
  "engines": {
16
- "node": ">=18.0.0"
16
+ "node": ">=22.5.0"
17
17
  },
18
18
  "keywords": [
19
19
  "claude",
@@ -54,7 +54,6 @@
54
54
  "ext": "js"
55
55
  },
56
56
  "dependencies": {
57
- "better-sqlite3": "^11.0.0",
58
57
  "dotenv": "^16.3.1",
59
58
  "tweetnacl": "^1.0.3",
60
59
  "tweetnacl-util": "^0.15.1",
@@ -7,7 +7,7 @@
7
7
  * Reference: server/db/connection.js — Database(path), pragma WAL
8
8
  */
9
9
 
10
- import Database from 'better-sqlite3';
10
+ import { DatabaseSync } from 'node:sqlite';
11
11
  import { randomUUID } from 'crypto';
12
12
  import { statSync } from 'fs';
13
13
 
@@ -80,7 +80,7 @@ function truncate(str, max) {
80
80
  * DebugTrace — SQLite-backed debug trace.
81
81
  */
82
82
  export class DebugTrace {
83
- /** @type {Database.Database} */
83
+ /** @type {import('node:sqlite').DatabaseSync} */
84
84
  #db;
85
85
 
86
86
  /** @type {string} */
@@ -94,9 +94,9 @@ export class DebugTrace {
94
94
  */
95
95
  constructor(dbPath) {
96
96
  this.#dbPath = dbPath;
97
- this.#db = new Database(dbPath);
98
- this.#db.pragma('journal_mode = WAL');
99
- this.#db.pragma('foreign_keys = ON');
97
+ this.#db = new DatabaseSync(dbPath);
98
+ this.#db.exec('PRAGMA journal_mode = WAL');
99
+ this.#db.exec('PRAGMA foreign_keys = ON');
100
100
  this.#db.exec(SCHEMA);
101
101
  }
102
102
 
@@ -282,9 +282,9 @@ export class DebugTrace {
282
282
  * @returns {{ turnCount: number, toolCount: number, eventCount: number, dbSizeBytes: number }}
283
283
  */
284
284
  stats() {
285
- const turnCount = this.#db.prepare('SELECT COUNT(*) as c FROM trace_turns').get().c;
286
- const toolCount = this.#db.prepare('SELECT COUNT(*) as c FROM trace_tools').get().c;
287
- const eventCount = this.#db.prepare('SELECT COUNT(*) as c FROM trace_events').get().c;
285
+ const turnCount = Number(this.#db.prepare('SELECT COUNT(*) as c FROM trace_turns').get().c);
286
+ const toolCount = Number(this.#db.prepare('SELECT COUNT(*) as c FROM trace_tools').get().c);
287
+ const eventCount = Number(this.#db.prepare('SELECT COUNT(*) as c FROM trace_events').get().c);
288
288
  let dbSizeBytes = 0;
289
289
  try {
290
290
  dbSizeBytes = statSync(this.#dbPath).size;
@@ -301,17 +301,17 @@ export class DebugTrace {
301
301
  */
302
302
  cleanup(retentionDays = 30) {
303
303
  const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
304
- const deletedTools = this.#db.prepare(`
304
+ const deletedTools = Number(this.#db.prepare(`
305
305
  DELETE FROM trace_tools WHERE turn_id IN (
306
306
  SELECT id FROM trace_turns WHERE started_at < ?
307
307
  )
308
- `).run(cutoff).changes;
309
- const deletedTurns = this.#db.prepare(`
308
+ `).run(cutoff).changes);
309
+ const deletedTurns = Number(this.#db.prepare(`
310
310
  DELETE FROM trace_turns WHERE started_at < ?
311
- `).run(cutoff).changes;
312
- const deletedEvents = this.#db.prepare(`
311
+ `).run(cutoff).changes);
312
+ const deletedEvents = Number(this.#db.prepare(`
313
313
  DELETE FROM trace_events WHERE created_at < ?
314
- `).run(cutoff).changes;
314
+ `).run(cutoff).changes);
315
315
  return { deletedTurns, deletedTools, deletedEvents };
316
316
  }
317
317
 
@@ -333,7 +333,7 @@ export class DebugTrace {
333
333
  * Get or create a prepared statement.
334
334
  * @param {string} key
335
335
  * @param {string} sql
336
- * @returns {Database.Statement}
336
+ * @returns {import('node:sqlite').StatementSync}
337
337
  */
338
338
  #prepare(key, sql) {
339
339
  if (!this.#stmts[key]) {
package/unify/engine.js CHANGED
@@ -36,6 +36,20 @@ import { pickEffort, parseEffortPrefix } from './effort.js';
36
36
  import { normalizeEffort } from './models.js';
37
37
  import { attachRouterPlan, extractPriorPlan, stripMetaForWire } from './router/continuity.js';
38
38
  import { resolveThinking } from './router/thinking.js';
39
+ import {
40
+ TOOL_BATCH_SIZE,
41
+ TURN_SUMMARY_THRESHOLD,
42
+ DUP_TOOL_THRESHOLD,
43
+ ExecLog,
44
+ buildEntry as buildExecLogEntry,
45
+ argsHashOf,
46
+ runT1Reflection,
47
+ runT2Reflection,
48
+ buildFallbackStub,
49
+ collapseRangeToReflection,
50
+ buildDuplicateReminder,
51
+ extractToolPairsFromRange,
52
+ } from './tool-folding/index.js';
39
53
 
40
54
  /**
41
55
  * task-324 — Turn cap removed.
@@ -172,6 +186,26 @@ export class Engine {
172
186
  */
173
187
  #currentAbortCtrl = null;
174
188
 
189
+ /**
190
+ * PR-L — V7 Tool History Reflection state. Owned per Engine instance.
191
+ *
192
+ * • `#execLog` — append-only log of every tool execution, used for
193
+ * fallback-stub generation and duplicate-call detection. Persists
194
+ * to <yeaftDir>/tool-log/<traceId>/<turnIdx>.jsonl when yeaftDir
195
+ * is set; in-memory only otherwise.
196
+ * • `#pendingT2` — Map<turnNumber, { promise, loopRange, count, ... }>
197
+ * keyed by the turn number that triggered T2. The next query() call
198
+ * non-blocking-checks this map; if the promise has resolved, the
199
+ * prior turn's history is rewritten with the reflection. If still
200
+ * pending, the engine falls back to the exec-log stub.
201
+ * • `#reflectedTurns` — Set<turnNumber>; ensures T1 fires at most
202
+ * once per turn (when toolCount crosses TOOL_BATCH_SIZE).
203
+ */
204
+ #execLog = null;
205
+ #pendingT2 = new Map();
206
+ #reflectedTurns = new Set();
207
+ #__queryCounter = 0;
208
+
175
209
  /** @type {string|null} */
176
210
  #abortReason = null;
177
211
 
@@ -202,6 +236,14 @@ export class Engine {
202
236
  this.#mcpManager = mcpManager || null;
203
237
  this.#yeaftDir = yeaftDir || null;
204
238
 
239
+ // PR-L: tool history reflection log. Keyed by traceId so distinct
240
+ // engine instances don't stomp on each other's jsonl files. When
241
+ // yeaftDir is null the ExecLog still works — purely in-memory.
242
+ this.#execLog = new ExecLog({
243
+ yeaftDir: this.#yeaftDir,
244
+ conversationId: this.#traceId,
245
+ });
246
+
205
247
  // Build fast config: uses fastModelId for internal tasks (recall, consolidation, dream)
206
248
  // Falls back to primary model if no fastModel configured
207
249
  const fastModelId = config.fastModelId || config.model;
@@ -846,6 +888,22 @@ export class Engine {
846
888
  { role: 'user', content: prompt },
847
889
  ];
848
890
 
891
+ // PR-L: T2 carry-forward. If a previous query()'s end-of-turn
892
+ // reflection has resolved, rewrite that turn's range in
893
+ // `conversationMessages` to a single assistant reflection message.
894
+ // If still pending, fall back to the exec-log stub — non-blocking,
895
+ // never wait. This runs BEFORE the first adapter.stream so the
896
+ // upcoming call sees the rewritten history.
897
+ yield* this.#applyPendingT2Reflections(conversationMessages, prompt);
898
+
899
+ // PR-L: track this query()'s tool-arc for reflection.
900
+ // `turnStartIdx` is where the current user message lives; the arc
901
+ // we may collapse spans (turnStartIdx + 1 .. last assistant/tool).
902
+ const turnStartIdx = conversationMessages.length - 1;
903
+ let queryToolCount = 0;
904
+ let t1Fired = false;
905
+ const queryNumber = (this.#__queryCounter = (this.#__queryCounter || 0) + 1);
906
+
849
907
  const toolDefs = this.#getToolDefs();
850
908
  let turnNumber = 0;
851
909
  let continueTurns = 0; // auto-continue counter
@@ -1196,6 +1254,46 @@ export class Engine {
1196
1254
  }
1197
1255
  }
1198
1256
 
1257
+ // PR-L: T2 end-of-turn (asynchronous) reflection. Fires when the
1258
+ // total tool count for this query() exceeds TURN_SUMMARY_THRESHOLD
1259
+ // (5) AND T1 didn't already collapse the arc. Kicks off the
1260
+ // primary-model call without await; the next query()'s
1261
+ // `#applyPendingT2Reflections` carries the result forward.
1262
+ if (queryToolCount > TURN_SUMMARY_THRESHOLD && !t1Fired) {
1263
+ const arcStart = turnStartIdx + 1;
1264
+ const arcEnd = conversationMessages.length - 1;
1265
+ if (arcEnd > arcStart) {
1266
+ const { pairs, assistantText } = extractToolPairsFromRange(
1267
+ conversationMessages, arcStart, arcEnd,
1268
+ );
1269
+ yield {
1270
+ type: 'reflection',
1271
+ trigger: 't2',
1272
+ status: 'pending',
1273
+ loopRange: [arcStart, arcEnd],
1274
+ toolCount: pairs.length,
1275
+ };
1276
+ const promise = runT2Reflection({
1277
+ adapter: this.#adapter,
1278
+ model: this.#config.model,
1279
+ originalUserMsg: prompt,
1280
+ toolPairs: pairs,
1281
+ assistantText,
1282
+ signal,
1283
+ });
1284
+ // Detach: never await. The promise outlives this query() and
1285
+ // the next call will pick it up (or use the fallback stub if
1286
+ // it hasn't resolved by then).
1287
+ promise.catch(() => { /* swallow until carry-forward */ });
1288
+ this.#pendingT2.set(queryNumber, {
1289
+ promise,
1290
+ loopRange: [arcStart, arcEnd],
1291
+ count: pairs.length,
1292
+ originalUserMsg: prompt,
1293
+ });
1294
+ }
1295
+ }
1296
+
1199
1297
  break;
1200
1298
  }
1201
1299
 
@@ -1206,6 +1304,8 @@ export class Engine {
1206
1304
  // break out of the outer while-loop cleanly once the current
1207
1305
  // tool batch finishes reporting.
1208
1306
  let abortedDuringTools = false;
1307
+ /** @type {string[]} */
1308
+ const pendingDupReminders = [];
1209
1309
 
1210
1310
  for (const tc of toolCalls) {
1211
1311
  // task-325a: honour abort between tools. We don't cancel a tool
@@ -1219,6 +1319,29 @@ export class Engine {
1219
1319
 
1220
1320
  const toolStartTime = Date.now();
1221
1321
 
1322
+ // PR-L: duplicate-call detection. If this exact (toolName,
1323
+ // argsHash) pair has already been executed DUP_TOOL_THRESHOLD
1324
+ // (3) times within the current turn + last 2 turns, queue a
1325
+ // system reminder. We push the reminder AFTER the tool batch
1326
+ // completes (not now) so the
1327
+ // assistant(tool_use) → user(tool_result, …) pairing demanded
1328
+ // by the Anthropic / OpenAI Responses APIs stays intact. We
1329
+ // don't block the call — the LLM still decides.
1330
+ const dupHash = argsHashOf(tc.input);
1331
+ const dupInfo = this.#execLog.dupInfo({
1332
+ toolName: tc.name,
1333
+ argsHash: dupHash,
1334
+ currentTurn: turnNumber,
1335
+ lookbackTurns: 2,
1336
+ });
1337
+ if (dupInfo.count + 1 >= DUP_TOOL_THRESHOLD) {
1338
+ pendingDupReminders.push(buildDuplicateReminder({
1339
+ toolName: tc.name,
1340
+ count: dupInfo.count + 1,
1341
+ lastResultBrief: dupInfo.lastResultBrief,
1342
+ }));
1343
+ }
1344
+
1222
1345
  let output;
1223
1346
  let isError = false;
1224
1347
 
@@ -1266,6 +1389,88 @@ export class Engine {
1266
1389
  content: output,
1267
1390
  isError,
1268
1391
  });
1392
+
1393
+ // PR-L: persist this execution to the exec-log for fallback-stub
1394
+ // and duplicate-call detection. Best-effort — disk failures are
1395
+ // swallowed inside ExecLog.append.
1396
+ this.#execLog.append(turnNumber, buildExecLogEntry({
1397
+ loopIdx: queryToolCount,
1398
+ toolName: tc.name,
1399
+ args: tc.input,
1400
+ output,
1401
+ isError,
1402
+ }));
1403
+ queryToolCount += 1;
1404
+ }
1405
+
1406
+ // PR-L: flush any duplicate-call reminders queued during the batch.
1407
+ // Pushed AFTER the for-loop so the tool_use → tool_result pairing
1408
+ // is intact; the next adapter.stream() will see the reminder as a
1409
+ // user message immediately after the last tool result.
1410
+ for (const reminder of pendingDupReminders) {
1411
+ conversationMessages.push({ role: 'user', content: reminder });
1412
+ }
1413
+
1414
+ // PR-L: T1 in-turn (synchronous) reflection. Fires exactly once per
1415
+ // query() lifetime, the moment queryToolCount crosses
1416
+ // TOOL_BATCH_SIZE (13). Generates a markdown reflection over the
1417
+ // assistant+tool arc since the user prompt and rewrites the history
1418
+ // in place — collapsing it to a SINGLE assistant message — before
1419
+ // the next adapter.stream() runs.
1420
+ if (!t1Fired
1421
+ && queryToolCount >= TOOL_BATCH_SIZE
1422
+ && !this.#reflectedTurns.has(`${queryNumber}:t1`)
1423
+ && !abortedDuringTools && !signal?.aborted) {
1424
+ t1Fired = true;
1425
+ this.#reflectedTurns.add(`${queryNumber}:t1`);
1426
+ try {
1427
+ const arcStart = turnStartIdx + 1;
1428
+ const arcEnd = conversationMessages.length - 1;
1429
+ const { pairs, assistantText } = extractToolPairsFromRange(
1430
+ conversationMessages, arcStart, arcEnd,
1431
+ );
1432
+ yield {
1433
+ type: 'reflection',
1434
+ trigger: 't1',
1435
+ status: 'pending',
1436
+ loopRange: [arcStart, arcEnd],
1437
+ toolCount: pairs.length,
1438
+ };
1439
+ const { content, durationMs } = await runT1Reflection({
1440
+ adapter: this.#adapter,
1441
+ model: this.#config.model,
1442
+ originalUserMsg: prompt,
1443
+ toolPairs: pairs,
1444
+ assistantText,
1445
+ signal,
1446
+ });
1447
+ const next = collapseRangeToReflection(
1448
+ conversationMessages, arcStart, arcEnd, content,
1449
+ );
1450
+ conversationMessages.length = 0;
1451
+ for (const m of next) conversationMessages.push(m);
1452
+ yield {
1453
+ type: 'reflection',
1454
+ trigger: 't1',
1455
+ // PR-L bug fix: keep the same loopRange as the `pending` event
1456
+ // so the frontend key stays stable across pending → ready and
1457
+ // the spinner card is replaced in place (no orphan).
1458
+ status: 'ready',
1459
+ loopRange: [arcStart, arcEnd],
1460
+ toolCount: pairs.length,
1461
+ content,
1462
+ durationMs,
1463
+ };
1464
+ } catch (err) {
1465
+ // Best-effort. On failure leave history unchanged so the loop
1466
+ // continues normally — never block the turn.
1467
+ yield {
1468
+ type: 'reflection',
1469
+ trigger: 't1',
1470
+ status: 'error',
1471
+ error: err && err.message || String(err),
1472
+ };
1473
+ }
1269
1474
  }
1270
1475
 
1271
1476
  // task-325a: if abort fired between tools, converge now — emit
@@ -1335,6 +1540,86 @@ export class Engine {
1335
1540
  /** @returns {import('./mcp.js').MCPManager|null} */
1336
1541
  get mcpManager() { return this.#mcpManager; }
1337
1542
 
1543
+ /**
1544
+ * PR-L — V7 Tool History Reflection helpers.
1545
+ *
1546
+ * `#applyPendingT2Reflections` is called at the start of every
1547
+ * `#runQuery` to carry forward any prior turn's async reflection. It is
1548
+ * a generator so it can yield reflection events to the engine consumer.
1549
+ * Non-blocking: never awaits a pending promise.
1550
+ *
1551
+ * @param {Array} conversationMessages
1552
+ * @param {string} originalUserMsg
1553
+ */
1554
+ async *#applyPendingT2Reflections(conversationMessages, originalUserMsg) {
1555
+ if (this.#pendingT2.size === 0) return;
1556
+ // Drain in insertion order (Map preserves it). We process all entries
1557
+ // because the user could send multiple prompts back-to-back before
1558
+ // the engine resumes — each historical turn gets its rewrite.
1559
+ const drained = [...this.#pendingT2.entries()];
1560
+ this.#pendingT2.clear();
1561
+
1562
+ for (const [turnNumber, info] of drained) {
1563
+ const range = info.loopRange;
1564
+ if (!Array.isArray(range) || range.length !== 2) continue;
1565
+ const [startIdx, endIdx] = range;
1566
+ if (startIdx < 0 || endIdx < startIdx || endIdx >= conversationMessages.length) {
1567
+ continue;
1568
+ }
1569
+
1570
+ // Non-blocking readiness check: race the promise against an already-
1571
+ // settled Promise.resolve(SENTINEL). If the reflection promise wins,
1572
+ // it has resolved synchronously (microtask order). Otherwise it's
1573
+ // still pending and we use the fallback stub.
1574
+ const PENDING_SENTINEL = {};
1575
+ const settled = await Promise.race([
1576
+ info.promise.then((v) => ({ ok: true, content: v.content, durationMs: v.durationMs }))
1577
+ .catch((err) => ({ ok: false, err })),
1578
+ Promise.resolve(PENDING_SENTINEL),
1579
+ ]);
1580
+
1581
+ let content;
1582
+ let trigger;
1583
+ let durationMs = 0;
1584
+ if (settled === PENDING_SENTINEL) {
1585
+ // Stub fallback. Detach the unresolved promise so we don't keep
1586
+ // holding it (caller may still observe it via .catch elsewhere).
1587
+ info.promise.catch(() => { /* swallow late rejection */ });
1588
+ const entries = this.#execLog ? this.#execLog.readTurn(turnNumber) : [];
1589
+ content = buildFallbackStub({ execLogEntries: entries, originalUserMsg: info.originalUserMsg || originalUserMsg });
1590
+ trigger = 't2-fallback';
1591
+ } else if (settled.ok) {
1592
+ content = settled.content;
1593
+ trigger = 't2';
1594
+ durationMs = settled.durationMs || 0;
1595
+ } else {
1596
+ // Promise rejected — leave history unchanged.
1597
+ continue;
1598
+ }
1599
+
1600
+ // Rewrite history.
1601
+ const next = collapseRangeToReflection(conversationMessages, startIdx, endIdx, content);
1602
+ // Mutate in place so caller's reference stays valid.
1603
+ conversationMessages.length = 0;
1604
+ for (const m of next) conversationMessages.push(m);
1605
+
1606
+ yield {
1607
+ type: 'reflection',
1608
+ trigger,
1609
+ status: 'ready',
1610
+ loopRange: [startIdx, endIdx],
1611
+ toolCount: info.count || 0,
1612
+ content,
1613
+ durationMs,
1614
+ };
1615
+ }
1616
+ }
1617
+
1618
+ /**
1619
+ * PR-L — read-only accessor for tests.
1620
+ */
1621
+ get _execLog() { return this.#execLog; }
1622
+
1338
1623
  /**
1339
1624
  * task-299 Phase 1: the engine's current thread marker.
1340
1625
  * Defaults to 'main' if the thread store is unreachable for any reason.
@@ -0,0 +1,188 @@
1
+ /**
2
+ * exec-log.js — Persistent tool execution log used by the reflection
3
+ * subsystem (PR-L).
4
+ *
5
+ * Two purposes:
6
+ * 1. Source of fallback-stub when T2 reflection isn't ready in time.
7
+ * 2. Duplicate-call detection — count how many times the SAME
8
+ * (toolName, argsHash) pair has been executed in the current turn
9
+ * plus the last two turns, so the engine can inject a reminder when
10
+ * the model is stuck in a loop.
11
+ *
12
+ * Storage layout (only when yeaftDir is configured):
13
+ * <yeaftDir>/tool-log/<conversationId>/<turnIdx>.jsonl
14
+ *
15
+ * One JSON object per line:
16
+ * { loopIdx, toolName, argsHash, argsBrief, resultBrief,
17
+ * resultBytes, resultStatus, timestamp }
18
+ *
19
+ * If yeaftDir is absent, the log is held in memory only.
20
+ */
21
+
22
+ import { createHash } from 'crypto';
23
+ import fs from 'fs';
24
+ import path from 'path';
25
+
26
+ const ARGS_BRIEF_CAP = 200;
27
+ const RESULT_BRIEF_CAP = 500;
28
+
29
+ /**
30
+ * Canonical-JSON-stringify and hash. Stable across key ordering so identical
31
+ * args produce identical hashes regardless of how the LLM serialised them.
32
+ *
33
+ * @param {any} args
34
+ * @returns {string} 16 hex chars
35
+ */
36
+ export function argsHashOf(args) {
37
+ let canon;
38
+ try {
39
+ canon = canonicalStringify(args);
40
+ } catch {
41
+ canon = String(args);
42
+ }
43
+ return createHash('sha256').update(canon).digest('hex').slice(0, 16);
44
+ }
45
+
46
+ function canonicalStringify(value) {
47
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
48
+ if (Array.isArray(value)) {
49
+ return '[' + value.map(canonicalStringify).join(',') + ']';
50
+ }
51
+ const keys = Object.keys(value).sort();
52
+ return '{' + keys.map(k => JSON.stringify(k) + ':' + canonicalStringify(value[k])).join(',') + '}';
53
+ }
54
+
55
+ function brief(value, cap) {
56
+ let s;
57
+ if (typeof value === 'string') s = value;
58
+ else {
59
+ try { s = JSON.stringify(value); } catch { s = String(value); }
60
+ }
61
+ if (s == null) s = '';
62
+ if (s.length <= cap) return s;
63
+ return s.slice(0, cap) + '…';
64
+ }
65
+
66
+ /**
67
+ * Build a single exec-log entry from a tool execution result.
68
+ *
69
+ * @param {{ loopIdx: number, toolName: string, args: any, output: any, isError: boolean }} p
70
+ * @returns {object}
71
+ */
72
+ export function buildEntry({ loopIdx, toolName, args, output, isError }) {
73
+ const argsBrief = brief(args, ARGS_BRIEF_CAP);
74
+ const resultBrief = brief(output, RESULT_BRIEF_CAP);
75
+ const resultBytes = typeof output === 'string'
76
+ ? Buffer.byteLength(output, 'utf8')
77
+ : Buffer.byteLength(brief(output, 1_000_000), 'utf8');
78
+ return {
79
+ loopIdx,
80
+ toolName,
81
+ argsHash: argsHashOf(args),
82
+ argsBrief,
83
+ resultBrief,
84
+ resultBytes,
85
+ resultStatus: isError ? 'error' : 'ok',
86
+ timestamp: Date.now(),
87
+ };
88
+ }
89
+
90
+ /**
91
+ * ExecLog — manages append + read for one Engine instance. Tracks turns in
92
+ * memory and (if yeaftDir is configured) mirrors them to disk as JSONL.
93
+ */
94
+ export class ExecLog {
95
+ /**
96
+ * @param {{ yeaftDir?: string|null, conversationId?: string|null }} opts
97
+ */
98
+ constructor({ yeaftDir = null, conversationId = null } = {}) {
99
+ this.yeaftDir = yeaftDir || null;
100
+ this.conversationId = conversationId || 'default';
101
+ /** @type {Map<number, object[]>} */
102
+ this.turns = new Map();
103
+ }
104
+
105
+ /** Path for a turn's jsonl file (or null if persistence is disabled). */
106
+ pathFor(turnIdx) {
107
+ if (!this.yeaftDir) return null;
108
+ return path.join(this.yeaftDir, 'tool-log', this.conversationId, `${turnIdx}.jsonl`);
109
+ }
110
+
111
+ /** Append an entry to a given turn's log. */
112
+ append(turnIdx, entry) {
113
+ let arr = this.turns.get(turnIdx);
114
+ if (!arr) { arr = []; this.turns.set(turnIdx, arr); }
115
+ arr.push(entry);
116
+ const p = this.pathFor(turnIdx);
117
+ if (p) {
118
+ try {
119
+ fs.mkdirSync(path.dirname(p), { recursive: true });
120
+ fs.appendFileSync(p, JSON.stringify(entry) + '\n', 'utf8');
121
+ } catch {
122
+ // Best-effort persistence — never break the engine on disk failure.
123
+ }
124
+ }
125
+ }
126
+
127
+ /** Read all entries for a turn (memory first; disk on cold-start). */
128
+ readTurn(turnIdx) {
129
+ const mem = this.turns.get(turnIdx);
130
+ if (mem) return mem.slice();
131
+ const p = this.pathFor(turnIdx);
132
+ if (!p) return [];
133
+ try {
134
+ const txt = fs.readFileSync(p, 'utf8');
135
+ const out = [];
136
+ for (const line of txt.split('\n')) {
137
+ const t = line.trim();
138
+ if (!t) continue;
139
+ try { out.push(JSON.parse(t)); } catch { /* skip bad line */ }
140
+ }
141
+ this.turns.set(turnIdx, out.slice());
142
+ return out;
143
+ } catch {
144
+ return [];
145
+ }
146
+ }
147
+
148
+ /**
149
+ * Count exact duplicates of (toolName, argsHash) across the current turn
150
+ * plus the previous N turns.
151
+ *
152
+ * @param {{ toolName: string, argsHash: string, currentTurn: number, lookbackTurns?: number }} q
153
+ * @returns {number}
154
+ */
155
+ dupCount({ toolName, argsHash, currentTurn, lookbackTurns = 2 }) {
156
+ let count = 0;
157
+ let lastBrief = '';
158
+ for (let t = Math.max(0, currentTurn - lookbackTurns); t <= currentTurn; t += 1) {
159
+ for (const e of this.readTurn(t)) {
160
+ if (e.toolName === toolName && e.argsHash === argsHash) {
161
+ count += 1;
162
+ lastBrief = e.resultBrief || lastBrief;
163
+ }
164
+ }
165
+ }
166
+ return count;
167
+ }
168
+
169
+ /**
170
+ * Like dupCount but also returns the most-recent matching resultBrief
171
+ * (used to compose the duplicate reminder).
172
+ *
173
+ * @returns {{ count: number, lastResultBrief: string }}
174
+ */
175
+ dupInfo({ toolName, argsHash, currentTurn, lookbackTurns = 2 }) {
176
+ let count = 0;
177
+ let lastBrief = '';
178
+ for (let t = Math.max(0, currentTurn - lookbackTurns); t <= currentTurn; t += 1) {
179
+ for (const e of this.readTurn(t)) {
180
+ if (e.toolName === toolName && e.argsHash === argsHash) {
181
+ count += 1;
182
+ lastBrief = e.resultBrief || lastBrief;
183
+ }
184
+ }
185
+ }
186
+ return { count, lastResultBrief: lastBrief };
187
+ }
188
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * fallback-stub.js — Generated when T2 reflection isn't ready in time
3
+ * (PR-L). Built synchronously from exec-log entries — no LLM call.
4
+ */
5
+
6
+ /**
7
+ * @param {{ execLogEntries: Array<object>, originalUserMsg?: string }} p
8
+ * @returns {string}
9
+ */
10
+ export function buildFallbackStub({ execLogEntries, originalUserMsg }) {
11
+ const entries = Array.isArray(execLogEntries) ? execLogEntries : [];
12
+ const N = entries.length;
13
+ const counts = new Map();
14
+ for (const e of entries) {
15
+ counts.set(e.toolName, (counts.get(e.toolName) || 0) + 1);
16
+ }
17
+ const tally = [...counts.entries()]
18
+ .sort((a, b) => b[1] - a[1])
19
+ .map(([name, n]) => `- ${name} × ${n}`)
20
+ .join('\n');
21
+
22
+ const errors = entries.filter(e => e.resultStatus === 'error');
23
+ const findings = entries
24
+ .slice(0, 10)
25
+ .map(e => `- \`${e.toolName}(${e.argsBrief})\` → ${e.resultStatus}: ${e.resultBrief}`)
26
+ .join('\n');
27
+
28
+ const head = originalUserMsg
29
+ ? `_(Original request: ${String(originalUserMsg).slice(0, 200)})_\n\n`
30
+ : '';
31
+
32
+ return `${head}## What was attempted
33
+ A previous turn executed ${N} tool call${N === 1 ? '' : 's'}; reflection wasn't generated in time, so this is a mechanical summary.
34
+
35
+ ## Key findings
36
+ ${findings || '_(no entries)_'}
37
+
38
+ ## Direction check
39
+ - ${errors.length} tool call${errors.length === 1 ? '' : 's'} failed.
40
+ - This is a fallback stub; no semantic analysis was performed.
41
+
42
+ ## Suggested next direction
43
+ Continue per the user's original request; do not assume the previous turn made progress beyond the raw findings above.
44
+
45
+ ## Tool execution log
46
+ ${tally || '_(empty)_'}`;
47
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * tool-folding/index.js — V7 reflection subsystem entry (PR-L).
3
+ *
4
+ * Exposes:
5
+ * - Constants TOOL_BATCH_SIZE, TURN_SUMMARY_THRESHOLD, DUP_TOOL_THRESHOLD
6
+ * - Reflector helpers (T1 sync, T2 async, fallback stub)
7
+ * - Helpers for collapsing message ranges into a single assistant
8
+ * reflection message
9
+ * - Duplicate-reminder text formatter
10
+ *
11
+ * The constants are NOT config-driven — V7 design freezes them in code.
12
+ */
13
+
14
+ export const TOOL_BATCH_SIZE = 13;
15
+ export const TURN_SUMMARY_THRESHOLD = 5;
16
+ export const DUP_TOOL_THRESHOLD = 3;
17
+
18
+ export { ExecLog, buildEntry, argsHashOf } from './exec-log.js';
19
+ export { buildReflectionPrompt, REFLECTION_TEMPLATE } from './reflection-prompt.js';
20
+ export { runT1Reflection } from './t1-reflector.js';
21
+ export { runT2Reflection } from './t2-reflector.js';
22
+ export { buildFallbackStub } from './fallback-stub.js';
23
+
24
+ /**
25
+ * Collapse messages[startIdx..endIdx] (inclusive) into a single
26
+ * `{ role: 'assistant', content }` message. Returns a NEW array; does not
27
+ * mutate the input.
28
+ *
29
+ * The original assistant+tool sequence (the action arc) is replaced by the
30
+ * reflection; user messages within that range stay put (defensive — the
31
+ * caller normally passes a range that contains only assistant+tool).
32
+ *
33
+ * @param {Array} messages
34
+ * @param {number} startIdx
35
+ * @param {number} endIdx
36
+ * @param {string} reflectionContent
37
+ * @returns {Array}
38
+ */
39
+ export function collapseRangeToReflection(messages, startIdx, endIdx, reflectionContent) {
40
+ if (!Array.isArray(messages)) return messages;
41
+ if (startIdx < 0 || endIdx < startIdx || endIdx >= messages.length) return messages;
42
+ const before = messages.slice(0, startIdx);
43
+ const collapsed = messages.slice(startIdx, endIdx + 1);
44
+ const after = messages.slice(endIdx + 1);
45
+
46
+ // Preserve any user messages that happened to appear inside the range
47
+ // (not expected per V7 spec, but defensive). Everything else (assistant +
48
+ // tool) is replaced by ONE assistant reflection message.
49
+ const preservedUsers = collapsed.filter(m => m && m.role === 'user');
50
+ const reflectionMsg = {
51
+ role: 'assistant',
52
+ content: reflectionContent,
53
+ _reflection: true,
54
+ };
55
+ return [...before, ...preservedUsers, reflectionMsg, ...after];
56
+ }
57
+
58
+ /**
59
+ * Build the (toolName, argsHash) → 3rd-time reminder text used by the
60
+ * duplicate-call detector.
61
+ *
62
+ * @param {{ toolName: string, count: number, lastResultBrief: string }} p
63
+ * @returns {string}
64
+ */
65
+ export function buildDuplicateReminder({ toolName, count, lastResultBrief }) {
66
+ return `[system note] You have called ${toolName} with the same arguments ${count} times. `
67
+ + `Previous result: ${(lastResultBrief || '').trim()}. `
68
+ + `Consider whether re-running this tool is necessary or if you should try a different approach.`;
69
+ }
70
+
71
+ /**
72
+ * Convert assistant.toolCalls + matching tool results into the
73
+ * { name, input, output, isError } pairs the reflector prompt expects.
74
+ *
75
+ * Walks `messages[startIdx..endIdx]` and pairs each tool_use with its
76
+ * tool_result by toolCallId. Unpaired tool_use entries are dropped.
77
+ *
78
+ * @param {Array} messages
79
+ * @param {number} startIdx
80
+ * @param {number} endIdx
81
+ * @returns {{ pairs: Array, assistantText: string }}
82
+ */
83
+ export function extractToolPairsFromRange(messages, startIdx, endIdx) {
84
+ const pairs = [];
85
+ const byId = new Map();
86
+ let assistantText = '';
87
+ const lo = Math.max(0, startIdx);
88
+ const hi = Math.min(messages.length - 1, endIdx);
89
+ for (let i = lo; i <= hi; i += 1) {
90
+ const m = messages[i];
91
+ if (!m) continue;
92
+ if (m.role === 'assistant') {
93
+ if (typeof m.content === 'string' && m.content) {
94
+ assistantText += (assistantText ? '\n' : '') + m.content;
95
+ }
96
+ const calls = Array.isArray(m.toolCalls) ? m.toolCalls : [];
97
+ for (const tc of calls) {
98
+ const ent = { name: tc.name, input: tc.input, output: '', isError: false, _id: tc.id };
99
+ pairs.push(ent);
100
+ if (tc.id) byId.set(tc.id, ent);
101
+ }
102
+ } else if (m.role === 'tool') {
103
+ const ent = m.toolCallId ? byId.get(m.toolCallId) : null;
104
+ if (ent) {
105
+ ent.output = typeof m.content === 'string' ? m.content : JSON.stringify(m.content);
106
+ ent.isError = !!m.isError;
107
+ } else {
108
+ // Orphan tool result — still include for completeness.
109
+ pairs.push({
110
+ name: '(orphan)',
111
+ input: {},
112
+ output: typeof m.content === 'string' ? m.content : JSON.stringify(m.content),
113
+ isError: !!m.isError,
114
+ });
115
+ }
116
+ }
117
+ }
118
+ // Strip internal _id field before returning.
119
+ return {
120
+ pairs: pairs.map(({ name, input, output, isError }) => ({ name, input, output, isError })),
121
+ assistantText,
122
+ };
123
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * reflection-prompt.js — V7 reflection prompt builder (PR-L).
3
+ *
4
+ * One template, used by both T1 (in-turn) and T2 (end-of-turn) reflectors.
5
+ * The primary model is asked to REFLECT on a sequence of tool calls — not
6
+ * just summarise. The output is markdown with five fixed sections so the
7
+ * frontend ReflectionCard can render each independently.
8
+ */
9
+
10
+ const TEMPLATE = `You are reviewing a sequence of {N} tool calls executed by an AI agent.
11
+ Your job is NOT just to summarize, but to REFLECT.
12
+
13
+ Output as markdown with these exact sections:
14
+
15
+ ## What was attempted
16
+ 2-3 sentences on the goal and action arc.
17
+
18
+ ## Key findings
19
+ Concrete facts: paths, line numbers, IDs, error codes (preserve verbatim).
20
+
21
+ ## Direction check
22
+ - Is the trajectory still aligned with the user's original request?
23
+ - Any drift / scope creep?
24
+ - Any tool calls that look redundant?
25
+ - Any signs of unproductive loops?
26
+
27
+ ## Suggested next direction
28
+ What should the next loop focus on? What should it AVOID?
29
+
30
+ ## Tool execution log
31
+ Compact list: <tool_name> × <count> (with notable args).
32
+
33
+ CRITICAL: Preserve all identifiers, paths, URLs, line numbers, and error
34
+ messages literally. Do NOT paraphrase data values.
35
+
36
+ User original request:
37
+ {originalUserMessage}
38
+
39
+ Tool execution sequence:
40
+ {toolCallsAndResults}`;
41
+
42
+ /**
43
+ * Render the prompt.
44
+ *
45
+ * @param {{ originalUserMsg: string, toolPairs: Array<{ name: string, input: any, output: string, isError: boolean }>, assistantText?: string }} p
46
+ * @returns {string}
47
+ */
48
+ export function buildReflectionPrompt({ originalUserMsg, toolPairs, assistantText }) {
49
+ const N = toolPairs.length;
50
+ const seq = toolPairs.map((p, i) => formatPair(i + 1, p)).join('\n\n');
51
+ const head = assistantText && assistantText.trim()
52
+ ? `Assistant text emitted during this batch:\n${assistantText.trim()}\n\n`
53
+ : '';
54
+ return TEMPLATE
55
+ .replace('{N}', String(N))
56
+ .replace('{originalUserMessage}', String(originalUserMsg || '').slice(0, 4000))
57
+ .replace('{toolCallsAndResults}', head + seq);
58
+ }
59
+
60
+ function formatPair(idx, p) {
61
+ let inputStr;
62
+ try { inputStr = JSON.stringify(p.input); } catch { inputStr = String(p.input); }
63
+ if (typeof inputStr === 'string' && inputStr.length > 1000) {
64
+ inputStr = inputStr.slice(0, 1000) + '…';
65
+ }
66
+ let outputStr = typeof p.output === 'string' ? p.output : (() => {
67
+ try { return JSON.stringify(p.output); } catch { return String(p.output); }
68
+ })();
69
+ if (outputStr.length > 2000) outputStr = outputStr.slice(0, 2000) + '…';
70
+ const status = p.isError ? ' [ERROR]' : '';
71
+ return `[${idx}] ${p.name}${status}\n args: ${inputStr}\n result: ${outputStr}`;
72
+ }
73
+
74
+ export const REFLECTION_TEMPLATE = TEMPLATE;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * t1-reflector.js — V7 in-turn (synchronous) reflection (PR-L).
3
+ *
4
+ * Triggered when the current turn has accumulated TOOL_BATCH_SIZE (13) tool
5
+ * results and the engine is about to loop back into adapter.stream(). Calls
6
+ * the PRIMARY model — never the fast model — to generate a markdown
7
+ * reflection over the batch.
8
+ *
9
+ * On success: returns { content, durationMs }.
10
+ * On failure: throws (engine catches and leaves history unchanged).
11
+ */
12
+
13
+ import { buildReflectionPrompt } from './reflection-prompt.js';
14
+
15
+ /**
16
+ * @param {{
17
+ * adapter: { call: Function },
18
+ * model: string,
19
+ * originalUserMsg: string,
20
+ * toolPairs: Array<{ name: string, input: any, output: string, isError: boolean }>,
21
+ * assistantText?: string,
22
+ * signal?: AbortSignal,
23
+ * }} p
24
+ * @returns {Promise<{ content: string, durationMs: number }>}
25
+ */
26
+ export async function runT1Reflection({ adapter, model, originalUserMsg, toolPairs, assistantText, signal }) {
27
+ const t0 = Date.now();
28
+ const prompt = buildReflectionPrompt({ originalUserMsg, toolPairs, assistantText });
29
+ const result = await adapter.call({
30
+ model,
31
+ system: prompt,
32
+ messages: [{ role: 'user', content: 'Produce the reflection now.' }],
33
+ maxTokens: 2048,
34
+ signal,
35
+ });
36
+ const content = (result && typeof result.text === 'string') ? result.text.trim() : '';
37
+ if (!content) {
38
+ throw new Error('T1 reflection returned empty content');
39
+ }
40
+ return { content, durationMs: Date.now() - t0 };
41
+ }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * t2-reflector.js — V7 end-of-turn (asynchronous) reflection (PR-L).
3
+ *
4
+ * Same prompt and primary-model call as T1, but kicked off without await.
5
+ * The engine stores the resulting promise on the instance; on the next
6
+ * query() it checks (non-blocking) whether the promise has resolved and
7
+ * either rewrites history with the reflection or falls back to the
8
+ * exec-log stub.
9
+ */
10
+
11
+ import { buildReflectionPrompt } from './reflection-prompt.js';
12
+
13
+ /**
14
+ * @param {{
15
+ * adapter: { call: Function },
16
+ * model: string,
17
+ * originalUserMsg: string,
18
+ * toolPairs: Array<{ name: string, input: any, output: string, isError: boolean }>,
19
+ * assistantText?: string,
20
+ * signal?: AbortSignal,
21
+ * }} p
22
+ * @returns {Promise<{ content: string, durationMs: number }>}
23
+ */
24
+ export async function runT2Reflection({ adapter, model, originalUserMsg, toolPairs, assistantText, signal }) {
25
+ const t0 = Date.now();
26
+ const prompt = buildReflectionPrompt({ originalUserMsg, toolPairs, assistantText });
27
+ const result = await adapter.call({
28
+ model,
29
+ system: prompt,
30
+ messages: [{ role: 'user', content: 'Produce the reflection now.' }],
31
+ maxTokens: 2048,
32
+ signal,
33
+ });
34
+ const content = (result && typeof result.text === 'string') ? result.text.trim() : '';
35
+ if (!content) {
36
+ throw new Error('T2 reflection returned empty content');
37
+ }
38
+ return { content, durationMs: Date.now() - t0 };
39
+ }
@@ -1006,6 +1006,24 @@ function handleEngineEvent(event, threadId, hctx) {
1006
1006
  }, gid);
1007
1007
  break;
1008
1008
 
1009
+ case 'reflection':
1010
+ // PR-L: V7 tool-history reflection event. Two phases per occurrence:
1011
+ // status: 'pending' — generation kicked off
1012
+ // status: 'ready' — markdown content + durationMs
1013
+ // status: 'error' — generation failed (history left unchanged)
1014
+ sendUnifyEvent({
1015
+ type: 'reflection',
1016
+ trigger: event.trigger,
1017
+ status: event.status,
1018
+ loopRange: event.loopRange,
1019
+ toolCount: event.toolCount,
1020
+ content: event.content,
1021
+ durationMs: event.durationMs,
1022
+ error: event.error,
1023
+ threadId,
1024
+ }, gid);
1025
+ break;
1026
+
1009
1027
  case 'debug_turn':
1010
1028
  sendUnifyEvent({
1011
1029
  type: 'debug_turn',