ibill-ai-ledger 1.0.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/plugin.js ADDED
@@ -0,0 +1,814 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * AI Ledger — Anna Executa 插件(Node.js,零第三方依赖)
4
+ *
5
+ * 协议:JSON-RPC 2.0 over stdio(换行分隔)
6
+ * 宿主 → 插件:initialize / describe / health / invoke
7
+ * 插件 → 宿主(反向 RPC):storage/*(APS KV)、sampling/createMessage(LLM)
8
+ *
9
+ * 数据(APS scope=app self,key 前缀 ai-ledger/):
10
+ * ai-ledger/meta -> { schema: 1 }
11
+ * ai-ledger/budgets -> { monthly_total?: number(元), categories?: {catId: 元} }
12
+ * ai-ledger/month/<YYYY-MM> -> { entries: [Entry] }
13
+ * Entry = { id, type:'expense'|'income', amountCents, categoryId, note,
14
+ * date:'YYYY-MM-DD', source:'manual'|'voice'|'chat', createdAt }
15
+ */
16
+ "use strict";
17
+
18
+ const readline = require("node:readline");
19
+ const { randomUUID } = require("node:crypto");
20
+ const path = require("node:path");
21
+ const { pathToFileURL } = require("node:url");
22
+
23
+ // 共享领域核心(ESM):npm 包内自带 lib/ledger-core.mjs(发布后自包含);
24
+ // 仓库本地开发时回退到仓库根 shared/ledger-core.js
25
+ let Core = null;
26
+ async function loadCore() {
27
+ if (Core) return Core;
28
+ const candidates = [
29
+ path.join(__dirname, "lib", "ledger-core.mjs"),
30
+ path.join(__dirname, "..", "..", "shared", "ledger-core.js"),
31
+ ];
32
+ for (const p of candidates) {
33
+ try { Core = await import(pathToFileURL(p).href); return Core; } catch { /* next */ }
34
+ }
35
+ throw new Error("cannot load ledger-core.js");
36
+ }
37
+
38
+ const PLUGIN_VERSION = "1.0.0";
39
+ const STORE_PREFIX = "ai-ledger";
40
+ const K = {
41
+ meta: `${STORE_PREFIX}/meta`,
42
+ budgets: `${STORE_PREFIX}/budgets`,
43
+ month: (m) => `${STORE_PREFIX}/month/${m}`,
44
+ monthPrefix: `${STORE_PREFIX}/month/`,
45
+ year: (y) => `${STORE_PREFIX}/year/${y}`,
46
+ yearPrefix: `${STORE_PREFIX}/year/`,
47
+ };
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // JSON-RPC 基础
51
+ // ---------------------------------------------------------------------------
52
+ function send(obj) {
53
+ process.stdout.write(JSON.stringify(obj) + "\n");
54
+ }
55
+ function reply(id, result) {
56
+ send({ jsonrpc: "2.0", id, result });
57
+ }
58
+ function replyError(id, code, message, data) {
59
+ send({ jsonrpc: "2.0", id, error: { code, message, ...(data ? { data } : {}) } });
60
+ }
61
+
62
+ /** 反向 RPC(插件 → 宿主),等待宿主在 stdin 上回传 result/error */
63
+ const pending = new Map();
64
+ function reverse(method, params, timeoutMs = 30000) {
65
+ return new Promise((resolve, reject) => {
66
+ const id = randomUUID();
67
+ const timer = setTimeout(() => {
68
+ pending.delete(id);
69
+ reject(Object.assign(new Error(`reverse RPC timeout: ${method}`), { code: -32005 }));
70
+ }, timeoutMs);
71
+ pending.set(id, { resolve, reject, timer });
72
+ send({ jsonrpc: "2.0", id, method, params });
73
+ });
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // APS 存储封装(scope=app self,按应用-用户隔离;host_capabilities: aps.kv)
78
+ // ---------------------------------------------------------------------------
79
+ async function kvGet(key) {
80
+ const r = await reverse("storage/get", { key }, 15000);
81
+ // 宿主 storage/get 只回 {value}(legacy/APS 均无 exists 字段),
82
+ // value 为 null/undefined 即视为不存在。
83
+ const exists = !!(r && r.value !== null && r.value !== undefined);
84
+ return { value: exists ? r.value : null, exists, etag: r?.etag ?? null };
85
+ }
86
+ async function kvSetRaw(key, value, ifMatch) {
87
+ const params = { key, value };
88
+ if (ifMatch) params.if_match = ifMatch;
89
+ const r = await reverse("storage/set", params, 15000);
90
+ return { etag: r.etag ?? null };
91
+ }
92
+ async function kvList(prefix) {
93
+ const r = await reverse("storage/list", { prefix, limit: 100 }, 15000);
94
+ return r.items || [];
95
+ }
96
+ async function kvDelete(key) {
97
+ await reverse("storage/delete", { key }, 15000);
98
+ return { deleted: true };
99
+ }
100
+
101
+ async function readBudgets() {
102
+ const r = await kvGet(K.budgets);
103
+ return r.exists && r.value ? r.value : {};
104
+ }
105
+ async function writeBudgets(budgets, etag) {
106
+ try {
107
+ return await kvSetRaw(K.budgets, budgets, etag);
108
+ } catch (e) {
109
+ if (e && (e.code === -32023 || /precondition/i.test(String(e.message || "")))) {
110
+ const cur = await kvGet(K.budgets);
111
+ const merged = Object.assign({}, cur.value || {}, budgets);
112
+ return await kvSetRaw(K.budgets, merged, cur.etag);
113
+ }
114
+ throw e;
115
+ }
116
+ }
117
+
118
+ async function readMonth(m) {
119
+ const r = await kvGet(K.month(m));
120
+ if (!r.exists || !r.value || !Array.isArray(r.value.entries)) {
121
+ return { etag: r.etag, entries: [] };
122
+ }
123
+ return { etag: r.etag, entries: r.value.entries };
124
+ }
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // 归档:压缩/解压 entry + 年度读写
128
+ // ---------------------------------------------------------------------------
129
+ // 全字段 entry → 紧凑归档格式(省 ~60% 空间)
130
+ function compactEntry(e) {
131
+ return {
132
+ d: e.date,
133
+ t: e.type,
134
+ a: e.amountCents,
135
+ c: e.categoryId,
136
+ ...(e.note ? { n: e.note } : {}),
137
+ };
138
+ }
139
+ // 紧凑归档 → 全字段 entry(解档时还原)
140
+ function expandEntry(c, fallbackId) {
141
+ return {
142
+ id: fallbackId,
143
+ type: c.t || "expense",
144
+ amountCents: c.a || 0,
145
+ categoryId: c.c || "other_exp",
146
+ note: c.n || null,
147
+ date: c.d,
148
+ source: "archived",
149
+ createdAt: null,
150
+ };
151
+ }
152
+
153
+ async function readYear(y) {
154
+ const r = await kvGet(K.year(y));
155
+ if (!r.exists || !r.value) return { etag: null, archive: null };
156
+ return { etag: r.etag, archive: r.value };
157
+ }
158
+ async function writeYear(y, archive, etag) {
159
+ return kvSetRaw(K.year(y), archive, etag);
160
+ }
161
+
162
+ // 检查某月是否已归档(属于某年的归档)
163
+ async function findArchiveForMonth(month) {
164
+ const year = month.slice(0, 4);
165
+ const { archive } = await readYear(year);
166
+ if (archive && archive.months && archive.months[month]) {
167
+ return { archive, year, compactEntries: archive.months[month] };
168
+ }
169
+ return null;
170
+ }
171
+ /** 读-改-写月分片,冲突时重读并重放 mutation 一次 */
172
+ async function mutateMonth(m, mutate) {
173
+ let cur = await readMonth(m);
174
+ for (let attempt = 0; attempt < 2; attempt++) {
175
+ const next = mutate(cur.entries.map((e) => ({ ...e })));
176
+ try {
177
+ await kvSetRaw(K.month(m), { entries: next }, cur.etag);
178
+ return next;
179
+ } catch (e) {
180
+ if (attempt === 0 && (e.code === -32023 || /precondition/i.test(String(e.message || "")))) {
181
+ cur = await readMonth(m); // 重新拉取,再重放
182
+ continue;
183
+ }
184
+ throw e;
185
+ }
186
+ }
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // LLM 采样(sampling/createMessage)——不可用时返回 null,由调用方降级
191
+ // ---------------------------------------------------------------------------
192
+ const PARSE_SYSTEM_PROMPT = `你是中文记账解析助手。把用户口语化的账单描述抽取为结构化 JSON。
193
+ 规则:
194
+ - 金额单位:人民币元,输出数字(如 238.5)。
195
+ - 日期:依据系统提示中的“今天”日期换算相对日期(昨天/前天/上周X),输出 YYYY-MM-DD。
196
+ - type:支出=expense,收入=income。
197
+ - category 取下列 id 之一(也可给中文名):
198
+ 支出:food餐饮 transport交通 shopping购物 housing居家 entertainment娱乐
199
+ health医疗 education教育 communication通讯 travel旅行 gifts人情 other_exp其他
200
+ 收入:salary工资 freelance兼职 investment理财 redpacket_in红包 refund退款 other_inc其他
201
+ - 一句话可能包含多笔(“午餐35打车20”输出两条)。
202
+ - note 为去掉金额/日期后的消费内容简述(≤20字),没有可留空。
203
+ - confidence:0~1 的置信度。
204
+ 只输出 JSON,不要解释。`;
205
+
206
+ async function llmParse(text, today) {
207
+ const yesterday = Core.dateToStr(new Date(new Date(today).getTime() - 86400000));
208
+ const userPrompt = `今天是 ${today}(昨天 ${yesterday})。\n记账描述:${text}\n` +
209
+ `输出 JSON:{"records":[{"type":"expense|income","amount":数字,"category":"id或中文名","date":"YYYY-MM-DD","note":"","confidence":0.9}]}`;
210
+ const resp = await reverse("sampling/createMessage", {
211
+ messages: [{ role: "user", content: { type: "text", text: userPrompt } }],
212
+ maxTokens: 800,
213
+ systemPrompt: PARSE_SYSTEM_PROMPT,
214
+ temperature: 0.1,
215
+ includeContext: "none",
216
+ responseFormat: { type: "json_object" },
217
+ onUnsupported: "json_object",
218
+ metadata: { executa_invoke_id: currentInvokeId() },
219
+ }, 25000);
220
+ const raw = resp?.content?.text;
221
+ if (!raw) throw new Error("sampling returned empty content");
222
+ const json = JSON.parse(extractJson(raw));
223
+ const records = Array.isArray(json.records) ? json.records : [json];
224
+ const drafts = [];
225
+ for (const r of records) {
226
+ const amountCents = Core.yuanToCents(r.amount);
227
+ if (amountCents <= 0) continue;
228
+ const type = r.type === "income" ? "income" : "expense";
229
+ drafts.push({
230
+ type,
231
+ amountCents,
232
+ categoryId: Core.normalizeCategory(r.category, type),
233
+ date: isValidDate(r.date) ? r.date : today,
234
+ note: r.note ? String(r.note).slice(0, 40) : null,
235
+ confidence: typeof r.confidence === "number" ? r.confidence : 0.85,
236
+ });
237
+ }
238
+ return drafts;
239
+ }
240
+ function extractJson(s) {
241
+ const start = s.indexOf("{");
242
+ const end = s.lastIndexOf("}");
243
+ return start >= 0 && end > start ? s.slice(start, end + 1) : s;
244
+ }
245
+ function isValidDate(s) {
246
+ return typeof s === "string" && /^\d{4}-\d{2}-\d{2}$/.test(s);
247
+ }
248
+ function currentInvokeId() {
249
+ return global.__invokeId || null;
250
+ }
251
+
252
+ // ---------------------------------------------------------------------------
253
+ // 工具方法
254
+ // ---------------------------------------------------------------------------
255
+ function toolError(code, message, details) {
256
+ return { success: false, error: { code, message, ...(details ? { details } : {}) } };
257
+ }
258
+
259
+ async function toolAddEntry(args) {
260
+ const type = args.type === "income" ? "income" : "expense";
261
+ const amountCents = Core.yuanToCents(args.amount);
262
+ if (amountCents <= 0) return toolError("invalid_amount", "金额必须为大于 0 的数字");
263
+ const today = Core.todayStr();
264
+ const date = isValidDate(args.date) ? args.date
265
+ : (typeof args.date_hint === "string" ? Core.resolveDate(args.date_hint, new Date()) : today);
266
+ const categoryId = Core.normalizeCategory(args.category || args.categoryId, type);
267
+ const entry = {
268
+ id: randomUUID(),
269
+ type,
270
+ amountCents,
271
+ categoryId,
272
+ note: args.note ? String(args.note).slice(0, 60) : null,
273
+ date,
274
+ source: ["manual", "voice", "chat"].includes(args.source) ? args.source : "chat",
275
+ createdAt: new Date().toISOString(),
276
+ };
277
+ const month = Core.monthKeyOf(date);
278
+ await mutateMonth(month, (entries) => entries.concat(entry));
279
+ const summary = await buildSummary(month);
280
+ return { success: true, data: { entry, summary } };
281
+ }
282
+
283
+ function extractMonthKeys(items, prefix) {
284
+ // items 可能是:
285
+ // 1. string[] (Anna APS 真实返回)
286
+ // 2. [key,value][] (Map entries 形式)
287
+ // 3. [{key, etag, ...}] (harness mock 形式)
288
+ return items
289
+ .map((k) => {
290
+ if (typeof k === "string") return k;
291
+ if (k && typeof k === "object" && typeof k.key === "string") return k.key; // 形式 3
292
+ if (Array.isArray(k) && typeof k[0] === "string") return k[0]; // 形式 2
293
+ return null;
294
+ })
295
+ .filter((k) => typeof k === "string" && k.startsWith(prefix))
296
+ .map((k) => k.slice(prefix.length));
297
+ }
298
+
299
+ async function toolDeleteEntry(args) {
300
+ if (!args.id) return toolError("invalid_arg", "缺少 id");
301
+ let scanMonths;
302
+ if (args.month) {
303
+ scanMonths = [args.month];
304
+ } else {
305
+ const allItems = await kvList(K.monthPrefix);
306
+ scanMonths = extractMonthKeys(allItems, K.monthPrefix);
307
+ const now = new Date();
308
+ for (let i = 0; i < 6; i++) {
309
+ const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
310
+ const m = `${d.getFullYear()}-${Core.pad2(d.getMonth() + 1)}`;
311
+ if (!scanMonths.includes(m)) scanMonths.push(m);
312
+ }
313
+ }
314
+ let removed = null, removedMonth = null;
315
+ for (const m of scanMonths) {
316
+ const found = await findAndRemoveFromMonth(m, args.id);
317
+ if (found) { removed = found.entry; removedMonth = m; break; }
318
+ }
319
+ if (!removed) return toolError("not_found", "未找到该条记录");
320
+ return { success: true, data: { deleted: removed, month: removedMonth } };
321
+ }
322
+
323
+ async function findAndRemoveFromMonth(month, id) {
324
+ let found = null;
325
+ await mutateMonth(month, (entries) => {
326
+ const e = entries.find((x) => x.id === id);
327
+ if (e) { found = e; return entries.filter((x) => x.id !== id); }
328
+ return entries;
329
+ });
330
+ return found ? { entry: found } : null;
331
+ }
332
+
333
+ async function toolUpdateEntry(args) {
334
+ if (!args.id) return toolError("invalid_arg", "缺少 id");
335
+ // 1. 找原记录:扫所有已知月份 key
336
+ const allItems = await kvList(K.monthPrefix);
337
+ const scanMonths = extractMonthKeys(allItems, K.monthPrefix);
338
+ // 兜底:近 6 个月
339
+ const now = new Date();
340
+ for (let i = 0; i < 6; i++) {
341
+ const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
342
+ const m = `${d.getFullYear()}-${Core.pad2(d.getMonth() + 1)}`;
343
+ if (!scanMonths.includes(m)) scanMonths.push(m);
344
+ }
345
+ let oldEntry = null, oldMonth = null;
346
+ for (const m of scanMonths) {
347
+ const { entries } = await readMonth(m);
348
+ const found = entries.find((e) => e.id === args.id);
349
+ if (found) { oldEntry = found; oldMonth = m; break; }
350
+ }
351
+ if (!oldEntry) return toolError("not_found", "未找到该条记录");
352
+
353
+ // 2. 合并新值
354
+ const patched = { ...oldEntry };
355
+ if (args.type) patched.type = args.type;
356
+ if (typeof args.amountCents === "number") patched.amountCents = args.amountCents;
357
+ if (args.amount != null) {
358
+ const c = Math.round(Number(args.amount) * 100);
359
+ if (c > 0) patched.amountCents = c;
360
+ }
361
+ if (args.categoryId) patched.categoryId = args.categoryId;
362
+ if (args.category) {
363
+ const m = /^(expense|income):(.+)$/.exec(String(args.category));
364
+ patched.categoryId = m ? m[2] : String(args.category);
365
+ }
366
+ if (args.date) patched.date = String(args.date);
367
+ if ("note" in args) patched.note = args.note ? String(args.note).slice(0, 60) : null;
368
+
369
+ const newMonth = Core.monthKeyOf(patched.date);
370
+
371
+ // 3. 同月份 — 原地替换;跨月份 — 删旧月 + 加新月
372
+ if (oldMonth === newMonth) {
373
+ await mutateMonth(newMonth, (entries) =>
374
+ entries.map((e) => (e.id === patched.id ? patched : e)));
375
+ } else {
376
+ await mutateMonth(oldMonth, (entries) => entries.filter((e) => e.id !== patched.id));
377
+ await mutateMonth(newMonth, (entries) => entries.concat(patched));
378
+ }
379
+
380
+ // 4. summary 返回(按新月份)
381
+ const summary = await buildSummary(newMonth);
382
+ return { success: true, data: { entry: patched, summary, movedMonth: oldMonth !== newMonth } };
383
+ }
384
+
385
+ async function toolListEntries(args) {
386
+ const month = args.month || Core.todayStr().slice(0, 7);
387
+ const { entries } = await readMonth(month);
388
+ const sorted = [...entries].sort((a, b) =>
389
+ (b.date + b.createdAt).localeCompare(a.date + a.createdAt));
390
+ return { success: true, data: { month, entries: sorted } };
391
+ }
392
+
393
+ async function toolGetState() {
394
+ const budgets = await readBudgets();
395
+ // 扫所有已知月份 key(跨月数据也能加载)
396
+ const allMonthItems = await kvList(K.monthPrefix);
397
+ const months = extractMonthKeys(allMonthItems, K.monthPrefix);
398
+ // 兜底:近 6 个月
399
+ const now = new Date();
400
+ for (let i = 0; i < 6; i++) {
401
+ const d = new Date(now.getFullYear(), now.getMonth() - i, 1);
402
+ const m = `${d.getFullYear()}-${Core.pad2(d.getMonth() + 1)}`;
403
+ if (!months.includes(m)) months.push(m);
404
+ }
405
+ const all = [];
406
+ for (const m of months) {
407
+ const { entries } = await readMonth(m);
408
+ all.push(...entries);
409
+ }
410
+
411
+ // 加载已归档的往年数据(从 year key 还原为 entries)
412
+ const allYearItems = await kvList(K.yearPrefix);
413
+ const years = extractMonthKeys(allYearItems, K.yearPrefix);
414
+ const archivedYears = [];
415
+ for (const y of years) {
416
+ const { archive } = await readYear(y);
417
+ if (archive && archive.months) {
418
+ archivedYears.push({ year: y, entryCount: archive.summary?.entryCount || 0 });
419
+ for (const [monthKey, compactEntries] of Object.entries(archive.months)) {
420
+ if (!Array.isArray(compactEntries)) continue;
421
+ const expanded = compactEntries.map((c, i) =>
422
+ expandEntry(c, `${monthKey}-arc-${i}`));
423
+ all.push(...expanded);
424
+ }
425
+ }
426
+ }
427
+
428
+ const entries = all.sort((a, b) =>
429
+ (b.date + (b.createdAt || "")).localeCompare(a.date + (a.createdAt || "")));
430
+ return { success: true, data: { budgets, months, entries, archivedYears } };
431
+ }
432
+
433
+ async function toolSetBudget(args) {
434
+ const cur = await kvGet(K.budgets);
435
+ const curVal = cur.exists && cur.value ? cur.value : {};
436
+ const next = { ...curVal };
437
+ if (args.monthly_total !== undefined && args.monthly_total !== null) {
438
+ const c = Core.yuanToCents(args.monthly_total);
439
+ if (c <= 0) return toolError("invalid_amount", "月预算必须为大于 0 的数字");
440
+ next.monthly_total = args.monthly_total; // 预算以「元」存储
441
+ }
442
+ if (args.categories && typeof args.categories === "object") {
443
+ next.categories = { ...(curVal.categories || {}) };
444
+ for (const [cat, val] of Object.entries(args.categories)) {
445
+ const c = Core.yuanToCents(val);
446
+ if (c > 0) next.categories[cat] = val;
447
+ else delete next.categories[cat];
448
+ }
449
+ }
450
+ // 携带读到的 etag 做乐观锁;writeBudgets 冲突时会重读合并重试
451
+ await writeBudgets(next, cur.etag);
452
+ return { success: true, data: { budgets: next } };
453
+ }
454
+
455
+ async function buildSummary(month) {
456
+ const m = month || Core.todayStr().slice(0, 7);
457
+ // 先尝试月度 key
458
+ let { entries } = await readMonth(m);
459
+ // 如果月度 key 为空,尝试从年度归档读
460
+ if (entries.length === 0) {
461
+ const found = await findArchiveForMonth(m);
462
+ if (found) {
463
+ entries = found.compactEntries.map((c, i) =>
464
+ expandEntry(c, `${m}-arc-${i}`));
465
+ }
466
+ }
467
+ const budgets = await readBudgets();
468
+ const s = Core.summarize(entries, budgets, m);
469
+ return {
470
+ month: s.month,
471
+ expenseCents: s.expenseCents,
472
+ incomeCents: s.incomeCents,
473
+ balanceCents: s.balanceCents,
474
+ byCategory: s.byCategory,
475
+ daily: s.daily,
476
+ budget: s.budget,
477
+ };
478
+ }
479
+
480
+ async function toolGetSummary(args) {
481
+ const summary = await buildSummary(args.month);
482
+ return { success: true, data: { summary } };
483
+ }
484
+
485
+ async function toolParseText(args, ctx) {
486
+ const text = String(args.text || "").trim();
487
+ if (!text) return toolError("invalid_arg", "缺少 text");
488
+ const today = Core.todayStr();
489
+ let drafts = null;
490
+ let engine = "rules";
491
+ if (ctx?.sampling_token) {
492
+ try {
493
+ drafts = await llmParse(text, today);
494
+ engine = "llm";
495
+ } catch (e) {
496
+ process.stderr.write(`[ai-ledger] sampling failed, fallback to rules: ${e.message || e}\n`);
497
+ drafts = null;
498
+ }
499
+ }
500
+ if (!drafts || drafts.length === 0) {
501
+ const r = Core.parseRules(text, new Date());
502
+ drafts = r.drafts;
503
+ engine = r.engine; // "rules"
504
+ }
505
+ if (drafts.length === 0) return { success: false, error: { code: "no_amount", message: "没能从这句话里听出金额,换个说法试试?" } };
506
+ return { success: true, data: { drafts, engine } };
507
+ }
508
+
509
+ // ---------------------------------------------------------------------------
510
+ // archive_year — 将指定年份的12个月度记录归档为1个年度 key
511
+ // ---------------------------------------------------------------------------
512
+ async function toolArchiveYear(args) {
513
+ const year = String(args.year || (new Date().getFullYear() - 1));
514
+ const yearNum = parseInt(year, 10);
515
+ if (!yearNum || yearNum < 2000 || yearNum > 2100) {
516
+ return toolError("invalid_arg", `无效的年份: ${year}`);
517
+ }
518
+
519
+ // 1. 检查是否已归档
520
+ const existing = await readYear(year);
521
+ if (existing.archive) {
522
+ return toolError("already_archived", `${year} 年已归档`);
523
+ }
524
+
525
+ // 2. 读 12 个月的数据
526
+ const monthsData = {};
527
+ let totalEntries = 0;
528
+ let expenseCents = 0, incomeCents = 0;
529
+ const byCategory = {};
530
+
531
+ for (let m = 1; m <= 12; m++) {
532
+ const monthKey = `${year}-${Core.pad2(m)}`;
533
+ const { entries } = await readMonth(monthKey);
534
+ if (entries.length === 0) continue;
535
+ monthsData[monthKey] = entries.map(compactEntry);
536
+ totalEntries += entries.length;
537
+ for (const e of entries) {
538
+ if (e.type === "expense") {
539
+ expenseCents += e.amountCents;
540
+ byCategory[e.categoryId] = (byCategory[e.categoryId] || 0) + e.amountCents;
541
+ } else {
542
+ incomeCents += e.amountCents;
543
+ }
544
+ }
545
+ }
546
+
547
+ if (totalEntries === 0) {
548
+ return toolError("no_data", `${year} 年没有可归档的记录`);
549
+ }
550
+
551
+ // 3. 构建归档对象
552
+ const archive = {
553
+ year: yearNum,
554
+ archivedAt: new Date().toISOString(),
555
+ months: monthsData,
556
+ summary: {
557
+ expenseCents,
558
+ incomeCents,
559
+ balanceCents: incomeCents - expenseCents,
560
+ byCategory,
561
+ entryCount: totalEntries,
562
+ },
563
+ };
564
+
565
+ // 4. 写年度 key
566
+ await writeYear(year, archive, null);
567
+
568
+ // 5. 删除 12 个月度 key(释放 KV 空间)
569
+ let deletedKeys = [];
570
+ for (let m = 1; m <= 12; m++) {
571
+ const monthKey = `${year}-${Core.pad2(m)}`;
572
+ if (monthsData[monthKey]) {
573
+ try {
574
+ await kvDelete(K.month(monthKey));
575
+ deletedKeys.push(monthKey);
576
+ } catch (e) {
577
+ // 删除失败不阻断,记录即可
578
+ process.stderr.write(`[ai-ledger] archive: 删除 ${monthKey} 失败: ${e.message}\n`);
579
+ }
580
+ }
581
+ }
582
+
583
+ return {
584
+ success: true,
585
+ data: {
586
+ year: yearNum,
587
+ archivedAt: archive.archivedAt,
588
+ entryCount: totalEntries,
589
+ deletedMonthKeys: deletedKeys,
590
+ summary: archive.summary,
591
+ },
592
+ };
593
+ }
594
+
595
+ // ---------------------------------------------------------------------------
596
+ // unarchive_year — 解档:把年度 key 还原回月度 key(需要编辑往年记录时用)
597
+ // ---------------------------------------------------------------------------
598
+ async function toolUnarchiveYear(args) {
599
+ const year = String(args.year);
600
+ if (!year) return toolError("invalid_arg", "缺少 year 参数");
601
+
602
+ const { archive, etag } = await readYear(year);
603
+ if (!archive) {
604
+ return toolError("not_found", `${year} 年未找到归档`);
605
+ }
606
+
607
+ // 还原每个月的 entries
608
+ let restoredMonths = 0, restoredEntries = 0;
609
+ for (const [monthKey, compactEntries] of Object.entries(archive.months || {})) {
610
+ if (!Array.isArray(compactEntries) || compactEntries.length === 0) continue;
611
+ // 还原全字段 entries
612
+ const entries = compactEntries.map((c, i) =>
613
+ expandEntry(c, `${monthKey}-${i}-${Math.random().toString(36).slice(2, 8)}`));
614
+ await mutateMonth(monthKey, (existing) => existing.concat(entries));
615
+ restoredMonths++;
616
+ restoredEntries += entries.length;
617
+ }
618
+
619
+ // 删除年度 key
620
+ try {
621
+ await kvDelete(K.year(year));
622
+ } catch (e) {
623
+ process.stderr.write(`[ai-ledger] unarchive: 删除 year key 失败: ${e.message}\n`);
624
+ }
625
+
626
+ return {
627
+ success: true,
628
+ data: {
629
+ year: parseInt(year, 10),
630
+ restoredMonths,
631
+ restoredEntries,
632
+ },
633
+ };
634
+ }
635
+
636
+ const TOOLS = {
637
+ add_entry: toolAddEntry,
638
+ update_entry: toolUpdateEntry,
639
+ delete_entry: toolDeleteEntry,
640
+ list_entries: toolListEntries,
641
+ get_state: toolGetState,
642
+ set_budget: toolSetBudget,
643
+ get_summary: toolGetSummary,
644
+ parse_text: toolParseText,
645
+ archive_year: toolArchiveYear,
646
+ unarchive_year: toolUnarchiveYear,
647
+ };
648
+
649
+ // ---------------------------------------------------------------------------
650
+ // 插件 manifest(describe)
651
+ // ---------------------------------------------------------------------------
652
+ const MANIFEST = {
653
+ display_name: "AI Ledger 记账本",
654
+ version: PLUGIN_VERSION,
655
+ description: "AI-native personal bookkeeping: record expenses/income via chat or voice, "
656
+ + "monthly stats, category budgets, and durable per-user cloud storage. "
657
+ + "AI 记账工具:聊天/语音记账、月度统计、分类预算,数据云端保存。",
658
+ author: "ai-ledger",
659
+ tags: ["productivity", "finance", "ledger", "anna-app", "ai-native"],
660
+ host_capabilities: ["aps.kv", "llm.sample"],
661
+ runtime: { type: "node", min_version: "18.0.0" },
662
+ tools: [
663
+ {
664
+ name: "parse_text",
665
+ description: "把用户一句自然语言(中文口语,如“昨天和朋友吃火锅花了238块”“工资发了12000”)"
666
+ + "解析为一条或多条结构化记账草稿(金额/收支类型/分类/日期/备注)。"
667
+ + "返回 drafts 供用户确认,不直接入账。Parse a free-text bookkeeping sentence into "
668
+ + "structured draft entry(s); does NOT save anything.",
669
+ parameters: [
670
+ { name: "text", type: "string", description: "用户的记账描述原文 / the raw sentence", required: true },
671
+ ],
672
+ },
673
+ {
674
+ name: "add_entry",
675
+ description: "Save one ledger entry. Amount is in USD dollars. Use after the user confirms a parsed draft or states all details.",
676
+ parameters: [
677
+ { name: "type", type: "string", description: "expense (default) or income", required: false, default: "expense" },
678
+ { name: "amount", type: "number", description: "Amount in USD, e.g. 35 or 86.5", required: true },
679
+ { name: "category", type: "string", description: "Category id or name: food/transport/shopping/housing/entertainment/health/education/communication/travel/gifts/other_exp (expense); salary/freelance/investment/redpacket_in/refund/other_inc (income)", required: false },
680
+ { name: "note", type: "string", description: "Optional note, ≤ 60 chars", required: false },
681
+ { name: "date", type: "string", description: "Date YYYY-MM-DD, default today", required: false },
682
+ { name: "source", type: "string", description: "manual/voice/chat, default chat", required: false },
683
+ ],
684
+ },
685
+ { name: "list_entries",
686
+ description: "列出指定月份(默认本月)的全部账目,按日期倒序。List entries of a month (YYYY-MM, default current).",
687
+ parameters: [
688
+ { name: "month", type: "string", description: "月份 YYYY-MM,如 2026-09", required: false },
689
+ ] },
690
+ { name: "delete_entry",
691
+ description: "按 id 删除一条账目。Delete one entry by id.",
692
+ parameters: [
693
+ { name: "id", type: "string", description: "条目 id", required: true },
694
+ { name: "month", type: "string", description: "条目所在月份 YYYY-MM(可选,不传则在近4个月查找)", required: false },
695
+ ] },
696
+ { name: "update_entry",
697
+ description: "修改一条已有账目(金额、类型、分类、日期、备注)。可以跨月份移动,插件自动处理旧月/新月的搬迁。Update an existing entry's fields (amount/type/category/date/note). Cross-month moves are handled automatically.",
698
+ parameters: [
699
+ { name: "id", type: "string", description: "要修改的条目 id", required: true },
700
+ { name: "type", type: "string", description: "expense / income(可选)", required: false },
701
+ { name: "amount", type: "number", description: "新金额(美元),如 15 或 8.5(可选)", required: false },
702
+ { name: "categoryId", type: "string", description: "新分类 id(可选)", required: false },
703
+ { name: "date", type: "string", description: "新日期 YYYY-MM-DD(可选,跨月会自动搬迁)", required: false },
704
+ { name: "note", type: "string", description: "新备注,最多 60 字符,传空字符串即清空(可选)", required: false },
705
+ ] },
706
+ { name: "get_summary",
707
+ description: "获取某月汇总:支出/收入/结余总额、分类占比、每日支出、预算进度与是否超支。"
708
+ + "Monthly summary for answering questions like “这个月花了多少”.",
709
+ parameters: [
710
+ { name: "month", type: "string", description: "月份 YYYY-MM,默认本月", required: false },
711
+ ] },
712
+ { name: "set_budget",
713
+ description: "设置月度预算:月总额和/或分类预算(单位元)。Set monthly total budget and/or per-category budgets.",
714
+ parameters: [
715
+ { name: "monthly_total", type: "number", description: "每月总预算(元),如 3000", required: false },
716
+ { name: "categories", type: "object", description: "分类预算映射,如 {\"food\":1500,\"transport\":300}", required: false },
717
+ ] },
718
+ { name: "get_state",
719
+ description: "获取预算与最近3个月的全部账目(App 窗口打开时水合用)。Hydrate the app window state.",
720
+ parameters: [] },
721
+ { name: "archive_year",
722
+ description: "将指定年份的12个月度记录归档为1个年度key(紧凑格式),删除原月度key以节省KV存储容量。归档后记录变为只读,可通过 unarchive_year 解档。"
723
+ + "Archive a year's monthly records into a single compact yearly key, deleting the monthly keys to save KV storage. "
724
+ + "Archived records become read-only; use unarchive_year to restore.",
725
+ parameters: [
726
+ { name: "year", type: "string", description: "要归档的年份 YYYY,如 2025。不传则默认去年。Year to archive, default last year.", required: false },
727
+ ] },
728
+ { name: "unarchive_year",
729
+ description: "将年度归档还原回月度记录,使其可再次编辑。Unarchive a year: restore monthly records from the yearly archive key, making them editable again.",
730
+ parameters: [
731
+ { name: "year", type: "string", description: "要解档的年份 YYYY,如 2025。Year to unarchive.", required: true },
732
+ ] },
733
+ ],
734
+ };
735
+
736
+ // ---------------------------------------------------------------------------
737
+ // stdio 主循环
738
+ // ---------------------------------------------------------------------------
739
+ async function main() {
740
+ await loadCore();
741
+ const rl = readline.createInterface({ input: process.stdin });
742
+
743
+ rl.on("line", (line) => {
744
+ const trimmed = line.trim();
745
+ if (!trimmed) return;
746
+ let msg;
747
+ try { msg = JSON.parse(trimmed); } catch { return; }
748
+
749
+ // 反向 RPC 的响应(宿主回传):无 method 字段
750
+ if (!msg.method) {
751
+ const p = pending.get(msg.id);
752
+ if (!p) return;
753
+ pending.delete(msg.id);
754
+ clearTimeout(p.timer);
755
+ if (msg.error) {
756
+ const err = new Error(msg.error.message || "reverse RPC error");
757
+ err.code = msg.error.code;
758
+ err.data = msg.error.data;
759
+ p.reject(err);
760
+ } else {
761
+ p.resolve(msg.result);
762
+ }
763
+ return;
764
+ }
765
+
766
+ // 宿主 → 插件 的请求
767
+ (async () => {
768
+ try {
769
+ switch (msg.method) {
770
+ case "initialize":
771
+ reply(msg.id, {
772
+ protocolVersion: "2.0",
773
+ serverInfo: { name: "ai-ledger", version: PLUGIN_VERSION },
774
+ capabilities: { sampling: {}, storage: {} },
775
+ });
776
+ break;
777
+ case "describe":
778
+ reply(msg.id, MANIFEST);
779
+ break;
780
+ case "health":
781
+ reply(msg.id, { status: "ready", message: "", details: {} });
782
+ break;
783
+ case "invoke": {
784
+ const params = msg.params || {};
785
+ const ctx = params.context || {};
786
+ global.__invokeId = ctx.invoke_id || null;
787
+ const handler = TOOLS[params.tool];
788
+ if (!handler) {
789
+ replyError(msg.id, -32601, `unknown tool: ${params.tool}`);
790
+ } else {
791
+ const result = await handler(params.arguments || {}, ctx);
792
+ reply(msg.id, result);
793
+ }
794
+ break;
795
+ }
796
+ default:
797
+ replyError(msg.id, -32601, `unknown method: ${msg.method}`);
798
+ }
799
+ } catch (e) {
800
+ process.stderr.write(`[ai-ledger] handler error: ${e.stack || e}\n`);
801
+ replyError(msg.id, e.code ?? -32603, e.message || String(e));
802
+ } finally {
803
+ global.__invokeId = null;
804
+ }
805
+ })();
806
+ });
807
+
808
+ rl.on("close", () => process.exit(0));
809
+ }
810
+
811
+ main().catch((e) => {
812
+ process.stderr.write(`[ai-ledger] fatal: ${e.stack || e}\n`);
813
+ process.exit(1);
814
+ });