@cnife/pi-obsidian-diary 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,85 @@
1
+ # @cnife/pi-obsidian-diary
2
+
3
+ 将当前 pi 会话总结为 Obsidian 日记草稿,并通过 `sendUserMessage` 发给主 Agent,由主 Agent 在用户确认后写入日记文件。
4
+
5
+ ## 功能
6
+
7
+ - 注册 `/diary` slash 命令,按需触发(命令驱动,非事件驱动)。
8
+ - 读取当前会话 transcript(`ctx.sessionManager.getBranch()`,不截断全量)。
9
+ - 扫描 Obsidian vault 下的未完成待办(近 14 天)、近期日记(近 10 天前 3 篇各前 30 行)、今日日记全文。
10
+ - 按旧中文日期格式计算今日日记路径:`{base}/{diary_dir}/{year}/{month:02d}/{year}年{month}月{day}日{星期}.md`。
11
+ - 调用 `completeSimple()` 做语义总结,返回结构化 JSON `{variant, summary, instructions}`。
12
+ - 通过 `pi.sendUserMessage(json, {deliverAs:"followUp"})` 发到当前会话,主 Agent 展示草稿给用户确认后写入。
13
+
14
+ **架构不变量**:扩展层零写入日记文件,唯一写操作是配置缺失时写模板配置文件。日记写入完全委托主 Agent 在用户确认后执行。
15
+
16
+ ## 安装
17
+
18
+ ```bash
19
+ pi install npm:@cnife/pi-obsidian-diary
20
+ ```
21
+
22
+ ## 本地测试
23
+
24
+ ```bash
25
+ pi --no-extensions --no-skills -e packages/obsidian-diary/extensions/index.ts --no-session
26
+ ```
27
+
28
+ ## 配置
29
+
30
+ 配置文件路径为 `<agent-dir>/cnife-obsidian-diary.json`。`<agent-dir>` 由 `PI_CODING_AGENT_DIR` 环境变量决定,默认是 `~/.pi/agent`。
31
+
32
+ 首次调用 `/diary` 时若配置缺失会自动写入模板配置。**配置加载为硬失败**:缺失、损坏或字段类型非法时直接报错退出,绝不使用猜测的配置写入日记。
33
+
34
+ ```json
35
+ {
36
+ "model": null,
37
+ "vaults": {
38
+ "work": {
39
+ "base": "/path/to/obsidian/work-vault",
40
+ "diary_dir": "工作日志",
41
+ "template": "日志模板.md",
42
+ "exclude_meta": ["AGENTS.md", "任务.md", "日志模板.md"]
43
+ },
44
+ "personal": {
45
+ "base": "/path/to/obsidian/personal-vault",
46
+ "diary_dir": "个人日记",
47
+ "template": "日记模板.md",
48
+ "exclude_meta": ["AGENTS.md"]
49
+ }
50
+ }
51
+ }
52
+ ```
53
+
54
+ | 字段 | 说明 |
55
+ |------|------|
56
+ | `model` | `"provider/modelId"` 指定模型,`null` 用当前会话 `ctx.model`。 |
57
+ | `vaults.{work,personal}.base` | Obsidian vault 根目录(支持 `~` 展开)。 |
58
+ | `vaults.{work,personal}.diary_dir` | 日记子目录名。 |
59
+ | `vaults.{work,personal}.template` | 模板文件名(主 Agent 据此从模板创建新日记)。 |
60
+ | `vaults.{work,personal}.exclude_meta` | 扫描时排除的文件名列表(含所有 `*模板.md`)。 |
61
+
62
+ ## 用法
63
+
64
+ ```text
65
+ /diary # 自动判断 work/personal 变体,扫描两个 vault
66
+ /diary --work # 固定为 work 变体,只扫描 work vault
67
+ /diary --personal # 固定为 personal 变体,只扫描 personal vault
68
+ ```
69
+
70
+ ## 行为说明
71
+
72
+ - `/diary` 触发后,扩展走 fail-fast 线性链:配置加载 → 参数解析 → 读取 transcript → 扫描待办/近期/今日日记 → 路径越界校验 → 模型选择与认证 → LLM 语义总结 → 发送结构化消息。
73
+ - 每个失败路径都在发送消息前 return,确保无错误中间状态。
74
+ - `sendUserMessage` 返回 `void` 不可 await,发完即返回,消息进队列由主 Agent 处理。
75
+ - 主 Agent 收到的消息包含强指令:**禁止未经用户确认直接写入,必须先展示草稿等待确认**。
76
+
77
+ ## 故障排查
78
+
79
+ | 现象 | 原因 | 处理 |
80
+ |------|------|------|
81
+ | 提示"配置缺失或损坏" | 配置文件不存在或 JSON/字段非法 | 按提示路径编辑配置后重试 `/diary`。 |
82
+ | 提示"日记路径越界" | `base` 配置异常,计算出的日记路径不在 vault 内 | 检查 `base` 是否正确,是否含 `../` 逃逸。 |
83
+ | 提示"认证失败" | API key 未配置或模型无效 | 检查 `model` 配置或 pi 的 API key 设置。 |
84
+ | 提示"Diary gen failed" | LLM 调用出错或被中止 | 查看 `stopReason`,超长会话可能触发 `length`。 |
85
+ | 提示"当前会话无记录可总结" | 会话 transcript 为空 | 先进行一些对话再调用 `/diary`。 |
@@ -0,0 +1,635 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readdirSync,
5
+ readFileSync,
6
+ statSync,
7
+ writeFileSync,
8
+ } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { dirname, join, relative, resolve, sep } from "node:path";
11
+ import type { Model, TextContent } from "@earendil-works/pi-ai";
12
+ import { completeSimple } from "@earendil-works/pi-ai";
13
+ import {
14
+ type ExtensionAPI,
15
+ type ExtensionContext,
16
+ getAgentDir,
17
+ } from "@earendil-works/pi-coding-agent";
18
+
19
+ // ──── Config ────────────────────────────────────────────────────
20
+
21
+ interface VaultConfig {
22
+ base: string;
23
+ diary_dir: string;
24
+ template: string;
25
+ exclude_meta: string[];
26
+ }
27
+
28
+ interface DiaryConfig {
29
+ /** "provider/modelId",null 用当前 ctx.model */
30
+ model: string | null;
31
+ vaults: { work: VaultConfig; personal: VaultConfig };
32
+ }
33
+
34
+ const DEFAULT_CONFIG: DiaryConfig = {
35
+ model: null,
36
+ vaults: {
37
+ work: {
38
+ base: "",
39
+ diary_dir: "工作日志",
40
+ template: "日志模板.md",
41
+ exclude_meta: ["AGENTS.md", "任务.md", "日志模板.md"],
42
+ },
43
+ personal: {
44
+ base: "",
45
+ diary_dir: "个人日记",
46
+ template: "日记模板.md",
47
+ exclude_meta: ["AGENTS.md"],
48
+ },
49
+ },
50
+ };
51
+
52
+ const CONFIG_PATH = join(getAgentDir(), "cnife-obsidian-diary.json");
53
+ const WEEKDAYS = [
54
+ "星期一",
55
+ "星期二",
56
+ "星期三",
57
+ "星期四",
58
+ "星期五",
59
+ "星期六",
60
+ "星期日",
61
+ ];
62
+
63
+ function saveDefaultConfig(path: string): void {
64
+ mkdirSync(dirname(path), { recursive: true });
65
+ writeFileSync(path, `${JSON.stringify(DEFAULT_CONFIG, null, 2)}\n`, "utf-8");
66
+ }
67
+
68
+ /** 硬失败配置加载:任一级失败返回 null(日记写入绝不用猜测配置)。 */
69
+ function loadConfig(): DiaryConfig | null {
70
+ // Level 1: 文件不存在 → 写模板配置(唯一写操作,只写配置文件)后报错
71
+ if (!existsSync(CONFIG_PATH)) {
72
+ try {
73
+ saveDefaultConfig(CONFIG_PATH);
74
+ } catch {
75
+ return null;
76
+ }
77
+ return null;
78
+ }
79
+
80
+ // Level 2: 读取 + JSON 解析(硬失败,不回退默认值)
81
+ let raw: string;
82
+ try {
83
+ raw = readFileSync(CONFIG_PATH, "utf-8");
84
+ } catch {
85
+ return null;
86
+ }
87
+
88
+ let parsed: unknown;
89
+ try {
90
+ parsed = JSON.parse(raw);
91
+ } catch {
92
+ return null;
93
+ }
94
+
95
+ // Level 3: 类型校验(硬失败)
96
+ if (typeof parsed !== "object" || parsed === null) return null;
97
+ const obj = parsed as Record<string, unknown>;
98
+
99
+ const model = obj.model;
100
+ if (model !== null && typeof model !== "string") return null;
101
+
102
+ if (typeof obj.vaults !== "object" || obj.vaults === null) return null;
103
+ const vaultsObj = obj.vaults as Record<string, unknown>;
104
+
105
+ const work = parseVaultConfig(vaultsObj.work);
106
+ const personal = parseVaultConfig(vaultsObj.personal);
107
+ if (!work || !personal) return null;
108
+
109
+ return { model: model as string | null, vaults: { work, personal } };
110
+ }
111
+
112
+ function parseVaultConfig(v: unknown): VaultConfig | null {
113
+ if (typeof v !== "object" || v === null) return null;
114
+ const o = v as Record<string, unknown>;
115
+ if (typeof o.base !== "string") return null;
116
+ if (typeof o.diary_dir !== "string") return null;
117
+ if (typeof o.template !== "string") return null;
118
+ if (
119
+ !Array.isArray(o.exclude_meta) ||
120
+ o.exclude_meta.some((x) => typeof x !== "string")
121
+ ) {
122
+ return null;
123
+ }
124
+ return {
125
+ base: o.base,
126
+ diary_dir: o.diary_dir,
127
+ template: o.template,
128
+ exclude_meta: o.exclude_meta as string[],
129
+ };
130
+ }
131
+
132
+ // ──── Path Helpers ──────────────────────────────────────────────
133
+
134
+ /** 展开 ~ 为 home 目录(防御性,配置 base 可能含 ~)。 */
135
+ function expandHome(p: string): string {
136
+ if (p === "~") return homedir();
137
+ if (p.startsWith("~/") || p.startsWith("~\\")) {
138
+ return join(homedir(), p.slice(2));
139
+ }
140
+ return p;
141
+ }
142
+
143
+ /** 越界校验:防 ../ 逃逸。加 sep 防前缀误配(/vault 匹配 /vault-escape)。 */
144
+ function isPathWithin(filePath: string, baseDir: string): boolean {
145
+ const resolvedBase = resolve(baseDir);
146
+ const resolvedFile = resolve(filePath);
147
+ return (
148
+ resolvedFile === resolvedBase || resolvedFile.startsWith(resolvedBase + sep)
149
+ );
150
+ }
151
+
152
+ interface DiaryPaths {
153
+ diaryPath: string;
154
+ }
155
+
156
+ /** {base}/{diary_dir}/{year}/{month:02d}/{year}年{month}月{day}日{星期}.md */
157
+ function computeDiaryPaths(
158
+ vault: VaultConfig,
159
+ date: Date = new Date(),
160
+ ): DiaryPaths {
161
+ const base = expandHome(vault.base);
162
+ const year = date.getFullYear();
163
+ const month = date.getMonth() + 1;
164
+ const day = date.getDate();
165
+ // ponytail: getDay 0=周日→索引6,(getDay+6)%7 映射到 WEEKDAYS[0]=周一
166
+ const weekday = WEEKDAYS[(date.getDay() + 6) % 7];
167
+ const monthDir = join(
168
+ base,
169
+ vault.diary_dir,
170
+ String(year),
171
+ String(month).padStart(2, "0"),
172
+ );
173
+ return {
174
+ diaryPath: join(monthDir, `${year}年${month}月${day}日${weekday}.md`),
175
+ };
176
+ }
177
+
178
+ // ──── Scanning (复刻旧 _scan_todos / _scan_recent) ──────────────
179
+
180
+ const TODO_PATTERN = /^\s*-\s*\[ \]\s+(.+)$/;
181
+
182
+ interface Todo {
183
+ file: string;
184
+ line: number;
185
+ content: string;
186
+ }
187
+
188
+ interface RecentDiary {
189
+ file: string;
190
+ mtime: Date;
191
+ preview: string;
192
+ }
193
+
194
+ /** 递归遍历 .md 文件。 */
195
+ function walkMd(dir: string, cb: (filePath: string) => void): void {
196
+ let entries: ReturnType<typeof readdirSync>;
197
+ try {
198
+ entries = readdirSync(dir, { withFileTypes: true });
199
+ } catch {
200
+ return;
201
+ }
202
+ for (const entry of entries) {
203
+ const fullPath = join(dir, entry.name);
204
+ if (entry.isDirectory()) {
205
+ walkMd(fullPath, cb);
206
+ } else if (entry.isFile() && entry.name.endsWith(".md")) {
207
+ cb(fullPath);
208
+ }
209
+ }
210
+ }
211
+
212
+ function isExcluded(fname: string, exclude: Set<string>): boolean {
213
+ return fname.endsWith("模板.md") || exclude.has(fname);
214
+ }
215
+
216
+ function scanTodos(vault: VaultConfig, days = 14): Todo[] {
217
+ const base = expandHome(vault.base);
218
+ const diaryBase = join(base, vault.diary_dir);
219
+ const exclude = new Set(vault.exclude_meta);
220
+ const cutoff = Date.now() - days * 86_400_000;
221
+ const results: Todo[] = [];
222
+
223
+ walkMd(diaryBase, (filePath) => {
224
+ const fname = filePath.split(sep).pop() ?? "";
225
+ if (isExcluded(fname, exclude)) return;
226
+ const stat = statSync(filePath);
227
+ if (stat.mtimeMs < cutoff) return;
228
+ const rel = relative(base, filePath);
229
+ const content = readFileSync(filePath, "utf-8");
230
+ const lines = content.split("\n");
231
+ for (let i = 0; i < lines.length; i++) {
232
+ const m = TODO_PATTERN.exec(lines[i]);
233
+ if (m && m[1] === " ") {
234
+ results.push({ file: rel, line: i + 1, content: m[2].trim() });
235
+ }
236
+ }
237
+ });
238
+
239
+ return results;
240
+ }
241
+
242
+ function scanRecent(
243
+ vault: VaultConfig,
244
+ days = 10,
245
+ excludePath?: string,
246
+ ): RecentDiary[] {
247
+ const base = expandHome(vault.base);
248
+ const diaryBase = join(base, vault.diary_dir);
249
+ const exclude = new Set(vault.exclude_meta);
250
+ const cutoff = Date.now() - days * 86_400_000;
251
+ const found: { mtime: Date; path: string }[] = [];
252
+
253
+ walkMd(diaryBase, (filePath) => {
254
+ const fname = filePath.split(sep).pop() ?? "";
255
+ if (isExcluded(fname, exclude)) return;
256
+ const stat = statSync(filePath);
257
+ if (stat.mtimeMs < cutoff) return;
258
+ found.push({ mtime: stat.mtime, path: filePath });
259
+ });
260
+
261
+ found.sort((a, b) => b.mtime.getTime() - a.mtime.getTime());
262
+ const top = (
263
+ excludePath ? found.filter((f) => f.path !== excludePath) : found
264
+ ).slice(0, 3);
265
+
266
+ return top.map((f) => {
267
+ const content = readFileSync(f.path, "utf-8");
268
+ const allLines = content.split("\n");
269
+ const previewLines = allLines.slice(0, 30);
270
+ const preview =
271
+ allLines.length > 30
272
+ ? `${previewLines.join("\n")}\n... (截断)`
273
+ : previewLines.join("\n");
274
+ return { file: relative(base, f.path), mtime: f.mtime, preview };
275
+ });
276
+ }
277
+
278
+ function safeReadFile(path: string): string {
279
+ try {
280
+ return readFileSync(path, "utf-8").trim();
281
+ } catch {
282
+ return "";
283
+ }
284
+ }
285
+
286
+ // ──── Transcript (复用 auto-naming 模式) ────────────────────────
287
+
288
+ function messageContentToText(
289
+ content: string | Array<{ type: string; text?: string }>,
290
+ ): string {
291
+ if (typeof content === "string") return content;
292
+ if (Array.isArray(content)) {
293
+ return content
294
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
295
+ .map((c) => c.text)
296
+ .join(" ");
297
+ }
298
+ return "";
299
+ }
300
+
301
+ function buildTranscript(ctx: ExtensionContext): string | null {
302
+ const branch = ctx.sessionManager.getBranch();
303
+ const parts: string[] = [];
304
+ for (const entry of branch) {
305
+ if (entry.type === "message" && entry.message) {
306
+ if (entry.message.role === "user" || entry.message.role === "assistant") {
307
+ const text = messageContentToText(entry.message.content);
308
+ if (text) {
309
+ parts.push(`${entry.message.role}: ${text}`);
310
+ }
311
+ }
312
+ }
313
+ }
314
+ if (parts.length === 0) return null;
315
+ return parts.join("\n\n");
316
+ }
317
+
318
+ // ──── Model Resolution (复用 auto-naming 模式) ──────────────────
319
+
320
+ function parseModelRef(
321
+ ref: string,
322
+ ): { provider: string; id: string } | undefined {
323
+ const parts = ref.split("/");
324
+ if (parts.length !== 2 || !parts[0] || !parts[1]) return undefined;
325
+ return { provider: parts[0], id: parts[1] };
326
+ }
327
+
328
+ async function resolveModel(
329
+ ctx: ExtensionContext,
330
+ config: DiaryConfig,
331
+ ): Promise<Model<any> | null> {
332
+ if (config.model) {
333
+ const parsed = parseModelRef(config.model);
334
+ if (!parsed) {
335
+ ctx.ui.notify(
336
+ `Invalid model "${config.model}". Use "provider/modelId"`,
337
+ "warning",
338
+ );
339
+ return null;
340
+ }
341
+ const model = ctx.modelRegistry.find(parsed.provider, parsed.id);
342
+ if (!model) {
343
+ ctx.ui.notify(`Model "${config.model}" not found`, "warning");
344
+ return null;
345
+ }
346
+ return model;
347
+ }
348
+ if (!ctx.model) {
349
+ ctx.ui.notify("No model available", "warning");
350
+ return null;
351
+ }
352
+ return ctx.model;
353
+ }
354
+
355
+ // ──── LLM Summary ───────────────────────────────────────────────
356
+
357
+ interface DiarySummary {
358
+ variant: "work" | "personal";
359
+ summary: string;
360
+ instructions: string;
361
+ }
362
+
363
+ interface VaultContext {
364
+ name: "work" | "personal";
365
+ paths: DiaryPaths;
366
+ todos: Todo[];
367
+ recent: RecentDiary[];
368
+ today: string;
369
+ }
370
+
371
+ function formatMtime(d: Date): string {
372
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
373
+ const dd = String(d.getDate()).padStart(2, "0");
374
+ const hh = String(d.getHours()).padStart(2, "0");
375
+ const mi = String(d.getMinutes()).padStart(2, "0");
376
+ return `${mm}-${dd} ${hh}:${mi}`;
377
+ }
378
+
379
+ function buildContextSection(c: VaultContext): string {
380
+ const todoLines =
381
+ c.todos.length > 0
382
+ ? c.todos.map((t) => ` - ${t.file}:${t.line} | ${t.content}`).join("\n")
383
+ : " (无)";
384
+ const recentLines =
385
+ c.recent.length > 0
386
+ ? c.recent
387
+ .map(
388
+ (r) =>
389
+ ` ## ${r.file} (${formatMtime(r.mtime)})\n${r.preview
390
+ .split("\n")
391
+ .map((l) => ` ${l}`)
392
+ .join("\n")}`,
393
+ )
394
+ .join("\n\n")
395
+ : " (无)";
396
+ const todayContent = c.today || "(空)";
397
+ return [
398
+ `### ${c.name}`,
399
+ `- 日记路径: ${c.paths.diaryPath}`,
400
+ `- 待办 (${c.todos.length}):`,
401
+ todoLines,
402
+ `- 近期日记 (${c.recent.length}):`,
403
+ recentLines,
404
+ `- 今日日记已有内容:`,
405
+ todayContent,
406
+ ].join("\n");
407
+ }
408
+
409
+ /** 提取 JSON:优先围栏内,否则取首个 { 到末个 },兜底裸文本。 */
410
+ function extractJson(text: string): string {
411
+ const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/);
412
+ if (fenced) return fenced[1].trim();
413
+ const trimmed = text.trim();
414
+ const m = trimmed.match(/\{[\s\S]*\}/);
415
+ return m ? m[0] : trimmed;
416
+ }
417
+
418
+ async function generateDiarySummary(
419
+ ctx: ExtensionContext,
420
+ model: Model<any>,
421
+ apiKey: string | undefined,
422
+ headers: Record<string, string> | undefined,
423
+ transcript: string,
424
+ contexts: VaultContext[],
425
+ explicitVariant: "work" | "personal" | null,
426
+ ): Promise<DiarySummary | null> {
427
+ const variantClause = explicitVariant
428
+ ? `本次变体已固定为 "${explicitVariant}",variant 字段必须填 "${explicitVariant}"。`
429
+ : "请根据会话内容判断属于 work(工作)还是 personal(个人),填入 variant。";
430
+
431
+ const systemPrompt = `你是 Obsidian 日记总结助手。根据当前会话记录和日记上下文,生成今日日记草稿。
432
+
433
+ 要求:
434
+ 1. 总结会话中的关键事件、决策、成果,整合已有待办与近期日记的延续
435
+ 2. 日记语言为中文,风格简洁专业,使用 markdown 格式
436
+ 3. ${variantClause}
437
+ 4. summary 是日记正文
438
+ 5. instructions 是给写入执行者的操作说明(如:从模板创建新文件并写入 / 在已有内容后追加 / 覆盖更新等)
439
+
440
+ 只输出合法 JSON,不要使用 markdown 围栏,不要输出任何解释文字。格式:{"variant":"work"|"personal","summary":"...","instructions":"..."}`;
441
+
442
+ const contextSections = contexts.map(buildContextSection).join("\n\n");
443
+ const userMessage = `## 当前会话记录
444
+
445
+ ${transcript}
446
+
447
+ ## 日记上下文
448
+
449
+ ${contextSections}
450
+
451
+ 请生成今日日记草稿。`;
452
+
453
+ const response = await completeSimple(
454
+ model,
455
+ {
456
+ systemPrompt,
457
+ messages: [{ role: "user", content: userMessage, timestamp: Date.now() }],
458
+ },
459
+ { apiKey, headers, maxTokens: 2048 },
460
+ );
461
+
462
+ if (
463
+ response.stopReason === "error" ||
464
+ response.stopReason === "aborted" ||
465
+ response.stopReason === "length"
466
+ ) {
467
+ ctx.ui.notify(
468
+ `Diary gen failed: ${response.errorMessage ?? response.stopReason}`,
469
+ "warning",
470
+ );
471
+ return null;
472
+ }
473
+
474
+ const text = response.content
475
+ .filter((c): c is TextContent & { type: "text" } => c.type === "text")
476
+ .map((c) => c.text)
477
+ .join("")
478
+ .trim();
479
+
480
+ let parsed: unknown;
481
+ try {
482
+ parsed = JSON.parse(extractJson(text));
483
+ } catch {
484
+ ctx.ui.notify("Diary gen returned invalid JSON", "warning");
485
+ return null;
486
+ }
487
+
488
+ if (typeof parsed !== "object" || parsed === null) return null;
489
+ const o = parsed as Record<string, unknown>;
490
+ if (o.variant !== "work" && o.variant !== "personal") return null;
491
+ if (typeof o.summary !== "string" || !o.summary) return null;
492
+ if (typeof o.instructions !== "string") return null;
493
+
494
+ return {
495
+ variant: o.variant,
496
+ summary: o.summary,
497
+ instructions: o.instructions,
498
+ };
499
+ }
500
+
501
+ // ──── Output ────────────────────────────────────────────────────
502
+
503
+ function formatDiaryMessage(
504
+ diaryPath: string,
505
+ variant: string,
506
+ summary: string,
507
+ instructions: string,
508
+ ): string {
509
+ return [
510
+ "请完成 Obsidian 日记写入流程:",
511
+ "",
512
+ "**重要:禁止未经用户确认直接写入文件。必须先将下方日记草稿完整展示给用户,等待用户确认或修改后,再用工具写入。**",
513
+ "",
514
+ `- 日记路径: \`${diaryPath}\``,
515
+ `- 变体: ${variant}`,
516
+ "",
517
+ "## 日记草稿",
518
+ "",
519
+ summary,
520
+ "",
521
+ "## 写入指令",
522
+ "",
523
+ instructions,
524
+ ].join("\n");
525
+ }
526
+
527
+ // ──── Args ──────────────────────────────────────────────────────
528
+
529
+ function parseArgs(args: string): { variant: "work" | "personal" | null } {
530
+ const trimmed = (args ?? "").trim();
531
+ if (trimmed === "--work") return { variant: "work" };
532
+ if (trimmed === "--personal") return { variant: "personal" };
533
+ return { variant: null };
534
+ }
535
+
536
+ // ──── Entry Point ───────────────────────────────────────────────
537
+
538
+ export default function (pi: ExtensionAPI): void {
539
+ pi.registerCommand("diary", {
540
+ description: "Summarize the current session into an Obsidian diary entry",
541
+ handler: async (args, ctx) => {
542
+ // 1. 配置(硬失败)
543
+ const config = loadConfig();
544
+ if (!config) {
545
+ ctx.ui.notify(
546
+ `配置缺失或损坏,模板已写入 ${CONFIG_PATH},请编辑后重试`,
547
+ "error",
548
+ );
549
+ return;
550
+ }
551
+
552
+ // 2. 参数解析
553
+ const { variant: explicitVariant } = parseArgs(args);
554
+
555
+ // 3. 读取当前会话 transcript
556
+ const transcript = buildTranscript(ctx);
557
+ if (!transcript) {
558
+ ctx.ui.notify("当前会话无记录可总结", "warning");
559
+ return;
560
+ }
561
+
562
+ // 4. 路径计算 + 扫描(无标志双 vault,有标志单 vault)
563
+ const names: ("work" | "personal")[] = explicitVariant
564
+ ? [explicitVariant]
565
+ : ["work", "personal"];
566
+
567
+ const contexts: VaultContext[] = names.map((name) => {
568
+ const vault = config.vaults[name];
569
+ const paths = computeDiaryPaths(vault);
570
+ return {
571
+ name,
572
+ paths,
573
+ todos: scanTodos(vault),
574
+ recent: scanRecent(vault, 10, paths.diaryPath),
575
+ today: safeReadFile(paths.diaryPath),
576
+ };
577
+ });
578
+
579
+ // 5. 路径越界校验
580
+ for (const c of contexts) {
581
+ if (
582
+ !isPathWithin(
583
+ c.paths.diaryPath,
584
+ expandHome(config.vaults[c.name].base),
585
+ )
586
+ ) {
587
+ ctx.ui.notify(
588
+ `日记路径越界,请检查配置: ${c.paths.diaryPath}`,
589
+ "error",
590
+ );
591
+ return;
592
+ }
593
+ }
594
+
595
+ // 6. 模型选择 + 认证
596
+ const model = await resolveModel(ctx, config);
597
+ if (!model) return;
598
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
599
+ if (!auth.ok) {
600
+ ctx.ui.notify(`认证失败: ${auth.error}`, "warning");
601
+ return;
602
+ }
603
+
604
+ // 7. LLM 语义总结
605
+ const result = await generateDiarySummary(
606
+ ctx,
607
+ model,
608
+ auth.apiKey,
609
+ auth.headers,
610
+ transcript,
611
+ contexts,
612
+ explicitVariant,
613
+ );
614
+ if (!result) return;
615
+
616
+ // 8. 选定 diaryPath
617
+ const chosen = contexts.find((c) => c.name === result.variant);
618
+ if (!chosen) {
619
+ ctx.ui.notify(`无效变体: ${result.variant}`, "error");
620
+ return;
621
+ }
622
+
623
+ // 9. 发送到当前会话主 Agent
624
+ pi.sendUserMessage(
625
+ formatDiaryMessage(
626
+ chosen.paths.diaryPath,
627
+ result.variant,
628
+ result.summary,
629
+ result.instructions,
630
+ ),
631
+ { deliverAs: "followUp" },
632
+ );
633
+ },
634
+ });
635
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@cnife/pi-obsidian-diary",
3
+ "version": "0.1.0",
4
+ "private": false,
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "description": "Summarize the current session into an Obsidian diary entry",
9
+ "homepage": "https://github.com/CNife/pi-extensions#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/CNife/pi-extensions/issues"
12
+ },
13
+ "license": "MIT",
14
+ "author": "CNife <CNife@vip.qq.com>",
15
+ "repository": {
16
+ "type": "git",
17
+ "url": "git+https://github.com/CNife/pi-extensions.git"
18
+ },
19
+ "publishConfig": {
20
+ "access": "public",
21
+ "provenance": true
22
+ },
23
+ "pi": {
24
+ "extensions": [
25
+ "./extensions"
26
+ ]
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-coding-agent": "*"
30
+ }
31
+ }