#!/usr/bin/env npx tsx /** * 本地 Memory 数据查询脚本 * * 查询 memory-tdai 目录下的记忆数据,支持: * - 按层级(L0~L3)查询 * - L0/L1 从 SQLite(vectors.db)读取 * - 时间范围过滤(--since / --until) * - 字段过滤(--filter,仅支持 SQLite 表的直接列) * - 排序、分页(下推到 SQL 层) * - 多种输出格式(table / json / jsonl) * * @example * npx tsx read-local-memory.ts -d ./memory-tdai示例数据 * npx tsx read-local-memory.ts -d ./memory-tdai示例数据 -L L0 --since 7d * npx tsx read-local-memory.ts -d ./memory-tdai示例数据 -L L1 -f 'type=persona' */ import { createRequire } from "node:module" import type { DatabaseSync } from "node:sqlite" import * as fs from "node:fs" import * as path from "node:path" import { parseArgs } from "node:util" const require = createRequire(import.meta.url) function requireNodeSqlite(): typeof import("node:sqlite") { return require("node:sqlite") as typeof import("node:sqlite") } // ───────────────────────────────────────────── // Types // ───────────────────────────────────────────── type Level = "L0" | "L1" | "L2" | "L3" type SortDirection = "asc" | "desc" type OutputFormat = "table" | "json" | "jsonl" interface CliOptions { dataDir: string level?: Level since?: string until?: string limit: number offset: number sort: SortDirection filter?: string format: OutputFormat file?: string // L2 单文件详情查询:指定文件名,只返回该文件的完整内容 } interface FilterCondition { field: string operator: "=" | "!=" | ">=" | "<=" | ">" | "<" value: string } interface L2Meta { created: string updated: string summary: string heat: number [key: string]: string | number } interface L2Entry { fileName: string meta: L2Meta body: string } interface QueryResult { level: string total: number offset: number limit: number sort: SortDirection filter: Record | null data: T[] } // ───────────────────────────────────────────── // Constants // ───────────────────────────────────────────── const SQLITE_DB_NAME = "vectors.db" const LEVEL_DIRS: Record = { L2: "scene_blocks", L3: "persona.md", } /** L0 表的允许过滤列(白名单防 SQL 注入) */ const L0_FILTER_COLUMNS = new Set([ "record_id", "session_key", "session_id", "role", "message_text", "recorded_at", "timestamp", ]) /** L1 表的允许过滤列(白名单防 SQL 注入) */ const L1_FILTER_COLUMNS = new Set([ "record_id", "content", "type", "priority", "scene_name", "session_key", "session_id", "timestamp_str", "timestamp_start", "timestamp_end", "created_time", "updated_time", "metadata_json", ]) /** 驼峰字段名 → SQLite 列名映射(用户用驼峰过滤,内部转成 SQL 列名) */ const CAMEL_TO_COLUMN: Record = { id: "record_id", recordId: "record_id", sessionKey: "session_key", sessionId: "session_id", messageText: "message_text", recordedAt: "recorded_at", sceneName: "scene_name", timestampStr: "timestamp_str", timestampStart: "timestamp_start", timestampEnd: "timestamp_end", createdAt: "created_time", updatedAt: "updated_time", metadataJson: "metadata_json", } const META_START = "-----META-START-----" const META_END = "-----META-END-----" const RELATIVE_TIME_RE = /^(\d+)(d|h|m|s)$/ const HELP_TEXT = ` 📖 本地 Memory 数据查询脚本(SQLite 模式) Usage: npx tsx read-local-memory.ts -d <数据目录> [选项] 数据目录下须包含 vectors.db(SQLite 数据库),L0/L1 数据从中读取。 Options: -d, --data-dir <路径> 本地 memory-tdai 数据目录路径(必填,须含 vectors.db) -L, --level <层级> 查询层级: L0 / L1 / L2 / L3(不指定则查所有) --since <时间> 起始时间(ISO 字符串或相对表达式如 7d, 24h, 30m) --until <时间> 截止时间(同 since 格式) -l, --limit <数量> 每页返回数量(默认 50) --offset <偏移> 分页偏移(默认 0) --sort <方向> 排序: desc(新→旧)/ asc(旧→新),默认 desc -f, --filter <表达式> 字段过滤,仅支持表的直接列(如 role=user, type=persona, priority>=80) 支持驼峰或蛇形列名,多条件用逗号分隔 --format <格式> 输出: table / json / jsonl(默认 table) -h, --help 显示帮助 L0 可过滤列: record_id, session_key, session_id, role, message_text, recorded_at, timestamp L1 可过滤列: record_id, content, type, priority, scene_name, session_key, session_id, timestamp_str, timestamp_start, timestamp_end, created_time, updated_time Examples: # 查看所有层级概览 npx tsx read-local-memory.ts -d ./memory-tdai示例数据 # 查询 L0 近 7 天的对话 npx tsx read-local-memory.ts -d ./memory-tdai示例数据 -L L0 --since 7d # 查询 L1 记忆,只看 persona 类型 npx tsx read-local-memory.ts -d ./memory-tdai示例数据 -L L1 -f 'type=persona' # L0 分页:第 2 页(每页 20 条) npx tsx read-local-memory.ts -d ./memory-tdai示例数据 -L L0 -l 20 --offset 20 # 以 JSON 格式输出 npx tsx read-local-memory.ts -d ./memory-tdai示例数据 -L L0 --since 7d --format json `.trim() // ───────────────────────────────────────────── // CLI Argument Parsing // ───────────────────────────────────────────── function parseCli(): CliOptions { const { values } = parseArgs({ options: { "data-dir": { type: "string", short: "d" }, level: { type: "string", short: "L" }, since: { type: "string" }, until: { type: "string" }, limit: { type: "string", short: "l" }, offset: { type: "string" }, sort: { type: "string" }, filter: { type: "string", short: "f" }, format: { type: "string" }, file: { type: "string" }, help: { type: "boolean", short: "h" }, }, strict: true, allowPositionals: false, }) if (values.help) { console.log(HELP_TEXT) process.exit(0) } const dataDir = values["data-dir"] if (!dataDir) { console.error("❌ 缺少必填参数: --data-dir (-d)") console.error(' 使用 --help 查看用法') process.exit(1) } const resolvedDir = path.resolve(dataDir) if (!fs.existsSync(resolvedDir)) { console.error(`❌ 数据目录不存在: ${resolvedDir}`) process.exit(1) } const level = values.level?.toUpperCase() as Level | undefined if (level && !["L0", "L1", "L2", "L3"].includes(level)) { console.error(`❌ 无效的层级: ${values.level} (可选: L0, L1, L2, L3)`) process.exit(1) } const sort = (values.sort?.toLowerCase() ?? "desc") as SortDirection if (!["asc", "desc"].includes(sort)) { console.error(`❌ 无效的排序方向: ${values.sort} (可选: asc, desc)`) process.exit(1) } const format = (values.format?.toLowerCase() ?? "table") as OutputFormat if (!["table", "json", "jsonl"].includes(format)) { console.error(`❌ 无效的输出格式: ${values.format} (可选: table, json, jsonl)`) process.exit(1) } const limit = values.limit ? parseInt(values.limit, 10) : 50 const offset = values.offset ? parseInt(values.offset, 10) : 0 if (isNaN(limit) || limit < 1) { console.error(`❌ 无效的 limit: ${values.limit}`) process.exit(1) } if (isNaN(offset) || offset < 0) { console.error(`❌ 无效的 offset: ${values.offset}`) process.exit(1) } return { dataDir: resolvedDir, level, since: values.since, until: values.until, limit, offset, sort, filter: values.filter, format, file: values.file, } } // ───────────────────────────────────────────── // Time Parsing // ───────────────────────────────────────────── /** 将时间表达式解析为 Date 对象。支持 ISO 字符串或相对表达式(7d / 24h / 30m / 60s) */ function parseTimeExpr(expr: string): Date { const match = expr.match(RELATIVE_TIME_RE) if (match) { const [, numStr, unit] = match const num = parseInt(numStr, 10) const now = Date.now() const ms: Record = { d: 86_400_000, h: 3_600_000, m: 60_000, s: 1_000, } return new Date(now - num * ms[unit]) } const date = new Date(expr) if (isNaN(date.getTime())) { console.error(`❌ 无法解析时间: ${expr}`) process.exit(1) } return date } /** 将 L0 的 epoch ms 或 L1 的 ISO 字符串统一转换为 Date */ function toDate(value: unknown): Date | null { if (typeof value === "number") return new Date(value) if (typeof value === "string") { const d = new Date(value) return isNaN(d.getTime()) ? null : d } return null } // ───────────────────────────────────────────── // Filter Parsing // ───────────────────────────────────────────── const FILTER_OPERATORS = [">=", "<=", "!=", ">", "<", "="] as const /** SQL 操作符映射(!= → <> for SQLite) */ const SQL_OPERATOR_MAP: Record = { "=": "=", "!=": "<>", ">=": ">=", "<=": "<=", ">": ">", "<": "<", } function parseFilterExpr(expr: string): FilterCondition[] { return expr.split(",").map((part) => { const trimmed = part.trim() for (const op of FILTER_OPERATORS) { const idx = trimmed.indexOf(op) if (idx > 0) { return { field: trimmed.slice(0, idx).trim(), operator: op as FilterCondition["operator"], value: trimmed.slice(idx + op.length).trim(), } } } console.error(`❌ 无法解析过滤条件: ${trimmed}`) process.exit(1) }) } /** 将用户传入的字段名解析为 SQLite 列名(支持驼峰和蛇形) */ function resolveColumnName(field: string, allowedColumns: Set): string { // 直接匹配蛇形列名 if (allowedColumns.has(field)) return field // 尝试驼峰转换 const mapped = CAMEL_TO_COLUMN[field] if (mapped && allowedColumns.has(mapped)) return mapped return field // 返回原值,后续校验会报错 } /** 校验过滤条件的列名是否在白名单中 */ function validateFilterColumns(conditions: FilterCondition[], allowedColumns: Set, level: string): void { for (const c of conditions) { const col = resolveColumnName(c.field, allowedColumns) if (!allowedColumns.has(col)) { console.error(`❌ ${level} 不支持的过滤字段: ${c.field}`) console.error(` 可用字段: ${[...allowedColumns].join(", ")}`) process.exit(1) } } } function filtersToRecord(conditions: FilterCondition[]): Record { const result: Record = {} for (const c of conditions) { result[c.field] = `${c.operator}${c.value}` } return result } function filtersToDisplayString(conditions: FilterCondition[]): string { return conditions.map((c) => `${c.field}${c.operator}${c.value}`).join(", ") } // ───────────────────────────────────────────── // SQLite Helpers // ───────────────────────────────────────────── /** 只读打开 SQLite 数据库 */ function openSqliteReadonly(dbPath: string): DatabaseSync { const { DatabaseSync: DbSync } = requireNodeSqlite() const db = new DbSync(dbPath, { open: false }) // node:sqlite 没有直接的 readOnly 选项,用 query_only pragma 保证只读 db.open() db.exec("PRAGMA query_only = ON") return db } interface SqlQueryResult { total: number records: Record[] } /** * 构建 WHERE 子句(时间过滤 + 字段过滤),返回 SQL 片段和参数。 * 所有过滤条件通过参数化查询绑定,防止 SQL 注入。 */ function buildWhereClause( level: "L0" | "L1", sinceDate: Date | null, untilDate: Date | null, filterConditions: FilterCondition[] | null, ): { whereClause: string; params: (string | number)[] } { const clauses: string[] = [] const params: (string | number)[] = [] const allowedColumns = level === "L0" ? L0_FILTER_COLUMNS : L1_FILTER_COLUMNS // 时间过滤 if (level === "L0") { // L0: timestamp 是 epoch ms (INTEGER) if (sinceDate) { clauses.push("timestamp >= ?") params.push(sinceDate.getTime()) } if (untilDate) { clauses.push("timestamp <= ?") params.push(untilDate.getTime()) } } else { // L1: updated_time 是 ISO 字符串 (TEXT) if (sinceDate) { clauses.push("updated_time >= ?") params.push(sinceDate.toISOString()) } if (untilDate) { clauses.push("updated_time <= ?") params.push(untilDate.toISOString()) } } // 字段过滤 if (filterConditions) { for (const c of filterConditions) { const col = resolveColumnName(c.field, allowedColumns) const sqlOp = SQL_OPERATOR_MAP[c.operator] clauses.push(`${col} ${sqlOp} ?`) // 如果值可解析为数字且列是数字类型,传数字;否则传字符串 const numVal = Number(c.value) params.push(!isNaN(numVal) && c.value.trim() !== "" ? numVal : c.value) } } const whereClause = clauses.length > 0 ? `WHERE ${clauses.join(" AND ")}` : "" return { whereClause, params } } /** L0 SQLite 行 → 驼峰命名输出对象 */ function mapL0Row(row: Record): Record { return { id: row.record_id, sessionKey: row.session_key, sessionId: row.session_id, role: row.role, content: row.message_text, recordedAt: row.recorded_at, timestamp: row.timestamp, } } /** L1 SQLite 行 → 驼峰命名输出对象 */ function mapL1Row(row: Record): Record { const metadataRaw = row.metadata_json as string let metadata: unknown = {} try { metadata = metadataRaw ? JSON.parse(metadataRaw) : {} } catch { metadata = {} } const timestamps = [ ...(new Set( [row.timestamp_str, row.timestamp_start, row.timestamp_end] .filter(Boolean) as string[] )) ] return { id: row.record_id, content: row.content, type: row.type, priority: row.priority, scene_name: row.scene_name, source_message_ids: [], metadata, timestamps, createdAt: row.created_time || "", updatedAt: row.updated_time || "", sessionKey: row.session_key || "", sessionId: row.session_id || "", } } function querySqlite(db: DatabaseSync, level: "L0" | "L1", opts: CliOptions): SqlQueryResult { const table = level === "L0" ? "l0_conversations" : "l1_records" const timeCol = level === "L0" ? "timestamp" : "updated_time" const allowedColumns = level === "L0" ? L0_FILTER_COLUMNS : L1_FILTER_COLUMNS const sinceDate = opts.since ? parseTimeExpr(opts.since) : null const untilDate = opts.until ? parseTimeExpr(opts.until) : null let filterConditions: FilterCondition[] | null = null if (opts.filter) { filterConditions = parseFilterExpr(opts.filter) validateFilterColumns(filterConditions, allowedColumns, level) } const { whereClause, params } = buildWhereClause(level, sinceDate, untilDate, filterConditions) // 查总数 const countSql = `SELECT COUNT(*) AS cnt FROM ${table} ${whereClause}` const countRow = db.prepare(countSql).get(...params) as { cnt: number } const total = countRow.cnt // 查数据(排序 + 分页) const sortDir = opts.sort === "asc" ? "ASC" : "DESC" const dataSql = `SELECT * FROM ${table} ${whereClause} ORDER BY ${timeCol} ${sortDir} LIMIT ? OFFSET ?` const dataParams: (string | number)[] = [...params, opts.limit, opts.offset] const rows = db.prepare(dataSql).all(...dataParams) as Record[] // 映射为驼峰命名 const mapFn = level === "L0" ? mapL0Row : mapL1Row const records = rows.map(mapFn) return { total, records } } // ───────────────────────────────────────────── // Query: L0 / L1 (SQLite) // ───────────────────────────────────────────── function querySqliteLevel(db: DatabaseSync, opts: CliOptions, level: "L0" | "L1") { const { total, records: paged } = querySqlite(db, level, opts) const timeField = level === "L0" ? "timestamp" : "updatedAt" const levelLabel = level === "L0" ? "conversations" : "records" let filterConditions: FilterCondition[] | null = null if (opts.filter) { filterConditions = parseFilterExpr(opts.filter) } const filterRecord = filterConditions ? filtersToRecord(filterConditions) : null const filterDisplay = filterConditions ? filtersToDisplayString(filterConditions) : "" const sinceInfo = opts.since ? `since=${opts.since}` : "" const untilInfo = opts.until ? `until=${opts.until}` : "" const filterParts = [filterDisplay, sinceInfo, untilInfo].filter(Boolean) if (opts.format === "json") { const result: QueryResult> = { level, total, offset: opts.offset, limit: opts.limit, sort: opts.sort, filter: filterRecord, data: paged, } console.log(JSON.stringify(result)) return } if (opts.format === "jsonl") { for (const record of paged) { console.log(JSON.stringify(record)) } return } // ── table 格式 ── const rangeStart = total === 0 ? 0 : opts.offset + 1 const rangeEnd = Math.min(opts.offset + opts.limit, total) console.log() console.log(`📊 查询结果:${level} ${levelLabel}(SQLite)`) console.log(` 总条数: ${total}`) console.log(` 当前页: ${rangeStart}-${rangeEnd} / ${total}(按 ${timeField} ${opts.sort === "desc" ? "降序" : "升序"})`) if (filterParts.length > 0) { console.log(` 过滤条件: ${filterParts.join(", ")}`) } console.log() if (paged.length === 0) { console.log(" (无匹配数据)") console.log() return } if (level === "L0") { renderL0Table(paged) } else { renderL1Table(paged) } } /** 截断字符串并添加省略号 */ function truncate(str: string, maxLen: number): string { if (!str) return "" const clean = str.replace(/\n/g, "↵").replace(/\r/g, "") if (clean.length <= maxLen) return clean return clean.slice(0, maxLen - 1) + "…" } /** 计算字符串的显示宽度(CJK 字符占 2 宽) */ function displayWidth(str: string): number { let width = 0 for (const char of str) { const code = char.codePointAt(0)! // CJK Unified Ideographs / fullwidth / common CJK ranges if ( (code >= 0x4e00 && code <= 0x9fff) || // CJK 基本 (code >= 0x3000 && code <= 0x303f) || // CJK 标点 (code >= 0xff00 && code <= 0xffef) || // 全角 (code >= 0x3400 && code <= 0x4dbf) || // CJK 扩展A (code >= 0x20000 && code <= 0x2a6df) || // CJK 扩展B (code >= 0xf900 && code <= 0xfaff) // CJK 兼容 ) { width += 2 } else { width += 1 } } return width } /** 将字符串右填充到指定显示宽度 */ function padEnd(str: string, targetWidth: number): string { const diff = targetWidth - displayWidth(str) return diff > 0 ? str + " ".repeat(diff) : str } /** 将字符串居中到指定显示宽度 */ function padCenter(str: string, targetWidth: number): string { const diff = targetWidth - displayWidth(str) if (diff <= 0) return str const left = Math.floor(diff / 2) const right = diff - left return " ".repeat(left) + str + " ".repeat(right) } /** 打印表格 */ function printTable(headers: string[], rows: string[][], colWidths: number[]) { const hLine = (left: string, mid: string, right: string, fill: string) => left + colWidths.map((w) => fill.repeat(w + 2)).join(mid) + right console.log(hLine("┌", "┬", "┐", "─")) const headerRow = headers.map((h, i) => ` ${padCenter(h, colWidths[i])} `).join("│") console.log(`│${headerRow}│`) console.log(hLine("├", "┼", "┤", "─")) for (const row of rows) { const line = row.map((cell, i) => ` ${padEnd(cell, colWidths[i])} `).join("│") console.log(`│${line}│`) } console.log(hLine("└", "┴", "┘", "─")) } /** 格式化时间为可读字符串 */ function formatTime(value: unknown): string { const date = toDate(value) if (!date) return String(value ?? "") const y = date.getFullYear() const M = String(date.getMonth() + 1).padStart(2, "0") const d = String(date.getDate()).padStart(2, "0") const h = String(date.getHours()).padStart(2, "0") const m = String(date.getMinutes()).padStart(2, "0") return `${y}-${M}-${d} ${h}:${m}` } // ───────────────────────────────────────────── // File I/O Helpers (L2 Markdown) // ───────────────────────────────────────────── /** 读取并解析 L2 Markdown 文件(含 META 头) */ function parseL2File(filePath: string): L2Entry { const content = fs.readFileSync(filePath, "utf-8") const fileName = path.basename(filePath) const startIdx = content.indexOf(META_START) const endIdx = content.indexOf(META_END) const meta: L2Meta = { created: "", updated: "", summary: "", heat: 0 } let body = content if (startIdx !== -1 && endIdx !== -1) { const metaBlock = content.slice(startIdx + META_START.length, endIdx).trim() for (const line of metaBlock.split("\n")) { const colonIdx = line.indexOf(":") if (colonIdx > 0) { const key = line.slice(0, colonIdx).trim() const val = line.slice(colonIdx + 1).trim() if (key === "heat") { meta.heat = parseInt(val, 10) || 0 } else { ;(meta as Record)[key] = val } } } body = content.slice(endIdx + META_END.length).trim() } return { fileName, meta, body } } function renderL0Table(records: Record[]) { const headers = ["#", "timestamp", "role", "content"] const colWidths = [5, 18, 10, 50] const rows = records.map((r, i) => [ String(i + 1), formatTime(r.timestamp), truncate(String(r.role ?? ""), 10), truncate(String(r.content ?? ""), 50), ]) // 动态调整内容列宽(至少 30,至多 80) const maxContentWidth = Math.min( 80, Math.max(30, ...rows.map((r) => displayWidth(r[3]))) ) colWidths[3] = maxContentWidth printTable(headers, rows, colWidths) console.log() } function renderL1Table(records: Record[]) { const headers = ["#", "updatedAt", "type", "pri", "content"] const colWidths = [5, 18, 12, 4, 50] const rows = records.map((r, i) => [ String(i + 1), formatTime(r.updatedAt), truncate(String(r.type ?? ""), 12), String(r.priority ?? ""), truncate(String(r.content ?? ""), 50), ]) const maxContentWidth = Math.min( 80, Math.max(30, ...rows.map((r) => displayWidth(r[4]))) ) colWidths[4] = maxContentWidth printTable(headers, rows, colWidths) console.log() } // ───────────────────────────────────────────── // Query: L2 (Scene Blocks) // ───────────────────────────────────────────── function queryL2(opts: CliOptions) { const dirPath = path.join(opts.dataDir, LEVEL_DIRS.L2) if (!fs.existsSync(dirPath)) { // 目录不存在是正常业务场景(尚未产生场景数据),返回空数据 if (opts.format === "json") { console.log(JSON.stringify({ level: "L2", total: 0, data: [] })) return } if (opts.format === "jsonl") { return } console.log() console.log(`📊 查询结果:L2 scene_blocks`) console.log(` (尚未生成场景数据)`) console.log() return } const files = fs.readdirSync(dirPath).filter((f) => f.endsWith(".md")).sort() const entries: L2Entry[] = files.map((f) => parseL2File(path.join(dirPath, f))) // --file 参数:只返回指定文件的完整内容(含 body) if (opts.file) { const target = entries.find((e) => e.fileName === opts.file) if (!target) { console.error(`❌ 文件不存在: ${opts.file}`) process.exit(1) } if (opts.format === "json") { console.log(JSON.stringify({ level: "L2", fileName: target.fileName, ...target.meta, body: target.body, })) return } // table / jsonl 格式直接输出文件内容 console.log(target.body) return } if (opts.format === "json") { // 默认列表模式:只输出元信息(不含 body),避免超过 TAT 24KB 输出限制 const result = { level: "L2", total: entries.length, data: entries.map(({ fileName, meta }) => ({ fileName, ...meta, })), } console.log(JSON.stringify(result)) return } if (opts.format === "jsonl") { for (const { fileName, meta, body } of entries) { console.log(JSON.stringify({ fileName, ...meta, body })) } return } // ── table 格式 ── console.log() console.log(`📊 查询结果:L2 scene_blocks`) console.log(` 总文件数: ${entries.length}`) console.log() if (entries.length === 0) { console.log(" (无场景画像文件)") console.log() return } for (const { fileName, meta, body } of entries) { console.log(`${"─".repeat(60)}`) console.log(`📄 ${fileName}`) console.log(` Summary : ${meta.summary}`) console.log(` Heat : ${meta.heat}`) console.log(` Created : ${meta.created}`) console.log(` Updated : ${meta.updated}`) console.log() // 输出正文(限制行数避免过长) const lines = body.split("\n") const maxLines = 30 if (lines.length > maxLines) { console.log(lines.slice(0, maxLines).join("\n")) console.log(` ... (省略 ${lines.length - maxLines} 行,共 ${lines.length} 行)`) } else { console.log(body) } console.log() } } // ───────────────────────────────────────────── // Query: L3 (Persona) // ───────────────────────────────────────────── function queryL3(opts: CliOptions) { const filePath = path.join(opts.dataDir, LEVEL_DIRS.L3) // 文件不存在是正常业务场景(用户还没对话、插件刚安装等),返回空数据 if (!fs.existsSync(filePath)) { if (opts.format === "json") { console.log(JSON.stringify({ level: "L3", content: "" })) return } if (opts.format === "jsonl") { console.log(JSON.stringify({ level: "L3", content: "" })) return } console.log() console.log(`📊 查询结果:L3 persona`) console.log(` (画像文件尚未生成)`) console.log() return } const content = fs.readFileSync(filePath, "utf-8") if (opts.format === "json") { console.log(JSON.stringify({ level: "L3", content })) return } if (opts.format === "jsonl") { console.log(JSON.stringify({ level: "L3", content })) return } console.log() console.log(`📊 查询结果:L3 persona`) console.log(`${"─".repeat(60)}`) console.log(content) console.log() } // ───────────────────────────────────────────── // Overview: 全层级概览 // ───────────────────────────────────────────── function showOverview(db: DatabaseSync, opts: CliOptions) { console.log() console.log(`🗂️ Memory 数据概览`) console.log(` 数据目录: ${opts.dataDir}`) console.log(` 数据库: ${SQLITE_DB_NAME}`) console.log(`${"═".repeat(60)}`) // ── L0 ── try { const l0Count = (db.prepare("SELECT COUNT(*) AS cnt FROM l0_conversations").get() as { cnt: number }).cnt const l0Roles = db.prepare("SELECT role, COUNT(*) AS cnt FROM l0_conversations GROUP BY role").all() as Array<{ role: string; cnt: number }> const roleSummary = l0Roles.map((r) => `${r.role || "unknown"}: ${r.cnt}`).join(", ") console.log() console.log(`📂 L0 · conversations (l0_conversations)`) console.log(` 总条数: ${l0Count}`) if (roleSummary) { console.log(` 角色分布: ${roleSummary}`) } } catch { console.log() console.log(`📂 L0 · conversations (表不存在或查询失败)`) } // ── L1 ── try { const l1Count = (db.prepare("SELECT COUNT(*) AS cnt FROM l1_records").get() as { cnt: number }).cnt const l1Types = db.prepare("SELECT type, COUNT(*) AS cnt FROM l1_records GROUP BY type").all() as Array<{ type: string; cnt: number }> const typeSummary = l1Types.map((t) => `${t.type || "unknown"}: ${t.cnt}`).join(", ") console.log() console.log(`📂 L1 · records (l1_records)`) console.log(` 总条数: ${l1Count}`) if (typeSummary) { console.log(` 类型分布: ${typeSummary}`) } } catch { console.log() console.log(`📂 L1 · records (表不存在或查询失败)`) } // ── L2 ── const l2Dir = path.join(opts.dataDir, LEVEL_DIRS.L2) if (fs.existsSync(l2Dir)) { const files = fs.readdirSync(l2Dir).filter((f) => f.endsWith(".md")) const entries = files.map((f) => parseL2File(path.join(l2Dir, f))) const totalHeat = entries.reduce((sum, e) => sum + e.meta.heat, 0) console.log() console.log(`📂 L2 · scene_blocks`) console.log(` 文件数: ${files.length} 总热度: ${totalHeat}`) for (const entry of entries) { console.log(` · ${entry.fileName} (heat: ${entry.meta.heat}) ${truncate(entry.meta.summary, 40)}`) } } else { console.log() console.log(`📂 L2 · scene_blocks (目录不存在)`) } // ── L3 ── const l3Path = path.join(opts.dataDir, LEVEL_DIRS.L3) if (fs.existsSync(l3Path)) { const content = fs.readFileSync(l3Path, "utf-8") const lines = content.split("\n").length const bytes = Buffer.byteLength(content, "utf-8") console.log() console.log(`📂 L3 · persona`) console.log(` 大小: ${formatBytes(bytes)} 行数: ${lines}`) } else { console.log() console.log(`📂 L3 · persona (文件不存在)`) } console.log() console.log(`${"═".repeat(60)}`) console.log(`💡 使用 -L <层级> 查看详细数据,如: -L L0 --since 7d`) console.log() } function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B` if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } // ───────────────────────────────────────────── // Main // ───────────────────────────────────────────── /** 尝试打开 SQLite 数据库,不存在时返回 null */ function tryOpenSqlite(dataDir: string): DatabaseSync | null { const dbPath = path.join(dataDir, SQLITE_DB_NAME) if (!fs.existsSync(dbPath)) { return null } return openSqliteReadonly(dbPath) } /** L0/L1 数据库不存在时返回空数据(正常业务场景:插件刚安装,尚未产生对话) */ function emptyL0L1Result(opts: CliOptions, level: "L0" | "L1") { if (opts.format === "json") { const result: QueryResult> = { level, total: 0, offset: opts.offset, limit: opts.limit, sort: opts.sort, filter: null, data: [], } console.log(JSON.stringify(result)) return } if (opts.format === "jsonl") { return } const label = level === "L0" ? "conversations" : "records" console.log() console.log(`📊 查询结果:${level} ${label}(SQLite)`) console.log(` (数据库尚未生成,暂无数据)`) console.log() } function main() { const opts = parseCli() // L2/L3 不依赖 SQLite 数据库,直接处理 if (opts.level === "L2") { queryL2(opts) return } if (opts.level === "L3") { queryL3(opts) return } // L0/L1/概览模式需要 SQLite const db = tryOpenSqlite(opts.dataDir) // 数据库不存在:L0/L1 返回空数据,概览模式提示 if (!db) { if (opts.level === "L0" || opts.level === "L1") { emptyL0L1Result(opts, opts.level) return } // 概览模式:数据库不存在,报错退出 console.error(`❌ SQLite 数据库不存在: ${path.join(opts.dataDir, SQLITE_DB_NAME)}`) console.error(` 请确认数据目录下包含 ${SQLITE_DB_NAME}`) process.exit(1) } try { if (!opts.level) { showOverview(db, opts) return } switch (opts.level) { case "L0": querySqliteLevel(db, opts, "L0") break case "L1": querySqliteLevel(db, opts, "L1") break } } finally { db.close() } } main()