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 = {}) { 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 { 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 { 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> { 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; } catch { throw new Error("Calendar lookup returned invalid JSON."); } } function requireExpectedSummary(event: Record, 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 = { 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 = {}; 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}` }; } }); }