* TaskLens pi 扩展。
*
* 把 TaskLens 知识库的能力注册成 pi 的 agent 工具(17 个)与斜杠命令(4 个)。
* 所有工具都通过 `tasklens-cli`(NDJSON over stdio)调用 Rust core,因此桌面
* 应用、浏览器开发服务器与 agent 对知识库的语义完全一致。
*
* 工具名与参数与 MCP server 版本(`src-tauri` 之外的 MCP 交付物)保持一致:
* `vault_list` / `vault_read` / … / `vault_git_sync`。
*/
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import process from "node:process";
import type {
AgentToolResult,
ExtensionAPI,
ExtensionCommandContext,
ExtensionContext,
ToolDefinition,
} from "@earendil-works/pi-coding-agent";
import { Type } from "typebox";
const TOOL_COUNT = 17;
const TIMEOUT_MS = 60_000;
const READ_LIMIT = 20_000;
const BRIEF_BODY_LIMIT = 8_000;
const LIST_LIMIT = 200;
const CLI_BIN = process.platform === "win32" ? "tasklens-cli.exe" : "tasklens-cli";
const CLI_MISSING_HINT =
`未找到 \`${CLI_BIN}\`。请让 TASK_LENS_CLI 指向二进制绝对路径,` +
"或先执行 `bun run core:build` 并把 target/debug 加入 PATH。";
const VAULT_CONVENTIONS =
"Vault 约定:笔记类型写在 frontmatter `type:`(目录只是分类,类型目录下的子目录不改变类型)——" +
"`任务`(默认 `任务/`,字段 status/priority/category/created/owner/related_to)、" +
"`日志`(默认 `工作日志/`,字段 date)、`经验`(默认 `经验/`)、`成果`(默认 `成果/`)、" +
"`记忆碎片`(默认 `记忆碎片/`,字段 created/related_to)。" +
"目录可在设置里改,动手前用 vault_list 看该类型笔记实际在哪。" +
"任务正文分节:`## 背景`、`## 子任务`(`- [ ]` 清单)、`## 变更日志`(`- 日期: 内容`)。" +
'关联用 wikilink:related_to: ["[[软包工作-任务]]"]。';
type VaultPayload = { ok: true; data: unknown } | { ok: false; error: string };
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function messageOf(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
}
function describeSpawnError(cause: unknown): string {
if (isRecord(cause) && cause.code === "ENOENT") return CLI_MISSING_HINT;
return `无法启动 \`${CLI_BIN}\`:${messageOf(cause)}`;
}
function stderrTail(stderr: string): string {
const trimmed = stderr.trim();
return trimmed ? `(stderr:${trimmed.slice(-500)})` : "";
}
function parseResponse(line: string, stderr: string): VaultPayload {
if (!line) {
return { ok: false, error: `tasklens-cli 没有返回任何内容${stderrTail(stderr)}` };
}
let parsed: unknown;
try {
parsed = JSON.parse(line);
} catch {
return { ok: false, error: `tasklens-cli 返回了非法 JSON:${line.slice(0, 200)}` };
}
if (isRecord(parsed) && parsed.ok === true) return { ok: true, data: parsed.data };
if (isRecord(parsed) && parsed.ok === false && typeof parsed.error === "string") {
return { ok: false, error: parsed.error };
}
return { ok: false, error: `tasklens-cli 返回了未知结构:${line.slice(0, 200)}` };
}
* 执行一条知识库命令:spawn CLI,写一行 JSON,读一行响应。
* 任何失败(找不到二进制、超时、非法 JSON、命令报错)都转成 `{ok:false}`,
* 不抛异常,保证扩展不会把 pi 带崩。
*/
function callVault(
command: string,
args: Record<string, unknown>,
signal?: AbortSignal,
): Promise<VaultPayload> {
const cli = process.env.TASK_LENS_CLI?.trim() || CLI_BIN;
const vault = process.env.TASK_LENS_VAULT?.trim();
const argv = vault ? ["--vault", vault] : [];
return new Promise<VaultPayload>((resolve) => {
let settled = false;
let child: ChildProcessWithoutNullStreams | undefined;
let timer: NodeJS.Timeout | undefined;
const finish = (result: VaultPayload): void => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
child?.kill();
resolve(result);
};
const onAbort = (): void => finish({ ok: false, error: `已取消:\`${command}\`` });
if (signal?.aborted) {
finish({ ok: false, error: `已取消:\`${command}\`` });
return;
}
try {
child = spawn(cli, argv, { stdio: ["pipe", "pipe", "pipe"], windowsHide: true });
} catch (cause) {
finish({ ok: false, error: describeSpawnError(cause) });
return;
}
const running = child;
timer = setTimeout(() => {
finish({ ok: false, error: `\`${command}\` 超过 ${TIMEOUT_MS / 1000} 秒没有响应,已终止进程。` });
}, TIMEOUT_MS);
signal?.addEventListener("abort", onAbort, { once: true });
let stdout = "";
let stderr = "";
running.stdout.setEncoding("utf8");
running.stderr.setEncoding("utf8");
running.stdout.on("data", (chunk: string) => {
stdout += chunk;
const newline = stdout.indexOf("\n");
if (newline === -1) return;
finish(parseResponse(stdout.slice(0, newline).trim(), stderr));
});
running.stderr.on("data", (chunk: string) => {
stderr += chunk;
});
running.on("error", (cause) => finish({ ok: false, error: describeSpawnError(cause) }));
running.on("close", (code) => {
finish({
ok: false,
error: `tasklens-cli 提前退出(退出码 ${code ?? "?"})${stderrTail(stderr)}`,
});
});
running.stdin.on("error", () => {});
running.stdin.end(`${JSON.stringify({ cmd: command, args })}\n`);
});
}
async function invoke(
command: string,
args: Record<string, unknown>,
signal: AbortSignal | undefined,
format: (data: unknown) => string,
): Promise<VaultToolResult> {
const result = await callVault(command, args, signal);
if (!result.ok) return text(`错误:${result.error}`);
try {
return text(format(result.data));
} catch (cause) {
return text(`错误:解析知识库返回失败:${messageOf(cause)}`);
}
}
type Checked<T> = { ok: true; value: T } | { ok: false; error: string };
function checkPath(raw: unknown): Checked<string> {
if (typeof raw !== "string" || !raw.trim()) {
return { ok: false, error: "`path` 必须是非空的知识库内相对路径。" };
}
let value = raw.trim().replace(/^@/, "").replace(/\\/g, "/");
if (value.startsWith("//") || value.startsWith("/") || /^[a-zA-Z]:/.test(value)) {
return { ok: false, error: `\`path\` 必须是知识库内相对路径,不能是绝对路径:${raw}` };
}
const segments = value.split("/").filter((segment) => segment !== "" && segment !== ".");
if (segments.some((segment) => segment === "..")) {
return { ok: false, error: `\`path\` 不能包含 \`..\`:${raw}` };
}
value = segments.join("/");
if (!value) return { ok: false, error: "`path` 必须是非空的知识库内相对路径。" };
return { ok: true, value };
}
function checkNonEmptyString(raw: unknown, name: string): Checked<string> {
if (typeof raw !== "string" || !raw.trim()) {
return { ok: false, error: `\`${name}\` 必须是非空字符串。` };
}
return { ok: true, value: raw };
}
function checkLine(raw: unknown, name: string): Checked<number> {
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) {
return { ok: false, error: `\`${name}\` 必须是 ≥1 的整数行号(1 表示文件第一行)。` };
}
return { ok: true, value: raw };
}
function checkUpdates(raw: unknown): Checked<Record<string, unknown>> {
if (!isRecord(raw)) {
return { ok: false, error: "`updates` 必须是一个对象(键为字段名,值为字段内容)。" };
}
return { ok: true, value: raw };
}
function checkRemovals(raw: unknown): Checked<string[]> {
if (raw === undefined || raw === null) return { ok: true, value: [] };
if (!Array.isArray(raw) || raw.some((item) => typeof item !== "string")) {
return { ok: false, error: "`removals` 必须是字符串数组(要删除的 frontmatter 字段名)。" };
}
return { ok: true, value: raw.filter((item): item is string => typeof item === "string") };
}
function checkLines(raw: unknown): Checked<number[]> {
if (!Array.isArray(raw) || raw.length === 0) {
return { ok: false, error: "`lines` 必须是至少含一个行号的数组。" };
}
const lines: number[] = [];
for (const item of raw) {
const checked = checkLine(item, "lines[]");
if (!checked.ok) return { ok: false, error: checked.error };
lines.push(checked.value);
}
return { ok: true, value: lines };
}
function checkLimit(raw: unknown): Checked<number | undefined> {
if (raw === undefined || raw === null) return { ok: true, value: undefined };
if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 1) {
return { ok: false, error: "`limit` 必须是 ≥1 的整数。" };
}
return { ok: true, value: raw };
}
function errorText(error: string): VaultToolResult {
return text(`参数错误:${error}`);
}
type VaultToolResult = AgentToolResult<Record<string, unknown>>;
function text(body: string): VaultToolResult {
return { content: [{ type: "text", text: body }], details: {} };
}
function str(value: unknown, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function num(value: unknown, fallback = 0): number {
return typeof value === "number" && Number.isFinite(value) ? value : fallback;
}
function list(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function pad2(value: number): string {
return String(value).padStart(2, "0");
}
function formatWhen(ms: number): string {
if (!Number.isFinite(ms) || ms <= 0) return "-";
const date = new Date(ms);
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}`;
}
function todayIso(): string {
const date = new Date();
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}`;
}
function escapeCell(value: string): string {
return value.replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
}
function fenceFor(content: string): string {
const runs = content.match(/`+/g) ?? [];
const longest = runs.reduce((max, run) => Math.max(max, run.length), 2);
return "`".repeat(longest + 1);
}
function fenced(content: string, language = "markdown"): string {
const fence = fenceFor(content);
return `${fence}${language}\n${content}\n${fence}`;
}
function truncate(content: string, limit: number, label: string): string {
if (content.length <= limit) return content;
return `${content.slice(0, limit)}\n…(${label},已截断到 ${limit} 字符,原文 ${content.length} 字符)`;
}
function noteRefLines(refs: unknown[], prefix: string): string[] {
if (refs.length === 0) return [`- ${prefix}:(无)`];
return refs.map((raw) => {
const ref = isRecord(raw) ? raw : {};
const snippet = str(ref.snippet);
const suffix = snippet ? ` — ${snippet}` : "";
return `- ${prefix}:\`${str(ref.path)}\`(${str(ref.title) || "-"})${suffix}`;
});
}
function formatEntryTable(data: unknown, typeFilter: string | undefined): string {
const payload = isRecord(data) ? data : {};
const root = str(payload.root);
const all = list(payload.entries);
const matched = typeFilter
? all.filter((entry) => isRecord(entry) && str(entry.noteType) === typeFilter)
: all;
const shown = matched.slice(0, LIST_LIMIT);
const header = typeFilter
? `Vault:\`${root}\`,类型 \`${typeFilter}\` 共 ${matched.length} 条`
: `Vault:\`${root}\`,共 ${matched.length} 条`;
if (shown.length === 0) {
return `${header}\n\n(没有匹配的笔记。用 vault_search 全文检索,或确认 type 取值:任务 / 日志 / 经验 / 成果。)`;
}
const rows = shown.map((raw) => {
const entry = isRecord(raw) ? raw : {};
const frontmatter = isRecord(entry.frontmatter) ? entry.frontmatter : {};
return [
`\`${escapeCell(str(entry.path))}\``,
escapeCell(str(entry.title) || str(entry.name) || "-"),
escapeCell(str(entry.noteType) || "-"),
escapeCell(str(frontmatter.status) || "-"),
escapeCell(str(frontmatter.priority) || "-"),
escapeCell(str(frontmatter.category) || "-"),
`${num(entry.openTasks)}/${num(entry.doneTasks)}`,
escapeCell(formatWhen(num(entry.modifiedMs))),
].join(" | ");
});
const table = [
"| 路径 | 标题 | 类型 | 状态 | 优先级 | 分类 | 未完成/完成 | 更新时间 |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
...rows.map((row) => `| ${row} |`),
].join("\n");
const overflow =
matched.length > shown.length ? `\n\n(只显示前 ${LIST_LIMIT} 条,共 ${matched.length} 条。)` : "";
return `${header}\n\n${table}${overflow}`;
}
function formatRead(data: unknown): string {
const note = isRecord(data) ? data : {};
const path = str(note.path);
const content = str(note.content);
const meta = `\`${path}\`(${num(note.size)} 字节,更新于 ${formatWhen(num(note.modifiedMs))})`;
const body = truncate(content, READ_LIMIT, "内容过长");
return `${meta}\n\n${fenced(body)}`;
}
function formatSearch(data: unknown, query: string, limit: number | undefined): string {
const payload = isRecord(data) ? data : {};
const hits = list(payload.hits);
const scope = limit ? `,limit=${limit}` : "";
if (hits.length === 0) return `搜索 \`${query}\`:没有命中${scope}。`;
const lines = hits.map((raw) => {
const hit = isRecord(raw) ? raw : {};
const type = str(hit.noteType);
return `- \`${str(hit.path)}:${num(hit.line)}\`${type ? ` [${type}]` : ""} — ${str(hit.text)}`;
});
return `搜索 \`${query}\`:命中 ${hits.length} 条${scope}\n\n${lines.join("\n")}`;
}
function formatBrief(data: unknown): string {
const brief = isRecord(data) ? data : {};
const frontmatter = isRecord(brief.frontmatter) ? brief.frontmatter : {};
const lines: string[] = [];
lines.push(`# 任务简报:${str(brief.title) || str(brief.path)}`);
lines.push("");
lines.push(`- 路径:\`${str(brief.path)}\``);
lines.push(`- 类型:${str(brief.noteType) || "未知"}`);
lines.push(`- 子任务:${num(brief.openTasks)} 未完成 / ${num(brief.doneTasks)} 已完成`);
const keys = Object.keys(frontmatter);
if (keys.length > 0) {
lines.push(`- Frontmatter:${keys.map((key) => `${key}=${JSON.stringify(frontmatter[key])}`).join(",")}`);
}
const subtasks = list(brief.subtasks);
lines.push("", `## 子任务(${subtasks.length} 条,行号可用 vault_toggle_checkbox / vault_set_line / vault_delete_line)`);
if (subtasks.length === 0) {
lines.push("(无,可用 vault_append_item 往「子任务」小节追加 `- [ ] …`)");
} else {
for (const raw of subtasks) {
const item = isRecord(raw) ? raw : {};
const mark = item.checked === true ? "[x]" : "[ ]";
lines.push(`- ${mark} 第 ${num(item.line)} 行:${str(item.text)}`);
}
}
const changelog = list(brief.changelog);
lines.push("", "## 变更日志");
if (changelog.length === 0) lines.push("(无)");
else for (const entry of changelog) lines.push(`- ${str(entry)}`);
const workLogs = list(brief.workLogs);
lines.push("", "## 提到它的日志");
if (workLogs.length === 0) lines.push("(无)");
else {
for (const raw of workLogs) {
const log = isRecord(raw) ? raw : {};
lines.push(`- \`${str(log.path)}\`(${str(log.date)})`);
for (const hit of list(log.lines)) lines.push(` - ${str(hit)}`);
}
}
const experiences = list(brief.experiences);
const outcomes = list(brief.outcomes);
const related = list(brief.related);
const backlinks = list(brief.backlinks);
lines.push("", "## 相关笔记");
lines.push(...noteRefLines(experiences, "经验"));
lines.push(...noteRefLines(outcomes, "成果"));
lines.push(...noteRefLines(related, "关联(related_to)"));
lines.push(...noteRefLines(backlinks, "反向链接"));
const events = list(brief.events);
lines.push("", `## 结构化事件(${events.length} 条)`);
if (events.length === 0) lines.push("(无)");
else {
for (const raw of events) {
const event = isRecord(raw) ? raw : {};
lines.push(
`- ${formatWhen(num(event.atMs))} · **${str(event.kind)}** · ${str(event.source) || "-"} — ${str(event.detail)}`,
);
}
}
const body = str(brief.body);
if (body.trim()) {
lines.push("", "## 正文", "", fenced(truncate(body, BRIEF_BODY_LIMIT, "正文过长")));
}
return lines.join("\n");
}
function formatEvents(data: unknown, pathFilter: string | undefined): string {
const events = list(data);
const scope = pathFilter ? `(path=${pathFilter})` : "";
if (events.length === 0) return `没有事件记录${scope}。`;
const lines = events.map((raw) => {
const event = isRecord(raw) ? raw : {};
return `- \`${formatWhen(num(event.atMs))}\` **${str(event.kind)}** \`${str(event.path)}\` — ${str(event.detail)}`;
});
return `事件 ${events.length} 条${scope}\n\n${lines.join("\n")}`;
}
function mutationConfirmation(action: string, data: unknown, detail: string): string {
const note = isRecord(data) ? data : {};
const suffix = detail ? `,${detail}` : "";
return `已${action} \`${str(note.path)}\`${suffix}(当前 ${num(note.size)} 字节,更新于 ${formatWhen(num(note.modifiedMs))})。`;
}
function formatGitStatus(data: unknown): string {
const status = isRecord(data) ? data : {};
const detail = str(status.detail);
if (status.isRepo !== true) {
const reason = detail || "该知识库不是 Git 仓库";
return `${reason}。需要先 \`git init\` 并设置远端,再回来同步。`;
}
const lines = [
`- 仓库:是(分支 \`${str(status.branch) || "未知"}\`)`,
`- 远端:${str(status.remote) || "(未设置)"}`,
`- 上游:${str(status.upstream) || "(未设置)"}`,
`- 领先/落后:${num(status.ahead)} / ${num(status.behind)}`,
`- 本地变更:${num(status.changes)} 个文件`,
];
if (detail) lines.push(`- 详情:${detail}`);
return `Git 状态\n\n${lines.join("\n")}`;
}
function formatGitSync(data: unknown): string {
const sync = isRecord(data) ? data : {};
const steps = list(sync.steps);
const lines = steps.map((raw) => {
const step = isRecord(raw) ? raw : {};
const mark = step.ok === true ? "✅" : "⚠️";
const summary = str(step.summary);
const detail = str(step.detail);
return `- ${mark} ${str(step.action)}:${summary}${detail ? `(${detail})` : ""}`;
});
const status = formatGitStatus(sync.status);
const headline = sync.ok === true ? "同步完成" : "同步未全部成功";
return `${headline}\n\n${lines.join("\n") || "(没有执行任何步骤)"}\n\n${status}`;
}
type PathParams = { path: string };
type ListParams = { type?: string };
type SearchParams = { query: string; limit?: number };
type EventsParams = { path?: string; limit?: number };
type CreateParams = { folder: string; name: string; content: string };
type WriteParams = { path: string; content: string };
type FrontmatterParams = {
path: string;
updates: Record<string, unknown>;
removals?: string[];
source?: string;
};
type SectionParams = { path: string; heading: string; text: string };
type AppendParams = { path: string; heading: string; item: string };
type SetLineParams = { path: string; line: number; text: string };
type InsertLineParams = { path: string; afterLine: number; text: string };
type DeleteLineParams = { path: string; line: number };
type ToggleParams = { path: string; lines: number[] };
type GitSyncParams = { message: string };
export type VaultTool = ToolDefinition;
* 17 个知识库工具的定义。
*
* 与传输方式无关:pi 扩展把它逐条交给 `pi.registerTool()`;把 pi 当进程内 SDK
* 嵌进去的宿主(`agent-host/`)可以直接把它当 `createAgentSession({ customTools })`
* 传入。模块导入本身没有任何副作用——不 spawn CLI、不读文件、不写 stdout。
*/
export const VAULT_TOOLS: VaultTool[] = [
{
name: "vault_list",
label: "Vault 列表",
description:
`列出知识库里的笔记(可按 type 过滤),返回精简的表格(路径/标题/类型/状态/优先级/分类/未完成-完成/更新时间)。` +
`什么时候用:开始任何知识库工作前先看全貌,或找某类笔记(如所有「任务」)。需要全文检索用 vault_search,需要单个任务的全部上下文用 vault_task_brief。${VAULT_CONVENTIONS}`,
promptSnippet: "列出知识库笔记(可按 type=任务/日志/经验/成果 过滤)",
promptGuidelines: [
"Use vault_list 在动手前先了解知识库里已有哪些笔记,避免重复创建。",
],
parameters: Type.Object({
type: Type.Optional(Type.String({ description: "按笔记类型过滤:任务 / 日志 / 经验 / 成果" })),
}),
async execute(_toolCallId, params: ListParams, signal) {
const type = params.type?.trim() || undefined;
return invoke("list_vault", {}, signal, (data) => formatEntryTable(data, type));
},
},
{
name: "vault_read",
label: "Vault 读取",
description:
"读取一篇笔记的原文(含 frontmatter),返回路径、大小、更新时间与全文。" +
`什么时候用:需要看原文、确认行号或改正文之前。审计任务上下文优先用 vault_task_brief。超过 ${READ_LIMIT} 字符会截断并标注。`,
promptSnippet: "读取知识库笔记原文(含 frontmatter 与行号参考)",
promptGuidelines: [
"Use vault_read 在做逐行手术(vault_set_line / vault_insert_line / vault_delete_line)前先看清行号。",
],
parameters: Type.Object({
path: Type.String({ description: "知识库内相对路径,如 任务/示例任务.md" }),
}),
async execute(_toolCallId, params: PathParams, signal) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
return invoke("read_note", { path: path.value }, signal, formatRead);
},
},
{
name: "vault_search",
label: "Vault 检索",
description:
"在知识库全文里检索关键词,返回命中的路径、行号、类型与整行文本。" +
"什么时候用:想找提到某个词/某个人/某个系统的笔记时;比 vault_list 更精确,比逐个 vault_read 更快。",
promptSnippet: "全文检索知识库,返回命中笔记的路径与行号",
parameters: Type.Object({
query: Type.String({ description: "检索关键词" }),
limit: Type.Optional(Type.Integer({ description: "最多返回多少条命中,默认 50" })),
}),
async execute(_toolCallId, params: SearchParams, signal) {
const query = checkNonEmptyString(params.query, "query");
if (!query.ok) return errorText(query.error);
const limit = checkLimit(params.limit);
if (!limit.ok) return errorText(limit.error);
const args: Record<string, unknown> = { query: query.value };
if (limit.value !== undefined) args.limit = limit.value;
return invoke("search", args, signal, (data) => formatSearch(data, query.value, limit.value));
},
},
{
name: "vault_task_brief",
label: "任务简报",
description:
"一次拿到一个任务的全部上下文:任务本身、子任务(含行号)、变更日志、提到它的日志行、相关经验/成果/关联/反向链接,以及结构化事件。" +
`什么时候用:这是「信息梳理」的主入口——接手任务、写日志、沉淀经验、更新子任务之前先用它,避免反复 vault_read。${VAULT_CONVENTIONS}`,
promptSnippet: "读取任务简报:子任务行号 + 变更日志 + 相关日志/经验/成果 + 事件",
promptGuidelines: [
"Use vault_task_brief 作为任务信息梳理的第一步,它一次给出子任务行号与全部关联,比多次 vault_read 更省上下文。",
],
parameters: Type.Object({
path: Type.String({ description: "任务笔记的知识库内相对路径" }),
}),
async execute(_toolCallId, params: PathParams, signal) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
return invoke("task_brief", { path: path.value }, signal, formatBrief);
},
},
{
name: "vault_events",
label: "Vault 事件",
description:
"查询索引里记录的任务事件(创建、状态变更、frontmatter 改动等),可按笔记路径过滤、按数量截断。" +
"什么时候用:想知道某篇笔记最近发生了什么、或整体最近的活动轨迹时。",
promptSnippet: "列出知识库的结构化事件(可按 path 过滤)",
parameters: Type.Object({
path: Type.Optional(Type.String({ description: "只看这篇笔记的事件;省略表示全部" })),
limit: Type.Optional(Type.Integer({ description: "最多返回多少条,默认 200" })),
}),
async execute(_toolCallId, params: EventsParams, signal) {
const args: Record<string, unknown> = {};
let pathFilter: string | undefined;
if (params.path !== undefined) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
pathFilter = path.value;
args.path = path.value;
}
const limit = checkLimit(params.limit);
if (!limit.ok) return errorText(limit.error);
if (limit.value !== undefined) args.limit = limit.value;
return invoke("list_events", args, signal, (data) => formatEvents(data, pathFilter));
},
},
{
name: "vault_create_note",
label: "新建笔记",
description:
"在知识库里新建一篇笔记(自动补 .md 后缀、自动建目录、已存在会报错)。" +
`什么时候用:新建任务/日志/经验/成果时。请同时按约定写好 frontmatter 与正文分节;建完后用 vault_update_frontmatter 补字段。${VAULT_CONVENTIONS}`,
promptSnippet: "新建知识库笔记(按约定写 frontmatter 与分节)",
promptGuidelines: [
"Use vault_create_note 新建笔记,并在 content 里直接按知识库约定写好 frontmatter(type/status/…)与正文分节。",
],
parameters: Type.Object({
folder: Type.String({ description: "目标文件夹,如 任务;留空表示知识库根目录" }),
name: Type.String({ description: "笔记名(可带 .md,会自动去掉)" }),
content: Type.String({ description: "笔记完整内容,含 frontmatter 与正文" }),
}),
async execute(_toolCallId, params: CreateParams, signal) {
const name = checkNonEmptyString(params.name, "name");
if (!name.ok) return errorText(name.error);
const content = typeof params.content === "string" ? params.content : undefined;
if (content === undefined) return errorText("`content` 必须是字符串。");
const folder = typeof params.folder === "string" ? params.folder : "";
return invoke("create_note", { folder, name: name.value, content }, signal, (data) =>
mutationConfirmation("创建笔记", data, "记得随后用 vault_update_frontmatter 补全 type 等字段"),
);
},
},
{
name: "vault_create_folder",
label: "新建文件夹",
description:
"在知识库里新建文件夹(可多级,如 经验/会议纪要),已存在时也算成功。" +
"什么时候用:需要把笔记归档到新目录、或知识库约定目录还没建时;建完再用 vault_create_note 往里放笔记。",
promptSnippet: "在知识库内新建文件夹(可多级)",
parameters: Type.Object({
path: Type.String({ description: "要创建的知识库内相对目录,可多级,如 经验/会议纪要" }),
}),
async execute(_toolCallId, params: PathParams, signal) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
return invoke("create_folder", { path: path.value }, signal, (data) => {
const info = isRecord(data) ? data : {};
return `已创建文件夹 \`${str(info.path)}\`。`;
});
},
},
{
name: "vault_write",
label: "写入笔记",
description:
"整篇覆盖写入一篇已存在的笔记(不存在的路径也会被创建)。" +
"什么时候用:需要整篇重写时。只改一段或几行时优先 vault_replace_section / vault_set_line,避免误伤其余内容。",
promptSnippet: "整篇覆盖写入知识库笔记",
parameters: Type.Object({
path: Type.String({ description: "知识库内相对路径" }),
content: Type.String({ description: "新的完整内容" }),
}),
async execute(_toolCallId, params: WriteParams, signal) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
if (typeof params.content !== "string") return errorText("`content` 必须是字符串。");
return invoke("save_note", { path: path.value, content: params.content }, signal, (data) =>
mutationConfirmation("写入", data, ""),
);
},
},
{
name: "vault_update_frontmatter",
label: "更新 Frontmatter",
description:
"按插入顺序更新/新增 frontmatter 字段,并可删除指定字段(removals)。" +
`什么时候用:设置或修改 type/status/priority/category/created/owner/related_to 等字段时。${VAULT_CONVENTIONS}`,
promptSnippet: "更新笔记 frontmatter 字段(含 related_to wikilink 关联)",
promptGuidelines: [
"Use vault_update_frontmatter 维护任务字段与 related_to 关联;related_to 的值用 [[笔记名]] wikilink 数组。",
],
parameters: Type.Object({
path: Type.String({ description: "知识库内相对路径" }),
updates: Type.Object({}, { description: "要写入的字段(按插入顺序)", additionalProperties: true }),
removals: Type.Optional(
Type.Array(Type.String(), { description: "要删除的字段名" }),
),
source: Type.Optional(Type.String({ description: "写入者标注,默认 app;agent 写入可填 pi" })),
}),
async execute(_toolCallId, params: FrontmatterParams, signal) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
const updates = checkUpdates(params.updates);
if (!updates.ok) return errorText(updates.error);
const removals = checkRemovals(params.removals);
if (!removals.ok) return errorText(removals.error);
const args: Record<string, unknown> = { path: path.value, updates: updates.value };
if (removals.value.length > 0) args.removals = removals.value;
if (typeof params.source === "string" && params.source.trim()) args.source = params.source.trim();
const changed = [
...Object.keys(updates.value).map((key) => `+${key}`),
...removals.value.map((key) => `-${key}`),
];
return invoke("update_frontmatter", args, signal, (data) =>
mutationConfirmation("更新 frontmatter", data, changed.length > 0 ? changed.join("、") : "无字段变化"),
);
},
},
{
name: "vault_replace_section",
label: "替换小节",
description:
"按 `## 标题` 整体替换一个正文小节(小节不存在时会创建)。text 传空字符串表示清空该小节。" +
`什么时候用:重写「## 背景」这类整段内容。「## 变更日志」「## 子任务」这类清单优先用 vault_append_item / vault_toggle_checkbox。${VAULT_CONVENTIONS}`,
promptSnippet: "按 ## 标题整体替换正文小节(缺失则创建)",
parameters: Type.Object({
path: Type.String({ description: "知识库内相对路径" }),
heading: Type.String({ description: "小节标题,如 背景(不用带 ##)" }),
text: Type.String({ description: "新的小节正文" }),
}),
async execute(_toolCallId, params: SectionParams, signal) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
const heading = checkNonEmptyString(params.heading, "heading");
if (!heading.ok) return errorText(heading.error);
if (typeof params.text !== "string") return errorText("`text` 必须是字符串(清空小节传空字符串)。");
return invoke(
"replace_section",
{ path: path.value, heading: heading.value, text: params.text },
signal,
(data) => mutationConfirmation("替换小节", data, `## ${heading.value}`),
);
},
},
{
name: "vault_append_item",
label: "追加行",
description:
"在指定 `## 标题` 小节的末尾追加一行(小节不存在时会创建)。" +
`什么时候用:往「## 子任务」追加 \`- [ ] …\`,或往「## 变更日志」追加 \`- 日期: 内容\`。${VAULT_CONVENTIONS}`,
promptSnippet: "在 ## 子任务 / ## 变更日志 小节末尾追加一行",
parameters: Type.Object({
path: Type.String({ description: "知识库内相对路径" }),
heading: Type.String({ description: "小节标题,如 子任务、变更日志" }),
item: Type.String({ description: "要追加的整行内容,如 `- [ ] 联调支付` 或 `- 2026-09-12: 创建任务`" }),
}),
async execute(_toolCallId, params: AppendParams, signal) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
const heading = checkNonEmptyString(params.heading, "heading");
if (!heading.ok) return errorText(heading.error);
if (typeof params.item !== "string" || !params.item.trim()) {
return errorText("`item` 必须是要追加的非空整行内容。");
}
return invoke(
"append_body_item",
{ path: path.value, heading: heading.value, item: params.item },
signal,
(data) => mutationConfirmation("追加内容", data, `## ${heading.value}`),
);
},
},
{
name: "vault_set_line",
label: "改写行",
description:
"按 1 起算的行号改写正文某一行(行号可用 vault_read 或 vault_task_brief 获得)。" +
"什么时候用:修正某一行的文本,尤其是子任务描述或变更日志条目。",
promptSnippet: "按行号改写正文一行",
parameters: Type.Object({
path: Type.String({ description: "知识库内相对路径" }),
line: Type.Integer({ description: "1 起算的行号" }),
text: Type.String({ description: "该行的新内容" }),
}),
async execute(_toolCallId, params: SetLineParams, signal) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
const line = checkLine(params.line, "line");
if (!line.ok) return errorText(line.error);
if (typeof params.text !== "string") return errorText("`text` 必须是字符串。");
return invoke(
"set_body_line",
{ path: path.value, line: line.value, text: params.text },
signal,
(data) => mutationConfirmation("改写", data, `第 ${line.value} 行`),
);
},
},
{
name: "vault_insert_line",
label: "插入行",
description:
"在指定行(afterLine,1 起算)之后插入一行。" +
"什么时候用:新增子任务、给清单补嵌套行;行号先用 vault_read 或 vault_task_brief 确认。",
promptSnippet: "在某一行之后插入新行",
parameters: Type.Object({
path: Type.String({ description: "知识库内相对路径" }),
afterLine: Type.Integer({ description: "在这行之后插入(1 起算);0 表示插到正文最前" }),
text: Type.String({ description: "要插入的整行内容" }),
}),
async execute(_toolCallId, params: InsertLineParams, signal) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
if (typeof params.afterLine !== "number" || !Number.isInteger(params.afterLine) || params.afterLine < 0) {
return errorText("`afterLine` 必须是 ≥0 的整数行号(0 表示插到正文最前)。");
}
if (typeof params.text !== "string") return errorText("`text` 必须是字符串。");
return invoke(
"insert_body_line",
{ path: path.value, afterLine: params.afterLine, text: params.text },
signal,
(data) => mutationConfirmation("插入行", data, `第 ${params.afterLine} 行之后`),
);
},
},
{
name: "vault_delete_line",
label: "删除行",
description:
"按 1 起算的行号删除正文一行。删除后其余行号会前移,连续删除时要从大到小、或每删一次重新读行号。" +
"什么时候用:清掉作废的子任务或错误的日志行。",
promptSnippet: "按行号删除正文一行",
parameters: Type.Object({
path: Type.String({ description: "知识库内相对路径" }),
line: Type.Integer({ description: "要删除的 1 起算行号" }),
}),
async execute(_toolCallId, params: DeleteLineParams, signal) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
const line = checkLine(params.line, "line");
if (!line.ok) return errorText(line.error);
return invoke(
"delete_body_line",
{ path: path.value, line: line.value },
signal,
(data) => mutationConfirmation("删除", data, `第 ${line.value} 行`),
);
},
},
{
name: "vault_toggle_checkbox",
label: "勾选复选框",
description:
"切换若干行上的 `- [ ]` / `- [x]` 状态(一次可传多行,行号 1 起算、来自 vault_task_brief 的子任务列表)。" +
"什么时候用:完成任务、勾掉子任务时。「## 变更日志」请用 vault_append_item 追加而不是勾选。",
promptSnippet: "勾选/取消勾选子任务复选框(按行号,可多行)",
promptGuidelines: [
"Use vault_toggle_checkbox 勾选子任务;先用 vault_task_brief 或 vault_read 拿到准确行号。",
],
parameters: Type.Object({
path: Type.String({ description: "知识库内相对路径" }),
lines: Type.Array(Type.Integer(), { description: "要切换的 1 起算行号数组" }),
}),
async execute(_toolCallId, params: ToggleParams, signal) {
const path = checkPath(params.path);
if (!path.ok) return errorText(path.error);
const lines = checkLines(params.lines);
if (!lines.ok) return errorText(lines.error);
return invoke(
"toggle_checkbox",
{ path: path.value, lines: lines.value },
signal,
(data) => mutationConfirmation("切换复选框", data, `第 ${lines.value.join("、")} 行`),
);
},
},
{
name: "vault_git_status",
label: "Git 状态",
description:
"查看知识库的 Git 状态:是否仓库、分支、远端、领先/落后、本地变更数量。" +
"什么时候用:同步前先看有没有东西要提交;也能快速确认知识库是否已纳入版本控制。",
promptSnippet: "查看知识库的 Git 分支/远端/变更状态",
parameters: Type.Object({}),
async execute(_toolCallId, _params: Record<string, never>, signal) {
return invoke("git_status", {}, signal, formatGitStatus);
},
},
{
name: "vault_git_sync",
label: "Git 同步",
description:
"提交并同步知识库的 Git 改动(拉取、提交、推送,由 core 按仓库状态决定步骤)。" +
"什么时候用:写完一批笔记后做版本化。先 vault_git_status 看清状态;message 建议写成 `<日期> <一句话概括>`,留空则用设置里的默认提交信息。",
promptSnippet: "提交并推送知识库的 Git 改动",
parameters: Type.Object({
message: Type.String({ description: "提交信息;留空使用设置里的默认提交信息" }),
}),
async execute(_toolCallId, params: GitSyncParams, signal) {
if (typeof params.message !== "string") return errorText("`message` 必须是字符串。");
return invoke("git_sync", { message: params.message }, signal, formatGitSync);
},
},
];
function registerTools(pi: ExtensionAPI): void {
for (const tool of VAULT_TOOLS) pi.registerTool(tool);
}
function sendInstruction(pi: ExtensionAPI, ctx: ExtensionCommandContext, body: string): void {
if (typeof pi.sendMessage === "function") {
pi.sendMessage(
{ customType: "tasklens", content: body, display: true },
{ triggerTurn: true, deliverAs: "steer" },
);
return;
}
ctx.ui.notify(body, "info");
}
function scopedSuffix(args: string): string {
const target = args.trim();
return target ? `\n\n本次目标(用户指定):${target}` : "";
}
function registerCommands(pi: ExtensionAPI): void {
pi.registerCommand("tasklens-file", {
description: "把当前对话里的待办整理成 TaskLens 任务笔记(可跟一句补充说明)",
handler: async (args, ctx) => {
try {
sendInstruction(
pi,
ctx,
`请把当前对话里尚未落地的待办整理进 TaskLens 知识库:
1. 先调用 vault_list({ type: "任务" }) 看已有哪些任务,避免重复建笔记;必要时用 vault_search 查关键词。
2. 每个新任务用 vault_create_note 建到任务类型目录(默认 \`任务/\`;先看 vault_list 里已有任务实际在哪个目录,跟着它走),content 里按知识库约定直接写好 frontmatter(type: 任务、status、priority、category、created: ${todayIso()}、owner)与正文分节 \`## 背景\`、\`## 子任务\`(逐条 \`- [ ] …\`)、\`## 变更日志\`(\`- ${todayIso()}: 创建任务\`)。
3. 用 vault_update_frontmatter 补全字段,并用 related_to 的 [[wikilink]] 关联已有任务/日志。
4. 建完后用 vault_task_brief 复查子任务与关联,确认没有遗漏。
只整理对话里真实出现的待办,不要发明任务。最后汇报「新增/更新了哪些笔记」。${scopedSuffix(args)}`,
);
} catch (cause) {
ctx.ui.notify(`发送 TaskLens 指令失败:${messageOf(cause)}`, "error");
}
},
});
pi.registerCommand("tasklens-today", {
description: "梳理今天的日志,并把内容归并到任务/经验/成果",
handler: async (args, ctx) => {
try {
const today = todayIso();
sendInstruction(
pi,
ctx,
`请梳理今天(${today})的日志:
1. 先用 vault_list({ type: "日志" }) 找到今天的日志(文件名格式可在设置里改,默认 \`工作日志/${today}-工作日志.md\`)并 vault_read;不存在就用 vault_create_note 建,frontmatter 写 \`type: 日志\`、\`date: ${today}\`。
2. 通读对话与日志,把内容归并到位:任务进展用 vault_task_brief 定位子任务行号后,用 vault_toggle_checkbox 勾选、用 vault_append_item 往 \`## 变更日志\` 追加 \`- ${today}: …\`;可复用的方法沉淀到经验类型目录(默认 \`经验/\`),产出成果沉淀到成果类型目录(默认 \`成果/\`)。
3. 新沉淀的笔记用 vault_update_frontmatter 的 related_to 反向关联任务([[wikilink]])。
4. 汇报:更新了哪些文件、勾掉了哪些子任务、新增了哪些经验/成果。${scopedSuffix(args)}`,
);
} catch (cause) {
ctx.ui.notify(`发送 TaskLens 指令失败:${messageOf(cause)}`, "error");
}
},
});
pi.registerCommand("tasklens-distill", {
description: "从已完成的任务里沉淀经验与成果(可用参数指定任务)",
handler: async (args, ctx) => {
try {
sendInstruction(
pi,
ctx,
`请从任务里沉淀经验与成果:
1. 用 vault_list({ type: "任务" }) 列出任务,挑出最近完成或阶段性完成的任务(status 为已完成,或变更日志已有结论的);候选不明确时先问我选哪个。
2. 用 vault_task_brief 读该任务的背景、子任务、变更日志、相关日志。
3. 把可复用的方法写成经验笔记(默认 \`经验/<主题>.md\`,frontmatter \`type: 经验\`),把可交付的产出写成成果笔记(默认 \`成果/<主题>.md\`,frontmatter \`type: 成果\`),都用 vault_create_note 创建,正文写清「背景 / 做法 / 结论」;目录以 vault_list 里该类型笔记实际所在为准。
4. 用 vault_update_frontmatter 在任务与新建笔记之间补 related_to([[wikilink]]),并用 vault_append_item 在任务 \`## 变更日志\` 追加 \`- ${todayIso()}: 沉淀经验/成果 [[…]]\`。
5. 汇报新建了哪些笔记、和哪个任务关联。${scopedSuffix(args)}`,
);
} catch (cause) {
ctx.ui.notify(`发送 TaskLens 指令失败:${messageOf(cause)}`, "error");
}
},
});
pi.registerCommand("tasklens-sync", {
description: "查看知识库的 Git 状态,必要时提交并同步",
handler: async (args, ctx) => {
try {
sendInstruction(
pi,
ctx,
`请同步 TaskLens 知识库的 Git:
1. 先调用 vault_git_status 看分支、远端、领先/落后与变更数量。
2. 如果不是 Git 仓库,直接告诉我需要先 \`git init\` / 设置远端,不要擅自初始化。
3. 若有本地变更,用 vault_git_sync({ message: "${todayIso()} <一句话概括本次改动>" }) 提交并同步;提交信息要说明改了什么。
4. 把 vault_git_status / vault_git_sync 的关键结果汇报给我(分支、领先/落后、提交与推送结果)。${scopedSuffix(args)}`,
);
} catch (cause) {
ctx.ui.notify(`发送 TaskLens 指令失败:${messageOf(cause)}`, "error");
}
},
});
}
let announced = false;
async function announceReady(ctx: ExtensionContext): Promise<void> {
if (announced) return;
announced = true;
const probe = await callVault("active_vault", {});
if (!probe.ok) return;
const root = isRecord(probe.data) ? str(probe.data.root) : "";
if (!root) return;
ctx.ui.notify(`TaskLens 知识库工具已就绪(${TOOL_COUNT} 个):${root}`, "info");
}
export default function tasklensExtension(pi: ExtensionAPI): void {
registerTools(pi);
registerCommands(pi);
pi.on("session_start", async (_event, ctx) => {
try {
await announceReady(ctx);
} catch {
}
});
}