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/executa.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "slug": "ai-ledger",
3
+ "name": "iBill · AI 记账本",
4
+ "version": "1.0.0",
5
+ "executa_type": "tool",
6
+ "description": "iBill · AI 记账本的本地执行插件:自然语言/语音记账解析、收支记录存储、月度统计与预算管理。Anna Executa stdio JSON-RPC plugin (Node.js, zero dependencies).",
7
+ "tool_id": "tool-dev-ai-ledger",
8
+ "type": "node",
9
+ "enabled": true,
10
+ "distribution": {
11
+ "type": "npm",
12
+ "package_name": "ibill-ai-ledger",
13
+ "executable_name": "ibill-ai-ledger"
14
+ }
15
+ }
@@ -0,0 +1,414 @@
1
+ /**
2
+ * ledger-core.js — 记账领域共享核心(ESM 纯函数,零依赖)
3
+ * 同时被 Node Executa 插件(动态 import)与 UI(Vite/ESM)使用。
4
+ * 金额内部一律用「分」(integer cents) 存储,避免浮点误差。
5
+ */
6
+
7
+ // ---------------------------------------------------------------------------
8
+ // 分类定义(id / 中文名 / 英文名 / emoji / 规则关键词)
9
+ // ---------------------------------------------------------------------------
10
+ const EXPENSE_CATEGORIES = [
11
+ { id: "food", zh: "餐饮", en: "Food", emoji: "🍜",
12
+ keywords: ["吃", "饭", "餐", "顿", "火锅", "烧烤", "奶茶", "咖啡", "早餐", "午餐", "晚餐", "夜宵",
13
+ "零食", "水果", "外卖", "面", "米线", "肯德基", "麦当劳", "星巴克", "饮料", "啤酒", "聚餐",
14
+ "包子", "饺子", "米饭", "便当", "团购餐", "小吃", "宵夜", "粥", "馍", "饼"] },
15
+ { id: "transport", zh: "交通", en: "Transport", emoji: "🚕",
16
+ keywords: ["打车", "滴滴", "出租", "地铁", "公交", "加油", "停车", "高铁", "火车", "机票",
17
+ "飞机", "车票", "单车", "骑行", "高速", "过路", "油费", "车费", "顺风车", "大巴", "轮渡"] },
18
+ { id: "shopping", zh: "购物", en: "Shopping", emoji: "🛍️",
19
+ keywords: ["淘宝", "京东", "拼多多", "衣服", "鞋", "包", "购物", "网购", "快递", "超市",
20
+ "化妆品", "护肤", "数码", "电子", "家电", "日用品", "商城", "奥莱", "饰品"] },
21
+ { id: "housing", zh: "居家", en: "Housing", emoji: "🏠",
22
+ keywords: ["房租", "水电", "电费", "水费", "燃气", "煤气", "物业", "网费", "家具", "家居",
23
+ "日用", "维修", "租金", "暖气", "宽带"] },
24
+ { id: "entertainment", zh: "娱乐", en: "Fun", emoji: "🎮",
25
+ keywords: ["电影", "游戏", "ktv", "唱歌", "演出", "门票", "会员", "视频", "小说", "酒吧",
26
+ "游乐", "剧本杀", "密室", "直播", "订阅", "动漫", "展览"] },
27
+ { id: "health", zh: "医疗", en: "Health", emoji: "💊",
28
+ keywords: ["医院", "药", "体检", "挂号", "看病", "牙医", "诊所", "医保", "手术", "感冒",
29
+ "输液", "检查"] },
30
+ { id: "education", zh: "教育", en: "Education", emoji: "📚",
31
+ keywords: ["书", "课程", "学费", "培训", "考试", "文具", "教育", "网课", "教材", "辅导班"] },
32
+ { id: "communication", zh: "通讯", en: "Comm", emoji: "📱",
33
+ keywords: ["话费", "流量", "充值", "手机费", "电话", "sim"] },
34
+ { id: "travel", zh: "旅行", en: "Travel", emoji: "✈️",
35
+ keywords: ["酒店", "民宿", "旅行", "旅游", "签证", "度假", "景区", "机票"] },
36
+ { id: "gifts", zh: "人情", en: "Gifts", emoji: "🎁",
37
+ keywords: ["发红包", "随礼", "份子", "礼物", "送礼", "捐款", "红包", "请客", "孝敬"] },
38
+ { id: "other_exp", zh: "其他", en: "Other", emoji: "📦", keywords: [] },
39
+ ];
40
+
41
+ const INCOME_CATEGORIES = [
42
+ { id: "salary", zh: "工资", en: "Salary", emoji: "💰",
43
+ keywords: ["工资", "薪水", "发薪", "薪资", "绩效", "奖金"] },
44
+ { id: "freelance", zh: "兼职", en: "Freelance", emoji: "💻",
45
+ keywords: ["兼职", "外快", "稿费", "接单", "劳务费"] },
46
+ { id: "investment", zh: "理财", en: "Invest", emoji: "📈",
47
+ keywords: ["理财", "利息", "基金", "股票", "分红", "收益"] },
48
+ { id: "redpacket_in", zh: "红包", en: "Red Packet", emoji: "🧧",
49
+ keywords: ["抢红包", "收到红包", "红包到账"] },
50
+ { id: "refund", zh: "退款", en: "Refund", emoji: "↩️",
51
+ keywords: ["退款", "退货", "报销", "理赔"] },
52
+ { id: "other_inc", zh: "其他收入", en: "Other", emoji: "🪙", keywords: [] },
53
+ ];
54
+
55
+ const INCOME_HINTS = ["工资", "薪水", "发薪", "薪资", "赚", "收入", "报销", "收款", "收到",
56
+ "红包", "理财", "利息", "兼职", "外快", "退款", "中奖", "分红", "到账", "入账"];
57
+ // 强支出信号(用于区分「发红包」支出 vs「收到红包」收入)
58
+ const EXPENSE_HINTS = ["花", "买", "付", "支出", "消费", "掏", "缴", "交", "扣", "花了", "用了", "发红包"];
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // 金额工具
62
+ // ---------------------------------------------------------------------------
63
+ function yuanToCents(yuan) {
64
+ const n = typeof yuan === "string" ? parseFloat(yuan) : yuan;
65
+ if (!isFinite(n) || n <= 0) return 0;
66
+ return Math.round(n * 100);
67
+ }
68
+ function centsToYuan(cents) {
69
+ return Math.round(cents) / 100;
70
+ }
71
+ function formatCents(cents) {
72
+ const yuan = centsToYuan(cents || 0);
73
+ return yuan.toLocaleString("zh-CN", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // 日期工具
78
+ // ---------------------------------------------------------------------------
79
+ function pad2(n) { return String(n).padStart(2, "0"); }
80
+ function dateToStr(d) { return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`; }
81
+ function monthKeyOf(dateStr) { return (dateStr || "").slice(0, 7); }
82
+ function shiftDays(base, delta) {
83
+ const d = new Date(base.getFullYear(), base.getMonth(), base.getDate() + delta);
84
+ return d;
85
+ }
86
+ function todayStr(now) { return dateToStr(now || new Date()); }
87
+
88
+ const CN_NUM = { 零: 0, 〇: 0, 一: 1, 二: 2, 两: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7,
89
+ 八: 8, 九: 9, 十: 10, 百: 100, 千: 1000, 万: 10000 };
90
+
91
+ /** 解析中文数字,支持 1~99999 的常见口语写法:三十五、一百二、三百五十六、两千三、一万二 */
92
+ function cnNumber(str) {
93
+ if (!str) return null;
94
+ if (!/[零〇一二两三四五六七八九十百千万]/.test(str)) return null;
95
+ // 省略位口语补全:「两千三」→两千三百、「一百二」→一百二十、「一万二」→一万二千
96
+ str = str.replace(/([百千万])([一二两三四五六七八九])$/, (m, u, d) =>
97
+ u + d + ({ 百: "十", 千: "百", 万: "千" }[u] || ""));
98
+ let total = 0, section = 0, num = 0, hasAny = false;
99
+ for (const ch of str) {
100
+ const v = CN_NUM[ch];
101
+ if (v === undefined) return null;
102
+ hasAny = true;
103
+ if (v === 10000) {
104
+ section = (section + num) * v;
105
+ total += section;
106
+ section = 0; num = 0;
107
+ } else if (v === 1000 || v === 100 || v === 10) {
108
+ if (num === 0) num = 1; // 一百 / 十
109
+ section += num * v;
110
+ num = 0;
111
+ } else {
112
+ num = v;
113
+ }
114
+ }
115
+ total += section + num;
116
+ return hasAny ? total : null;
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // 规则解析(LLM 不可用时的降级,也是独立演示模式的解析器)
121
+ // 返回 { drafts: [{type, amountCents, categoryId, date, note, confidence}], engine }
122
+ // ---------------------------------------------------------------------------
123
+ function parseRules(rawText, now) {
124
+ const nowDate = now || new Date();
125
+ const text = String(rawText || "").trim();
126
+ if (!text) return { drafts: [], engine: "rules" };
127
+
128
+ // 先按标点/连接词切分:「午餐35,打车20」「买衣服399 还有咖啡28」
129
+ const rough = text
130
+ .split(/[,,、;;。]|然后|还有|另外|接着|再/)
131
+ .map((s) => s.trim())
132
+ .filter(Boolean);
133
+ // 再按「金额 token」切分无标点连写:「午餐35打车20」「早餐15咖啡28」
134
+ const segments = rough.flatMap(splitByAmounts);
135
+
136
+ const drafts = [];
137
+ for (const seg of segments) {
138
+ const draft = parseSegment(seg, nowDate);
139
+ if (draft && draft.amountCents > 0) drafts.push(draft);
140
+ }
141
+ // 切分后一笔都没解析出金额时,整句当一笔尝试
142
+ if (drafts.length === 0) {
143
+ const whole = parseSegment(text, nowDate);
144
+ if (whole && whole.amountCents > 0) drafts.push(whole);
145
+ }
146
+ return { drafts, engine: "rules" };
147
+ }
148
+
149
+ // 在一句话里找出所有「金额 token」的位置(用于多笔连写切分)
150
+ const QUANTIFIERS = "杯个瓶份盒斤条件位人次顿套箱袋包张台本支根片";
151
+ function findAmountTokens(seg) {
152
+ const mask = [];
153
+ const addMask = (re) => {
154
+ let m;
155
+ while ((m = re.exec(seg))) mask.push([m.index, m.index + m[0].length]);
156
+ };
157
+ addMask(/\d{1,2}\s*月\s*\d{1,2}\s*[号日]/g); // X月X号
158
+ addMask(/\d{4}\s*年/g); // 年份
159
+ const inMask = (i) => mask.some(([a, b]) => i >= a && i < b);
160
+
161
+ const tokens = [];
162
+ const push = (s, e, nextChar) => {
163
+ if (inMask(s)) return;
164
+ if (tokens.some((t) => s < t.e && e > t.s)) return; // 重叠去重
165
+ if (nextChar && QUANTIFIERS.includes(nextChar)) return; // 「2杯/3个」是数量不是金额
166
+ tokens.push({ s, e });
167
+ };
168
+ let m;
169
+ const reCompound = /(\d+)\s*块\s*(\d)(?:\s*[毛角分])?/g; // 35块5 / 35块5毛
170
+ while ((m = reCompound.exec(seg))) {
171
+ push(m.index, m.index + m[0].length, seg[m.index + m[0].length]);
172
+ }
173
+ const reJiao = /(\d+(?:\.\d+)?)\s*[毛角]/g;
174
+ while ((m = reJiao.exec(seg))) {
175
+ push(m.index, m.index + m[0].length, seg[m.index + m[0].length]);
176
+ }
177
+ const reArabic = /(\d+(?:\.\d{1,2})?)\s*(?:块钱|元钱|块|元|钱|rmb|RMB|¥|¥)?/g;
178
+ while ((m = reArabic.exec(seg))) {
179
+ if (m[0].length === 0) { reArabic.lastIndex++; continue; }
180
+ push(m.index, m.index + m[0].length, seg[m.index + m[0].length]);
181
+ }
182
+ const reCn = /[零〇一二两三四五六七八九十百千]{1,12}\s*(?:块钱|元钱|块|元|钱)/g;
183
+ while ((m = reCn.exec(seg))) {
184
+ push(m.index, m.index + m[0].length, seg[m.index + m[0].length]);
185
+ }
186
+ tokens.sort((a, b) => a.s - b.s);
187
+ return tokens;
188
+ }
189
+
190
+ function splitByAmounts(seg) {
191
+ const tokens = findAmountTokens(seg);
192
+ if (tokens.length < 2) return [seg];
193
+ const parts = [];
194
+ let start = 0;
195
+ for (const tok of tokens) {
196
+ parts.push(seg.slice(start, tok.e).trim());
197
+ start = tok.e;
198
+ }
199
+ if (start < seg.length && parts.length > 0) {
200
+ parts[parts.length - 1] = (parts[parts.length - 1] + " " + seg.slice(start).trim()).trim();
201
+ }
202
+ return parts.filter(Boolean);
203
+ }
204
+
205
+ function parseSegment(seg, nowDate) {
206
+ const text = seg;
207
+ // 1) 金额
208
+ let amountCents = 0;
209
+ let amountHit = null;
210
+ // 日期区间掩码:「10月5号」里的数字不能当金额
211
+ const dateMask = [];
212
+ {
213
+ const reDate = /\d{1,2}\s*月\s*\d{1,2}\s*[号日]/g;
214
+ let dm;
215
+ while ((dm = reDate.exec(text))) dateMask.push([dm.index, dm.index + dm[0].length]);
216
+ }
217
+ const inDate = (i) => dateMask.some(([a, b]) => i >= a && i < b);
218
+ // 1a) 「35块5 / 35块5毛 / 35块5分」特殊形式
219
+ const mFrac = text.match(/(\d+)\s*块\s*(\d)(?:\s*(毛|角|分))?/);
220
+ // 1b) 「8毛 / 6.5角」
221
+ const mJiao = text.match(/(\d+(?:\.\d+)?)\s*[毛角]/);
222
+ if (mFrac && !inDate(mFrac.index)) {
223
+ const unit = mFrac[3];
224
+ const frac = parseInt(mFrac[2], 10); // X块5 = 5角;X块5分 = 5分
225
+ amountCents = parseInt(mFrac[1], 10) * 100 + (unit === "分" ? frac : frac * 10);
226
+ amountHit = mFrac[0];
227
+ } else if (mJiao && !inDate(mJiao.index)) {
228
+ amountCents = Math.round(parseFloat(mJiao[1]) * 10);
229
+ amountHit = mJiao[0];
230
+ } else {
231
+ // 1c) 普通阿拉伯金额:35.5 / 86块 / 120元 / 399;跳过「2杯/3个」数量词与日期
232
+ const reAmt = /(\d+(?:\.\d{1,2})?)\s*(块钱|元钱|块|元|钱|rmb|RMB|¥|¥|分)?/g;
233
+ let mm;
234
+ while ((mm = reAmt.exec(text))) {
235
+ if (inDate(mm.index)) continue;
236
+ const after = text[mm.index + mm[0].length] || "";
237
+ if (after && QUANTIFIERS.includes(after)) continue; // 「2杯咖啡」「3个人」
238
+ if (mm[2] === "分" && after === "钟") continue; // 「20分钟」
239
+ amountCents = Math.round(parseFloat(mm[1]) * 100);
240
+ amountHit = mm[0];
241
+ break;
242
+ }
243
+ }
244
+ // 1b) 中文数字:三十五块 / 一百二元 / 两百
245
+ if (amountCents === 0) {
246
+ const cm = text.match(/([零〇一二两三四五六七八九十百千]+)\s*(块|元|块钱|元钱|钱)?/);
247
+ if (cm) {
248
+ const v = cnNumber(cm[1]);
249
+ if (v && v > 0) {
250
+ // 中文数字后若跟「块X」零头
251
+ const after = text.slice((cm.index || 0) + cm[0].length);
252
+ const tail = after.match(/^(\d)\s*(毛|角|分)?/);
253
+ let extra = 0;
254
+ if (tail) extra = tail[2] === "分" ? parseInt(tail[1], 10) : parseInt(tail[1], 10) * 10;
255
+ amountCents = v * 100 + extra;
256
+ amountHit = cm[0] + (tail ? tail[0] : "");
257
+ }
258
+ }
259
+ }
260
+ if (amountCents <= 0) return null;
261
+
262
+ // 1d) 「每X」倍数:「奶茶两杯每杯20」→40、「两顿每人30」→60(仅当句中有「每」且前文有数量词)
263
+ if (/每/.test(text)) {
264
+ const qm = text.match(/([一两二三四五六七八九\d]+)\s*([杯个瓶份盒包箱袋张台本支根片顿人次条件位])/);
265
+ if (qm) {
266
+ const n = /^\d+$/.test(qm[1]) ? parseInt(qm[1], 10) : cnNumber(qm[1]);
267
+ if (n && n > 1) amountCents *= n;
268
+ }
269
+ }
270
+
271
+ // 2) 收支类型
272
+ const isIncome = INCOME_HINTS.some((k) => text.includes(k)) &&
273
+ !(EXPENSE_HINTS.some((k) => text.includes(k)) && !/(工资|薪水|发薪|薪资|报销|退款|红包到账|收到)/.test(text));
274
+ const type = isIncome ? "income" : "expense";
275
+
276
+ // 3) 分类
277
+ const cats = type === "income" ? INCOME_CATEGORIES : EXPENSE_CATEGORIES;
278
+ let categoryId = type === "income" ? "other_inc" : "other_exp";
279
+ let bestScore = 0;
280
+ for (const c of cats) {
281
+ for (const kw of c.keywords) {
282
+ if (kw && text.includes(kw.toLowerCase())) {
283
+ const score = kw.length; // 更长关键词更具体,优先
284
+ if (score > bestScore) { bestScore = score; categoryId = c.id; }
285
+ }
286
+ }
287
+ }
288
+
289
+ // 4) 日期
290
+ const date = resolveDate(text, nowDate);
291
+
292
+ // 5) 备注:去掉金额、日期词后的剩余短语
293
+ let note = text
294
+ .replace(amountHit || "", "")
295
+ .replace(/(今天|今日|昨天|昨日|前天|大前天|明天)(晚上|上午|下午|早上|中午)?/g, "")
296
+ .replace(/(上?个?)(星期|周|礼拜)[一二三四五六日天1-7]/g, "")
297
+ .replace(/\d{1,2}月\d{1,2}[号日]/g, "")
298
+ .replace(/^(花了|花|用了|用|买了|买|付了|付|支出|收入|收到|赚了|赚)/, "")
299
+ .replace(/(块钱?|元钱?|元|块|毛|角|分)$/g, "")
300
+ .trim();
301
+ if (note.length > 40) note = note.slice(0, 40);
302
+
303
+ return {
304
+ type,
305
+ amountCents,
306
+ categoryId,
307
+ date,
308
+ note: note || null,
309
+ confidence: bestScore > 0 ? 0.8 : 0.55,
310
+ };
311
+ }
312
+
313
+ function resolveDate(text, nowDate) {
314
+ const base = new Date(nowDate.getFullYear(), nowDate.getMonth(), nowDate.getDate());
315
+ if (/前天/.test(text)) return dateToStr(shiftDays(base, /大前天/.test(text) ? -3 : -2));
316
+ if (/昨天|昨日/.test(text)) return dateToStr(shiftDays(base, -1));
317
+ if (/今天|今日|刚刚|刚才/.test(text)) return dateToStr(base);
318
+ // X月X号
319
+ const md = text.match(/(\d{1,2})\s*月\s*(\d{1,2})\s*[号日]/);
320
+ if (md) {
321
+ const month = parseInt(md[1], 10), day = parseInt(md[2], 10);
322
+ let year = base.getFullYear();
323
+ if (month > base.getMonth() + 1 ||
324
+ (month === base.getMonth() + 1 && day > base.getDate())) year -= 1;
325
+ return `${year}-${pad2(month)}-${pad2(day)}`;
326
+ }
327
+ // 周X / 上周X
328
+ const wd = text.match(/(上)?(?:星期|周|礼拜)([一二三四五六日天1-7])/);
329
+ if (wd) {
330
+ const map = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 日: 7, 天: 7, "1": 1, "2": 2,
331
+ "3": 3, "4": 4, "5": 5, "6": 6, "7": 7 };
332
+ const target = map[wd[2]]; // 1=Mon ... 7=Sun
333
+ const todayWd = base.getDay() === 0 ? 7 : base.getDay();
334
+ let delta = target - todayWd;
335
+ if (wd[1]) delta -= 7; // 上周
336
+ else if (delta > 0) delta -= 7; // 本周还没到的星期 → 上一周
337
+ return dateToStr(shiftDays(base, delta));
338
+ }
339
+ return dateToStr(base);
340
+ }
341
+
342
+ /** 把 LLM / 工具传入的分类(id 或中文名)归一化为分类 id */
343
+ function normalizeCategory(input, type) {
344
+ const cats = type === "income" ? INCOME_CATEGORIES : EXPENSE_CATEGORIES;
345
+ if (!input) return type === "income" ? "other_inc" : "other_exp";
346
+ const key = String(input).trim().toLowerCase();
347
+ const found = cats.find((c) =>
348
+ c.id === key || c.zh === input || c.en.toLowerCase() === key ||
349
+ c.keywords.some((k) => k === key));
350
+ return found ? found.id : (type === "income" ? "other_inc" : "other_exp");
351
+ }
352
+
353
+ function categoryMeta(id) {
354
+ const all = EXPENSE_CATEGORIES.concat(INCOME_CATEGORIES);
355
+ return all.find((c) => c.id === id) || { id, zh: id, en: id, emoji: "❓" };
356
+ }
357
+
358
+ /** 汇总计算:给定月份的条目数组 + 预算 → 统计结构(UI 与插件共用) */
359
+ function summarize(entries, budgets, monthStr) {
360
+ const month = monthStr || todayStr().slice(0, 7);
361
+ const inMonth = (entries || []).filter((e) => monthKeyOf(e.date) === month);
362
+ let expenseCents = 0, incomeCents = 0;
363
+ const byCatExp = new Map();
364
+ const dailyMap = new Map();
365
+ for (const e of inMonth) {
366
+ if (e.type === "expense") {
367
+ expenseCents += e.amountCents;
368
+ byCatExp.set(e.categoryId, (byCatExp.get(e.categoryId) || 0) + e.amountCents);
369
+ dailyMap.set(e.date, (dailyMap.get(e.date) || 0) + e.amountCents);
370
+ } else {
371
+ incomeCents += e.amountCents;
372
+ }
373
+ }
374
+ const byCategory = [...byCatExp.entries()]
375
+ .map(([categoryId, amountCents]) => {
376
+ const budgetCents = budgets?.categories?.[categoryId]
377
+ ? yuanToCents(budgets.categories[categoryId]) : 0;
378
+ return {
379
+ categoryId, amountCents,
380
+ count: inMonth.filter((e) => e.type === "expense" && e.categoryId === categoryId).length,
381
+ budgetCents,
382
+ };
383
+ })
384
+ .sort((a, b) => b.amountCents - a.amountCents);
385
+ const daysInMonth = new Date(parseInt(month.slice(0, 4), 10), parseInt(month.slice(5, 7), 10), 0).getDate();
386
+ const daily = [];
387
+ for (let d = 1; d <= daysInMonth; d++) {
388
+ const ds = `${month}-${pad2(d)}`;
389
+ daily.push({ date: ds, expenseCents: dailyMap.get(ds) || 0 });
390
+ }
391
+ const monthlyTotalCents = budgets?.monthly_total ? yuanToCents(budgets.monthly_total)
392
+ : (budgets?.monthly_total_cents || 0);
393
+ const budget = {
394
+ monthlyTotalCents,
395
+ spentCents: expenseCents,
396
+ remainingCents: monthlyTotalCents - expenseCents,
397
+ ratio: monthlyTotalCents > 0 ? expenseCents / monthlyTotalCents : 0,
398
+ over: monthlyTotalCents > 0 && expenseCents > monthlyTotalCents,
399
+ warn: monthlyTotalCents > 0 && expenseCents / monthlyTotalCents >= 0.8 &&
400
+ expenseCents <= monthlyTotalCents,
401
+ };
402
+ return {
403
+ month,
404
+ expenseCents, incomeCents, balanceCents: incomeCents - expenseCents,
405
+ byCategory, daily, budget,
406
+ };
407
+ }
408
+
409
+ export {
410
+ EXPENSE_CATEGORIES, INCOME_CATEGORIES,
411
+ yuanToCents, centsToYuan, formatCents,
412
+ dateToStr, monthKeyOf, todayStr, pad2, resolveDate,
413
+ parseRules, normalizeCategory, categoryMeta, summarize,
414
+ };
package/manifest.json ADDED
@@ -0,0 +1,209 @@
1
+ {
2
+ "display_name": "AI Ledger 记账本",
3
+ "version": "1.0.0",
4
+ "description": "AI-native personal bookkeeping: record expenses/income via chat or voice, monthly stats, category budgets, and durable per-user cloud storage. AI 记账工具:聊天/语音记账、月度统计、分类预算,数据云端保存。",
5
+ "author": "ai-ledger",
6
+ "tags": [
7
+ "productivity",
8
+ "finance",
9
+ "ledger",
10
+ "anna-app",
11
+ "ai-native"
12
+ ],
13
+ "host_capabilities": [
14
+ "aps.kv",
15
+ "llm.sample"
16
+ ],
17
+ "runtime": {
18
+ "type": "node",
19
+ "min_version": "18.0.0"
20
+ },
21
+ "tools": [
22
+ {
23
+ "name": "parse_text",
24
+ "description": "把用户一句自然语言(中文口语,如“昨天和朋友吃火锅花了238块”“工资发了12000”)解析为一条或多条结构化记账草稿(金额/收支类型/分类/日期/备注)。返回 drafts 供用户确认,不直接入账。Parse a free-text bookkeeping sentence into structured draft entry(s); does NOT save anything.",
25
+ "parameters": [
26
+ {
27
+ "name": "text",
28
+ "type": "string",
29
+ "description": "用户的记账描述原文 / the raw sentence",
30
+ "required": true
31
+ }
32
+ ]
33
+ },
34
+ {
35
+ "name": "add_entry",
36
+ "description": "Save one ledger entry. Amount is in USD dollars. Use after the user confirms a parsed draft or states all details.",
37
+ "parameters": [
38
+ {
39
+ "name": "type",
40
+ "type": "string",
41
+ "description": "expense (default) or income",
42
+ "required": false,
43
+ "default": "expense"
44
+ },
45
+ {
46
+ "name": "amount",
47
+ "type": "number",
48
+ "description": "Amount in USD, e.g. 35 or 86.5",
49
+ "required": true
50
+ },
51
+ {
52
+ "name": "category",
53
+ "type": "string",
54
+ "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)",
55
+ "required": false
56
+ },
57
+ {
58
+ "name": "note",
59
+ "type": "string",
60
+ "description": "Optional note, ≤ 60 chars",
61
+ "required": false
62
+ },
63
+ {
64
+ "name": "date",
65
+ "type": "string",
66
+ "description": "Date YYYY-MM-DD, default today",
67
+ "required": false
68
+ },
69
+ {
70
+ "name": "source",
71
+ "type": "string",
72
+ "description": "manual/voice/chat, default chat",
73
+ "required": false
74
+ }
75
+ ]
76
+ },
77
+ {
78
+ "name": "list_entries",
79
+ "description": "列出指定月份(默认本月)的全部账目,按日期倒序。List entries of a month (YYYY-MM, default current).",
80
+ "parameters": [
81
+ {
82
+ "name": "month",
83
+ "type": "string",
84
+ "description": "月份 YYYY-MM,如 2026-09",
85
+ "required": false
86
+ }
87
+ ]
88
+ },
89
+ {
90
+ "name": "delete_entry",
91
+ "description": "按 id 删除一条账目。Delete one entry by id.",
92
+ "parameters": [
93
+ {
94
+ "name": "id",
95
+ "type": "string",
96
+ "description": "条目 id",
97
+ "required": true
98
+ },
99
+ {
100
+ "name": "month",
101
+ "type": "string",
102
+ "description": "条目所在月份 YYYY-MM(可选,不传则在近4个月查找)",
103
+ "required": false
104
+ }
105
+ ]
106
+ },
107
+ {
108
+ "name": "update_entry",
109
+ "description": "修改一条已有账目(金额、类型、分类、日期、备注)。可以跨月份移动,插件自动处理旧月/新月的搬迁。Update an existing entry's fields (amount/type/category/date/note). Cross-month moves are handled automatically.",
110
+ "parameters": [
111
+ {
112
+ "name": "id",
113
+ "type": "string",
114
+ "description": "要修改的条目 id",
115
+ "required": true
116
+ },
117
+ {
118
+ "name": "type",
119
+ "type": "string",
120
+ "description": "expense / income(可选)",
121
+ "required": false
122
+ },
123
+ {
124
+ "name": "amount",
125
+ "type": "number",
126
+ "description": "新金额(美元),如 15 或 8.5(可选)",
127
+ "required": false
128
+ },
129
+ {
130
+ "name": "categoryId",
131
+ "type": "string",
132
+ "description": "新分类 id(可选)",
133
+ "required": false
134
+ },
135
+ {
136
+ "name": "date",
137
+ "type": "string",
138
+ "description": "新日期 YYYY-MM-DD(可选,跨月会自动搬迁)",
139
+ "required": false
140
+ },
141
+ {
142
+ "name": "note",
143
+ "type": "string",
144
+ "description": "新备注,最多 60 字符,传空字符串即清空(可选)",
145
+ "required": false
146
+ }
147
+ ]
148
+ },
149
+ {
150
+ "name": "get_summary",
151
+ "description": "获取某月汇总:支出/收入/结余总额、分类占比、每日支出、预算进度与是否超支。Monthly summary for answering questions like “这个月花了多少”.",
152
+ "parameters": [
153
+ {
154
+ "name": "month",
155
+ "type": "string",
156
+ "description": "月份 YYYY-MM,默认本月",
157
+ "required": false
158
+ }
159
+ ]
160
+ },
161
+ {
162
+ "name": "set_budget",
163
+ "description": "设置月度预算:月总额和/或分类预算(单位元)。Set monthly total budget and/or per-category budgets.",
164
+ "parameters": [
165
+ {
166
+ "name": "monthly_total",
167
+ "type": "number",
168
+ "description": "每月总预算(元),如 3000",
169
+ "required": false
170
+ },
171
+ {
172
+ "name": "categories",
173
+ "type": "object",
174
+ "description": "分类预算映射,如 {\"food\":1500,\"transport\":300}",
175
+ "required": false
176
+ }
177
+ ]
178
+ },
179
+ {
180
+ "name": "get_state",
181
+ "description": "获取预算与最近3个月的全部账目(App 窗口打开时水合用)。Hydrate the app window state.",
182
+ "parameters": []
183
+ },
184
+ {
185
+ "name": "archive_year",
186
+ "description": "将指定年份的12个月度记录归档为1个年度key(紧凑格式),删除原月度key以节省KV存储容量。归档后记录变为只读,可通过 unarchive_year 解档。Archive a year's monthly records into a single compact yearly key, deleting the monthly keys to save KV storage. Archived records become read-only; use unarchive_year to restore.",
187
+ "parameters": [
188
+ {
189
+ "name": "year",
190
+ "type": "string",
191
+ "description": "要归档的年份 YYYY,如 2025。不传则默认去年。Year to archive, default last year.",
192
+ "required": false
193
+ }
194
+ ]
195
+ },
196
+ {
197
+ "name": "unarchive_year",
198
+ "description": "将年度归档还原回月度记录,使其可再次编辑。Unarchive a year: restore monthly records from the yearly archive key, making them editable again.",
199
+ "parameters": [
200
+ {
201
+ "name": "year",
202
+ "type": "string",
203
+ "description": "要解档的年份 YYYY,如 2025。Year to unarchive.",
204
+ "required": true
205
+ }
206
+ ]
207
+ }
208
+ ]
209
+ }
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "ibill-ai-ledger",
3
+ "version": "1.0.0",
4
+ "description": "iBill · AI 记账本 — Anna Executa stdio plugin (Node.js, zero deps): chat/voice bookkeeping with AI parsing, budgets and monthly stats",
5
+ "license": "MIT",
6
+ "type": "commonjs",
7
+ "engines": {
8
+ "node": ">=18.0.0"
9
+ },
10
+ "bin": {
11
+ "ibill-ai-ledger": "plugin.js",
12
+ "tool-dev-ai-ledger": "plugin.js"
13
+ },
14
+ "files": [
15
+ "plugin.js",
16
+ "lib",
17
+ "executa.json",
18
+ "manifest.json"
19
+ ],
20
+ "main": "plugin.js",
21
+ "scripts": {
22
+ "start": "node plugin.js"
23
+ },
24
+ "keywords": [
25
+ "anna",
26
+ "executa",
27
+ "ai-ledger",
28
+ "bookkeeping",
29
+ "expense-tracker"
30
+ ]
31
+ }