@tencentdb-agent-memory/memory-tencentdb
Version:
Four-layer local memory system plugin for OpenClaw — auto-captures, structures, and profiles conversational knowledge using local LLM + SQLite vector search (L0→L1→L2→L3 pipeline)
877 lines (867 loc) • 34.3 kB
JavaScript
/**
* 本地 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 * as fs from "node:fs";
import * as path from "node:path";
import { parseArgs } from "node:util";
const require = createRequire(import.meta.url);
function requireNodeSqlite() {
return require("node:sqlite");
}
// ─────────────────────────────────────────────
// Constants
// ─────────────────────────────────────────────
const SQLITE_DB_NAME = "vectors.db";
const LEVEL_DIRS = {
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 = {
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() {
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();
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");
if (!["asc", "desc"].includes(sort)) {
console.error(`❌ 无效的排序方向: ${values.sort} (可选: asc, desc)`);
process.exit(1);
}
const format = (values.format?.toLowerCase() ?? "table");
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) {
const match = expr.match(RELATIVE_TIME_RE);
if (match) {
const [, numStr, unit] = match;
const num = parseInt(numStr, 10);
const now = Date.now();
const ms = {
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) {
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 = [">=", "<=", "!=", ">", "<", "="];
/** SQL 操作符映射(!= → <> for SQLite) */
const SQL_OPERATOR_MAP = {
"=": "=",
"!=": "<>",
">=": ">=",
"<=": "<=",
">": ">",
"<": "<",
};
function parseFilterExpr(expr) {
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,
value: trimmed.slice(idx + op.length).trim(),
};
}
}
console.error(`❌ 无法解析过滤条件: ${trimmed}`);
process.exit(1);
});
}
/** 将用户传入的字段名解析为 SQLite 列名(支持驼峰和蛇形) */
function resolveColumnName(field, allowedColumns) {
// 直接匹配蛇形列名
if (allowedColumns.has(field))
return field;
// 尝试驼峰转换
const mapped = CAMEL_TO_COLUMN[field];
if (mapped && allowedColumns.has(mapped))
return mapped;
return field; // 返回原值,后续校验会报错
}
/** 校验过滤条件的列名是否在白名单中 */
function validateFilterColumns(conditions, allowedColumns, level) {
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) {
const result = {};
for (const c of conditions) {
result[c.field] = `${c.operator}${c.value}`;
}
return result;
}
function filtersToDisplayString(conditions) {
return conditions.map((c) => `${c.field}${c.operator}${c.value}`).join(", ");
}
// ─────────────────────────────────────────────
// SQLite Helpers
// ─────────────────────────────────────────────
/** 只读打开 SQLite 数据库 */
function openSqliteReadonly(dbPath) {
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;
}
/**
* 构建 WHERE 子句(时间过滤 + 字段过滤),返回 SQL 片段和参数。
* 所有过滤条件通过参数化查询绑定,防止 SQL 注入。
*/
function buildWhereClause(level, sinceDate, untilDate, filterConditions) {
const clauses = [];
const params = [];
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) {
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) {
const metadataRaw = row.metadata_json;
let metadata = {};
try {
metadata = metadataRaw ? JSON.parse(metadataRaw) : {};
}
catch {
metadata = {};
}
const timestamps = [
...(new Set([row.timestamp_str, row.timestamp_start, row.timestamp_end]
.filter(Boolean)))
];
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, level, opts) {
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 = 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);
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 = [...params, opts.limit, opts.offset];
const rows = db.prepare(dataSql).all(...dataParams);
// 映射为驼峰命名
const mapFn = level === "L0" ? mapL0Row : mapL1Row;
const records = rows.map(mapFn);
return { total, records };
}
// ─────────────────────────────────────────────
// Query: L0 / L1 (SQLite)
// ─────────────────────────────────────────────
function querySqliteLevel(db, opts, level) {
const { total, records: paged } = querySqlite(db, level, opts);
const timeField = level === "L0" ? "timestamp" : "updatedAt";
const levelLabel = level === "L0" ? "conversations" : "records";
let filterConditions = 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 = {
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, maxLen) {
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) {
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, targetWidth) {
const diff = targetWidth - displayWidth(str);
return diff > 0 ? str + " ".repeat(diff) : str;
}
/** 将字符串居中到指定显示宽度 */
function padCenter(str, targetWidth) {
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, rows, colWidths) {
const hLine = (left, mid, right, fill) => 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) {
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) {
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 = { 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[key] = val;
}
}
}
body = content.slice(endIdx + META_END.length).trim();
}
return { fileName, meta, body };
}
function renderL0Table(records) {
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) {
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) {
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 = 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) {
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, opts) {
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().cnt;
const l0Roles = db.prepare("SELECT role, COUNT(*) AS cnt FROM l0_conversations GROUP BY role").all();
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().cnt;
const l1Types = db.prepare("SELECT type, COUNT(*) AS cnt FROM l1_records GROUP BY type").all();
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) {
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) {
const dbPath = path.join(dataDir, SQLITE_DB_NAME);
if (!fs.existsSync(dbPath)) {
return null;
}
return openSqliteReadonly(dbPath);
}
/** L0/L1 数据库不存在时返回空数据(正常业务场景:插件刚安装,尚未产生对话) */
function emptyL0L1Result(opts, level) {
if (opts.format === "json") {
const result = {
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();