import { existsSync, readFileSync } from "node:fs"; import { resolve, join } from "node:path"; import { homedir } from "node:os"; import { metaDir } from "./compat.js"; import { MemoryStore, validateStoragePath } from "./store.js"; import { createEmbedder, getVectorDimensions, type EmbeddingConfig } from "./embedder.js"; import { createRetriever, type RetrievalConfig, DEFAULT_RETRIEVAL_CONFIG } from "./retriever.js"; import { applyRetrievalProfile } from "./retrieval-profiles.js"; import { AccessTracker } from "./access-tracker.js"; import { createAuditLogger } from "./audit-log.js"; import { FrequencyTracker } from "./frequency-tracker.js"; import { createLLMClient, type LLMClient, type LLMConfig } from "./llm-client.js"; import { logInfo } from "./stderr-log.js"; import * as envConfig from "./env-config.js"; export type RecallMode = "full" | "light" | "summary" | "off"; export interface LocalMemoryConfig { dbPath: string; recallMode?: RecallMode; embedding: { provider: string; apiKey: string; model: string; baseURL?: string; dimensions?: number; taskQuery?: string; taskPassage?: string; }; llm?: { apiKey: string; model: string; baseURL: string; }; sources: Record; retrieval?: Partial; /** * Opt-in cross-scope sidecar map for search_memory. * Main scoped search remains isolated; callers must pass includeRelatedScopes * before configured related scopes are queried and shown separately. */ scopeRelations?: Record; /** * Default depth for auto-recall injection. * - "full": inject complete text (default, backward compatible) * - "l1": inject L1 overview from metadata (~500 tokens) * - "l0": inject L0 abstract from metadata (~100 tokens) * Agent can use memory_drill_down tool to get deeper content on demand. */ recallDepthDefault?: "l0" | "l1" | "full"; } export function loadDotEnv(): void { const envPath = resolve(metaDir(import.meta), "../.env"); if (!existsSync(envPath)) return; const content = readFileSync(envPath, "utf-8"); for (const line of content.split("\n")) { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith("#")) continue; const eqIdx = trimmed.indexOf("="); if (eqIdx > 0) { const key = trimmed.slice(0, eqIdx).trim(); const val = trimmed.slice(eqIdx + 1).trim(); if (!process.env[key]) process.env[key] = val; } } } export function findConfigPath(): string { if (process.env.LOCAL_MEMORY_CONFIG) { const explicit = resolve(process.env.LOCAL_MEMORY_CONFIG); // Check here rather than letting readFileSync throw a bare ENOENT: scripts // resolve the database at module load, so a stale env var would otherwise // surface as an unexplained crash before any of their own output appears. if (!existsSync(explicit)) { throw new Error( `LOCAL_MEMORY_CONFIG points to a file that does not exist:\n` + ` ${explicit}\n` + ` Fix: correct the env var, or unset it to use the repo's config.json.` ); } return explicit; } const localConfig = resolve(metaDir(import.meta), "../config.json"); if (existsSync(localConfig)) return localConfig; const branded = join(homedir(), ".config", "recallnest", "config.json"); if (existsSync(branded)) return branded; const exampleExists = existsSync(resolve(metaDir(import.meta), "../config.json.example")); throw new Error( "Config not found.\n" + (exampleExists ? " Quick fix: cp config.json.example config.json\n" : "") + " Or set LOCAL_MEMORY_CONFIG env var, or place config.json in ~/.config/recallnest/" ); } export function loadConfig(): LocalMemoryConfig { const raw = readFileSync(findConfigPath(), "utf-8"); return JSON.parse(raw) as LocalMemoryConfig; } export function resolveEnv(value: string): string { return value.replace(/\$\{([^}]+)\}/g, (_, name) => { const envVal = process.env[name]; if (!envVal) throw new Error(`Environment variable ${name} not set`); return envVal; }); } export function expandHome(p: string): string { if (p.startsWith("~/")) return join(homedir(), p.slice(2)); return p; } /** * Single source of truth for "which LanceDB directory do we operate on". * * Every entry point — MCP server, CLI, and maintenance scripts — must resolve * the database through this function. Scripts that hardcode "./data/lancedb" * or read a non-existent config key silently operate on a *different* database * than the server does, so a health check, cleanup, or backfill can report on * an empty directory while the real store sits elsewhere. * * Relative dbPath values resolve against the repo root (not process.cwd()), so * a script's behaviour does not depend on where it was invoked from. * * Pass an already-loaded config where you have one. Calling this with no * argument reads config.json from disk — convenient at a script's top level, * but it means a missing or misconfigured file surfaces here, before the * script's own output. findConfigPath() raises an actionable error for that. */ export function resolveDbPath(config?: LocalMemoryConfig): string { const cfg = config ?? loadConfig(); return resolve(metaDir(import.meta), "..", expandHome(cfg.dbPath)); } /** Sidecar directory (frequency-stats.json etc.) that lives beside the database. */ export function resolveDataDir(config?: LocalMemoryConfig): string { return resolve(resolveDbPath(config), ".."); } export function createComponents(config: LocalMemoryConfig, profileName?: string) { const dbPath = resolveDbPath(config); validateStoragePath(dbPath); const embeddingConfig: EmbeddingConfig = { provider: "openai-compatible", apiKey: resolveEnv(config.embedding.apiKey), model: config.embedding.model, baseURL: config.embedding.baseURL, dimensions: config.embedding.dimensions, taskQuery: config.embedding.taskQuery, taskPassage: config.embedding.taskPassage, }; const embedder = createEmbedder(embeddingConfig); const store = new MemoryStore({ dbPath, vectorDim: embedder.dimensions }); const mergedRetrievalConfig = { ...DEFAULT_RETRIEVAL_CONFIG, ...(config.retrieval || {}), }; // rerankApiKey 与 embedding.apiKey 同款支持 ${ENV_VAR} 引用。此前 retrieval 段是整体 // 展开、不过 resolveEnv,导致这个字段只能写明文——等于逼配置文件存凭证,是 rerank // 一直没被启用的障碍之一。 const baseRetrievalConfig = mergedRetrievalConfig.rerankApiKey ? { ...mergedRetrievalConfig, rerankApiKey: resolveEnv(mergedRetrievalConfig.rerankApiKey) } : mergedRetrievalConfig; const { profile, config: retrieverConfig } = applyRetrievalProfile(baseRetrievalConfig, profileName); const retriever = createRetriever(store, embedder, retrieverConfig); // Attach access tracker for reinforcement-based decay const accessTracker = new AccessTracker(store); accessTracker.registerExitFlush(); retriever.setAccessTracker(accessTracker); // P0.2: Attach frequency tracker for hit-count based boosting const dataDir = resolveDataDir(config); const frequencyTracker = new FrequencyTracker({ filePath: join(dataDir, "frequency-stats.json"), }); retriever.setFrequencyTracker(frequencyTracker); // F-1: 补接 audit logger,让 retrieve 操作真的被记录。 // // 2026-08-12 修复:`setAuditLogger`(retriever.ts:749)自定义以来**全仓零调用者**, // 而 retriever.ts:897 的 `this.auditLogger?.log({ operation: "retrieve", ... })` // 早就写好了 —— 可选链让它永远静默 no-op。结果是 audit-log.ts 文件头注释声称 // "record every store/update/delete/retrieve operation"、AuditOperation 类型里 // 也列着 "retrieve",而生产 audit.jsonl 里 2841 条记录**零条 retrieve**。 // // 同族的 setAccessTracker / setFrequencyTracker 就在上面两行,都接了,唯独这个漏了。 // 与同日修的 dream 末尾 resetWriteCount 漏传 scope 完全同型:接线漏一处、运行时 // 不报错、静默失效 —— 差别只在那个是漏参数、这个是漏调用。 // // simplified: audit.jsonl 无轮转机制(修复时 644K / 2841 行,约 230 字节一行)。 // 接上 retrieve 后增速会明显上升,因为检索远比写入频繁。 // 升级触发条件:文件超过约 50MB 时加按月轮转,别等到它拖慢 append。 retriever.setAuditLogger(createAuditLogger(join(dataDir, "audit.jsonl"))); // Create LLM client if configured (optional, graceful) let llm: LLMClient | null = null; if (config.llm) { llm = createLLMClient(config.llm); if (llm) { logInfo(`[INFO] LLM client initialized: ${config.llm.model} @ ${config.llm.baseURL}`); } } return { store, embedder, retriever, profile, accessTracker, frequencyTracker, llm }; } export function createStoreOnly(config: LocalMemoryConfig): MemoryStore { const dbPath = resolveDbPath(config); validateStoragePath(dbPath); return new MemoryStore({ dbPath, vectorDim: getVectorDimensions(config.embedding.model, config.embedding.dimensions), }); } export function createComponentResolver(config: LocalMemoryConfig) { const cache = new Map>(); const MAX_COMPONENT_CACHE_SIZE = 32; return function getComponents(profileName?: string) { const key = profileName || "default"; const cached = cache.get(key); if (cached) return cached; // Evict oldest entry if cache is full if (cache.size >= MAX_COMPONENT_CACHE_SIZE) { const firstKey = cache.keys().next().value; if (firstKey !== undefined) cache.delete(firstKey); } const created = createComponents(config, profileName); cache.set(key, created); return created; }; } const VALID_RECALL_MODES: RecallMode[] = ["full", "light", "summary", "off"]; /** * Resolve effective recall mode: per-call override > env var > config > default ("summary"). */ export function resolveRecallMode(config: LocalMemoryConfig, perCallOverride?: string): RecallMode { if (perCallOverride && VALID_RECALL_MODES.includes(perCallOverride as RecallMode)) { return perCallOverride as RecallMode; } const envMode = envConfig.recallModeRaw(); if (envMode && VALID_RECALL_MODES.includes(envMode as RecallMode)) { return envMode as RecallMode; } return config.recallMode ?? "summary"; }