Download packages/kap-server/src/search/indexCore.ts from SaylorTwift/kimi-code: direct link, hf CLI and curl.
- Browser
- Download file 43 kB
-
https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/kap-server/src/search/indexCore.ts
- Command line
-
hf download hf://SaylorTwift/kimi-code/packages/kap-server/src/search/indexCore.ts
-
curl -L -o indexCore.ts https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/kap-server/src/search/indexCore.ts
43 kB
| import { createHash } from 'node:crypto'; | |
| import { open, readFile, readdir, stat } from 'node:fs/promises'; | |
| import { join, relative } from 'node:path'; | |
| import { | |
| classifyStorageError, | |
| MiniDb, | |
| OpTracker, | |
| TextIndexBuildingError, | |
| wipeStoreDir, | |
| type BatchInputOp, | |
| } from '@moonshot-ai/minidb'; | |
| import { GlobalSearchError, type GlobalSearchIncomplete } from './contract.ts'; | |
| import { | |
| MAX_DOC_TEXT_CHARS, | |
| type FileMetaDoc, | |
| type MessageDoc, | |
| type SearchDoc, | |
| type SessionMetaDoc, | |
| type StatsDoc, | |
| type StepTrackerState, | |
| type TitleDoc, | |
| type TurnCounterState, | |
| } from './docs.ts'; | |
| import { | |
| decodePageToken, | |
| matchDocs, | |
| paginateRows, | |
| type MatchBudget, | |
| type MatchedRow, | |
| type NormalizedQuery, | |
| type SearchBudgets, | |
| } from './match.ts'; | |
| import { analyzeWireLine, type StepEffect, type TurnEffect } from './wireExtract.ts'; | |
| const TEXT_INDEX_NAME = 'body'; | |
| const TRI_INDEX_NAME = 'tri'; | |
| const WIRE_FILENAME = 'wire.jsonl'; | |
| const FILE_META_PREFIX = '\0meta\\file\\'; | |
| const SESSION_META_PREFIX = '\0meta\\session\\'; | |
| const STATS_KEY = '\0meta\\stats'; | |
| function hashPath(filePath: string): string { | |
| return createHash('sha256').update(filePath).digest('hex').slice(0, 32); | |
| } | |
| function fileMetaKey(sessionId: string, filePath: string): string { | |
| return `${FILE_META_PREFIX}${sessionId}\\${hashPath(filePath)}`; | |
| } | |
| function fileMetaPrefixFor(sessionId: string): string { | |
| return `${FILE_META_PREFIX}${sessionId}\\`; | |
| } | |
| function legacyFileMetaKey(filePath: string): string { | |
| return FILE_META_PREFIX + hashPath(filePath); | |
| } | |
| const WIRE_READ_CHUNK_BYTES = 1 << 20; | |
| const WIRE_BATCH_OPS = 1_000; | |
| const SYNC_ROUND_BYTE_BUDGET = 64 << 20; | |
| const SYNC_ROUND_TIME_BUDGET_MS = 30_000; | |
| const SYNC_FAILURE_ESCALATION_LIMIT = 5; | |
| const SESSION_SYNC_FAILURE_SKIP_LIMIT = 5; | |
| const SESSION_SYNC_SKIP_COOLDOWN_MS = 300_000; | |
| const EMPTY_BUFFER = Buffer.alloc(0); | |
| interface SyncRoundBudget { | |
| bytesLeft: number; | |
| deadline: number; | |
| } | |
| function syncBudgetExhausted(budget: SyncRoundBudget): boolean { | |
| return budget.bytesLeft <= 0 || Date.now() >= budget.deadline; | |
| } | |
| function errorMessage(error: unknown): string { | |
| return error instanceof Error ? error.message : String(error); | |
| } | |
| async function sessionDirectoryIdentity(dir: string): Promise<string | undefined> { | |
| try { | |
| const info = await stat(dir, { bigint: true }); | |
| if (!info.isDirectory() || info.ino <= 0n || info.birthtimeNs <= 0n) return undefined; | |
| return `${info.dev}:${info.ino}:${info.birthtimeNs}`; | |
| } catch (error) { | |
| const code = (error as NodeJS.ErrnoException).code; | |
| if (code === 'ENOENT' || code === 'ENOTDIR') return undefined; | |
| throw error; | |
| } | |
| } | |
| const querySourceChecks = { running: false, waiters: new Set<() => void>() }; | |
| async function queryDirectoryIdentity(dir: string, deadlineAt: number, deadline: Promise<null>): Promise<string | undefined | null> { | |
| while (querySourceChecks.running) { | |
| let wake!: () => void; | |
| const available = new Promise<boolean>((resolve) => { wake = () => { resolve(true); }; }); | |
| querySourceChecks.waiters.add(wake); | |
| try { | |
| if (await Promise.race([available, deadline]) === null) return null; | |
| } finally { | |
| querySourceChecks.waiters.delete(wake); | |
| } | |
| } | |
| if (Date.now() >= deadlineAt) return null; | |
| querySourceChecks.running = true; | |
| const identity = sessionDirectoryIdentity(dir); | |
| const release = () => { | |
| querySourceChecks.running = false; | |
| for (const wake of querySourceChecks.waiters) wake(); | |
| querySourceChecks.waiters.clear(); | |
| }; | |
| void identity.then(release, release); | |
| return Promise.race([identity, deadline]); | |
| } | |
| async function sessionDirectoryTitle(dir: string, log: SearchCoreLog): Promise<string> { | |
| for (const scope of ['', 'session-meta']) { | |
| try { | |
| const meta: unknown = JSON.parse(await readFile(join(dir, scope, 'state.json'), 'utf8')); | |
| if (typeof meta === 'object' && meta !== null && 'title' in meta && typeof meta.title === 'string') { | |
| return meta.title; | |
| } | |
| return ''; | |
| } catch (error) { | |
| if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { | |
| log.warn('search index: cannot read session title', { dir, error: errorMessage(error) }); | |
| } | |
| } | |
| } | |
| return ''; | |
| } | |
| const INITIAL_TURN_STATE: TurnCounterState = { next: 0, hasTurn: false, openers: [] }; | |
| function initialTurnState(): TurnCounterState { | |
| return INITIAL_TURN_STATE; | |
| } | |
| function applyUndoToTurnState(state: TurnCounterState, count: number): TurnCounterState { | |
| let found = 0; | |
| for (let i = state.openers.length - 1; i >= 0; i--) { | |
| if (state.openers[i]!.anchor) { | |
| found++; | |
| if (found === count) { | |
| return { | |
| next: state.openers[i]!.turn, | |
| hasTurn: i > 0, | |
| openers: state.openers.slice(0, i), | |
| }; | |
| } | |
| } | |
| } | |
| return state; | |
| } | |
| function advanceTurnCounter( | |
| state: TurnCounterState, | |
| effect: TurnEffect, | |
| ): { docTurn: number | undefined; state: TurnCounterState } { | |
| switch (effect.kind) { | |
| case 'open': | |
| return { | |
| docTurn: state.next, | |
| state: { | |
| next: state.next + 1, | |
| hasTurn: true, | |
| openers: [...state.openers, { turn: state.next, anchor: effect.anchor }], | |
| }, | |
| }; | |
| case 'ensure': { | |
| const next = state.hasTurn ? state : { ...state, next: state.next + 1, hasTurn: true }; | |
| return { docTurn: next.next - 1, state: next }; | |
| } | |
| case 'undo': | |
| return { docTurn: undefined, state: applyUndoToTurnState(state, effect.count) }; | |
| case 'none': | |
| return { docTurn: undefined, state }; | |
| } | |
| } | |
| const INITIAL_STEP_STATE: StepTrackerState = { byUuid: {}, begins: 0 }; | |
| function initialStepState(): StepTrackerState { | |
| return INITIAL_STEP_STATE; | |
| } | |
| function advanceStepTracker(state: StepTrackerState, effect: StepEffect): StepTrackerState { | |
| if (effect.kind !== 'begin') return state; | |
| const begins = state.begins + 1; | |
| const ordinal = effect.ordinal ?? begins; | |
| if (state.byUuid[effect.uuid] === ordinal) return state; | |
| return { byUuid: { ...state.byUuid, [effect.uuid]: ordinal }, begins }; | |
| } | |
| export interface SearchCoreLog { | |
| info(message: string, meta?: Record<string, unknown>): void; | |
| warn(message: string, meta?: Record<string, unknown>): void; | |
| } | |
| export interface SearchCoreOptions { | |
| readonly indexDir: string; | |
| readonly log: SearchCoreLog; | |
| readonly bootSalt: string; | |
| readonly onLockToken?: (token: string) => void; | |
| } | |
| export interface SyncSessionInput { | |
| readonly id: string; | |
| readonly workspaceId: string; | |
| readonly title?: string; | |
| readonly updatedAt: number; | |
| readonly dir: string; | |
| } | |
| interface SessionSyncResult { | |
| readonly truncated: boolean; | |
| readonly failed: boolean; | |
| readonly error?: string; | |
| } | |
| export interface CoreIndexView { | |
| readonly state: 'building' | 'ready' | 'readonly'; | |
| readonly indexedSessions: number; | |
| readonly documents: number; | |
| readonly readOnly: boolean; | |
| readonly freshnessStale: boolean; | |
| readonly degraded?: string; | |
| readonly lockToken?: string; | |
| } | |
| export type CoreSearchResult = | |
| | { | |
| readonly kind: 'page'; | |
| readonly rows: MatchedRow[]; | |
| readonly hasMore: boolean; | |
| readonly incomplete?: GlobalSearchIncomplete; | |
| readonly generation: string; | |
| readonly index: CoreIndexView; | |
| } | |
| | { readonly kind: 'building'; readonly index: CoreIndexView }; | |
| export interface CoreSearchParams { | |
| readonly q: NormalizedQuery; | |
| readonly pageToken?: string; | |
| readonly budgets: SearchBudgets; | |
| } | |
| export interface CoreSyncOutcome { | |
| readonly noop: boolean; | |
| readonly sessions: number; | |
| readonly documents: number; | |
| readonly truncated: boolean; | |
| readonly failures: number; | |
| readonly lockToken?: string; | |
| readonly lifecycle: CoreLifecycleReport; | |
| } | |
| type CoreSyncPassOutcome = Omit<CoreSyncOutcome, 'lockToken' | 'lifecycle'>; | |
| export type CoreLifecycleState = | |
| | 'stopped' | |
| | 'opening' | |
| | 'building' | |
| | 'ready' | |
| | 'degraded' | |
| | 'closing'; | |
| export interface CoreLifecycleReport { | |
| readonly state: CoreLifecycleState; | |
| readonly detail?: string; | |
| } | |
| export interface CoreStatus { | |
| readonly sessions: number; | |
| readonly documents: number; | |
| readonly lastIndexedAt: number | null; | |
| readonly generation: number; | |
| readonly readOnly: boolean; | |
| readonly lockToken?: string; | |
| readonly degraded?: string; | |
| readonly lifecycle: CoreLifecycleReport; | |
| } | |
| export class SearchIndexCore { | |
| private walOffset = 0; | |
| private fingerprint = ''; | |
| private disposed = false; | |
| private readonly ops = new OpTracker(); | |
| private generation = 0; | |
| private syncReplaced = false; | |
| private lastRefreshError: { at: number; message: string } | null = null; | |
| private openError: string | null = null; | |
| private fileMetaMigrated = false; | |
| private lockToken: string | undefined; | |
| private lastMaintenanceDetail: string | undefined; | |
| private consecutiveSyncFailures = 0; | |
| private readonly sessionSyncFailures = new Map<string, number>(); | |
| private readonly sessionSyncSkips = new Map<string, { at: number; updatedAt: number }>(); | |
| db: MiniDb<SearchDoc> | null = null; | |
| openPromise: Promise<void> | null = null; | |
| refreshPromise: Promise<void> | null = null; | |
| fullSyncDone = false; | |
| syncRoundBytes = SYNC_ROUND_BYTE_BUDGET; | |
| syncRoundMs = SYNC_ROUND_TIME_BUDGET_MS; | |
| syncSkipCooldownMs = SESSION_SYNC_SKIP_COOLDOWN_MS; | |
| constructor(private readonly options: SearchCoreOptions) {} | |
| get lockTokenView(): string | undefined { | |
| return this.lockToken; | |
| } | |
| private get indexDir(): string { | |
| return this.options.indexDir; | |
| } | |
| private get log(): SearchCoreLog { | |
| return this.options.log; | |
| } | |
| ensureOpen(): Promise<void> { | |
| this.openPromise ??= this.openDb().then( | |
| () => { | |
| this.openError = null; | |
| }, | |
| (error: unknown) => { | |
| this.openPromise = null; | |
| this.openError = errorMessage(error); | |
| throw error; | |
| }, | |
| ); | |
| return this.openPromise; | |
| } | |
| private async openDb(): Promise<void> { | |
| const db = await this.openSearchDb(); | |
| if (this.disposed) { | |
| await db.close().catch(() => {}); | |
| throw new GlobalSearchError('index_unavailable', 'search service is disposed'); | |
| } | |
| await this.publishDb(db, null); | |
| } | |
| private tokenGeneration(): string { | |
| return `${this.options.bootSalt}:${this.generation}`; | |
| } | |
| private async publishDb(next: MiniDb<SearchDoc>, prev: MiniDb<SearchDoc> | null): Promise<void> { | |
| let fingerprint: string; | |
| try { | |
| if (!next.readOnly) { | |
| for (const [name, options] of [ | |
| [TEXT_INDEX_NAME, { fields: ['text'] }], | |
| [TRI_INDEX_NAME, { fields: ['text'], tokenizer: 'ngram' }], | |
| ] as const) { | |
| try { | |
| await next.createTextIndex(name, options); | |
| } catch (error) { | |
| if (!(error instanceof Error && error.message.includes('already exists'))) throw error; | |
| } | |
| } | |
| } | |
| fingerprint = await this.computeFingerprint(); | |
| } catch (error) { | |
| await next.close().catch(() => {}); | |
| throw error; | |
| } | |
| this.db = next; | |
| this.walOffset = next.recoveryInfo?.walScanEnd ?? 0; | |
| this.generation++; | |
| this.fingerprint = fingerprint; | |
| this.lockToken = next.readOnly ? undefined : await this.readLockToken(); | |
| if (prev !== null) await prev.close().catch(() => {}); | |
| const lifecycle = next.lifecycleStatus(); | |
| this.log.info('global search: index opened', { | |
| dir: this.indexDir, | |
| readOnly: next.readOnly, | |
| state: lifecycle.state, | |
| generation: next.getIndexGeneration()?.id ?? null, | |
| openMs: Math.round(lifecycle.phases.openMs), | |
| fullRecoveryMs: Math.round(lifecycle.phases.fullRecoveryMs), | |
| }); | |
| } | |
| private async readLockToken(): Promise<string | undefined> { | |
| try { | |
| const raw = await readFile(join(this.indexDir, 'db.lock'), 'utf8'); | |
| const parsed = JSON.parse(raw) as { pid?: unknown; token?: unknown }; | |
| if (parsed.pid !== process.pid || typeof parsed.token !== 'string') return undefined; | |
| return parsed.token; | |
| } catch { | |
| return undefined; | |
| } | |
| } | |
| private async openSearchDb(): Promise<MiniDb<SearchDoc>> { | |
| const opts = { | |
| dir: this.indexDir, | |
| valueCodec: 'json', | |
| valueMode: 'disk', | |
| fsyncPolicy: 'everysec', | |
| onLockFail: 'readonly', | |
| onLockAcquired: (info: { readonly token: string }) => { | |
| this.lockToken = info.token; | |
| this.options.onLockToken?.(info.token); | |
| }, | |
| } as const; | |
| try { | |
| return await MiniDb.open<SearchDoc>(opts); | |
| } catch (error) { | |
| if (classifyStorageError(error) !== 'rebuild') throw error; | |
| const outcome = await wipeStoreDir({ dir: this.indexDir }); | |
| if (outcome === 'locked') throw error; | |
| this.log.warn('global search: search-index corruption detected; rebuilding from scratch', { | |
| dir: this.indexDir, | |
| error: errorMessage(error), | |
| }); | |
| return MiniDb.open<SearchDoc>(opts); | |
| } | |
| } | |
| beginClose(): void { | |
| this.disposed = true; | |
| } | |
| async close(): Promise<void> { | |
| this.disposed = true; | |
| await this.ops.close(); | |
| await this.openPromise?.catch(() => {}); | |
| const db = this.db; | |
| this.db = null; | |
| if (db) await db.close().catch(() => {}); | |
| } | |
| private async tracked(op: () => Promise<void>): Promise<void> { | |
| if (!this.ops.enter()) return; | |
| try { | |
| await op(); | |
| } finally { | |
| this.ops.leave(); | |
| } | |
| } | |
| private async computeFingerprint(): Promise<string> { | |
| const parts: string[] = []; | |
| for (const name of ['db.wal', 'db.snapshot', 'db.textindexes.json']) { | |
| try { | |
| const s = await stat(join(this.indexDir, name)); | |
| parts.push(`${name}:${s.dev}:${s.ino}:${s.mtimeMs}:${s.size}`); | |
| } catch { | |
| parts.push(`${name}:-`); | |
| } | |
| } | |
| return parts.join('|'); | |
| } | |
| refresh(): Promise<void> { | |
| this.refreshPromise ??= this.tracked(() => this.doRefreshReadonly()) | |
| .then( | |
| () => { | |
| this.lastRefreshError = null; | |
| }, | |
| (error: unknown) => { | |
| this.lastRefreshError = { at: Date.now(), message: errorMessage(error) }; | |
| this.log.warn('global search: read-only refresh failed; serving the stale view', { | |
| error: errorMessage(error), | |
| }); | |
| }, | |
| ) | |
| .finally(() => { | |
| this.refreshPromise = null; | |
| }); | |
| return this.refreshPromise; | |
| } | |
| private async doRefreshReadonly(): Promise<void> { | |
| const db = this.db; | |
| if (!db || !db.readOnly || this.disposed) return; | |
| const fp = await this.computeFingerprint(); | |
| if (fp === this.fingerprint) return; | |
| const [, snapPrev, defsPrev] = this.fingerprint.split('|'); | |
| const [, snapNow, defsNow] = fp.split('|'); | |
| if (snapPrev === snapNow && defsPrev === defsNow) { | |
| const res = await db.catchUpFromWal(this.walOffset); | |
| if (res !== null) { | |
| this.walOffset = res.offset; | |
| this.fingerprint = fp; | |
| return; | |
| } | |
| } | |
| const next = await this.openSearchDb(); | |
| if (this.disposed) { | |
| await next.close().catch(() => {}); | |
| return; | |
| } | |
| if (this.db !== db) { | |
| await next.close().catch(() => {}); | |
| return; | |
| } | |
| await this.publishDb(next, db); | |
| } | |
| async sync(sessions: readonly SyncSessionInput[]): Promise<CoreSyncOutcome> { | |
| let outcome: CoreSyncPassOutcome = { | |
| noop: true, | |
| sessions: 0, | |
| documents: 0, | |
| truncated: false, | |
| failures: 0, | |
| }; | |
| await this.tracked(async () => { | |
| try { | |
| outcome = await this.runSync(sessions); | |
| this.consecutiveSyncFailures = 0; | |
| } catch (error) { | |
| if (classifyStorageError(error) !== 'rebuild') { | |
| this.consecutiveSyncFailures += 1; | |
| if (this.consecutiveSyncFailures < SYNC_FAILURE_ESCALATION_LIMIT) throw error; | |
| } | |
| this.consecutiveSyncFailures = 0; | |
| await this.recoverByRebuild(error); | |
| outcome = { noop: false, sessions: 0, documents: 0, truncated: true, failures: 0 }; | |
| } | |
| }); | |
| return { ...outcome, lockToken: this.lockToken, lifecycle: this.lifecycleState() }; | |
| } | |
| private async recoverByRebuild(cause: unknown): Promise<void> { | |
| this.log.warn('global search: search-index state is unrecoverable; wiping and rebuilding from the transcripts', { | |
| dir: this.indexDir, | |
| error: errorMessage(cause), | |
| }); | |
| await this.reindex(); | |
| } | |
| private async runSync(sessions: readonly SyncSessionInput[]): Promise<CoreSyncPassOutcome> { | |
| const noop: CoreSyncPassOutcome = { | |
| noop: true, | |
| sessions: 0, | |
| documents: 0, | |
| truncated: false, | |
| failures: 0, | |
| }; | |
| if (this.disposed) return noop; | |
| this.syncReplaced = false; | |
| await this.ensureOpen(); | |
| const db = this.db; | |
| if (!db || db.readOnly || this.disposed) return noop; | |
| await this.migrateFileMetaKeys(db); | |
| const currentIds = new Set(sessions.map((s) => s.id)); | |
| for (const row of db.query({ key: { prefix: SESSION_META_PREFIX }, project: [] })) { | |
| if (this.disposed) return noop; | |
| const sessionId = row.key.slice(SESSION_META_PREFIX.length); | |
| if (!currentIds.has(sessionId)) await this.deleteSessionDocs(db, sessionId); | |
| } | |
| const budget: SyncRoundBudget = { | |
| bytesLeft: this.syncRoundBytes, | |
| deadline: Date.now() + this.syncRoundMs, | |
| }; | |
| let indexed = 0; | |
| let truncated = false; | |
| let failures = 0; | |
| for (const summary of sessions) { | |
| if (this.disposed) return noop; | |
| const skip = this.sessionSyncSkips.get(summary.id); | |
| if ( | |
| skip !== undefined && | |
| summary.updatedAt === skip.updatedAt && | |
| Date.now() - skip.at < this.syncSkipCooldownMs | |
| ) { | |
| continue; | |
| } | |
| this.sessionSyncSkips.delete(summary.id); | |
| if (syncBudgetExhausted(budget)) { | |
| truncated = true; | |
| break; | |
| } | |
| const result = await this.syncSession(db, summary, budget); | |
| if (result === undefined) continue; | |
| if (result.truncated) truncated = true; | |
| if (result.failed) { | |
| const count = (this.sessionSyncFailures.get(summary.id) ?? 0) + 1; | |
| this.sessionSyncFailures.set(summary.id, count); | |
| if (count >= SESSION_SYNC_FAILURE_SKIP_LIMIT) { | |
| this.sessionSyncFailures.delete(summary.id); | |
| this.sessionSyncSkips.set(summary.id, { at: Date.now(), updatedAt: summary.updatedAt }); | |
| this.log.warn( | |
| 'global search: giving up on a session whose wire transcript stays unreadable', | |
| { sessionId: summary.id, error: result.error }, | |
| ); | |
| } else { | |
| failures += 1; | |
| this.log.warn('global search: failed to index session', { | |
| sessionId: summary.id, | |
| error: result.error, | |
| }); | |
| } | |
| } else { | |
| this.sessionSyncFailures.delete(summary.id); | |
| } | |
| indexed++; | |
| } | |
| if (this.disposed) return noop; | |
| const metaCount = db.query({ key: { prefix: '\0meta\\' }, project: [] }).length; | |
| const stats: StatsDoc = { | |
| kind: 'stats', | |
| degraded: indexed < sessions.length ? `Skipped ${sessions.length - indexed} session(s) during indexing` : undefined, | |
| sessions: indexed, | |
| documents: db.size - metaCount, | |
| lastIndexedAt: Date.now(), | |
| }; | |
| await db.set(STATS_KEY, stats); | |
| if (!truncated && failures === 0) this.fullSyncDone = true; | |
| if (this.syncReplaced) { | |
| this.generation++; | |
| } | |
| return { noop: false, sessions: indexed, documents: stats.documents, truncated, failures }; | |
| } | |
| private async migrateFileMetaKeys(db: MiniDb<SearchDoc>): Promise<void> { | |
| if (this.fileMetaMigrated) return; | |
| const ops: BatchInputOp<SearchDoc>[] = []; | |
| for (const row of db.query({ key: { prefix: FILE_META_PREFIX }, project: [] })) { | |
| const rest = row.key.slice(FILE_META_PREFIX.length); | |
| if (rest.includes('\\')) continue; | |
| const meta = row.value; | |
| if (meta.kind !== 'fileMeta') continue; | |
| ops.push({ op: 'set', key: fileMetaKey(meta.sessionId, meta.path), value: meta }); | |
| ops.push({ op: 'del', key: row.key }); | |
| } | |
| if (ops.length > 0) await db.batch(ops); | |
| this.fileMetaMigrated = true; | |
| } | |
| private async deleteSessionDocs(db: MiniDb<SearchDoc>, sessionId: string): Promise<void> { | |
| for (const row of db.query({ key: { prefix: `${sessionId}/` }, project: [] })) { | |
| await db.del(row.key); | |
| } | |
| for (const row of db.query({ key: { prefix: fileMetaPrefixFor(sessionId) }, project: [] })) { | |
| await db.del(row.key); | |
| } | |
| await db.del(SESSION_META_PREFIX + sessionId); | |
| } | |
| private async syncSession( | |
| db: MiniDb<SearchDoc>, | |
| summary: SyncSessionInput, | |
| budget: SyncRoundBudget, | |
| ): Promise<SessionSyncResult | undefined> { | |
| let identity: string | undefined; | |
| try { | |
| identity = await sessionDirectoryIdentity(summary.dir); | |
| } catch (error) { | |
| this.log.warn('global search: failed to index session', { | |
| sessionId: summary.id, | |
| error: errorMessage(error), | |
| }); | |
| return undefined; | |
| } | |
| if (identity === undefined) { | |
| await this.deleteSessionDocs(db, summary.id); | |
| return undefined; | |
| } | |
| const title = await sessionDirectoryTitle(summary.dir, this.log); | |
| const metaKey = SESSION_META_PREFIX + summary.id; | |
| const previous = db.get(metaKey); | |
| if (previous?.kind !== 'sessionMeta' || previous.identity !== identity || previous.dir !== summary.dir) { | |
| await this.deleteSessionDocs(db, summary.id); | |
| if (previous !== undefined) this.syncReplaced = true; | |
| } | |
| const meta: SessionMetaDoc = { kind: 'sessionMeta', dir: summary.dir, identity, title }; | |
| if (previous?.kind !== 'sessionMeta' || previous.identity !== identity || previous.dir !== summary.dir || previous.title !== title) { | |
| await db.set(metaKey, meta); | |
| } | |
| const wireFiles = await collectWireFiles(summary.dir); | |
| const seenPaths = new Set(wireFiles.map((file) => file.path)); | |
| for (const row of db.query({ key: { prefix: fileMetaPrefixFor(summary.id) } })) { | |
| const meta = row.value; | |
| if (meta.kind !== 'fileMeta') continue; | |
| if (seenPaths.has(meta.path)) continue; | |
| await this.deleteFileDocs(db, meta); | |
| await db.del(row.key); | |
| } | |
| let truncated = false; | |
| let failed = false; | |
| let error: string | undefined; | |
| for (const file of wireFiles) { | |
| const result = await this.syncWireFile(db, { ...summary, title, sessionIdentity: identity }, file, budget); | |
| if (result.truncated) truncated = true; | |
| if (result.failed) { | |
| failed = true; | |
| error ??= result.error; | |
| } | |
| } | |
| const titleKey = `${summary.id}/$title`; | |
| const existing = db.get(titleKey); | |
| if (title.length > 0) { | |
| if (existing?.kind !== 'title' || existing.text !== title) { | |
| const doc: TitleDoc = { | |
| kind: 'title', | |
| sessionIdentity: identity, | |
| sessionId: summary.id, | |
| workspaceId: summary.workspaceId, | |
| sessionTitle: title, | |
| agentId: '', | |
| role: 'title', | |
| text: title, | |
| time: summary.updatedAt, | |
| }; | |
| await db.set(titleKey, doc); | |
| if (existing !== undefined) this.syncReplaced = true; | |
| } | |
| } else if (existing !== undefined) { | |
| await db.del(titleKey); | |
| } | |
| return { truncated, failed, error }; | |
| } | |
| private async deleteFileDocs(db: MiniDb<SearchDoc>, meta: FileMetaDoc): Promise<void> { | |
| const prefix = `${meta.sessionId}/${meta.agentId}/${meta.source}:`; | |
| for (const row of db.query({ key: { prefix }, project: [] })) { | |
| await db.del(row.key); | |
| } | |
| } | |
| private async syncWireFile( | |
| db: MiniDb<SearchDoc>, | |
| summary: SyncSessionInput & { readonly sessionIdentity: string }, | |
| file: WireFileRef, | |
| budget: SyncRoundBudget, | |
| ): Promise<SessionSyncResult> { | |
| let st: { size: number; mtimeMs: number; ino: number }; | |
| try { | |
| st = await stat(file.path); | |
| } catch { | |
| return { truncated: false, failed: false }; | |
| } | |
| const size = st.size; | |
| const metaKey = fileMetaKey(summary.id, file.path); | |
| let meta = db.get(metaKey); | |
| let legacyKey: string | null = null; | |
| if (meta?.kind !== 'fileMeta') { | |
| const oldKey = legacyFileMetaKey(file.path); | |
| const legacy = db.get(oldKey); | |
| if (legacy?.kind === 'fileMeta') { | |
| meta = legacy; | |
| legacyKey = oldKey; | |
| } | |
| } | |
| const known = meta?.kind === 'fileMeta' ? meta : undefined; | |
| let offset = known?.offset ?? 0; | |
| let turnState: TurnCounterState = known?.turnState ?? initialTurnState(); | |
| let stepState: StepTrackerState = known?.stepState ?? initialStepState(); | |
| const fileMeta = ( | |
| nextOffset: number, | |
| turns: TurnCounterState, | |
| steps: StepTrackerState, | |
| ): FileMetaDoc => ({ | |
| kind: 'fileMeta', | |
| sessionId: summary.id, | |
| agentId: file.agentId, | |
| source: file.source, | |
| path: file.path, | |
| offset: nextOffset, | |
| size, | |
| mtimeMs: st.mtimeMs, | |
| ino: st.ino, | |
| turnState: turns, | |
| stepState: steps, | |
| }); | |
| const legacyMeta = known !== undefined && known.stepState === undefined; | |
| const replacedFile = known?.ino !== undefined && known.ino !== st.ino; | |
| const rewrittenInPlace = | |
| known?.mtimeMs !== undefined && size === known.offset && st.mtimeMs > known.mtimeMs; | |
| if (size < offset || legacyMeta || replacedFile || rewrittenInPlace) { | |
| this.syncReplaced = true; | |
| await this.deleteFileDocs(db, fileMeta(0, initialTurnState(), initialStepState())); | |
| offset = 0; | |
| turnState = initialTurnState(); | |
| stepState = initialStepState(); | |
| } | |
| if (size === offset) { | |
| if ( | |
| legacyKey !== null || | |
| known === undefined || | |
| known.size !== size || | |
| known.mtimeMs !== st.mtimeMs || | |
| known.ino !== st.ino || | |
| known.offset !== offset | |
| ) { | |
| const ops: BatchInputOp<SearchDoc>[] = [ | |
| { op: 'set', key: metaKey, value: fileMeta(offset, turnState, stepState) }, | |
| ]; | |
| if (legacyKey !== null) ops.push({ op: 'del', key: legacyKey }); | |
| await db.batch(ops); | |
| } | |
| return { truncated: false, failed: false }; | |
| } | |
| let handle: Awaited<ReturnType<typeof open>>; | |
| try { | |
| handle = await open(file.path, 'r'); | |
| } catch (error) { | |
| return { truncated: false, failed: true, error: errorMessage(error) }; | |
| } | |
| const ops: BatchInputOp<SearchDoc>[] = []; | |
| let byteCursor = offset; | |
| let position = offset; | |
| let wireError: unknown; | |
| try { | |
| let pending: Buffer = EMPTY_BUFFER; | |
| let finishing = false; | |
| const chunk = Buffer.allocUnsafe(WIRE_READ_CHUNK_BYTES); | |
| while (position < size) { | |
| if (this.disposed) return { truncated: false, failed: false }; | |
| if (syncBudgetExhausted(budget)) { | |
| if (pending.length === 0) break; | |
| finishing = true; | |
| } | |
| let bytesRead: number; | |
| try { | |
| ({ bytesRead } = await handle.read( | |
| chunk, | |
| 0, | |
| Math.min(chunk.length, size - position), | |
| position, | |
| )); | |
| } catch (error) { | |
| wireError = error; | |
| break; | |
| } | |
| if (bytesRead === 0) break; | |
| budget.bytesLeft -= bytesRead; | |
| const slice = chunk.subarray(0, bytesRead); | |
| position += bytesRead; | |
| let start = 0; | |
| let completedRecord = false; | |
| for (;;) { | |
| const nl = slice.indexOf(0x0a, start); | |
| if (nl === -1) break; | |
| const lineBuf = | |
| pending.length > 0 | |
| ? Buffer.concat([pending, slice.subarray(start, nl)]) | |
| : slice.subarray(start, nl); | |
| pending = EMPTY_BUFFER; | |
| const lineOffset = byteCursor; | |
| byteCursor += lineBuf.length + 1; | |
| ({ turnState, stepState } = this.collectWireLine( | |
| ops, | |
| summary, | |
| file, | |
| lineBuf.toString('utf8'), | |
| lineOffset, | |
| { turnState, stepState }, | |
| )); | |
| completedRecord = true; | |
| start = nl + 1; | |
| } | |
| pending = | |
| pending.length > 0 | |
| ? Buffer.concat([pending, slice.subarray(start)]) | |
| : Buffer.from(slice.subarray(start)); | |
| if (finishing && completedRecord) break; | |
| if (ops.length >= WIRE_BATCH_OPS) { | |
| ops.push({ op: 'set', key: metaKey, value: fileMeta(byteCursor, turnState, stepState) }); | |
| if (legacyKey !== null) { | |
| ops.push({ op: 'del', key: legacyKey }); | |
| legacyKey = null; | |
| } | |
| await db.batch(ops); | |
| ops.length = 0; | |
| } | |
| } | |
| } finally { | |
| await handle.close(); | |
| } | |
| const truncated = position < size && syncBudgetExhausted(budget); | |
| if (byteCursor !== offset || legacyKey !== null) { | |
| ops.push({ op: 'set', key: metaKey, value: fileMeta(byteCursor, turnState, stepState) }); | |
| if (legacyKey !== null) ops.push({ op: 'del', key: legacyKey }); | |
| await db.batch(ops); | |
| } | |
| return { | |
| truncated, | |
| failed: wireError !== undefined, | |
| error: wireError !== undefined ? errorMessage(wireError) : undefined, | |
| }; | |
| } | |
| private collectWireLine( | |
| ops: BatchInputOp<SearchDoc>[], | |
| summary: SyncSessionInput & { readonly sessionIdentity: string }, | |
| file: WireFileRef, | |
| line: string, | |
| lineOffset: number, | |
| counters: { turnState: TurnCounterState; stepState: StepTrackerState }, | |
| ): { turnState: TurnCounterState; stepState: StepTrackerState } { | |
| let { turnState, stepState } = counters; | |
| const analysis = analyzeWireLine(line); | |
| const advanced = advanceTurnCounter(turnState, analysis.turn); | |
| if ( | |
| analysis.turn.kind === 'open' || | |
| analysis.turn.kind === 'undo' || | |
| (analysis.turn.kind === 'ensure' && !turnState.hasTurn) | |
| ) { | |
| stepState = initialStepState(); | |
| } | |
| turnState = advanced.state; | |
| stepState = advanceStepTracker(stepState, analysis.step); | |
| const extracted = analysis.messages; | |
| for (let i = 0; i < extracted.length; i++) { | |
| const e = extracted[i]!; | |
| const stepOrdinal = e.stepUuid !== undefined ? stepState.byUuid[e.stepUuid] : undefined; | |
| const doc: MessageDoc = { | |
| kind: 'message', | |
| sessionIdentity: summary.sessionIdentity, | |
| sessionId: summary.id, | |
| workspaceId: summary.workspaceId, | |
| sessionTitle: summary.title ?? '', | |
| agentId: file.agentId, | |
| role: e.role, | |
| text: e.text.length > MAX_DOC_TEXT_CHARS ? e.text.slice(0, MAX_DOC_TEXT_CHARS) : e.text, | |
| time: e.time ?? summary.updatedAt, | |
| turn: advanced.docTurn, | |
| stepId: | |
| advanced.docTurn !== undefined && stepOrdinal !== undefined | |
| ? `t${advanced.docTurn}.${stepOrdinal}` | |
| : undefined, | |
| }; | |
| ops.push({ | |
| op: 'set', | |
| key: `${docKeyPrefix(summary.id, file)}${lineOffset}:${i}`, | |
| value: doc, | |
| }); | |
| } | |
| return { turnState, stepState }; | |
| } | |
| async search(params: CoreSearchParams): Promise<CoreSearchResult> { | |
| const { q, budgets } = params; | |
| const db = this.db; | |
| if (db === null) { | |
| if (this.disposed) { | |
| throw new GlobalSearchError('index_unavailable', 'search service is disposed'); | |
| } | |
| if (this.openError !== null) { | |
| throw new GlobalSearchError( | |
| 'index_unavailable', | |
| `search index failed to open: ${this.openError}`, | |
| ); | |
| } | |
| if (params.pageToken !== undefined) { | |
| throw new GlobalSearchError( | |
| 'invalid_page_token', | |
| 'the search index is not ready yet; restart the search', | |
| ); | |
| } | |
| return { kind: 'building', index: this.buildingView() }; | |
| } | |
| let freshnessStale = false; | |
| let serveDb = db; | |
| if (serveDb.readOnly) { | |
| let fp: string | null = null; | |
| try { | |
| fp = await this.computeFingerprint(); | |
| } catch (error) { | |
| this.lastRefreshError = { at: Date.now(), message: errorMessage(error) }; | |
| } | |
| if (this.db === null) { | |
| throw new GlobalSearchError('index_unavailable', 'search service is disposed'); | |
| } | |
| serveDb = this.db; | |
| if (serveDb.readOnly) { | |
| freshnessStale = fp === null || fp !== this.fingerprint || this.refreshPromise !== null; | |
| if (fp !== null && fp !== this.fingerprint) void this.refresh(); | |
| } | |
| } | |
| const generation = this.tokenGeneration(); | |
| const page = decodePageToken(q, 'index', params.pageToken, generation); | |
| if (serveDb.textIndexBuilding(q.mode === 'literal' ? TRI_INDEX_NAME : TEXT_INDEX_NAME)) { | |
| return { kind: 'building', index: this.buildingView(serveDb) }; | |
| } | |
| let candidates: { key: string; value: SearchDoc | undefined; score: number }[]; | |
| let incomplete: GlobalSearchIncomplete | undefined; | |
| const runBounded = ( | |
| db2: MiniDb<SearchDoc>, | |
| ): Promise<{ | |
| hits: { key: string; value: SearchDoc; score: number }[]; | |
| visits: number; | |
| truncated: boolean; | |
| }> => { | |
| if (q.mode === 'literal') { | |
| return db2.searchBoundedAsync(TRI_INDEX_NAME, q.query, { | |
| op: 'AND', | |
| limit: budgets.literalCandidateCap + 1, | |
| maxVisits: budgets.postingsVisitBudget, | |
| }); | |
| } | |
| return db2.searchBoundedAsync(TEXT_INDEX_NAME, q.query, { | |
| op: q.op, | |
| limit: budgets.maxTextHits + 1, | |
| maxVisits: budgets.postingsVisitBudget, | |
| }); | |
| }; | |
| try { | |
| let res: { | |
| hits: { key: string; value: SearchDoc; score: number }[]; | |
| visits: number; | |
| truncated: boolean; | |
| }; | |
| try { | |
| res = await runBounded(serveDb); | |
| } catch (error) { | |
| const msg = error instanceof Error ? error.message : String(error); | |
| const closedRace = | |
| msg.includes('postings file is closed') || | |
| msg.includes('MiniDb is closed') || | |
| msg.includes('ValueReader is not open'); | |
| if (!closedRace || this.db === null || this.db === serveDb) throw error; | |
| serveDb = this.db; | |
| res = await runBounded(serveDb); | |
| } | |
| if (q.mode === 'literal') { | |
| candidates = res.hits; | |
| if (res.truncated) incomplete = 'postings_budget'; | |
| if (candidates.length > budgets.literalCandidateCap) { | |
| candidates.length = budgets.literalCandidateCap; | |
| incomplete ??= 'candidate_cap'; | |
| } | |
| } else { | |
| candidates = res.hits; | |
| if (res.truncated) incomplete = 'postings_budget'; | |
| if (candidates.length > budgets.maxTextHits) { | |
| candidates.length = budgets.maxTextHits; | |
| incomplete ??= 'candidate_cap'; | |
| } | |
| } | |
| } catch (error) { | |
| if (error instanceof TextIndexBuildingError) { | |
| return { kind: 'building', index: this.buildingView(serveDb) }; | |
| } | |
| if (error instanceof Error && error.message.includes('no such text index')) { | |
| return { | |
| kind: 'page', | |
| rows: [], | |
| hasMore: false, | |
| incomplete: undefined, | |
| generation, | |
| index: this.readIndexView(serveDb, freshnessStale), | |
| }; | |
| } | |
| throw error; | |
| } | |
| const budget: MatchBudget = { | |
| deadlineAt: Date.now() + budgets.queryDeadlineMs, | |
| textCharsLeft: budgets.queryTextBudgetChars, | |
| }; | |
| const boundary = page.kind === 'keyset' ? page.boundary : undefined; | |
| const matched = matchDocs(q, candidates, boundary, budget); | |
| incomplete ??= matched.incomplete; | |
| const index = this.readIndexView(serveDb, freshnessStale); | |
| const sources = new Map<string, SessionMetaDoc | undefined>(); | |
| for (const row of matched.rows) { | |
| const id = row.value.sessionId; | |
| if (sources.has(id)) continue; | |
| if (Date.now() > budget.deadlineAt) { | |
| incomplete ??= 'deadline'; | |
| break; | |
| } | |
| const meta = serveDb.get(SESSION_META_PREFIX + id); | |
| sources.set(id, meta?.kind === 'sessionMeta' ? meta : undefined); | |
| } | |
| const identities = new Map<string, string | undefined>(); | |
| const visible: MatchedRow[] = []; | |
| let deadlineTimer: ReturnType<typeof setTimeout> | undefined; | |
| const deadline = new Promise<null>((resolve) => { | |
| deadlineTimer = setTimeout(() => resolve(null), Math.max(0, budget.deadlineAt - Date.now())); | |
| deadlineTimer.unref?.(); | |
| }); | |
| try { | |
| for (const row of matched.rows) { | |
| if (Date.now() > budget.deadlineAt) { | |
| incomplete ??= 'deadline'; | |
| break; | |
| } | |
| const meta = sources.get(row.value.sessionId); | |
| if (meta?.dir === undefined || row.value.sessionIdentity === undefined || meta.identity !== row.value.sessionIdentity) { | |
| freshnessStale = true; | |
| continue; | |
| } | |
| if (!identities.has(meta.dir)) { | |
| try { | |
| const identity = await queryDirectoryIdentity(meta.dir, budget.deadlineAt, deadline); | |
| if (identity === null || Date.now() > budget.deadlineAt) { | |
| incomplete ??= 'deadline'; | |
| break; | |
| } | |
| identities.set(meta.dir, identity); | |
| } catch (error) { | |
| throw new GlobalSearchError('index_unavailable', `cannot verify search source: ${errorMessage(error)}`); | |
| } | |
| } | |
| if (identities.get(meta.dir) === row.value.sessionIdentity) { | |
| visible.push({ ...row, value: { ...row.value, sessionTitle: meta.title ?? '' } }); | |
| } else { | |
| freshnessStale = true; | |
| } | |
| } | |
| } finally { | |
| if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); | |
| } | |
| const { pageRows, hasMore } = paginateRows(q, page, visible); | |
| return { | |
| kind: 'page', | |
| rows: pageRows, | |
| hasMore, | |
| incomplete, | |
| generation, | |
| index: { ...index, freshnessStale: freshnessStale || this.db !== serveDb }, | |
| }; | |
| } | |
| async reindex(): Promise<void> { | |
| await this.ensureOpen(); | |
| if (this.db?.readOnly === true) { | |
| throw new GlobalSearchError( | |
| 'readonly_index', | |
| 'another process holds the search-index write lock; reindex from that process', | |
| ); | |
| } | |
| await this.refreshPromise?.catch(() => {}); | |
| const db = this.db; | |
| if (db) { | |
| await db.close().catch(() => {}); | |
| this.db = null; | |
| } | |
| this.openPromise = null; | |
| this.fullSyncDone = false; | |
| this.sessionSyncFailures.clear(); | |
| this.sessionSyncSkips.clear(); | |
| this.lockToken = undefined; | |
| const outcome = await wipeStoreDir({ dir: this.indexDir }); | |
| if (outcome === 'locked') { | |
| throw new GlobalSearchError( | |
| 'readonly_index', | |
| 'another process holds the search-index write lock; reindex from that process', | |
| ); | |
| } | |
| await this.ensureOpen(); | |
| } | |
| lifecycleState(): CoreLifecycleReport { | |
| if (this.disposed) return { state: this.db === null ? 'stopped' : 'closing' }; | |
| const db = this.db; | |
| if (db !== null) this.noteMaintenanceDetail(db); | |
| if (db === null) { | |
| if (this.openPromise !== null) return { state: 'opening' }; | |
| if (this.openError !== null) return { state: 'degraded', detail: this.openError }; | |
| return { state: 'stopped' }; | |
| } | |
| if (db.textIndexBuilding(TEXT_INDEX_NAME) || db.textIndexBuilding(TRI_INDEX_NAME)) { | |
| return { state: 'building' }; | |
| } | |
| return { state: 'ready' }; | |
| } | |
| private maintenanceDetail(db: MiniDb<SearchDoc>): string | undefined { | |
| const failure = db.lastCompactError ?? db.lastGenBuildError; | |
| return failure === null ? undefined : errorMessage(failure); | |
| } | |
| private noteMaintenanceDetail(db: MiniDb<SearchDoc>): void { | |
| const detail = this.maintenanceDetail(db); | |
| if (detail === this.lastMaintenanceDetail) return; | |
| const previous = this.lastMaintenanceDetail; | |
| this.lastMaintenanceDetail = detail; | |
| if (detail !== undefined) { | |
| this.log.warn('global search: index maintenance is failing; the WAL keeps growing until it recovers', { | |
| error: detail, | |
| }); | |
| } else if (previous !== undefined) { | |
| this.log.info('global search: index maintenance recovered'); | |
| } | |
| } | |
| async status(): Promise<CoreStatus> { | |
| await this.ensureOpen(); | |
| if (this.db?.readOnly === true) { | |
| await this.refresh(); | |
| } | |
| const stats = this.db?.get(STATS_KEY); | |
| const maintenance = this.db !== null ? this.maintenanceDetail(this.db) : undefined; | |
| return { | |
| sessions: stats?.kind === 'stats' ? stats.sessions : 0, | |
| documents: stats?.kind === 'stats' ? stats.documents : 0, | |
| lastIndexedAt: stats?.kind === 'stats' ? stats.lastIndexedAt : null, | |
| generation: this.generation, | |
| readOnly: this.db?.readOnly === true, | |
| lockToken: this.lockToken, | |
| degraded: this.lastRefreshError?.message ?? maintenance ?? (stats?.kind === 'stats' ? stats.degraded : undefined), | |
| lifecycle: this.lifecycleState(), | |
| }; | |
| } | |
| private buildingView(db?: MiniDb<SearchDoc>): CoreIndexView { | |
| const handle = db ?? this.db; | |
| const stats = handle?.get(STATS_KEY); | |
| const indexed = stats?.kind === 'stats' ? stats.sessions : 0; | |
| return { | |
| state: 'building', | |
| indexedSessions: indexed, | |
| documents: stats?.kind === 'stats' ? stats.documents : 0, | |
| readOnly: handle?.readOnly === true, | |
| freshnessStale: true, | |
| degraded: this.lastRefreshError?.message ?? (stats?.kind === 'stats' ? stats.degraded : undefined), | |
| lockToken: this.lockToken, | |
| }; | |
| } | |
| private readIndexView(db: MiniDb<SearchDoc>, freshnessStale: boolean): CoreIndexView { | |
| const stats = db.get(STATS_KEY); | |
| const indexed = stats?.kind === 'stats' ? stats.sessions : 0; | |
| const documents = stats?.kind === 'stats' ? stats.documents : 0; | |
| const building = db.textIndexBuilding(TEXT_INDEX_NAME) || db.textIndexBuilding(TRI_INDEX_NAME); | |
| return { | |
| state: building ? 'building' : db.readOnly ? 'readonly' : this.fullSyncDone ? 'ready' : 'building', | |
| indexedSessions: indexed, | |
| documents, | |
| readOnly: db.readOnly, | |
| freshnessStale, | |
| degraded: this.lastRefreshError?.message ?? (stats?.kind === 'stats' ? stats.degraded : undefined), | |
| lockToken: this.lockToken, | |
| }; | |
| } | |
| } | |
| interface WireFileRef { | |
| readonly path: string; | |
| readonly agentId: string; | |
| readonly source: 'root' | 'agents'; | |
| } | |
| async function collectWireFiles(sessionDir: string): Promise<WireFileRef[]> { | |
| const files: WireFileRef[] = []; | |
| const root = join(sessionDir, WIRE_FILENAME); | |
| try { | |
| if ((await stat(root)).isFile()) files.push({ path: root, agentId: 'main', source: 'root' }); | |
| } catch { | |
| } | |
| const agentsDir = join(sessionDir, 'agents'); | |
| try { | |
| const entries = await readdir(agentsDir, { recursive: true, withFileTypes: true }); | |
| for (const entry of entries) { | |
| if (!entry.isFile() || entry.name !== WIRE_FILENAME) continue; | |
| const path = join(entry.parentPath, entry.name); | |
| files.push({ path, agentId: relative(agentsDir, entry.parentPath), source: 'agents' }); | |
| } | |
| } catch { | |
| } | |
| return files; | |
| } | |
| function docKeyPrefix(sessionId: string, file: WireFileRef): string { | |
| return `${sessionId}/${file.agentId}/${file.source}:`; | |
| } | |