* P5 CLI 入口:workvault run --vault <dir> --profile <file|name> --data <json> [--dry-run]
* 不依赖 Obsidian(任意 Markdown 目录,Obsidian 可选),Agent/服务端/脚本/CI 可直接调用
*/
import * as fs from "fs";
import * as path from "path";
import type { Profile } from "./core/types";
import { renderSource } from "./core/engine";
import { planIncremental } from "./core/incremental";
import { FsWriter } from "./core/fsWriter";
import { applyNotes } from "./core/writer";
import { createProvider, autoProviderKind } from "./core/provider";
function llmOpts(config: any, a: Record<string, any> = {}) {
const env = process.env.WORK_VAULT_API_KEY
? {
apiBase: process.env.WORK_VAULT_API_BASE || "https://api.deepseek.com",
apiKey: process.env.WORK_VAULT_API_KEY,
model: process.env.WORK_VAULT_MODEL || "deepseek-chat",
}
: {};
return {
apiBase: a["api-base"] ?? config.provider?.apiBase ?? env.apiBase,
apiKey: a["api-key"] ?? config.provider?.apiKey ?? env.apiKey,
model: a["model"] ?? config.provider?.model ?? env.model,
maxTokens: a["max-tokens"] ? Number(a["max-tokens"]) : config.provider?.maxTokens,
};
}
import { compileRaw } from "./core/compiler";
import { lintWiki } from "./core/lint";
import { queryWiki } from "./core/query";
import { loadConfig } from "./core/config";
import { writeClaims } from "./core/ledger";
import { runSetup } from "./setup";
import { runChat } from "./chat";
import {
writeMemory,
searchMemory,
listMemory,
captureMemory,
confirmPending,
listPendingMemory,
sessionStart,
sessionEnd,
} from "./core/memory";
import type { MemoryType } from "./core/memory";
const config = loadConfig([__dirname]);
* vault 解析优先级:--vault 参数 > WORK_VAULT_VAULT 环境变量(Agent/平台注入,legacy
* WORK_OBSIDIAN_VAULT 兼容)> 配置文件。环境变量通道让 Agent 指令模板无需硬编码路径:
* 每台机器在环境变量里设一次即可。
*/
* 默认工作区:当前目录下的 .workvault 文件夹(零配置兜底)。
* · 行为确定:知识库就在运行命令的目录里,一眼可见
* · 点开头不污染目录、不与克隆自带的 workvault 目录撞名
* · 本地/容器挂载同一工作目录时,两边指向同一位置
* · 注意:随当前目录变化(每个目录一个知识库);要固定一处请用 WORK_VAULT_VAULT/--vault/配置文件
*/
function defaultWorkspaceDir(): string {
return path.join(process.cwd(), ".workvault");
}
* vault 解析优先级:--vault 参数 > WORK_VAULT_VAULT 环境变量(legacy WORK_OBSIDIAN_VAULT 兼容)
* > 配置文件 > 默认工作区 .workvault(自动创建并提示)。
*/
function vaultFor(a: Record<string, string>): string | undefined {
const envVault =
process.env.WORK_VAULT_VAULT || process.env.WORK_OBSIDIAN_VAULT || undefined;
const v = a["vault"] ?? envVault ?? config.vault;
if (v) return v;
const fallback = defaultWorkspaceDir();
try {
fs.mkdirSync(fallback, { recursive: true });
} catch {
}
console.error(
`⚠ 未指定知识库目录:默认工作区 = 当前目录下的 .workvault\n` +
` 已就绪:${fallback}\n` +
` 提示:默认工作区跟随当前目录(每个目录一个知识库);要固定一处,` +
`请 set WORK_VAULT_VAULT=<目录> 或用 --vault / workvault.config.json`,
);
return fallback;
}
const TEXT_EXTS = [".md", ".mdx", ".txt", ".json", ".yaml", ".yml", ".csv", ".html", ".py", ".js", ".ts"];
function parseArgs(argv: string[]): Record<string, string> {
const out: Record<string, string> = {};
for (let i = 0; i < argv.length; i++) {
const a = argv[i];
if (a.startsWith("--")) {
const key = a.slice(2);
const val = argv[i + 1] && !argv[i + 1].startsWith("--") ? argv[i + 1] : "true";
out[key] = val;
if (val !== "true") i++;
}
}
return out;
}
function loadProfile(ref: string): Profile {
if (fs.existsSync(ref)) {
return JSON.parse(fs.readFileSync(ref, "utf-8")) as Profile;
}
const builtin = path.join(__dirname, "templates", "profiles", `${ref}.json`);
if (fs.existsSync(builtin)) {
return JSON.parse(fs.readFileSync(builtin, "utf-8")) as Profile;
}
const examplesDir = path.join(__dirname, "examples");
if (fs.existsSync(examplesDir)) {
for (const pkg of fs.readdirSync(examplesDir)) {
const p = path.join(examplesDir, pkg, "profiles", `${ref}.json`);
if (fs.existsSync(p)) {
return JSON.parse(fs.readFileSync(p, "utf-8")) as Profile;
}
}
}
throw new Error(`profile 不存在: ${ref}(支持:路径 / 通用内置名 / 示例包名)`);
}
export async function cliMain(argv: string[]): Promise<number> {
if (argv.length === 0 || argv[0] === "chat") {
return chatMain();
}
if (argv[0] === "compile") {
return compileMain(argv.slice(1));
}
if (argv[0] === "query") {
return queryMain(argv.slice(1));
}
if (argv[0] === "setup") {
return setupMain();
}
if (argv[0] === "lint") {
return lintMain(argv.slice(1));
}
if (argv[0] === "session") {
return sessionMain(argv.slice(1));
}
if (argv[0] === "memory") {
return memoryMain(argv.slice(1));
}
const args = parseArgs(argv);
const vault = vaultFor(args);
const profileRef = args["profile"];
const dataPath = args["data"];
const dryRun = args["dry-run"] === "true" || args["dry-run"] === "";
if (!vault || !profileRef || !dataPath) {
console.error(
"用法: workvault run --vault <dir> --profile <file|name> --data <json> [--dry-run]",
);
return 2;
}
const profile = loadProfile(profileRef);
const data = fs.readFileSync(dataPath, "utf-8");
const source = { kind: "json" as const, content: data };
const { notes, issues, warnings } = renderSource(source, profile);
for (const w of warnings) console.warn("[warn]", w);
for (const i of issues) console.error("[issue]", i.path, i.issue);
if (issues.length > 0) {
console.error("存在校验问题,中止写入(可用 --dry-run 查看)");
return 1;
}
const writer = new FsWriter(vault);
const existing = await writer.listExistingPaths();
let notesToWrite = notes;
let changedPaths: string[] = [];
if (profile.incremental) {
const plan = await planIncremental(notes, writer);
notesToWrite = plan.notes;
changedPaths = plan.changed;
console.log(`[incremental] 变化 ${plan.changed.length} 篇,跳过 ${plan.unchanged.length} 篇`);
}
const result = await applyNotes(notesToWrite, writer, {
dryRun,
conflictStrategy: profile.conflictStrategy ?? "overwrite",
existingPaths: existing,
});
if (dryRun) {
console.log(`[dry-run] 将写入 ${result.written.length} 篇,跳过 ${result.skipped.length} 篇`);
for (const p of result.written) console.log(" +", p);
return 0;
}
console.log(`已写入 ${result.written.length} 篇,跳过 ${result.skipped.length} 篇,错误 ${result.errors.length}`);
for (const e of result.errors) console.error(" !", e);
return result.errors.length > 0 ? 1 : 0;
}
function lintMain(args: string[]): number {
const a = parseArgs(args);
const vault = vaultFor(a);
if (!vault) {
console.error("用法: workvault lint --vault <dir> [--wiki-dir wiki]");
return 2;
}
const wikiDir = a["wiki-dir"] ?? "wiki";
const r = lintWiki(vault, wikiDir);
console.log(`[lint] wiki 页面 ${r.totalPages} 篇`);
if (r.indexMissing) console.warn(" ! index.md 缺失(建议 compile 生成索引)");
for (const b of r.brokenLinks) console.warn(` ! 断链 ${b.from} → [[${b.link}]]`);
for (const l of r.indexBrokenLinks) console.warn(` ! index 断链 → [[${l}]]`);
for (const p of r.orphanPages) console.warn(` ! 孤立页 ${p}`);
for (const c of r.conflicts) {
console.warn(` ! 矛盾线索 ${c.page} ↔ ${c.conflict_with}(${c.source} ${c.updated}):${c.claim.slice(0, 60)}`);
}
if (r.ok) {
console.log(
r.indexMissing
? "[lint] 健康 ✓(无断链、无孤立页;index 尚未生成,可按需 compile 生成索引)"
: "[lint] 健康 ✓(无断链、无孤立页、index 一致)",
);
if (r.conflicts.length > 0) {
console.log(`[lint] ⚠ 另有 ${r.conflicts.length} 条矛盾线索(claims 台账标注),建议人工核实后处理`);
}
return 0;
}
console.log(
`[lint] 共 ${r.brokenLinks.length + r.indexBrokenLinks.length} 处断链 / ${r.orphanPages.length} 个孤立页 / ${r.conflicts.length} 条矛盾线索`,
);
return 1;
}
async function queryMain(args: string[]): Promise<number> {
const a = parseArgs(args);
const vault = vaultFor(a);
const question = a["question"];
const schemaPath = a["schema"] ?? path.join(__dirname, "templates", "schemas", "query.AGENTS.md");
const providerKind = a["provider"] ?? (autoProviderKind(config));
const archive = a["archive"] === "true" || a["archive"] === "";
if (!vault || !question) {
console.error(
"用法: workvault query --vault <dir> --question \"问题\" [--schema query.AGENTS.md] " +
"[--provider openai|echo] [--api-base <url>] [--api-key <key>] [--model <m>] [--wiki-dir wiki] [--archive] [--dry-run]",
);
return 2;
}
if (!fs.existsSync(schemaPath)) {
console.error(`schema 不存在: ${schemaPath}`);
return 2;
}
let provider;
try {
provider = createProvider(providerKind, llmOpts(config, a));
} catch (e) {
console.error((e as Error).message);
return 2;
}
console.log(`[query] ${question}`);
const result = await queryWiki({
provider,
vault,
question,
schema: fs.readFileSync(schemaPath, "utf-8"),
wikiDir: a["wiki-dir"] ?? "wiki",
archive,
dryRun: a["dry-run"] === "true" || a["dry-run"] === "",
});
for (const e of result.errors) console.error(" !", e);
if (result.answer) {
console.log("---- 回答 ----");
console.log(result.answer);
}
if (result.sources.length > 0) console.log(`\n[query] 来源: ${result.sources.join(", ")}`);
if (result.gaps.length > 0) console.log(`[query] 知识缺口: ${result.gaps.join("; ")}`);
if (archive) {
if (result.archived.length > 0) {
console.log(`[query] 已归档 ${result.archived.length} 页: ${result.archived.join(", ")}`);
} else {
console.log("[query] 无新认知归档");
}
}
return result.ok ? 0 : 1;
}
function readInput(ref: string): Array<{ name: string; content: string }> {
const stat = fs.statSync(ref);
if (stat.isDirectory()) {
const out: Array<{ name: string; content: string }> = [];
const walk = (dir: string, prefix: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith(".")) continue;
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full, rel);
else if (entry.isFile() && TEXT_EXTS.includes(path.extname(entry.name).toLowerCase())) {
out.push({ name: rel, content: fs.readFileSync(full, "utf-8") });
}
}
};
walk(ref, "");
return out;
}
return [{ name: path.basename(ref), content: fs.readFileSync(ref, "utf-8") }];
}
async function compileMain(args: string[]): Promise<number> {
const a = parseArgs(args);
const vault = vaultFor(a);
const schemaPath = a["schema"] ?? path.join(__dirname, "templates", "schemas", "wiki.AGENTS.md");
const inputRef = a["input"];
const providerKind = a["provider"] ?? (autoProviderKind(config));
const instruction = a["instruction"];
if (!vault || !schemaPath || !inputRef) {
console.error(
"用法: workvault compile --vault <dir> --schema <file> --input <dir|file> " +
"[--provider openai|echo] [--api-base <url>] [--api-key <key>] [--model <m>] " +
"[--instruction \"自然语言指令\"] [--wiki-dir wiki] [--dry-run]",
);
return 2;
}
if (!fs.existsSync(schemaPath)) {
console.error(`schema 文件不存在: ${schemaPath}`);
return 2;
}
if (!fs.existsSync(inputRef)) {
console.error(`input 不存在: ${inputRef}`);
return 2;
}
let provider;
try {
provider = createProvider(providerKind, llmOpts(config, a));
} catch (e) {
console.error((e as Error).message);
return 2;
}
const schema = fs.readFileSync(schemaPath, "utf-8");
const inputs = readInput(inputRef);
if (inputs.length === 0) {
console.error("input 中没有可编译的文本文件");
return 2;
}
console.log(`[compile] 资料 ${inputs.length} 份 · provider ${providerKind} · schema ${path.basename(schemaPath)}`);
if (instruction) console.log(`[compile] 指令: ${instruction}`);
const result = await compileRaw({
provider,
schema,
inputs,
instruction,
vault,
wikiDir: a["wiki-dir"] ?? "wiki",
dryRun: a["dry-run"] === "true" || a["dry-run"] === "",
});
if (!(a["dry-run"] === "true" || a["dry-run"] === "") && result.claims.length > 0) {
writeClaims(vault, a["wiki-dir"] ?? "wiki", result.claims);
}
if (result.log) console.log(`[compile] ${result.log}`);
console.log(`[compile] claims ${result.claims.length} 条`);
for (const e of result.errors) console.error(" !", e);
if (a["dry-run"] === "true" || a["dry-run"] === "") {
console.log(`[compile] 将写入 ${result.written.length} 篇,跳过 ${result.skipped.length} 篇(dry-run)`);
for (const p of result.written) console.log(" +", p);
} else {
console.log(
`[compile] 已写入 ${result.written.length} 篇,跳过 ${result.skipped.length} 篇,未变 ${result.unchanged.length} 篇,错误 ${result.errors.length}`,
);
}
return result.ok ? 0 : 1;
}
async function setupMain(): Promise<number> {
const cfg = await runSetup();
if (cfg === null) {
return chatMain();
}
const target = path.join(process.cwd(), "workvault.config.json");
console.log("\n✅ 已生成 " + target);
console.log(" vault: " + cfg.vault);
console.log(" LLM: " + (cfg.provider.apiBase || "?") + " | " + (cfg.provider.model || "?"));
if (cfg.provider.apiKey) {
console.log(" API Key: 已配置(" + String(cfg.provider.apiKey).slice(0, 6) + "…)");
} else {
console.log(" ⚠️ API Key 未配置,可重跑 setup 补上");
}
console.log("");
return chatMain();
}
async function chatMain(): Promise<number> {
const vault = vaultFor({});
if (!vault) {
console.error("未配置 vault。请先运行: node cli.js setup");
return 2;
}
let provider;
try {
provider = createProvider(autoProviderKind(config), llmOpts(config));
} catch (e) {
console.error((e as Error).message);
return 2;
}
const readSchema = (name: string): string => {
const p = path.join(__dirname, "templates", "schemas", name);
return fs.existsSync(p) ? fs.readFileSync(p, "utf-8") : "";
};
return runChat({
provider,
vault,
schemaWiki: readSchema("wiki.AGENTS.md"),
schemaQuery: readSchema("query.AGENTS.md"),
cwd: process.cwd(),
});
}
async function sessionMain(args: string[]): Promise<number> {
const cmd = args[0];
const a = parseArgs(args.slice(1));
const vault = vaultFor(a);
if (!vault) {
console.error("用法: workvault session <start|end> --vault <dir> ...");
return 2;
}
const memoryDir = a["memory-dir"];
if (cmd === "start") {
const context = sessionStart(vault, { project: a["project"], focus: a["focus"], memoryDir });
console.log(context);
return 0;
}
if (cmd === "end") {
const summary = a["summary"];
if (!summary) {
console.error("session end 需要 --summary \"会话摘要\"");
return 2;
}
const autoCapture = a["auto-capture"] === "true" || a["auto-capture"] === "";
let provider;
if (autoCapture) {
try {
provider = createProvider(a["provider"] ?? autoProviderKind(config), llmOpts(config, a));
} catch (e) {
console.error((e as Error).message);
return 2;
}
}
const schemaPath = path.join(__dirname, "templates", "schemas", "memory.AGENTS.md");
const r = await sessionEnd(vault, {
summary,
project: a["project"],
agent: a["agent"],
memoryDir,
provider,
autoCapture,
schema: fs.existsSync(schemaPath) ? fs.readFileSync(schemaPath, "utf-8") : undefined,
autoCommit: a["auto-commit"] === "true" || a["auto-commit"] === "",
});
if (!r.ok) {
console.error("! " + r.error);
return 1;
}
console.log(`[session] 日志 → ${r.journal}`);
if (r.pending && r.pending.length > 0) {
console.log(`[session] 提炼 ${r.pending.length} 条候选记忆到 .pending/(确认: workvault memory pending --confirm <名称>)`);
}
if (r.commit) console.log("[session] " + r.commit);
if (r.captureError) console.error("! 提炼失败: " + r.captureError);
return 0;
}
console.error("用法: workvault session <start|end> --vault <dir> ...");
return 2;
}
async function memoryMain(args: string[]): Promise<number> {
const cmd = args[0];
const a = parseArgs(args.slice(1));
const vault = vaultFor(a);
if (!vault) {
console.error("用法: workvault memory <write|search|list|capture|pending> --vault <dir> ...");
return 2;
}
const memoryDir = a["memory-dir"];
if (cmd === "write") {
let entry: Record<string, unknown>;
if (a["json"]) {
try {
entry = JSON.parse(a["json"]);
} catch (e) {
console.error("--json 不是合法 JSON: " + (e as Error).message);
return 2;
}
} else {
entry = {
type: a["type"],
title: a["title"],
description: a["description"],
content: a["content"] ?? "",
project: a["project"] || undefined,
agent: a["agent"] || undefined,
source: a["source"] || undefined,
tags: a["tags"] ? a["tags"].split(",").map((s) => s.trim()).filter(Boolean) : undefined,
};
if (!entry.type || !entry.title || !entry.description) {
console.error("memory write 需要 --type --title --description(或用 --json 传整条)");
return 2;
}
}
const r = writeMemory(
vault,
{
type: String(entry.type) as MemoryType,
title: String(entry.title),
description: String(entry.description),
content: String(entry.content ?? ""),
project: entry.project ? String(entry.project) : undefined,
agent: entry.agent ? String(entry.agent) : undefined,
source: entry.source ? String(entry.source) : undefined,
tags: Array.isArray(entry.tags) ? entry.tags.map(String) : undefined,
},
{ memoryDir, dryRun: a["dry-run"] === "true" || a["dry-run"] === "" },
);
if (!r.ok) {
console.error("! " + r.reason);
return 1;
}
console.log(`✓ memory/${r.path}` + (a["dry-run"] === "true" || a["dry-run"] === "" ? "(dry-run)" : ""));
return 0;
}
if (cmd === "search") {
const hits = searchMemory(
vault,
{ query: a["query"], type: a["type"], project: a["project"], limit: a["limit"] ? Number(a["limit"]) : undefined },
memoryDir,
);
if (hits.length === 0) {
console.log("无匹配记忆");
return 0;
}
for (const h of hits) {
console.log(`- [${h.type}] ${h.title}(${h.updated ?? h.created ?? ""})${h.description ? ":" + h.description : ""}`);
}
console.log(`共 ${hits.length} 条`);
return 0;
}
if (cmd === "list") {
const entries = listMemory(vault, { type: a["type"], project: a["project"] }, memoryDir);
if (entries.length === 0) {
console.log("暂无记忆");
return 0;
}
for (const e of entries) {
console.log(`- [${e.type}] ${e.title}(${e.updated ?? e.created ?? ""})${e.description ? ":" + e.description : ""}`);
}
console.log(`共 ${entries.length} 条`);
return 0;
}
if (cmd === "capture") {
const text = a["text"];
if (!text) {
console.error("memory capture 需要 --text \"会话内容\"");
return 2;
}
let provider;
try {
provider = createProvider(a["provider"] ?? autoProviderKind(config), llmOpts(config, a));
} catch (e) {
console.error((e as Error).message);
return 2;
}
const schemaPath = path.join(__dirname, "templates", "schemas", "memory.AGENTS.md");
const cap = await captureMemory(vault, {
provider,
text,
project: a["project"],
agent: a["agent"],
memoryDir,
schema: fs.existsSync(schemaPath) ? fs.readFileSync(schemaPath, "utf-8") : undefined,
writePending: a["preview"] !== "true",
});
if (cap.error) {
console.error("! " + cap.error);
return 1;
}
if (cap.drafts.length === 0) {
console.log("没有提炼出值得记忆的内容");
return 0;
}
for (const d of cap.drafts) {
console.log(`- [${d.type}] ${d.title}:${d.description}`);
}
if (cap.written.length > 0) {
console.log(`已写入 ${cap.written.length} 条到 .pending/(确认: workvault memory pending --confirm <名称>)`);
} else {
console.log("(preview 模式,未写入)");
}
return 0;
}
if (cmd === "pending") {
if (a["confirm"]) {
const r = confirmPending(vault, a["confirm"], { memoryDir });
if (!r.ok) {
console.error("! " + r.reason);
return 1;
}
console.log(`✓ 已确认入库: memory/${r.path}`);
return 0;
}
const pending = listPendingMemory(vault, memoryDir);
if (pending.length === 0) {
console.log("无挂起记忆");
return 0;
}
for (const p of pending) {
console.log(`- ${p.path} [${p.type}] ${p.description}`);
}
console.log(`共 ${pending.length} 条(确认: workvault memory pending --confirm <名称>)`);
return 0;
}
console.error("用法: workvault memory <write|search|list|capture|pending> --vault <dir> ...");
return 2;
}
if (require.main === module) {
cliMain(process.argv.slice(2)).then((code) => process.exit(code));
}