@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.
- package/conversation.js +4 -2
- package/local-runtime/server/handlers/agent-conversation.js +11 -9
- package/local-runtime/server/handlers/client-conversation.js +28 -2
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +99 -94
- package/local-runtime/web/app.bundle.js.gz +0 -0
- package/local-runtime/web/index.html +4 -3
- package/local-runtime/web/style.bundle.css +1 -1
- package/local-runtime/web/style.bundle.css.gz +0 -0
- package/local-runtime/web/vendor/katex/LICENSE +21 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.ttf +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff +0 -0
- package/local-runtime/web/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/local-runtime/web/vendor/katex/katex.min.css +1 -0
- package/local-runtime/web/vendor.bundle.js +1 -0
- package/local-runtime/web/vendor.bundle.js.gz +0 -0
- package/package.json +1 -1
- package/yeaft/cli.js +3 -0
- package/yeaft/config-api.js +16 -5
- package/yeaft/config.js +11 -2
- package/yeaft/conversation/history-index-worker.js +151 -0
- package/yeaft/conversation/history-index.js +96 -1
- package/yeaft/conversation/persist.js +21 -10
- package/yeaft/conversation/recall-relevance.js +103 -0
- package/yeaft/conversation/search.js +2 -1
- package/yeaft/debug-trace.js +15 -4
- package/yeaft/effort.js +159 -6
- package/yeaft/engine.js +180 -41
- package/yeaft/history-window.js +430 -5
- package/yeaft/llm/adapter.js +3 -2
- package/yeaft/llm/anthropic.js +97 -25
- package/yeaft/llm/openai-responses.js +71 -7
- package/yeaft/llm/provider-state.js +242 -0
- package/yeaft/llm/router.js +14 -2
- package/yeaft/llm/usage-accounting.js +3 -0
- package/yeaft/models.js +4 -1
- package/yeaft/snapshot-filter.js +1 -0
- package/yeaft/sub-agent/output-log.js +4 -0
- package/yeaft/sub-agent/prompt-queue.js +3 -0
- package/yeaft/sub-agent/runner.js +13 -2
- package/yeaft/tools/agent.js +3 -0
- package/yeaft/tools/send-message.js +2 -0
- package/yeaft/web-bridge.js +24 -17
|
Binary file
|
package/package.json
CHANGED
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
|
: {}),
|
package/yeaft/config-api.js
CHANGED
|
@@ -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
|
|
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
|
|
355
|
-
if (!
|
|
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.
|
|
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
|
|
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
|
-
|
|
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'
|
|
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;
|