feat: scenarios for curator/memo-inbox/pi-grok, deploy and backup tooling
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.
This commit is contained in:
@@ -0,0 +1,937 @@
|
||||
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}` };
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user