Files
pi-agent-config/scenarios/memo-inbox/workspace/.pi/extensions/memo-guard.ts
T
Kai 07dd648b5f 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.
2026-08-26 23:17:12 -07:00

938 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}` };
}
});
}