@llmnesia/mcp
Advanced tools
| #!/usr/bin/env node | ||
| import { createRequire as __llmnesiaCreateRequire } from "node:module"; | ||
| const require = __llmnesiaCreateRequire(import.meta.url); | ||
| import { | ||
| VERSION | ||
| } from "./chunk-YYNCL7SQ.js"; | ||
| // src/config.ts | ||
| import { readFileSync } from "fs"; | ||
| import { homedir } from "os"; | ||
| import { join, resolve } from "path"; | ||
| var CORPUS_FORMAT_VERSION = 1; | ||
| function nativeHostConfigPath() { | ||
| return join(homedir(), ".llmnesia", "config.json"); | ||
| } | ||
| function readNativeHostCorpusRoot() { | ||
| try { | ||
| const parsed = JSON.parse(readFileSync(nativeHostConfigPath(), "utf8")); | ||
| return typeof parsed.corpusRoot === "string" ? parsed.corpusRoot : void 0; | ||
| } catch { | ||
| return void 0; | ||
| } | ||
| } | ||
| function resolveCorpusDir(explicit) { | ||
| const raw = explicit ?? process.env.LLMNESIA_CORPUS_DIR ?? readNativeHostCorpusRoot() ?? "~/.llmnesia/corpus"; | ||
| return expandHome(raw); | ||
| } | ||
| function expandHome(p) { | ||
| if (p === "~") { | ||
| return homedir(); | ||
| } | ||
| if (p.startsWith("~/") || p.startsWith("~\\")) { | ||
| return resolve(homedir(), p.slice(2)); | ||
| } | ||
| return resolve(p); | ||
| } | ||
| // src/corpus.ts | ||
| import { promises as fs } from "fs"; | ||
| import { dirname, join as join3 } from "path"; | ||
| // src/paths.ts | ||
| import { join as join2 } from "path"; | ||
| import { createHash } from "crypto"; | ||
| var CorpusPaths = class { | ||
| constructor(root) { | ||
| this.root = root; | ||
| } | ||
| root; | ||
| get metaFile() { | ||
| return join2(this.root, "meta.json"); | ||
| } | ||
| get inboxDir() { | ||
| return join2(this.root, "inbox"); | ||
| } | ||
| get processedDir() { | ||
| return join2(this.inboxDir, "processed"); | ||
| } | ||
| get conversationsDir() { | ||
| return join2(this.root, "conversations"); | ||
| } | ||
| get indexDir() { | ||
| return join2(this.root, "index"); | ||
| } | ||
| get indexDbFile() { | ||
| return join2(this.indexDir, "llmnesia.db"); | ||
| } | ||
| /** Directory holding a platform's conversation JSON files. */ | ||
| platformDir(platform) { | ||
| return join2(this.conversationsDir, safeSegment(platform)); | ||
| } | ||
| /** Absolute path to the JSON file backing a given docId. */ | ||
| conversationFile(platform, docId) { | ||
| return join2(this.platformDir(platform), `${slugifyDocId(docId)}.json`); | ||
| } | ||
| }; | ||
| var SLUG_MAX = 120; | ||
| function slugifyDocId(docId) { | ||
| const lossless = docId.length > 0 && docId.length <= SLUG_MAX && /^[a-zA-Z0-9._-]+$/.test(docId); | ||
| if (lossless) { | ||
| return docId; | ||
| } | ||
| const hash = createHash("sha1").update(docId).digest("hex").slice(0, 12); | ||
| const readable = docId.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, SLUG_MAX - 13); | ||
| return readable.length > 0 ? `${readable}-${hash}` : hash; | ||
| } | ||
| function safeSegment(value) { | ||
| const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^[.-]+|[.-]+$/g, ""); | ||
| return cleaned.length > 0 ? cleaned : "unknown"; | ||
| } | ||
| // src/serialize.ts | ||
| function canonicalizeConversation(record) { | ||
| return { | ||
| conversation: canonicalConversationDoc(record.conversation), | ||
| messages: [...record.messages].sort((a, b) => a.msgIndex - b.msgIndex).map(canonicalMessageDoc) | ||
| }; | ||
| } | ||
| function canonicalConversationDoc(c) { | ||
| const out = { | ||
| docId: c.docId, | ||
| platform: c.platform, | ||
| title: c.title, | ||
| url: c.url, | ||
| createdAt: c.createdAt, | ||
| contentUpdatedAt: c.contentUpdatedAt, | ||
| indexedAt: c.indexedAt, | ||
| corpusChangedAt: c.corpusChangedAt, | ||
| updatedAt: c.updatedAt, | ||
| lastSeenAt: c.lastSeenAt, | ||
| messageCount: c.messageCount, | ||
| preview: c.preview, | ||
| hasImages: c.hasImages, | ||
| pinned: c.pinned, | ||
| indexLevel: c.indexLevel, | ||
| estimatedBytes: c.estimatedBytes | ||
| }; | ||
| assignIfDefined(out, "searchDocLength", c.searchDocLength); | ||
| assignIfDefined(out, "contentHash", c.contentHash); | ||
| assignIfDefined(out, "truncated", c.truncated); | ||
| assignIfDefined(out, "kind", c.kind); | ||
| assignIfDefined(out, "sourceId", c.sourceId); | ||
| assignIfDefined(out, "baseUrl", c.baseUrl); | ||
| assignIfDefined(out, "isDeeplinkable", c.isDeeplinkable); | ||
| assignIfDefined(out, "origin", c.origin); | ||
| assignIfDefined(out, "accountId", c.accountId); | ||
| assignIfDefined(out, "accountLabel", c.accountLabel); | ||
| if (c.summary) { | ||
| out.summary = { | ||
| text: c.summary.text, | ||
| recipe: c.summary.recipe, | ||
| method: c.summary.method, | ||
| createdAt: c.summary.createdAt | ||
| }; | ||
| } | ||
| return out; | ||
| } | ||
| function canonicalMessageDoc(m) { | ||
| const out = { | ||
| msgKey: m.msgKey, | ||
| docId: m.docId, | ||
| msgIndex: m.msgIndex, | ||
| role: m.role, | ||
| text: m.text | ||
| }; | ||
| assignIfDefined(out, "formattedText", m.formattedText); | ||
| out.createdAt = m.createdAt; | ||
| out.updatedAt = m.updatedAt; | ||
| out.contentHash = m.contentHash; | ||
| assignIfDefined(out, "platformMessageId", m.platformMessageId); | ||
| return out; | ||
| } | ||
| function assignIfDefined(target, key, value) { | ||
| if (value !== void 0) { | ||
| target[key] = value; | ||
| } | ||
| } | ||
| // src/corpus.ts | ||
| var DIR_MODE = 448; | ||
| var FILE_MODE = 384; | ||
| var Corpus = class { | ||
| paths; | ||
| // Serialize writes within this process so concurrent upserts (inbox drain + | ||
| // MCP save_conversation) don't interleave. Cross-process safety relies on the | ||
| // attended, single-companion v1 model (see PLAN-MCP.md open question 3). | ||
| writeChain = Promise.resolve(); | ||
| constructor(root) { | ||
| this.paths = new CorpusPaths(root); | ||
| } | ||
| /** Create the directory skeleton and meta.json if absent. Idempotent. */ | ||
| async init() { | ||
| await fs.mkdir(this.paths.conversationsDir, { recursive: true, mode: DIR_MODE }); | ||
| await fs.mkdir(this.paths.processedDir, { recursive: true, mode: DIR_MODE }); | ||
| await fs.mkdir(this.paths.indexDir, { recursive: true, mode: DIR_MODE }); | ||
| await hardenDir(this.paths.root); | ||
| await hardenDir(this.paths.conversationsDir); | ||
| await hardenDir(this.paths.inboxDir); | ||
| await hardenDir(this.paths.processedDir); | ||
| await hardenDir(this.paths.indexDir); | ||
| try { | ||
| await fs.access(this.paths.metaFile); | ||
| } catch { | ||
| const meta = { | ||
| formatVersion: CORPUS_FORMAT_VERSION, | ||
| createdAt: Date.now(), | ||
| lastIngestAt: null, | ||
| conversationCount: 0 | ||
| }; | ||
| await writeFileAtomic(this.paths.metaFile, `${JSON.stringify(meta, null, 2)} | ||
| `); | ||
| } | ||
| } | ||
| async readMeta() { | ||
| try { | ||
| const raw = await fs.readFile(this.paths.metaFile, "utf8"); | ||
| return JSON.parse(raw); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async writeMeta(patch) { | ||
| const current = await this.readMeta() ?? { | ||
| formatVersion: CORPUS_FORMAT_VERSION, | ||
| createdAt: Date.now(), | ||
| lastIngestAt: null, | ||
| conversationCount: 0 | ||
| }; | ||
| const next = { ...current, ...patch }; | ||
| await writeFileAtomic(this.paths.metaFile, `${JSON.stringify(next, null, 2)} | ||
| `); | ||
| } | ||
| /** | ||
| * Insert or replace a conversation, keyed on docId. New bridge records use | ||
| * the local `corpusChangedAt` mutation clock; older records fall back to the | ||
| * source `updatedAt`. If mutation clocks tie, source freshness breaks the | ||
| * tie. Re-writing identical content remains a byte-identical no-op. | ||
| */ | ||
| async upsert(record) { | ||
| return this.enqueueWrite(async () => { | ||
| const canonical = canonicalizeConversation(record); | ||
| const { conversation } = canonical; | ||
| const file = this.paths.conversationFile(conversation.platform, conversation.docId); | ||
| const existing = await readJsonIfExists(file); | ||
| if (existing) { | ||
| const existingChangedAt = existing.conversation.corpusChangedAt ?? existing.conversation.updatedAt ?? 0; | ||
| const incomingChangedAt = conversation.corpusChangedAt ?? conversation.updatedAt ?? 0; | ||
| const existingUpdatedAt = existing.conversation.updatedAt ?? 0; | ||
| const incomingUpdatedAt = conversation.updatedAt ?? 0; | ||
| if (incomingChangedAt < existingChangedAt || incomingChangedAt === existingChangedAt && incomingUpdatedAt < existingUpdatedAt) { | ||
| return { docId: conversation.docId, outcome: "skipped" }; | ||
| } | ||
| } | ||
| const serialized = `${JSON.stringify(canonical, null, 2)} | ||
| `; | ||
| if (existing) { | ||
| const existingSerialized = `${JSON.stringify(canonicalizeConversation(existing), null, 2)} | ||
| `; | ||
| if (existingSerialized === serialized) { | ||
| return { docId: conversation.docId, outcome: "skipped" }; | ||
| } | ||
| } | ||
| await fs.mkdir(dirname(file), { recursive: true, mode: DIR_MODE }); | ||
| await writeFileAtomic(file, serialized); | ||
| return { docId: conversation.docId, outcome: existing ? "updated" : "created" }; | ||
| }); | ||
| } | ||
| /** Read a conversation by docId, scanning platform dirs for the slug file. */ | ||
| async get(docId) { | ||
| const filename = `${slugifyDocId(docId)}.json`; | ||
| for (const platform of await this.listPlatforms()) { | ||
| const candidate = join3(this.paths.platformDir(platform), filename); | ||
| const record = await readJsonIfExists(candidate); | ||
| if (record && record.conversation.docId === docId) { | ||
| return record; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| /** Platform subdirectory names under conversations/. */ | ||
| async listPlatforms() { | ||
| try { | ||
| const entries = await fs.readdir(this.paths.conversationsDir, { withFileTypes: true }); | ||
| return entries.filter((e) => e.isDirectory()).map((e) => e.name); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| /** Async-iterate every stored conversation (used by reindex + stats). */ | ||
| async *iterate() { | ||
| for (const platform of await this.listPlatforms()) { | ||
| const dir = this.paths.platformDir(platform); | ||
| let files; | ||
| try { | ||
| files = await fs.readdir(dir); | ||
| } catch { | ||
| continue; | ||
| } | ||
| for (const name of files) { | ||
| if (!name.endsWith(".json")) { | ||
| continue; | ||
| } | ||
| const record = await readJsonIfExists(join3(dir, name)); | ||
| if (record) { | ||
| yield record; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| async stats() { | ||
| const byPlatform = {}; | ||
| let conversationCount = 0; | ||
| let messageCount = 0; | ||
| for await (const record of this.iterate()) { | ||
| conversationCount += 1; | ||
| messageCount += record.messages.length; | ||
| const platform = safeSegment(record.conversation.platform); | ||
| byPlatform[platform] = (byPlatform[platform] ?? 0) + 1; | ||
| } | ||
| return { conversationCount, messageCount, byPlatform }; | ||
| } | ||
| /** Refresh meta.json's conversationCount / lastIngestAt after an ingest run. */ | ||
| async touchIngestMeta() { | ||
| const { conversationCount } = await this.stats(); | ||
| await this.writeMeta({ lastIngestAt: Date.now(), conversationCount }); | ||
| } | ||
| enqueueWrite(fn) { | ||
| const run = this.writeChain.then(fn, fn); | ||
| this.writeChain = run.then( | ||
| () => void 0, | ||
| () => void 0 | ||
| ); | ||
| return run; | ||
| } | ||
| }; | ||
| async function readJsonIfExists(file) { | ||
| try { | ||
| const raw = await fs.readFile(file, "utf8"); | ||
| return JSON.parse(raw); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function writeFileAtomic(file, contents) { | ||
| const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; | ||
| await fs.writeFile(tmp, contents, { encoding: "utf8", mode: FILE_MODE }); | ||
| await fs.rename(tmp, file); | ||
| } | ||
| async function hardenDir(dir) { | ||
| try { | ||
| await fs.chmod(dir, DIR_MODE); | ||
| } catch { | ||
| } | ||
| } | ||
| // src/ingest.ts | ||
| import { promises as fs2, createReadStream } from "fs"; | ||
| import { join as join4 } from "path"; | ||
| import { createInterface } from "readline"; | ||
| import { createGunzip } from "zlib"; | ||
| var EMPTY_REPORT = { | ||
| conversations: 0, | ||
| created: 0, | ||
| updated: 0, | ||
| skipped: 0, | ||
| messages: 0, | ||
| malformedLines: 0 | ||
| }; | ||
| async function ingestFile(corpus, file, index) { | ||
| const report = { ...EMPTY_REPORT }; | ||
| let current = null; | ||
| let buffered = []; | ||
| const flush = async () => { | ||
| if (!current) { | ||
| return; | ||
| } | ||
| const record = { conversation: current, messages: buffered }; | ||
| const result = await corpus.upsert(record); | ||
| tally(report, result); | ||
| if (index && result.outcome !== "skipped") { | ||
| index.upsertConversation(record); | ||
| } | ||
| report.conversations += 1; | ||
| report.messages += buffered.length; | ||
| current = null; | ||
| buffered = []; | ||
| }; | ||
| const rl = createInterface({ input: openLineSource(file), crlfDelay: Infinity }); | ||
| for await (const raw of rl) { | ||
| const line = raw.trim(); | ||
| if (!line) { | ||
| continue; | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(line); | ||
| } catch { | ||
| report.malformedLines += 1; | ||
| continue; | ||
| } | ||
| if (typeof parsed !== "object" || parsed === null) { | ||
| report.malformedLines += 1; | ||
| continue; | ||
| } | ||
| const record = parsed; | ||
| const type = record.type; | ||
| if (type === "meta") { | ||
| continue; | ||
| } | ||
| if (type === "conversation") { | ||
| await flush(); | ||
| current = coerceConversation(record.conversation); | ||
| buffered = []; | ||
| if (!current) { | ||
| report.malformedLines += 1; | ||
| } | ||
| continue; | ||
| } | ||
| if (type === "message" && current) { | ||
| const docId = typeof record.docId === "string" ? record.docId : ""; | ||
| if (docId !== current.docId) { | ||
| report.malformedLines += 1; | ||
| continue; | ||
| } | ||
| const message = coerceMessage(current.docId, record.message); | ||
| if (message) { | ||
| buffered.push(message); | ||
| } else { | ||
| report.malformedLines += 1; | ||
| } | ||
| } | ||
| } | ||
| await flush(); | ||
| return report; | ||
| } | ||
| async function pendingInboxFiles(corpus) { | ||
| try { | ||
| return (await fs2.readdir(corpus.paths.inboxDir, { withFileTypes: true })).filter((e) => e.isFile() && isNdjson(e.name)).map((e) => e.name).sort(); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| async function drainInbox(corpus, index, options = {}) { | ||
| await corpus.init(); | ||
| const inbox = corpus.paths.inboxDir; | ||
| const entries = await pendingInboxFiles(corpus); | ||
| const total = { ...EMPTY_REPORT, files: 0, failed: 0 }; | ||
| for (const name of entries) { | ||
| const src = join4(inbox, name); | ||
| try { | ||
| const report = await ingestFile(corpus, src, index); | ||
| accumulate(total, report); | ||
| total.files += 1; | ||
| await moveToProcessed(corpus, src, name); | ||
| } catch (error) { | ||
| if (await stillPending(src)) { | ||
| total.failed += 1; | ||
| options.onFileError?.(name, error); | ||
| } | ||
| } | ||
| } | ||
| if (total.files > 0) { | ||
| await corpus.touchIngestMeta(); | ||
| } | ||
| return total; | ||
| } | ||
| async function stillPending(src) { | ||
| try { | ||
| await fs2.access(src); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| async function moveToProcessed(corpus, src, name) { | ||
| const dest = join4(corpus.paths.processedDir, name); | ||
| try { | ||
| await fs2.rename(src, dest); | ||
| } catch { | ||
| const alt = join4(corpus.paths.processedDir, `${Date.now()}-${name}`); | ||
| await fs2.copyFile(src, alt); | ||
| await fs2.unlink(src); | ||
| } | ||
| } | ||
| function isNdjson(name) { | ||
| const lower = name.toLowerCase(); | ||
| return lower.endsWith(".jsonl") || lower.endsWith(".ndjson") || lower.endsWith(".jsonl.gz") || lower.endsWith(".ndjson.gz"); | ||
| } | ||
| function openLineSource(file) { | ||
| const stream = createReadStream(file); | ||
| if (file.toLowerCase().endsWith(".gz")) { | ||
| return stream.pipe(createGunzip()); | ||
| } | ||
| return stream; | ||
| } | ||
| function coerceConversation(value) { | ||
| if (!value || typeof value !== "object") { | ||
| return null; | ||
| } | ||
| const c = value; | ||
| if (typeof c.docId !== "string" || !c.docId) { | ||
| return null; | ||
| } | ||
| const conversation = { ...c }; | ||
| if (typeof conversation.updatedAt !== "number") { | ||
| conversation.updatedAt = typeof conversation.contentUpdatedAt === "number" ? conversation.contentUpdatedAt : 0; | ||
| } | ||
| return conversation; | ||
| } | ||
| function coerceMessage(docId, value) { | ||
| if (!value || typeof value !== "object") { | ||
| return null; | ||
| } | ||
| const m = value; | ||
| const text = typeof m.text === "string" ? m.text : ""; | ||
| if (!text.trim()) { | ||
| return null; | ||
| } | ||
| const msgIndex = typeof m.msgIndex === "number" ? m.msgIndex : 0; | ||
| const message = { | ||
| // The backup line omits msgKey; reconstruct the extension's convention. | ||
| msgKey: `${docId}:${msgIndex}`, | ||
| docId, | ||
| msgIndex, | ||
| role: coerceRole(m.role), | ||
| text, | ||
| createdAt: typeof m.createdAt === "number" ? m.createdAt : 0, | ||
| updatedAt: typeof m.updatedAt === "number" ? m.updatedAt : 0, | ||
| contentHash: typeof m.contentHash === "string" ? m.contentHash : "" | ||
| }; | ||
| if (typeof m.formattedText === "string") { | ||
| message.formattedText = m.formattedText; | ||
| } | ||
| if (typeof m.platformMessageId === "string") { | ||
| message.platformMessageId = m.platformMessageId; | ||
| } else if (m.platformMessageId === null) { | ||
| message.platformMessageId = null; | ||
| } | ||
| return message; | ||
| } | ||
| function coerceRole(value) { | ||
| return value === "assistant" || value === "user" || value === "system" || value === "unknown" ? value : "unknown"; | ||
| } | ||
| function tally(report, result) { | ||
| if (result.outcome === "created") { | ||
| report.created += 1; | ||
| } else if (result.outcome === "updated") { | ||
| report.updated += 1; | ||
| } else { | ||
| report.skipped += 1; | ||
| } | ||
| } | ||
| function accumulate(total, report) { | ||
| total.conversations += report.conversations; | ||
| total.created += report.created; | ||
| total.updated += report.updated; | ||
| total.skipped += report.skipped; | ||
| total.messages += report.messages; | ||
| total.malformedLines += report.malformedLines; | ||
| } | ||
| // src/searchIndex.ts | ||
| import { promises as fs3, chmodSync, closeSync, openSync } from "fs"; | ||
| import { createRequire } from "module"; | ||
| // src/sqliteWarning.ts | ||
| var INSTALLED = /* @__PURE__ */ Symbol.for("llmnesia.sqliteWarningFilterInstalled"); | ||
| function isSqliteExperimentalWarning(warning) { | ||
| return warning.name === "ExperimentalWarning" && /\bSQLite\b/i.test(warning.message); | ||
| } | ||
| function install() { | ||
| const flagged = globalThis; | ||
| if (flagged[INSTALLED]) { | ||
| return; | ||
| } | ||
| flagged[INSTALLED] = true; | ||
| const previous = process.listeners("warning"); | ||
| process.removeAllListeners("warning"); | ||
| process.on("warning", (warning) => { | ||
| if (isSqliteExperimentalWarning(warning)) { | ||
| return; | ||
| } | ||
| for (const listener of previous) { | ||
| listener.call(process, warning); | ||
| } | ||
| }); | ||
| } | ||
| install(); | ||
| // src/searchIndex.ts | ||
| var nodeRequire = createRequire(import.meta.url); | ||
| var sqlite; | ||
| function loadSqlite() { | ||
| return sqlite ??= nodeRequire("node:sqlite"); | ||
| } | ||
| var DB_FILE_MODE = 384; | ||
| function hardenDbFile(dbPath) { | ||
| try { | ||
| closeSync(openSync(dbPath, "a", DB_FILE_MODE)); | ||
| chmodSync(dbPath, DB_FILE_MODE); | ||
| } catch { | ||
| } | ||
| } | ||
| var INDEX_FORMAT_VERSION = 1; | ||
| var TITLE_ROW_INDEX = -1; | ||
| var TITLE_ROLE = "title"; | ||
| var DEFAULT_LIMIT = 10; | ||
| var SNIPPET_TOKENS = 12; | ||
| var SCHEMA_SQL = ` | ||
| CREATE TABLE IF NOT EXISTS index_meta ( | ||
| key TEXT PRIMARY KEY, | ||
| value TEXT NOT NULL | ||
| ); | ||
| CREATE TABLE IF NOT EXISTS conversations ( | ||
| docId TEXT PRIMARY KEY, | ||
| platform TEXT NOT NULL, | ||
| title TEXT NOT NULL, | ||
| url TEXT NOT NULL, | ||
| createdAt INTEGER NOT NULL, | ||
| updatedAt INTEGER NOT NULL, | ||
| messageCount INTEGER NOT NULL, | ||
| preview TEXT NOT NULL, | ||
| pinned INTEGER NOT NULL | ||
| ); | ||
| CREATE INDEX IF NOT EXISTS idx_conversations_platform ON conversations(platform); | ||
| CREATE INDEX IF NOT EXISTS idx_conversations_createdAt ON conversations(createdAt); | ||
| CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( | ||
| docId UNINDEXED, | ||
| msgIndex UNINDEXED, | ||
| role UNINDEXED, | ||
| text, | ||
| tokenize = 'porter unicode61' | ||
| ); | ||
| `; | ||
| var SearchIndex = class { | ||
| db; | ||
| stmts; | ||
| constructor(dbPath) { | ||
| hardenDbFile(dbPath); | ||
| this.db = new (loadSqlite()).DatabaseSync(dbPath); | ||
| this.db.exec("PRAGMA journal_mode = WAL"); | ||
| this.db.exec("PRAGMA foreign_keys = OFF"); | ||
| this.db.exec(SCHEMA_SQL); | ||
| this.setMeta("format_version", String(INDEX_FORMAT_VERSION)); | ||
| this.stmts = { | ||
| deleteFts: this.db.prepare("DELETE FROM messages_fts WHERE docId = ?"), | ||
| insertFts: this.db.prepare( | ||
| "INSERT INTO messages_fts (docId, msgIndex, role, text) VALUES (?, ?, ?, ?)" | ||
| ), | ||
| deleteConversation: this.db.prepare("DELETE FROM conversations WHERE docId = ?"), | ||
| insertConversation: this.db.prepare( | ||
| `INSERT INTO conversations | ||
| (docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned) | ||
| VALUES (@docId, @platform, @title, @url, @createdAt, @updatedAt, @messageCount, @preview, @pinned)` | ||
| ) | ||
| }; | ||
| } | ||
| /** True when the stored format version is missing or stale — caller rebuilds. */ | ||
| needsRebuild() { | ||
| const stored = this.getMeta("format_version"); | ||
| return stored !== String(INDEX_FORMAT_VERSION); | ||
| } | ||
| /** Number of indexed conversations. */ | ||
| count() { | ||
| const row = this.db.prepare("SELECT COUNT(*) AS n FROM conversations").get(); | ||
| return row.n; | ||
| } | ||
| /** Most-recently-updated conversations (metadata only), newest first. */ | ||
| listRecent(n) { | ||
| return this.listConversations(n, "recent"); | ||
| } | ||
| /** Conversation metadata in a caller-selected, deterministic chronology. */ | ||
| listConversations(n, sort) { | ||
| const limit = Math.max(1, n); | ||
| const orderBy = sort === "oldest" ? "createdAt ASC, updatedAt ASC" : sort === "newest" ? "createdAt DESC, updatedAt DESC" : "updatedAt DESC, createdAt DESC"; | ||
| const rows = this.db.prepare( | ||
| `SELECT docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned | ||
| FROM conversations ORDER BY ${orderBy} LIMIT ?` | ||
| ).all(limit); | ||
| return rows.map((r) => ({ ...r, pinned: r.pinned === 1 })); | ||
| } | ||
| /** | ||
| * Run `fn` inside a transaction, rolling back if it throws. node:sqlite has | ||
| * no transaction() wrapper, so this is the one place BEGIN/COMMIT/ROLLBACK | ||
| * lives for synchronous writes; {@link rebuildFrom} spells it out separately | ||
| * because its body is async and cannot be expressed as a sync callback. | ||
| */ | ||
| transaction(fn) { | ||
| this.db.exec("BEGIN"); | ||
| try { | ||
| const result = fn(); | ||
| this.db.exec("COMMIT"); | ||
| return result; | ||
| } catch (err) { | ||
| this.db.exec("ROLLBACK"); | ||
| throw err; | ||
| } | ||
| } | ||
| upsertConversation(record) { | ||
| this.transaction(() => this.writeRecord(record)); | ||
| } | ||
| removeConversation(docId) { | ||
| this.transaction(() => { | ||
| this.stmts.deleteFts.run(docId); | ||
| this.stmts.deleteConversation.run(docId); | ||
| }); | ||
| } | ||
| /** | ||
| * Clear the index and repopulate it from the corpus — proving the corpus is | ||
| * the source of truth. Uses one manual transaction spanning the async file | ||
| * reads, which the sync {@link transaction} helper cannot hold. | ||
| */ | ||
| async rebuildFrom(corpus) { | ||
| this.db.exec("DELETE FROM messages_fts; DELETE FROM conversations;"); | ||
| let conversations = 0; | ||
| this.db.exec("BEGIN"); | ||
| try { | ||
| for await (const record of corpus.iterate()) { | ||
| this.writeRecord(record); | ||
| conversations += 1; | ||
| } | ||
| this.db.exec("COMMIT"); | ||
| } catch (err) { | ||
| this.db.exec("ROLLBACK"); | ||
| throw err; | ||
| } | ||
| return { conversations }; | ||
| } | ||
| // Replace a conversation's index rows. Caller supplies the transaction (the | ||
| // transaction() helper for single upserts, or rebuildFrom's manual one). | ||
| writeRecord(rec) { | ||
| const { conversation, messages } = rec; | ||
| this.stmts.deleteFts.run(conversation.docId); | ||
| this.stmts.deleteConversation.run(conversation.docId); | ||
| const titleText = [conversation.title, conversation.preview].filter(Boolean).join("\n"); | ||
| if (titleText.trim()) { | ||
| this.stmts.insertFts.run(conversation.docId, TITLE_ROW_INDEX, TITLE_ROLE, titleText); | ||
| } | ||
| for (const message of messages) { | ||
| if (message.text.trim()) { | ||
| this.stmts.insertFts.run(conversation.docId, message.msgIndex, message.role, message.text); | ||
| } | ||
| } | ||
| this.stmts.insertConversation.run({ | ||
| docId: conversation.docId, | ||
| platform: conversation.platform, | ||
| title: conversation.title, | ||
| url: conversation.url, | ||
| createdAt: conversation.createdAt, | ||
| updatedAt: conversation.updatedAt ?? conversation.contentUpdatedAt ?? 0, | ||
| messageCount: conversation.messageCount ?? messages.length, | ||
| preview: conversation.preview ?? "", | ||
| pinned: conversation.pinned ? 1 : 0 | ||
| }); | ||
| } | ||
| search(query, filters = {}) { | ||
| const match = toMatchQuery(query, filters.matchMode); | ||
| if (!match) { | ||
| return []; | ||
| } | ||
| const limit = Math.max(1, filters.limit ?? DEFAULT_LIMIT); | ||
| const clauses = ["messages_fts MATCH @match"]; | ||
| const params = { match }; | ||
| if (filters.platform) { | ||
| clauses.push("c.platform = @platform"); | ||
| params.platform = filters.platform; | ||
| } | ||
| if (filters.title) { | ||
| clauses.push("c.title LIKE @title ESCAPE '\\'"); | ||
| params.title = `%${escapeLike(filters.title)}%`; | ||
| } | ||
| if (typeof filters.dateFrom === "number") { | ||
| clauses.push("c.createdAt >= @dateFrom"); | ||
| params.dateFrom = filters.dateFrom; | ||
| } | ||
| if (typeof filters.dateTo === "number") { | ||
| clauses.push("c.createdAt <= @dateTo"); | ||
| params.dateTo = filters.dateTo; | ||
| } | ||
| const scanCap = Math.min(2e3, Math.max(200, limit * 40)); | ||
| const sql = ` | ||
| SELECT | ||
| c.docId, c.platform, c.title, c.url, c.createdAt, c.updatedAt, | ||
| c.messageCount, c.preview, c.pinned, | ||
| m.msgIndex AS msgIndex, m.role AS role, | ||
| snippet(messages_fts, 3, '[', ']', '\u2026', ${SNIPPET_TOKENS}) AS snippet, | ||
| bm25(messages_fts) AS bm25 | ||
| FROM messages_fts m | ||
| JOIN conversations c ON c.docId = m.docId | ||
| WHERE ${clauses.join(" AND ")} | ||
| ORDER BY bm25 | ||
| LIMIT @scanCap | ||
| `; | ||
| const rows = this.db.prepare(sql).all({ ...params, scanCap }); | ||
| const seen = /* @__PURE__ */ new Set(); | ||
| const hits = []; | ||
| for (const row of rows) { | ||
| if (seen.has(row.docId)) { | ||
| continue; | ||
| } | ||
| seen.add(row.docId); | ||
| hits.push({ | ||
| docId: row.docId, | ||
| platform: row.platform, | ||
| title: row.title, | ||
| url: row.url, | ||
| createdAt: row.createdAt, | ||
| updatedAt: row.updatedAt, | ||
| messageCount: row.messageCount, | ||
| preview: row.preview, | ||
| pinned: row.pinned === 1, | ||
| // BM25 is negative (more negative = better); expose a positive relevance. | ||
| score: -row.bm25, | ||
| snippet: row.snippet, | ||
| matchRole: normalizeMatchRole(row.role), | ||
| matchMsgIndex: row.msgIndex | ||
| }); | ||
| if (hits.length >= limit) { | ||
| break; | ||
| } | ||
| } | ||
| return hits; | ||
| } | ||
| close() { | ||
| this.db.close(); | ||
| } | ||
| getMeta(key) { | ||
| const row = this.db.prepare("SELECT value FROM index_meta WHERE key = ?").get(key); | ||
| return row ? row.value : null; | ||
| } | ||
| setMeta(key, value) { | ||
| this.db.prepare("INSERT INTO index_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value); | ||
| } | ||
| }; | ||
| async function openSearchIndex(corpus, options = {}) { | ||
| await corpus.init(); | ||
| const index = new SearchIndex(corpus.paths.indexDbFile); | ||
| if (options.autoBuild !== false && (index.needsRebuild() || index.count() === 0)) { | ||
| await index.rebuildFrom(corpus); | ||
| } | ||
| return index; | ||
| } | ||
| async function deleteIndexDb(dbFile) { | ||
| for (const suffix of ["", "-wal", "-shm"]) { | ||
| await fs3.rm(`${dbFile}${suffix}`, { force: true }); | ||
| } | ||
| } | ||
| function toMatchQuery(query, mode = "any") { | ||
| const terms = query.split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 0); | ||
| if (terms.length === 0) { | ||
| return null; | ||
| } | ||
| if (mode === "phrase") { | ||
| return `"${terms.join(" ")}"`; | ||
| } | ||
| return terms.map((t) => `"${t}"`).join(mode === "all" ? " AND " : " OR "); | ||
| } | ||
| function escapeLike(value) { | ||
| return value.replace(/[\\%_]/g, "\\$&"); | ||
| } | ||
| function normalizeMatchRole(role) { | ||
| if (role === "title") { | ||
| return "title"; | ||
| } | ||
| return role === "assistant" || role === "user" || role === "system" ? role : "unknown"; | ||
| } | ||
| // src/runtimeInstall.ts | ||
| import { cp, mkdir, readdir, rename, rm, writeFile } from "fs/promises"; | ||
| import { existsSync } from "fs"; | ||
| import { homedir as homedir2 } from "os"; | ||
| import { dirname as dirname2, join as join5 } from "path"; | ||
| import { fileURLToPath } from "url"; | ||
| function defaultSourceDir() { | ||
| return dirname2(fileURLToPath(import.meta.url)); | ||
| } | ||
| function runtimeBinDir(home = homedir2()) { | ||
| return join5(home, ".llmnesia", "bin"); | ||
| } | ||
| async function installRuntime(options = {}) { | ||
| const home = options.home ?? homedir2(); | ||
| const sourceDir = options.sourceDir ?? defaultSourceDir(); | ||
| const nodePath = options.nodePath ?? process.execPath; | ||
| const binDir = runtimeBinDir(home); | ||
| const sourceEntry = join5(sourceDir, "cli.js"); | ||
| if (!existsSync(sourceEntry)) { | ||
| throw new Error( | ||
| `Cannot locate the built runtime: no cli.js in ${sourceDir}. Run this from an installed package (npx -y @llmnesia/mcp@latest install) or build first (npm run build).` | ||
| ); | ||
| } | ||
| const parentDir = dirname2(binDir); | ||
| const nonce = `${process.pid}-${Date.now()}`; | ||
| const stagingDir = join5(parentDir, `.bin-install-${nonce}`); | ||
| const backupDir = join5(parentDir, `.bin-backup-${nonce}`); | ||
| await mkdir(parentDir, { recursive: true }); | ||
| await rm(stagingDir, { recursive: true, force: true }); | ||
| await rm(backupDir, { recursive: true, force: true }); | ||
| await mkdir(stagingDir, { recursive: true }); | ||
| try { | ||
| for (const name of await readdir(sourceDir)) { | ||
| await cp(join5(sourceDir, name), join5(stagingDir, name), { recursive: true }); | ||
| } | ||
| await writeFile( | ||
| join5(stagingDir, "package.json"), | ||
| `${JSON.stringify( | ||
| { | ||
| name: "llmnesia-mcp-runtime", | ||
| private: true, | ||
| type: "module", | ||
| // Stamped so the installed build is identifiable without spawning it. | ||
| // Its absence is how a machine sat on 0.1.3 while npm was ten | ||
| // releases ahead and nothing, doctor included, could say so. | ||
| version: options.version ?? VERSION, | ||
| comment: "Installed copy of @llmnesia/mcp's dist. Managed by the LLMnesia installer; edits are lost on reinstall." | ||
| }, | ||
| null, | ||
| 2 | ||
| )} | ||
| `, | ||
| "utf8" | ||
| ); | ||
| const hadPreviousRuntime = existsSync(binDir); | ||
| if (hadPreviousRuntime) { | ||
| await rename(binDir, backupDir); | ||
| } | ||
| try { | ||
| await rename(stagingDir, binDir); | ||
| } catch (err) { | ||
| if (hadPreviousRuntime && !existsSync(binDir) && existsSync(backupDir)) { | ||
| await rename(backupDir, binDir); | ||
| } | ||
| throw err; | ||
| } | ||
| await rm(backupDir, { recursive: true, force: true }); | ||
| } finally { | ||
| await rm(stagingDir, { recursive: true, force: true }); | ||
| } | ||
| const entry = join5(binDir, "cli.js"); | ||
| return { | ||
| binDir, | ||
| entry, | ||
| serve: { command: nodePath, args: [entry, "serve"] }, | ||
| sourceDir, | ||
| nodePath | ||
| }; | ||
| } | ||
| async function uninstallRuntime(home = homedir2()) { | ||
| const binDir = runtimeBinDir(home); | ||
| if (!existsSync(binDir)) { | ||
| return null; | ||
| } | ||
| await rm(binDir, { recursive: true, force: true }); | ||
| return binDir; | ||
| } | ||
| // src/selfUpdate.ts | ||
| import { spawn } from "child_process"; | ||
| import { mkdtemp, readFile, rm as rm2, writeFile as writeFile2 } from "fs/promises"; | ||
| import { dirname as dirname3, join as join6 } from "path"; | ||
| import { homedir as homedir3, tmpdir } from "os"; | ||
| import { fileURLToPath as fileURLToPath2 } from "url"; | ||
| var PACKAGE_NAME = "@llmnesia/mcp"; | ||
| var REGISTRY_LATEST_URL = "https://registry.npmjs.org/@llmnesia%2Fmcp/latest"; | ||
| var UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3; | ||
| var REGISTRY_TIMEOUT_MS = 5e3; | ||
| var INSTALL_TIMEOUT_MS = 12e4; | ||
| var UPDATE_CHECK_DELAY_MS = 3e4; | ||
| function stateFile(home) { | ||
| return join6(home, ".llmnesia", "update-state.json"); | ||
| } | ||
| async function readState(home) { | ||
| try { | ||
| const parsed = JSON.parse(await readFile(stateFile(home), "utf8")); | ||
| return typeof parsed === "object" && parsed !== null ? parsed : {}; | ||
| } catch { | ||
| return {}; | ||
| } | ||
| } | ||
| async function writeState(home, state) { | ||
| try { | ||
| await writeFile2(stateFile(home), `${JSON.stringify(state, null, 2)} | ||
| `, "utf8"); | ||
| } catch { | ||
| } | ||
| } | ||
| function compareVersions(a, b) { | ||
| const parse = (v) => { | ||
| const match = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim()); | ||
| return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : []; | ||
| }; | ||
| const left = parse(a); | ||
| const right = parse(b); | ||
| if (left.length === 0 || right.length === 0) { | ||
| return 0; | ||
| } | ||
| for (let i = 0; i < 3; i += 1) { | ||
| if (left[i] !== right[i]) { | ||
| return left[i] - right[i]; | ||
| } | ||
| } | ||
| return 0; | ||
| } | ||
| function isStableRelease(version) { | ||
| return /^\d+\.\d+\.\d+$/.test(version.trim()); | ||
| } | ||
| function isSelfUpdateDisabled(env = process.env) { | ||
| const raw = env.LLMNESIA_NO_AUTO_UPDATE; | ||
| return raw === "1" || raw === "true"; | ||
| } | ||
| async function fetchLatestFromRegistry() { | ||
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS); | ||
| try { | ||
| const response = await fetch(REGISTRY_LATEST_URL, { | ||
| signal: controller.signal, | ||
| headers: { accept: "application/json" } | ||
| }); | ||
| if (!response.ok) { | ||
| throw new Error(`registry responded ${response.status}`); | ||
| } | ||
| const body = await response.json(); | ||
| if (typeof body.version !== "string") { | ||
| throw new Error("registry response had no version"); | ||
| } | ||
| return body.version; | ||
| } finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
| function npmCommand() { | ||
| const binDir = dirname3(process.execPath); | ||
| return process.platform === "win32" ? join6(binDir, "npm.cmd") : join6(binDir, "npm"); | ||
| } | ||
| async function npmDownload(version) { | ||
| const prefix = await mkdtemp(join6(tmpdir(), "llmnesia-update-")); | ||
| await new Promise((resolve2, reject) => { | ||
| const child = spawn( | ||
| npmCommand(), | ||
| [ | ||
| "install", | ||
| `${PACKAGE_NAME}@${version}`, | ||
| "--prefix", | ||
| prefix, | ||
| "--no-audit", | ||
| "--no-fund", | ||
| "--no-save", | ||
| "--loglevel", | ||
| "error" | ||
| ], | ||
| { stdio: ["ignore", "ignore", "pipe"], windowsHide: true } | ||
| ); | ||
| let stderr = ""; | ||
| child.stderr?.on("data", (chunk) => { | ||
| stderr += String(chunk); | ||
| }); | ||
| const timer = setTimeout(() => child.kill(), INSTALL_TIMEOUT_MS); | ||
| child.once("error", (err) => { | ||
| clearTimeout(timer); | ||
| reject(err); | ||
| }); | ||
| child.once("close", (code) => { | ||
| clearTimeout(timer); | ||
| if (code === 0) { | ||
| resolve2(); | ||
| } else { | ||
| reject(new Error(`npm install exited ${code}${stderr ? `: ${stderr.trim()}` : ""}`)); | ||
| } | ||
| }); | ||
| }); | ||
| return join6(prefix, "node_modules", PACKAGE_NAME, "dist"); | ||
| } | ||
| async function maybeSelfUpdate(options = {}) { | ||
| const env = options.env ?? process.env; | ||
| if (isSelfUpdateDisabled(env)) { | ||
| return { action: "disabled" }; | ||
| } | ||
| const home = options.home ?? homedir3(); | ||
| const currentVersion = options.currentVersion ?? VERSION; | ||
| const runningDir = options.runningDir ?? dirname3(fileURLToPath2(import.meta.url)); | ||
| if (runningDir !== runtimeBinDir(home)) { | ||
| return { action: "not-installed-runtime" }; | ||
| } | ||
| if (!isStableRelease(currentVersion)) { | ||
| return { action: "not-a-release-build", currentVersion }; | ||
| } | ||
| const now = options.now ?? Date.now; | ||
| const state = await readState(home); | ||
| if (state.lastCheckedAt !== void 0 && now() - state.lastCheckedAt < UPDATE_CHECK_INTERVAL_MS) { | ||
| return { action: "throttled" }; | ||
| } | ||
| let latest; | ||
| try { | ||
| latest = await (options.fetchLatestVersion ?? fetchLatestFromRegistry)(); | ||
| } catch (err) { | ||
| await writeState(home, { ...state, lastCheckedAt: now() }); | ||
| return { action: "check-failed", error: err instanceof Error ? err.message : String(err) }; | ||
| } | ||
| const checked = { ...state, lastCheckedAt: now(), lastSeenVersion: latest }; | ||
| if (!isStableRelease(latest) || compareVersions(latest, currentVersion) <= 0) { | ||
| await writeState(home, checked); | ||
| return { action: "up-to-date", latest }; | ||
| } | ||
| if (state.lastFailedVersion === latest) { | ||
| await writeState(home, checked); | ||
| return { action: "skipped-failed-before", latest }; | ||
| } | ||
| let sourceDir; | ||
| try { | ||
| sourceDir = await (options.downloadPackage ?? npmDownload)(latest); | ||
| await installRuntime({ home, sourceDir, version: latest }); | ||
| await writeState(home, { ...checked, lastUpdatedTo: latest, lastFailedVersion: void 0 }); | ||
| return { action: "updated", from: currentVersion, to: latest }; | ||
| } catch (err) { | ||
| await writeState(home, { ...checked, lastFailedVersion: latest }); | ||
| return { action: "update-failed", latest, error: err instanceof Error ? err.message : String(err) }; | ||
| } finally { | ||
| if (sourceDir) { | ||
| await rm2(join6(sourceDir, "..", "..", ".."), { recursive: true, force: true }).catch(() => { | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| function scheduleSelfUpdate(options = {}) { | ||
| const { delayMs = UPDATE_CHECK_DELAY_MS, onOutcome, ...rest } = options; | ||
| const timer = setTimeout(() => { | ||
| void maybeSelfUpdate(rest).then( | ||
| (outcome) => onOutcome?.(outcome), | ||
| () => { | ||
| } | ||
| ); | ||
| }, delayMs); | ||
| timer.unref?.(); | ||
| } | ||
| function describeOutcome(outcome) { | ||
| switch (outcome.action) { | ||
| case "updated": | ||
| return `updated ${outcome.from} to ${outcome.to}; it takes effect next launch`; | ||
| case "update-failed": | ||
| return `could not install ${outcome.latest}: ${outcome.error}`; | ||
| case "check-failed": | ||
| return `could not reach the npm registry: ${outcome.error}`; | ||
| case "up-to-date": | ||
| return `already current (latest published is ${outcome.latest})`; | ||
| case "skipped-failed-before": | ||
| return `${outcome.latest} failed to install previously; not retrying automatically`; | ||
| case "throttled": | ||
| return "checked recently"; | ||
| case "disabled": | ||
| return "automatic updates are turned off (LLMNESIA_NO_AUTO_UPDATE)"; | ||
| case "not-installed-runtime": | ||
| return "not running from ~/.llmnesia/bin, so nothing to update"; | ||
| case "not-a-release-build": | ||
| return `running an unreleased build (${outcome.currentVersion}); leaving the installed runtime alone`; | ||
| } | ||
| } | ||
| export { | ||
| resolveCorpusDir, | ||
| Corpus, | ||
| ingestFile, | ||
| pendingInboxFiles, | ||
| drainInbox, | ||
| SearchIndex, | ||
| openSearchIndex, | ||
| deleteIndexDb, | ||
| runtimeBinDir, | ||
| installRuntime, | ||
| uninstallRuntime, | ||
| isSelfUpdateDisabled, | ||
| scheduleSelfUpdate, | ||
| describeOutcome | ||
| }; |
| #!/usr/bin/env node | ||
| import { createRequire as __llmnesiaCreateRequire } from "node:module"; | ||
| const require = __llmnesiaCreateRequire(import.meta.url); | ||
| var __create = Object.create; | ||
| var __defProp = Object.defineProperty; | ||
| var __getOwnPropDesc = Object.getOwnPropertyDescriptor; | ||
| var __getOwnPropNames = Object.getOwnPropertyNames; | ||
| var __getProtoOf = Object.getPrototypeOf; | ||
| var __hasOwnProp = Object.prototype.hasOwnProperty; | ||
| var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { | ||
| get: (a, b) => (typeof require !== "undefined" ? require : a)[b] | ||
| }) : x)(function(x) { | ||
| if (typeof require !== "undefined") return require.apply(this, arguments); | ||
| throw Error('Dynamic require of "' + x + '" is not supported'); | ||
| }); | ||
| var __commonJS = (cb, mod) => function __require2() { | ||
| return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; | ||
| }; | ||
| var __export = (target, all) => { | ||
| for (var name in all) | ||
| __defProp(target, name, { get: all[name], enumerable: true }); | ||
| }; | ||
| var __copyProps = (to, from, except, desc) => { | ||
| if (from && typeof from === "object" || typeof from === "function") { | ||
| for (let key of __getOwnPropNames(from)) | ||
| if (!__hasOwnProp.call(to, key) && key !== except) | ||
| __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); | ||
| } | ||
| return to; | ||
| }; | ||
| var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( | ||
| // If the importer is in node compatibility mode or this is not an ESM | ||
| // file that has been converted to a CommonJS file using a Babel- | ||
| // compatible transform (i.e. "__esModule" has not been set), then set | ||
| // "default" to the CommonJS "module.exports" for node compatibility. | ||
| isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, | ||
| mod | ||
| )); | ||
| // src/version.ts | ||
| var VERSION = true ? "0.1.15" : "0.0.0-dev"; | ||
| export { | ||
| __require, | ||
| __commonJS, | ||
| __export, | ||
| __toESM, | ||
| VERSION | ||
| }; |
Sorry, the diff of this file is too big to display
+2
-2
@@ -6,3 +6,3 @@ #!/usr/bin/env node | ||
| VERSION | ||
| } from "./chunk-QGVH3T5W.js"; | ||
| } from "./chunk-YYNCL7SQ.js"; | ||
@@ -145,3 +145,3 @@ // src/nodeDownload.ts | ||
| } | ||
| const { main } = await import("./cli-I2P4TSZW.js"); | ||
| const { main } = await import("./cli-BQZPGQQX.js"); | ||
| return main(); | ||
@@ -148,0 +148,0 @@ } |
+3
-2
| { | ||
| "name": "@llmnesia/mcp", | ||
| "version": "0.1.14", | ||
| "version": "0.1.15", | ||
| "private": false, | ||
@@ -42,4 +42,5 @@ "type": "module", | ||
| "scripts": { | ||
| "build": "tsup && npm run build:mcpb", | ||
| "build": "node scripts/sync-server-json.mjs && tsup && npm run build:mcpb", | ||
| "build:mcpb": "node scripts/build-mcpb.mjs", | ||
| "version": "node scripts/sync-server-json.mjs", | ||
| "prepack": "npm run build", | ||
@@ -46,0 +47,0 @@ "dev": "tsx src/cli.ts", |
+2
-2
@@ -6,3 +6,3 @@ { | ||
| "description": "Search your AI chat history from Claude, Cursor, Codex, and other MCP clients.", | ||
| "version": "0.1.14", | ||
| "version": "0.1.15", | ||
| "packages": [ | ||
@@ -13,3 +13,3 @@ { | ||
| "identifier": "@llmnesia/mcp", | ||
| "version": "0.1.14", | ||
| "version": "0.1.15", | ||
| "transport": { | ||
@@ -16,0 +16,0 @@ "type": "stdio" |
| #!/usr/bin/env node | ||
| import { createRequire as __llmnesiaCreateRequire } from "node:module"; | ||
| const require = __llmnesiaCreateRequire(import.meta.url); | ||
| import { | ||
| VERSION | ||
| } from "./chunk-QGVH3T5W.js"; | ||
| // src/config.ts | ||
| import { readFileSync } from "fs"; | ||
| import { homedir } from "os"; | ||
| import { join, resolve } from "path"; | ||
| var CORPUS_FORMAT_VERSION = 1; | ||
| function nativeHostConfigPath() { | ||
| return join(homedir(), ".llmnesia", "config.json"); | ||
| } | ||
| function readNativeHostCorpusRoot() { | ||
| try { | ||
| const parsed = JSON.parse(readFileSync(nativeHostConfigPath(), "utf8")); | ||
| return typeof parsed.corpusRoot === "string" ? parsed.corpusRoot : void 0; | ||
| } catch { | ||
| return void 0; | ||
| } | ||
| } | ||
| function resolveCorpusDir(explicit) { | ||
| const raw = explicit ?? process.env.LLMNESIA_CORPUS_DIR ?? readNativeHostCorpusRoot() ?? "~/.llmnesia/corpus"; | ||
| return expandHome(raw); | ||
| } | ||
| function expandHome(p) { | ||
| if (p === "~") { | ||
| return homedir(); | ||
| } | ||
| if (p.startsWith("~/") || p.startsWith("~\\")) { | ||
| return resolve(homedir(), p.slice(2)); | ||
| } | ||
| return resolve(p); | ||
| } | ||
| // src/corpus.ts | ||
| import { promises as fs } from "fs"; | ||
| import { dirname, join as join3 } from "path"; | ||
| // src/paths.ts | ||
| import { join as join2 } from "path"; | ||
| import { createHash } from "crypto"; | ||
| var CorpusPaths = class { | ||
| constructor(root) { | ||
| this.root = root; | ||
| } | ||
| root; | ||
| get metaFile() { | ||
| return join2(this.root, "meta.json"); | ||
| } | ||
| get inboxDir() { | ||
| return join2(this.root, "inbox"); | ||
| } | ||
| get processedDir() { | ||
| return join2(this.inboxDir, "processed"); | ||
| } | ||
| get conversationsDir() { | ||
| return join2(this.root, "conversations"); | ||
| } | ||
| get indexDir() { | ||
| return join2(this.root, "index"); | ||
| } | ||
| get indexDbFile() { | ||
| return join2(this.indexDir, "llmnesia.db"); | ||
| } | ||
| /** Directory holding a platform's conversation JSON files. */ | ||
| platformDir(platform) { | ||
| return join2(this.conversationsDir, safeSegment(platform)); | ||
| } | ||
| /** Absolute path to the JSON file backing a given docId. */ | ||
| conversationFile(platform, docId) { | ||
| return join2(this.platformDir(platform), `${slugifyDocId(docId)}.json`); | ||
| } | ||
| }; | ||
| var SLUG_MAX = 120; | ||
| function slugifyDocId(docId) { | ||
| const lossless = docId.length > 0 && docId.length <= SLUG_MAX && /^[a-zA-Z0-9._-]+$/.test(docId); | ||
| if (lossless) { | ||
| return docId; | ||
| } | ||
| const hash = createHash("sha1").update(docId).digest("hex").slice(0, 12); | ||
| const readable = docId.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, SLUG_MAX - 13); | ||
| return readable.length > 0 ? `${readable}-${hash}` : hash; | ||
| } | ||
| function safeSegment(value) { | ||
| const cleaned = value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^[.-]+|[.-]+$/g, ""); | ||
| return cleaned.length > 0 ? cleaned : "unknown"; | ||
| } | ||
| // src/serialize.ts | ||
| function canonicalizeConversation(record) { | ||
| return { | ||
| conversation: canonicalConversationDoc(record.conversation), | ||
| messages: [...record.messages].sort((a, b) => a.msgIndex - b.msgIndex).map(canonicalMessageDoc) | ||
| }; | ||
| } | ||
| function canonicalConversationDoc(c) { | ||
| const out = { | ||
| docId: c.docId, | ||
| platform: c.platform, | ||
| title: c.title, | ||
| url: c.url, | ||
| createdAt: c.createdAt, | ||
| contentUpdatedAt: c.contentUpdatedAt, | ||
| indexedAt: c.indexedAt, | ||
| corpusChangedAt: c.corpusChangedAt, | ||
| updatedAt: c.updatedAt, | ||
| lastSeenAt: c.lastSeenAt, | ||
| messageCount: c.messageCount, | ||
| preview: c.preview, | ||
| hasImages: c.hasImages, | ||
| pinned: c.pinned, | ||
| indexLevel: c.indexLevel, | ||
| estimatedBytes: c.estimatedBytes | ||
| }; | ||
| assignIfDefined(out, "searchDocLength", c.searchDocLength); | ||
| assignIfDefined(out, "contentHash", c.contentHash); | ||
| assignIfDefined(out, "truncated", c.truncated); | ||
| assignIfDefined(out, "kind", c.kind); | ||
| assignIfDefined(out, "sourceId", c.sourceId); | ||
| assignIfDefined(out, "baseUrl", c.baseUrl); | ||
| assignIfDefined(out, "isDeeplinkable", c.isDeeplinkable); | ||
| assignIfDefined(out, "origin", c.origin); | ||
| assignIfDefined(out, "accountId", c.accountId); | ||
| assignIfDefined(out, "accountLabel", c.accountLabel); | ||
| if (c.summary) { | ||
| out.summary = { | ||
| text: c.summary.text, | ||
| recipe: c.summary.recipe, | ||
| method: c.summary.method, | ||
| createdAt: c.summary.createdAt | ||
| }; | ||
| } | ||
| return out; | ||
| } | ||
| function canonicalMessageDoc(m) { | ||
| const out = { | ||
| msgKey: m.msgKey, | ||
| docId: m.docId, | ||
| msgIndex: m.msgIndex, | ||
| role: m.role, | ||
| text: m.text | ||
| }; | ||
| assignIfDefined(out, "formattedText", m.formattedText); | ||
| out.createdAt = m.createdAt; | ||
| out.updatedAt = m.updatedAt; | ||
| out.contentHash = m.contentHash; | ||
| assignIfDefined(out, "platformMessageId", m.platformMessageId); | ||
| return out; | ||
| } | ||
| function assignIfDefined(target, key, value) { | ||
| if (value !== void 0) { | ||
| target[key] = value; | ||
| } | ||
| } | ||
| // src/corpus.ts | ||
| var DIR_MODE = 448; | ||
| var FILE_MODE = 384; | ||
| var Corpus = class { | ||
| paths; | ||
| // Serialize writes within this process so concurrent upserts (inbox drain + | ||
| // MCP save_conversation) don't interleave. Cross-process safety relies on the | ||
| // attended, single-companion v1 model (see PLAN-MCP.md open question 3). | ||
| writeChain = Promise.resolve(); | ||
| constructor(root) { | ||
| this.paths = new CorpusPaths(root); | ||
| } | ||
| /** Create the directory skeleton and meta.json if absent. Idempotent. */ | ||
| async init() { | ||
| await fs.mkdir(this.paths.conversationsDir, { recursive: true, mode: DIR_MODE }); | ||
| await fs.mkdir(this.paths.processedDir, { recursive: true, mode: DIR_MODE }); | ||
| await fs.mkdir(this.paths.indexDir, { recursive: true, mode: DIR_MODE }); | ||
| await hardenDir(this.paths.root); | ||
| await hardenDir(this.paths.conversationsDir); | ||
| await hardenDir(this.paths.inboxDir); | ||
| await hardenDir(this.paths.processedDir); | ||
| await hardenDir(this.paths.indexDir); | ||
| try { | ||
| await fs.access(this.paths.metaFile); | ||
| } catch { | ||
| const meta = { | ||
| formatVersion: CORPUS_FORMAT_VERSION, | ||
| createdAt: Date.now(), | ||
| lastIngestAt: null, | ||
| conversationCount: 0 | ||
| }; | ||
| await writeFileAtomic(this.paths.metaFile, `${JSON.stringify(meta, null, 2)} | ||
| `); | ||
| } | ||
| } | ||
| async readMeta() { | ||
| try { | ||
| const raw = await fs.readFile(this.paths.metaFile, "utf8"); | ||
| return JSON.parse(raw); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async writeMeta(patch) { | ||
| const current = await this.readMeta() ?? { | ||
| formatVersion: CORPUS_FORMAT_VERSION, | ||
| createdAt: Date.now(), | ||
| lastIngestAt: null, | ||
| conversationCount: 0 | ||
| }; | ||
| const next = { ...current, ...patch }; | ||
| await writeFileAtomic(this.paths.metaFile, `${JSON.stringify(next, null, 2)} | ||
| `); | ||
| } | ||
| /** | ||
| * Insert or replace a conversation, keyed on docId. New bridge records use | ||
| * the local `corpusChangedAt` mutation clock; older records fall back to the | ||
| * source `updatedAt`. If mutation clocks tie, source freshness breaks the | ||
| * tie. Re-writing identical content remains a byte-identical no-op. | ||
| */ | ||
| async upsert(record) { | ||
| return this.enqueueWrite(async () => { | ||
| const canonical = canonicalizeConversation(record); | ||
| const { conversation } = canonical; | ||
| const file = this.paths.conversationFile(conversation.platform, conversation.docId); | ||
| const existing = await readJsonIfExists(file); | ||
| if (existing) { | ||
| const existingChangedAt = existing.conversation.corpusChangedAt ?? existing.conversation.updatedAt ?? 0; | ||
| const incomingChangedAt = conversation.corpusChangedAt ?? conversation.updatedAt ?? 0; | ||
| const existingUpdatedAt = existing.conversation.updatedAt ?? 0; | ||
| const incomingUpdatedAt = conversation.updatedAt ?? 0; | ||
| if (incomingChangedAt < existingChangedAt || incomingChangedAt === existingChangedAt && incomingUpdatedAt < existingUpdatedAt) { | ||
| return { docId: conversation.docId, outcome: "skipped" }; | ||
| } | ||
| } | ||
| const serialized = `${JSON.stringify(canonical, null, 2)} | ||
| `; | ||
| if (existing) { | ||
| const existingSerialized = `${JSON.stringify(canonicalizeConversation(existing), null, 2)} | ||
| `; | ||
| if (existingSerialized === serialized) { | ||
| return { docId: conversation.docId, outcome: "skipped" }; | ||
| } | ||
| } | ||
| await fs.mkdir(dirname(file), { recursive: true, mode: DIR_MODE }); | ||
| await writeFileAtomic(file, serialized); | ||
| return { docId: conversation.docId, outcome: existing ? "updated" : "created" }; | ||
| }); | ||
| } | ||
| /** Read a conversation by docId, scanning platform dirs for the slug file. */ | ||
| async get(docId) { | ||
| const filename = `${slugifyDocId(docId)}.json`; | ||
| for (const platform of await this.listPlatforms()) { | ||
| const candidate = join3(this.paths.platformDir(platform), filename); | ||
| const record = await readJsonIfExists(candidate); | ||
| if (record && record.conversation.docId === docId) { | ||
| return record; | ||
| } | ||
| } | ||
| return null; | ||
| } | ||
| /** Platform subdirectory names under conversations/. */ | ||
| async listPlatforms() { | ||
| try { | ||
| const entries = await fs.readdir(this.paths.conversationsDir, { withFileTypes: true }); | ||
| return entries.filter((e) => e.isDirectory()).map((e) => e.name); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| /** Async-iterate every stored conversation (used by reindex + stats). */ | ||
| async *iterate() { | ||
| for (const platform of await this.listPlatforms()) { | ||
| const dir = this.paths.platformDir(platform); | ||
| let files; | ||
| try { | ||
| files = await fs.readdir(dir); | ||
| } catch { | ||
| continue; | ||
| } | ||
| for (const name of files) { | ||
| if (!name.endsWith(".json")) { | ||
| continue; | ||
| } | ||
| const record = await readJsonIfExists(join3(dir, name)); | ||
| if (record) { | ||
| yield record; | ||
| } | ||
| } | ||
| } | ||
| } | ||
| async stats() { | ||
| const byPlatform = {}; | ||
| let conversationCount = 0; | ||
| let messageCount = 0; | ||
| for await (const record of this.iterate()) { | ||
| conversationCount += 1; | ||
| messageCount += record.messages.length; | ||
| const platform = safeSegment(record.conversation.platform); | ||
| byPlatform[platform] = (byPlatform[platform] ?? 0) + 1; | ||
| } | ||
| return { conversationCount, messageCount, byPlatform }; | ||
| } | ||
| /** Refresh meta.json's conversationCount / lastIngestAt after an ingest run. */ | ||
| async touchIngestMeta() { | ||
| const { conversationCount } = await this.stats(); | ||
| await this.writeMeta({ lastIngestAt: Date.now(), conversationCount }); | ||
| } | ||
| enqueueWrite(fn) { | ||
| const run = this.writeChain.then(fn, fn); | ||
| this.writeChain = run.then( | ||
| () => void 0, | ||
| () => void 0 | ||
| ); | ||
| return run; | ||
| } | ||
| }; | ||
| async function readJsonIfExists(file) { | ||
| try { | ||
| const raw = await fs.readFile(file, "utf8"); | ||
| return JSON.parse(raw); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| async function writeFileAtomic(file, contents) { | ||
| const tmp = `${file}.tmp-${process.pid}-${Date.now()}`; | ||
| await fs.writeFile(tmp, contents, { encoding: "utf8", mode: FILE_MODE }); | ||
| await fs.rename(tmp, file); | ||
| } | ||
| async function hardenDir(dir) { | ||
| try { | ||
| await fs.chmod(dir, DIR_MODE); | ||
| } catch { | ||
| } | ||
| } | ||
| // src/ingest.ts | ||
| import { promises as fs2, createReadStream } from "fs"; | ||
| import { join as join4 } from "path"; | ||
| import { createInterface } from "readline"; | ||
| import { createGunzip } from "zlib"; | ||
| var EMPTY_REPORT = { | ||
| conversations: 0, | ||
| created: 0, | ||
| updated: 0, | ||
| skipped: 0, | ||
| messages: 0, | ||
| malformedLines: 0 | ||
| }; | ||
| async function ingestFile(corpus, file, index) { | ||
| const report = { ...EMPTY_REPORT }; | ||
| let current = null; | ||
| let buffered = []; | ||
| const flush = async () => { | ||
| if (!current) { | ||
| return; | ||
| } | ||
| const record = { conversation: current, messages: buffered }; | ||
| const result = await corpus.upsert(record); | ||
| tally(report, result); | ||
| if (index && result.outcome !== "skipped") { | ||
| index.upsertConversation(record); | ||
| } | ||
| report.conversations += 1; | ||
| report.messages += buffered.length; | ||
| current = null; | ||
| buffered = []; | ||
| }; | ||
| const rl = createInterface({ input: openLineSource(file), crlfDelay: Infinity }); | ||
| for await (const raw of rl) { | ||
| const line = raw.trim(); | ||
| if (!line) { | ||
| continue; | ||
| } | ||
| let parsed; | ||
| try { | ||
| parsed = JSON.parse(line); | ||
| } catch { | ||
| report.malformedLines += 1; | ||
| continue; | ||
| } | ||
| if (typeof parsed !== "object" || parsed === null) { | ||
| report.malformedLines += 1; | ||
| continue; | ||
| } | ||
| const record = parsed; | ||
| const type = record.type; | ||
| if (type === "meta") { | ||
| continue; | ||
| } | ||
| if (type === "conversation") { | ||
| await flush(); | ||
| current = coerceConversation(record.conversation); | ||
| buffered = []; | ||
| if (!current) { | ||
| report.malformedLines += 1; | ||
| } | ||
| continue; | ||
| } | ||
| if (type === "message" && current) { | ||
| const docId = typeof record.docId === "string" ? record.docId : ""; | ||
| if (docId !== current.docId) { | ||
| report.malformedLines += 1; | ||
| continue; | ||
| } | ||
| const message = coerceMessage(current.docId, record.message); | ||
| if (message) { | ||
| buffered.push(message); | ||
| } else { | ||
| report.malformedLines += 1; | ||
| } | ||
| } | ||
| } | ||
| await flush(); | ||
| return report; | ||
| } | ||
| async function pendingInboxFiles(corpus) { | ||
| try { | ||
| return (await fs2.readdir(corpus.paths.inboxDir, { withFileTypes: true })).filter((e) => e.isFile() && isNdjson(e.name)).map((e) => e.name).sort(); | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| async function drainInbox(corpus, index, options = {}) { | ||
| await corpus.init(); | ||
| const inbox = corpus.paths.inboxDir; | ||
| const entries = await pendingInboxFiles(corpus); | ||
| const total = { ...EMPTY_REPORT, files: 0, failed: 0 }; | ||
| for (const name of entries) { | ||
| const src = join4(inbox, name); | ||
| try { | ||
| const report = await ingestFile(corpus, src, index); | ||
| accumulate(total, report); | ||
| total.files += 1; | ||
| await moveToProcessed(corpus, src, name); | ||
| } catch (error) { | ||
| if (await stillPending(src)) { | ||
| total.failed += 1; | ||
| options.onFileError?.(name, error); | ||
| } | ||
| } | ||
| } | ||
| if (total.files > 0) { | ||
| await corpus.touchIngestMeta(); | ||
| } | ||
| return total; | ||
| } | ||
| async function stillPending(src) { | ||
| try { | ||
| await fs2.access(src); | ||
| return true; | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| async function moveToProcessed(corpus, src, name) { | ||
| const dest = join4(corpus.paths.processedDir, name); | ||
| try { | ||
| await fs2.rename(src, dest); | ||
| } catch { | ||
| const alt = join4(corpus.paths.processedDir, `${Date.now()}-${name}`); | ||
| await fs2.copyFile(src, alt); | ||
| await fs2.unlink(src); | ||
| } | ||
| } | ||
| function isNdjson(name) { | ||
| const lower = name.toLowerCase(); | ||
| return lower.endsWith(".jsonl") || lower.endsWith(".ndjson") || lower.endsWith(".jsonl.gz") || lower.endsWith(".ndjson.gz"); | ||
| } | ||
| function openLineSource(file) { | ||
| const stream = createReadStream(file); | ||
| if (file.toLowerCase().endsWith(".gz")) { | ||
| return stream.pipe(createGunzip()); | ||
| } | ||
| return stream; | ||
| } | ||
| function coerceConversation(value) { | ||
| if (!value || typeof value !== "object") { | ||
| return null; | ||
| } | ||
| const c = value; | ||
| if (typeof c.docId !== "string" || !c.docId) { | ||
| return null; | ||
| } | ||
| const conversation = { ...c }; | ||
| if (typeof conversation.updatedAt !== "number") { | ||
| conversation.updatedAt = typeof conversation.contentUpdatedAt === "number" ? conversation.contentUpdatedAt : 0; | ||
| } | ||
| return conversation; | ||
| } | ||
| function coerceMessage(docId, value) { | ||
| if (!value || typeof value !== "object") { | ||
| return null; | ||
| } | ||
| const m = value; | ||
| const text = typeof m.text === "string" ? m.text : ""; | ||
| if (!text.trim()) { | ||
| return null; | ||
| } | ||
| const msgIndex = typeof m.msgIndex === "number" ? m.msgIndex : 0; | ||
| const message = { | ||
| // The backup line omits msgKey; reconstruct the extension's convention. | ||
| msgKey: `${docId}:${msgIndex}`, | ||
| docId, | ||
| msgIndex, | ||
| role: coerceRole(m.role), | ||
| text, | ||
| createdAt: typeof m.createdAt === "number" ? m.createdAt : 0, | ||
| updatedAt: typeof m.updatedAt === "number" ? m.updatedAt : 0, | ||
| contentHash: typeof m.contentHash === "string" ? m.contentHash : "" | ||
| }; | ||
| if (typeof m.formattedText === "string") { | ||
| message.formattedText = m.formattedText; | ||
| } | ||
| if (typeof m.platformMessageId === "string") { | ||
| message.platformMessageId = m.platformMessageId; | ||
| } else if (m.platformMessageId === null) { | ||
| message.platformMessageId = null; | ||
| } | ||
| return message; | ||
| } | ||
| function coerceRole(value) { | ||
| return value === "assistant" || value === "user" || value === "system" || value === "unknown" ? value : "unknown"; | ||
| } | ||
| function tally(report, result) { | ||
| if (result.outcome === "created") { | ||
| report.created += 1; | ||
| } else if (result.outcome === "updated") { | ||
| report.updated += 1; | ||
| } else { | ||
| report.skipped += 1; | ||
| } | ||
| } | ||
| function accumulate(total, report) { | ||
| total.conversations += report.conversations; | ||
| total.created += report.created; | ||
| total.updated += report.updated; | ||
| total.skipped += report.skipped; | ||
| total.messages += report.messages; | ||
| total.malformedLines += report.malformedLines; | ||
| } | ||
| // src/searchIndex.ts | ||
| import { promises as fs3, chmodSync, closeSync, openSync } from "fs"; | ||
| import { createRequire } from "module"; | ||
| // src/sqliteWarning.ts | ||
| var INSTALLED = /* @__PURE__ */ Symbol.for("llmnesia.sqliteWarningFilterInstalled"); | ||
| function isSqliteExperimentalWarning(warning) { | ||
| return warning.name === "ExperimentalWarning" && /\bSQLite\b/i.test(warning.message); | ||
| } | ||
| function install() { | ||
| const flagged = globalThis; | ||
| if (flagged[INSTALLED]) { | ||
| return; | ||
| } | ||
| flagged[INSTALLED] = true; | ||
| const previous = process.listeners("warning"); | ||
| process.removeAllListeners("warning"); | ||
| process.on("warning", (warning) => { | ||
| if (isSqliteExperimentalWarning(warning)) { | ||
| return; | ||
| } | ||
| for (const listener of previous) { | ||
| listener.call(process, warning); | ||
| } | ||
| }); | ||
| } | ||
| install(); | ||
| // src/searchIndex.ts | ||
| var nodeRequire = createRequire(import.meta.url); | ||
| var sqlite; | ||
| function loadSqlite() { | ||
| return sqlite ??= nodeRequire("node:sqlite"); | ||
| } | ||
| var DB_FILE_MODE = 384; | ||
| function hardenDbFile(dbPath) { | ||
| try { | ||
| closeSync(openSync(dbPath, "a", DB_FILE_MODE)); | ||
| chmodSync(dbPath, DB_FILE_MODE); | ||
| } catch { | ||
| } | ||
| } | ||
| var INDEX_FORMAT_VERSION = 1; | ||
| var TITLE_ROW_INDEX = -1; | ||
| var TITLE_ROLE = "title"; | ||
| var DEFAULT_LIMIT = 10; | ||
| var SNIPPET_TOKENS = 12; | ||
| var SCHEMA_SQL = ` | ||
| CREATE TABLE IF NOT EXISTS index_meta ( | ||
| key TEXT PRIMARY KEY, | ||
| value TEXT NOT NULL | ||
| ); | ||
| CREATE TABLE IF NOT EXISTS conversations ( | ||
| docId TEXT PRIMARY KEY, | ||
| platform TEXT NOT NULL, | ||
| title TEXT NOT NULL, | ||
| url TEXT NOT NULL, | ||
| createdAt INTEGER NOT NULL, | ||
| updatedAt INTEGER NOT NULL, | ||
| messageCount INTEGER NOT NULL, | ||
| preview TEXT NOT NULL, | ||
| pinned INTEGER NOT NULL | ||
| ); | ||
| CREATE INDEX IF NOT EXISTS idx_conversations_platform ON conversations(platform); | ||
| CREATE INDEX IF NOT EXISTS idx_conversations_createdAt ON conversations(createdAt); | ||
| CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5( | ||
| docId UNINDEXED, | ||
| msgIndex UNINDEXED, | ||
| role UNINDEXED, | ||
| text, | ||
| tokenize = 'porter unicode61' | ||
| ); | ||
| `; | ||
| var SearchIndex = class { | ||
| db; | ||
| stmts; | ||
| constructor(dbPath) { | ||
| hardenDbFile(dbPath); | ||
| this.db = new (loadSqlite()).DatabaseSync(dbPath); | ||
| this.db.exec("PRAGMA journal_mode = WAL"); | ||
| this.db.exec("PRAGMA foreign_keys = OFF"); | ||
| this.db.exec(SCHEMA_SQL); | ||
| this.setMeta("format_version", String(INDEX_FORMAT_VERSION)); | ||
| this.stmts = { | ||
| deleteFts: this.db.prepare("DELETE FROM messages_fts WHERE docId = ?"), | ||
| insertFts: this.db.prepare( | ||
| "INSERT INTO messages_fts (docId, msgIndex, role, text) VALUES (?, ?, ?, ?)" | ||
| ), | ||
| deleteConversation: this.db.prepare("DELETE FROM conversations WHERE docId = ?"), | ||
| insertConversation: this.db.prepare( | ||
| `INSERT INTO conversations | ||
| (docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned) | ||
| VALUES (@docId, @platform, @title, @url, @createdAt, @updatedAt, @messageCount, @preview, @pinned)` | ||
| ) | ||
| }; | ||
| } | ||
| /** True when the stored format version is missing or stale — caller rebuilds. */ | ||
| needsRebuild() { | ||
| const stored = this.getMeta("format_version"); | ||
| return stored !== String(INDEX_FORMAT_VERSION); | ||
| } | ||
| /** Number of indexed conversations. */ | ||
| count() { | ||
| const row = this.db.prepare("SELECT COUNT(*) AS n FROM conversations").get(); | ||
| return row.n; | ||
| } | ||
| /** Most-recently-updated conversations (metadata only), newest first. */ | ||
| listRecent(n) { | ||
| return this.listConversations(n, "recent"); | ||
| } | ||
| /** Conversation metadata in a caller-selected, deterministic chronology. */ | ||
| listConversations(n, sort) { | ||
| const limit = Math.max(1, n); | ||
| const orderBy = sort === "oldest" ? "createdAt ASC, updatedAt ASC" : sort === "newest" ? "createdAt DESC, updatedAt DESC" : "updatedAt DESC, createdAt DESC"; | ||
| const rows = this.db.prepare( | ||
| `SELECT docId, platform, title, url, createdAt, updatedAt, messageCount, preview, pinned | ||
| FROM conversations ORDER BY ${orderBy} LIMIT ?` | ||
| ).all(limit); | ||
| return rows.map((r) => ({ ...r, pinned: r.pinned === 1 })); | ||
| } | ||
| /** | ||
| * Run `fn` inside a transaction, rolling back if it throws. node:sqlite has | ||
| * no transaction() wrapper, so this is the one place BEGIN/COMMIT/ROLLBACK | ||
| * lives for synchronous writes; {@link rebuildFrom} spells it out separately | ||
| * because its body is async and cannot be expressed as a sync callback. | ||
| */ | ||
| transaction(fn) { | ||
| this.db.exec("BEGIN"); | ||
| try { | ||
| const result = fn(); | ||
| this.db.exec("COMMIT"); | ||
| return result; | ||
| } catch (err) { | ||
| this.db.exec("ROLLBACK"); | ||
| throw err; | ||
| } | ||
| } | ||
| upsertConversation(record) { | ||
| this.transaction(() => this.writeRecord(record)); | ||
| } | ||
| removeConversation(docId) { | ||
| this.transaction(() => { | ||
| this.stmts.deleteFts.run(docId); | ||
| this.stmts.deleteConversation.run(docId); | ||
| }); | ||
| } | ||
| /** | ||
| * Clear the index and repopulate it from the corpus — proving the corpus is | ||
| * the source of truth. Uses one manual transaction spanning the async file | ||
| * reads, which the sync {@link transaction} helper cannot hold. | ||
| */ | ||
| async rebuildFrom(corpus) { | ||
| this.db.exec("DELETE FROM messages_fts; DELETE FROM conversations;"); | ||
| let conversations = 0; | ||
| this.db.exec("BEGIN"); | ||
| try { | ||
| for await (const record of corpus.iterate()) { | ||
| this.writeRecord(record); | ||
| conversations += 1; | ||
| } | ||
| this.db.exec("COMMIT"); | ||
| } catch (err) { | ||
| this.db.exec("ROLLBACK"); | ||
| throw err; | ||
| } | ||
| return { conversations }; | ||
| } | ||
| // Replace a conversation's index rows. Caller supplies the transaction (the | ||
| // transaction() helper for single upserts, or rebuildFrom's manual one). | ||
| writeRecord(rec) { | ||
| const { conversation, messages } = rec; | ||
| this.stmts.deleteFts.run(conversation.docId); | ||
| this.stmts.deleteConversation.run(conversation.docId); | ||
| const titleText = [conversation.title, conversation.preview].filter(Boolean).join("\n"); | ||
| if (titleText.trim()) { | ||
| this.stmts.insertFts.run(conversation.docId, TITLE_ROW_INDEX, TITLE_ROLE, titleText); | ||
| } | ||
| for (const message of messages) { | ||
| if (message.text.trim()) { | ||
| this.stmts.insertFts.run(conversation.docId, message.msgIndex, message.role, message.text); | ||
| } | ||
| } | ||
| this.stmts.insertConversation.run({ | ||
| docId: conversation.docId, | ||
| platform: conversation.platform, | ||
| title: conversation.title, | ||
| url: conversation.url, | ||
| createdAt: conversation.createdAt, | ||
| updatedAt: conversation.updatedAt ?? conversation.contentUpdatedAt ?? 0, | ||
| messageCount: conversation.messageCount ?? messages.length, | ||
| preview: conversation.preview ?? "", | ||
| pinned: conversation.pinned ? 1 : 0 | ||
| }); | ||
| } | ||
| search(query, filters = {}) { | ||
| const match = toMatchQuery(query, filters.matchMode); | ||
| if (!match) { | ||
| return []; | ||
| } | ||
| const limit = Math.max(1, filters.limit ?? DEFAULT_LIMIT); | ||
| const clauses = ["messages_fts MATCH @match"]; | ||
| const params = { match }; | ||
| if (filters.platform) { | ||
| clauses.push("c.platform = @platform"); | ||
| params.platform = filters.platform; | ||
| } | ||
| if (filters.title) { | ||
| clauses.push("c.title LIKE @title ESCAPE '\\'"); | ||
| params.title = `%${escapeLike(filters.title)}%`; | ||
| } | ||
| if (typeof filters.dateFrom === "number") { | ||
| clauses.push("c.createdAt >= @dateFrom"); | ||
| params.dateFrom = filters.dateFrom; | ||
| } | ||
| if (typeof filters.dateTo === "number") { | ||
| clauses.push("c.createdAt <= @dateTo"); | ||
| params.dateTo = filters.dateTo; | ||
| } | ||
| const scanCap = Math.min(2e3, Math.max(200, limit * 40)); | ||
| const sql = ` | ||
| SELECT | ||
| c.docId, c.platform, c.title, c.url, c.createdAt, c.updatedAt, | ||
| c.messageCount, c.preview, c.pinned, | ||
| m.msgIndex AS msgIndex, m.role AS role, | ||
| snippet(messages_fts, 3, '[', ']', '\u2026', ${SNIPPET_TOKENS}) AS snippet, | ||
| bm25(messages_fts) AS bm25 | ||
| FROM messages_fts m | ||
| JOIN conversations c ON c.docId = m.docId | ||
| WHERE ${clauses.join(" AND ")} | ||
| ORDER BY bm25 | ||
| LIMIT @scanCap | ||
| `; | ||
| const rows = this.db.prepare(sql).all({ ...params, scanCap }); | ||
| const seen = /* @__PURE__ */ new Set(); | ||
| const hits = []; | ||
| for (const row of rows) { | ||
| if (seen.has(row.docId)) { | ||
| continue; | ||
| } | ||
| seen.add(row.docId); | ||
| hits.push({ | ||
| docId: row.docId, | ||
| platform: row.platform, | ||
| title: row.title, | ||
| url: row.url, | ||
| createdAt: row.createdAt, | ||
| updatedAt: row.updatedAt, | ||
| messageCount: row.messageCount, | ||
| preview: row.preview, | ||
| pinned: row.pinned === 1, | ||
| // BM25 is negative (more negative = better); expose a positive relevance. | ||
| score: -row.bm25, | ||
| snippet: row.snippet, | ||
| matchRole: normalizeMatchRole(row.role), | ||
| matchMsgIndex: row.msgIndex | ||
| }); | ||
| if (hits.length >= limit) { | ||
| break; | ||
| } | ||
| } | ||
| return hits; | ||
| } | ||
| close() { | ||
| this.db.close(); | ||
| } | ||
| getMeta(key) { | ||
| const row = this.db.prepare("SELECT value FROM index_meta WHERE key = ?").get(key); | ||
| return row ? row.value : null; | ||
| } | ||
| setMeta(key, value) { | ||
| this.db.prepare("INSERT INTO index_meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(key, value); | ||
| } | ||
| }; | ||
| async function openSearchIndex(corpus, options = {}) { | ||
| await corpus.init(); | ||
| const index = new SearchIndex(corpus.paths.indexDbFile); | ||
| if (options.autoBuild !== false && (index.needsRebuild() || index.count() === 0)) { | ||
| await index.rebuildFrom(corpus); | ||
| } | ||
| return index; | ||
| } | ||
| async function deleteIndexDb(dbFile) { | ||
| for (const suffix of ["", "-wal", "-shm"]) { | ||
| await fs3.rm(`${dbFile}${suffix}`, { force: true }); | ||
| } | ||
| } | ||
| function toMatchQuery(query, mode = "any") { | ||
| const terms = query.split(/[^\p{L}\p{N}]+/u).filter((t) => t.length > 0); | ||
| if (terms.length === 0) { | ||
| return null; | ||
| } | ||
| if (mode === "phrase") { | ||
| return `"${terms.join(" ")}"`; | ||
| } | ||
| return terms.map((t) => `"${t}"`).join(mode === "all" ? " AND " : " OR "); | ||
| } | ||
| function escapeLike(value) { | ||
| return value.replace(/[\\%_]/g, "\\$&"); | ||
| } | ||
| function normalizeMatchRole(role) { | ||
| if (role === "title") { | ||
| return "title"; | ||
| } | ||
| return role === "assistant" || role === "user" || role === "system" ? role : "unknown"; | ||
| } | ||
| // src/runtimeInstall.ts | ||
| import { cp, mkdir, readdir, rename, rm, writeFile } from "fs/promises"; | ||
| import { existsSync } from "fs"; | ||
| import { homedir as homedir2 } from "os"; | ||
| import { dirname as dirname2, join as join5 } from "path"; | ||
| import { fileURLToPath } from "url"; | ||
| function defaultSourceDir() { | ||
| return dirname2(fileURLToPath(import.meta.url)); | ||
| } | ||
| function runtimeBinDir(home = homedir2()) { | ||
| return join5(home, ".llmnesia", "bin"); | ||
| } | ||
| async function installRuntime(options = {}) { | ||
| const home = options.home ?? homedir2(); | ||
| const sourceDir = options.sourceDir ?? defaultSourceDir(); | ||
| const nodePath = options.nodePath ?? process.execPath; | ||
| const binDir = runtimeBinDir(home); | ||
| const sourceEntry = join5(sourceDir, "cli.js"); | ||
| if (!existsSync(sourceEntry)) { | ||
| throw new Error( | ||
| `Cannot locate the built runtime: no cli.js in ${sourceDir}. Run this from an installed package (npx -y @llmnesia/mcp@latest install) or build first (npm run build).` | ||
| ); | ||
| } | ||
| const parentDir = dirname2(binDir); | ||
| const nonce = `${process.pid}-${Date.now()}`; | ||
| const stagingDir = join5(parentDir, `.bin-install-${nonce}`); | ||
| const backupDir = join5(parentDir, `.bin-backup-${nonce}`); | ||
| await mkdir(parentDir, { recursive: true }); | ||
| await rm(stagingDir, { recursive: true, force: true }); | ||
| await rm(backupDir, { recursive: true, force: true }); | ||
| await mkdir(stagingDir, { recursive: true }); | ||
| try { | ||
| for (const name of await readdir(sourceDir)) { | ||
| await cp(join5(sourceDir, name), join5(stagingDir, name), { recursive: true }); | ||
| } | ||
| await writeFile( | ||
| join5(stagingDir, "package.json"), | ||
| `${JSON.stringify( | ||
| { | ||
| name: "llmnesia-mcp-runtime", | ||
| private: true, | ||
| type: "module", | ||
| // Stamped so the installed build is identifiable without spawning it. | ||
| // Its absence is how a machine sat on 0.1.3 while npm was ten | ||
| // releases ahead and nothing, doctor included, could say so. | ||
| version: options.version ?? VERSION, | ||
| comment: "Installed copy of @llmnesia/mcp's dist. Managed by the LLMnesia installer; edits are lost on reinstall." | ||
| }, | ||
| null, | ||
| 2 | ||
| )} | ||
| `, | ||
| "utf8" | ||
| ); | ||
| const hadPreviousRuntime = existsSync(binDir); | ||
| if (hadPreviousRuntime) { | ||
| await rename(binDir, backupDir); | ||
| } | ||
| try { | ||
| await rename(stagingDir, binDir); | ||
| } catch (err) { | ||
| if (hadPreviousRuntime && !existsSync(binDir) && existsSync(backupDir)) { | ||
| await rename(backupDir, binDir); | ||
| } | ||
| throw err; | ||
| } | ||
| await rm(backupDir, { recursive: true, force: true }); | ||
| } finally { | ||
| await rm(stagingDir, { recursive: true, force: true }); | ||
| } | ||
| const entry = join5(binDir, "cli.js"); | ||
| return { | ||
| binDir, | ||
| entry, | ||
| serve: { command: nodePath, args: [entry, "serve"] }, | ||
| sourceDir, | ||
| nodePath | ||
| }; | ||
| } | ||
| async function uninstallRuntime(home = homedir2()) { | ||
| const binDir = runtimeBinDir(home); | ||
| if (!existsSync(binDir)) { | ||
| return null; | ||
| } | ||
| await rm(binDir, { recursive: true, force: true }); | ||
| return binDir; | ||
| } | ||
| // src/selfUpdate.ts | ||
| import { spawn } from "child_process"; | ||
| import { mkdtemp, readFile, rm as rm2, writeFile as writeFile2 } from "fs/promises"; | ||
| import { dirname as dirname3, join as join6 } from "path"; | ||
| import { homedir as homedir3, tmpdir } from "os"; | ||
| import { fileURLToPath as fileURLToPath2 } from "url"; | ||
| var PACKAGE_NAME = "@llmnesia/mcp"; | ||
| var REGISTRY_LATEST_URL = "https://registry.npmjs.org/@llmnesia%2Fmcp/latest"; | ||
| var UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1e3; | ||
| var REGISTRY_TIMEOUT_MS = 5e3; | ||
| var INSTALL_TIMEOUT_MS = 12e4; | ||
| var UPDATE_CHECK_DELAY_MS = 3e4; | ||
| function stateFile(home) { | ||
| return join6(home, ".llmnesia", "update-state.json"); | ||
| } | ||
| async function readState(home) { | ||
| try { | ||
| const parsed = JSON.parse(await readFile(stateFile(home), "utf8")); | ||
| return typeof parsed === "object" && parsed !== null ? parsed : {}; | ||
| } catch { | ||
| return {}; | ||
| } | ||
| } | ||
| async function writeState(home, state) { | ||
| try { | ||
| await writeFile2(stateFile(home), `${JSON.stringify(state, null, 2)} | ||
| `, "utf8"); | ||
| } catch { | ||
| } | ||
| } | ||
| function compareVersions(a, b) { | ||
| const parse = (v) => { | ||
| const match = /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim()); | ||
| return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : []; | ||
| }; | ||
| const left = parse(a); | ||
| const right = parse(b); | ||
| if (left.length === 0 || right.length === 0) { | ||
| return 0; | ||
| } | ||
| for (let i = 0; i < 3; i += 1) { | ||
| if (left[i] !== right[i]) { | ||
| return left[i] - right[i]; | ||
| } | ||
| } | ||
| return 0; | ||
| } | ||
| function isStableRelease(version) { | ||
| return /^\d+\.\d+\.\d+$/.test(version.trim()); | ||
| } | ||
| function isSelfUpdateDisabled(env = process.env) { | ||
| const raw = env.LLMNESIA_NO_AUTO_UPDATE; | ||
| return raw === "1" || raw === "true"; | ||
| } | ||
| async function fetchLatestFromRegistry() { | ||
| const controller = new AbortController(); | ||
| const timer = setTimeout(() => controller.abort(), REGISTRY_TIMEOUT_MS); | ||
| try { | ||
| const response = await fetch(REGISTRY_LATEST_URL, { | ||
| signal: controller.signal, | ||
| headers: { accept: "application/json" } | ||
| }); | ||
| if (!response.ok) { | ||
| throw new Error(`registry responded ${response.status}`); | ||
| } | ||
| const body = await response.json(); | ||
| if (typeof body.version !== "string") { | ||
| throw new Error("registry response had no version"); | ||
| } | ||
| return body.version; | ||
| } finally { | ||
| clearTimeout(timer); | ||
| } | ||
| } | ||
| function npmCommand() { | ||
| const binDir = dirname3(process.execPath); | ||
| return process.platform === "win32" ? join6(binDir, "npm.cmd") : join6(binDir, "npm"); | ||
| } | ||
| async function npmDownload(version) { | ||
| const prefix = await mkdtemp(join6(tmpdir(), "llmnesia-update-")); | ||
| await new Promise((resolve2, reject) => { | ||
| const child = spawn( | ||
| npmCommand(), | ||
| [ | ||
| "install", | ||
| `${PACKAGE_NAME}@${version}`, | ||
| "--prefix", | ||
| prefix, | ||
| "--no-audit", | ||
| "--no-fund", | ||
| "--no-save", | ||
| "--loglevel", | ||
| "error" | ||
| ], | ||
| { stdio: ["ignore", "ignore", "pipe"], windowsHide: true } | ||
| ); | ||
| let stderr = ""; | ||
| child.stderr?.on("data", (chunk) => { | ||
| stderr += String(chunk); | ||
| }); | ||
| const timer = setTimeout(() => child.kill(), INSTALL_TIMEOUT_MS); | ||
| child.once("error", (err) => { | ||
| clearTimeout(timer); | ||
| reject(err); | ||
| }); | ||
| child.once("close", (code) => { | ||
| clearTimeout(timer); | ||
| if (code === 0) { | ||
| resolve2(); | ||
| } else { | ||
| reject(new Error(`npm install exited ${code}${stderr ? `: ${stderr.trim()}` : ""}`)); | ||
| } | ||
| }); | ||
| }); | ||
| return join6(prefix, "node_modules", PACKAGE_NAME, "dist"); | ||
| } | ||
| async function maybeSelfUpdate(options = {}) { | ||
| const env = options.env ?? process.env; | ||
| if (isSelfUpdateDisabled(env)) { | ||
| return { action: "disabled" }; | ||
| } | ||
| const home = options.home ?? homedir3(); | ||
| const currentVersion = options.currentVersion ?? VERSION; | ||
| const runningDir = options.runningDir ?? dirname3(fileURLToPath2(import.meta.url)); | ||
| if (runningDir !== runtimeBinDir(home)) { | ||
| return { action: "not-installed-runtime" }; | ||
| } | ||
| if (!isStableRelease(currentVersion)) { | ||
| return { action: "not-a-release-build", currentVersion }; | ||
| } | ||
| const now = options.now ?? Date.now; | ||
| const state = await readState(home); | ||
| if (state.lastCheckedAt !== void 0 && now() - state.lastCheckedAt < UPDATE_CHECK_INTERVAL_MS) { | ||
| return { action: "throttled" }; | ||
| } | ||
| let latest; | ||
| try { | ||
| latest = await (options.fetchLatestVersion ?? fetchLatestFromRegistry)(); | ||
| } catch (err) { | ||
| await writeState(home, { ...state, lastCheckedAt: now() }); | ||
| return { action: "check-failed", error: err instanceof Error ? err.message : String(err) }; | ||
| } | ||
| const checked = { ...state, lastCheckedAt: now(), lastSeenVersion: latest }; | ||
| if (!isStableRelease(latest) || compareVersions(latest, currentVersion) <= 0) { | ||
| await writeState(home, checked); | ||
| return { action: "up-to-date", latest }; | ||
| } | ||
| if (state.lastFailedVersion === latest) { | ||
| await writeState(home, checked); | ||
| return { action: "skipped-failed-before", latest }; | ||
| } | ||
| let sourceDir; | ||
| try { | ||
| sourceDir = await (options.downloadPackage ?? npmDownload)(latest); | ||
| await installRuntime({ home, sourceDir, version: latest }); | ||
| await writeState(home, { ...checked, lastUpdatedTo: latest, lastFailedVersion: void 0 }); | ||
| return { action: "updated", from: currentVersion, to: latest }; | ||
| } catch (err) { | ||
| await writeState(home, { ...checked, lastFailedVersion: latest }); | ||
| return { action: "update-failed", latest, error: err instanceof Error ? err.message : String(err) }; | ||
| } finally { | ||
| if (sourceDir) { | ||
| await rm2(join6(sourceDir, "..", "..", ".."), { recursive: true, force: true }).catch(() => { | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| function scheduleSelfUpdate(options = {}) { | ||
| const { delayMs = UPDATE_CHECK_DELAY_MS, onOutcome, ...rest } = options; | ||
| const timer = setTimeout(() => { | ||
| void maybeSelfUpdate(rest).then( | ||
| (outcome) => onOutcome?.(outcome), | ||
| () => { | ||
| } | ||
| ); | ||
| }, delayMs); | ||
| timer.unref?.(); | ||
| } | ||
| function describeOutcome(outcome) { | ||
| switch (outcome.action) { | ||
| case "updated": | ||
| return `updated ${outcome.from} to ${outcome.to}; it takes effect next launch`; | ||
| case "update-failed": | ||
| return `could not install ${outcome.latest}: ${outcome.error}`; | ||
| case "check-failed": | ||
| return `could not reach the npm registry: ${outcome.error}`; | ||
| case "up-to-date": | ||
| return `already current (latest published is ${outcome.latest})`; | ||
| case "skipped-failed-before": | ||
| return `${outcome.latest} failed to install previously; not retrying automatically`; | ||
| case "throttled": | ||
| return "checked recently"; | ||
| case "disabled": | ||
| return "automatic updates are turned off (LLMNESIA_NO_AUTO_UPDATE)"; | ||
| case "not-installed-runtime": | ||
| return "not running from ~/.llmnesia/bin, so nothing to update"; | ||
| case "not-a-release-build": | ||
| return `running an unreleased build (${outcome.currentVersion}); leaving the installed runtime alone`; | ||
| } | ||
| } | ||
| export { | ||
| resolveCorpusDir, | ||
| Corpus, | ||
| ingestFile, | ||
| pendingInboxFiles, | ||
| drainInbox, | ||
| SearchIndex, | ||
| openSearchIndex, | ||
| deleteIndexDb, | ||
| runtimeBinDir, | ||
| installRuntime, | ||
| uninstallRuntime, | ||
| isSelfUpdateDisabled, | ||
| scheduleSelfUpdate, | ||
| describeOutcome | ||
| }; |
| #!/usr/bin/env node | ||
| import { createRequire as __llmnesiaCreateRequire } from "node:module"; | ||
| const require = __llmnesiaCreateRequire(import.meta.url); | ||
| var __create = Object.create; | ||
| var __defProp = Object.defineProperty; | ||
| var __getOwnPropDesc = Object.getOwnPropertyDescriptor; | ||
| var __getOwnPropNames = Object.getOwnPropertyNames; | ||
| var __getProtoOf = Object.getPrototypeOf; | ||
| var __hasOwnProp = Object.prototype.hasOwnProperty; | ||
| var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { | ||
| get: (a, b) => (typeof require !== "undefined" ? require : a)[b] | ||
| }) : x)(function(x) { | ||
| if (typeof require !== "undefined") return require.apply(this, arguments); | ||
| throw Error('Dynamic require of "' + x + '" is not supported'); | ||
| }); | ||
| var __commonJS = (cb, mod) => function __require2() { | ||
| return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; | ||
| }; | ||
| var __export = (target, all) => { | ||
| for (var name in all) | ||
| __defProp(target, name, { get: all[name], enumerable: true }); | ||
| }; | ||
| var __copyProps = (to, from, except, desc) => { | ||
| if (from && typeof from === "object" || typeof from === "function") { | ||
| for (let key of __getOwnPropNames(from)) | ||
| if (!__hasOwnProp.call(to, key) && key !== except) | ||
| __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); | ||
| } | ||
| return to; | ||
| }; | ||
| var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( | ||
| // If the importer is in node compatibility mode or this is not an ESM | ||
| // file that has been converted to a CommonJS file using a Babel- | ||
| // compatible transform (i.e. "__esModule" has not been set), then set | ||
| // "default" to the CommonJS "module.exports" for node compatibility. | ||
| isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, | ||
| mod | ||
| )); | ||
| // src/version.ts | ||
| var VERSION = true ? "0.1.14" : "0.0.0-dev"; | ||
| export { | ||
| __require, | ||
| __commonJS, | ||
| __export, | ||
| __toESM, | ||
| VERSION | ||
| }; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is too big to display
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1135891
0.05%27405
0.01%