@chainlesschain/personal-data-hub 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/__tests__/adapters/ai-chat-history.test.js +395 -0
  2. package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
  3. package/__tests__/adapters/ai-chat-vendors.test.js +733 -0
  4. package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
  5. package/__tests__/adapters/email-adapter.test.js +138 -1
  6. package/__tests__/adapters/email-classifier.test.js +347 -0
  7. package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
  8. package/__tests__/adapters/email-retry-progress.test.js +294 -0
  9. package/__tests__/adapters/email-templates.test.js +699 -0
  10. package/__tests__/adapters/system-data-adapter.test.js +440 -0
  11. package/__tests__/adapters/system-data-disclosure.test.js +153 -0
  12. package/__tests__/analysis-skills.test.js +409 -0
  13. package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
  14. package/__tests__/entity-resolver-stages.test.js +411 -0
  15. package/__tests__/entity-resolver-vault.test.js +246 -0
  16. package/__tests__/entity-resolver.test.js +526 -0
  17. package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
  18. package/__tests__/longtail-adapters.test.js +217 -0
  19. package/__tests__/mobile-extractor.test.js +288 -0
  20. package/__tests__/shopping-adapters.test.js +296 -0
  21. package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
  22. package/__tests__/sidecar-supervisor.test.js +120 -0
  23. package/__tests__/social-adapters.test.js +206 -0
  24. package/__tests__/travel-adapters.test.js +325 -0
  25. package/__tests__/vault.test.js +3 -3
  26. package/__tests__/wechat-adapter.test.js +476 -0
  27. package/__tests__/whatsapp-adapter.test.js +135 -0
  28. package/lib/adapter-spec.js +12 -0
  29. package/lib/adapters/_python-sidecar-base.js +207 -0
  30. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +335 -0
  31. package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
  32. package/lib/adapters/ai-chat-history/http-client.js +211 -0
  33. package/lib/adapters/ai-chat-history/index.js +28 -0
  34. package/lib/adapters/ai-chat-history/schema-map.js +221 -0
  35. package/lib/adapters/ai-chat-history/vendor-spec.js +85 -0
  36. package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
  37. package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
  38. package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
  39. package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
  40. package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
  41. package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
  42. package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
  43. package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
  44. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +307 -0
  45. package/lib/adapters/alipay-bill/counterparty.js +129 -0
  46. package/lib/adapters/alipay-bill/csv-parser.js +217 -0
  47. package/lib/adapters/alipay-bill/index.js +41 -0
  48. package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
  49. package/lib/adapters/email-imap/classifier.js +495 -0
  50. package/lib/adapters/email-imap/email-adapter.js +419 -8
  51. package/lib/adapters/email-imap/index.js +42 -0
  52. package/lib/adapters/email-imap/pdf-extractor.js +192 -0
  53. package/lib/adapters/email-imap/templates/bill.js +232 -0
  54. package/lib/adapters/email-imap/templates/government.js +120 -0
  55. package/lib/adapters/email-imap/templates/index.js +78 -0
  56. package/lib/adapters/email-imap/templates/order.js +186 -0
  57. package/lib/adapters/email-imap/templates/other.js +114 -0
  58. package/lib/adapters/email-imap/templates/register.js +113 -0
  59. package/lib/adapters/email-imap/templates/travel.js +157 -0
  60. package/lib/adapters/email-imap/templates/utils.js +275 -0
  61. package/lib/adapters/email-imap/transactions.js +234 -0
  62. package/lib/adapters/messaging-qq/index.js +158 -0
  63. package/lib/adapters/messaging-telegram/index.js +142 -0
  64. package/lib/adapters/messaging-whatsapp/index.js +189 -0
  65. package/lib/adapters/shopping-base/index.js +208 -0
  66. package/lib/adapters/shopping-jd/index.js +150 -0
  67. package/lib/adapters/shopping-meituan/index.js +154 -0
  68. package/lib/adapters/shopping-taobao/index.js +176 -0
  69. package/lib/adapters/social-bilibili/index.js +171 -0
  70. package/lib/adapters/social-douyin/index.js +116 -0
  71. package/lib/adapters/social-weibo/index.js +164 -0
  72. package/lib/adapters/social-xiaohongshu/index.js +96 -0
  73. package/lib/adapters/system-data/disclosure.js +166 -0
  74. package/lib/adapters/system-data/index.js +34 -0
  75. package/lib/adapters/system-data/system-data-adapter.js +344 -0
  76. package/lib/adapters/travel-12306/index.js +151 -0
  77. package/lib/adapters/travel-amap/index.js +164 -0
  78. package/lib/adapters/travel-baidu-map/index.js +162 -0
  79. package/lib/adapters/travel-base/index.js +240 -0
  80. package/lib/adapters/travel-ctrip/index.js +151 -0
  81. package/lib/adapters/wechat/content-parser.js +326 -0
  82. package/lib/adapters/wechat/db-reader.js +209 -0
  83. package/lib/adapters/wechat/index.js +28 -0
  84. package/lib/adapters/wechat/key-extractor.js +158 -0
  85. package/lib/adapters/wechat/normalize.js +220 -0
  86. package/lib/adapters/wechat/wechat-adapter.js +205 -0
  87. package/lib/analysis-skills/base.js +113 -0
  88. package/lib/analysis-skills/footprint.js +167 -0
  89. package/lib/analysis-skills/index.js +58 -0
  90. package/lib/analysis-skills/interests.js +161 -0
  91. package/lib/analysis-skills/relations.js +226 -0
  92. package/lib/analysis-skills/spending.js +216 -0
  93. package/lib/analysis-skills/timeline.js +167 -0
  94. package/lib/entity-resolver/embedding-stage.js +198 -0
  95. package/lib/entity-resolver/entity-resolver.js +384 -0
  96. package/lib/entity-resolver/index.js +42 -0
  97. package/lib/entity-resolver/llm-stage.js +191 -0
  98. package/lib/entity-resolver/rule-stage.js +208 -0
  99. package/lib/entity-resolver/worker.js +149 -0
  100. package/lib/index.js +115 -0
  101. package/lib/migrations.js +73 -0
  102. package/lib/mobile-extractor/android.js +193 -0
  103. package/lib/mobile-extractor/index.js +9 -0
  104. package/lib/mobile-extractor/ios.js +223 -0
  105. package/lib/registry.js +42 -0
  106. package/lib/sidecar/index.js +15 -0
  107. package/lib/sidecar/supervisor.js +359 -0
  108. package/lib/vault.js +266 -0
  109. package/package.json +29 -3
  110. package/scripts/_make-fixture-all.js +126 -0
  111. package/scripts/_make-fixture-contacts.js +84 -0
  112. package/scripts/evaluate-entity-resolver.js +213 -0
  113. package/scripts/smoke-phase-5-5.js +196 -0
  114. package/scripts/smoke-phase-5-7.js +181 -0
  115. package/scripts/smoke-system-data-contacts.js +309 -0
  116. package/scripts/smoke-system-data.js +312 -0
@@ -0,0 +1,409 @@
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
+ runAnalysisSkill,
18
+ ANALYSIS_SKILL_NAMES,
19
+ } = require("../lib/analysis-skills");
20
+
21
+ // ─── Test fixtures ──────────────────────────────────────────────────────
22
+
23
+ function makeVault() {
24
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "hub-skill-"));
25
+ const vault = new LocalVault({ path: path.join(dir, "v.db"), key: generateKeyHex() });
26
+ vault.open();
27
+ return { vault, dir };
28
+ }
29
+
30
+ function cleanup({ vault, dir }) {
31
+ try { vault.close(); } catch (_e) {}
32
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {}
33
+ }
34
+
35
+ function defaultSource(adapter = "test") {
36
+ return {
37
+ adapter, adapterVersion: "0.1",
38
+ originalId: "tx-" + Math.random().toString(36).slice(2, 10),
39
+ capturedAt: Date.now(), capturedBy: "api",
40
+ };
41
+ }
42
+
43
+ function makePerson(vault, id, names, identifiers = {}, opts = {}) {
44
+ vault.putPerson({
45
+ id, type: "person", subtype: opts.subtype || "contact",
46
+ names, identifiers, ingestedAt: Date.now(),
47
+ source: defaultSource(opts.adapter || "test"),
48
+ });
49
+ }
50
+
51
+ function makePayment(vault, opts) {
52
+ const participants = [];
53
+ if (opts.counterpartyId) participants.push(opts.counterpartyId);
54
+ participants.push("person-self");
55
+ vault.putEvent({
56
+ id: opts.id,
57
+ type: "event",
58
+ subtype: opts.subtype || "payment",
59
+ occurredAt: opts.occurredAt,
60
+ actor: opts.actor || "person-self",
61
+ participants,
62
+ content: {
63
+ title: opts.title || "(no title)",
64
+ amount: { value: opts.amount, currency: "CNY", direction: opts.direction || "out" },
65
+ },
66
+ ingestedAt: Date.now(),
67
+ source: defaultSource(opts.adapter || "test"),
68
+ extra: {
69
+ counterparty: opts.counterpartyName,
70
+ ...(opts.category ? { category: opts.category } : {}),
71
+ ...(opts.extra || {}),
72
+ },
73
+ });
74
+ }
75
+
76
+ function ts(year, month, day) {
77
+ return new Date(year, month - 1, day).getTime();
78
+ }
79
+
80
+ // ─── AnalysisSkill base ─────────────────────────────────────────────────
81
+
82
+ describe("AnalysisSkill base", () => {
83
+ it("requires vault", () => {
84
+ expect(() => new AnalysisSkill()).toThrow();
85
+ expect(() => new AnalysisSkill({})).toThrow(/vault/);
86
+ });
87
+
88
+ it("resolveTimeWindow handles since/until pair", () => {
89
+ const skill = new AnalysisSkill({ vault: { dummy: true } });
90
+ const r = skill.resolveTimeWindow({ since: 1000, until: 2000 });
91
+ expect(r.since).toBe(1000);
92
+ expect(r.until).toBe(2000);
93
+ });
94
+
95
+ it("resolveTimeWindow handles sinceDays", () => {
96
+ const skill = new AnalysisSkill({ vault: { dummy: true } });
97
+ const r = skill.resolveTimeWindow({ sinceDays: 7 });
98
+ const days7Ms = 7 * 24 * 3600_000;
99
+ expect(Date.now() - r.since).toBeGreaterThanOrEqual(days7Ms - 1000);
100
+ expect(Date.now() - r.since).toBeLessThanOrEqual(days7Ms + 1000);
101
+ });
102
+
103
+ it("resolveTimeWindow returns null window for all-time", () => {
104
+ const skill = new AnalysisSkill({ vault: { dummy: true } });
105
+ expect(skill.resolveTimeWindow({}).since).toBeNull();
106
+ });
107
+
108
+ it("ANALYSIS_SKILL_NAMES lists exactly 5", () => {
109
+ expect(ANALYSIS_SKILL_NAMES).toHaveLength(5);
110
+ expect(ANALYSIS_SKILL_NAMES).toContain("analysis.spending");
111
+ expect(ANALYSIS_SKILL_NAMES).toContain("analysis.relations");
112
+ expect(ANALYSIS_SKILL_NAMES).toContain("analysis.footprint");
113
+ expect(ANALYSIS_SKILL_NAMES).toContain("analysis.interests");
114
+ expect(ANALYSIS_SKILL_NAMES).toContain("analysis.timeline");
115
+ });
116
+
117
+ it("base.run() throws (subclasses must override)", async () => {
118
+ const skill = new AnalysisSkill({ vault: { dummy: true } });
119
+ await expect(skill.run()).rejects.toThrow();
120
+ });
121
+ });
122
+
123
+ // ─── SpendingSkill ───────────────────────────────────────────────────────
124
+
125
+ describe("SpendingSkill", () => {
126
+ let rig;
127
+ beforeEach(() => { rig = makeVault(); });
128
+ afterEach(() => cleanup(rig));
129
+
130
+ function setupAlipayPayments() {
131
+ makePerson(rig.vault, "p-meituan", ["美团"], {}, { subtype: "merchant" });
132
+ makePerson(rig.vault, "p-tb", ["淘宝"], {}, { subtype: "merchant" });
133
+ makePerson(rig.vault, "p-jd", ["京东"], {}, { subtype: "merchant" });
134
+ makePayment(rig.vault, { id: "evt-1", occurredAt: ts(2026, 4, 1), counterpartyName: "美团", counterpartyId: "p-meituan", amount: 38.50, adapter: "alipay", title: "美团外卖" });
135
+ makePayment(rig.vault, { id: "evt-2", occurredAt: ts(2026, 4, 15), counterpartyName: "美团", counterpartyId: "p-meituan", amount: 25.00, adapter: "alipay", title: "美团外卖" });
136
+ makePayment(rig.vault, { id: "evt-3", occurredAt: ts(2026, 4, 20), counterpartyName: "淘宝", counterpartyId: "p-tb", amount: 299.00, adapter: "alipay", title: "运动鞋" });
137
+ 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" });
138
+ // Refund
139
+ 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" });
140
+ }
141
+
142
+ it("aggregates total spend across all events", async () => {
143
+ setupAlipayPayments();
144
+ const skill = new SpendingSkill({ vault: rig.vault });
145
+ const r = await skill.run({});
146
+ expect(r.summary.totalSpend).toBeCloseTo(38.5 + 25 + 299 + 999, 2);
147
+ expect(r.summary.totalIncome).toBe(50);
148
+ expect(r.summary.eventCount).toBe(5);
149
+ expect(r.summary.currency).toBe("CNY");
150
+ });
151
+
152
+ it("breakdown by merchant ranks top spenders", async () => {
153
+ setupAlipayPayments();
154
+ const skill = new SpendingSkill({ vault: rig.vault });
155
+ const r = await skill.run({ dimension: "merchant" });
156
+ expect(r.breakdown[0].key).toBe("京东");
157
+ expect(r.breakdown[0].totalSpend).toBe(999);
158
+ expect(r.breakdown[1].key).toBe("淘宝");
159
+ expect(r.breakdown[1].totalSpend).toBe(299);
160
+ });
161
+
162
+ it("merchantFilter scopes to subset", async () => {
163
+ setupAlipayPayments();
164
+ const skill = new SpendingSkill({ vault: rig.vault });
165
+ const r = await skill.run({ merchantFilter: "美团" });
166
+ expect(r.summary.eventCount).toBe(2);
167
+ expect(r.summary.totalSpend).toBeCloseTo(63.5, 2);
168
+ });
169
+
170
+ it("time window filters events", async () => {
171
+ setupAlipayPayments();
172
+ const skill = new SpendingSkill({ vault: rig.vault });
173
+ const r = await skill.run({ since: ts(2026, 4, 1), until: ts(2026, 5, 1) });
174
+ // Excludes evt-4 (May)
175
+ expect(r.summary.eventCount).toBe(4);
176
+ });
177
+
178
+ it("trend returns monthly buckets", async () => {
179
+ setupAlipayPayments();
180
+ const skill = new SpendingSkill({ vault: rig.vault });
181
+ const r = await skill.run({});
182
+ expect(r.trend.length).toBeGreaterThanOrEqual(2);
183
+ expect(r.trend[0].monthKey).toBe("2026-04");
184
+ expect(r.trend[1].monthKey).toBe("2026-05");
185
+ });
186
+
187
+ it("LLM commentary fires when LLM provided", async () => {
188
+ setupAlipayPayments();
189
+ const llm = { isLocal: true, chat: async () => ({ text: "测试 commentary" }) };
190
+ const skill = new SpendingSkill({ vault: rig.vault, llm });
191
+ const r = await skill.run({});
192
+ expect(r.llm_commentary).toBe("测试 commentary");
193
+ });
194
+
195
+ it("no LLM → commentary is null", async () => {
196
+ setupAlipayPayments();
197
+ const skill = new SpendingSkill({ vault: rig.vault });
198
+ const r = await skill.run({});
199
+ expect(r.llm_commentary).toBeNull();
200
+ });
201
+
202
+ it("empty vault → zero summary, no breakdown", async () => {
203
+ const skill = new SpendingSkill({ vault: rig.vault });
204
+ const r = await skill.run({});
205
+ expect(r.summary.totalSpend).toBe(0);
206
+ expect(r.breakdown).toEqual([]);
207
+ });
208
+
209
+ it("non-local LLM without acceptNonLocal → commentary suppressed", async () => {
210
+ setupAlipayPayments();
211
+ const llm = { isLocal: false, chat: async () => { throw new Error("should not call"); } };
212
+ const skill = new SpendingSkill({ vault: rig.vault, llm });
213
+ const r = await skill.run({});
214
+ expect(r.llm_commentary).toBeNull();
215
+ });
216
+ });
217
+
218
+ // ─── RelationsSkill ──────────────────────────────────────────────────────
219
+
220
+ describe("RelationsSkill", () => {
221
+ let rig;
222
+ beforeEach(() => { rig = makeVault(); });
223
+ afterEach(() => cleanup(rig));
224
+
225
+ it("single person mode: aggregates interactions with one person", async () => {
226
+ makePerson(rig.vault, "p-mom", ["妈"]);
227
+ makePayment(rig.vault, { id: "e1", occurredAt: ts(2026, 4, 1), counterpartyId: "p-mom", counterpartyName: "妈", amount: 500 });
228
+ makePayment(rig.vault, { id: "e2", occurredAt: ts(2026, 5, 1), counterpartyId: "p-mom", counterpartyName: "妈", amount: 300 });
229
+ makePayment(rig.vault, { id: "e3", occurredAt: ts(2026, 5, 5), counterpartyId: "p-other", counterpartyName: "其他", amount: 100 });
230
+
231
+ const skill = new RelationsSkill({ vault: rig.vault });
232
+ const r = await skill.run({ personId: "p-mom" });
233
+ expect(r.mode).toBe("single");
234
+ expect(r.profile.totalInteractions).toBe(2);
235
+ expect(r.profile.totalSpend).toBe(800);
236
+ expect(r.profile.names).toContain("妈");
237
+ });
238
+
239
+ it("ranked mode: returns top counterparties", async () => {
240
+ makePerson(rig.vault, "p-mom", ["妈"]);
241
+ makePerson(rig.vault, "p-dad", ["爸"]);
242
+ makePayment(rig.vault, { id: "e1", occurredAt: ts(2026, 4, 1), counterpartyId: "p-mom", counterpartyName: "妈", amount: 500 });
243
+ makePayment(rig.vault, { id: "e2", occurredAt: ts(2026, 4, 2), counterpartyId: "p-mom", counterpartyName: "妈", amount: 200 });
244
+ makePayment(rig.vault, { id: "e3", occurredAt: ts(2026, 4, 3), counterpartyId: "p-dad", counterpartyName: "爸", amount: 100 });
245
+
246
+ const skill = new RelationsSkill({ vault: rig.vault });
247
+ const r = await skill.run({});
248
+ expect(r.mode).toBe("ranked");
249
+ expect(r.ranked.length).toBeGreaterThanOrEqual(2);
250
+ expect(r.ranked[0].personId).toBe("p-mom");
251
+ expect(r.ranked[0].totalInteractions).toBe(2);
252
+ });
253
+ });
254
+
255
+ // ─── FootprintSkill ──────────────────────────────────────────────────────
256
+
257
+ describe("FootprintSkill", () => {
258
+ let rig;
259
+ beforeEach(() => { rig = makeVault(); });
260
+ afterEach(() => cleanup(rig));
261
+
262
+ it("returns top places + monthly distribution", async () => {
263
+ rig.vault.putEvent({
264
+ id: "trip-1", type: "event", subtype: "trip",
265
+ occurredAt: ts(2026, 4, 1),
266
+ actor: "person-self",
267
+ content: { title: "Beijing trip" },
268
+ ingestedAt: Date.now(),
269
+ source: defaultSource("travel"),
270
+ extra: { from: "Shanghai", to: "Beijing" },
271
+ });
272
+ rig.vault.putEvent({
273
+ id: "trip-2", type: "event", subtype: "trip",
274
+ occurredAt: ts(2026, 4, 15),
275
+ actor: "person-self",
276
+ content: { title: "Beijing trip 2" },
277
+ ingestedAt: Date.now(),
278
+ source: defaultSource("travel"),
279
+ extra: { from: "Shanghai", to: "Beijing" },
280
+ });
281
+ rig.vault.putEvent({
282
+ id: "trip-3", type: "event", subtype: "trip",
283
+ occurredAt: ts(2026, 5, 1),
284
+ actor: "person-self",
285
+ content: { title: "Hangzhou trip" },
286
+ ingestedAt: Date.now(),
287
+ source: defaultSource("travel"),
288
+ extra: { to: "Hangzhou" },
289
+ });
290
+
291
+ const skill = new FootprintSkill({ vault: rig.vault });
292
+ const r = await skill.run({});
293
+ expect(r.summary.totalTrips).toBeGreaterThan(0);
294
+ expect(r.topPlaces[0].name).toBeDefined();
295
+ expect(r.monthlyDistribution.length).toBeGreaterThan(0);
296
+ });
297
+
298
+ it("empty vault → zero trips", async () => {
299
+ const skill = new FootprintSkill({ vault: rig.vault });
300
+ const r = await skill.run({});
301
+ expect(r.summary.totalTrips).toBe(0);
302
+ expect(r.topPlaces).toEqual([]);
303
+ });
304
+ });
305
+
306
+ // ─── InterestsSkill ──────────────────────────────────────────────────────
307
+
308
+ describe("InterestsSkill", () => {
309
+ let rig;
310
+ beforeEach(() => { rig = makeVault(); });
311
+ afterEach(() => cleanup(rig));
312
+
313
+ it("returns topTopics + topItems (no LLM)", async () => {
314
+ rig.vault.putTopic({
315
+ id: "topic-coffee", type: "topic", name: "Coffee",
316
+ derivedFromEvents: ["evt-1", "evt-2", "evt-3", "evt-4", "evt-5"],
317
+ ingestedAt: Date.now(), source: defaultSource("test"),
318
+ });
319
+ rig.vault.putItem({
320
+ id: "item-iphone", type: "item", subtype: "product",
321
+ name: "iPhone 17",
322
+ price: { value: 9999, currency: "CNY" },
323
+ ingestedAt: Date.now(), source: defaultSource("test"),
324
+ });
325
+ const skill = new InterestsSkill({ vault: rig.vault });
326
+ const r = await skill.run({});
327
+ expect(r.topTopics.length).toBeGreaterThanOrEqual(1);
328
+ expect(r.topTopics[0].name).toBe("Coffee");
329
+ expect(r.topTopics[0].eventCount).toBe(5);
330
+ expect(r.topItems.length).toBeGreaterThanOrEqual(1);
331
+ expect(r.topItems[0].name).toBe("iPhone 17");
332
+ expect(r.llmInterests).toBeNull();
333
+ });
334
+
335
+ it("LLM clustering parses JSON array response", async () => {
336
+ rig.vault.putTopic({
337
+ id: "topic-a", type: "topic", name: "Photography",
338
+ derivedFromEvents: ["evt-1", "evt-2", "evt-3"],
339
+ ingestedAt: Date.now(), source: defaultSource("test"),
340
+ });
341
+ const llm = {
342
+ isLocal: true,
343
+ chat: async () => ({
344
+ text: '[{"category":"摄影","evidenceCount":3,"examples":["Photography topic"]}]',
345
+ }),
346
+ };
347
+ const skill = new InterestsSkill({ vault: rig.vault, llm });
348
+ const r = await skill.run({});
349
+ expect(r.llmInterests).toHaveLength(1);
350
+ expect(r.llmInterests[0].category).toBe("摄影");
351
+ });
352
+ });
353
+
354
+ // ─── TimelineSkill ──────────────────────────────────────────────────────
355
+
356
+ describe("TimelineSkill", () => {
357
+ let rig;
358
+ beforeEach(() => { rig = makeVault(); });
359
+ afterEach(() => cleanup(rig));
360
+
361
+ it("returns chronological entries with snippet + adapter tag", async () => {
362
+ makePayment(rig.vault, { id: "tl-1", occurredAt: ts(2026, 5, 1), counterpartyName: "美团", amount: 38, adapter: "alipay-bill", title: "外卖" });
363
+ makePayment(rig.vault, { id: "tl-2", occurredAt: ts(2026, 5, 2), counterpartyName: "淘宝", amount: 199, adapter: "alipay-bill", title: "购物" });
364
+ const skill = new TimelineSkill({ vault: rig.vault });
365
+ const r = await skill.run({ since: ts(2026, 4, 1) });
366
+ expect(r.entries.length).toBe(2);
367
+ expect(r.entries[0].occurredAt).toBeLessThanOrEqual(r.entries[1].occurredAt); // chronological
368
+ expect(r.entries[0].adapter).toBe("alipay-bill");
369
+ expect(r.summary.totalEvents).toBe(2);
370
+ });
371
+
372
+ it("topicFilter narrows events", async () => {
373
+ makePayment(rig.vault, { id: "tl-1", occurredAt: ts(2026, 5, 1), counterpartyName: "美团", amount: 38, title: "美团外卖订单" });
374
+ makePayment(rig.vault, { id: "tl-2", occurredAt: ts(2026, 5, 2), counterpartyName: "淘宝", amount: 199, title: "运动鞋" });
375
+ const skill = new TimelineSkill({ vault: rig.vault });
376
+ const r = await skill.run({ since: ts(2026, 4, 1), topicFilter: "美团" });
377
+ expect(r.entries.length).toBe(1);
378
+ expect(r.entries[0].title).toBe("美团外卖订单");
379
+ });
380
+
381
+ it("LLM narrative fires when entries exist + LLM provided", async () => {
382
+ makePayment(rig.vault, { id: "tl-1", occurredAt: ts(2026, 5, 1), counterpartyName: "美团", amount: 38, title: "外卖" });
383
+ const llm = { isLocal: true, chat: async () => ({ text: "你这周点了一次外卖。" }) };
384
+ const skill = new TimelineSkill({ vault: rig.vault, llm });
385
+ const r = await skill.run({ since: ts(2026, 4, 1) });
386
+ expect(r.llm_narrative).toBe("你这周点了一次外卖。");
387
+ });
388
+ });
389
+
390
+ // ─── runAnalysisSkill dispatcher ─────────────────────────────────────────
391
+
392
+ describe("runAnalysisSkill", () => {
393
+ let rig;
394
+ beforeEach(() => { rig = makeVault(); });
395
+ afterEach(() => cleanup(rig));
396
+
397
+ it("routes by name", async () => {
398
+ const r = await runAnalysisSkill({ vault: rig.vault }, "analysis.spending", {});
399
+ expect(r.skill).toBe("analysis.spending");
400
+ });
401
+
402
+ it("throws on unknown skill", async () => {
403
+ await expect(runAnalysisSkill({ vault: rig.vault }, "analysis.unknown", {})).rejects.toThrow();
404
+ });
405
+
406
+ it("requires vault in deps", async () => {
407
+ await expect(runAnalysisSkill({}, "analysis.spending", {})).rejects.toThrow(/vault/);
408
+ });
409
+ });
@@ -0,0 +1,177 @@
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 { AdapterRegistry } = require("../lib/registry");
11
+ const { EntityResolver } = require("../lib/entity-resolver");
12
+
13
+ // Minimal adapter that yields raw rows then normalizes to UnifiedSchema
14
+ class FakeAdapter {
15
+ constructor(name, persons) {
16
+ this.name = name;
17
+ this.version = "0.1.0";
18
+ this.capabilities = ["sync:test"];
19
+ this.rateLimits = {};
20
+ this.dataDisclosure = { fields: [], sensitivity: "low", legalGate: false };
21
+ this._persons = persons;
22
+ }
23
+ async authenticate() { return { ok: true }; }
24
+ async healthCheck() { return { ok: true, lastChecked: Date.now() }; }
25
+ async *sync() {
26
+ for (const p of this._persons) {
27
+ yield { adapter: this.name, originalId: p.id, capturedAt: Date.now(), payload: { person: p } };
28
+ }
29
+ }
30
+ normalize(raw) {
31
+ const p = raw.payload.person;
32
+ const now = Date.now();
33
+ const source = {
34
+ adapter: this.name, adapterVersion: this.version,
35
+ originalId: raw.originalId, capturedAt: raw.capturedAt, capturedBy: "api",
36
+ };
37
+ return {
38
+ events: [{
39
+ id: `evt-${raw.originalId}`,
40
+ type: "event",
41
+ subtype: "interaction",
42
+ occurredAt: now,
43
+ actor: p.id,
44
+ ingestedAt: now,
45
+ source,
46
+ }],
47
+ persons: [{
48
+ id: p.id,
49
+ type: "person",
50
+ subtype: "contact",
51
+ names: p.names || [],
52
+ identifiers: p.identifiers || {},
53
+ ingestedAt: now,
54
+ source,
55
+ }],
56
+ places: [], items: [], topics: [],
57
+ };
58
+ }
59
+ }
60
+
61
+ function makeRig() {
62
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "er-hook-"));
63
+ const vault = new LocalVault({ path: path.join(dir, "v.db"), key: generateKeyHex() });
64
+ vault.open();
65
+ const resolver = new EntityResolver({ vault });
66
+ const registry = new AdapterRegistry({ vault, entityResolver: resolver });
67
+ return { vault, resolver, registry, dir };
68
+ }
69
+
70
+ function cleanup({ vault, dir }) {
71
+ try { vault.close(); } catch (_e) {}
72
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {}
73
+ }
74
+
75
+ // ─── tests ────────────────────────────────────────────────────────────────
76
+
77
+ describe("Phase 8.6 — registry → entity resolver ingest hook", () => {
78
+ let rig;
79
+ afterEach(() => cleanup(rig));
80
+
81
+ it("registry.syncAdapter fires resolveOnIngest with new Persons", async () => {
82
+ rig = makeRig();
83
+ const adapter = new FakeAdapter("a1", [
84
+ { id: "p-1", names: ["Alice"], identifiers: { email: ["alice@x.com"] } },
85
+ { id: "p-2", names: ["Bob"], identifiers: { email: ["bob@x.com"] } },
86
+ ]);
87
+ rig.registry.register(adapter);
88
+ const report = await rig.registry.syncAdapter("a1");
89
+ expect(report.status).toBe("ok");
90
+ expect(report.entityResolver).toBeDefined();
91
+ expect(report.entityResolver.newPersons).toBe(2);
92
+ });
93
+
94
+ it("cross-adapter R1 match: same email → auto-merged", async () => {
95
+ rig = makeRig();
96
+ // Adapter 1 imports Alice via email
97
+ rig.registry.register(new FakeAdapter("email", [
98
+ { id: "p-email-alice", names: ["Alice"], identifiers: { email: ["alice@x.com"] } },
99
+ ]));
100
+ await rig.registry.syncAdapter("email");
101
+
102
+ // Adapter 2 imports same Alice via different name + same email
103
+ rig.registry.register(new FakeAdapter("alipay", [
104
+ { id: "p-alipay-alice-X", names: ["A. Alice"], identifiers: { email: ["alice@x.com"] } },
105
+ ]));
106
+ const report = await rig.registry.syncAdapter("alipay");
107
+ expect(report.entityResolver.sameImmediate).toBeGreaterThanOrEqual(1);
108
+
109
+ // Verify merge group exists
110
+ const members = rig.vault.getMergeGroupMembers("p-email-alice").sort();
111
+ expect(members).toEqual(["p-alipay-alice-X", "p-email-alice"]);
112
+ });
113
+
114
+ it("uncertain pairs (name overlap only) → enqueued for async", async () => {
115
+ rig = makeRig();
116
+ rig.registry.register(new FakeAdapter("email", [
117
+ { id: "p-1", names: ["张三"] }, // no identifiers
118
+ ]));
119
+ await rig.registry.syncAdapter("email");
120
+
121
+ rig.registry.register(new FakeAdapter("alipay", [
122
+ { id: "p-2", names: ["张三"] },
123
+ ]));
124
+ const report = await rig.registry.syncAdapter("alipay");
125
+ expect(report.entityResolver.enqueued).toBeGreaterThanOrEqual(1);
126
+ expect(rig.vault.resolveQueueStats().pending).toBeGreaterThanOrEqual(1);
127
+ });
128
+
129
+ it("zero-overlap pair → no merge, no resolver decisions, person still ingested", async () => {
130
+ rig = makeRig();
131
+ rig.registry.register(new FakeAdapter("email", [
132
+ { id: "p-1", names: ["Alice"], identifiers: { email: ["a@x.com"] } },
133
+ ]));
134
+ await rig.registry.syncAdapter("email");
135
+
136
+ rig.registry.register(new FakeAdapter("alipay", [
137
+ { id: "p-2", names: ["Bob"], identifiers: { phone: ["13999998888"] } },
138
+ ]));
139
+ const report = await rig.registry.syncAdapter("alipay");
140
+ expect(report.status).toBe("ok");
141
+ expect(rig.vault.stats().mergeGroups).toBe(0);
142
+ });
143
+
144
+ it("resolver failure does NOT break ingest (audit-logged)", async () => {
145
+ rig = makeRig();
146
+ // Mock resolver that throws on resolveOnIngest
147
+ rig.registry.entityResolver = {
148
+ resolveOnIngest: () => { throw new Error("resolver boom"); },
149
+ };
150
+ rig.registry.register(new FakeAdapter("a1", [
151
+ { id: "p-1", names: ["Alice"], identifiers: { email: ["alice@x.com"] } },
152
+ ]));
153
+ const report = await rig.registry.syncAdapter("a1");
154
+ expect(report.status).toBe("ok"); // sync completes
155
+ expect(report.entityCounts.persons).toBe(1); // person still ingested
156
+ const audits = rig.vault.queryAudit({ action: "adapter.sync.entity_resolver_failed", limit: 5 });
157
+ expect(audits.length).toBeGreaterThanOrEqual(1);
158
+ });
159
+
160
+ it("no entityResolver = registry works as before (backward-compat)", async () => {
161
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "er-hook-"));
162
+ const vault = new LocalVault({ path: path.join(dir, "v.db"), key: generateKeyHex() });
163
+ vault.open();
164
+ try {
165
+ const registry = new AdapterRegistry({ vault }); // no entityResolver
166
+ registry.register(new FakeAdapter("a1", [
167
+ { id: "p-1", names: ["x"], identifiers: { email: ["a@x.com"] } },
168
+ ]));
169
+ const report = await registry.syncAdapter("a1");
170
+ expect(report.status).toBe("ok");
171
+ expect(report.entityResolver).toBeUndefined();
172
+ } finally {
173
+ vault.close();
174
+ fs.rmSync(dir, { recursive: true, force: true });
175
+ }
176
+ });
177
+ });