856 lines
26 KiB
JavaScript
856 lines
26 KiB
JavaScript
'use strict';
|
||
|
||
const fsp = require('fs/promises');
|
||
const path = require('path');
|
||
|
||
const CACHE_VERSION = 1;
|
||
const DEFAULT_MAX_FILE_BYTES = 32 * 1024 * 1024;
|
||
const DEFAULT_MAX_QUERY_CHARS = 200;
|
||
const DEFAULT_MAX_RESULTS = 50;
|
||
const DEFAULT_SNIPPET_CHARS = 220;
|
||
const MAX_QUERY_CHARS_HARD_LIMIT = 200;
|
||
const MAX_RESULTS_HARD_LIMIT = 50;
|
||
const UPSERT_DEBOUNCE_MS = 250;
|
||
const PERSIST_DEBOUNCE_MS = 500;
|
||
const BUILD_BATCH_SIZE = 25;
|
||
|
||
const SESSION_ID_RE = /^[a-zA-Z0-9-]+$/;
|
||
|
||
function isObject(value) {
|
||
return !!value && typeof value === 'object' && !Array.isArray(value);
|
||
}
|
||
|
||
function clampInteger(value, fallback, min, max) {
|
||
const number = Number(value);
|
||
if (!Number.isFinite(number)) return fallback;
|
||
const integer = Math.floor(number);
|
||
if (integer < min) return min;
|
||
if (integer > max) return max;
|
||
return integer;
|
||
}
|
||
|
||
function cleanDisplayText(value) {
|
||
if (typeof value !== 'string') return '';
|
||
return value.replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim();
|
||
}
|
||
|
||
function normalizeSearchText(value) {
|
||
if (typeof value !== 'string') return '';
|
||
return value
|
||
.normalize('NFKC')
|
||
.toLocaleLowerCase()
|
||
.replace(/[\u0000-\u001f\u007f]+/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim();
|
||
}
|
||
|
||
function isWordChar(char) {
|
||
return /[\p{L}\p{N}_]/u.test(char);
|
||
}
|
||
|
||
function tokenizeSearchText(value) {
|
||
const text = normalizeSearchText(value);
|
||
if (!text) return [];
|
||
const tokens = [];
|
||
let current = '';
|
||
for (const char of text) {
|
||
if (isWordChar(char)) {
|
||
current += char;
|
||
} else if (current) {
|
||
tokens.push(current);
|
||
current = '';
|
||
}
|
||
}
|
||
if (current) tokens.push(current);
|
||
return tokens;
|
||
}
|
||
|
||
function uniqueList(values) {
|
||
const seen = new Set();
|
||
const result = [];
|
||
for (const value of values) {
|
||
if (!value || seen.has(value)) continue;
|
||
seen.add(value);
|
||
result.push(value);
|
||
}
|
||
return result;
|
||
}
|
||
|
||
function containsQueryTerms(queryText) {
|
||
if (!queryText) return [];
|
||
const parts = queryText.split(/\s+/).filter(Boolean);
|
||
return uniqueList(parts.length > 1 ? parts : [queryText]);
|
||
}
|
||
|
||
function sanitizeSessionId(sessionId) {
|
||
return String(sessionId || '').replace(/[^a-zA-Z0-9-]/g, '');
|
||
}
|
||
|
||
function legalSessionId(sessionId) {
|
||
const id = String(sessionId || '');
|
||
return !!id && SESSION_ID_RE.test(id);
|
||
}
|
||
|
||
function sessionIdFromFileName(fileName) {
|
||
if (typeof fileName !== 'string' || !fileName.endsWith('.json')) return '';
|
||
const id = fileName.slice(0, -5);
|
||
return legalSessionId(id) ? id : '';
|
||
}
|
||
|
||
function filePathInsideRoot(rootDir, fileName) {
|
||
const root = path.resolve(rootDir);
|
||
const target = path.resolve(root, fileName);
|
||
const relative = path.relative(root, target);
|
||
if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
||
return target;
|
||
}
|
||
|
||
function sessionFilePath(rootDir, sessionId) {
|
||
const id = sanitizeSessionId(sessionId);
|
||
if (!legalSessionId(id)) return null;
|
||
return filePathInsideRoot(rootDir, `${id}.json`);
|
||
}
|
||
|
||
function sessionIdForDocument(sessionId, fallbackId) {
|
||
const fallback = String(fallbackId || '');
|
||
if (legalSessionId(fallback)) return fallback;
|
||
const id = String(sessionId || '');
|
||
return legalSessionId(id) ? id : '';
|
||
}
|
||
|
||
function fileFingerprint(stat) {
|
||
return {
|
||
dev: Number(stat.dev) || 0,
|
||
ino: Number(stat.ino) || 0,
|
||
size: Number(stat.size) || 0,
|
||
mtimeMs: Number(stat.mtimeMs) || 0,
|
||
};
|
||
}
|
||
|
||
function sameFingerprint(left, right) {
|
||
return !!left
|
||
&& !!right
|
||
&& left.dev === right.dev
|
||
&& left.ino === right.ino
|
||
&& left.size === right.size
|
||
&& left.mtimeMs === right.mtimeMs;
|
||
}
|
||
|
||
function safeIsoFromValue(value, fallbackMs) {
|
||
const date = value ? new Date(value) : new Date(fallbackMs || Date.now());
|
||
if (Number.isNaN(date.getTime())) return new Date(fallbackMs || Date.now()).toISOString();
|
||
return date.toISOString();
|
||
}
|
||
|
||
function basenameFromCwd(cwd) {
|
||
const text = cleanDisplayText(cwd);
|
||
if (!text) return '';
|
||
return path.basename(text.replace(/[\\/]+$/, ''));
|
||
}
|
||
|
||
function normalizeAgent(agent) {
|
||
const text = normalizeSearchText(String(agent || ''));
|
||
return text || 'codex';
|
||
}
|
||
|
||
// 只抽取 message.content 中的可见文本,显式排除工具结果和附件类内容。
|
||
function messageContentToText(content) {
|
||
if (typeof content === 'string') return content;
|
||
if (Array.isArray(content)) {
|
||
return content.map((part) => {
|
||
if (typeof part === 'string') return part;
|
||
if (!isObject(part)) return '';
|
||
const type = normalizeSearchText(String(part.type || ''));
|
||
if (type === 'tool_use' || type === 'tool_result' || type === 'image' || type === 'attachment') return '';
|
||
return typeof part.text === 'string' ? part.text : '';
|
||
}).filter(Boolean).join('\n');
|
||
}
|
||
if (isObject(content) && typeof content.text === 'string') return content.text;
|
||
return '';
|
||
}
|
||
|
||
function makeSearchField(value, weight) {
|
||
const text = cleanDisplayText(value);
|
||
const normalized = normalizeSearchText(text);
|
||
return {
|
||
text,
|
||
normalized,
|
||
words: tokenizeSearchText(normalized),
|
||
weight,
|
||
};
|
||
}
|
||
|
||
function createSessionDocument(session, stat, fallbackId) {
|
||
const safeSession = isObject(session) ? session : {};
|
||
const sessionId = sessionIdForDocument(safeSession.id, fallbackId);
|
||
if (!legalSessionId(sessionId)) return null;
|
||
|
||
const cwd = cleanDisplayText(safeSession.cwd || '');
|
||
const projectName = cleanDisplayText(safeSession.projectName || safeSession.project || basenameFromCwd(cwd));
|
||
const title = cleanDisplayText(safeSession.title || 'Untitled') || 'Untitled';
|
||
const updated = safeIsoFromValue(safeSession.updated || safeSession.updatedAt || safeSession.created, stat?.mtimeMs);
|
||
const created = safeSession.created ? safeIsoFromValue(safeSession.created, stat?.birthtimeMs || stat?.mtimeMs) : null;
|
||
const agent = normalizeAgent(safeSession.agent);
|
||
|
||
const metaFields = [
|
||
makeSearchField(title, 8),
|
||
makeSearchField(projectName, 5),
|
||
makeSearchField(cwd, 3),
|
||
makeSearchField(sessionId, 4),
|
||
].filter((field) => field.normalized);
|
||
|
||
const messages = [];
|
||
const sourceMessages = Array.isArray(safeSession.messages) ? safeSession.messages : [];
|
||
for (let index = 0; index < sourceMessages.length; index += 1) {
|
||
const message = sourceMessages[index];
|
||
if (!isObject(message)) continue;
|
||
const role = normalizeSearchText(String(message.role || ''));
|
||
if (role !== 'user' && role !== 'assistant') continue;
|
||
const text = cleanDisplayText(messageContentToText(message.content));
|
||
if (!text) continue;
|
||
const normalized = normalizeSearchText(text);
|
||
if (!normalized) continue;
|
||
messages.push({
|
||
messageIndex: index,
|
||
role,
|
||
timestamp: message.timestamp || message.created || message.createdAt || null,
|
||
text,
|
||
normalized,
|
||
words: tokenizeSearchText(normalized),
|
||
});
|
||
}
|
||
|
||
return {
|
||
sessionId,
|
||
agent,
|
||
title,
|
||
projectName,
|
||
cwd,
|
||
updated,
|
||
created,
|
||
metaFields,
|
||
messages,
|
||
};
|
||
}
|
||
|
||
function countContains(text, term) {
|
||
if (!text || !term) return 0;
|
||
let count = 0;
|
||
let offset = 0;
|
||
while (offset < text.length) {
|
||
const index = text.indexOf(term, offset);
|
||
if (index < 0) break;
|
||
count += 1;
|
||
offset = index + Math.max(term.length, 1);
|
||
}
|
||
return count;
|
||
}
|
||
|
||
function countWordHits(words, terms) {
|
||
if (!Array.isArray(words) || !words.length || !terms.length) return 0;
|
||
const counts = new Map();
|
||
for (const word of words) counts.set(word, (counts.get(word) || 0) + 1);
|
||
let hits = 0;
|
||
for (const term of terms) hits += counts.get(term) || 0;
|
||
return hits;
|
||
}
|
||
|
||
function fieldMatchScore(field, terms, matchMode) {
|
||
if (!field || !terms.length) return 0;
|
||
if (matchMode === 'word') {
|
||
const hits = countWordHits(field.words, terms);
|
||
return hits > 0 ? hits * field.weight : 0;
|
||
}
|
||
let hits = 0;
|
||
for (const term of terms) hits += countContains(field.normalized, term);
|
||
return hits > 0 ? hits * field.weight : 0;
|
||
}
|
||
|
||
function messageMatchScore(message, terms, matchMode) {
|
||
if (!message || !terms.length) return 0;
|
||
if (matchMode === 'word') return countWordHits(message.words, terms);
|
||
let hits = 0;
|
||
for (const term of terms) hits += countContains(message.normalized, term);
|
||
return hits;
|
||
}
|
||
|
||
function findSnippetIndex(normalizedText, terms, matchMode) {
|
||
if (!normalizedText || !terms.length) return 0;
|
||
if (matchMode === 'word') {
|
||
let best = -1;
|
||
for (const term of terms) {
|
||
const index = normalizedText.indexOf(term);
|
||
if (index >= 0 && (best < 0 || index < best)) best = index;
|
||
}
|
||
return best >= 0 ? best : 0;
|
||
}
|
||
let best = -1;
|
||
for (const term of terms) {
|
||
const index = normalizedText.indexOf(term);
|
||
if (index >= 0 && (best < 0 || index < best)) best = index;
|
||
}
|
||
return best >= 0 ? best : 0;
|
||
}
|
||
|
||
function makeSnippet(text, terms, matchMode, snippetChars) {
|
||
const display = cleanDisplayText(text);
|
||
if (!display) return '';
|
||
const limit = clampInteger(snippetChars, DEFAULT_SNIPPET_CHARS, 80, 1000);
|
||
if (display.length <= limit) return display;
|
||
const normalized = normalizeSearchText(display);
|
||
const matchIndex = findSnippetIndex(normalized, terms, matchMode);
|
||
const half = Math.floor(limit / 2);
|
||
let start = Math.max(0, matchIndex - half);
|
||
let end = Math.min(display.length, start + limit);
|
||
start = Math.max(0, end - limit);
|
||
const prefix = start > 0 ? '...' : '';
|
||
const suffix = end < display.length ? '...' : '';
|
||
return `${prefix}${display.slice(start, end)}${suffix}`;
|
||
}
|
||
|
||
function buildMetadataSnippet(doc) {
|
||
return [
|
||
doc.title,
|
||
doc.projectName,
|
||
doc.cwd,
|
||
doc.sessionId,
|
||
].filter(Boolean).join(' ');
|
||
}
|
||
|
||
function updatedTime(doc) {
|
||
const time = new Date(doc?.updated || 0).getTime();
|
||
return Number.isFinite(time) ? time : 0;
|
||
}
|
||
|
||
// “最新”按本次命中的消息时间排序;没有消息时间时才回退到会话更新时间。
|
||
function resultTime(result) {
|
||
const matchTimes = (result?.matches || [])
|
||
.map((match) => new Date(match?.timestamp || 0).getTime())
|
||
.filter((time) => Number.isFinite(time) && time > 0);
|
||
if (matchTimes.length > 0) return Math.max(...matchTimes);
|
||
const directTime = new Date(result?.timestamp || 0).getTime();
|
||
if (Number.isFinite(directTime) && directTime > 0) return directTime;
|
||
return updatedTime(result);
|
||
}
|
||
|
||
function buildQuery(rawQuery, options) {
|
||
const maxQueryChars = clampInteger(
|
||
options.maxQueryChars,
|
||
DEFAULT_MAX_QUERY_CHARS,
|
||
1,
|
||
MAX_QUERY_CHARS_HARD_LIMIT,
|
||
);
|
||
const queryText = normalizeSearchText(String(rawQuery || '').slice(0, maxQueryChars));
|
||
const matchMode = options.matchMode === 'word' ? 'word' : 'contains';
|
||
const terms = matchMode === 'word' ? uniqueList(tokenizeSearchText(queryText)) : containsQueryTerms(queryText);
|
||
return { queryText, terms, matchMode, maxQueryChars };
|
||
}
|
||
|
||
function sleepImmediate() {
|
||
return new Promise((resolve) => setImmediate(resolve));
|
||
}
|
||
|
||
function timerUnref(timer) {
|
||
if (timer && typeof timer.unref === 'function') timer.unref();
|
||
}
|
||
|
||
function safeError(err) {
|
||
return String(err?.message || err || '').slice(0, 240);
|
||
}
|
||
|
||
async function writeJsonAtomic(filePath, data) {
|
||
const dir = path.dirname(filePath);
|
||
await fsp.mkdir(dir, { recursive: true });
|
||
const tmpPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
|
||
const json = `${JSON.stringify(data)}\n`;
|
||
try {
|
||
// 缓存包含派生文本,按私有文件写入后再原子替换。
|
||
await fsp.writeFile(tmpPath, json, { encoding: 'utf8', mode: 0o600 });
|
||
await fsp.rename(tmpPath, filePath);
|
||
await fsp.chmod(filePath, 0o600).catch(() => {});
|
||
} catch (err) {
|
||
await fsp.unlink(tmpPath).catch(() => {});
|
||
throw err;
|
||
}
|
||
}
|
||
|
||
function createSessionSearchIndex(options = {}) {
|
||
const sessionsDir = path.resolve(options.sessionsDir || path.join(process.cwd(), 'sessions'));
|
||
const cacheFile = path.resolve(
|
||
options.cacheFile
|
||
? (path.isAbsolute(options.cacheFile) ? options.cacheFile : path.join(sessionsDir, options.cacheFile))
|
||
: path.join(sessionsDir, '_search', 'index-v1.json'),
|
||
);
|
||
const maxFileBytes = clampInteger(options.maxFileBytes, DEFAULT_MAX_FILE_BYTES, 1024, Number.MAX_SAFE_INTEGER);
|
||
const maxQueryChars = clampInteger(options.maxQueryChars, DEFAULT_MAX_QUERY_CHARS, 1, MAX_QUERY_CHARS_HARD_LIMIT);
|
||
const maxResults = clampInteger(options.maxResults, DEFAULT_MAX_RESULTS, 1, MAX_RESULTS_HARD_LIMIT);
|
||
const snippetChars = clampInteger(options.snippetChars, DEFAULT_SNIPPET_CHARS, 80, 1000);
|
||
const logger = options.logger || null;
|
||
|
||
const documents = new Map();
|
||
const fileMeta = new Map();
|
||
const upsertTimers = new Map();
|
||
let state = 'ready';
|
||
let lastError = null;
|
||
let initialized = false;
|
||
let buildPromise = null;
|
||
let dirty = false;
|
||
let persistTimer = null;
|
||
let persistPromise = null;
|
||
let lastPersistedAt = null;
|
||
let lastBuiltAt = null;
|
||
let skippedFiles = 0;
|
||
let failedFiles = 0;
|
||
|
||
function log(level, event, details = {}) {
|
||
if (!logger) return;
|
||
try {
|
||
const fn = logger[level] || logger.warn || logger.log;
|
||
if (typeof fn === 'function') fn.call(logger, event, details);
|
||
} catch {
|
||
// 日志不能影响索引主流程。
|
||
}
|
||
}
|
||
|
||
function serializeCache() {
|
||
return {
|
||
version: CACHE_VERSION,
|
||
generatedAt: new Date().toISOString(),
|
||
sessionsDir,
|
||
files: Object.fromEntries(fileMeta.entries()),
|
||
sessions: Object.fromEntries(documents.entries()),
|
||
};
|
||
}
|
||
|
||
function applyCache(cache) {
|
||
if (!isObject(cache) || cache.version !== CACHE_VERSION || !isObject(cache.files) || !isObject(cache.sessions)) {
|
||
return false;
|
||
}
|
||
documents.clear();
|
||
fileMeta.clear();
|
||
for (const [sessionId, meta] of Object.entries(cache.files)) {
|
||
if (!legalSessionId(sessionId) || !isObject(meta)) continue;
|
||
fileMeta.set(sessionId, {
|
||
dev: Number(meta.dev) || 0,
|
||
ino: Number(meta.ino) || 0,
|
||
size: Number(meta.size) || 0,
|
||
mtimeMs: Number(meta.mtimeMs) || 0,
|
||
});
|
||
}
|
||
for (const [sessionId, doc] of Object.entries(cache.sessions)) {
|
||
if (!legalSessionId(sessionId) || !isObject(doc) || doc.sessionId !== sessionId) continue;
|
||
documents.set(sessionId, doc);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
async function loadCache() {
|
||
try {
|
||
const raw = await fsp.readFile(cacheFile, 'utf8');
|
||
const parsed = JSON.parse(raw);
|
||
if (!applyCache(parsed)) {
|
||
log('warn', 'session_search_cache_ignored', { reason: 'version_or_shape' });
|
||
documents.clear();
|
||
fileMeta.clear();
|
||
dirty = true;
|
||
}
|
||
} catch (err) {
|
||
if (err && err.code !== 'ENOENT') {
|
||
log('warn', 'session_search_cache_load_failed', { error: safeError(err) });
|
||
}
|
||
documents.clear();
|
||
fileMeta.clear();
|
||
dirty = true;
|
||
}
|
||
}
|
||
|
||
async function scanSessionFiles() {
|
||
await fsp.mkdir(sessionsDir, { recursive: true });
|
||
const entries = await fsp.readdir(sessionsDir, { withFileTypes: true });
|
||
const files = new Map();
|
||
for (const entry of entries) {
|
||
if (!entry.isFile()) continue;
|
||
const sessionId = sessionIdFromFileName(entry.name);
|
||
if (!sessionId) continue;
|
||
const filePath = filePathInsideRoot(sessionsDir, entry.name);
|
||
if (!filePath) continue;
|
||
try {
|
||
const stat = await fsp.stat(filePath);
|
||
if (!stat.isFile()) continue;
|
||
files.set(sessionId, { filePath, stat, fingerprint: fileFingerprint(stat) });
|
||
} catch (err) {
|
||
failedFiles += 1;
|
||
log('warn', 'session_search_stat_failed', { sessionId: sessionId.slice(0, 8), error: safeError(err) });
|
||
}
|
||
}
|
||
return files;
|
||
}
|
||
|
||
async function readAndIndexFile(sessionId, filePath, stat, fingerprint) {
|
||
if (stat.size > maxFileBytes) {
|
||
skippedFiles += 1;
|
||
documents.delete(sessionId);
|
||
fileMeta.delete(sessionId);
|
||
dirty = true;
|
||
log('warn', 'session_search_file_skipped', { sessionId: sessionId.slice(0, 8), reason: 'max_file_bytes', size: stat.size });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const raw = await fsp.readFile(filePath, 'utf8');
|
||
const session = JSON.parse(raw);
|
||
const doc = createSessionDocument(session, stat, sessionId);
|
||
if (!doc) {
|
||
documents.delete(sessionId);
|
||
fileMeta.delete(sessionId);
|
||
dirty = true;
|
||
return;
|
||
}
|
||
documents.set(sessionId, doc);
|
||
fileMeta.set(sessionId, fingerprint);
|
||
dirty = true;
|
||
} catch (err) {
|
||
failedFiles += 1;
|
||
documents.delete(sessionId);
|
||
fileMeta.delete(sessionId);
|
||
dirty = true;
|
||
log('warn', 'session_search_file_index_failed', { sessionId: sessionId.slice(0, 8), error: safeError(err) });
|
||
}
|
||
}
|
||
|
||
function markDirtyAndSchedulePersist() {
|
||
dirty = true;
|
||
if (persistTimer) clearTimeout(persistTimer);
|
||
persistTimer = setTimeout(() => {
|
||
persistTimer = null;
|
||
persistNow().catch((err) => {
|
||
state = 'error';
|
||
lastError = safeError(err);
|
||
log('error', 'session_search_cache_persist_failed', { error: lastError });
|
||
});
|
||
}, PERSIST_DEBOUNCE_MS);
|
||
timerUnref(persistTimer);
|
||
}
|
||
|
||
async function persistNow() {
|
||
if (persistPromise) return persistPromise;
|
||
persistPromise = (async () => {
|
||
while (dirty) {
|
||
dirty = false;
|
||
await writeJsonAtomic(cacheFile, serializeCache());
|
||
lastPersistedAt = new Date().toISOString();
|
||
}
|
||
})().finally(() => {
|
||
persistPromise = null;
|
||
});
|
||
return persistPromise;
|
||
}
|
||
|
||
async function rebuildChangedFiles() {
|
||
state = 'building';
|
||
lastError = null;
|
||
skippedFiles = 0;
|
||
failedFiles = 0;
|
||
await loadCache();
|
||
const scanned = await scanSessionFiles();
|
||
|
||
// 以 sessions 根目录下当前合法 JSON 文件为准,删除派生缓存里的孤儿项。
|
||
for (const sessionId of Array.from(documents.keys())) {
|
||
if (!scanned.has(sessionId)) {
|
||
documents.delete(sessionId);
|
||
fileMeta.delete(sessionId);
|
||
dirty = true;
|
||
}
|
||
}
|
||
for (const sessionId of Array.from(fileMeta.keys())) {
|
||
if (!scanned.has(sessionId)) {
|
||
fileMeta.delete(sessionId);
|
||
dirty = true;
|
||
}
|
||
}
|
||
|
||
const changed = [];
|
||
for (const [sessionId, entry] of scanned.entries()) {
|
||
if (!sameFingerprint(fileMeta.get(sessionId), entry.fingerprint) || !documents.has(sessionId)) {
|
||
changed.push([sessionId, entry]);
|
||
}
|
||
}
|
||
|
||
for (let index = 0; index < changed.length; index += 1) {
|
||
const [sessionId, entry] = changed[index];
|
||
await readAndIndexFile(sessionId, entry.filePath, entry.stat, entry.fingerprint);
|
||
if ((index + 1) % BUILD_BATCH_SIZE === 0) await sleepImmediate();
|
||
}
|
||
|
||
if (dirty) await persistNow();
|
||
lastBuiltAt = new Date().toISOString();
|
||
state = 'ready';
|
||
initialized = true;
|
||
return status();
|
||
}
|
||
|
||
function initialize() {
|
||
if (buildPromise) return buildPromise;
|
||
buildPromise = rebuildChangedFiles()
|
||
.catch((err) => {
|
||
state = 'error';
|
||
lastError = safeError(err);
|
||
log('error', 'session_search_initialize_failed', { error: lastError });
|
||
return status();
|
||
})
|
||
.finally(() => {
|
||
buildPromise = null;
|
||
});
|
||
return buildPromise;
|
||
}
|
||
|
||
async function upsertNow(sessionId) {
|
||
const id = sanitizeSessionId(sessionId);
|
||
if (!legalSessionId(id)) return false;
|
||
const filePath = sessionFilePath(sessionsDir, id);
|
||
if (!filePath) return false;
|
||
try {
|
||
const stat = await fsp.stat(filePath);
|
||
if (!stat.isFile()) {
|
||
documents.delete(id);
|
||
fileMeta.delete(id);
|
||
markDirtyAndSchedulePersist();
|
||
return true;
|
||
}
|
||
await readAndIndexFile(id, filePath, stat, fileFingerprint(stat));
|
||
markDirtyAndSchedulePersist();
|
||
return true;
|
||
} catch (err) {
|
||
if (err && err.code === 'ENOENT') {
|
||
documents.delete(id);
|
||
fileMeta.delete(id);
|
||
markDirtyAndSchedulePersist();
|
||
return true;
|
||
}
|
||
failedFiles += 1;
|
||
log('warn', 'session_search_upsert_failed', { sessionId: id.slice(0, 8), error: safeError(err) });
|
||
return false;
|
||
}
|
||
}
|
||
|
||
function scheduleUpsert(sessionId) {
|
||
const id = sanitizeSessionId(sessionId);
|
||
if (!legalSessionId(id)) return false;
|
||
const existing = upsertTimers.get(id);
|
||
if (existing) clearTimeout(existing);
|
||
const timer = setTimeout(() => {
|
||
upsertTimers.delete(id);
|
||
upsertNow(id).catch((err) => {
|
||
state = 'error';
|
||
lastError = safeError(err);
|
||
log('error', 'session_search_schedule_upsert_failed', { sessionId: id.slice(0, 8), error: lastError });
|
||
});
|
||
}, UPSERT_DEBOUNCE_MS);
|
||
timerUnref(timer);
|
||
upsertTimers.set(id, timer);
|
||
return true;
|
||
}
|
||
|
||
function remove(sessionId) {
|
||
const id = sanitizeSessionId(sessionId);
|
||
if (!legalSessionId(id)) return false;
|
||
const timer = upsertTimers.get(id);
|
||
if (timer) clearTimeout(timer);
|
||
upsertTimers.delete(id);
|
||
const hadDocument = documents.delete(id);
|
||
const hadFileMeta = fileMeta.delete(id);
|
||
const existed = hadDocument || hadFileMeta;
|
||
if (existed) markDirtyAndSchedulePersist();
|
||
return existed;
|
||
}
|
||
|
||
function docContainsAllTerms(doc, terms, matchMode) {
|
||
if (!terms.length) return false;
|
||
if (matchMode === 'word') {
|
||
// word 模式用受控 tokenizer,不用用户输入拼接正则。
|
||
const seen = new Set();
|
||
for (const field of doc.metaFields || []) {
|
||
for (const word of field.words || []) seen.add(word);
|
||
}
|
||
for (const message of doc.messages || []) {
|
||
for (const word of message.words || []) seen.add(word);
|
||
}
|
||
return terms.every((term) => seen.has(term));
|
||
}
|
||
|
||
for (const term of terms) {
|
||
let found = false;
|
||
for (const field of doc.metaFields || []) {
|
||
if (field.normalized.includes(term)) {
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
if (!found) {
|
||
for (const message of doc.messages || []) {
|
||
if (message.normalized.includes(term)) {
|
||
found = true;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
if (!found) return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function buildSearchResult(doc, terms, matchMode) {
|
||
let score = 0;
|
||
let metaMatched = false;
|
||
for (const field of doc.metaFields || []) {
|
||
const fieldScore = fieldMatchScore(field, terms, matchMode);
|
||
if (fieldScore > 0) metaMatched = true;
|
||
score += fieldScore;
|
||
}
|
||
|
||
const matchedMessages = [];
|
||
for (const message of doc.messages || []) {
|
||
const messageScore = messageMatchScore(message, terms, matchMode);
|
||
if (messageScore <= 0) continue;
|
||
score += messageScore;
|
||
matchedMessages.push({ message, score: messageScore });
|
||
}
|
||
|
||
matchedMessages.sort((a, b) => b.score - a.score || a.message.messageIndex - b.message.messageIndex);
|
||
const matches = [];
|
||
for (const entry of matchedMessages.slice(0, 2)) {
|
||
matches.push({
|
||
sessionId: doc.sessionId,
|
||
messageIndex: entry.message.messageIndex,
|
||
role: entry.message.role,
|
||
timestamp: entry.message.timestamp || null,
|
||
snippet: makeSnippet(entry.message.text, terms, matchMode, snippetChars),
|
||
score: entry.score,
|
||
});
|
||
}
|
||
|
||
if (!matches.length && metaMatched) {
|
||
matches.push({
|
||
sessionId: doc.sessionId,
|
||
messageIndex: null,
|
||
role: 'metadata',
|
||
timestamp: doc.updated,
|
||
snippet: makeSnippet(buildMetadataSnippet(doc), terms, matchMode, snippetChars),
|
||
score,
|
||
});
|
||
}
|
||
|
||
const firstMatch = matches[0] || null;
|
||
return {
|
||
sessionId: doc.sessionId,
|
||
messageIndex: firstMatch ? firstMatch.messageIndex : null,
|
||
role: firstMatch ? firstMatch.role : null,
|
||
timestamp: firstMatch ? firstMatch.timestamp : null,
|
||
matchTimestamp: firstMatch ? firstMatch.timestamp : null,
|
||
sessionUpdated: doc.updated,
|
||
title: doc.title,
|
||
projectName: doc.projectName,
|
||
updated: doc.updated,
|
||
score,
|
||
matchedMessageCount: matchedMessages.length,
|
||
matches,
|
||
};
|
||
}
|
||
|
||
function search(params = {}) {
|
||
const started = process.hrtime.bigint();
|
||
const query = buildQuery(params.query, {
|
||
maxQueryChars: Math.min(maxQueryChars, MAX_QUERY_CHARS_HARD_LIMIT),
|
||
matchMode: params.matchMode,
|
||
});
|
||
const limit = clampInteger(params.limit, maxResults, 1, Math.min(maxResults, MAX_RESULTS_HARD_LIMIT));
|
||
const sort = params.sort === 'newest' ? 'newest' : 'relevance';
|
||
const agentFilter = params.agent ? normalizeAgent(params.agent) : '';
|
||
|
||
if (!query.queryText || !query.terms.length) {
|
||
return {
|
||
total: 0,
|
||
tookMs: Number((process.hrtime.bigint() - started) / 1000000n),
|
||
indexState: state,
|
||
results: [],
|
||
};
|
||
}
|
||
|
||
const results = [];
|
||
for (const doc of documents.values()) {
|
||
if (agentFilter && doc.agent !== agentFilter) continue;
|
||
if (!docContainsAllTerms(doc, query.terms, query.matchMode)) continue;
|
||
const result = buildSearchResult(doc, query.terms, query.matchMode);
|
||
if (result.score <= 0) continue;
|
||
results.push(result);
|
||
}
|
||
|
||
results.sort((a, b) => {
|
||
if (sort === 'newest') return resultTime(b) - resultTime(a) || b.score - a.score || a.sessionId.localeCompare(b.sessionId);
|
||
return b.score - a.score || resultTime(b) - resultTime(a) || a.sessionId.localeCompare(b.sessionId);
|
||
});
|
||
|
||
return {
|
||
total: results.length,
|
||
tookMs: Number((process.hrtime.bigint() - started) / 1000000n),
|
||
indexState: state,
|
||
results: results.slice(0, limit),
|
||
};
|
||
}
|
||
|
||
async function flush() {
|
||
if (buildPromise) await buildPromise;
|
||
const pendingIds = Array.from(upsertTimers.keys());
|
||
for (const id of pendingIds) {
|
||
const timer = upsertTimers.get(id);
|
||
if (timer) clearTimeout(timer);
|
||
upsertTimers.delete(id);
|
||
await upsertNow(id);
|
||
}
|
||
if (persistTimer) {
|
||
clearTimeout(persistTimer);
|
||
persistTimer = null;
|
||
}
|
||
if (dirty) await persistNow();
|
||
if (persistPromise) await persistPromise;
|
||
return status();
|
||
}
|
||
|
||
function status() {
|
||
return {
|
||
state,
|
||
building: state === 'building',
|
||
ready: state === 'ready',
|
||
initialized,
|
||
error: lastError,
|
||
sessionsDir,
|
||
cacheFile,
|
||
indexedSessions: documents.size,
|
||
trackedFiles: fileMeta.size,
|
||
pendingUpserts: upsertTimers.size,
|
||
dirty,
|
||
skippedFiles,
|
||
failedFiles,
|
||
lastBuiltAt,
|
||
lastPersistedAt,
|
||
};
|
||
}
|
||
|
||
return {
|
||
initialize,
|
||
scheduleUpsert,
|
||
remove,
|
||
search,
|
||
status,
|
||
flush,
|
||
};
|
||
}
|
||
|
||
module.exports = {
|
||
createSessionSearchIndex,
|
||
normalizeSearchText,
|
||
tokenizeSearchText,
|
||
sanitizeSessionId,
|
||
createSessionDocument,
|
||
};
|