@yeaft/webchat-agent 1.0.504 → 1.0.505

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 (81) hide show
  1. package/local-runtime/version.json +1 -1
  2. package/local-runtime/web/app.bundle.js +98 -93
  3. package/local-runtime/web/app.bundle.js.gz +0 -0
  4. package/local-runtime/web/index.html +4 -3
  5. package/local-runtime/web/style.bundle.css +1 -1
  6. package/local-runtime/web/style.bundle.css.gz +0 -0
  7. package/local-runtime/web/vendor/katex/LICENSE +21 -0
  8. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
  9. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
  10. package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  11. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
  12. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
  13. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  14. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
  15. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
  16. package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  17. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
  18. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
  19. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  20. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
  21. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
  22. package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  23. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
  24. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff +0 -0
  25. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  26. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
  27. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
  28. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  29. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
  30. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff +0 -0
  31. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  32. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
  33. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff +0 -0
  34. package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  35. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
  36. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
  37. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  38. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
  39. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff +0 -0
  40. package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  41. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
  42. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
  43. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  44. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
  45. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
  46. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  47. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
  48. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
  49. package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  50. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
  51. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff +0 -0
  52. package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  53. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
  54. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
  55. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  56. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
  57. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
  58. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  59. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
  60. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
  61. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  62. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
  63. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
  64. package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  65. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
  66. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
  67. package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  68. package/local-runtime/web/vendor/katex/katex.min.css +1 -0
  69. package/local-runtime/web/vendor.bundle.js +1 -0
  70. package/local-runtime/web/vendor.bundle.js.gz +0 -0
  71. package/package.json +1 -1
  72. package/yeaft/config-api.js +16 -5
  73. package/yeaft/config.js +11 -2
  74. package/yeaft/conversation/history-index-worker.js +151 -0
  75. package/yeaft/conversation/history-index.js +96 -1
  76. package/yeaft/conversation/persist.js +6 -1
  77. package/yeaft/conversation/recall-relevance.js +103 -0
  78. package/yeaft/debug-trace.js +15 -4
  79. package/yeaft/engine.js +89 -16
  80. package/yeaft/history-window.js +370 -0
  81. package/yeaft/web-bridge.js +19 -12
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.505",
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",
@@ -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,
@@ -1532,13 +1532,17 @@ export class ConversationStore {
1532
1532
  *
1533
1533
  * @param {string} sessionId — required; null/empty returns []
1534
1534
  * @param {number} [turnsLimit=DEFAULT_RECENT_TURNS]
1535
+ * @param {{includeReflections?: boolean, beforeSeq?: number}} [options]
1536
+ * beforeSeq anchors the window before a durable user row (exclusive), so
1537
+ * later queued inputs cannot displace that query's history.
1535
1538
  * @returns {object[]}
1536
1539
  */
1537
- loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS, { includeReflections = false } = {}) {
1540
+ loadRecentBySession(sessionId, turnsLimit = DEFAULT_RECENT_TURNS, { includeReflections = false, beforeSeq = Infinity } = {}) {
1538
1541
  if (!sessionId) return [];
1539
1542
  if (turnsLimit === Infinity || turnsLimit < 0) {
1540
1543
  const all = this.#loadSessionMessages(sessionId);
1541
1544
  const filtered = all.filter(m => m && m.sessionId === sessionId
1545
+ && (!Number.isFinite(beforeSeq) || parseSeqFromId(m.id) < beforeSeq)
1542
1546
  && (!isHiddenConversationRow(m) || (includeReflections && m._reflection === true)));
1543
1547
  return pairSanitize(filtered);
1544
1548
  }
@@ -1547,6 +1551,7 @@ export class ConversationStore {
1547
1551
  const { messages, truncated } = this.#loadRecentSessionWindow(sessionId, turnsLimit, {
1548
1552
  roles: null,
1549
1553
  includeReflections,
1554
+ beforeSeq: Number.isFinite(beforeSeq) ? beforeSeq : Infinity,
1550
1555
  });
1551
1556
  if (truncated) {
1552
1557
  maybeWarnHistoryTruncated(sessionId, this.#dir, turnsLimit);
@@ -0,0 +1,103 @@
1
+ // Deterministic, deliberately conservative relevance shared by indexed and
2
+ // in-memory turn recall. No provider calls and no transcript mutation.
3
+ const MAX_PROMPT_CHARS = 4096;
4
+ const MAX_TERMS = 8;
5
+ const STOP_WORDS = new Set(`a an and are as at be been but by can could did do does for from had has have how i if in is it its me my of on or our please should so that the their them there these they this to was we were what when where which who why will with would you your about again before earlier previous remember recall history message messages conversation turn turns tell show find help need want use using make get know explain answer question code file project problem fix work task test tests implementation implement change thanks continue revisit discuss discussed discussion decide decided follow-up
6
+ 的 了 是 在 和 与 或 我 你 他 她 它 我们 你们 他们 这个 那个 什么 怎么 如何 为什么 请 请问 帮 帮我 帮忙 可以 能 不能 是否 需要 想 要 再 还 又 也 就 都 把 将 给 对 从 到 上 下 中 里 有 没有 一下 一些 一个 这些 那些 之前 以前 上次 刚才 历史 记得 回忆 召回 消息 对话 问题 回答 内容 事情 继续 现在 今天 昨天 后来 然后 相关 具体 代码 文件 项目 实现 修改 功能 测试 方案 方法 工作 任务 谢谢 好的 好 看看 查找 搜索 查询 处理 解决 进行 使用 讨论 提到 记忆 总结 回顾 提醒`.split(/\s+/u));
7
+ const segmenter = new Intl.Segmenter('zh', { granularity: 'word' });
8
+
9
+ function isIdentifier(term) {
10
+ return /[\p{L}\d][_.:/@-][\p{L}\d]/u.test(term)
11
+ || /\p{L}.*\d|\d.*\p{L}/u.test(term)
12
+ || /[a-z][A-Z]/u.test(term);
13
+ }
14
+
15
+ /** Extract at most eight meaningful lexical terms; generic prompts yield []. */
16
+ export function extractRecallTerms(prompt) {
17
+ // VP routing is not subject matter: "@vp-omni 继续" must not recall every
18
+ // earlier message addressed to that VP just because it looks like an ID.
19
+ const text = typeof prompt === 'string'
20
+ ? prompt.slice(0, MAX_PROMPT_CHARS).replace(/@vp-[A-Za-z0-9_-]+\b/gu, ' ') : '';
21
+ const tokens = [];
22
+ // Keep paths, issue IDs, snake_case and camelCase intact before segmentation.
23
+ const rest = text.replace(/[\p{L}\p{N}]+(?:[_.:/@-][\p{L}\p{N}]+)+|[A-Za-z][A-Za-z0-9]*/gu, token => {
24
+ tokens.push(token);
25
+ return ' ';
26
+ });
27
+ let singleHan = '';
28
+ const flushHan = () => {
29
+ if (singleHan.length >= 2) tokens.push(singleHan);
30
+ singleHan = '';
31
+ };
32
+ for (const part of segmenter.segment(rest)) {
33
+ // ICU dictionaries sometimes split technical words such as 缓存 into
34
+ // individual Han characters. Preserve short adjacent runs, not stop words.
35
+ if (part.isWordLike && /^\p{Script=Han}$/u.test(part.segment) && !STOP_WORDS.has(part.segment)) {
36
+ singleHan += part.segment;
37
+ if (singleHan.length === 4) flushHan();
38
+ } else {
39
+ flushHan();
40
+ if (part.isWordLike) tokens.push(part.segment);
41
+ }
42
+ }
43
+ flushHan();
44
+ const seen = new Set();
45
+ const terms = tokens.filter(term => {
46
+ const key = term.toLocaleLowerCase();
47
+ if (seen.has(key) || STOP_WORDS.has(key) || /^\d+$/u.test(key)
48
+ || Array.from(key).length < 2 || key.length > 96) return false;
49
+ seen.add(key);
50
+ return true;
51
+ });
52
+ // Identifiers are more useful than prose if a long prompt exhausts the cap.
53
+ return terms.sort((a, b) => Number(isIdentifier(b)) - Number(isIdentifier(a))).slice(0, MAX_TERMS);
54
+ }
55
+
56
+ /**
57
+ * Score full visible turn text. stats.termDocumentFrequency may be a bounded
58
+ * candidate sample, not corpus-wide IDF; its sample size must be explicit.
59
+ * Returns explainable rejection reasons rather than weak positive matches.
60
+ */
61
+ export function scoreRecallTurn(promptOrTerms, text, stats = {}) {
62
+ const terms = (Array.isArray(promptOrTerms) ? promptOrTerms : extractRecallTerms(promptOrTerms)).slice(0, MAX_TERMS);
63
+ const body = String(text || '').toLocaleLowerCase();
64
+ const matchedTerms = terms.filter(term => {
65
+ const key = term.toLocaleLowerCase();
66
+ if (/^[a-z0-9_]+$/u.test(key)) {
67
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
68
+ return new RegExp(`(?<![a-z0-9_])${escaped}(?![a-z0-9_])`, 'u').test(body);
69
+ }
70
+ return body.includes(key);
71
+ });
72
+ const identifierMatches = matchedTerms.filter(isIdentifier);
73
+ const independentTerms = matchedTerms.filter(term => !matchedTerms.some(other => (
74
+ other !== term && other.toLocaleLowerCase().includes(term.toLocaleLowerCase())
75
+ )));
76
+ const coverage = terms.length ? matchedTerms.length / terms.length : 0;
77
+ const sampleSize = Math.max(0, Number(stats.sampleSize) || 0);
78
+ const distinctiveness = matchedTerms.length ? Math.max(...matchedTerms.map(term => {
79
+ const frequency = stats.termDocumentFrequency?.[term.toLocaleLowerCase()];
80
+ return sampleSize >= 8 && Number.isFinite(frequency) ? 1 - frequency / sampleSize : 1;
81
+ })) : 0;
82
+ let reason = 'relevant';
83
+ if (!terms.length) reason = 'generic_prompt';
84
+ else if (!matchedTerms.length) reason = 'no_match';
85
+ else if (!identifierMatches.length && independentTerms.length < 2) reason = 'insufficient_keywords';
86
+ else if (!identifierMatches.length && coverage < 0.5) reason = 'low_coverage';
87
+ else if (!identifierMatches.length && distinctiveness < 0.15) reason = 'low_distinctiveness';
88
+ const score = reason === 'relevant'
89
+ ? Math.round((independentTerms.length * 2 + identifierMatches.length * 4 + coverage * 2 + distinctiveness) * 100) / 100
90
+ : 0;
91
+ return { score, matchedTerms, reason, coverage, distinctiveness, identifierMatches, sampleSize };
92
+ }
93
+
94
+ export const RECALL_LIMITS = Object.freeze({
95
+ maxTerms: MAX_TERMS,
96
+ maxCandidates: 128,
97
+ candidatesPerTerm: 32,
98
+ shortTermRows: 512,
99
+ maxBoundaryRows: 4096,
100
+ maxTurnRows: 64,
101
+ maxTurnBytes: 64 * 1024,
102
+ maxReadBytes: 256 * 1024,
103
+ });
@@ -621,6 +621,8 @@ function expandTrace(trace) {
621
621
  const turnsById = new Map([[trace.requestId || trace.traceId, summarizeTrace(trace, true)]]);
622
622
  let snapshot = null;
623
623
  let rawRequest = trace?.baseRequest?.rawRequest ?? null;
624
+ let latestRawRequestLoop = null;
625
+ let latestSystemPromptLoop = null;
624
626
  const loops = [];
625
627
  for (const loop of Array.isArray(trace?.loops) ? trace.loops : []) {
626
628
  snapshot = applyRequestDelta(snapshot || trace.baseRequest || null, loop.requestDelta || {});
@@ -633,8 +635,8 @@ function expandTrace(trace) {
633
635
  loopInstanceId: loop.loopInstanceId || loop.turnRowId || null,
634
636
  loopNumber: loop.loopNumber || 0,
635
637
  model: loop.model || null,
636
- // Request snapshots are attached only to the latest loop below. Earlier
637
- // loops retain their responses, calls and timing, not repeated history.
638
+ // Keep only the latest available raw request and system prompt, each on
639
+ // its actual source loop. Messages remain limited to the final loop.
638
640
  systemPrompt: '',
639
641
  messages: [],
640
642
  response: loop.response || '',
@@ -650,12 +652,21 @@ function expandTrace(trace) {
650
652
  vpId: trace.vpId || null,
651
653
  threadId: trace.threadId || null,
652
654
  });
655
+ const current = loops.at(-1);
656
+ if (rawRequest != null) {
657
+ if (latestRawRequestLoop) latestRawRequestLoop.rawRequest = null;
658
+ current.rawRequest = rawRequest;
659
+ latestRawRequestLoop = current;
660
+ }
661
+ if (snapshot.systemPrompt) {
662
+ if (latestSystemPromptLoop) latestSystemPromptLoop.systemPrompt = '';
663
+ current.systemPrompt = snapshot.systemPrompt;
664
+ latestSystemPromptLoop = current;
665
+ }
653
666
  }
654
667
  const latest = loops.at(-1);
655
668
  if (latest && snapshot) {
656
- latest.systemPrompt = snapshot.systemPrompt || '';
657
669
  latest.messages = Array.isArray(snapshot.messages) ? snapshot.messages : [];
658
- latest.rawRequest = rawRequest;
659
670
  }
660
671
  return { loops, turns: Array.from(turnsById.values()) };
661
672
  }