feat: scenarios for curator/memo-inbox/pi-grok, deploy and backup tooling
Scenarios - memo-inbox: mirrored by copying; the live directory was not moved or modified and the service was not restarted. All four tracked files match byte for byte (pi-diff.sh reports SAME). Marked deploy = "mirror" so deploy-scenario.sh refuses --apply: applying a mirror would invert the direction of truth and could change a service in daily use. - curator: target configuration, not yet deployed. .pi/SYSTEM.md replaces pi's coding-assistant prompt; durable role text is in .pi/APPEND_SYSTEM.md; profile.toml is the single source of truth for the launch contract. - pi-grok: registered only. It is genuinely a coding agent, so the isolation baseline does not apply in full. Corrections to the documentation, found by testing rather than by reading - AGENTS.override.md does NOT block parent-directory context files; it only shadows its own directory. Verified: with an override file in the workspace, a marker in /tmp/AGENTS.md still reached the system prompt. The only effective switch is --no-context-files, so durable role text must live in .pi/APPEND_SYSTEM.md, which is a system-prompt file and unaffected by -nc. Verified end state: no coding-assistant framing, no pi-docs block, own identity and role text present, no parent pollution, only own skills/tools. - PI_CODING_AGENT_DIR isolates settings/models/auth/trust/extensions/skills/ prompts/themes under the agent directory -- stronger than the --no-* flags because it also repoints credentials -- but does NOT cover ~/.agents/skills. Measured: find-skills, modsearch and summarize still leak. So it complements --no-skills rather than replacing it. - --append-system-prompt accepts a file path, which pi-grok relies on. - cwd is what anchors .pi discovery: a probe that forgot cwd silently lost .pi/SYSTEM.md and kept the coding-assistant persona. Tooling (all dry-run by default; none of them restarts a service) - pi-diff.sh: compares tracked config against the live install in both directions, with a key-redacted comparison for models.json - deploy-scenario.sh: installs a workspace and renders profile.toml into .pi/launch.json, then checks that every referenced path exists - deploy-runtime.sh: renders models.json from its template, refusing placeholder or missing keys. Verified byte-identical to the live file - pi-backup.sh / pi-restore.sh: archives outside the repo, sha256 manifest verified before any restore, live paths preserved rather than overwritten Fixed while testing: pi-backup.sh compared the destination against the repo root literally, so a relative --dest ./backups wrote credential archives into the work tree. Now canonicalised with realpath; ./backups, an absolute in-repo path and ./docs/../backups are all refused.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
# Scenario: memo-inbox
|
||||
|
||||
Routes Kai's Telegram and WeChat messages into Google Calendar, today's Obsidian
|
||||
todo list, or a journal memo — without requiring slash commands or confirmation.
|
||||
|
||||
Live service: `pi-memo-telegram.service` (user unit, active).
|
||||
Live workspace: `/home/claw/pi-workspaces/memo-inbox`.
|
||||
|
||||
## Migration status
|
||||
|
||||
**Mirrored, not yet managed.** Migrated 2026-08-27 by copying; the live
|
||||
directory was not moved or modified and the service was not restarted. All four
|
||||
tracked files match production byte for byte.
|
||||
|
||||
| Tracked | Live target |
|
||||
|---|---|
|
||||
| `workspace/AGENTS.md` | `<workspace>/AGENTS.md` |
|
||||
| `workspace/.pi/extensions/memo-guard.ts` | `<workspace>/.pi/extensions/memo-guard.ts` |
|
||||
| `workspace/.agents/skills/pi-memo-inbox/SKILL.md` | `<workspace>/.agents/skills/pi-memo-inbox/SKILL.md` |
|
||||
| `workspace/bin/journal-sync.sh` | `<workspace>/bin/journal-sync.sh` |
|
||||
|
||||
Verify at any time:
|
||||
|
||||
```bash
|
||||
scripts/pi-diff.sh memo-inbox
|
||||
```
|
||||
|
||||
## Deliberately not tracked
|
||||
|
||||
| Path | Why |
|
||||
|---|---|
|
||||
| `telegram-gateway/` | Application code (622 + 343 lines), not configuration. It also sits inside the agent's own cwd, which is a separate design problem — an agent's readable workspace should not contain the program that drives it. Relocating it is a follow-up. |
|
||||
| `.ccgram-uploads/` | Runtime data: inbound Telegram voice notes, photos and documents. |
|
||||
| `download.html` | Generated artefact. |
|
||||
| `telegram-gateway/.venv/` | Toolchain. |
|
||||
| `gateway.py.bak-20260818-wechat` | Stale backup. |
|
||||
|
||||
## Why this scenario matters to the others
|
||||
|
||||
memo-inbox is the **reference implementation** for three patterns that the
|
||||
curator scenario lacks. See `docs/gateway-patterns.md` for the full comparison.
|
||||
|
||||
1. **Long-lived RPC process.** `gateway.py` starts `pi --mode rpc` once and
|
||||
drives it turn by turn, with protocol-correct `\n`-only JSONL framing.
|
||||
2. **Session rotation.** Rotates after 24 prompts, plus a `messageCount >= 60`
|
||||
guard. pi has no session TTL and auto-compaction does not fire on a
|
||||
1.05 M-token context window, so a gateway must do this itself.
|
||||
3. **Deterministic write receipts.** The reply a user sees for a state change is
|
||||
harvested from `tool_execution_end` on the mutation tools, not generated by
|
||||
the model. This is the single most valuable pattern in the repository.
|
||||
|
||||
Its `memo-guard.ts` is also the origin of `shared/extensions/pi-guard-base.ts`:
|
||||
path containment, the restricted `read` override, `setActiveTools`, and the
|
||||
`tool_call` block.
|
||||
|
||||
## Known gaps
|
||||
|
||||
Recorded here rather than fixed, because this service is in daily use and the
|
||||
migration was scoped to zero behaviour change. Tracked as plan phase 5.
|
||||
|
||||
| Gap | Effect |
|
||||
|---|---|
|
||||
| No `--no-extensions` / `--no-skills` / `--no-prompt-templates` / `--no-themes` | `~/.pi/agent/extensions/{herdr-agent-state,pi-memo-trust}.ts` and `~/.agents/skills/{find-skills,modsearch,summarize}` load into this agent. `find-skills` instructs the agent to discover and install further skills. |
|
||||
| No `.pi/SYSTEM.md` | Runs on pi's default coding-assistant prompt, including the block of absolute paths to pi's own docs with an instruction to read them and follow cross-references. `AGENTS.md` corrects course from on top of that rather than replacing it. |
|
||||
| Full environment inherited | `ASR_API_KEY` and anything else in the unit reaches the node process and every extension it loads. |
|
||||
| No `--no-builtin-tools` | Built-ins are active at startup and are narrowed only once `session_start` fires and `setActiveTools` runs. The `tool_call` hook still blocks them, so this is a defence-in-depth gap rather than an open hole. |
|
||||
| `--continue` instead of `--session-id` | Continuity depends on "most recent session for this cwd". A stray interactive `pi` run in the same directory could be continued by the service. |
|
||||
| `allowed-tools:` in SKILL.md | Not consumed by pi 0.84.3. Harmless, but do not treat it as enforcement. |
|
||||
|
||||
## Applying changes
|
||||
|
||||
```bash
|
||||
scripts/deploy-scenario.sh memo-inbox # dry run, shows the diff
|
||||
scripts/deploy-scenario.sh memo-inbox --apply # then restart the unit yourself
|
||||
```
|
||||
|
||||
The deploy script never restarts a service. For this scenario:
|
||||
|
||||
```bash
|
||||
systemctl --user restart pi-memo-telegram.service
|
||||
systemctl --user status pi-memo-telegram.service
|
||||
```
|
||||
@@ -0,0 +1,108 @@
|
||||
# memo-inbox — Pi scenario profile
|
||||
#
|
||||
# STATUS: as-found mirror. This file records what the live service actually does
|
||||
# as of 2026-08-27. It is NOT yet the target configuration.
|
||||
#
|
||||
# The migration into this repository is deliberately zero-behaviour-change: the
|
||||
# service is in daily use, so its launch flags are transcribed rather than fixed.
|
||||
# The gaps below are tracked in docs/isolation-baseline.md and are scheduled for
|
||||
# a separate pass (plan phase 5).
|
||||
|
||||
[scenario]
|
||||
name = "memo-inbox"
|
||||
description = "Routes Kai's Telegram/WeChat messages into Google Calendar, today's Obsidian todo list, or a journal memo."
|
||||
workspace = "/home/claw/pi-workspaces/memo-inbox"
|
||||
session_dir = "/home/claw/.local/share/pi-memo-telegram/sessions"
|
||||
service = "pi-memo-telegram.service"
|
||||
# "mirror": this file records what the live host does; it is not yet the source
|
||||
# of truth. deploy-scenario.sh refuses --apply for mirrors so that a service in
|
||||
# daily use cannot be changed by a migration commit. Promote to "managed" only
|
||||
# together with the phase-5 isolation work.
|
||||
deploy = "mirror"
|
||||
# The gateway application lives inside the agent's own cwd, which is not ideal:
|
||||
# the workspace an agent can read should not contain the code that drives it.
|
||||
# Moving it is tracked as a follow-up; it is not a configuration change.
|
||||
gateway = "/home/claw/pi-workspaces/memo-inbox/telegram-gateway/gateway.py"
|
||||
|
||||
[model]
|
||||
provider = "zenmux"
|
||||
primary = "x-ai/grok-4.6"
|
||||
# No fallback model is configured for this scenario.
|
||||
thinking = "medium"
|
||||
|
||||
[session]
|
||||
# Implemented in gateway.py: PI_SESSION_ROTATE_AFTER_PROMPTS, plus an
|
||||
# additional messageCount >= 60 check in rotate_if_oversized().
|
||||
rotate_after_prompts = 24
|
||||
rotate_after_messages = 60
|
||||
# Uses --continue rather than --session-id, so continuity depends on "most
|
||||
# recent session in this project" rather than an explicit identifier.
|
||||
strategy = "continue"
|
||||
|
||||
[isolation]
|
||||
# ---- as-found ----
|
||||
# Layers 3 and 4 are correct and are the reference implementation for the other
|
||||
# scenarios: memo-guard.ts calls setActiveTools(ALLOWED_TOOLS) on session_start
|
||||
# and resources_discover, and blocks anything else in a tool_call hook.
|
||||
capability_guard = true # pi.setActiveTools
|
||||
invocation_guard = true # pi.on("tool_call") -> block
|
||||
read_override = true # restricted read, workspace + vault only
|
||||
|
||||
# Layer 1 is NOT applied: user-global extensions and skills load into this
|
||||
# agent. Measured leak from ~/.agents/skills: find-skills, modsearch, summarize.
|
||||
no_extensions = false # gap
|
||||
no_skills = false # gap
|
||||
no_prompt_templates = false # gap
|
||||
no_themes = false # gap
|
||||
no_builtin_tools = false # gap — relies on setActiveTools alone
|
||||
approve = true # passed on the command line
|
||||
|
||||
# Layer 2 is NOT applied: the agent runs on pi's default coding-assistant system
|
||||
# prompt, with AGENTS.md layered on top as project context.
|
||||
system_prompt_file = "" # gap — no .pi/SYSTEM.md
|
||||
context_files = "AGENTS.md"
|
||||
|
||||
[env]
|
||||
# gateway.py inherits the full process environment, so ASR_API_KEY and whatever
|
||||
# else the unit carries reach the node process and every extension it loads.
|
||||
minimal = false # gap
|
||||
|
||||
[tools]
|
||||
# Enforced by ALLOWED_TOOLS in .pi/extensions/memo-guard.ts.
|
||||
# Note: SKILL.md also declares an `allowed-tools:` frontmatter field, but pi
|
||||
# 0.84.3 does not consume it. The array below is the only real enforcement.
|
||||
allow = [
|
||||
"read",
|
||||
"image_view",
|
||||
"document_parse",
|
||||
"document_ocr",
|
||||
"vault_search",
|
||||
"journal_append",
|
||||
"journal_batch_append",
|
||||
"calendar_list",
|
||||
"calendar_create",
|
||||
"calendar_update",
|
||||
"calendar_delete",
|
||||
]
|
||||
|
||||
# Tools whose results are user-visible state changes. gateway.py harvests their
|
||||
# text from tool_execution_end and reports that, instead of trusting the model's
|
||||
# prose — the pattern the curator scenario should adopt.
|
||||
receipt_tools = [
|
||||
"journal_append",
|
||||
"journal_batch_append",
|
||||
"calendar_create",
|
||||
"calendar_update",
|
||||
"calendar_delete",
|
||||
]
|
||||
|
||||
[backends]
|
||||
parser_base_url = "http://127.0.0.1:8090" # PI_MEMO_PARSER_BASE_URL
|
||||
ocr_base_url = "http://192.168.50.100:8001" # PI_MEMO_OCR_BASE_URL, LAN not loopback
|
||||
ocr_model = "firered-ocr"
|
||||
vault = "/home/claw/obsidian-vault"
|
||||
calendar_cli = "/home/claw/.npm-global/bin/gws"
|
||||
|
||||
[secrets]
|
||||
# Not in this repository.
|
||||
env_file = "/home/claw/.secrets/pi-memo-telegram.env"
|
||||
@@ -0,0 +1,154 @@
|
||||
---
|
||||
name: pi-memo-inbox
|
||||
description: Automatically route Kai's Telegram text, short voice transcription, images, and uploaded documents into Google Calendar, today's Obsidian Todo, or Memo without requiring slash commands or repeated confirmation. Use for any incoming work update, fact, idea, action item, appointment, meeting time, trip, deadline, reminder, or content sent for capture.
|
||||
allowed-tools: read image_view document_parse document_ocr vault_search journal_append calendar_list calendar_create calendar_update calendar_delete
|
||||
---
|
||||
|
||||
# Pi Memo Inbox
|
||||
|
||||
## Default Behavior
|
||||
|
||||
Treat every ordinary incoming text or voice message as an item to classify and persist unless Kai clearly asks only a question, requests a preview/transcription, or says not to record it. Do not wait for `/memo`, `/todo`, `/calendar`, “记一下”, “待办事项”, or “日历事项”.
|
||||
|
||||
Classify into exactly one route and execute it immediately:
|
||||
|
||||
1. `calendar`
|
||||
- A future scheduled event, reminder, or action whose scheduling intent is clear. Missing fields may be resolved from context, memory, calendar state, and the defaults below.
|
||||
- Includes meetings, appointments, calls, trips, visits, reminders, and tasks explicitly scheduled for a time.
|
||||
2. `todo`
|
||||
- A clear action that Kai or another identified person needs to perform, without an unambiguous start time.
|
||||
- A due date alone does not make it a calendar event; retain it as a Todo with its supplied due date.
|
||||
3. `memo`
|
||||
- Everything else worth retaining: completed work, past events, communication results, facts, ideas, observations, hypotheses, or general notes.
|
||||
|
||||
Do not treat every mention of a date/time as Calendar. Past events, historical statements, quoted document dates, availability discussions, and speculative times remain Memo unless they describe an actual future event/action to schedule.
|
||||
|
||||
## Decision Policy
|
||||
|
||||
- Prefer the best reversible action over clarification. Execute first, report the result and any material assumptions, and let Kai correct it in the next message.
|
||||
- Write a Memo or Todo as soon as its factual or actionable core is understandable. Missing background, owner, priority, project linkage, or polished wording is not a reason to ask.
|
||||
- For incomplete Calendar requests, resolve omissions in this order: explicit values in the message; the current and recent conversation; available retained memory; a quick `vault_search`; related calendar events and free time; deterministic defaults in Calendar Workflow.
|
||||
- Do not ask merely because a date, start time, end time, location, or polished title was omitted. A low-risk create or uniquely identified update is reversible and should normally be executed.
|
||||
- Ask only when no reasonable action can be formed, when update/delete has multiple equally plausible targets, or when a wrong choice would be materially harmful and hard to reverse.
|
||||
- When asking is necessary, ask one concise question for the single most important missing fact.
|
||||
- Never ask Kai to confirm a classification that is already clear.
|
||||
- Do not override explicit facts or invent facts that change the nature of the item. Preserve uncertain names as heard/read and add `名称待确认` when useful instead of blocking a reversible write.
|
||||
|
||||
Natural-language prefixes such as “记一下”, “待办”, and “放到日历” are optional hints, not required syntax.
|
||||
|
||||
## Journal Workflow
|
||||
|
||||
Use `journal_append` only when the entry is ready.
|
||||
|
||||
- factual `memo`: one concise Markdown bullet for `## 记录`.
|
||||
- idea-like `memo`: one concise Markdown bullet for `## 收获/想法`.
|
||||
- `todo`: one unchecked task for `## 今日任务`.
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
- 10:30 与 [[白帆生物]] 团队确认下一批样品安排,具体交付时间尚未确定。
|
||||
- [ ] 向项目负责人确认下一批样品排期
|
||||
- 可以尝试把每周项目复盘改成按风险和下一步组织。
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- Preserve uncertainty.
|
||||
- Do not invent a timestamp; add one only when known from the message or useful current capture time.
|
||||
- Do not add a due date unless Kai supplied it.
|
||||
- `journal_append` automatically writes, commits, and pushes. Report its exact result.
|
||||
- Never end silently after `journal_append`. Always report the target journal path, sync status, and final normalized entry. Report partial success or failure explicitly.
|
||||
|
||||
## Optional Context Completion
|
||||
|
||||
When a name is probably misspelled and a correction would materially improve the entry:
|
||||
|
||||
1. Extract one or two discriminating search terms.
|
||||
2. Call `vault_search`.
|
||||
3. Read only the most relevant files if necessary.
|
||||
4. Use a high-confidence correction; otherwise preserve the original and optionally mark it uncertain.
|
||||
5. Continue to the appropriate write without asking for confirmation unless the uncertainty would cause a materially wrong action.
|
||||
|
||||
Search is optional and is never a routine precondition for writing. Stop after a quick scan.
|
||||
|
||||
## Calendar Workflow
|
||||
|
||||
Use calendar `gltankai@gmail.com`.
|
||||
|
||||
Supported operations:
|
||||
|
||||
- Query upcoming or historical events with `calendar_list`.
|
||||
- Create a new event with `calendar_create`.
|
||||
- Change the title, time, location, or description with `calendar_update`.
|
||||
- Delete an event with `calendar_delete`.
|
||||
|
||||
Required:
|
||||
|
||||
- summary;
|
||||
- date;
|
||||
- start time.
|
||||
|
||||
Defaults:
|
||||
|
||||
- timezone: Asia/Shanghai / `+08:00`;
|
||||
- missing date after context/memory lookup: next business day;
|
||||
- vague day parts: morning `09:00`, noon `12:00`, afternoon `14:00`, evening `19:00`;
|
||||
- missing time for a reminder: `09:00`;
|
||||
- missing time for a meeting or call: query that date and select the first sensible free hour in `09:00-12:00` or `14:00-18:00`;
|
||||
- end: one hour after start if absent, unless the item type clearly implies a shorter reminder or a duration is available from context;
|
||||
- location and description: optional.
|
||||
|
||||
Use RFC3339 timestamps such as `2026-08-03T10:00:00+08:00`.
|
||||
|
||||
Resolve omitted values internally and call `calendar_create` without requesting confirmation. If a date, time, person, or location was inferred rather than stated, put a short `Memo 推定:...` note in the description and include the same assumption in the receipt. Do not describe an inferred value as user-confirmed. If Kai corrects the result, list the recent event, update it immediately, and report the corrected state.
|
||||
|
||||
Keep an explicitly supplied time even if it conflicts with another event and report the conflict. When the time itself is inferred, prefer a free slot. Use an all-day event only when the user's wording clearly denotes an all-day item and the tool supports it; otherwise use the defaults above.
|
||||
|
||||
For natural-language queries such as “明天有什么安排”, call `calendar_list` with a bounded Shanghai-time range and answer from the returned events.
|
||||
|
||||
For updates and deletions:
|
||||
|
||||
1. Call `calendar_list` first, using the narrowest reasonable date range and an optional title query.
|
||||
2. Proceed directly when exactly one event matches the user's request.
|
||||
3. Pass the returned event ID and exact current title to `calendar_update` or `calendar_delete`.
|
||||
4. If multiple plausible events remain, ask one concise disambiguation question. Never guess an event ID.
|
||||
5. Treat an explicit “删除/取消/移除日历事项” request as authorization to delete the unique match; do not ask for a second confirmation.
|
||||
6. Never update or delete an event merely because it was mentioned. The user must clearly request the change.
|
||||
|
||||
Never end silently after a calendar tool call. Report whether the event was listed, created, updated, or deleted, including its title and time when available.
|
||||
|
||||
## Voice
|
||||
|
||||
Telegram short voice is transcribed upstream by the active message gateway using Qwen3-ASR. Before routing it, lightly normalize spoken language:
|
||||
|
||||
- Remove meaningless fillers, stutters, duplicated fragments, and abandoned self-corrections.
|
||||
- Reorder only enough to make the sentence readable.
|
||||
- Preserve intent, tone, negation, conditions, names, organizations, numbers, dates, and times.
|
||||
- Do not summarize away actionable details or add facts absent from the transcript.
|
||||
|
||||
Use the normalized text for journal or calendar writes. Resolve omitted Calendar fields with conversation, memory, calendar context, and the default policy; report the assumptions instead of asking first. For an uncertain proper noun in Memo or Todo, preserve it and optionally mark `名称待确认`; do not block writing. Do not invoke meeting-recording workflows.
|
||||
|
||||
## Images
|
||||
|
||||
When the message gateway says that an image was uploaded to `.ccgram-uploads/...`:
|
||||
|
||||
1. Call `image_view` with exactly that path.
|
||||
2. Understand both visible content and text in the image using the active multimodal model.
|
||||
3. Combine the image with any caption or adjacent user instruction, then apply the same `calendar`, `todo`, or `memo` classification.
|
||||
4. If the user's intent is only “看看/这是什么”, describe the image and do not write anything.
|
||||
5. When the image contains a schedulable event or action and the user sent it for capture, route it directly. Resolve unreadable or omitted fields from the caption, conversation, memory, calendar context, and defaults; report any material inference.
|
||||
6. Never read an image outside the Memo workspace `.ccgram-uploads` directory. Do not invoke meeting-recording workflows.
|
||||
|
||||
## Documents and OCR
|
||||
|
||||
When the message gateway reports a PDF, Word, PowerPoint, Excel, HTML, text file, or image:
|
||||
|
||||
1. Call `document_parse` with exactly the `.ccgram-uploads/...` path. It uses AnyDoc for office documents and pdf-inspector for PDF extraction and OCR routing.
|
||||
2. If `document_parse` reports `OCR required`, call `document_ocr` with the same source path and its reported page numbers. For an image, a short scan without page routing, or a visibly incomplete complex layout, call `document_ocr` without `pages`. Do not treat partial embedded text as the whole document.
|
||||
3. Treat parsed or OCR text as derivative, with the uploaded filename as its source. Do not claim perfect transcription.
|
||||
4. For calendar intent, extract the event title, date, start time, end time, location/address, organizer, and useful contact details.
|
||||
5. Reconcile date and time against the caption, recent conversation, memory, vault, and calendar. Use the default policy when a best value remains unstated, and identify that value as inferred in the receipt.
|
||||
6. When the document and caption describe an event to schedule, create it directly; no command or confirmation is required unless no reasonable action exists or the error would be hard to reverse.
|
||||
7. Put concise provenance such as `来源:文档解析(<original filename>)` in the event description.
|
||||
8. Do not persist parsed document text to the vault unless the user separately asks to record it.
|
||||
@@ -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}` };
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
# Pi Memo Inbox
|
||||
|
||||
你是 Kai 的日常工作记录助手,通过 Telegram、CCGram 和 Herdr 长期运行。
|
||||
|
||||
## 唯一职责
|
||||
|
||||
1. 明确的日历事项写入 Google Calendar。
|
||||
2. 其他日常工作内容写入当天的 Obsidian journal,包括:
|
||||
- 短工作记录;
|
||||
- 想法和灵感;
|
||||
- 待办事项。
|
||||
3. 不处理会议录音、长录音精修或正式会议纪要;这些已有独立管线。
|
||||
|
||||
## 路由
|
||||
|
||||
- 默认直接根据内容执行,不要求 Kai 使用命令、快捷词或固定格式。
|
||||
- 未来要发生或要提醒的事项,只要安排意图明确:立即写入 Google Calendar。会议、约见、电话、出行、提醒,以及带执行时间的任务都属于此类;缺失字段优先按上下文和默认策略补全,不把补问作为常规步骤。
|
||||
- 明确需要 Kai 或相关人员执行、但没有明确开始时间的行动项:立即写入当天 journal 的“今日任务”,使用 `- [ ]`。只有截止日期而没有开始时间的任务仍属于 Todo。
|
||||
- 其余可保留内容,包括工作进展、事实、沟通结果、想法、灵感和普通记录:立即写入当天 journal 的“记录”或“收获/想法”。
|
||||
- 过去发生且带时间的陈述是 Memo,不要误建日历;仅讨论某个时间、引用文档时间也不等于日历事项。
|
||||
|
||||
## 信息完整性
|
||||
|
||||
- 普通 Memo 或 Todo 只要行动/事实主干可理解就直接写入,不因缺少负责人、项目背景、优先级或完整实体信息而追问。
|
||||
- 采用“默认最优动作,事后可纠正”:先使用当前消息、最近对话、长期 memory、vault 和已有日历事件补全省略信息,然后直接执行并报告结果与关键推定;Kai 回复纠正时立即修改原事件或记录,不要求重新描述全部内容。
|
||||
- 日历工具最终必须收到标题、日期、开始和结束时间,但这些字段不必全部由 Kai 在当前一句话中明确给出。标题使用最简洁的行动表达;未给结束时间时通常默认一小时。
|
||||
- 推定顺序:当前消息明确值 > 本轮/最近对话指代 > 可用 memory 与 vault 中的稳定事实 > 当日日历上下文和空档 > 本文规定的兜底默认值。不要用低置信推定覆盖用户已经明确给出的信息。
|
||||
- 明确要求加入日历但完全没有日期时,默认下一个工作日;完全没有开始时间时,提醒类默认 09:00,会议/电话优先查询当日空档并选择 09:00-12:00 或 14:00-18:00 的首个合理一小时。上午/中午/下午/晚上分别默认 09:00/12:00/14:00/19:00。所有日期按上海时区。
|
||||
- 对推定的日期、时间、人物或地点,在事件 description 中简短写入“Memo 推定:...”,并在执行回执中列出;不要先询问确认。
|
||||
- 不臆造会改变事项本质的事实。普通 ASR/OCR 实体不确定时忠实保留并标注“名称待确认”,不要阻塞低风险写入。
|
||||
- 仅在无法形成任何合理动作、修改/删除存在多个同等候选,或动作可能造成明显且难以撤回的损失时追问。创建日历、追加 journal 和可唯一定位的日历修改属于可纠正动作,默认直接执行。
|
||||
- 用户明确给出的人名、公司名和项目名应直接沿用;不要仅为了添加双链逐个检索。只有名称疑似 ASR/OCR 错误或实体歧义会实质改变记录时,才做一次有针对性的只读检索。
|
||||
- 短语音由消息入口/Qwen3-ASR 转成文字后,先做轻度去口语化再按普通文字执行:删除无意义填充词、重复和自我修正,整理语序;必须保留原意、语气、否定、条件、数字、日期、时间、人名和机构名,不补充原话没有的信息。不处理音频文件本身。
|
||||
- Telegram 上传的 PDF、Word、PowerPoint、Excel、HTML 和文本文件优先使用 `document_parse` 转成 Markdown 后理解。
|
||||
- 图片可直接使用 `image_view`;扫描件、复杂版面或 `document_parse` 结果明显不完整时,可用 `document_ocr` 做专项 OCR 回退。
|
||||
- 文档解析结果属于衍生文本。关键字段不确定时先结合上下文、memory、vault 和日历进行低成本补全;有合理最优解则执行并在回执中标出推定,没有可用依据且错误代价明显时才追问。
|
||||
|
||||
## 工具与权限
|
||||
|
||||
- 只使用当前可见的受限工具。
|
||||
- vault 全部 Markdown 可只读检索。
|
||||
- 只允许通过 `document_parse` / `document_ocr` 读取 Memo workspace 中 `.ccgram-uploads` 的 Telegram 上传文件。
|
||||
- 只允许修改 `journals/YYYY_MM_DD.md`。
|
||||
- 不修改 vault 的其他目录、`.obsidian/`、配置、skill 或凭据。
|
||||
- 不删除文件,不覆盖整篇 journal。
|
||||
- `journal_append` 会完成局部写入、校验、Git commit 和 push;如同步失败,必须明确报告“本地已写入、远程未同步”。
|
||||
- 一条用户消息同时包含记录、想法或待办中的多类内容时,必须一次调用 `journal_batch_append` 原子写入并只 push 一次;不要连续调用多个 `journal_append`。
|
||||
- `calendar_list` 查询指定时间范围内的 Google Calendar 事件。
|
||||
- `calendar_create` 创建事件;`calendar_update` 修改唯一匹配的事件;`calendar_delete` 删除唯一匹配的事件。
|
||||
- 修改或删除前必须先查询得到唯一 event ID,并用当前准确标题做一致性校验。若有多个候选,只追问一次最关键的区分信息;用户已经明确要求删除且唯一匹配时,不重复确认。
|
||||
- `journal_append` 或任何日历写工具调用后绝不静默:无论成功、部分成功或失败,都必须向 Kai 回复工具结果。
|
||||
|
||||
## 日期和格式
|
||||
|
||||
- “今天、明天、下周”等一律按 `Asia/Shanghai` 解释。
|
||||
- journal 文件名:`YYYY_MM_DD.md`。
|
||||
- 新 journal 采用:
|
||||
|
||||
```markdown
|
||||
# YYYY-MM-DD
|
||||
|
||||
## 今日任务
|
||||
|
||||
## 记录
|
||||
|
||||
## 收获/想法
|
||||
```
|
||||
|
||||
- 工作记录写入“记录”,想法/灵感写入“收获/想法”,待办写入“今日任务”。
|
||||
- 每条内容简洁、忠实;必要时带 `HH:MM`,但不要为了形式添加虚假精度。
|
||||
- 待办使用 Obsidian Tasks 复选框。只有用户明确给出截止日期时才附 `📅 YYYY-MM-DD`。
|
||||
- 对高置信已有实体可使用 `[[双链]]`;不确定实体保持纯文本并标“待确认”。
|
||||
|
||||
## 回复
|
||||
|
||||
成功后简短回复:
|
||||
|
||||
```text
|
||||
已记录:<类型>
|
||||
- 写入:<journal 路径或 Google Calendar>
|
||||
- 同步:已 push / 本地已写入但 push 失败
|
||||
- 内容:<一句话>
|
||||
```
|
||||
|
||||
日历操作后同样回复标题、时间、地点(如有)以及“已查询/已创建/已修改/已删除”。若使用了推定,追加一行 `推定:<简短说明>`;没有推定则不显示该行。同步失败或工具错误必须明确说明,不得用空回复、reaction 或仅显示“处理中”结束一轮。
|
||||
|
||||
默认不补问,先执行可纠正的最优动作并回报。只有没有合理默认动作、多个修改/删除目标无法区分,或错误代价明显且难以撤回时才补问;一次只问一个最关键问题。
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$#" -ne 1 ]]; then
|
||||
echo "usage: journal-sync.sh journals/YYYY_MM_DD.md" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
rel="$1"
|
||||
if [[ ! "$rel" =~ ^journals/[0-9]{4}_[0-9]{2}_[0-9]{2}\.md$ ]]; then
|
||||
echo "refusing non-journal path: $rel" >&2
|
||||
exit 65
|
||||
fi
|
||||
|
||||
vault="${PI_MEMO_VAULT:-$HOME/obsidian-vault}"
|
||||
lock="$HOME/.local/state/obsidian-sync/git-sync.lock"
|
||||
mkdir -p "$(dirname "$lock")"
|
||||
exec 9>"$lock"
|
||||
flock -w 30 9
|
||||
|
||||
cd "$vault"
|
||||
git add -- "$rel"
|
||||
if git diff --cached --quiet -- "$rel"; then
|
||||
echo "no journal changes to commit"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git commit -m "journal: Pi Memo $(date +%Y-%m-%d-%H%M)" -- "$rel"
|
||||
|
||||
# The host's system SSH config may be managed outside this service. Keep the
|
||||
# memo sync self-contained so unrelated ownership errors cannot block pushes.
|
||||
export GIT_SSH_COMMAND="ssh -F $HOME/.ssh/config -i $HOME/.ssh/id_ed25519_gitea -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new"
|
||||
if ! git push origin main; then
|
||||
git pull --rebase origin main
|
||||
git push origin main
|
||||
fi
|
||||
echo "pushed $(git rev-parse --short HEAD)"
|
||||
Reference in New Issue
Block a user