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:
Kai
2026-08-26 23:17:12 -07:00
parent 7b5e0b093d
commit 07dd648b5f
20 changed files with 2611 additions and 17 deletions
@@ -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}` };
}
});
}