* ★ 记忆层:workvault 的 Agent 会话记忆核心(纯 Node,无 Obsidian 依赖)。
*
* 理念:记忆 = 可见、可手改、git 可追踪的 Markdown(透明记忆)。
* 分类目录:facts / preferences / decisions / findings / learnings / references
* 生命周期:session_start(注入记忆上下文)→ 会话中 write/search/capture
* → session_end(写 journal + 重建概览 + 可选 auto_capture 提炼到 .pending/)
* 纪律:本工具生成的文件带指纹;无指纹的「手写」记忆绝不覆盖(与 vault 笔记同一规则)。
*/
import * as fs from "fs";
import * as path from "path";
import { execSync } from "child_process";
import type { LlmProvider } from "./provider";
import { extractFrontmatter } from "./engine";
import { embedFingerprint, extractFingerprint, stripFingerprint } from "./incremental";
import { pathSafe, assertSafeRelativePath } from "./path";
import { simpleHash } from "./template";
import { extractJson } from "./compiler";
export const MEMORY_TYPES = ["fact", "preference", "decision", "finding", "learning", "reference"] as const;
export type MemoryType = (typeof MEMORY_TYPES)[number];
export const TYPE_DIRS: Record<MemoryType, string> = {
fact: "facts",
preference: "preferences",
decision: "decisions",
finding: "findings",
learning: "learnings",
reference: "references",
};
export interface MemoryEntry {
type: MemoryType;
title: string;
description: string;
content: string;
project?: string;
agent?: string;
tags?: string[];
source?: string;
}
export interface MemoryReadEntry extends MemoryEntry {
path: string;
created?: string;
updated?: string;
}
export function resolveMemoryDir(vault: string, memoryDir?: string): string {
return path.join(vault, memoryDir ?? "memory");
}
function today(): string {
return new Date().toISOString().slice(0, 10);
}
function nowStamp(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
return `${today()} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function fmValue(v: string | undefined): string {
if (v === undefined) return "";
if (/[:#\[\]{}&*!|>'"%@`]|^\s|\s$|^[-?]/.test(v)) return JSON.stringify(v);
return v;
}
function buildFrontmatter(e: MemoryEntry, created: string, updated: string): string {
const lines = [
`type: ${e.type}`,
`description: ${fmValue(e.description)}`,
`created: ${created}`,
`updated: ${updated}`,
];
if (e.project) lines.push(`project: ${fmValue(e.project)}`);
if (e.agent) lines.push(`agent: ${fmValue(e.agent)}`);
if (e.tags && e.tags.length) lines.push(`tags: [${e.tags.join(", ")}]`);
if (e.source) lines.push(`source: ${fmValue(e.source)}`);
return `---\n${lines.join("\n")}\n---\n\n`;
}
export function parseMemoryFile(absPath: string, relPath: string): MemoryReadEntry | null {
try {
const content = fs.readFileSync(absPath, "utf-8").replace(/^\uFEFF/, "");
const fm = extractFrontmatter(content);
if (!fm) return null;
const kv: Record<string, string> = {};
for (const line of fm.split("\n")) {
const i = line.indexOf(":");
if (i <= 0) continue;
kv[line.slice(0, i).trim()] = line
.slice(i + 1)
.trim()
.replace(/^"(.*)"$/, "$1")
.replace(/^'(.*)'$/, "$1");
}
const type = kv["type"] as MemoryType;
if (!type || !MEMORY_TYPES.includes(type)) return null;
const body = content.slice(content.indexOf("\n---", 3) + 4).trim() || "";
return {
type,
title: path.basename(relPath).replace(/\.md$/, ""),
description: kv["description"] ?? "",
content: body,
project: kv["project"] || undefined,
agent: kv["agent"] || undefined,
source: kv["source"] || undefined,
path: relPath,
created: kv["created"] || undefined,
updated: kv["updated"] || undefined,
};
} catch {
return null;
}
}
export function listMemory(
vault: string,
opts: { type?: string; project?: string; limit?: number } = {},
memoryDir?: string,
): MemoryReadEntry[] {
const root = resolveMemoryDir(vault, memoryDir);
if (!fs.existsSync(root)) return [];
const out: MemoryReadEntry[] = [];
for (const dir of Object.values(TYPE_DIRS)) {
const full = path.join(root, dir);
if (!fs.existsSync(full)) continue;
for (const f of fs.readdirSync(full)) {
if (!f.endsWith(".md")) continue;
const entry = parseMemoryFile(path.join(full, f), `${dir}/${f}`);
if (!entry) continue;
if (opts.type && entry.type !== opts.type) continue;
if (opts.project && entry.project !== opts.project) continue;
out.push(entry);
}
}
const sorted = out.sort((a, b) =>
String(b.updated ?? b.created ?? "").localeCompare(String(a.updated ?? a.created ?? "")),
);
return opts.limit ? sorted.slice(0, opts.limit) : sorted;
}
export function searchMemory(
vault: string,
opts: { query?: string; type?: string; project?: string; limit?: number } = {},
memoryDir?: string,
): MemoryReadEntry[] {
const q = (opts.query ?? "").trim().toLowerCase();
const all = listMemory(vault, { type: opts.type, project: opts.project }, memoryDir);
if (!q) return all;
const tokens = q.split(/\s+/).filter(Boolean);
return all.filter((e) => {
const hay = `${e.title} ${e.description} ${e.content}`.toLowerCase();
return tokens.every((t) => hay.includes(t));
});
}
* 写入一条记忆(自动重建概览)。
* 纪律:已存在且带指纹(我们生成的)→ 允许覆盖(保留 created,更新 updated);
* 已存在但无指纹(手写)→ 拒绝覆盖,返回 skipped。
*/
export function writeMemory(
vault: string,
e: MemoryEntry,
opts: { memoryDir?: string; dryRun?: boolean } = {},
): { ok: boolean; path?: string; skipped?: boolean; reason?: string } {
const dir = TYPE_DIRS[e.type];
if (!dir) return { ok: false, reason: `未知记忆类型: ${e.type}` };
const title = pathSafe(e.title) || "untitled";
const rel = `${dir}/${title}.md`;
try {
assertSafeRelativePath(rel);
} catch (err) {
return { ok: false, reason: (err as Error).message };
}
const root = resolveMemoryDir(vault, opts.memoryDir);
const full = path.join(root, ...rel.split("/"));
let created = today();
if (fs.existsSync(full)) {
const existing = fs.readFileSync(full, "utf-8");
if (extractFingerprint(existing) === null) {
return { ok: false, skipped: true, reason: `已存在无指纹(手写)文件,拒绝覆盖: ${rel}` };
}
const prev = parseMemoryFile(full, rel);
if (prev?.created) created = prev.created;
}
const withFm = buildFrontmatter(e, created, today()) + (e.content ?? "").trim() + "\n";
const final = embedFingerprint(withFm, simpleHash(stripFingerprint(withFm)));
if (!opts.dryRun) {
fs.mkdirSync(path.dirname(full), { recursive: true });
fs.writeFileSync(full, final, "utf-8");
rebuildIndex(vault, opts.memoryDir);
}
return { ok: true, path: rel };
}
export function rebuildIndex(vault: string, memoryDir?: string): void {
const root = resolveMemoryDir(vault, memoryDir);
const entries = listMemory(vault, {}, memoryDir);
const lines = [
"# 记忆概览",
"",
"> 由 workvault 自动维护,请勿手改(记忆正文可自由编辑,本页随每次写入自动重建)。",
"",
];
for (const type of MEMORY_TYPES) {
const group = entries.filter((e) => e.type === type);
lines.push(`## ${type}s(${group.length})`);
if (group.length === 0) {
lines.push("");
continue;
}
for (const e of group) {
const date = e.updated ?? e.created ?? "";
const desc = e.description ? `— ${e.description}` : "";
lines.push(`- ${date} [${e.type}] ${desc} → [[${e.path.replace(/\.md$/, "")}]]`);
}
lines.push("");
}
const pending = listPendingMemory(vault, memoryDir);
lines.push(`## 待确认(${pending.length})`);
if (pending.length > 0) {
for (const p of pending) {
lines.push(`- ${p.type} — ${p.description} → [[${p.path.replace(/\.md$/, "")}]]`);
}
} else {
lines.push("- (无)");
}
lines.push("");
lines.push("## 会话日志");
lines.push(`- 今日:[[journals/${today()}]]`);
lines.push("");
const content = lines.join("\n");
const final = embedFingerprint(content, simpleHash(stripFingerprint(content)));
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(path.join(root, "index.md"), final, "utf-8");
}
export function listPendingMemory(vault: string, memoryDir?: string): MemoryReadEntry[] {
const root = resolveMemoryDir(vault, memoryDir);
const dir = path.join(root, ".pending");
if (!fs.existsSync(dir)) return [];
const out: MemoryReadEntry[] = [];
for (const f of fs.readdirSync(dir)) {
if (!f.endsWith(".md")) continue;
const entry = parseMemoryFile(path.join(dir, f), `.pending/${f}`);
if (entry) out.push(entry);
}
return out.sort((a, b) => a.path.localeCompare(b.path));
}
* LLM 提炼候选记忆(不直接入库)。
* writePending=false → 只返回草稿(预览);true → 写入 .pending/<stamp>-<i>.md。
*/
export async function captureMemory(
vault: string,
opts: {
provider: LlmProvider;
text: string;
schema?: string;
type?: string;
project?: string;
agent?: string;
memoryDir?: string;
writePending?: boolean;
},
): Promise<{ drafts: MemoryEntry[]; written: string[]; error?: string }> {
const schema =
opts.schema ??
[
"# 记忆提炼规范",
"从会话文本中提炼值得长期记住的记忆,只输出一个 JSON 对象:",
'{"memories":[{"type":"fact|preference|decision|finding|learning|reference","title":"短标题","description":"一句话钩子","content":"正文(Markdown)","project":"可选"}]}',
"规则:",
"- 事实/偏好/决定/新发现/学到的方法/参考资料,各归其类",
"- 含糊、临时性内容不提炼;一条记忆只讲一件事",
"- description 必须自包含(脱离上下文也能看懂)",
"- 不要编造会话中不存在的信息",
].join("\n");
const sys = `你是记忆提炼器。请严格按以下规范输出:\n---\n${schema}\n---\n只输出 JSON,不要任何解释。`;
const user = `把以下会话记录提炼为记忆:\n\n${opts.text.slice(0, 12000)}`;
let parsed: Record<string, unknown> | null = null;
let raw = "";
try {
raw = await opts.provider.ask(sys, user);
parsed = extractJson(raw);
if (!parsed || !Array.isArray(parsed.memories)) {
const retry = await opts.provider.ask(
sys,
`${user}\n\n注意:你上一次的输出不是合法 JSON。请严格只输出 {"memories":[...]},不要输出其他文字。`,
);
parsed = extractJson(retry);
}
} catch (e) {
return { drafts: [], written: [], error: (e as Error).message };
}
if (!parsed || !Array.isArray(parsed.memories)) {
return { drafts: [], written: [], error: `LLM 输出不是合法 JSON(前 200 字):${raw.slice(0, 200)}` };
}
const drafts: MemoryEntry[] = [];
for (const m of parsed.memories as Array<Record<string, unknown>>) {
const type = String(m.type ?? "") as MemoryType;
if (!MEMORY_TYPES.includes(type)) continue;
const title = String(m.title ?? "").trim();
const description = String(m.description ?? "").trim();
const content = String(m.content ?? "").trim();
if (!title || !description) continue;
drafts.push({
type,
title,
description,
content,
project: opts.project ?? (m.project ? String(m.project) : undefined),
agent: opts.agent,
source: opts.agent ? `auto_capture:${opts.agent}` : undefined,
});
}
const written: string[] = [];
if (opts.writePending !== false) {
const root = resolveMemoryDir(vault, opts.memoryDir);
const stamp = nowStamp().replace(/[\s:]/g, "-");
fs.mkdirSync(path.join(root, ".pending"), { recursive: true });
drafts.forEach((d, i) => {
const rel = `.pending/${stamp}-${i + 1}.md`;
const withFm = buildFrontmatter(d, today(), today()) + d.content + "\n";
fs.writeFileSync(
path.join(root, ...rel.split("/")),
embedFingerprint(withFm, simpleHash(stripFingerprint(withFm))),
"utf-8",
);
written.push(rel);
});
}
return { drafts, written };
}
export function confirmPending(
vault: string,
name: string,
opts: { memoryDir?: string } = {},
): { ok: boolean; path?: string; reason?: string } {
const rel = name.startsWith(".pending/") ? name : `.pending/${pathSafe(name)}.md`;
const root = resolveMemoryDir(vault, opts.memoryDir);
const full = path.join(root, ...rel.split("/"));
if (!fs.existsSync(full)) return { ok: false, reason: `挂起记忆不存在: ${rel}` };
const entry = parseMemoryFile(full, rel);
if (!entry) return { ok: false, reason: `无法解析挂起记忆: ${rel}` };
const res = writeMemory(
vault,
{
type: entry.type,
title: entry.title,
description: entry.description,
content: entry.content,
project: entry.project,
agent: entry.agent,
source: entry.source,
},
opts,
);
if (!res.ok) return res;
fs.unlinkSync(full);
return { ok: true, path: res.path };
}
* session_start:为 Agent 注入「记忆上下文」——概览 + 相关记忆 + 今日日志 + 待确认。
* 纯读取,不写任何文件。
*/
export function sessionStart(
vault: string,
opts: { project?: string; focus?: string; memoryDir?: string } = {},
): string {
const root = resolveMemoryDir(vault, opts.memoryDir);
const parts: string[] = [];
const entries = listMemory(vault, { project: opts.project }, opts.memoryDir);
if (entries.length === 0) {
parts.push("【记忆概览】暂无记忆。");
} else {
const byType = MEMORY_TYPES.map((t) => ({ t, n: entries.filter((e) => e.type === t).length }))
.filter((x) => x.n > 0)
.map((x) => `${x.t}:${x.n}`)
.join(" · ");
parts.push(`【记忆概览】共 ${entries.length} 条(${byType})。`);
}
if (opts.focus?.trim()) {
const hit = searchMemory(vault, { query: opts.focus, project: opts.project, limit: 8 }, opts.memoryDir);
if (hit.length > 0) {
parts.push(
`【相关记忆(focus: ${opts.focus})】\n` +
hit
.map((e) => `- [${e.type}] ${e.title}(${e.created ?? ""})${e.description ? ":" + e.description : ""}`)
.join("\n"),
);
}
}
const journal = path.join(root, "journals", `${today()}.md`);
if (fs.existsSync(journal)) {
const txt = fs.readFileSync(journal, "utf-8").trim();
parts.push(`【今日会话日志】\n${txt.slice(-800)}`);
}
const pending = listPendingMemory(vault, opts.memoryDir);
if (pending.length > 0) {
parts.push(
`【待确认记忆 ${pending.length} 条】用 memory confirm <名称> 逐条确认:\n` +
pending.map((p) => `- ${p.path}:${p.description}`).join("\n"),
);
} else {
parts.push("【待确认记忆】无。");
}
return parts.join("\n\n");
}
* session_end:追加今日会话日志 + 重建概览;可选 auto_capture 把摘要提炼成
* 候选记忆写入 .pending/(待确认入库);可选 git 自动提交。
*/
export async function sessionEnd(
vault: string,
opts: {
summary: string;
project?: string;
agent?: string;
memoryDir?: string;
provider?: LlmProvider;
autoCapture?: boolean;
schema?: string;
autoCommit?: boolean;
},
): Promise<{ ok: boolean; journal?: string; pending?: string[]; captureError?: string; commit?: string; error?: string }> {
try {
const root = resolveMemoryDir(vault, opts.memoryDir);
const jDir = path.join(root, "journals");
fs.mkdirSync(jDir, { recursive: true });
const jPath = path.join(jDir, `${today()}.md`);
const stamp = nowStamp();
const section = [`## ${stamp}${opts.project ? ` · ${opts.project}` : ""}`, ""];
for (const line of opts.summary.split("\n")) {
const l = line.trim();
section.push(l ? `- ${l}` : "-");
}
section.push("");
const existed = fs.existsSync(jPath);
const append = (existed ? "\n" : "# 会话日志\n\n") + section.join("\n");
fs.appendFileSync(jPath, append, "utf-8");
rebuildIndex(vault, opts.memoryDir);
let pending: string[] = [];
let captureError: string | undefined;
if (opts.provider && opts.autoCapture !== false && opts.summary.trim()) {
const cap = await captureMemory(vault, {
provider: opts.provider,
text: opts.summary,
schema: opts.schema,
project: opts.project,
agent: opts.agent,
memoryDir: opts.memoryDir,
writePending: true,
});
pending = cap.written;
captureError = cap.error;
if (cap.written.length > 0) rebuildIndex(vault, opts.memoryDir);
}
let commit: string | undefined;
if (opts.autoCommit) commit = gitCommitMemory(vault, opts.memoryDir) ?? undefined;
return { ok: true, journal: `journals/${today()}.md`, pending, captureError, commit };
} catch (e) {
return { ok: false, error: (e as Error).message };
}
}
function gitCommitMemory(vault: string, memoryDir?: string): string | null {
try {
if (!fs.existsSync(path.join(vault, ".git"))) return null;
const rel = memoryDir ?? "memory";
execSync(`git add "${rel}" && git commit -m "workvault: memory update"`, {
cwd: vault,
stdio: "pipe",
encoding: "utf-8",
});
return "已提交到 git";
} catch {
return null;
}
}