@chainlesschain/personal-data-hub 0.4.29 → 0.4.30

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 (198) hide show
  1. package/lib/prompt-builder.js +15 -1
  2. package/package.json +4 -1
  3. package/__tests__/adapter-guide.test.js +0 -47
  4. package/__tests__/adapter-spec.test.js +0 -78
  5. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +0 -211
  6. package/__tests__/adapters/ai-chat-health-checker.test.js +0 -262
  7. package/__tests__/adapters/ai-chat-history.test.js +0 -396
  8. package/__tests__/adapters/ai-chat-http-client.test.js +0 -242
  9. package/__tests__/adapters/ai-chat-vendors.test.js +0 -874
  10. package/__tests__/adapters/alipay-bill-adapter.test.js +0 -538
  11. package/__tests__/adapters/apple-health.test.js +0 -95
  12. package/__tests__/adapters/bank-family.test.js +0 -125
  13. package/__tests__/adapters/biz-tianyancha.test.js +0 -159
  14. package/__tests__/adapters/browser-history-chrome.test.js +0 -377
  15. package/__tests__/adapters/browser-history-edge.test.js +0 -159
  16. package/__tests__/adapters/car-mercedesme.test.js +0 -74
  17. package/__tests__/adapters/doc-baidu-netdisk.test.js +0 -102
  18. package/__tests__/adapters/doc-camscanner.test.js +0 -147
  19. package/__tests__/adapters/doc-platforms.test.js +0 -177
  20. package/__tests__/adapters/edu-huawei-learning-live.test.js +0 -198
  21. package/__tests__/adapters/edu-zuoyebang-live.test.js +0 -226
  22. package/__tests__/adapters/email-adapter-snapshot.test.js +0 -237
  23. package/__tests__/adapters/email-adapter.test.js +0 -742
  24. package/__tests__/adapters/email-classifier.test.js +0 -347
  25. package/__tests__/adapters/email-imap-session.test.js +0 -334
  26. package/__tests__/adapters/email-parser.test.js +0 -244
  27. package/__tests__/adapters/email-pdf-extractor.test.js +0 -529
  28. package/__tests__/adapters/email-providers.test.js +0 -84
  29. package/__tests__/adapters/email-retry-progress.test.js +0 -294
  30. package/__tests__/adapters/email-templates.test.js +0 -822
  31. package/__tests__/adapters/family-23-collectors-scaffold.test.js +0 -182
  32. package/__tests__/adapters/finance-alipay-live.test.js +0 -258
  33. package/__tests__/adapters/finance-dcep.test.js +0 -74
  34. package/__tests__/adapters/fitness-joyrun.test.js +0 -82
  35. package/__tests__/adapters/game-genshin-live.test.js +0 -238
  36. package/__tests__/adapters/game-genshin-scaffold.test.js +0 -108
  37. package/__tests__/adapters/game-honor-of-kings-live.test.js +0 -230
  38. package/__tests__/adapters/git-activity.test.js +0 -222
  39. package/__tests__/adapters/gov-12123.test.js +0 -103
  40. package/__tests__/adapters/gov-ixiamen.test.js +0 -150
  41. package/__tests__/adapters/gov-tax.test.js +0 -135
  42. package/__tests__/adapters/health-meiyou.test.js +0 -125
  43. package/__tests__/adapters/local-files.test.js +0 -264
  44. package/__tests__/adapters/local-im-pc.test.js +0 -154
  45. package/__tests__/adapters/messaging-whatsapp.test.js +0 -289
  46. package/__tests__/adapters/music-kugou.test.js +0 -187
  47. package/__tests__/adapters/music-qq.test.js +0 -112
  48. package/__tests__/adapters/netease-music-live.test.js +0 -244
  49. package/__tests__/adapters/netease-music.test.js +0 -74
  50. package/__tests__/adapters/pc-local-discovery.test.js +0 -141
  51. package/__tests__/adapters/qq-pc-direct-read.test.js +0 -227
  52. package/__tests__/adapters/reading-family.test.js +0 -108
  53. package/__tests__/adapters/recruit-boss.test.js +0 -180
  54. package/__tests__/adapters/shell-history.test.js +0 -180
  55. package/__tests__/adapters/shopping-base.test.js +0 -179
  56. package/__tests__/adapters/shopping-dianping.test.js +0 -239
  57. package/__tests__/adapters/social-bilibili-adb-api-client.test.js +0 -721
  58. package/__tests__/adapters/social-bilibili-adb-chromium-cookies-reader.test.js +0 -346
  59. package/__tests__/adapters/social-bilibili-adb-collector.test.js +0 -284
  60. package/__tests__/adapters/social-bilibili-adb-cookies-extension.test.js +0 -343
  61. package/__tests__/adapters/social-bilibili-adb-snapshot-builder.test.js +0 -296
  62. package/__tests__/adapters/social-csdn.test.js +0 -175
  63. package/__tests__/adapters/social-dongchedi.test.js +0 -165
  64. package/__tests__/adapters/social-douyin-adb-aweme-detail.test.js +0 -165
  65. package/__tests__/adapters/social-douyin-adb-collector.test.js +0 -254
  66. package/__tests__/adapters/social-douyin-adb-db-extension.test.js +0 -114
  67. package/__tests__/adapters/social-douyin-adb-im-db-parser.test.js +0 -304
  68. package/__tests__/adapters/social-douyin-adb-snapshot-builder.test.js +0 -216
  69. package/__tests__/adapters/social-douyin-adb-usage-profile.test.js +0 -229
  70. package/__tests__/adapters/social-douyin-adb-watch-history.test.js +0 -269
  71. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +0 -496
  72. package/__tests__/adapters/social-kuaishou-adb-collector.test.js +0 -276
  73. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +0 -152
  74. package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +0 -178
  75. package/__tests__/adapters/social-toutiao-adb-account-reader.test.js +0 -135
  76. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +0 -626
  77. package/__tests__/adapters/social-toutiao-adb-article.test.js +0 -155
  78. package/__tests__/adapters/social-toutiao-adb-collector.test.js +0 -378
  79. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +0 -193
  80. package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +0 -196
  81. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +0 -311
  82. package/__tests__/adapters/social-weibo-adb-api-client.test.js +0 -362
  83. package/__tests__/adapters/social-weibo-adb-collector.test.js +0 -201
  84. package/__tests__/adapters/social-weibo-adb-cookies-extension.test.js +0 -167
  85. package/__tests__/adapters/social-weibo-adb-snapshot-builder.test.js +0 -189
  86. package/__tests__/adapters/social-xiaohongshu-adb-api-client.test.js +0 -431
  87. package/__tests__/adapters/social-xiaohongshu-adb-collector.test.js +0 -207
  88. package/__tests__/adapters/social-xiaohongshu-adb-cookies-extension.test.js +0 -0
  89. package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +0 -351
  90. package/__tests__/adapters/social-xiaohongshu-adb-sign.test.js +0 -130
  91. package/__tests__/adapters/social-xiaohongshu-adb-snapshot-builder.test.js +0 -200
  92. package/__tests__/adapters/social-zhihu.test.js +0 -246
  93. package/__tests__/adapters/system-data-adapter.test.js +0 -443
  94. package/__tests__/adapters/system-data-android-ingest.test.js +0 -144
  95. package/__tests__/adapters/system-data-android.test.js +0 -519
  96. package/__tests__/adapters/system-data-disclosure.test.js +0 -153
  97. package/__tests__/adapters/travel-12306.test.js +0 -512
  98. package/__tests__/adapters/travel-amap.test.js +0 -219
  99. package/__tests__/adapters/travel-baidu-map.test.js +0 -305
  100. package/__tests__/adapters/travel-base.test.js +0 -205
  101. package/__tests__/adapters/travel-ctrip.test.js +0 -377
  102. package/__tests__/adapters/travel-didi-consumer.test.js +0 -66
  103. package/__tests__/adapters/travel-didi.test.js +0 -204
  104. package/__tests__/adapters/travel-tencent-map.test.js +0 -207
  105. package/__tests__/adapters/travel-tongcheng.test.js +0 -289
  106. package/__tests__/adapters/video-platforms.test.js +0 -152
  107. package/__tests__/adapters/video-xigua.test.js +0 -106
  108. package/__tests__/adapters/vscode.test.js +0 -299
  109. package/__tests__/adapters/wechat-bootstrap.test.js +0 -240
  110. package/__tests__/adapters/wechat-env-probe.test.js +0 -162
  111. package/__tests__/adapters/wechat-frida-agent.test.js +0 -322
  112. package/__tests__/adapters/wechat-frida-integration.test.js +0 -149
  113. package/__tests__/adapters/wechat-frida-key-provider.test.js +0 -188
  114. package/__tests__/adapters/wechat-md5-key-provider.test.js +0 -101
  115. package/__tests__/adapters/wechat-pc-direct-read.test.js +0 -365
  116. package/__tests__/adapters/wechat-pc-group-topic.test.js +0 -63
  117. package/__tests__/adapters/wechat-pc-v4-sidecar.test.js +0 -72
  118. package/__tests__/adapters/weread.test.js +0 -123
  119. package/__tests__/adapters/wework-pc.test.js +0 -124
  120. package/__tests__/adapters/win-recent.test.js +0 -192
  121. package/__tests__/analysis-skills.test.js +0 -754
  122. package/__tests__/analysis.test.js +0 -1845
  123. package/__tests__/audio-ximalaya-snapshot.test.js +0 -279
  124. package/__tests__/batch.test.js +0 -133
  125. package/__tests__/bridges-cc-kg.test.js +0 -231
  126. package/__tests__/bridges-cc-llm.test.js +0 -191
  127. package/__tests__/bridges-cc-rag.test.js +0 -162
  128. package/__tests__/categories.test.js +0 -92
  129. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +0 -213
  130. package/__tests__/e2e/full-user-journey.test.js +0 -188
  131. package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +0 -146
  132. package/__tests__/entity-resolver-ingest-hook.test.js +0 -177
  133. package/__tests__/entity-resolver-stages.test.js +0 -411
  134. package/__tests__/entity-resolver-vault.test.js +0 -249
  135. package/__tests__/entity-resolver.test.js +0 -526
  136. package/__tests__/fitness-keep-snapshot.test.js +0 -224
  137. package/__tests__/fixtures/entity-resolver-200-mock.json +0 -96
  138. package/__tests__/ids.test.js +0 -45
  139. package/__tests__/integration/ai-chat-history-registry.test.js +0 -228
  140. package/__tests__/integration/aichat-wizard-end-to-end.test.js +0 -282
  141. package/__tests__/integration/cross-adapter-pipelines.test.js +0 -396
  142. package/__tests__/integration/local-data-adapters-pipeline.test.js +0 -373
  143. package/__tests__/integration/social-bilibili-pipeline.test.js +0 -261
  144. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +0 -390
  145. package/__tests__/key-providers.test.js +0 -126
  146. package/__tests__/kg-derive.test.js +0 -219
  147. package/__tests__/llm-client.test.js +0 -122
  148. package/__tests__/longtail-adapters.test.js +0 -281
  149. package/__tests__/messaging-qq-snapshot.test.js +0 -294
  150. package/__tests__/mobile-extractor-encrypted.test.js +0 -460
  151. package/__tests__/mobile-extractor.test.js +0 -288
  152. package/__tests__/mock-adapter.test.js +0 -93
  153. package/__tests__/prompt-builder.test.js +0 -249
  154. package/__tests__/query-parser.test.js +0 -365
  155. package/__tests__/rag-derive.test.js +0 -169
  156. package/__tests__/registry-readiness.test.js +0 -292
  157. package/__tests__/registry.test.js +0 -420
  158. package/__tests__/salvage-ingest.test.js +0 -97
  159. package/__tests__/schemas.test.js +0 -331
  160. package/__tests__/shopping-adapters.test.js +0 -392
  161. package/__tests__/shopping-eleme-snapshot.test.js +0 -454
  162. package/__tests__/shopping-pinduoduo-snapshot.test.js +0 -484
  163. package/__tests__/shopping-snapshot.test.js +0 -438
  164. package/__tests__/shopping-vipshop-snapshot.test.js +0 -425
  165. package/__tests__/shopping-xianyu-snapshot.test.js +0 -451
  166. package/__tests__/sidecar-contacts-cross-validate.test.js +0 -186
  167. package/__tests__/sidecar-supervisor.test.js +0 -128
  168. package/__tests__/sign-providers.test.js +0 -62
  169. package/__tests__/social-adapters.test.js +0 -280
  170. package/__tests__/social-bilibili-snapshot.test.js +0 -278
  171. package/__tests__/social-douban-snapshot.test.js +0 -351
  172. package/__tests__/social-douyin-im-direct-read.test.js +0 -377
  173. package/__tests__/social-douyin-salvage-collector.test.js +0 -98
  174. package/__tests__/social-douyin-salvage-mapper.test.js +0 -90
  175. package/__tests__/social-douyin-snapshot.test.js +0 -256
  176. package/__tests__/social-kuaishou-snapshot.test.js +0 -362
  177. package/__tests__/social-toutiao-snapshot.test.js +0 -366
  178. package/__tests__/social-weibo-snapshot.test.js +0 -234
  179. package/__tests__/social-weibo-sqlite-device.test.js +0 -174
  180. package/__tests__/social-xiaohongshu-snapshot.test.js +0 -232
  181. package/__tests__/sqlite-leaf-salvage.test.js +0 -97
  182. package/__tests__/travel-adapters.test.js +0 -483
  183. package/__tests__/travel-maps-snapshot.test.js +0 -426
  184. package/__tests__/vault-driver-error.test.js +0 -74
  185. package/__tests__/vault-search-helpers.test.js +0 -104
  186. package/__tests__/vault-search.test.js +0 -423
  187. package/__tests__/vault.test.js +0 -767
  188. package/__tests__/wechat-adapter.test.js +0 -594
  189. package/__tests__/whatsapp-adapter.test.js +0 -138
  190. package/scripts/_make-fixture-all.js +0 -126
  191. package/scripts/_make-fixture-contacts.js +0 -84
  192. package/scripts/evaluate-entity-resolver.js +0 -213
  193. package/scripts/run-native-tests-sandbox.sh +0 -55
  194. package/scripts/smoke-phase-5-5.js +0 -196
  195. package/scripts/smoke-phase-5-7.js +0 -181
  196. package/scripts/smoke-system-data-contacts.js +0 -309
  197. package/scripts/smoke-system-data.js +0 -312
  198. 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
- });