@chainlesschain/personal-data-hub 0.1.0 → 0.2.1

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 (154) hide show
  1. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
  2. package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
  3. package/__tests__/adapters/ai-chat-history.test.js +396 -0
  4. package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
  5. package/__tests__/adapters/ai-chat-vendors.test.js +874 -0
  6. package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
  7. package/__tests__/adapters/email-adapter.test.js +138 -1
  8. package/__tests__/adapters/email-classifier.test.js +347 -0
  9. package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
  10. package/__tests__/adapters/email-retry-progress.test.js +294 -0
  11. package/__tests__/adapters/email-templates.test.js +699 -0
  12. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
  13. package/__tests__/adapters/system-data-adapter.test.js +440 -0
  14. package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
  15. package/__tests__/adapters/system-data-android.test.js +387 -0
  16. package/__tests__/adapters/system-data-disclosure.test.js +153 -0
  17. package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
  18. package/__tests__/adapters/wechat-env-probe.test.js +162 -0
  19. package/__tests__/adapters/wechat-frida-agent.test.js +191 -0
  20. package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
  21. package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
  22. package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
  23. package/__tests__/analysis-skills.test.js +556 -0
  24. package/__tests__/analysis.test.js +329 -1
  25. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
  26. package/__tests__/e2e/full-user-journey.test.js +188 -0
  27. package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
  28. package/__tests__/entity-resolver-stages.test.js +411 -0
  29. package/__tests__/entity-resolver-vault.test.js +246 -0
  30. package/__tests__/entity-resolver.test.js +526 -0
  31. package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
  32. package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
  33. package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
  34. package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
  35. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
  36. package/__tests__/longtail-adapters.test.js +217 -0
  37. package/__tests__/mobile-extractor.test.js +288 -0
  38. package/__tests__/registry.test.js +4 -2
  39. package/__tests__/shopping-adapters.test.js +296 -0
  40. package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
  41. package/__tests__/sidecar-supervisor.test.js +120 -0
  42. package/__tests__/social-adapters.test.js +206 -0
  43. package/__tests__/travel-adapters.test.js +325 -0
  44. package/__tests__/vault.test.js +3 -3
  45. package/__tests__/wechat-adapter.test.js +476 -0
  46. package/__tests__/whatsapp-adapter.test.js +135 -0
  47. package/lib/adapter-spec.js +12 -0
  48. package/lib/adapters/_python-sidecar-base.js +207 -0
  49. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +374 -0
  50. package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
  51. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  52. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  53. package/lib/adapters/ai-chat-history/http-client.js +211 -0
  54. package/lib/adapters/ai-chat-history/index.js +28 -0
  55. package/lib/adapters/ai-chat-history/schema-map.js +258 -0
  56. package/lib/adapters/ai-chat-history/vendor-spec.js +86 -0
  57. package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
  58. package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
  59. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  60. package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
  61. package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
  62. package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
  63. package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
  64. package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
  65. package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
  66. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  67. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +311 -0
  68. package/lib/adapters/alipay-bill/counterparty.js +129 -0
  69. package/lib/adapters/alipay-bill/csv-parser.js +217 -0
  70. package/lib/adapters/alipay-bill/index.js +41 -0
  71. package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
  72. package/lib/adapters/email-imap/classifier.js +495 -0
  73. package/lib/adapters/email-imap/email-adapter.js +419 -8
  74. package/lib/adapters/email-imap/index.js +42 -0
  75. package/lib/adapters/email-imap/pdf-extractor.js +192 -0
  76. package/lib/adapters/email-imap/templates/bill.js +232 -0
  77. package/lib/adapters/email-imap/templates/government.js +120 -0
  78. package/lib/adapters/email-imap/templates/index.js +78 -0
  79. package/lib/adapters/email-imap/templates/order.js +186 -0
  80. package/lib/adapters/email-imap/templates/other.js +114 -0
  81. package/lib/adapters/email-imap/templates/register.js +113 -0
  82. package/lib/adapters/email-imap/templates/travel.js +157 -0
  83. package/lib/adapters/email-imap/templates/utils.js +275 -0
  84. package/lib/adapters/email-imap/transactions.js +234 -0
  85. package/lib/adapters/messaging-qq/index.js +158 -0
  86. package/lib/adapters/messaging-telegram/index.js +142 -0
  87. package/lib/adapters/messaging-whatsapp/index.js +189 -0
  88. package/lib/adapters/shopping-base/index.js +208 -0
  89. package/lib/adapters/shopping-jd/index.js +150 -0
  90. package/lib/adapters/shopping-meituan/index.js +154 -0
  91. package/lib/adapters/shopping-taobao/index.js +176 -0
  92. package/lib/adapters/social-bilibili/index.js +171 -0
  93. package/lib/adapters/social-douyin/index.js +116 -0
  94. package/lib/adapters/social-kuaishou/index.js +237 -0
  95. package/lib/adapters/social-toutiao/index.js +236 -0
  96. package/lib/adapters/social-weibo/index.js +164 -0
  97. package/lib/adapters/social-xiaohongshu/index.js +96 -0
  98. package/lib/adapters/system-data/disclosure.js +166 -0
  99. package/lib/adapters/system-data/index.js +34 -0
  100. package/lib/adapters/system-data/system-data-adapter.js +344 -0
  101. package/lib/adapters/system-data-android/adapter.js +348 -0
  102. package/lib/adapters/system-data-android/index.js +76 -0
  103. package/lib/adapters/travel-12306/index.js +151 -0
  104. package/lib/adapters/travel-amap/index.js +164 -0
  105. package/lib/adapters/travel-baidu-map/index.js +162 -0
  106. package/lib/adapters/travel-base/index.js +240 -0
  107. package/lib/adapters/travel-ctrip/index.js +151 -0
  108. package/lib/adapters/wechat/bootstrap.js +146 -0
  109. package/lib/adapters/wechat/content-parser.js +326 -0
  110. package/lib/adapters/wechat/db-reader.js +209 -0
  111. package/lib/adapters/wechat/env-probe.js +218 -0
  112. package/lib/adapters/wechat/frida-agent/loader.js +67 -0
  113. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
  114. package/lib/adapters/wechat/index.js +37 -0
  115. package/lib/adapters/wechat/key-extractor.js +158 -0
  116. package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
  117. package/lib/adapters/wechat/key-providers/index.js +22 -0
  118. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  119. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  120. package/lib/adapters/wechat/normalize.js +220 -0
  121. package/lib/adapters/wechat/wechat-adapter.js +205 -0
  122. package/lib/analysis-skills/base.js +113 -0
  123. package/lib/analysis-skills/footprint.js +167 -0
  124. package/lib/analysis-skills/index.js +58 -0
  125. package/lib/analysis-skills/interests.js +161 -0
  126. package/lib/analysis-skills/relations.js +226 -0
  127. package/lib/analysis-skills/spending.js +219 -0
  128. package/lib/analysis-skills/timeline.js +167 -0
  129. package/lib/analysis.js +191 -2
  130. package/lib/entity-resolver/embedding-stage.js +198 -0
  131. package/lib/entity-resolver/entity-resolver.js +384 -0
  132. package/lib/entity-resolver/index.js +42 -0
  133. package/lib/entity-resolver/llm-stage.js +191 -0
  134. package/lib/entity-resolver/rule-stage.js +208 -0
  135. package/lib/entity-resolver/worker.js +149 -0
  136. package/lib/index.js +131 -0
  137. package/lib/migrations.js +73 -0
  138. package/lib/mobile-extractor/android.js +193 -0
  139. package/lib/mobile-extractor/index.js +9 -0
  140. package/lib/mobile-extractor/ios.js +223 -0
  141. package/lib/prompt-builder.js +11 -1
  142. package/lib/query-parser.js +7 -1
  143. package/lib/registry.js +42 -0
  144. package/lib/sidecar/index.js +15 -0
  145. package/lib/sidecar/supervisor.js +359 -0
  146. package/lib/vault.js +343 -0
  147. package/package.json +36 -3
  148. package/scripts/_make-fixture-all.js +126 -0
  149. package/scripts/_make-fixture-contacts.js +84 -0
  150. package/scripts/evaluate-entity-resolver.js +213 -0
  151. package/scripts/smoke-phase-5-5.js +196 -0
  152. package/scripts/smoke-phase-5-7.js +181 -0
  153. package/scripts/smoke-system-data-contacts.js +309 -0
  154. package/scripts/smoke-system-data.js +312 -0
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
 
3
- import { describe, it, expect, afterEach } from "vitest";
3
+ import { describe, it, expect, afterEach, vi } from "vitest";
4
4
 
5
5
  const fs = require("node:fs");
6
6
  const os = require("node:os");
@@ -287,6 +287,241 @@ describe("AnalysisEngine RAG retriever", () => {
287
287
  });
288
288
  });
289
289
 
290
+ // ─── TOTALS block — authoritative counts beat FACTS sample length ─────
291
+ //
292
+ // Bug 2026-05-21: even after _gatherFacts pulled persons + items into the
293
+ // prompt, the LLM still said "32 contacts" because FACTS is capped at 80
294
+ // items and the LLM was counting the array. Real vault had ~500 contacts.
295
+ // Fix: stick vault.stats() totals at the head of the user message so the
296
+ // model has an authoritative ground-truth number to quote.
297
+
298
+ describe("AnalysisEngine emits TOTALS preamble", () => {
299
+ it("includes vault.stats() totals in the prompt", async () => {
300
+ const fakeVault = {
301
+ queryEvents: () => [],
302
+ queryPersons: () => [],
303
+ queryItems: () => [],
304
+ stats: () => ({ events: 12, persons: 512, places: 3, items: 89, topics: 0 }),
305
+ getEvent: () => null,
306
+ audit: () => {},
307
+ };
308
+ const chatCalls = [];
309
+ const llm = {
310
+ isLocal: true,
311
+ chat: async (msgs) => {
312
+ chatCalls.push(msgs);
313
+ return { text: "ok", usage: {} };
314
+ },
315
+ };
316
+ const engine = new AnalysisEngine({ vault: fakeVault, llm });
317
+ await engine.ask("几个联系人");
318
+ const userMsg = chatCalls[0][1].content;
319
+ expect(userMsg).toContain("TOTALS");
320
+ expect(userMsg).toContain('"persons": 512');
321
+ expect(userMsg).toContain('"items": 89');
322
+ // System prompt tells LLM to trust TOTALS for counts.
323
+ expect(chatCalls[0][0].content).toMatch(/TOTALS.*authoritative/);
324
+ });
325
+
326
+ it("intent=count for '几个联系人' and '几个 app' and '多少个 X'", () => {
327
+ const { parseQuery } = require("../lib/query-parser");
328
+ expect(parseQuery("几个联系人").intent).toBe("count");
329
+ expect(parseQuery("几个 app").intent).toBe("count");
330
+ expect(parseQuery("我有多少个联系人?").intent).toBe("count");
331
+ expect(parseQuery("how many contacts do I have").intent).toBe("count");
332
+ expect(parseQuery("列出我的联系人").intent).toBe("list");
333
+ });
334
+
335
+ it("legacy vault without stats() falls back gracefully — no TOTALS block", async () => {
336
+ const legacyVault = {
337
+ queryEvents: () => [],
338
+ // no stats()
339
+ getEvent: () => null,
340
+ audit: () => {},
341
+ };
342
+ const chatCalls = [];
343
+ const llm = {
344
+ isLocal: true,
345
+ chat: async (msgs) => {
346
+ chatCalls.push(msgs);
347
+ return { text: "ok", usage: {} };
348
+ },
349
+ };
350
+ const engine = new AnalysisEngine({ vault: legacyVault, llm });
351
+ await engine.ask("test");
352
+ const userMsg = chatCalls[0][1].content;
353
+ expect(userMsg).not.toContain("TOTALS");
354
+ });
355
+ });
356
+
357
+ // ─── Cache bypass — PDH ask must always go to LLM, never cached ───────
358
+ //
359
+ // Bug 2026-05-21: desktop ResponseCache (7-day TTL) served a stale
360
+ // hallucinated answer ("32 contacts") even after _gatherFacts fix put real
361
+ // persons in the prompt — same sha256(messages) hit from an earlier session.
362
+ // AnalysisEngine.ask must pass skipCache:true so LLMManager bypasses cache.
363
+
364
+ describe("AnalysisEngine.ask cache bypass", () => {
365
+ it("passes skipCache:true to llm.chat options", async () => {
366
+ freshVault();
367
+ seedOrders(vault);
368
+ const chatCalls = [];
369
+ const llm = {
370
+ isLocal: true,
371
+ chat: async (messages, opts) => {
372
+ chatCalls.push({ messages, opts });
373
+ return { text: "ok", usage: {} };
374
+ },
375
+ };
376
+ const engine = new AnalysisEngine({ vault, llm });
377
+ await engine.ask("test", { now: NOW });
378
+ expect(chatCalls).toHaveLength(1);
379
+ expect(chatCalls[0].opts.skipCache).toBe(true);
380
+ });
381
+
382
+ it("retrieveContext does NOT need skipCache (no LLM call)", async () => {
383
+ freshVault();
384
+ seedOrders(vault);
385
+ const llm = {
386
+ isLocal: true,
387
+ chat: () => {
388
+ throw new Error("must not be called");
389
+ },
390
+ };
391
+ const engine = new AnalysisEngine({ vault, llm });
392
+ // retrieveContext is Path Y — caller hosts the LLM, so cache concerns
393
+ // belong to the caller, not us. Don't pass skipCache here.
394
+ const r = await engine.retrieveContext("test");
395
+ expect(r.factCount).toBeGreaterThanOrEqual(0);
396
+ });
397
+ });
398
+
399
+ // ─── Path C follow-up — persons / items show up as facts ───────────────
400
+ //
401
+ // Bug 2026-05-21: "我有几个联系人" hallucinated "2" because contacts ingest
402
+ // into persons table but _gatherFacts only queried events. Fix: pull persons
403
+ // + items into facts within the maxFacts budget.
404
+
405
+ describe("AnalysisEngine._gatherFacts includes persons and items", () => {
406
+ it("returns persons + items even when events are empty (contacts-only vault)", async () => {
407
+ freshVault();
408
+ // Use a fake vault that exposes queryPersons / queryItems but no event
409
+ // history — mimics the post-Path-C-ingest state where contacts +
410
+ // installed apps are the only data.
411
+ const fakeVault = {
412
+ queryEvents: () => [],
413
+ queryPersons: ({ limit }) => {
414
+ const n = Math.min(limit ?? 100, 5);
415
+ return Array.from({ length: n }, (_, i) => ({
416
+ id: "person-android-" + i,
417
+ type: "person",
418
+ subtype: "contact",
419
+ names: ["联系人" + i],
420
+ ingestedAt: Date.now(),
421
+ source: {
422
+ adapter: "system-data-android",
423
+ adapterVersion: "0.1.0",
424
+ capturedAt: Date.now(),
425
+ capturedBy: "api",
426
+ },
427
+ }));
428
+ },
429
+ queryItems: ({ limit }) => {
430
+ const n = Math.min(limit ?? 100, 3);
431
+ return Array.from({ length: n }, (_, i) => ({
432
+ id: "item-android-app-com.foo" + i,
433
+ type: "item",
434
+ subtype: "other",
435
+ name: "App" + i,
436
+ ingestedAt: Date.now(),
437
+ source: {
438
+ adapter: "system-data-android",
439
+ adapterVersion: "0.1.0",
440
+ capturedAt: Date.now(),
441
+ capturedBy: "api",
442
+ },
443
+ }));
444
+ },
445
+ getEvent: () => null,
446
+ audit: () => {},
447
+ };
448
+ const llm = new MockLLMClient({ reply: "你共有 5 个联系人" });
449
+ const engine = new AnalysisEngine({ vault: fakeVault, llm });
450
+ const r = await engine.ask("我有几个联系人");
451
+ expect(r.facts.length).toBe(8); // 0 events + 5 persons + 3 items
452
+ expect(r.facts.filter((f) => f.type === "person").length).toBe(5);
453
+ expect(r.facts.filter((f) => f.type === "item").length).toBe(3);
454
+ });
455
+
456
+ it("respects maxFacts budget — events get majority, persons + items split remainder", async () => {
457
+ const fakeVault = {
458
+ queryEvents: () => Array.from({ length: 60 }, (_, i) => ({
459
+ id: "event-" + i, type: "event", subtype: "order",
460
+ occurredAt: Date.now(), actor: "person-self",
461
+ ingestedAt: Date.now(), source: {
462
+ adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api",
463
+ },
464
+ })),
465
+ queryPersons: ({ limit }) => Array.from({ length: Math.min(limit, 100) }, (_, i) => ({
466
+ id: "p-" + i, type: "person", subtype: "contact",
467
+ names: ["P" + i], ingestedAt: Date.now(),
468
+ source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
469
+ })),
470
+ queryItems: ({ limit }) => Array.from({ length: Math.min(limit, 100) }, (_, i) => ({
471
+ id: "i-" + i, type: "item", subtype: "other", name: "Item" + i,
472
+ ingestedAt: Date.now(),
473
+ source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
474
+ })),
475
+ getEvent: () => null,
476
+ audit: () => {},
477
+ };
478
+ const llm = new MockLLMClient({ reply: "" });
479
+ const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 80 });
480
+ const r = await engine.retrieveContext("hello");
481
+ // 60 events + budget for the rest. remaining = 80-60 = 20 → 10 persons + 10 items
482
+ expect(r.facts.filter((f) => f.type === "event").length).toBe(60);
483
+ expect(r.facts.filter((f) => f.type === "person").length).toBe(10);
484
+ expect(r.facts.filter((f) => f.type === "item").length).toBe(10);
485
+ });
486
+
487
+ it("gracefully degrades when vault lacks queryPersons / queryItems (legacy fork)", async () => {
488
+ const legacyVault = {
489
+ queryEvents: () => [],
490
+ // no queryPersons / queryItems methods
491
+ getEvent: () => null,
492
+ audit: () => {},
493
+ };
494
+ const llm = new MockLLMClient({ reply: "" });
495
+ const engine = new AnalysisEngine({ vault: legacyVault, llm });
496
+ const r = await engine.ask("hello");
497
+ expect(r.facts.length).toBe(0);
498
+ expect(r.warning).toBe("no-facts");
499
+ });
500
+
501
+ it("events take majority when budget < events.length (no person/item budget left)", async () => {
502
+ const fakeVault = {
503
+ queryEvents: () => Array.from({ length: 80 }, (_, i) => ({
504
+ id: "e" + i, type: "event", subtype: "order",
505
+ occurredAt: Date.now(), actor: "self",
506
+ ingestedAt: Date.now(),
507
+ source: { adapter: "taobao", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
508
+ })),
509
+ queryPersons: vi.fn(() => []),
510
+ queryItems: vi.fn(() => []),
511
+ getEvent: () => null,
512
+ audit: () => {},
513
+ };
514
+ const llm = new MockLLMClient({ reply: "" });
515
+ const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 80 });
516
+ const r = await engine.ask("hi");
517
+ expect(r.facts.length).toBe(80);
518
+ // budget exhausted → queryPersons / queryItems still called but with limit 0
519
+ // (current impl skips with personBudget <= 0). Verify they're NOT called.
520
+ expect(fakeVault.queryPersons).not.toHaveBeenCalled();
521
+ expect(fakeVault.queryItems).not.toHaveBeenCalled();
522
+ });
523
+ });
524
+
290
525
  // ─── Empty / bad input ────────────────────────────────────────────────────
291
526
 
292
527
  describe("AnalysisEngine input validation", () => {
@@ -300,3 +535,96 @@ describe("AnalysisEngine input validation", () => {
300
535
  await expect(engine.ask(null)).rejects.toThrow();
301
536
  });
302
537
  });
538
+
539
+ // ─── retrieveContext: prompt assembly without LLM call ───────────────────
540
+ //
541
+ // Path Y wiring lets a mobile front-end host the LLM call locally (e.g. the
542
+ // Android-side Volcengine Doubao adapter) while keeping vault + retrieval on
543
+ // the desktop. retrieveContext mirrors the front half of ask() and returns
544
+ // the assembled messages so the caller can hand them straight to its own LLM.
545
+
546
+ describe("AnalysisEngine.retrieveContext", () => {
547
+ it("returns parsed + facts + messages without invoking the LLM", async () => {
548
+ freshVault();
549
+ const [e1, e2, e3] = seedOrders(vault);
550
+
551
+ // LLM that would throw if called — proves retrieveContext is LLM-free.
552
+ const llm = {
553
+ isLocal: true,
554
+ chat: () => { throw new Error("LLM must not be called by retrieveContext"); },
555
+ };
556
+ const engine = new AnalysisEngine({ vault, llm });
557
+ const r = await engine.retrieveContext("上个月在淘宝总共花了多少?", { now: NOW });
558
+
559
+ expect(r.question).toBe("上个月在淘宝总共花了多少?");
560
+ expect(r.parsed.filters.adapter).toBe("taobao");
561
+ expect(r.facts.length).toBe(3);
562
+ expect(r.factIds).toEqual(expect.arrayContaining([e1.id, e2.id, e3.id]));
563
+ expect(r.factCount).toBe(3);
564
+ expect(r.truncated).toBe(false);
565
+ expect(Array.isArray(r.messages)).toBe(true);
566
+ expect(r.messages.length).toBeGreaterThan(0);
567
+ expect(r.messages[0]).toHaveProperty("role");
568
+ expect(r.messages[0]).toHaveProperty("content");
569
+ expect(r.systemPrompt).toBeTypeOf("string");
570
+ expect(r.retrievedAt).toBeTypeOf("number");
571
+ expect(r.durationMs).toBeGreaterThanOrEqual(0);
572
+ });
573
+
574
+ it("ignores acceptNonLocal — privacy gate does not apply (no LLM contacted)", async () => {
575
+ freshVault();
576
+ seedOrders(vault);
577
+ // Non-local LLM declared on the engine, but retrieveContext doesn't call it.
578
+ const llm = {
579
+ isLocal: false,
580
+ chat: () => { throw new Error("must not be called"); },
581
+ };
582
+ const engine = new AnalysisEngine({ vault, llm });
583
+ // No acceptNonLocal option needed.
584
+ const r = await engine.retrieveContext("test", { now: NOW });
585
+ expect(r.factCount).toBeGreaterThanOrEqual(0);
586
+ });
587
+
588
+ it("incorporates RAG retriever results into facts", async () => {
589
+ freshVault();
590
+ const orders = seedOrders(vault);
591
+ const ragRetriever = async () => [{ id: orders[3].id, text: "fake", metadata: {} }];
592
+ const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
593
+ const engine = new AnalysisEngine({ vault, llm, ragRetriever });
594
+ const r = await engine.retrieveContext("上个月在淘宝总共花了多少?", { now: NOW });
595
+ expect(r.facts.length).toBe(4);
596
+ expect(r.ragContextIds).toEqual([orders[3].id]);
597
+ });
598
+
599
+ it("RAG failure is captured but doesn't abort retrieval", async () => {
600
+ freshVault();
601
+ seedOrders(vault);
602
+ const ragRetriever = async () => { throw new Error("qdrant unreachable"); };
603
+ const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
604
+ const engine = new AnalysisEngine({ vault, llm, ragRetriever });
605
+ const r = await engine.retrieveContext("test", { now: NOW });
606
+ expect(r.factCount).toBeGreaterThanOrEqual(0);
607
+ const audits = vault.queryAudit({ action: "analysis.rag_failed" });
608
+ expect(audits.length).toBe(1);
609
+ });
610
+
611
+ it("writes analysis.retrieve_context audit row by default", async () => {
612
+ freshVault();
613
+ seedOrders(vault);
614
+ const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
615
+ const engine = new AnalysisEngine({ vault, llm });
616
+ await engine.retrieveContext("test", { now: NOW });
617
+ const audits = vault.queryAudit({ action: "analysis.retrieve_context" });
618
+ expect(audits.length).toBe(1);
619
+ });
620
+
621
+ it("rejects empty / non-string question", async () => {
622
+ freshVault();
623
+ const engine = new AnalysisEngine({
624
+ vault,
625
+ llm: new MockLLMClient({ reply: "" }),
626
+ });
627
+ await expect(engine.retrieveContext("")).rejects.toThrow(/non-empty/);
628
+ await expect(engine.retrieveContext(null)).rejects.toThrow();
629
+ });
630
+ });
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+
3
+ // E2E — Personal Data Hub cross-source journey including Phase 10.2 AIChat.
4
+ //
5
+ // User has:
6
+ // 1. Two AI vendors with chat history (DeepSeek + Kimi)
7
+ // 2. EntityResolver wires up vendor Persons as ai-agents
8
+ // 3. Timeline + Relations skills weave AI conversations into the same
9
+ // chronological story as other data sources (RAG-ready surface).
10
+ //
11
+ // Verifies the Phase 10.2 wiring through the entire stack — sync → vault
12
+ // → KG/RAG sinks → analysis skills — using fixture HTTP (no live cookies).
13
+
14
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
15
+
16
+ const fs = require("node:fs");
17
+ const path = require("node:path");
18
+ const os = require("node:os");
19
+
20
+ const {
21
+ LocalVault, generateKeyHex, AdapterRegistry,
22
+ EntityResolver,
23
+ TimelineSkill, RelationsSkill,
24
+ } = require("../../lib");
25
+ const {
26
+ AIChatHistoryAdapter, CookieAuthSession,
27
+ } = require("../../lib/adapters/ai-chat-history");
28
+
29
+ function makeResponse({ status = 200, body = {} } = {}) {
30
+ return {
31
+ status,
32
+ ok: status >= 200 && status < 300,
33
+ headers: { get: () => null },
34
+ async json() { return body; },
35
+ };
36
+ }
37
+
38
+ function makeRoutedFetch(routes) {
39
+ return async (url, init) => {
40
+ for (const [pattern, response] of routes) {
41
+ if (typeof pattern === "string" ? url.includes(pattern) : pattern.test(url)) {
42
+ if (typeof response === "function") return response(url, init);
43
+ return response;
44
+ }
45
+ }
46
+ return makeResponse({ status: 404 });
47
+ };
48
+ }
49
+
50
+ function makeClock() {
51
+ let t = 1700000000_000;
52
+ return { now: () => t, sleep: async (ms) => { t += ms; } };
53
+ }
54
+
55
+ describe("E2E — Personal Data Hub cross-source journey with AIChat", () => {
56
+ let dir, vault, registry, resolver;
57
+
58
+ beforeEach(() => {
59
+ dir = fs.mkdtempSync(path.join(os.tmpdir(), "pdh-aichat-e2e-"));
60
+ vault = new LocalVault({ path: path.join(dir, "v.db"), key: generateKeyHex() });
61
+ vault.open();
62
+ resolver = new EntityResolver({ vault });
63
+ registry = new AdapterRegistry({ vault, entityResolver: resolver });
64
+ });
65
+
66
+ afterEach(() => {
67
+ try { vault.close(); } catch (_e) {}
68
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {}
69
+ });
70
+
71
+ it("two AIChat vendors land in vault; TimelineSkill returns interleaved events", async () => {
72
+ const clk = makeClock();
73
+ const fetch = makeRoutedFetch([
74
+ // DeepSeek
75
+ ["/get_user_info", makeResponse({ body: { code: 0, data: { biz_data: { user_id: "ds-u1" } } } })],
76
+ [/fetch_page.*before=/, makeResponse({ body: { data: { biz_data: { chat_sessions: [] } } } })],
77
+ ["/chat_session/fetch_page", makeResponse({ body: { data: { biz_data: { chat_sessions: [
78
+ { id: "ds-c1", title: "DeepSeek code review", model: "deepseek-r1", inserted_at: 1700000100, updated_at: 1700000200 },
79
+ ] } } } })],
80
+ ["/chat/history_messages", makeResponse({ body: { data: { biz_data: { chat_messages: [
81
+ { id: "ds-m1", role: "USER", content: "explain this Go race condition", inserted_at: 1700000100 },
82
+ { id: "ds-m2", role: "ASSISTANT", content: "The issue is the unlocked mutex...", inserted_at: 1700000150, model: "deepseek-r1" },
83
+ ] } } } })],
84
+ // Kimi
85
+ ["/api/user", makeResponse({ body: { id: "km-u1" } })],
86
+ [/list\?offset=30/, makeResponse({ body: { items: [], total: 1 } })],
87
+ [/list\?offset=0/, makeResponse({ body: { items: [
88
+ { id: "km-c1", name: "Kimi 200k context Q", created_at: 1700000300, updated_at: 1700000400 },
89
+ ], total: 1 } })],
90
+ [/segment\/scroll/, async (_url, init) => {
91
+ const body = JSON.parse(init.body);
92
+ if (body.last === "0") {
93
+ return makeResponse({ body: {
94
+ items: [
95
+ { id: "km-msg-1", role: "user", content: "summarize this paper", created_at: 1700000300 },
96
+ { id: "km-msg-2", role: "assistant", content: "The paper proposes...", created_at: 1700000350 },
97
+ ],
98
+ has_more: false,
99
+ } });
100
+ }
101
+ return makeResponse({ body: { items: [], has_more: false } });
102
+ }],
103
+ ]);
104
+
105
+ const adapter = new AIChatHistoryAdapter({ fetch, sleep: clk.sleep, now: clk.now });
106
+ adapter.setSession("deepseek", new CookieAuthSession({
107
+ vendor: "deepseek",
108
+ cookies: [{ name: "userToken", value: "ds-cookie" }],
109
+ }));
110
+ adapter.setSession("kimi", new CookieAuthSession({
111
+ vendor: "kimi",
112
+ cookies: [{ name: "access_token", value: "km-cookie" }],
113
+ }));
114
+ registry.register(adapter);
115
+
116
+ // ─── Sync ──────────────────────────────────────────────
117
+ const report = await registry.syncAdapter("ai-chat-history");
118
+ expect(report.status).toBe("ok");
119
+ expect(report.invalidCount).toBe(0);
120
+ expect(report.rawCount).toBeGreaterThanOrEqual(6); // 2 convs + 4 msgs
121
+
122
+ // ─── Vault inspection ──────────────────────────────────
123
+ const events = vault.queryEvents({ adapter: "ai-chat-history", limit: 100 });
124
+ expect(events.length).toBe(4); // 2 deepseek msgs + 2 kimi msgs
125
+
126
+ // Both vendors present
127
+ const vendors = new Set(events.map((e) => e.extra && e.extra.vendor).filter(Boolean));
128
+ expect(vendors.has("deepseek")).toBe(true);
129
+ expect(vendors.has("kimi")).toBe(true);
130
+
131
+ // Vendor AI-agent Persons deduped
132
+ const aiAgentCount = vault.db
133
+ .prepare("SELECT COUNT(*) AS n FROM persons WHERE subtype = 'ai-agent'")
134
+ .get().n;
135
+ expect(aiAgentCount).toBe(2);
136
+
137
+ // ─── TimelineSkill: events interleaved by time ─────────
138
+ const timeline = new TimelineSkill({ vault });
139
+ const result = await timeline.run({ since: 1700000000_000, until: 1700001000_000, limit: 100 });
140
+ expect(result.entries.length).toBeGreaterThanOrEqual(4);
141
+
142
+ // Both vendors appear via the timeline entries (each entry references an event)
143
+ const allText = result.entries.map((e) => JSON.stringify(e)).join(" ");
144
+ expect(allText).toMatch(/Go race|race condition/);
145
+ expect(allText).toMatch(/summarize this paper|paper proposes/);
146
+ });
147
+
148
+ it("RelationsSkill aggregates AI-agent interactions across vendors", async () => {
149
+ const clk = makeClock();
150
+ const fetch = makeRoutedFetch([
151
+ ["/get_user_info", makeResponse({ body: { code: 0, data: { biz_data: { user_id: "ds-u1" } } } })],
152
+ [/fetch_page.*before=/, makeResponse({ body: { data: { biz_data: { chat_sessions: [] } } } })],
153
+ ["/chat_session/fetch_page", makeResponse({ body: { data: { biz_data: { chat_sessions: [
154
+ { id: "ds-c1", title: "ds chat", model: "deepseek-r1", inserted_at: 1700000100, updated_at: 1700000200 },
155
+ ] } } } })],
156
+ ["/chat/history_messages", makeResponse({ body: { data: { biz_data: { chat_messages: [
157
+ { id: "ds-m1", role: "USER", content: "hi", inserted_at: 1700000100 },
158
+ { id: "ds-m2", role: "ASSISTANT", content: "hi back", inserted_at: 1700000110 },
159
+ { id: "ds-m3", role: "USER", content: "more questions", inserted_at: 1700000120 },
160
+ ] } } } })],
161
+ ]);
162
+
163
+ const adapter = new AIChatHistoryAdapter({ fetch, sleep: clk.sleep, now: clk.now });
164
+ adapter.setSession("deepseek", new CookieAuthSession({ vendor: "deepseek", cookies: [] }));
165
+ registry.register(adapter);
166
+
167
+ await registry.syncAdapter("ai-chat-history");
168
+
169
+ const skill = new RelationsSkill({ vault });
170
+ const r = await skill.run({ personId: "person-ai-deepseek" });
171
+ expect(r.profile.totalInteractions).toBeGreaterThan(0);
172
+ });
173
+
174
+ it("AIChat + cookie-expired sentinel: partial success, journey continues", async () => {
175
+ const clk = makeClock();
176
+ // DeepSeek returns 401 (expired); Kimi works.
177
+ const fetch = makeRoutedFetch([
178
+ [/chat_session\/fetch_page/, makeResponse({ status: 401 })],
179
+ ["/api/user", makeResponse({ body: { id: "km-u1" } })],
180
+ [/list\?offset=30/, makeResponse({ body: { items: [], total: 1 } })],
181
+ [/list\?offset=0/, makeResponse({ body: { items: [
182
+ { id: "km-c1", name: "still works", created_at: 1700000300, updated_at: 1700000400 },
183
+ ], total: 1 } })],
184
+ [/segment\/scroll/, async (_url, init) => {
185
+ const body = JSON.parse(init.body);
186
+ if (body.last === "0") {
187
+ return makeResponse({ body: {
188
+ items: [{ id: "km-msg-1", role: "user", content: "hi kimi", created_at: 1700000300 }],
189
+ has_more: false,
190
+ } });
191
+ }
192
+ return makeResponse({ body: { items: [], has_more: false } });
193
+ }],
194
+ ]);
195
+ const adapter = new AIChatHistoryAdapter({ fetch, sleep: clk.sleep, now: clk.now });
196
+ adapter.setSession("deepseek", new CookieAuthSession({ vendor: "deepseek", cookies: [] }));
197
+ adapter.setSession("kimi", new CookieAuthSession({ vendor: "kimi", cookies: [] }));
198
+ registry.register(adapter);
199
+
200
+ const report = await registry.syncAdapter("ai-chat-history");
201
+ expect(report.status).toBe("ok");
202
+
203
+ // Kimi events landed even though DeepSeek failed.
204
+ const allEvents = vault.queryEvents({ adapter: "ai-chat-history", limit: 100 });
205
+ const kimiEvents = allEvents.filter((e) => e.extra && e.extra.vendor === "kimi");
206
+ expect(kimiEvents.length).toBeGreaterThan(0);
207
+
208
+ // Timeline still returns kimi event despite deepseek failure.
209
+ const timeline = new TimelineSkill({ vault });
210
+ const result = await timeline.run({ since: 1700000000_000, until: 1700001000_000, limit: 100 });
211
+ expect(result.entries.length).toBeGreaterThan(0);
212
+ });
213
+ });