Scenarios - memo-inbox: mirrored by copying; the live directory was not moved or modified and the service was not restarted. All four tracked files match byte for byte (pi-diff.sh reports SAME). Marked deploy = "mirror" so deploy-scenario.sh refuses --apply: applying a mirror would invert the direction of truth and could change a service in daily use. - curator: target configuration, not yet deployed. .pi/SYSTEM.md replaces pi's coding-assistant prompt; durable role text is in .pi/APPEND_SYSTEM.md; profile.toml is the single source of truth for the launch contract. - pi-grok: registered only. It is genuinely a coding agent, so the isolation baseline does not apply in full. Corrections to the documentation, found by testing rather than by reading - AGENTS.override.md does NOT block parent-directory context files; it only shadows its own directory. Verified: with an override file in the workspace, a marker in /tmp/AGENTS.md still reached the system prompt. The only effective switch is --no-context-files, so durable role text must live in .pi/APPEND_SYSTEM.md, which is a system-prompt file and unaffected by -nc. Verified end state: no coding-assistant framing, no pi-docs block, own identity and role text present, no parent pollution, only own skills/tools. - PI_CODING_AGENT_DIR isolates settings/models/auth/trust/extensions/skills/ prompts/themes under the agent directory -- stronger than the --no-* flags because it also repoints credentials -- but does NOT cover ~/.agents/skills. Measured: find-skills, modsearch and summarize still leak. So it complements --no-skills rather than replacing it. - --append-system-prompt accepts a file path, which pi-grok relies on. - cwd is what anchors .pi discovery: a probe that forgot cwd silently lost .pi/SYSTEM.md and kept the coding-assistant persona. Tooling (all dry-run by default; none of them restarts a service) - pi-diff.sh: compares tracked config against the live install in both directions, with a key-redacted comparison for models.json - deploy-scenario.sh: installs a workspace and renders profile.toml into .pi/launch.json, then checks that every referenced path exists - deploy-runtime.sh: renders models.json from its template, refusing placeholder or missing keys. Verified byte-identical to the live file - pi-backup.sh / pi-restore.sh: archives outside the repo, sha256 manifest verified before any restore, live paths preserved rather than overwritten Fixed while testing: pi-backup.sh compared the destination against the repo root literally, so a relative --dest ./backups wrote credential archives into the work tree. Now canonicalised with realpath; ./backups, an absolute in-repo path and ./docs/../backups are all refused.
938 lines
36 KiB
TypeScript
938 lines
36 KiB
TypeScript
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||
import { Type } from "typebox";
|
||
import { spawn } from "node:child_process";
|
||
import {
|
||
existsSync,
|
||
lstatSync,
|
||
mkdtempSync,
|
||
mkdirSync,
|
||
readFileSync,
|
||
readdirSync,
|
||
realpathSync,
|
||
renameSync,
|
||
rmSync,
|
||
writeFileSync,
|
||
} from "node:fs";
|
||
import { tmpdir } from "node:os";
|
||
import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
||
|
||
const HOME = process.env.HOME || "/home/claw";
|
||
const WORKSPACE = realpathSync(resolve(process.cwd()));
|
||
const VAULT = realpathSync(resolve(process.env.PI_MEMO_VAULT || join(HOME, "obsidian-vault")));
|
||
const JOURNALS = realpathSync(resolve(join(VAULT, "journals")));
|
||
const UPLOADS = realpathSync(resolve(join(WORKSPACE, ".ccgram-uploads")));
|
||
const SYNC_HELPER = resolve(WORKSPACE, "bin", "journal-sync.sh");
|
||
const ALLOWED_TOOLS = [
|
||
"read",
|
||
"image_view",
|
||
"document_parse",
|
||
"document_ocr",
|
||
"vault_search",
|
||
"journal_append",
|
||
"journal_batch_append",
|
||
"calendar_list",
|
||
"calendar_create",
|
||
"calendar_update",
|
||
"calendar_delete",
|
||
];
|
||
const MAX_READ_CHARS = 80_000;
|
||
const MAX_SEARCH_FILES = 2_000;
|
||
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
||
const MAX_DOCUMENT_BYTES = 20 * 1024 * 1024;
|
||
const MAX_OCR_PAGES = 10;
|
||
const MAX_PARSE_BYTES = 100 * 1024 * 1024;
|
||
const PARSER_BASE_URL = process.env.PI_MEMO_PARSER_BASE_URL || "http://127.0.0.1:8090";
|
||
const OCR_BASE_URL = process.env.PI_MEMO_OCR_BASE_URL || "http://192.168.50.100:8001";
|
||
const OCR_MODEL = process.env.PI_MEMO_OCR_MODEL || "firered-ocr";
|
||
const CALENDAR_ID = "gltankai@gmail.com";
|
||
const GWS = "/home/claw/.npm-global/bin/gws";
|
||
|
||
type RunResult = { code: number; stdout: string; stderr: string };
|
||
|
||
function textResult(text: string, details: Record<string, unknown> = {}) {
|
||
return { content: [{ type: "text" as const, text }], details };
|
||
}
|
||
|
||
function inside(root: string, candidate: string): boolean {
|
||
const rel = relative(root, candidate);
|
||
return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel));
|
||
}
|
||
|
||
function safeRealPath(candidate: string, allowMissing = false): string {
|
||
const absolute = resolve(candidate);
|
||
if (existsSync(absolute)) return realpathSync(absolute);
|
||
if (!allowMissing) throw new Error(`Path does not exist: ${candidate}`);
|
||
const parent = realpathSync(dirname(absolute));
|
||
return join(parent, basename(absolute));
|
||
}
|
||
|
||
function resolveReadable(input: string): string {
|
||
const candidate = safeRealPath(isAbsolute(input) ? input : resolve(WORKSPACE, input));
|
||
if (!inside(WORKSPACE, candidate) && !inside(VAULT, candidate)) {
|
||
throw new Error("Read denied: only the Memo workspace and Obsidian vault are readable.");
|
||
}
|
||
if (!lstatSync(candidate).isFile()) throw new Error("Read denied: path is not a file.");
|
||
return candidate;
|
||
}
|
||
|
||
function resolveUpload(input: string): string {
|
||
const candidate = safeRealPath(isAbsolute(input) ? input : resolve(WORKSPACE, input));
|
||
if (!inside(UPLOADS, candidate)) {
|
||
throw new Error("Document access denied: only Telegram uploads are allowed.");
|
||
}
|
||
if (!lstatSync(candidate).isFile()) throw new Error("Document access denied: path is not a file.");
|
||
return candidate;
|
||
}
|
||
|
||
function mimeForImage(path: string): string | null {
|
||
const extension = extname(path).toLocaleLowerCase();
|
||
if (extension === ".png") return "image/png";
|
||
if (extension === ".jpg" || extension === ".jpeg") return "image/jpeg";
|
||
if (extension === ".webp") return "image/webp";
|
||
return null;
|
||
}
|
||
|
||
async function fireOcr(imagePath: string, pageLabel: string): Promise<string> {
|
||
const mimeType = mimeForImage(imagePath);
|
||
if (!mimeType) throw new Error(`Unsupported OCR image type: ${extname(imagePath)}`);
|
||
const data = readFileSync(imagePath).toString("base64");
|
||
const response = await fetch(`${OCR_BASE_URL.replace(/\/$/, "")}/v1/chat/completions`, {
|
||
method: "POST",
|
||
headers: { "content-type": "application/json" },
|
||
body: JSON.stringify({
|
||
model: OCR_MODEL,
|
||
messages: [
|
||
{
|
||
role: "user",
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text:
|
||
"请对这份文档页面做高精度 OCR,保留原文并按阅读顺序输出。重点准确保留标题、人名、机构名、日期、开始和结束时间、地点、地址、联系方式;不要猜测看不清的字符。",
|
||
},
|
||
{
|
||
type: "image_url",
|
||
image_url: { url: `data:${mimeType};base64,${data}` },
|
||
},
|
||
],
|
||
},
|
||
],
|
||
temperature: 0,
|
||
max_tokens: 2048,
|
||
}),
|
||
signal: AbortSignal.timeout(120_000),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`FireOCR ${pageLabel} failed: HTTP ${response.status} ${await response.text()}`);
|
||
}
|
||
const payload = (await response.json()) as {
|
||
choices?: Array<{ message?: { content?: string } }>;
|
||
};
|
||
const text = payload.choices?.[0]?.message?.content?.trim();
|
||
if (!text) throw new Error(`FireOCR ${pageLabel} returned no text.`);
|
||
return text;
|
||
}
|
||
|
||
function shanghaiNow(): { date: string; time: string } {
|
||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||
timeZone: "Asia/Shanghai",
|
||
year: "numeric",
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
hourCycle: "h23",
|
||
}).formatToParts(new Date());
|
||
const get = (type: string) => parts.find((part) => part.type === type)?.value || "";
|
||
return {
|
||
date: `${get("year")}-${get("month")}-${get("day")}`,
|
||
time: `${get("hour")}:${get("minute")}`,
|
||
};
|
||
}
|
||
|
||
function validateDate(date: string): void {
|
||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new Error("date must be YYYY-MM-DD");
|
||
const parsed = new Date(`${date}T00:00:00Z`);
|
||
if (Number.isNaN(parsed.getTime()) || parsed.toISOString().slice(0, 10) !== date) {
|
||
throw new Error("Invalid calendar date");
|
||
}
|
||
}
|
||
|
||
function run(command: string, args: string[], timeoutMs = 60_000): Promise<RunResult> {
|
||
return new Promise((resolvePromise) => {
|
||
const child = spawn(command, args, {
|
||
cwd: WORKSPACE,
|
||
env: { ...process.env, HOME },
|
||
stdio: ["ignore", "pipe", "pipe"],
|
||
});
|
||
let stdout = "";
|
||
let stderr = "";
|
||
const timer = setTimeout(() => child.kill("SIGTERM"), timeoutMs);
|
||
child.stdout.on("data", (chunk) => (stdout += String(chunk)));
|
||
child.stderr.on("data", (chunk) => (stderr += String(chunk)));
|
||
child.on("error", (error) => {
|
||
clearTimeout(timer);
|
||
resolvePromise({ code: 127, stdout, stderr: `${stderr}${error.message}` });
|
||
});
|
||
child.on("close", (code) => {
|
||
clearTimeout(timer);
|
||
resolvePromise({ code: code ?? 1, stdout: stdout.trim(), stderr: stderr.trim() });
|
||
});
|
||
});
|
||
}
|
||
|
||
function newJournal(date: string): string {
|
||
return `# ${date}\n\n## 今日任务\n\n## 记录\n\n## 收获/想法\n`;
|
||
}
|
||
|
||
function insertUnderHeading(markdown: string, heading: string, entry: string): string {
|
||
const marker = `## ${heading}`;
|
||
const start = markdown.indexOf(marker);
|
||
if (start < 0) return `${markdown.trimEnd()}\n\n${marker}\n\n${entry}\n`;
|
||
const bodyStart = start + marker.length;
|
||
const nextHeading = markdown.indexOf("\n## ", bodyStart);
|
||
const insertAt = nextHeading < 0 ? markdown.length : nextHeading;
|
||
const before = markdown.slice(0, insertAt).trimEnd();
|
||
const after = markdown.slice(insertAt).trimStart();
|
||
return after ? `${before}\n\n${entry}\n\n${after}` : `${before}\n\n${entry}\n`;
|
||
}
|
||
|
||
function validateEntry(category: string, entry: string): string {
|
||
const clean = entry.trim().replace(/\r/g, "");
|
||
if (!clean || clean.length > 4_000) throw new Error("Entry must be 1-4000 characters.");
|
||
if (clean.includes("\n# ") || clean.includes("\n## ")) {
|
||
throw new Error("Entry must not contain headings.");
|
||
}
|
||
if (category === "todo") {
|
||
if (!clean.startsWith("- [ ] ")) throw new Error("Todo must start with '- [ ] '.");
|
||
} else if (!clean.startsWith("- ")) {
|
||
throw new Error("Record and idea entries must start with '- '.");
|
||
}
|
||
return clean;
|
||
}
|
||
|
||
function walkMarkdown(root: string, output: string[]): void {
|
||
if (output.length >= MAX_SEARCH_FILES) return;
|
||
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
||
if (output.length >= MAX_SEARCH_FILES) return;
|
||
if (entry.name === ".git" || entry.name === ".obsidian" || entry.name === "assets") continue;
|
||
const path = join(root, entry.name);
|
||
if (entry.isDirectory()) walkMarkdown(path, output);
|
||
else if (entry.isFile() && entry.name.endsWith(".md")) output.push(path);
|
||
}
|
||
}
|
||
|
||
function parseRfc3339(value: string, field: string): Date {
|
||
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2})?\+08:00$/.test(value)) {
|
||
throw new Error(`${field} must be RFC3339 with +08:00`);
|
||
}
|
||
const parsed = new Date(value);
|
||
if (Number.isNaN(parsed.getTime())) throw new Error(`Invalid ${field}`);
|
||
return parsed;
|
||
}
|
||
|
||
async function getCalendarEvent(eventId: string): Promise<Record<string, unknown>> {
|
||
const result = await run(
|
||
GWS,
|
||
[
|
||
"calendar",
|
||
"events",
|
||
"get",
|
||
"--params",
|
||
JSON.stringify({
|
||
calendarId: CALENDAR_ID,
|
||
eventId,
|
||
fields: "id,summary,start,end,location,description,status,htmlLink",
|
||
}),
|
||
"--format",
|
||
"json",
|
||
],
|
||
60_000,
|
||
);
|
||
if (result.code !== 0) {
|
||
throw new Error(`Calendar lookup failed: ${result.stderr || result.stdout}`);
|
||
}
|
||
try {
|
||
return JSON.parse(result.stdout) as Record<string, unknown>;
|
||
} catch {
|
||
throw new Error("Calendar lookup returned invalid JSON.");
|
||
}
|
||
}
|
||
|
||
function requireExpectedSummary(event: Record<string, unknown>, expectedSummary: string): string {
|
||
const actual = typeof event.summary === "string" ? event.summary.trim() : "";
|
||
if (!actual || actual !== expectedSummary.trim()) {
|
||
throw new Error(
|
||
`Calendar event title mismatch: expected "${expectedSummary}", found "${actual || "(empty)"}".`,
|
||
);
|
||
}
|
||
return actual;
|
||
}
|
||
|
||
export default function memoGuard(pi: ExtensionAPI) {
|
||
pi.registerTool({
|
||
name: "read",
|
||
label: "Read Memo/Vault File",
|
||
description: "Read a UTF-8 file from the Pi Memo workspace or Obsidian vault. Other host paths are denied.",
|
||
parameters: Type.Object({
|
||
path: Type.String(),
|
||
offset: Type.Optional(Type.Number({ minimum: 0 })),
|
||
limit: Type.Optional(Type.Number({ minimum: 1, maximum: MAX_READ_CHARS })),
|
||
}),
|
||
async execute(_id, params) {
|
||
try {
|
||
const path = resolveReadable(params.path);
|
||
const content = readFileSync(path, "utf8");
|
||
const offset = Math.floor(params.offset || 0);
|
||
const limit = Math.floor(params.limit || MAX_READ_CHARS);
|
||
const slice = content.slice(offset, offset + limit);
|
||
return textResult(slice, {
|
||
path,
|
||
offset,
|
||
returned: slice.length,
|
||
truncated: offset + limit < content.length,
|
||
});
|
||
} catch (error) {
|
||
return textResult(`ERROR: ${(error as Error).message}`, { error: true });
|
||
}
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "image_view",
|
||
label: "View Telegram Image",
|
||
description:
|
||
"Load one Telegram image from the Memo workspace .ccgram-uploads directory for visual understanding and OCR. Other files and paths are denied.",
|
||
parameters: Type.Object({
|
||
path: Type.String({ description: "Image path supplied by CCGram" }),
|
||
}),
|
||
async execute(_id, params) {
|
||
try {
|
||
const candidate = safeRealPath(
|
||
isAbsolute(params.path) ? params.path : resolve(WORKSPACE, params.path),
|
||
);
|
||
if (!inside(UPLOADS, candidate)) {
|
||
throw new Error("Image access denied: only Telegram uploads are allowed.");
|
||
}
|
||
const stat = lstatSync(candidate);
|
||
if (!stat.isFile()) throw new Error("Image access denied: path is not a file.");
|
||
if (stat.size > MAX_IMAGE_BYTES) throw new Error("Image exceeds the 10 MiB limit.");
|
||
const extension = candidate.toLocaleLowerCase().split(".").pop();
|
||
const mimeType =
|
||
extension === "png"
|
||
? "image/png"
|
||
: extension === "jpg" || extension === "jpeg"
|
||
? "image/jpeg"
|
||
: extension === "webp"
|
||
? "image/webp"
|
||
: extension === "gif"
|
||
? "image/gif"
|
||
: null;
|
||
if (!mimeType) throw new Error("Unsupported image type; use PNG, JPEG, WebP, or GIF.");
|
||
const data = readFileSync(candidate).toString("base64");
|
||
return {
|
||
content: [
|
||
{
|
||
type: "image" as const,
|
||
data,
|
||
mimeType,
|
||
},
|
||
{ type: "text" as const, text: `Loaded Telegram image: ${basename(candidate)}` },
|
||
],
|
||
details: { path: relative(WORKSPACE, candidate), mimeType, bytes: stat.size },
|
||
};
|
||
} catch (error) {
|
||
return textResult(`ERROR: ${(error as Error).message}`, { error: true });
|
||
}
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "document_parse",
|
||
label: "Parse Telegram Document",
|
||
description:
|
||
"Convert one Telegram PDF, Office, OpenDocument, RTF, EPUB, HTML, image, or text document from .ccgram-uploads to Markdown using local AnyDoc and pdf-inspector. Other paths are denied.",
|
||
parameters: Type.Object({
|
||
path: Type.String({ description: "Document path supplied by CCGram" }),
|
||
}),
|
||
async execute(_id, params) {
|
||
try {
|
||
const path = resolveUpload(params.path);
|
||
const stat = lstatSync(path);
|
||
if (stat.size > MAX_PARSE_BYTES) throw new Error("Document exceeds the 100 MiB parse limit.");
|
||
const supported = new Set([
|
||
".pdf",
|
||
".docx",
|
||
".doc",
|
||
".docm",
|
||
".pptx",
|
||
".ppt",
|
||
".pptm",
|
||
".pps",
|
||
".ppsx",
|
||
".ppsm",
|
||
".pot",
|
||
".xlsx",
|
||
".xls",
|
||
".xlsm",
|
||
".xlsb",
|
||
".odt",
|
||
".ods",
|
||
".odp",
|
||
".rtf",
|
||
".epub",
|
||
".html",
|
||
".htm",
|
||
".csv",
|
||
".json",
|
||
".xml",
|
||
".txt",
|
||
".md",
|
||
".png",
|
||
".jpg",
|
||
".jpeg",
|
||
".webp",
|
||
]);
|
||
const extension = extname(path).toLocaleLowerCase();
|
||
if (!supported.has(extension)) {
|
||
throw new Error(`Unsupported document type: ${extension || "(none)"}`);
|
||
}
|
||
const form = new FormData();
|
||
form.append(
|
||
"file",
|
||
new Blob([readFileSync(path)]),
|
||
basename(path),
|
||
);
|
||
const response = await fetch(`${PARSER_BASE_URL.replace(/\/$/, "")}/v1/parse`, {
|
||
method: "POST",
|
||
body: form,
|
||
signal: AbortSignal.timeout(300_000),
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(`Document parser failed: HTTP ${response.status} ${await response.text()}`);
|
||
}
|
||
const payload = (await response.json()) as {
|
||
filename?: string;
|
||
markdown?: string;
|
||
characters?: number;
|
||
parser?: string;
|
||
requires_ocr?: boolean;
|
||
ocr_enabled?: boolean;
|
||
pdf?: {
|
||
type?: string;
|
||
page_count?: number;
|
||
confidence?: number;
|
||
pages_needing_ocr?: number[];
|
||
complex_layout?: boolean;
|
||
has_encoding_issues?: boolean;
|
||
} | null;
|
||
};
|
||
const markdown = payload.markdown?.trim() || "";
|
||
if (!markdown && !payload.requires_ocr) throw new Error("Document parser returned no content.");
|
||
const sourceCharacters = payload.characters ?? markdown.length;
|
||
const pagesNeedingOcr = payload.pdf?.pages_needing_ocr || [];
|
||
const routing = payload.requires_ocr
|
||
? pagesNeedingOcr.length
|
||
? `OCR required for PDF page(s): ${pagesNeedingOcr.join(", ")}. Call document_ocr for the source document.`
|
||
: "OCR required. Call document_ocr for the source image or scanned PDF."
|
||
: "OCR routing: no fallback required.";
|
||
const output = [
|
||
`Parsed source: ${payload.filename || basename(path)}`,
|
||
`Parser: local ${payload.parser || "AnyDoc/pdf-inspector"}`,
|
||
`Parsed characters: ${sourceCharacters}`,
|
||
payload.pdf
|
||
? `PDF classification: ${payload.pdf.type || "unknown"}; ${payload.pdf.page_count || "?"} page(s); confidence ${payload.pdf.confidence ?? "unknown"}.`
|
||
: null,
|
||
routing,
|
||
"Parsed text is derivative. Verify uncertain names, numbers, dates, and times against the source.",
|
||
"",
|
||
markdown || "No reliable embedded text was extracted.",
|
||
].filter((line) => line !== null).join("\n");
|
||
return textResult(output.slice(0, MAX_READ_CHARS), {
|
||
path: relative(WORKSPACE, path),
|
||
sourceCharacters,
|
||
parser: payload.parser || "anydoc+pdf-inspector",
|
||
requiresOcr: payload.requires_ocr ?? false,
|
||
pagesNeedingOcr,
|
||
pdf: payload.pdf || undefined,
|
||
ocrEnabled: payload.ocr_enabled ?? false,
|
||
truncated: output.length > MAX_READ_CHARS,
|
||
});
|
||
} catch (error) {
|
||
return textResult(`ERROR: ${(error as Error).message}`, { error: true });
|
||
}
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "document_ocr",
|
||
label: "OCR Telegram Document",
|
||
description:
|
||
"OCR one Telegram PDF or image from .ccgram-uploads with Kai's local FireOCR service. PDFs may specify up to 10 1-based page numbers from document_parse routing. Other paths are denied.",
|
||
parameters: Type.Object({
|
||
path: Type.String({ description: "PDF or image path supplied by CCGram" }),
|
||
pages: Type.Optional(
|
||
Type.Array(Type.Integer({ minimum: 1 }), {
|
||
description: "Optional 1-based PDF pages reported by document_parse",
|
||
maxItems: MAX_OCR_PAGES,
|
||
}),
|
||
),
|
||
}),
|
||
async execute(_id, params) {
|
||
let scratch: string | null = null;
|
||
try {
|
||
const path = resolveUpload(params.path);
|
||
const stat = lstatSync(path);
|
||
if (stat.size > MAX_DOCUMENT_BYTES) throw new Error("Document exceeds the 20 MiB OCR limit.");
|
||
const extension = extname(path).toLocaleLowerCase();
|
||
let pages: Array<{ path: string; page: number }> = [];
|
||
if (extension === ".pdf") {
|
||
const info = await run("/usr/bin/mutool", ["info", path], 30_000);
|
||
if (info.code !== 0) throw new Error(`Cannot inspect PDF: ${info.stderr || info.stdout}`);
|
||
const match = info.stdout.match(/^Pages:\s*(\d+)/m);
|
||
if (!match) throw new Error("Cannot determine PDF page count.");
|
||
const totalPages = Number(match[1]);
|
||
if (totalPages < 1) throw new Error("PDF has no pages.");
|
||
const requestedPages = params.pages?.length
|
||
? [...new Set(params.pages)].sort((a, b) => a - b)
|
||
: Array.from({ length: totalPages }, (_, index) => index + 1);
|
||
if (requestedPages.some((page) => page > totalPages)) {
|
||
throw new Error(`Requested OCR page exceeds the PDF page count (${totalPages}).`);
|
||
}
|
||
if (requestedPages.length > MAX_OCR_PAGES) {
|
||
throw new Error(
|
||
`OCR requested ${requestedPages.length} pages; the Pi Memo limit is ${MAX_OCR_PAGES}. Pass the pages reported by document_parse or send a smaller PDF.`,
|
||
);
|
||
}
|
||
scratch = mkdtempSync(join(tmpdir(), "pi-memo-ocr-"));
|
||
for (const page of requestedPages) {
|
||
const outputPath = join(scratch, `page-${String(page).padStart(4, "0")}.png`);
|
||
const rendered = await run(
|
||
"/usr/bin/mutool",
|
||
["draw", "-q", "-r", "180", "-o", outputPath, path, String(page)],
|
||
120_000,
|
||
);
|
||
if (rendered.code !== 0) {
|
||
throw new Error(`PDF page ${page} rendering failed: ${rendered.stderr || rendered.stdout}`);
|
||
}
|
||
pages.push({ path: outputPath, page });
|
||
}
|
||
} else if (mimeForImage(path)) {
|
||
pages = [{ path, page: 1 }];
|
||
} else {
|
||
throw new Error("Unsupported document type; use PDF, PNG, JPEG, or WebP.");
|
||
}
|
||
|
||
const sections: string[] = [];
|
||
for (const item of pages) {
|
||
const text = await fireOcr(item.path, `page ${item.page}`);
|
||
sections.push(`## Page ${item.page}\n\n${text}`);
|
||
}
|
||
const output = [
|
||
`OCR source: ${basename(path)}`,
|
||
`OCR engine: ${OCR_MODEL} at local FireOCR`,
|
||
"OCR is derivative. Verify uncertain names, numbers, dates, and times against the source.",
|
||
"",
|
||
...sections,
|
||
].join("\n");
|
||
return textResult(output.slice(0, MAX_READ_CHARS), {
|
||
path: relative(WORKSPACE, path),
|
||
pages: pages.length,
|
||
pageNumbers: pages.map((item) => item.page),
|
||
model: OCR_MODEL,
|
||
source: "OCR",
|
||
truncated: output.length > MAX_READ_CHARS,
|
||
});
|
||
} catch (error) {
|
||
return textResult(`ERROR: ${(error as Error).message}`, { error: true });
|
||
} finally {
|
||
if (scratch) rmSync(scratch, { recursive: true, force: true });
|
||
}
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "vault_search",
|
||
label: "Search Obsidian Vault",
|
||
description: "Quick read-only search across Markdown filenames and content in the Obsidian vault.",
|
||
parameters: Type.Object({
|
||
query: Type.String({ minLength: 2, maxLength: 120 }),
|
||
maxResults: Type.Optional(Type.Number({ minimum: 1, maximum: 20 })),
|
||
}),
|
||
async execute(_id, params) {
|
||
const query = params.query.trim().toLocaleLowerCase();
|
||
const maxResults = Math.floor(params.maxResults || 8);
|
||
const files: string[] = [];
|
||
walkMarkdown(VAULT, files);
|
||
const matches: Array<{ path: string; snippets: string[] }> = [];
|
||
for (const path of files) {
|
||
let content = "";
|
||
try {
|
||
content = readFileSync(path, "utf8");
|
||
} catch {
|
||
continue;
|
||
}
|
||
const rel = relative(VAULT, path);
|
||
const lines = content.split(/\r?\n/);
|
||
const snippets = lines
|
||
.filter((line) => line.toLocaleLowerCase().includes(query))
|
||
.slice(0, 3)
|
||
.map((line) => line.trim().slice(0, 240));
|
||
if (rel.toLocaleLowerCase().includes(query) || snippets.length) {
|
||
matches.push({ path: rel, snippets });
|
||
if (matches.length >= maxResults) break;
|
||
}
|
||
}
|
||
if (!matches.length) return textResult("No matching vault notes.");
|
||
return textResult(
|
||
matches
|
||
.map((match) => {
|
||
const lines = match.snippets.length ? match.snippets.map((s) => ` ${s}`).join("\n") : " filename match";
|
||
return `- ${match.path}\n${lines}`;
|
||
})
|
||
.join("\n"),
|
||
{ count: matches.length },
|
||
);
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "journal_append",
|
||
label: "Append and Sync Journal",
|
||
description: "Append one prepared entry to today's or a specified Shanghai-date journal, then commit and push only that journal file.",
|
||
parameters: Type.Object({
|
||
category: Type.Union([
|
||
Type.Literal("record"),
|
||
Type.Literal("idea"),
|
||
Type.Literal("todo"),
|
||
]),
|
||
entry: Type.String({ minLength: 1, maxLength: 4000 }),
|
||
date: Type.Optional(Type.String({ description: "YYYY-MM-DD in Asia/Shanghai; defaults to today" })),
|
||
}),
|
||
async execute(_id, params) {
|
||
try {
|
||
const date = params.date || shanghaiNow().date;
|
||
validateDate(date);
|
||
const entry = validateEntry(params.category, params.entry);
|
||
const filename = `${date.replaceAll("-", "_")}.md`;
|
||
const path = safeRealPath(join(JOURNALS, filename), true);
|
||
if (!inside(JOURNALS, path)) throw new Error("Journal path escaped journals directory.");
|
||
let content = existsSync(path) ? readFileSync(path, "utf8") : newJournal(date);
|
||
const heading =
|
||
params.category === "todo" ? "今日任务" : params.category === "idea" ? "收获/想法" : "记录";
|
||
content = insertUnderHeading(content, heading, entry);
|
||
const temp = `${path}.pi-memo-${process.pid}.tmp`;
|
||
writeFileSync(temp, content, { encoding: "utf8", mode: 0o644 });
|
||
renameSync(temp, path);
|
||
const rel = relative(VAULT, path);
|
||
const sync = await run(SYNC_HELPER, [rel], 120_000);
|
||
const synced = sync.code === 0;
|
||
return textResult(
|
||
synced
|
||
? `Journal updated and pushed: ${rel}\n${entry}`
|
||
: `Journal updated locally but sync failed: ${rel}\n${entry}\n${sync.stderr || sync.stdout}`,
|
||
{ path: rel, category: params.category, synced, syncCode: sync.code },
|
||
);
|
||
} catch (error) {
|
||
return textResult(`ERROR: ${(error as Error).message}`, { error: true });
|
||
}
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "journal_batch_append",
|
||
label: "Append and Sync Multiple Journal Entries",
|
||
description:
|
||
"Atomically append multiple prepared record/idea/todo entries to one Shanghai-date journal, then commit and push that journal once. Prefer this whenever one user message contains more than one journal entry or category.",
|
||
parameters: Type.Object({
|
||
date: Type.Optional(Type.String({ description: "YYYY-MM-DD in Asia/Shanghai; defaults to today" })),
|
||
entries: Type.Array(
|
||
Type.Object({
|
||
category: Type.Union([
|
||
Type.Literal("record"),
|
||
Type.Literal("idea"),
|
||
Type.Literal("todo"),
|
||
]),
|
||
entry: Type.String({ minLength: 1, maxLength: 4000 }),
|
||
}),
|
||
{ minItems: 1, maxItems: 10 },
|
||
),
|
||
}),
|
||
async execute(_id, params) {
|
||
try {
|
||
const date = params.date || shanghaiNow().date;
|
||
validateDate(date);
|
||
const prepared = params.entries.map((item) => ({
|
||
category: item.category,
|
||
entry: validateEntry(item.category, item.entry),
|
||
}));
|
||
const filename = `${date.replaceAll("-", "_")}.md`;
|
||
const path = safeRealPath(join(JOURNALS, filename), true);
|
||
if (!inside(JOURNALS, path)) throw new Error("Journal path escaped journals directory.");
|
||
let content = existsSync(path) ? readFileSync(path, "utf8") : newJournal(date);
|
||
for (const item of prepared) {
|
||
const heading =
|
||
item.category === "todo" ? "今日任务" : item.category === "idea" ? "收获/想法" : "记录";
|
||
content = insertUnderHeading(content, heading, item.entry);
|
||
}
|
||
const temp = `${path}.pi-memo-${process.pid}.tmp`;
|
||
writeFileSync(temp, content, { encoding: "utf8", mode: 0o644 });
|
||
renameSync(temp, path);
|
||
const rel = relative(VAULT, path);
|
||
const sync = await run(SYNC_HELPER, [rel], 120_000);
|
||
const synced = sync.code === 0;
|
||
const entries = prepared.map((item) => item.entry).join("\n");
|
||
return textResult(
|
||
synced
|
||
? `Journal batch updated and pushed: ${rel}\n${entries}`
|
||
: `Journal batch updated locally but sync failed: ${rel}\n${entries}\n${sync.stderr || sync.stdout}`,
|
||
{ path: rel, entries: prepared.length, synced, syncCode: sync.code },
|
||
);
|
||
} catch (error) {
|
||
return textResult(`ERROR: ${(error as Error).message}`, { error: true });
|
||
}
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "calendar_list",
|
||
label: "Find Google Calendar Events",
|
||
description:
|
||
"List events in Kai's work calendar within an explicit Shanghai-time range. Use this first to obtain a unique event ID before updating or deleting.",
|
||
parameters: Type.Object({
|
||
timeMin: Type.String({ description: "Inclusive RFC3339 lower bound with +08:00" }),
|
||
timeMax: Type.String({ description: "Exclusive RFC3339 upper bound with +08:00" }),
|
||
query: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
|
||
maxResults: Type.Optional(Type.Integer({ minimum: 1, maximum: 50 })),
|
||
}),
|
||
async execute(_id, params) {
|
||
try {
|
||
const timeMin = parseRfc3339(params.timeMin, "timeMin");
|
||
const timeMax = parseRfc3339(params.timeMax, "timeMax");
|
||
if (timeMax <= timeMin) throw new Error("timeMax must be after timeMin");
|
||
if (timeMax.getTime() - timeMin.getTime() > 366 * 24 * 60 * 60 * 1000) {
|
||
throw new Error("Calendar query range exceeds 366 days");
|
||
}
|
||
const queryParams: Record<string, unknown> = {
|
||
calendarId: CALENDAR_ID,
|
||
timeMin: params.timeMin,
|
||
timeMax: params.timeMax,
|
||
singleEvents: true,
|
||
orderBy: "startTime",
|
||
maxResults: params.maxResults || 20,
|
||
fields: "items(id,summary,start,end,location,description,status,htmlLink),nextPageToken",
|
||
};
|
||
if (params.query) queryParams.q = params.query;
|
||
const result = await run(
|
||
GWS,
|
||
[
|
||
"calendar",
|
||
"events",
|
||
"list",
|
||
"--params",
|
||
JSON.stringify(queryParams),
|
||
"--format",
|
||
"json",
|
||
],
|
||
60_000,
|
||
);
|
||
if (result.code !== 0) {
|
||
return textResult(`Calendar query failed: ${result.stderr || result.stdout}`, {
|
||
error: true,
|
||
code: result.code,
|
||
});
|
||
}
|
||
let count: number | undefined;
|
||
try {
|
||
const payload = JSON.parse(result.stdout) as { items?: unknown[] };
|
||
count = payload.items?.length || 0;
|
||
} catch {
|
||
count = undefined;
|
||
}
|
||
return textResult(`Calendar events:\n${result.stdout}`, {
|
||
count,
|
||
timeMin: params.timeMin,
|
||
timeMax: params.timeMax,
|
||
query: params.query,
|
||
});
|
||
} catch (error) {
|
||
return textResult(`ERROR: ${(error as Error).message}`, { error: true });
|
||
}
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "calendar_create",
|
||
label: "Create Google Calendar Event",
|
||
description: "Create one event in Kai's work Google Calendar. The agent may resolve omitted user fields from context and defaults, then must pass concrete RFC3339 +08:00 start and end times.",
|
||
parameters: Type.Object({
|
||
summary: Type.String({ minLength: 1, maxLength: 200 }),
|
||
start: Type.String(),
|
||
end: Type.String(),
|
||
location: Type.Optional(Type.String({ maxLength: 500 })),
|
||
description: Type.Optional(Type.String({ maxLength: 4000 })),
|
||
}),
|
||
async execute(_id, params) {
|
||
try {
|
||
const start = parseRfc3339(params.start, "start");
|
||
const end = parseRfc3339(params.end, "end");
|
||
if (end <= start) throw new Error("end must be after start");
|
||
if (end.getTime() - start.getTime() > 14 * 24 * 60 * 60 * 1000) {
|
||
throw new Error("Event duration exceeds 14 days");
|
||
}
|
||
const args = [
|
||
"calendar",
|
||
"+insert",
|
||
"--calendar",
|
||
CALENDAR_ID,
|
||
"--summary",
|
||
params.summary,
|
||
"--start",
|
||
params.start,
|
||
"--end",
|
||
params.end,
|
||
];
|
||
if (params.location) args.push("--location", params.location);
|
||
if (params.description) args.push("--description", params.description);
|
||
const result = await run(GWS, args, 60_000);
|
||
if (result.code !== 0) {
|
||
return textResult(`Calendar creation failed: ${result.stderr || result.stdout}`, {
|
||
error: true,
|
||
code: result.code,
|
||
});
|
||
}
|
||
return textResult(
|
||
`Calendar event created: ${params.summary}\n${params.start} – ${params.end}\n${result.stdout}`,
|
||
{ summary: params.summary, start: params.start, end: params.end, created: true },
|
||
);
|
||
} catch (error) {
|
||
return textResult(`ERROR: ${(error as Error).message}`, { error: true });
|
||
}
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "calendar_update",
|
||
label: "Update Google Calendar Event",
|
||
description:
|
||
"Patch one uniquely identified event in Kai's work calendar. The caller must first list events and provide both the event ID and its exact current title.",
|
||
parameters: Type.Object({
|
||
eventId: Type.String({ minLength: 1, maxLength: 1024 }),
|
||
expectedSummary: Type.String({ minLength: 1, maxLength: 200 }),
|
||
summary: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
|
||
start: Type.Optional(Type.String()),
|
||
end: Type.Optional(Type.String()),
|
||
location: Type.Optional(Type.String({ maxLength: 500 })),
|
||
description: Type.Optional(Type.String({ maxLength: 4000 })),
|
||
}),
|
||
async execute(_id, params) {
|
||
try {
|
||
const current = await getCalendarEvent(params.eventId);
|
||
const currentSummary = requireExpectedSummary(current, params.expectedSummary);
|
||
if ((params.start && !params.end) || (!params.start && params.end)) {
|
||
throw new Error("start and end must be supplied together");
|
||
}
|
||
const body: Record<string, unknown> = {};
|
||
if (params.summary !== undefined) body.summary = params.summary;
|
||
if (params.location !== undefined) body.location = params.location;
|
||
if (params.description !== undefined) body.description = params.description;
|
||
if (params.start && params.end) {
|
||
const start = parseRfc3339(params.start, "start");
|
||
const end = parseRfc3339(params.end, "end");
|
||
if (end <= start) throw new Error("end must be after start");
|
||
if (end.getTime() - start.getTime() > 14 * 24 * 60 * 60 * 1000) {
|
||
throw new Error("Event duration exceeds 14 days");
|
||
}
|
||
body.start = { dateTime: params.start, timeZone: "Asia/Shanghai" };
|
||
body.end = { dateTime: params.end, timeZone: "Asia/Shanghai" };
|
||
}
|
||
if (!Object.keys(body).length) throw new Error("No calendar fields were supplied to update");
|
||
const result = await run(
|
||
GWS,
|
||
[
|
||
"calendar",
|
||
"events",
|
||
"patch",
|
||
"--params",
|
||
JSON.stringify({
|
||
calendarId: CALENDAR_ID,
|
||
eventId: params.eventId,
|
||
sendUpdates: "none",
|
||
}),
|
||
"--json",
|
||
JSON.stringify(body),
|
||
"--format",
|
||
"json",
|
||
],
|
||
60_000,
|
||
);
|
||
if (result.code !== 0) {
|
||
return textResult(`Calendar update failed: ${result.stderr || result.stdout}`, {
|
||
error: true,
|
||
code: result.code,
|
||
});
|
||
}
|
||
return textResult(
|
||
`Calendar event updated: ${currentSummary}\nEvent ID: ${params.eventId}\n${result.stdout}`,
|
||
{ eventId: params.eventId, previousSummary: currentSummary, updated: true },
|
||
);
|
||
} catch (error) {
|
||
return textResult(`ERROR: ${(error as Error).message}`, { error: true });
|
||
}
|
||
},
|
||
});
|
||
|
||
pi.registerTool({
|
||
name: "calendar_delete",
|
||
label: "Delete Google Calendar Event",
|
||
description:
|
||
"Delete one uniquely identified event from Kai's work calendar. The caller must first list events and provide both the event ID and its exact current title.",
|
||
parameters: Type.Object({
|
||
eventId: Type.String({ minLength: 1, maxLength: 1024 }),
|
||
expectedSummary: Type.String({ minLength: 1, maxLength: 200 }),
|
||
}),
|
||
async execute(_id, params) {
|
||
try {
|
||
const current = await getCalendarEvent(params.eventId);
|
||
const currentSummary = requireExpectedSummary(current, params.expectedSummary);
|
||
const result = await run(
|
||
GWS,
|
||
[
|
||
"calendar",
|
||
"events",
|
||
"delete",
|
||
"--params",
|
||
JSON.stringify({
|
||
calendarId: CALENDAR_ID,
|
||
eventId: params.eventId,
|
||
sendUpdates: "none",
|
||
}),
|
||
],
|
||
60_000,
|
||
);
|
||
if (result.code !== 0) {
|
||
return textResult(`Calendar deletion failed: ${result.stderr || result.stdout}`, {
|
||
error: true,
|
||
code: result.code,
|
||
});
|
||
}
|
||
return textResult(
|
||
`Calendar event deleted: ${currentSummary}\nEvent ID: ${params.eventId}`,
|
||
{ eventId: params.eventId, summary: currentSummary, deleted: true },
|
||
);
|
||
} catch (error) {
|
||
return textResult(`ERROR: ${(error as Error).message}`, { error: true });
|
||
}
|
||
},
|
||
});
|
||
|
||
const restrictTools = () => pi.setActiveTools(ALLOWED_TOOLS);
|
||
pi.on("session_start", async () => restrictTools());
|
||
pi.on("resources_discover", async () => restrictTools());
|
||
pi.on("tool_call", async (event) => {
|
||
if (!ALLOWED_TOOLS.includes(event.toolName)) {
|
||
return { block: true, reason: `Pi Memo workspace blocks tool: ${event.toolName}` };
|
||
}
|
||
});
|
||
}
|