@yeaft/webchat-agent 1.0.504 → 1.0.506

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.
Files changed (100) hide show
  1. package/conversation.js +4 -2
  2. package/local-runtime/server/handlers/agent-conversation.js +11 -9
  3. package/local-runtime/server/handlers/client-conversation.js +28 -2
  4. package/local-runtime/version.json +1 -1
  5. package/local-runtime/web/app.bundle.js +99 -94
  6. package/local-runtime/web/app.bundle.js.gz +0 -0
  7. package/local-runtime/web/index.html +4 -3
  8. package/local-runtime/web/style.bundle.css +1 -1
  9. package/local-runtime/web/style.bundle.css.gz +0 -0
  10. package/local-runtime/web/vendor/katex/LICENSE +21 -0
  11. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
  12. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
  13. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  14. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  15. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  16. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  17. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  18. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  19. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  20. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  21. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  22. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  23. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  24. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  25. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  26. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
  27. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff +0 -0
  28. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  29. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  30. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  31. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  32. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
  33. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff +0 -0
  34. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  35. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
  36. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff +0 -0
  37. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  38. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  39. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  40. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  41. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
  42. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff +0 -0
  43. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  44. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  45. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  46. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  47. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  48. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  49. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  50. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  51. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  52. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  53. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
  54. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff +0 -0
  55. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
  57. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
  58. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  59. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
  60. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
  61. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  62. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
  63. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
  64. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  65. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
  66. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
  67. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  68. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  69. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  70. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  71. package/local-runtime/web/vendor/katex/katex.min.css +1 -0
  72. package/local-runtime/web/vendor.bundle.js +1 -0
  73. package/local-runtime/web/vendor.bundle.js.gz +0 -0
  74. package/package.json +1 -1
  75. package/yeaft/cli.js +3 -0
  76. package/yeaft/config-api.js +16 -5
  77. package/yeaft/config.js +11 -2
  78. package/yeaft/conversation/history-index-worker.js +151 -0
  79. package/yeaft/conversation/history-index.js +96 -1
  80. package/yeaft/conversation/persist.js +21 -10
  81. package/yeaft/conversation/recall-relevance.js +103 -0
  82. package/yeaft/conversation/search.js +2 -1
  83. package/yeaft/debug-trace.js +15 -4
  84. package/yeaft/effort.js +159 -6
  85. package/yeaft/engine.js +180 -41
  86. package/yeaft/history-window.js +430 -5
  87. package/yeaft/llm/adapter.js +3 -2
  88. package/yeaft/llm/anthropic.js +97 -25
  89. package/yeaft/llm/openai-responses.js +71 -7
  90. package/yeaft/llm/provider-state.js +242 -0
  91. package/yeaft/llm/router.js +14 -2
  92. package/yeaft/llm/usage-accounting.js +3 -0
  93. package/yeaft/models.js +4 -1
  94. package/yeaft/snapshot-filter.js +1 -0
  95. package/yeaft/sub-agent/output-log.js +4 -0
  96. package/yeaft/sub-agent/prompt-queue.js +3 -0
  97. package/yeaft/sub-agent/runner.js +13 -2
  98. package/yeaft/tools/agent.js +3 -0
  99. package/yeaft/tools/send-message.js +2 -0
  100. package/yeaft/web-bridge.js +24 -17
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "1.0.504",
3
+ "version": "1.0.506",
4
4
  "description": "Remote worker agent for Yeaft Web Code Agent — connects the native Yeaft engine, CLI providers, and workbench tools",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/yeaft/cli.js CHANGED
@@ -388,6 +388,7 @@ async function runREPL(config, args) {
388
388
  content: m.content,
389
389
  ...(m.toolCallId && { toolCallId: m.toolCallId }),
390
390
  ...(m.toolCalls && { toolCalls: m.toolCalls }),
391
+ ...(m.providerState && { providerState: m.providerState }),
391
392
  ...(Array.isArray(m.thinkingBlocks) && m.thinkingBlocks.length > 0
392
393
  ? { thinkingBlocks: m.thinkingBlocks.map(block => ({ ...block })) }
393
394
  : {}),
@@ -908,6 +909,7 @@ async function runStreamJson(config, args) {
908
909
  content: message.content,
909
910
  ...(message.toolCallId && { toolCallId: message.toolCallId }),
910
911
  ...(message.toolCalls && { toolCalls: message.toolCalls }),
912
+ ...(message.providerState && { providerState: message.providerState }),
911
913
  ...(Array.isArray(message.thinkingBlocks) && message.thinkingBlocks.length > 0
912
914
  ? { thinkingBlocks: message.thinkingBlocks.map(block => ({ ...block })) }
913
915
  : {}),
@@ -1263,6 +1265,7 @@ async function runOnce(config, args) {
1263
1265
  content: m.content,
1264
1266
  ...(m.toolCallId && { toolCallId: m.toolCallId }),
1265
1267
  ...(m.toolCalls && { toolCalls: m.toolCalls }),
1268
+ ...(m.providerState && { providerState: m.providerState }),
1266
1269
  ...(Array.isArray(m.thinkingBlocks) && m.thinkingBlocks.length > 0
1267
1270
  ? { thinkingBlocks: m.thinkingBlocks.map(block => ({ ...block })) }
1268
1271
  : {}),
@@ -12,7 +12,7 @@ import { existsSync, readFileSync } from 'fs';
12
12
  import { join } from 'path';
13
13
  import { DEFAULT_YEAFT_DIR } from './init.js';
14
14
  import { normalizeProviderModels, parseModelRef, serializeModelForPersistence } from './models.js';
15
- import { normaliseTelemetrySection, normaliseYeaftSection } from './config.js';
15
+ import { clampYeaftField, normaliseTelemetrySection, normaliseYeaftSection } from './config.js';
16
16
  import { normaliseBrowserRuntimeSection, validateBrowserRuntimeUpdate } from '../browser-runtime/config.js';
17
17
  import { normalizePluginConfig } from './plugins.js';
18
18
  import { mutateAgentConfig, readAgentConfigForWrite } from './config-store.js';
@@ -196,7 +196,7 @@ export function updateLlmConfig(update, dir) {
196
196
  * stable shape — `normaliseYeaftSection` guarantees that.
197
197
  *
198
198
  * @param {string} [dir] — Yeaft data directory
199
- * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number, recentTurnsLimit: number, dream: object } | { error: string }}
199
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number, recentTurnsLimit: number, relatedTurnsLimit: number, dream: object } | { error: string }}
200
200
  */
201
201
  export function getYeaftSettings(dir) {
202
202
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
@@ -215,15 +215,16 @@ export function getYeaftSettings(dir) {
215
215
  * Update the Yeaft-section of config.json. Merges into existing config
216
216
  * (LLM provider / model fields are untouched) and validates each field:
217
217
  * `maxConcurrentThreads` must be 1..50, `autoArchiveIdleDays` must be
218
- * 1..3650, `recentTurnsLimit` must be 1..500. Dream limits are read-only
218
+ * 1..3650, `recentTurnsLimit` must be 1..500, `relatedTurnsLimit` must be
219
+ * 0..10 (0 disables related recall). Dream limits are read-only
219
220
  * runtime defaults here; invalid values are rejected
220
221
  * outright so the UI sees an error rather than silently reverting — a
221
222
  * silent revert would make "I set it to 100 and nothing happened"
222
223
  * impossible to debug.
223
224
  *
224
- * @param {{ maxConcurrentThreads?: number, autoArchiveIdleDays?: number, recentTurnsLimit?: number }} update
225
+ * @param {{ maxConcurrentThreads?: number, autoArchiveIdleDays?: number, recentTurnsLimit?: number, relatedTurnsLimit?: number }} update
225
226
  * @param {string} [dir]
226
- * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number, recentTurnsLimit: number, dream: object } | { error: string }}
227
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number, recentTurnsLimit: number, relatedTurnsLimit: number, dream: object } | { error: string }}
227
228
  */
228
229
  export function updateYeaftSettings(update, dir) {
229
230
  const root = dir || process.env.YEAFT_DIR || DEFAULT_YEAFT_DIR;
@@ -252,6 +253,13 @@ export function updateYeaftSettings(update, dir) {
252
253
  return { error: 'recentTurnsLimit must be between 1 and 500' };
253
254
  }
254
255
  }
256
+ if (update.relatedTurnsLimit !== undefined) {
257
+ const value = clampYeaftField(update.relatedTurnsLimit, 'relatedTurnsLimit');
258
+ const n = Number(update.relatedTurnsLimit);
259
+ if (value === null || n < 0 || n > 10) {
260
+ return { error: 'relatedTurnsLimit must be between 0 and 10' };
261
+ }
262
+ }
255
263
 
256
264
  try {
257
265
  return mutateAgentConfig(root, existing => {
@@ -266,6 +274,9 @@ export function updateYeaftSettings(update, dir) {
266
274
  recentTurnsLimit: update.recentTurnsLimit !== undefined
267
275
  ? Math.floor(Number(update.recentTurnsLimit))
268
276
  : prev.recentTurnsLimit,
277
+ relatedTurnsLimit: update.relatedTurnsLimit !== undefined
278
+ ? clampYeaftField(update.relatedTurnsLimit, 'relatedTurnsLimit')
279
+ : prev.relatedTurnsLimit,
269
280
  };
270
281
  if (existing.yeaft?.dream && typeof existing.yeaft.dream === 'object') {
271
282
  merged.dream = existing.yeaft.dream;
package/yeaft/config.js CHANGED
@@ -54,6 +54,9 @@ const DEFAULTS = {
54
54
  // bring back after boot or reconnect. Older transcript remains available
55
55
  // through history pagination/search. Range: 1–500.
56
56
  yeaftRecentTurnsLimit: 20,
57
+ // Same-Session related Q&A turns, selected by deterministic full-text rules.
58
+ // Range: 0–10; 0 disables related recall without changing recent history.
59
+ yeaftRelatedTurnsLimit: 8,
57
60
  // CLAUDE.md / AGENTS.md project-doc cap, in bytes. Mirrors Codex's
58
61
  // `project_doc_max_bytes`. 0 disables the feature (no project-doc
59
62
  // block is injected). Hand-edited values are NOT clamped — we let
@@ -254,13 +257,14 @@ function isTruthy(val) {
254
257
  * bounds via `clampYeaftField`.
255
258
  *
256
259
  * @param {any} raw — jsonConfig.yeaft (may be undefined / malformed)
257
- * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number, recentTurnsLimit: number, dream: object }}
260
+ * @returns {{ maxConcurrentThreads: number, autoArchiveIdleDays: number, recentTurnsLimit: number, relatedTurnsLimit: number, dream: object }}
258
261
  */
259
262
  export function normaliseYeaftSection(raw) {
260
263
  const out = {
261
264
  maxConcurrentThreads: DEFAULTS.yeaftMaxConcurrentThreads,
262
265
  autoArchiveIdleDays: DEFAULTS.yeaftAutoArchiveIdleDays,
263
266
  recentTurnsLimit: DEFAULTS.yeaftRecentTurnsLimit,
267
+ relatedTurnsLimit: DEFAULTS.yeaftRelatedTurnsLimit,
264
268
  dream: { ...DEFAULT_LIMITS },
265
269
  };
266
270
  if (!raw || typeof raw !== 'object') return out;
@@ -270,6 +274,8 @@ export function normaliseYeaftSection(raw) {
270
274
  if (ad !== null) out.autoArchiveIdleDays = ad;
271
275
  const rt = clampYeaftField(raw.recentTurnsLimit, 'recentTurnsLimit');
272
276
  if (rt !== null) out.recentTurnsLimit = rt;
277
+ const related = clampYeaftField(raw.relatedTurnsLimit, 'relatedTurnsLimit');
278
+ if (related !== null) out.relatedTurnsLimit = related;
273
279
  const dream = raw.dream;
274
280
  if (dream && typeof dream === 'object' && !Array.isArray(dream)) {
275
281
  for (const key of Object.keys(DEFAULT_LIMITS)) {
@@ -287,17 +293,20 @@ export function normaliseYeaftSection(raw) {
287
293
  * (`normaliseYeaftSection`) and write (`updateYeaftSettings` validation).
288
294
  *
289
295
  * @param {unknown} v
290
- * @param {'maxConcurrentThreads'|'autoArchiveIdleDays'|'recentTurnsLimit'} field
296
+ * @param {'maxConcurrentThreads'|'autoArchiveIdleDays'|'recentTurnsLimit'|'relatedTurnsLimit'} field
291
297
  * @returns {number | null}
292
298
  */
293
299
  export function clampYeaftField(v, field) {
294
300
  if (v === null || v === undefined) return null;
301
+ if (field === 'relatedTurnsLimit' &&
302
+ (typeof v !== 'number' && typeof v !== 'string' || typeof v === 'string' && !v.trim())) return null;
295
303
  const n = Number(v);
296
304
  if (!Number.isFinite(n)) return null;
297
305
  let lo;
298
306
  let hi;
299
307
  if (field === 'maxConcurrentThreads') { lo = 1; hi = 50; }
300
308
  else if (field === 'recentTurnsLimit') { lo = 1; hi = 500; }
309
+ else if (field === 'relatedTurnsLimit') { lo = 0; hi = 10; }
301
310
  else { lo = 1; hi = 3650; } // autoArchiveIdleDays
302
311
  return Math.min(hi, Math.max(lo, Math.floor(n)));
303
312
  }
@@ -10,6 +10,7 @@ import {
10
10
  normalizeLiteralSearch,
11
11
  } from './visible-entry.js';
12
12
  import { fingerprintConversationSources } from './history-index-state.js';
13
+ import { extractRecallTerms, scoreRecallTurn, RECALL_LIMITS } from './recall-relevance.js';
13
14
 
14
15
  const INDEX_SCHEMA_VERSION = 2;
15
16
  const SHORT_BLOOM_BYTES = 256;
@@ -423,6 +424,155 @@ function queryIndex(request) {
423
424
  }
424
425
  }
425
426
 
427
+ // Candidate discovery and complete-turn reads share one worker operation and
428
+ // immutable index generation. Only bounded metadata is read before byte checks.
429
+ function recallTurns(request) {
430
+ const db = openDatabase(workerData.databasePath);
431
+ try {
432
+ const indexMeta = readMeta(db);
433
+ const generation = Number(indexMeta.generation) || 0;
434
+ const terms = extractRecallTerms(request.prompt);
435
+ const cap = (value, fallback) => Math.min(fallback, Math.max(1, Math.floor(Number(value) || fallback)));
436
+ const limit = cap(request.limit, 10);
437
+ const maxTurnRows = cap(request.maxTurnRows, RECALL_LIMITS.maxTurnRows);
438
+ const maxTurnBytes = cap(request.maxTurnBytes, RECALL_LIMITS.maxTurnBytes);
439
+ const maxReadBytes = cap(request.maxReadBytes, RECALL_LIMITS.maxReadBytes);
440
+ const beforeSeq = Number.isFinite(request.beforeSeq) ? request.beforeSeq : Number.MAX_SAFE_INTEGER;
441
+ const meta = {
442
+ status: 'ready', reason: 'no_match', terms, indexGeneration: generation,
443
+ candidateRowsRead: 0, boundaryRowsRead: 0, turnRowsRead: 0, readBytes: 0,
444
+ skippedOversizedTurns: 0, skippedFencedTurns: 0, candidateCapped: false,
445
+ limits: { ...RECALL_LIMITS, maxTurnRows, maxTurnBytes, maxReadBytes, limit },
446
+ ...indexStats(indexMeta),
447
+ };
448
+ const sourceBefore = currentSourceToken();
449
+ if (sourceBefore.fingerprint !== indexMeta.raw_source_fingerprint) {
450
+ return { turns: [], meta: { ...meta, status: 'not_ready', reason: 'stale_result' } };
451
+ }
452
+ const candidates = new Map();
453
+ const candidateById = db.prepare(`SELECT entry_id,entry_start_seq,entry_end_seq,role
454
+ FROM entries WHERE rowid=? AND session_id=? AND role IN ('user','assistant') AND entry_end_seq<?`);
455
+ const addCandidate = row => {
456
+ if (!row) return;
457
+ if (candidates.size < RECALL_LIMITS.maxCandidates) candidates.set(row.entry_id, row);
458
+ else meta.candidateCapped = true;
459
+ };
460
+ const shortTerms = terms.filter(term => codePoints(term).length < 3);
461
+ for (const term of terms.filter(term => codePoints(term).length >= 3)) {
462
+ const chars = codePoints(normalizeLiteralSearch(term));
463
+ const grams = [...new Set([chars.slice(0, 3).join(''), chars.slice(-3).join('')])];
464
+ const match = grams.map(gram => `"${gram.replaceAll('"', '""')}"`).join(' AND ');
465
+ // Fixed trigram prefix, not exhaustive term search: false positives or
466
+ // rows rejected by the seq fence can consume all 32 slots. Do not scan
467
+ // additional postings to fill the result; candidateCapped reports this.
468
+ const ids = db.prepare('SELECT rowid FROM entry_fts WHERE entry_fts MATCH ? ORDER BY rowid ASC LIMIT ?')
469
+ .all(match, RECALL_LIMITS.candidatesPerTerm + 1);
470
+ if (ids.length > RECALL_LIMITS.candidatesPerTerm) meta.candidateCapped = true;
471
+ for (const { rowid } of ids.slice(0, RECALL_LIMITS.candidatesPerTerm)) {
472
+ meta.candidateRowsRead += 1;
473
+ addCandidate(candidateById.get(rowid, workerData.sessionId, beforeSeq));
474
+ }
475
+ }
476
+ // Two-character Chinese words cannot use trigram FTS. Scan only a fixed
477
+ // newest metadata/bloom prefix, never unbounded text or the source JSONL.
478
+ if (shortTerms.length) {
479
+ const rows = db.prepare(`SELECT entry_id,entry_start_seq,entry_end_seq,role,short_bloom
480
+ FROM entries WHERE session_id=? AND entry_end_seq<?
481
+ ORDER BY entry_end_seq DESC,entry_id DESC LIMIT ?`)
482
+ .all(workerData.sessionId, beforeSeq, RECALL_LIMITS.shortTermRows);
483
+ meta.candidateRowsRead += rows.length;
484
+ if (rows.length === RECALL_LIMITS.shortTermRows) meta.candidateCapped = true;
485
+ for (const row of rows) {
486
+ if (shortTerms.some(term => bloomMayContain(row.short_bloom, normalizeLiteralSearch(term)))) addCandidate(row);
487
+ }
488
+ }
489
+ const previousRows = db.prepare(`SELECT entry_id,entry_start_seq,entry_end_seq,role
490
+ FROM entries WHERE session_id=? AND entry_end_seq<=?
491
+ ORDER BY entry_end_seq DESC,entry_id DESC LIMIT ?`);
492
+ const followingRows = db.prepare(`SELECT entry_id,entry_start_seq,entry_end_seq,role,
493
+ length(CAST(text_body AS BLOB))+length(CAST(source_message_ids AS BLOB)) AS bytes
494
+ FROM entries WHERE session_id=? AND entry_end_seq>=?
495
+ ORDER BY entry_end_seq ASC,entry_id ASC LIMIT ?`);
496
+ const readEntry = db.prepare('SELECT * FROM entries WHERE session_id=? AND entry_id=?');
497
+ const seenUsers = new Set();
498
+ const turns = [];
499
+ for (const candidate of [...candidates.values()].sort((a, b) => b.entry_end_seq - a.entry_end_seq)) {
500
+ let user = candidate.role === 'user' ? candidate : null;
501
+ if (!user) {
502
+ const remaining = RECALL_LIMITS.maxBoundaryRows - meta.boundaryRowsRead;
503
+ if (remaining <= 0) { meta.candidateCapped = true; break; }
504
+ for (const row of previousRows.iterate(workerData.sessionId, candidate.entry_start_seq, remaining)) {
505
+ meta.boundaryRowsRead += 1;
506
+ if (row.role === 'user') { user = row; break; }
507
+ }
508
+ }
509
+ if (!user || seenUsers.has(user.entry_id)) continue;
510
+ seenUsers.add(user.entry_id);
511
+ if (user.entry_start_seq >= beforeSeq) continue;
512
+ const rows = [];
513
+ let complete = true;
514
+ let bytes = 0;
515
+ for (const row of followingRows.iterate(workerData.sessionId, user.entry_start_seq, maxTurnRows + 1)) {
516
+ meta.turnRowsRead += 1;
517
+ if (row.role === 'user' && row.entry_id !== user.entry_id) break;
518
+ rows.push(row);
519
+ bytes += Number(row.bytes) || 0;
520
+ if (rows.length > maxTurnRows || bytes > maxTurnBytes) {
521
+ complete = false;
522
+ meta.skippedOversizedTurns += 1;
523
+ break;
524
+ }
525
+ }
526
+ if (!complete) continue;
527
+ if (rows.some(row => row.entry_end_seq >= beforeSeq)) {
528
+ meta.skippedFencedTurns += 1;
529
+ continue;
530
+ }
531
+ if (meta.readBytes + bytes > maxReadBytes) { meta.candidateCapped = true; continue; }
532
+ const messages = rows.map(row => {
533
+ const full = readEntry.get(workerData.sessionId, row.entry_id);
534
+ const entry = parseEntry(full, generation);
535
+ return { ...entry, id: entry.entryId, sessionId: workerData.sessionId, content: full.text_body };
536
+ });
537
+ meta.readBytes += bytes;
538
+ turns.push({ id: user.entry_id, userSeq: Number(user.entry_start_seq), messages });
539
+ }
540
+ const termDocumentFrequency = Object.create(null);
541
+ for (const term of terms) termDocumentFrequency[term.toLocaleLowerCase()] = 0;
542
+ for (const turn of turns) {
543
+ turn.relevance = scoreRecallTurn(terms, turn.messages.map(message => message.content).join('\n'));
544
+ for (const term of turn.relevance.matchedTerms) termDocumentFrequency[term.toLocaleLowerCase()] += 1;
545
+ }
546
+ const stats = { sampleSize: turns.length, termDocumentFrequency };
547
+ const rejections = {};
548
+ for (const turn of turns) {
549
+ turn.relevance = scoreRecallTurn(terms, turn.messages.map(message => message.content).join('\n'), stats);
550
+ turn.score = turn.relevance.score;
551
+ turn.matchedTerms = turn.relevance.matchedTerms;
552
+ if (!turn.score) rejections[turn.relevance.reason] = (rejections[turn.relevance.reason] || 0) + 1;
553
+ }
554
+ const selected = turns.filter(turn => turn.score > 0)
555
+ .sort((a, b) => b.score - a.score || b.userSeq - a.userSeq).slice(0, limit);
556
+ if (workerData.testHooksEnabled && request._testBarrier instanceof SharedArrayBuffer) {
557
+ const barrier = new Int32Array(request._testBarrier);
558
+ Atomics.store(barrier, 0, 1);
559
+ Atomics.notify(barrier, 0);
560
+ while (Atomics.load(barrier, 1) === 0) Atomics.wait(barrier, 1, 0, 100);
561
+ }
562
+ const sourceAfter = currentSourceToken();
563
+ if (sourceBefore.fingerprint !== sourceAfter.fingerprint) {
564
+ return { turns: [], meta: { ...meta, status: 'not_ready', reason: 'stale_result' } };
565
+ }
566
+ return {
567
+ turns: selected,
568
+ meta: { ...meta, reason: selected.length ? 'matched' : terms.length ? 'no_match' : 'generic_prompt',
569
+ ...stats, rejections, sourceFingerprint: sourceAfter.fingerprint },
570
+ };
571
+ } finally {
572
+ db.close();
573
+ }
574
+ }
575
+
426
576
  function outlineIndex(request) {
427
577
  const db = openDatabase(workerData.databasePath);
428
578
  try {
@@ -544,6 +694,7 @@ async function run() {
544
694
  try {
545
695
  let result;
546
696
  if (op === 'search') result = queryIndex(payload);
697
+ else if (op === 'recall-turns') result = recallTurns(payload);
547
698
  else if (op === 'outline') result = outlineIndex(payload);
548
699
  else if (op === 'validate-anchor') result = validateAnchor(payload);
549
700
  else if (op === 'validate-and-read-window') result = validateAndReadWindow(payload);
@@ -2,6 +2,7 @@ import { Worker } from 'node:worker_threads';
2
2
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { writeAtomic } from '../storage/atomic.js';
5
+ import { extractRecallTerms, scoreRecallTurn } from './recall-relevance.js';
5
6
  import {
6
7
  conversationIndexDatabasePath,
7
8
  conversationIndexManifestPath,
@@ -11,6 +12,7 @@ import {
11
12
  } from './history-index-state.js';
12
13
 
13
14
  const MAX_REBUILD_RETRIES = 3;
15
+ const RECALL_WAIT_MS = 1_500;
14
16
  const MAX_HISTORY_INDEX_MANAGERS = 8;
15
17
  const HISTORY_INDEX_IDLE_MS = 5 * 60_000;
16
18
  const DESTRUCTIVE_MUTATION_REASONS = new Set([
@@ -103,7 +105,6 @@ class HistoryIndexQueryWorker {
103
105
  testHooksEnabled: process.env.NODE_ENV === 'test',
104
106
  },
105
107
  });
106
- this.worker.unref();
107
108
  this.worker.on('message', message => {
108
109
  if (message?.type === 'fatal') {
109
110
  this.#rejectAll(new Error(message.error));
@@ -112,6 +113,7 @@ class HistoryIndexQueryWorker {
112
113
  const pending = this.pending.get(message?.requestId);
113
114
  if (!pending) return;
114
115
  this.pending.delete(message.requestId);
116
+ if (this.pending.size === 0) this.worker.unref();
115
117
  if (message.error) {
116
118
  const error = new Error(message.error);
117
119
  if (message.code) error.code = message.code;
@@ -122,17 +124,22 @@ class HistoryIndexQueryWorker {
122
124
  this.worker.on('exit', code => {
123
125
  if (code !== 0) this.#rejectAll(new Error(`history index query worker exited ${code}`));
124
126
  });
127
+ // Installing a message listener refs the MessagePort again. Idle indexes
128
+ // must not keep one-shot CLI processes alive; only in-flight reads do.
129
+ this.worker.unref();
125
130
  }
126
131
 
127
132
  #rejectAll(error) {
128
133
  for (const pending of this.pending.values()) pending.reject(error);
129
134
  this.pending.clear();
135
+ this.worker.unref();
130
136
  }
131
137
 
132
138
  request(op, payload) {
133
139
  const requestId = `history-index-${process.pid}-${++requestCounter}`;
134
140
  return new Promise((resolve, reject) => {
135
141
  this.pending.set(requestId, { resolve, reject });
142
+ this.worker.ref();
136
143
  this.worker.postMessage({ requestId, op, payload });
137
144
  });
138
145
  }
@@ -396,10 +403,62 @@ class SessionHistoryIndex {
396
403
  throw error;
397
404
  }
398
405
 
406
+ // A newly persisted user invalidates even a warm index on every query. Give
407
+ // rebuilds a bounded opportunity to catch up, without ever trusting an old
408
+ // prefix (a destructive mutation may have preceded the latest append).
409
+ // The first cold call starts a background build; subsequent calls join it.
410
+ async recall(payload) {
411
+ const deadline = Date.now() + RECALL_WAIT_MS;
412
+ const notReady = reason => ({ turns: [], meta: { status: 'not_ready', reason } });
413
+ const withinDeadline = async promise => {
414
+ let timer;
415
+ try {
416
+ return await Promise.race([
417
+ promise,
418
+ new Promise((_, reject) => {
419
+ timer = setTimeout(() => {
420
+ const error = new Error('history recall readiness deadline exceeded');
421
+ error.code = 'index_building';
422
+ reject(error);
423
+ }, Math.max(0, deadline - Date.now()));
424
+ }),
425
+ ]);
426
+ } finally {
427
+ clearTimeout(timer);
428
+ }
429
+ };
430
+ // Do not cancel a timed-out build: the next query must be able to make
431
+ // progress, rather than continually restarting a cold or stale rebuild.
432
+ if (this.rebuildPromise || this.rebuildTimer) await withinDeadline(this.rebuild());
433
+ await withinDeadline(this.ensureReady({ allowStale: true, waitForBuild: false }));
434
+ if (readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId)
435
+ !== Number(this.activeManifest?.sourceRevision)) {
436
+ await withinDeadline(this.rebuild());
437
+ }
438
+ const worker = this.active;
439
+ const manifest = this.activeManifest;
440
+ const revision = readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId);
441
+ if (revision !== Number(manifest?.sourceRevision)) {
442
+ this.scheduleRebuild();
443
+ return notReady('stale_result');
444
+ }
445
+ // Never retry a result invalidated during its read. Raw source fingerprints
446
+ // fence external edits too; revision and generation fence manager races.
447
+ const result = await withinDeadline(worker.request('recall-turns', payload));
448
+ if (worker !== this.active || manifest !== this.activeManifest
449
+ || revision !== readConversationMutationRevision(this.ownerRoot, 'session', this.sessionId)
450
+ || result.meta?.status === 'not_ready') {
451
+ this.scheduleRebuild();
452
+ return notReady('stale_result');
453
+ }
454
+ return result;
455
+ }
456
+
399
457
  async request(op, payload) {
400
458
  this.leases += 1;
401
459
  this.touch();
402
460
  try {
461
+ if (op === 'recall-turns') return await this.recall(payload);
403
462
  const mutation = readConversationMutationInfo(this.ownerRoot, 'session', this.sessionId);
404
463
  const allowStale = op === 'outline'
405
464
  && !payload?.cursor
@@ -494,6 +553,42 @@ export async function searchConversationIndex(ownerRoot, sessionId, query, opts
494
553
  });
495
554
  }
496
555
 
556
+ /**
557
+ * Recall complete visible user/assistant turns from this owner-root Session.
558
+ * beforeSeq excludes that user turn and all later rows (exclusive seq fence).
559
+ * limit defaults to 8, capped at 10. maxTurnRows/maxTurnBytes/maxReadBytes may
560
+ * only lower hard worker caps. Canonical message IDs identify entries; their
561
+ * sourceMessageIds preserve all persisted identities, including aggregated VP
562
+ * replies. The first cold call yields not_ready and starts a background build;
563
+ * later calls wait up to 1500ms for readiness and the worker read, without
564
+ * cancelling a timed-out build. Mutations during a read yield not_ready.
565
+ * Discovery is bounded, not exhaustive: each >=3-character term uses the first
566
+ * 32 trigram candidates (before seq filtering), and shorter terms only inspect
567
+ * the newest 512 eligible entry metadata rows. A ready/no_match is not proof
568
+ * that the entire Session lacks a match; meta.candidateCapped exposes caps.
569
+ */
570
+ export async function recallConversationTurns(ownerRoot, sessionId, prompt, opts = {}) {
571
+ const terms = extractRecallTerms(prompt);
572
+ const eligibility = scoreRecallTurn(terms, terms.join(' '));
573
+ if (!eligibility.score) {
574
+ return { turns: [], meta: { status: 'ready', reason: eligibility.reason, terms } };
575
+ }
576
+ try {
577
+ return await requestConversationHistoryIndex(ownerRoot, sessionId, 'recall-turns', {
578
+ prompt: typeof prompt === 'string' ? prompt.slice(0, 4096) : '',
579
+ beforeSeq: opts.beforeSeq,
580
+ limit: Math.min(10, Math.max(1, Math.floor(Number(opts.limit) || 8))),
581
+ maxTurnRows: opts.maxTurnRows,
582
+ maxTurnBytes: opts.maxTurnBytes,
583
+ maxReadBytes: opts.maxReadBytes,
584
+ ...(process.env.NODE_ENV === 'test' ? { _testBarrier: opts._testBarrier } : {}),
585
+ });
586
+ } catch (error) {
587
+ if (!['index_building', 'stale_result', 'source_changed'].includes(error?.code)) throw error;
588
+ return { turns: [], meta: { status: 'not_ready', reason: error.code, terms } };
589
+ }
590
+ }
591
+
497
592
  export async function loadConversationOutlineFromIndex(ownerRoot, sessionId, opts = {}) {
498
593
  return requestConversationHistoryIndex(ownerRoot, sessionId, 'outline', {
499
594
  ...opts,
@@ -351,8 +351,10 @@ export function projectVisibleSessionMessages(messages) {
351
351
  }
352
352
 
353
353
  const visible = [];
354
- for (const row of rows) {
355
- if (!row || (row.role !== 'user' && row.role !== 'assistant')) continue;
354
+ for (const sourceRow of rows) {
355
+ if (!sourceRow || (sourceRow.role !== 'user' && sourceRow.role !== 'assistant')) continue;
356
+ // Public history never owns provider-private continuation payloads.
357
+ const { providerState, thinkingBlocks, ...row } = sourceRow;
356
358
  if (!isVisibleConversationRow(row)) continue;
357
359
  if (row.role !== 'assistant' || !Array.isArray(row.toolCalls) || row.toolCalls.length === 0) {
358
360
  if (row.role === 'assistant' && !row.content && !row.attachments && !row.images
@@ -502,19 +504,18 @@ function serializeMessage(msg) {
502
504
  // bytes that don't need to be human-readable. Without this round-trip
503
505
  // the next Anthropic request 400s with "content[].thinking in the
504
506
  // thinking mode must be passed back to the API".
505
- if (msg.thinkingBlocks && msg.thinkingBlocks.length > 0) {
507
+ if (msg.providerState) fm.push(`providerStateB64: ${Buffer.from(JSON.stringify(msg.providerState)).toString('base64')}`);
508
+ if (!msg.providerState && msg.thinkingBlocks && msg.thinkingBlocks.length > 0) {
506
509
  fm.push(`thinkingBlocks:`);
507
510
  for (const tb of msg.thinkingBlocks) {
508
- if (!tb || typeof tb.signature !== 'string' || !tb.signature) continue;
511
+ if (!tb) continue;
509
512
  if (tb.redacted) {
510
513
  if (typeof tb.data !== 'string') continue;
511
514
  const dataB64 = Buffer.from(tb.data, 'utf8').toString('base64');
512
- const signatureB64 = Buffer.from(tb.signature, 'utf8').toString('base64');
513
515
  fm.push(` - redacted: true`);
514
516
  fm.push(` dataB64: ${dataB64}`);
515
- fm.push(` signatureB64: ${signatureB64}`);
516
517
  } else {
517
- if (typeof tb.thinking !== 'string') continue;
518
+ if (typeof tb.thinking !== 'string' || typeof tb.signature !== 'string' || !tb.signature) continue;
518
519
  const thinkingB64 = Buffer.from(tb.thinking, 'utf8').toString('base64');
519
520
  const signatureB64 = Buffer.from(tb.signature, 'utf8').toString('base64');
520
521
  fm.push(` - thinkingB64: ${thinkingB64}`);
@@ -646,7 +647,11 @@ export function parseMessage(raw) {
646
647
  }
647
648
 
648
649
  // task-327d: parse thinkingBlocks (mirror of toolCalls parser above)
649
- if (frontmatter.includes('thinkingBlocks:')) {
650
+ const stateMatch = frontmatter.match(/^providerStateB64: (.+)$/m);
651
+ if (stateMatch) {
652
+ try { msg.providerState = JSON.parse(Buffer.from(stateMatch[1], 'base64').toString('utf8')); } catch { /* legacy invalid row */ }
653
+ }
654
+ if (!msg.providerState && frontmatter.includes('thinkingBlocks:')) {
650
655
  const thinkingBlocks = [];
651
656
  const tbMatch = frontmatter.match(/thinkingBlocks:\n((?:\s+-\s+[\s\S]*?)(?=\n\w|$))/);
652
657
  if (tbMatch) {
@@ -672,7 +677,7 @@ export function parseMessage(raw) {
672
677
  }
673
678
  // Both fields required — an unsigned block would 400 on replay.
674
679
  if (tb.redacted) {
675
- if (typeof tb.data === 'string' && typeof tb.signature === 'string' && tb.signature) {
680
+ if (typeof tb.data === 'string') {
676
681
  thinkingBlocks.push(tb);
677
682
  }
678
683
  } else if (typeof tb.thinking === 'string' && typeof tb.signature === 'string' && tb.signature) {
@@ -1532,13 +1537,17 @@ export class ConversationStore {
1532
1537
  *
1533
1538
  * @param {string} sessionId — required; null/empty returns []
1534
1539
  * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
1540
+ * @param {{includeReflections?: boolean, beforeSeq?: number}} [options]
1541
+ * beforeSeq anchors the window before a durable user row (exclusive), so
1542
+ * later queued inputs cannot displace that query's history.
1535
1543
  * @returns {object[]}
1536
1544
  */
1537
- loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS, { includeReflections = false } = {}) {
1545
+ loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS, { includeReflections = false, beforeSeq = Infinity } = {}) {
1538
1546
  if (!sessionId) return [];
1539
1547
  if (turnsLimit === Infinity || turnsLimit < 0) {
1540
1548
  const all = this.#loadSessionMessages(sessionId);
1541
1549
  const filtered = all.filter(m => m && m.sessionId === sessionId
1550
+ && (!Number.isFinite(beforeSeq) || parseSeqFromId(m.id) < beforeSeq)
1542
1551
  && (!isHiddenConversationRow(m) || (includeReflections && m._reflection === true)));
1543
1552
  return pairSanitize(filtered);
1544
1553
  }
@@ -1547,6 +1556,7 @@ export class ConversationStore {
1547
1556
  const { messages, truncated } = this.#loadRecentSessionWindow(sessionId, turnsLimit, {
1548
1557
  roles: null,
1549
1558
  includeReflections,
1559
+ beforeSeq: Number.isFinite(beforeSeq) ? beforeSeq : Infinity,
1550
1560
  });
1551
1561
  if (truncated) {
1552
1562
  maybeWarnHistoryTruncated(sessionId, this.#dir, turnsLimit);
@@ -1616,6 +1626,7 @@ export class ConversationStore {
1616
1626
  const copy = { ...m };
1617
1627
  delete copy.toolCalls;
1618
1628
  delete copy.thinkingBlocks;
1629
+ delete copy.providerState;
1619
1630
  out.push(copy);
1620
1631
  }
1621
1632
  continue;