Files
TencentDB-Agent-Memory/scripts/read-local-memory/read-local-memory.ts
T
2026-05-20 22:58:05 +08:00

1058 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env npx tsx
/**
* 本地 Memory 数据查询脚本
*
* 查询 memory-tdai 目录下的记忆数据,支持:
* - 按层级(L0~L3)查询
* - L0/L1 从 SQLitevectors.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<T> {
level: string
total: number
offset: number
limit: number
sort: SortDirection
filter: Record<string, string> | null
data: T[]
}
// ─────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────
const SQLITE_DB_NAME = "vectors.db"
const LEVEL_DIRS: Record<string, string> = {
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<string, string> = {
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.dbSQLite 数据库),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<string, number> = {
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<string, string> = {
"=": "=",
"!=": "<>",
">=": ">=",
"<=": "<=",
">": ">",
"<": "<",
}
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>): 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<string>, 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<string, string> {
const result: Record<string, string> = {}
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<string, unknown>[]
}
/**
* 构建 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<string, unknown>): Record<string, unknown> {
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<string, unknown>): Record<string, unknown> {
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<string, unknown>[]
// 映射为驼峰命名
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<Record<string, unknown>> = {
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<string, string | number>)[key] = val
}
}
}
body = content.slice(endIdx + META_END.length).trim()
}
return { fileName, meta, body }
}
function renderL0Table(records: Record<string, unknown>[]) {
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<string, unknown>[]) {
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<Record<string, unknown>> = {
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()