@yeaft/webchat-agent 1.0.503 → 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.
- package/local-runtime/server/db/connection.js +14 -2
- package/local-runtime/server/db/user-stats-db.js +7 -0
- package/local-runtime/server/handlers/agent-conversation.js +2 -1
- package/local-runtime/server/handlers/agent-output.js +4 -1
- package/local-runtime/server/routes/admin-routes.js +3 -3
- package/local-runtime/version.json +1 -1
- package/local-runtime/web/app.bundle.js +108 -120
- 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/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 +6 -1
- package/yeaft/conversation/recall-relevance.js +103 -0
- package/yeaft/debug-trace.js +15 -4
- package/yeaft/engine.js +89 -16
- package/yeaft/history-window.js +370 -0
- package/yeaft/vp/seed-defaults.js +169 -8
- package/yeaft/vp/stock-ids.js +2 -0
- package/yeaft/web-bridge.js +19 -12
|
Binary file
|
package/package.json
CHANGED
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,
|
|
@@ -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
|
+
});
|
package/yeaft/debug-trace.js
CHANGED
|
@@ -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
|
-
//
|
|
637
|
-
//
|
|
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
|
}
|