Files
cc-web/lib/session-search-index.js

856 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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,
};