@chainlesschain/personal-data-hub 0.4.29 → 0.4.31

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.
Files changed (199) hide show
  1. package/lib/forensics/qq-nt-collect.js +190 -0
  2. package/lib/prompt-builder.js +15 -1
  3. package/package.json +8 -3
  4. package/__tests__/adapter-guide.test.js +0 -47
  5. package/__tests__/adapter-spec.test.js +0 -78
  6. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +0 -211
  7. package/__tests__/adapters/ai-chat-health-checker.test.js +0 -262
  8. package/__tests__/adapters/ai-chat-history.test.js +0 -396
  9. package/__tests__/adapters/ai-chat-http-client.test.js +0 -242
  10. package/__tests__/adapters/ai-chat-vendors.test.js +0 -874
  11. package/__tests__/adapters/alipay-bill-adapter.test.js +0 -538
  12. package/__tests__/adapters/apple-health.test.js +0 -95
  13. package/__tests__/adapters/bank-family.test.js +0 -125
  14. package/__tests__/adapters/biz-tianyancha.test.js +0 -159
  15. package/__tests__/adapters/browser-history-chrome.test.js +0 -377
  16. package/__tests__/adapters/browser-history-edge.test.js +0 -159
  17. package/__tests__/adapters/car-mercedesme.test.js +0 -74
  18. package/__tests__/adapters/doc-baidu-netdisk.test.js +0 -102
  19. package/__tests__/adapters/doc-camscanner.test.js +0 -147
  20. package/__tests__/adapters/doc-platforms.test.js +0 -177
  21. package/__tests__/adapters/edu-huawei-learning-live.test.js +0 -198
  22. package/__tests__/adapters/edu-zuoyebang-live.test.js +0 -226
  23. package/__tests__/adapters/email-adapter-snapshot.test.js +0 -237
  24. package/__tests__/adapters/email-adapter.test.js +0 -742
  25. package/__tests__/adapters/email-classifier.test.js +0 -347
  26. package/__tests__/adapters/email-imap-session.test.js +0 -334
  27. package/__tests__/adapters/email-parser.test.js +0 -244
  28. package/__tests__/adapters/email-pdf-extractor.test.js +0 -529
  29. package/__tests__/adapters/email-providers.test.js +0 -84
  30. package/__tests__/adapters/email-retry-progress.test.js +0 -294
  31. package/__tests__/adapters/email-templates.test.js +0 -822
  32. package/__tests__/adapters/family-23-collectors-scaffold.test.js +0 -182
  33. package/__tests__/adapters/finance-alipay-live.test.js +0 -258
  34. package/__tests__/adapters/finance-dcep.test.js +0 -74
  35. package/__tests__/adapters/fitness-joyrun.test.js +0 -82
  36. package/__tests__/adapters/game-genshin-live.test.js +0 -238
  37. package/__tests__/adapters/game-genshin-scaffold.test.js +0 -108
  38. package/__tests__/adapters/game-honor-of-kings-live.test.js +0 -230
  39. package/__tests__/adapters/git-activity.test.js +0 -222
  40. package/__tests__/adapters/gov-12123.test.js +0 -103
  41. package/__tests__/adapters/gov-ixiamen.test.js +0 -150
  42. package/__tests__/adapters/gov-tax.test.js +0 -135
  43. package/__tests__/adapters/health-meiyou.test.js +0 -125
  44. package/__tests__/adapters/local-files.test.js +0 -264
  45. package/__tests__/adapters/local-im-pc.test.js +0 -154
  46. package/__tests__/adapters/messaging-whatsapp.test.js +0 -289
  47. package/__tests__/adapters/music-kugou.test.js +0 -187
  48. package/__tests__/adapters/music-qq.test.js +0 -112
  49. package/__tests__/adapters/netease-music-live.test.js +0 -244
  50. package/__tests__/adapters/netease-music.test.js +0 -74
  51. package/__tests__/adapters/pc-local-discovery.test.js +0 -141
  52. package/__tests__/adapters/qq-pc-direct-read.test.js +0 -227
  53. package/__tests__/adapters/reading-family.test.js +0 -108
  54. package/__tests__/adapters/recruit-boss.test.js +0 -180
  55. package/__tests__/adapters/shell-history.test.js +0 -180
  56. package/__tests__/adapters/shopping-base.test.js +0 -179
  57. package/__tests__/adapters/shopping-dianping.test.js +0 -239
  58. package/__tests__/adapters/social-bilibili-adb-api-client.test.js +0 -721
  59. package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +0 -346
  60. package/__tests__/adapters/social-bilibili-adb-collector.test.js +0 -284
  61. package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +0 -343
  62. package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +0 -296
  63. package/__tests__/adapters/social-csdn.test.js +0 -175
  64. package/__tests__/adapters/social-dongchedi.test.js +0 -165
  65. package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +0 -165
  66. package/__tests__/adapters/social-douyin-adb-collector.test.js +0 -254
  67. package/__tests__/adapters/social-douyin-adb-db-extension.test.js +0 -114
  68. package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +0 -304
  69. package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +0 -216
  70. package/__tests__/adapters/social-douyin-adb-usage-profile.test.js +0 -229
  71. package/__tests__/adapters/social-douyin-adb-watch-history.test.js +0 -269
  72. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +0 -496
  73. package/__tests__/adapters/social-kuaishou-adb-collector.test.js +0 -276
  74. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +0 -152
  75. package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +0 -178
  76. package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +0 -135
  77. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +0 -626
  78. package/__tests__/adapters/social-toutiao-adb-article.test.js +0 -155
  79. package/__tests__/adapters/social-toutiao-adb-collector.test.js +0 -378
  80. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +0 -193
  81. package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +0 -196
  82. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +0 -311
  83. package/__tests__/adapters/social-weibo-adb-api-client.test.js +0 -362
  84. package/__tests__/adapters/social-weibo-adb-collector.test.js +0 -201
  85. package/__tests__/adapters/social-weibo-adb-cookies-extension.test.js +0 -167
  86. package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +0 -189
  87. package/__tests__/adapters/social-xiaohongshu-adb-api-client.test.js +0 -431
  88. package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +0 -207
  89. package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
  90. package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +0 -351
  91. package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +0 -130
  92. package/__tests__/adapters/social-xiaohongshu-adb-snapshot-builder.test.js +0 -200
  93. package/__tests__/adapters/social-zhihu.test.js +0 -246
  94. package/__tests__/adapters/system-data-adapter.test.js +0 -443
  95. package/__tests__/adapters/system-data-android-ingest.test.js +0 -144
  96. package/__tests__/adapters/system-data-android.test.js +0 -519
  97. package/__tests__/adapters/system-data-disclosure.test.js +0 -153
  98. package/__tests__/adapters/travel-12306.test.js +0 -512
  99. package/__tests__/adapters/travel-amap.test.js +0 -219
  100. package/__tests__/adapters/travel-baidu-map.test.js +0 -305
  101. package/__tests__/adapters/travel-base.test.js +0 -205
  102. package/__tests__/adapters/travel-ctrip.test.js +0 -377
  103. package/__tests__/adapters/travel-didi-consumer.test.js +0 -66
  104. package/__tests__/adapters/travel-didi.test.js +0 -204
  105. package/__tests__/adapters/travel-tencent-map.test.js +0 -207
  106. package/__tests__/adapters/travel-tongcheng.test.js +0 -289
  107. package/__tests__/adapters/video-platforms.test.js +0 -152
  108. package/__tests__/adapters/video-xigua.test.js +0 -106
  109. package/__tests__/adapters/vscode.test.js +0 -299
  110. package/__tests__/adapters/wechat-bootstrap.test.js +0 -240
  111. package/__tests__/adapters/wechat-env-probe.test.js +0 -162
  112. package/__tests__/adapters/wechat-frida-agent.test.js +0 -322
  113. package/__tests__/adapters/wechat-frida-integration.test.js +0 -149
  114. package/__tests__/adapters/wechat-frida-key-provider.test.js +0 -188
  115. package/__tests__/adapters/wechat-md5-key-provider.test.js +0 -101
  116. package/__tests__/adapters/wechat-pc-direct-read.test.js +0 -365
  117. package/__tests__/adapters/wechat-pc-group-topic.test.js +0 -63
  118. package/__tests__/adapters/wechat-pc-v4-sidecar.test.js +0 -72
  119. package/__tests__/adapters/weread.test.js +0 -123
  120. package/__tests__/adapters/wework-pc.test.js +0 -124
  121. package/__tests__/adapters/win-recent.test.js +0 -192
  122. package/__tests__/analysis-skills.test.js +0 -754
  123. package/__tests__/analysis.test.js +0 -1845
  124. package/__tests__/audio-ximalaya-snapshot.test.js +0 -279
  125. package/__tests__/batch.test.js +0 -133
  126. package/__tests__/bridges-cc-kg.test.js +0 -231
  127. package/__tests__/bridges-cc-llm.test.js +0 -191
  128. package/__tests__/bridges-cc-rag.test.js +0 -162
  129. package/__tests__/categories.test.js +0 -92
  130. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +0 -213
  131. package/__tests__/e2e/full-user-journey.test.js +0 -188
  132. package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +0 -146
  133. package/__tests__/entity-resolver-ingest-hook.test.js +0 -177
  134. package/__tests__/entity-resolver-stages.test.js +0 -411
  135. package/__tests__/entity-resolver-vault.test.js +0 -249
  136. package/__tests__/entity-resolver.test.js +0 -526
  137. package/__tests__/fitness-keep-snapshot.test.js +0 -224
  138. package/__tests__/fixtures/entity-resolver-200-mock.json +0 -96
  139. package/__tests__/ids.test.js +0 -45
  140. package/__tests__/integration/ai-chat-history-registry.test.js +0 -228
  141. package/__tests__/integration/aichat-wizard-end-to-end.test.js +0 -282
  142. package/__tests__/integration/cross-adapter-pipelines.test.js +0 -396
  143. package/__tests__/integration/local-data-adapters-pipeline.test.js +0 -373
  144. package/__tests__/integration/social-bilibili-pipeline.test.js +0 -261
  145. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +0 -390
  146. package/__tests__/key-providers.test.js +0 -126
  147. package/__tests__/kg-derive.test.js +0 -219
  148. package/__tests__/llm-client.test.js +0 -122
  149. package/__tests__/longtail-adapters.test.js +0 -281
  150. package/__tests__/messaging-qq-snapshot.test.js +0 -294
  151. package/__tests__/mobile-extractor-encrypted.test.js +0 -460
  152. package/__tests__/mobile-extractor.test.js +0 -288
  153. package/__tests__/mock-adapter.test.js +0 -93
  154. package/__tests__/prompt-builder.test.js +0 -249
  155. package/__tests__/query-parser.test.js +0 -365
  156. package/__tests__/rag-derive.test.js +0 -169
  157. package/__tests__/registry-readiness.test.js +0 -292
  158. package/__tests__/registry.test.js +0 -420
  159. package/__tests__/salvage-ingest.test.js +0 -97
  160. package/__tests__/schemas.test.js +0 -331
  161. package/__tests__/shopping-adapters.test.js +0 -392
  162. package/__tests__/shopping-eleme-snapshot.test.js +0 -454
  163. package/__tests__/shopping-pinduoduo-snapshot.test.js +0 -484
  164. package/__tests__/shopping-snapshot.test.js +0 -438
  165. package/__tests__/shopping-vipshop-snapshot.test.js +0 -425
  166. package/__tests__/shopping-xianyu-snapshot.test.js +0 -451
  167. package/__tests__/sidecar-contacts-cross-validate.test.js +0 -186
  168. package/__tests__/sidecar-supervisor.test.js +0 -128
  169. package/__tests__/sign-providers.test.js +0 -62
  170. package/__tests__/social-adapters.test.js +0 -280
  171. package/__tests__/social-bilibili-snapshot.test.js +0 -278
  172. package/__tests__/social-douban-snapshot.test.js +0 -351
  173. package/__tests__/social-douyin-im-direct-read.test.js +0 -377
  174. package/__tests__/social-douyin-salvage-collector.test.js +0 -98
  175. package/__tests__/social-douyin-salvage-mapper.test.js +0 -90
  176. package/__tests__/social-douyin-snapshot.test.js +0 -256
  177. package/__tests__/social-kuaishou-snapshot.test.js +0 -362
  178. package/__tests__/social-toutiao-snapshot.test.js +0 -366
  179. package/__tests__/social-weibo-snapshot.test.js +0 -234
  180. package/__tests__/social-weibo-sqlite-device.test.js +0 -174
  181. package/__tests__/social-xiaohongshu-snapshot.test.js +0 -232
  182. package/__tests__/sqlite-leaf-salvage.test.js +0 -97
  183. package/__tests__/travel-adapters.test.js +0 -483
  184. package/__tests__/travel-maps-snapshot.test.js +0 -426
  185. package/__tests__/vault-driver-error.test.js +0 -74
  186. package/__tests__/vault-search-helpers.test.js +0 -104
  187. package/__tests__/vault-search.test.js +0 -423
  188. package/__tests__/vault.test.js +0 -767
  189. package/__tests__/wechat-adapter.test.js +0 -594
  190. package/__tests__/whatsapp-adapter.test.js +0 -138
  191. package/scripts/_make-fixture-all.js +0 -126
  192. package/scripts/_make-fixture-contacts.js +0 -84
  193. package/scripts/evaluate-entity-resolver.js +0 -213
  194. package/scripts/run-native-tests-sandbox.sh +0 -55
  195. package/scripts/smoke-phase-5-5.js +0 -196
  196. package/scripts/smoke-phase-5-7.js +0 -181
  197. package/scripts/smoke-system-data-contacts.js +0 -309
  198. package/scripts/smoke-system-data.js +0 -312
  199. package/vitest.config.js +0 -88
@@ -1,754 +0,0 @@
1
- "use strict";
2
-
3
- import { describe, it, expect, beforeEach, afterEach } from "vitest";
4
-
5
- const path = require("node:path");
6
- const fs = require("node:fs");
7
- const os = require("node:os");
8
- const { LocalVault } = require("../lib/vault");
9
- const { generateKeyHex } = require("../lib/key-providers");
10
- const {
11
- AnalysisSkill,
12
- SpendingSkill,
13
- RelationsSkill,
14
- FootprintSkill,
15
- InterestsSkill,
16
- TimelineSkill,
17
- OverviewSkill,
18
- runAnalysisSkill,
19
- ANALYSIS_SKILL_NAMES,
20
- } = require("../lib/analysis-skills");
21
-
22
- // ─── Test fixtures ──────────────────────────────────────────────────────
23
-
24
- function makeVault() {
25
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hub-skill-"));
26
- const vault = new LocalVault({ path: path.join(dir, "v.db"), key: generateKeyHex() });
27
- vault.open();
28
- return { vault, dir };
29
- }
30
-
31
- function cleanup({ vault, dir }) {
32
- try { vault.close(); } catch (_e) {}
33
- try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {}
34
- }
35
-
36
- function defaultSource(adapter = "test") {
37
- return {
38
- adapter, adapterVersion: "0.1",
39
- originalId: "tx-" + Math.random().toString(36).slice(2, 10),
40
- capturedAt: Date.now(), capturedBy: "api",
41
- };
42
- }
43
-
44
- function makePerson(vault, id, names, identifiers = {}, opts = {}) {
45
- vault.putPerson({
46
- id, type: "person", subtype: opts.subtype || "contact",
47
- names, identifiers, ingestedAt: Date.now(),
48
- source: defaultSource(opts.adapter || "test"),
49
- });
50
- }
51
-
52
- function makePayment(vault, opts) {
53
- const participants = [];
54
- if (opts.counterpartyId) participants.push(opts.counterpartyId);
55
- participants.push("person-self");
56
- vault.putEvent({
57
- id: opts.id,
58
- type: "event",
59
- subtype: opts.subtype || "payment",
60
- occurredAt: opts.occurredAt,
61
- actor: opts.actor || "person-self",
62
- participants,
63
- content: {
64
- title: opts.title || "(no title)",
65
- amount: { value: opts.amount, currency: "CNY", direction: opts.direction || "out" },
66
- },
67
- ingestedAt: Date.now(),
68
- source: defaultSource(opts.adapter || "test"),
69
- extra: {
70
- counterparty: opts.counterpartyName,
71
- ...(opts.category ? { category: opts.category } : {}),
72
- ...(opts.extra || {}),
73
- },
74
- });
75
- }
76
-
77
- function ts(year, month, day) {
78
- return new Date(year, month - 1, day).getTime();
79
- }
80
-
81
- // ─── AnalysisSkill base ─────────────────────────────────────────────────
82
-
83
- describe("AnalysisSkill base", () => {
84
- it("requires vault", () => {
85
- expect(() => new AnalysisSkill()).toThrow();
86
- expect(() => new AnalysisSkill({})).toThrow(/vault/);
87
- });
88
-
89
- it("resolveTimeWindow handles since/until pair", () => {
90
- const skill = new AnalysisSkill({ vault: { dummy: true } });
91
- const r = skill.resolveTimeWindow({ since: 1000, until: 2000 });
92
- expect(r.since).toBe(1000);
93
- expect(r.until).toBe(2000);
94
- });
95
-
96
- it("resolveTimeWindow handles sinceDays", () => {
97
- const skill = new AnalysisSkill({ vault: { dummy: true } });
98
- const r = skill.resolveTimeWindow({ sinceDays: 7 });
99
- const days7Ms = 7 * 24 * 3600_000;
100
- expect(Date.now() - r.since).toBeGreaterThanOrEqual(days7Ms - 1000);
101
- expect(Date.now() - r.since).toBeLessThanOrEqual(days7Ms + 1000);
102
- });
103
-
104
- it("resolveTimeWindow returns null window for all-time", () => {
105
- const skill = new AnalysisSkill({ vault: { dummy: true } });
106
- expect(skill.resolveTimeWindow({}).since).toBeNull();
107
- });
108
-
109
- it("ANALYSIS_SKILL_NAMES lists exactly 6", () => {
110
- expect(ANALYSIS_SKILL_NAMES).toHaveLength(6);
111
- expect(ANALYSIS_SKILL_NAMES).toContain("analysis.spending");
112
- expect(ANALYSIS_SKILL_NAMES).toContain("analysis.relations");
113
- expect(ANALYSIS_SKILL_NAMES).toContain("analysis.footprint");
114
- expect(ANALYSIS_SKILL_NAMES).toContain("analysis.interests");
115
- expect(ANALYSIS_SKILL_NAMES).toContain("analysis.timeline");
116
- expect(ANALYSIS_SKILL_NAMES).toContain("analysis.overview");
117
- });
118
-
119
- it("base.run() throws (subclasses must override)", async () => {
120
- const skill = new AnalysisSkill({ vault: { dummy: true } });
121
- await expect(skill.run()).rejects.toThrow();
122
- });
123
- });
124
-
125
- // ─── SpendingSkill ───────────────────────────────────────────────────────
126
-
127
- describe("SpendingSkill", () => {
128
- let rig;
129
- beforeEach(() => { rig = makeVault(); });
130
- afterEach(() => cleanup(rig));
131
-
132
- function setupAlipayPayments() {
133
- makePerson(rig.vault, "p-meituan", ["美团"], {}, { subtype: "merchant" });
134
- makePerson(rig.vault, "p-tb", ["淘宝"], {}, { subtype: "merchant" });
135
- makePerson(rig.vault, "p-jd", ["京东"], {}, { subtype: "merchant" });
136
- makePayment(rig.vault, { id: "evt-1", occurredAt: ts(2026, 4, 1), counterpartyName: "美团", counterpartyId: "p-meituan", amount: 38.50, adapter: "alipay", title: "美团外卖" });
137
- makePayment(rig.vault, { id: "evt-2", occurredAt: ts(2026, 4, 15), counterpartyName: "美团", counterpartyId: "p-meituan", amount: 25.00, adapter: "alipay", title: "美团外卖" });
138
- makePayment(rig.vault, { id: "evt-3", occurredAt: ts(2026, 4, 20), counterpartyName: "淘宝", counterpartyId: "p-tb", amount: 299.00, adapter: "alipay", title: "运动鞋" });
139
- makePayment(rig.vault, { id: "evt-4", occurredAt: ts(2026, 5, 5), counterpartyName: "京东", counterpartyId: "p-jd", amount: 999.00, adapter: "alipay", title: "iPhone case", subtype: "payment" });
140
- // Refund
141
- makePayment(rig.vault, { id: "evt-5", occurredAt: ts(2026, 4, 22), counterpartyName: "淘宝", counterpartyId: "p-tb", amount: 50.00, direction: "in", subtype: "refund", title: "淘宝退款", adapter: "alipay" });
142
- }
143
-
144
- it("aggregates total spend across all events", async () => {
145
- setupAlipayPayments();
146
- const skill = new SpendingSkill({ vault: rig.vault });
147
- const r = await skill.run({});
148
- expect(r.summary.totalSpend).toBeCloseTo(38.5 + 25 + 299 + 999, 2);
149
- expect(r.summary.totalIncome).toBe(50);
150
- expect(r.summary.eventCount).toBe(5);
151
- expect(r.summary.currency).toBe("CNY");
152
- });
153
-
154
- it("headline totals come from uncapped sumEventAmount, not the 5000-capped row fetch", async () => {
155
- // queryEvents caps at 5000 rows per subtype; a >5000-payment user would
156
- // have totalSpend silently undercounted. Fake a vault where the row fetch
157
- // returns only a sample but sumEventAmount reports the true sum.
158
- const fakeVault = {
159
- queryEvents: ({ subtype }) =>
160
- subtype === "payment"
161
- ? [
162
- { id: "p1", subtype: "payment", occurredAt: ts(2026, 5, 1), content: { amount: { value: 10, direction: "out", currency: "CNY" } } },
163
- { id: "p2", subtype: "payment", occurredAt: ts(2026, 5, 2), content: { amount: { value: 20, direction: "out", currency: "CNY" } } },
164
- ]
165
- : [],
166
- sumEventAmount: ({ subtype }) =>
167
- subtype === "payment"
168
- ? { total: 88000, currency: "CNY", count: 5200, byDirection: { out: 88000, in: 1234 }, byCurrency: {} }
169
- : { total: 0, currency: "CNY", count: 0, byDirection: { out: 0, in: 0 }, byCurrency: {} },
170
- };
171
- const r = await new SpendingSkill({ vault: fakeVault }).run({ commentary: false });
172
- expect(r.summary.totalSpend).toBe(88000); // true sum, not the 30 from 2 sampled rows
173
- expect(r.summary.totalIncome).toBe(1234);
174
- expect(r.summary.eventCount).toBe(5200);
175
- expect(r.summary.netFlow).toBe(Math.round((1234 - 88000) * 100) / 100);
176
- });
177
-
178
- it("with a merchant/person/direction filter it falls back to the row sample (SQL can't express it)", async () => {
179
- const fakeVault = {
180
- queryEvents: ({ subtype }) =>
181
- subtype === "payment"
182
- ? [{ id: "p1", subtype: "payment", occurredAt: ts(2026, 5, 1), content: { amount: { value: 30, direction: "out", currency: "CNY" } }, extra: { counterparty: "美团" } }]
183
- : [],
184
- // would be used by the accurate path — must NOT be when a filter is active
185
- sumEventAmount: () => { throw new Error("sumEventAmount must not be called when a row-only filter is set"); },
186
- };
187
- const r = await new SpendingSkill({ vault: fakeVault }).run({ commentary: false, direction: "out" });
188
- expect(r.summary.totalSpend).toBe(30); // from the row sample, not SQL
189
- });
190
-
191
- it("breakdown by merchant ranks top spenders", async () => {
192
- setupAlipayPayments();
193
- const skill = new SpendingSkill({ vault: rig.vault });
194
- const r = await skill.run({ dimension: "merchant" });
195
- expect(r.breakdown[0].key).toBe("京东");
196
- expect(r.breakdown[0].totalSpend).toBe(999);
197
- expect(r.breakdown[1].key).toBe("淘宝");
198
- expect(r.breakdown[1].totalSpend).toBe(299);
199
- });
200
-
201
- it("merchantFilter scopes to subset", async () => {
202
- setupAlipayPayments();
203
- const skill = new SpendingSkill({ vault: rig.vault });
204
- const r = await skill.run({ merchantFilter: "美团" });
205
- expect(r.summary.eventCount).toBe(2);
206
- expect(r.summary.totalSpend).toBeCloseTo(63.5, 2);
207
- });
208
-
209
- it("time window filters events", async () => {
210
- setupAlipayPayments();
211
- const skill = new SpendingSkill({ vault: rig.vault });
212
- const r = await skill.run({ since: ts(2026, 4, 1), until: ts(2026, 5, 1) });
213
- // Excludes evt-4 (May)
214
- expect(r.summary.eventCount).toBe(4);
215
- });
216
-
217
- it("trend returns monthly buckets", async () => {
218
- setupAlipayPayments();
219
- const skill = new SpendingSkill({ vault: rig.vault });
220
- const r = await skill.run({});
221
- expect(r.trend.length).toBeGreaterThanOrEqual(2);
222
- expect(r.trend[0].monthKey).toBe("2026-04");
223
- expect(r.trend[1].monthKey).toBe("2026-05");
224
- });
225
-
226
- it("LLM commentary fires when LLM provided", async () => {
227
- setupAlipayPayments();
228
- const llm = { isLocal: true, chat: async () => ({ text: "测试 commentary" }) };
229
- const skill = new SpendingSkill({ vault: rig.vault, llm });
230
- const r = await skill.run({});
231
- expect(r.llm_commentary).toBe("测试 commentary");
232
- });
233
-
234
- it("no LLM → commentary is null", async () => {
235
- setupAlipayPayments();
236
- const skill = new SpendingSkill({ vault: rig.vault });
237
- const r = await skill.run({});
238
- expect(r.llm_commentary).toBeNull();
239
- });
240
-
241
- it("empty vault → zero summary, no breakdown", async () => {
242
- const skill = new SpendingSkill({ vault: rig.vault });
243
- const r = await skill.run({});
244
- expect(r.summary.totalSpend).toBe(0);
245
- expect(r.breakdown).toEqual([]);
246
- });
247
-
248
- it("non-local LLM without acceptNonLocal → commentary suppressed", async () => {
249
- setupAlipayPayments();
250
- const llm = { isLocal: false, chat: async () => { throw new Error("should not call"); } };
251
- const skill = new SpendingSkill({ vault: rig.vault, llm });
252
- const r = await skill.run({});
253
- expect(r.llm_commentary).toBeNull();
254
- });
255
- });
256
-
257
- // ─── RelationsSkill ──────────────────────────────────────────────────────
258
-
259
- describe("RelationsSkill", () => {
260
- let rig;
261
- beforeEach(() => { rig = makeVault(); });
262
- afterEach(() => cleanup(rig));
263
-
264
- it("single person mode: aggregates interactions with one person", async () => {
265
- makePerson(rig.vault, "p-mom", ["妈"]);
266
- makePayment(rig.vault, { id: "e1", occurredAt: ts(2026, 4, 1), counterpartyId: "p-mom", counterpartyName: "妈", amount: 500 });
267
- makePayment(rig.vault, { id: "e2", occurredAt: ts(2026, 5, 1), counterpartyId: "p-mom", counterpartyName: "妈", amount: 300 });
268
- makePayment(rig.vault, { id: "e3", occurredAt: ts(2026, 5, 5), counterpartyId: "p-other", counterpartyName: "其他", amount: 100 });
269
-
270
- const skill = new RelationsSkill({ vault: rig.vault });
271
- const r = await skill.run({ personId: "p-mom" });
272
- expect(r.mode).toBe("single");
273
- expect(r.profile.totalInteractions).toBe(2);
274
- expect(r.profile.totalSpend).toBe(800);
275
- expect(r.profile.names).toContain("妈");
276
- });
277
-
278
- it("ranked mode: returns top counterparties", async () => {
279
- makePerson(rig.vault, "p-mom", ["妈"]);
280
- makePerson(rig.vault, "p-dad", ["爸"]);
281
- makePayment(rig.vault, { id: "e1", occurredAt: ts(2026, 4, 1), counterpartyId: "p-mom", counterpartyName: "妈", amount: 500 });
282
- makePayment(rig.vault, { id: "e2", occurredAt: ts(2026, 4, 2), counterpartyId: "p-mom", counterpartyName: "妈", amount: 200 });
283
- makePayment(rig.vault, { id: "e3", occurredAt: ts(2026, 4, 3), counterpartyId: "p-dad", counterpartyName: "爸", amount: 100 });
284
-
285
- const skill = new RelationsSkill({ vault: rig.vault });
286
- const r = await skill.run({});
287
- expect(r.mode).toBe("ranked");
288
- expect(r.ranked.length).toBeGreaterThanOrEqual(2);
289
- expect(r.ranked[0].personId).toBe("p-mom");
290
- expect(r.ranked[0].totalInteractions).toBe(2);
291
- });
292
-
293
- it("empty vault → ranked mode returns empty list, no crash", async () => {
294
- const skill = new RelationsSkill({ vault: rig.vault });
295
- const r = await skill.run({});
296
- expect(r.mode).toBe("ranked");
297
- expect(r.ranked).toEqual([]);
298
- expect(r.citations).toEqual([]);
299
- expect(r.llm_commentary).toBeNull();
300
- });
301
-
302
- it("non-local LLM gate: isLocal=false without acceptNonLocal → commentary stays null", async () => {
303
- makePerson(rig.vault, "p-mom", ["妈"]);
304
- makePayment(rig.vault, { id: "e1", occurredAt: ts(2026, 5, 1), counterpartyId: "p-mom", counterpartyName: "妈", amount: 100 });
305
- const nonLocalLlm = {
306
- isLocal: false,
307
- chat: async () => ({ text: "this should never reach the caller" }),
308
- };
309
- const skill = new RelationsSkill({ vault: rig.vault, llm: nonLocalLlm });
310
- const r = await skill.run({ personId: "p-mom" });
311
- expect(r.mode).toBe("single");
312
- expect(r.profile.totalInteractions).toBe(1);
313
- // gate enforced by base.callLlmCommentary
314
- expect(r.llm_commentary).toBeNull();
315
- });
316
-
317
- it("LLM exception swallowed → commentary null but profile data intact", async () => {
318
- makePerson(rig.vault, "p-mom", ["妈"]);
319
- makePayment(rig.vault, { id: "e1", occurredAt: ts(2026, 5, 1), counterpartyId: "p-mom", counterpartyName: "妈", amount: 100 });
320
- const throwingLlm = {
321
- isLocal: true,
322
- chat: async () => { throw new Error("model timeout"); },
323
- };
324
- const skill = new RelationsSkill({ vault: rig.vault, llm: throwingLlm });
325
- const r = await skill.run({ personId: "p-mom" });
326
- expect(r.profile.totalInteractions).toBe(1); // data path unaffected
327
- expect(r.llm_commentary).toBeNull();
328
- });
329
-
330
- it("merge-group expansion: vault.getMergeGroupMembers fans the personId out", async () => {
331
- // Two PersonIds representing the same real-world person across sources;
332
- // EntityResolver (Phase 8) would normally merge them into a group.
333
- makePerson(rig.vault, "p-mom-email", ["妈"], { email: ["mom@163.com"] });
334
- makePerson(rig.vault, "p-mom-alipay", ["陈XX"], { alipay: ["mom@163.com"] });
335
- makePayment(rig.vault, { id: "e1", occurredAt: ts(2026, 4, 1), counterpartyId: "p-mom-email", counterpartyName: "妈", amount: 200, adapter: "email-imap" });
336
- makePayment(rig.vault, { id: "e2", occurredAt: ts(2026, 5, 1), counterpartyId: "p-mom-alipay", counterpartyName: "陈XX", amount: 300, adapter: "alipay-bill" });
337
-
338
- // Stub the resolver hook
339
- rig.vault.getMergeGroupMembers = (pid) =>
340
- (pid === "p-mom-email" || pid === "p-mom-alipay")
341
- ? ["p-mom-email", "p-mom-alipay"]
342
- : [pid];
343
-
344
- const skill = new RelationsSkill({ vault: rig.vault });
345
- const r = await skill.run({ personId: "p-mom-email" });
346
- expect(r.profile.totalInteractions).toBe(2); // both events counted
347
- expect(r.profile.totalSpend).toBe(500);
348
- expect(Object.keys(r.profile.byAdapter).sort()).toEqual(["alipay-bill", "email-imap"]);
349
- });
350
- });
351
-
352
- // ─── FootprintSkill ──────────────────────────────────────────────────────
353
-
354
- describe("FootprintSkill", () => {
355
- let rig;
356
- beforeEach(() => { rig = makeVault(); });
357
- afterEach(() => cleanup(rig));
358
-
359
- it("returns top places + monthly distribution", async () => {
360
- rig.vault.putEvent({
361
- id: "trip-1", type: "event", subtype: "trip",
362
- occurredAt: ts(2026, 4, 1),
363
- actor: "person-self",
364
- content: { title: "Beijing trip" },
365
- ingestedAt: Date.now(),
366
- source: defaultSource("travel"),
367
- extra: { from: "Shanghai", to: "Beijing" },
368
- });
369
- rig.vault.putEvent({
370
- id: "trip-2", type: "event", subtype: "trip",
371
- occurredAt: ts(2026, 4, 15),
372
- actor: "person-self",
373
- content: { title: "Beijing trip 2" },
374
- ingestedAt: Date.now(),
375
- source: defaultSource("travel"),
376
- extra: { from: "Shanghai", to: "Beijing" },
377
- });
378
- rig.vault.putEvent({
379
- id: "trip-3", type: "event", subtype: "trip",
380
- occurredAt: ts(2026, 5, 1),
381
- actor: "person-self",
382
- content: { title: "Hangzhou trip" },
383
- ingestedAt: Date.now(),
384
- source: defaultSource("travel"),
385
- extra: { to: "Hangzhou" },
386
- });
387
-
388
- const skill = new FootprintSkill({ vault: rig.vault });
389
- const r = await skill.run({});
390
- expect(r.summary.totalTrips).toBeGreaterThan(0);
391
- expect(r.topPlaces[0].name).toBeDefined();
392
- expect(r.monthlyDistribution.length).toBeGreaterThan(0);
393
- });
394
-
395
- it("empty vault → zero trips", async () => {
396
- const skill = new FootprintSkill({ vault: rig.vault });
397
- const r = await skill.run({});
398
- expect(r.summary.totalTrips).toBe(0);
399
- expect(r.topPlaces).toEqual([]);
400
- });
401
-
402
- it("local LLM commentary fires when trips present", async () => {
403
- rig.vault.putEvent({
404
- id: "trip-1", type: "event", subtype: "trip",
405
- occurredAt: ts(2026, 4, 1),
406
- actor: "person-self",
407
- content: { title: "Hangzhou trip" },
408
- ingestedAt: Date.now(),
409
- source: defaultSource("travel"),
410
- extra: { to: "Hangzhou" },
411
- });
412
- const llm = { isLocal: true, chat: async () => ({ text: "你这个月去过 1 个地方。" }) };
413
- const skill = new FootprintSkill({ vault: rig.vault, llm });
414
- const r = await skill.run({});
415
- expect(r.llm_commentary).toBe("你这个月去过 1 个地方。");
416
- });
417
-
418
- it("non-local LLM gate → llm_commentary null", async () => {
419
- rig.vault.putEvent({
420
- id: "trip-1", type: "event", subtype: "trip",
421
- occurredAt: ts(2026, 4, 1),
422
- actor: "person-self",
423
- content: { title: "Beijing" },
424
- ingestedAt: Date.now(),
425
- source: defaultSource("travel"),
426
- extra: { to: "Beijing" },
427
- });
428
- const llm = { isLocal: false, chat: async () => ({ text: "should not appear" }) };
429
- const skill = new FootprintSkill({ vault: rig.vault, llm });
430
- const r = await skill.run({});
431
- expect(r.llm_commentary).toBeNull();
432
- });
433
-
434
- it("LLM exception swallowed → commentary null but data intact", async () => {
435
- rig.vault.putEvent({
436
- id: "trip-1", type: "event", subtype: "trip",
437
- occurredAt: ts(2026, 4, 1),
438
- actor: "person-self",
439
- content: { title: "Tokyo" },
440
- ingestedAt: Date.now(),
441
- source: defaultSource("travel"),
442
- extra: { to: "Tokyo" },
443
- });
444
- const llm = { isLocal: true, chat: async () => { throw new Error("net down"); } };
445
- const skill = new FootprintSkill({ vault: rig.vault, llm });
446
- const r = await skill.run({});
447
- expect(r.summary.totalTrips).toBe(1);
448
- expect(r.llm_commentary).toBeNull();
449
- });
450
- });
451
-
452
- // ─── InterestsSkill ──────────────────────────────────────────────────────
453
-
454
- describe("InterestsSkill", () => {
455
- let rig;
456
- beforeEach(() => { rig = makeVault(); });
457
- afterEach(() => cleanup(rig));
458
-
459
- it("returns topTopics + topItems (no LLM)", async () => {
460
- rig.vault.putTopic({
461
- id: "topic-coffee", type: "topic", name: "Coffee",
462
- derivedFromEvents: ["evt-1", "evt-2", "evt-3", "evt-4", "evt-5"],
463
- ingestedAt: Date.now(), source: defaultSource("test"),
464
- });
465
- rig.vault.putItem({
466
- id: "item-iphone", type: "item", subtype: "product",
467
- name: "iPhone 17",
468
- price: { value: 9999, currency: "CNY" },
469
- ingestedAt: Date.now(), source: defaultSource("test"),
470
- });
471
- const skill = new InterestsSkill({ vault: rig.vault });
472
- const r = await skill.run({});
473
- expect(r.topTopics.length).toBeGreaterThanOrEqual(1);
474
- expect(r.topTopics[0].name).toBe("Coffee");
475
- expect(r.topTopics[0].eventCount).toBe(5);
476
- expect(r.topItems.length).toBeGreaterThanOrEqual(1);
477
- expect(r.topItems[0].name).toBe("iPhone 17");
478
- expect(r.llmInterests).toBeNull();
479
- });
480
-
481
- it("LLM clustering parses JSON array response", async () => {
482
- rig.vault.putTopic({
483
- id: "topic-a", type: "topic", name: "Photography",
484
- derivedFromEvents: ["evt-1", "evt-2", "evt-3"],
485
- ingestedAt: Date.now(), source: defaultSource("test"),
486
- });
487
- const llm = {
488
- isLocal: true,
489
- chat: async () => ({
490
- text: '[{"category":"摄影","evidenceCount":3,"examples":["Photography topic"]}]',
491
- }),
492
- };
493
- const skill = new InterestsSkill({ vault: rig.vault, llm });
494
- const r = await skill.run({});
495
- expect(r.llmInterests).toHaveLength(1);
496
- expect(r.llmInterests[0].category).toBe("摄影");
497
- });
498
-
499
- it("empty vault → topTopics + topItems empty, no crash", async () => {
500
- const skill = new InterestsSkill({ vault: rig.vault });
501
- const r = await skill.run({});
502
- expect(r.topTopics).toEqual([]);
503
- expect(r.topItems).toEqual([]);
504
- expect(r.llmInterests).toBeNull();
505
- });
506
-
507
- it("non-local LLM gate → llmInterests null even with topics present", async () => {
508
- rig.vault.putTopic({
509
- id: "topic-b", type: "topic", name: "Cooking",
510
- derivedFromEvents: ["e-1"],
511
- ingestedAt: Date.now(), source: defaultSource("test"),
512
- });
513
- const llm = {
514
- isLocal: false,
515
- chat: async () => ({ text: '[{"category":"烹饪","evidenceCount":1,"examples":["Cooking"]}]' }),
516
- };
517
- const skill = new InterestsSkill({ vault: rig.vault, llm });
518
- const r = await skill.run({});
519
- expect(r.topTopics[0].name).toBe("Cooking");
520
- expect(r.llmInterests).toBeNull();
521
- });
522
-
523
- it("LLM clustering exception swallowed → llmInterests null but data intact", async () => {
524
- rig.vault.putTopic({
525
- id: "topic-c", type: "topic", name: "Travel",
526
- derivedFromEvents: ["e-1", "e-2"],
527
- ingestedAt: Date.now(), source: defaultSource("test"),
528
- });
529
- const llm = {
530
- isLocal: true,
531
- chat: async () => { throw new Error("vllm 500"); },
532
- };
533
- const skill = new InterestsSkill({ vault: rig.vault, llm });
534
- const r = await skill.run({});
535
- expect(r.topTopics[0].name).toBe("Travel");
536
- expect(r.llmInterests).toBeNull();
537
- });
538
-
539
- it("drops unresolved numeric group-id topics (e.g. WeChat chatroom ids) from the profile", async () => {
540
- // Real interest topic
541
- rig.vault.putTopic({
542
- id: "topic-doubao", type: "topic", name: "豆包",
543
- derivedFromEvents: ["e1"],
544
- ingestedAt: Date.now(), source: defaultSource("test"),
545
- });
546
- // Unresolved group-chat topics named by raw numeric chatroom id — noise.
547
- rig.vault.putTopic({
548
- id: "topic-g1", type: "topic", name: "45498354778",
549
- derivedFromEvents: [],
550
- ingestedAt: Date.now() + 1, source: defaultSource("test"),
551
- });
552
- rig.vault.putTopic({
553
- id: "topic-g2", type: "topic", name: "54346634535",
554
- derivedFromEvents: [],
555
- ingestedAt: Date.now() + 2, source: defaultSource("test"),
556
- });
557
- const skill = new InterestsSkill({ vault: rig.vault });
558
- const r = await skill.run({});
559
- const names = r.topTopics.map((t) => t.name);
560
- expect(names).toContain("豆包");
561
- expect(names).not.toContain("45498354778");
562
- expect(names).not.toContain("54346634535");
563
- });
564
- });
565
-
566
- // ─── TimelineSkill ──────────────────────────────────────────────────────
567
-
568
- describe("TimelineSkill", () => {
569
- let rig;
570
- beforeEach(() => { rig = makeVault(); });
571
- afterEach(() => cleanup(rig));
572
-
573
- it("returns chronological entries with snippet + adapter tag", async () => {
574
- makePayment(rig.vault, { id: "tl-1", occurredAt: ts(2026, 5, 1), counterpartyName: "美团", amount: 38, adapter: "alipay-bill", title: "外卖" });
575
- makePayment(rig.vault, { id: "tl-2", occurredAt: ts(2026, 5, 2), counterpartyName: "淘宝", amount: 199, adapter: "alipay-bill", title: "购物" });
576
- const skill = new TimelineSkill({ vault: rig.vault });
577
- const r = await skill.run({ since: ts(2026, 4, 1) });
578
- expect(r.entries.length).toBe(2);
579
- expect(r.entries[0].occurredAt).toBeLessThanOrEqual(r.entries[1].occurredAt); // chronological
580
- expect(r.entries[0].adapter).toBe("alipay-bill");
581
- expect(r.summary.totalEvents).toBe(2);
582
- });
583
-
584
- it("topicFilter narrows events", async () => {
585
- makePayment(rig.vault, { id: "tl-1", occurredAt: ts(2026, 5, 1), counterpartyName: "美团", amount: 38, title: "美团外卖订单" });
586
- makePayment(rig.vault, { id: "tl-2", occurredAt: ts(2026, 5, 2), counterpartyName: "淘宝", amount: 199, title: "运动鞋" });
587
- const skill = new TimelineSkill({ vault: rig.vault });
588
- const r = await skill.run({ since: ts(2026, 4, 1), topicFilter: "美团" });
589
- expect(r.entries.length).toBe(1);
590
- expect(r.entries[0].title).toBe("美团外卖订单");
591
- });
592
-
593
- it("LLM narrative fires when entries exist + LLM provided", async () => {
594
- makePayment(rig.vault, { id: "tl-1", occurredAt: ts(2026, 5, 1), counterpartyName: "美团", amount: 38, title: "外卖" });
595
- const llm = { isLocal: true, chat: async () => ({ text: "你这周点了一次外卖。" }) };
596
- const skill = new TimelineSkill({ vault: rig.vault, llm });
597
- const r = await skill.run({ since: ts(2026, 4, 1) });
598
- expect(r.llm_narrative).toBe("你这周点了一次外卖。");
599
- });
600
-
601
- it("excludes inventory-snapshot events (installed-app / contact roster) from the narrative", async () => {
602
- // Real activity event (extra has no `kind` → must be kept)
603
- makePayment(rig.vault, { id: "act-1", occurredAt: ts(2026, 5, 1), counterpartyName: "美团", amount: 10, adapter: "alipay-bill", title: "外卖" });
604
- // Inventory-snapshot events stamped at a LATER (collection) time — these
605
- // would dominate a DESC time query but must be filtered out.
606
- rig.vault.putEvent({
607
- id: "event-android-app-com.x", type: "event", subtype: "other",
608
- occurredAt: ts(2026, 6, 1), actor: "person-self",
609
- content: { title: "应用:X" },
610
- ingestedAt: Date.now(), source: defaultSource("system-data-android"),
611
- extra: { kind: "app-snapshot", packageName: "com.x" },
612
- });
613
- rig.vault.putEvent({
614
- id: "event-android-contact-y", type: "event", subtype: "other",
615
- occurredAt: ts(2026, 6, 1), actor: "person-self",
616
- content: { title: "联系人:Y" },
617
- ingestedAt: Date.now(), source: defaultSource("system-data-android"),
618
- extra: { kind: "contact-snapshot" },
619
- });
620
- // Aggregate-baseline event (douyin app-usage-profile) — a single rolling
621
- // summary, not a discrete activity, so it must be filtered from the timeline.
622
- rig.vault.putEvent({
623
- id: "event-douyin-usage", type: "event", subtype: "other",
624
- occurredAt: ts(2026, 6, 1), actor: "person-self",
625
- content: { title: "抖音使用画像:24天/108h" },
626
- ingestedAt: Date.now(), source: defaultSource("social-douyin"),
627
- extra: { kind: "app-usage-profile" },
628
- });
629
- const skill = new TimelineSkill({ vault: rig.vault });
630
- const r = await skill.run({ since: ts(2026, 4, 1) });
631
- const ids = r.entries.map((e) => e.id);
632
- expect(ids).toContain("act-1");
633
- expect(ids).not.toContain("event-android-app-com.x");
634
- expect(ids).not.toContain("event-android-contact-y");
635
- expect(ids).not.toContain("event-douyin-usage");
636
- expect(r.summary.totalEvents).toBe(1);
637
- });
638
- });
639
-
640
- // ─── runAnalysisSkill dispatcher ─────────────────────────────────────────
641
-
642
- describe("runAnalysisSkill", () => {
643
- let rig;
644
- beforeEach(() => { rig = makeVault(); });
645
- afterEach(() => cleanup(rig));
646
-
647
- it("routes by name", async () => {
648
- const r = await runAnalysisSkill({ vault: rig.vault }, "analysis.spending", {});
649
- expect(r.skill).toBe("analysis.spending");
650
- });
651
-
652
- it("throws on unknown skill", async () => {
653
- await expect(runAnalysisSkill({ vault: rig.vault }, "analysis.unknown", {})).rejects.toThrow();
654
- });
655
-
656
- it("requires vault in deps", async () => {
657
- await expect(runAnalysisSkill({}, "analysis.spending", {})).rejects.toThrow(/vault/);
658
- });
659
- });
660
-
661
- // ─── OverviewSkill (cross-app de-silo aggregation) ──────────────────────
662
- describe("OverviewSkill — cross-app unified snapshot", () => {
663
- let rig;
664
- beforeEach(() => { rig = makeVault(); });
665
- afterEach(() => cleanup(rig));
666
-
667
- function makeMsg(vault, opts) {
668
- vault.putEvent({
669
- id: opts.id, type: "event", subtype: opts.subtype || "message",
670
- occurredAt: opts.occurredAt, actor: opts.actor || "person-self",
671
- participants: opts.participants || [],
672
- content: { title: opts.title || "msg" },
673
- ingestedAt: Date.now(), source: defaultSource(opts.adapter || "test"),
674
- });
675
- }
676
-
677
- it("aggregates events/spend/contacts across multiple apps", async () => {
678
- const { vault } = rig;
679
- makePerson(vault, "p-friend", ["小明"], {}, { adapter: "wechat" });
680
- // payments from 2 finance/shopping apps
681
- makePayment(vault, { id: "e1", amount: 30, occurredAt: ts(2026, 5, 1), adapter: "alipay-bill", subtype: "payment" });
682
- makePayment(vault, { id: "e2", amount: 70, occurredAt: ts(2026, 5, 2), adapter: "shopping-taobao", subtype: "order" });
683
- // messages from 2 social/im apps, same friend
684
- makeMsg(vault, { id: "e3", occurredAt: ts(2026, 5, 3), adapter: "wechat", participants: ["p-friend"] });
685
- makeMsg(vault, { id: "e4", occurredAt: ts(2026, 5, 4), adapter: "social-douyin", participants: ["p-friend"] });
686
- makeMsg(vault, { id: "e5", occurredAt: ts(2026, 6, 1), adapter: "social-douyin", subtype: "post", participants: [] });
687
-
688
- const skill = new OverviewSkill({ vault });
689
- const r = await skill.run({ commentary: false });
690
-
691
- expect(r.summary.totalEvents).toBe(5);
692
- expect(r.summary.appsActive).toBe(4); // alipay-bill, shopping-taobao, wechat, social-douyin
693
- });
694
-
695
- it("byApp/byType/total use uncapped facetCounts, not the row-capped fetch", async () => {
696
- // queryEvents hard-caps at 10k rows; on a big vault one dominant app crowds
697
- // out the rest, so deriving byApp from the row fetch undercounts (real bug:
698
- // social-douyin showed 10 instead of 232). Fake a vault where the capped
699
- // row fetch and the SQL GROUP BY disagree, and assert overview trusts SQL.
700
- const fakeVault = {
701
- facetCounts: () => ({
702
- byAdapter: { "social-douyin": 232, "wechat-pc": 100000 },
703
- bySubtype: { browse: 232, message: 100000 },
704
- byCategory: {},
705
- total: 100232,
706
- mode: "like",
707
- shortQuery: false,
708
- }),
709
- // simulates the cap: only wechat rows survived the recent-10k window
710
- queryEvents: () => [
711
- { id: "w1", subtype: "message", occurredAt: ts(2026, 6, 1), actor: "person-self", source: { adapter: "wechat-pc" }, content: {} },
712
- ],
713
- };
714
- const r = await new OverviewSkill({ vault: fakeVault }).run({ commentary: false });
715
- const dy = r.byApp.find((a) => a.app === "social-douyin");
716
- expect(dy && dy.count).toBe(232); // would be absent/0 if derived from the row fetch
717
- expect(r.byApp[0].app).toBe("wechat-pc"); // 100000 sorts first
718
- expect(r.summary.totalEvents).toBe(100232);
719
- expect(r.summary.appsActive).toBe(2);
720
- expect(r.byType.find((t) => t.type === "browse").count).toBe(232);
721
- });
722
-
723
- it("counts 4 distinct apps + sums cross-app spend + top contact merged", async () => {
724
- const { vault } = rig;
725
- makePerson(vault, "p-friend", ["小明"], {}, { adapter: "wechat" });
726
- makePayment(vault, { id: "a", amount: 30, occurredAt: ts(2026, 5, 1), adapter: "alipay-bill", subtype: "payment" });
727
- makePayment(vault, { id: "b", amount: 70, occurredAt: ts(2026, 5, 2), adapter: "shopping-taobao", subtype: "order" });
728
- makeMsg(vault, { id: "c", occurredAt: ts(2026, 5, 3), adapter: "wechat", participants: ["p-friend"] });
729
- makeMsg(vault, { id: "d", occurredAt: ts(2026, 5, 4), adapter: "social-douyin", participants: ["p-friend"] });
730
-
731
- const r = await new OverviewSkill({ vault }).run({ commentary: false });
732
- const apps = r.byApp.map((x) => x.app).sort();
733
- expect(apps).toContain("wechat");
734
- expect(apps).toContain("social-douyin");
735
- expect(apps).toContain("alipay-bill");
736
- expect(apps).toContain("shopping-taobao");
737
- expect(r.summary.appsActive).toBe(4);
738
- expect(r.spending.total).toBe(100); // 30 + 70 across two apps
739
- // the friend appears in wechat + douyin → one merged top contact w/ byApp breakdown
740
- const friend = r.topContacts.find((c) => c.personId === "p-friend");
741
- expect(friend).toBeTruthy();
742
- expect(friend.interactions).toBe(2);
743
- expect(Object.keys(friend.byApp).sort()).toEqual(["social-douyin", "wechat"]);
744
- // byType has payment/order/message
745
- const types = r.byType.map((t) => t.type);
746
- expect(types).toContain("message");
747
- });
748
-
749
- it("is registered + runnable via runAnalysisSkill", async () => {
750
- expect(ANALYSIS_SKILL_NAMES).toContain("analysis.overview");
751
- const r = await runAnalysisSkill({ vault: rig.vault }, "analysis.overview", { commentary: false });
752
- expect(r.skill).toBe("analysis.overview");
753
- });
754
- });