@chainlesschain/personal-data-hub 0.2.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 (50) 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 +8 -7
  4. package/__tests__/adapters/ai-chat-vendors.test.js +149 -8
  5. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
  6. package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
  7. package/__tests__/adapters/system-data-android.test.js +387 -0
  8. package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
  9. package/__tests__/adapters/wechat-env-probe.test.js +162 -0
  10. package/__tests__/adapters/wechat-frida-agent.test.js +191 -0
  11. package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
  12. package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
  13. package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
  14. package/__tests__/analysis-skills.test.js +147 -0
  15. package/__tests__/analysis.test.js +329 -1
  16. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
  17. package/__tests__/e2e/full-user-journey.test.js +188 -0
  18. package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
  19. package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
  20. package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
  21. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
  22. package/__tests__/registry.test.js +4 -2
  23. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +55 -16
  24. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  25. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  26. package/lib/adapters/ai-chat-history/schema-map.js +42 -5
  27. package/lib/adapters/ai-chat-history/vendor-spec.js +1 -0
  28. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  29. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  30. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +4 -0
  31. package/lib/adapters/social-kuaishou/index.js +237 -0
  32. package/lib/adapters/social-toutiao/index.js +236 -0
  33. package/lib/adapters/system-data-android/adapter.js +348 -0
  34. package/lib/adapters/system-data-android/index.js +76 -0
  35. package/lib/adapters/wechat/bootstrap.js +146 -0
  36. package/lib/adapters/wechat/env-probe.js +218 -0
  37. package/lib/adapters/wechat/frida-agent/loader.js +67 -0
  38. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
  39. package/lib/adapters/wechat/index.js +9 -0
  40. package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
  41. package/lib/adapters/wechat/key-providers/index.js +22 -0
  42. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  43. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  44. package/lib/analysis-skills/spending.js +4 -1
  45. package/lib/analysis.js +191 -2
  46. package/lib/index.js +16 -0
  47. package/lib/prompt-builder.js +11 -1
  48. package/lib/query-parser.js +7 -1
  49. package/lib/vault.js +77 -0
  50. package/package.json +8 -1
@@ -0,0 +1,101 @@
1
+ "use strict";
2
+
3
+ import { describe, it, expect } from "vitest";
4
+
5
+ const {
6
+ WeChatKeyProvider,
7
+ WeChatMD5KeyProvider,
8
+ } = require("../../lib/adapters/wechat");
9
+
10
+ describe("WeChatKeyProvider — base contract", () => {
11
+ it("getKey throws on bare base instance (subclass must override)", async () => {
12
+ const base = new WeChatKeyProvider();
13
+ await expect(base.getKey()).rejects.toThrow(/must be overridden/);
14
+ });
15
+
16
+ it("name defaults to key-provider-base", () => {
17
+ const base = new WeChatKeyProvider();
18
+ expect(base.name).toBe("key-provider-base");
19
+ });
20
+ });
21
+
22
+ describe("WeChatMD5KeyProvider — construction validation", () => {
23
+ it("throws if opts missing", () => {
24
+ expect(() => new WeChatMD5KeyProvider()).toThrow(/wechatDataPath/);
25
+ });
26
+
27
+ it("throws if wechatDataPath missing", () => {
28
+ expect(() => new WeChatMD5KeyProvider({ uin: "1" })).toThrow(/wechatDataPath/);
29
+ });
30
+
31
+ it("name returns md5", () => {
32
+ const p = new WeChatMD5KeyProvider({ wechatDataPath: "/tmp/wx" });
33
+ expect(p.name).toBe("md5");
34
+ });
35
+ });
36
+
37
+ describe("WeChatMD5KeyProvider — extractor DI happy path", () => {
38
+ it("getKey returns hex when extractor yields key", async () => {
39
+ const extractor = (opts) => {
40
+ expect(opts.wechatDataPath).toBe("/tmp/wx");
41
+ return {
42
+ uin: "100200300",
43
+ imei: "350123456789012",
44
+ key: "abcdef1",
45
+ source: "test",
46
+ warnings: [],
47
+ };
48
+ };
49
+ const p = new WeChatMD5KeyProvider({
50
+ wechatDataPath: "/tmp/wx",
51
+ extractor,
52
+ });
53
+ const key = await p.getKey();
54
+ expect(key).toBe("abcdef1");
55
+ expect(p.getLastResult().uin).toBe("100200300");
56
+ expect(p.getLastResult().imei).toBe("350123456789012");
57
+ });
58
+
59
+ it("getKey passes uin/imei overrides through to extractor", async () => {
60
+ let passedOpts = null;
61
+ const extractor = (opts) => {
62
+ passedOpts = opts;
63
+ return { key: "0000000", uin: opts.uin, imei: opts.imei, warnings: [] };
64
+ };
65
+ const p = new WeChatMD5KeyProvider({
66
+ wechatDataPath: "/tmp/wx",
67
+ uin: "999",
68
+ imei: "deadbeef",
69
+ extractor,
70
+ });
71
+ await p.getKey();
72
+ expect(passedOpts.uin).toBe("999");
73
+ expect(passedOpts.imei).toBe("deadbeef");
74
+ });
75
+ });
76
+
77
+ describe("WeChatMD5KeyProvider — extractor failure surfaces as throw", () => {
78
+ it("throws with warnings when extractor returns no key", async () => {
79
+ const extractor = () => ({
80
+ uin: null,
81
+ imei: null,
82
+ key: null,
83
+ source: "missing",
84
+ warnings: ["UIN not found in shared_prefs", "IMEI not found in CompatibleInfo.cfg"],
85
+ });
86
+ const p = new WeChatMD5KeyProvider({
87
+ wechatDataPath: "/tmp/empty",
88
+ extractor,
89
+ });
90
+ await expect(p.getKey()).rejects.toThrow(/UIN not found/);
91
+ });
92
+
93
+ it("throws with generic reason when warnings empty", async () => {
94
+ const extractor = () => ({ key: null, warnings: [] });
95
+ const p = new WeChatMD5KeyProvider({
96
+ wechatDataPath: "/tmp/empty",
97
+ extractor,
98
+ });
99
+ await expect(p.getKey()).rejects.toThrow(/extraction returned empty/);
100
+ });
101
+ });
@@ -250,6 +250,64 @@ describe("RelationsSkill", () => {
250
250
  expect(r.ranked[0].personId).toBe("p-mom");
251
251
  expect(r.ranked[0].totalInteractions).toBe(2);
252
252
  });
253
+
254
+ it("empty vault → ranked mode returns empty list, no crash", async () => {
255
+ const skill = new RelationsSkill({ vault: rig.vault });
256
+ const r = await skill.run({});
257
+ expect(r.mode).toBe("ranked");
258
+ expect(r.ranked).toEqual([]);
259
+ expect(r.citations).toEqual([]);
260
+ expect(r.llm_commentary).toBeNull();
261
+ });
262
+
263
+ it("non-local LLM gate: isLocal=false without acceptNonLocal → commentary stays null", async () => {
264
+ makePerson(rig.vault, "p-mom", ["妈"]);
265
+ makePayment(rig.vault, { id: "e1", occurredAt: ts(2026, 5, 1), counterpartyId: "p-mom", counterpartyName: "妈", amount: 100 });
266
+ const nonLocalLlm = {
267
+ isLocal: false,
268
+ chat: async () => ({ text: "this should never reach the caller" }),
269
+ };
270
+ const skill = new RelationsSkill({ vault: rig.vault, llm: nonLocalLlm });
271
+ const r = await skill.run({ personId: "p-mom" });
272
+ expect(r.mode).toBe("single");
273
+ expect(r.profile.totalInteractions).toBe(1);
274
+ // gate enforced by base.callLlmCommentary
275
+ expect(r.llm_commentary).toBeNull();
276
+ });
277
+
278
+ it("LLM exception swallowed → commentary null but profile data intact", async () => {
279
+ makePerson(rig.vault, "p-mom", ["妈"]);
280
+ makePayment(rig.vault, { id: "e1", occurredAt: ts(2026, 5, 1), counterpartyId: "p-mom", counterpartyName: "妈", amount: 100 });
281
+ const throwingLlm = {
282
+ isLocal: true,
283
+ chat: async () => { throw new Error("model timeout"); },
284
+ };
285
+ const skill = new RelationsSkill({ vault: rig.vault, llm: throwingLlm });
286
+ const r = await skill.run({ personId: "p-mom" });
287
+ expect(r.profile.totalInteractions).toBe(1); // data path unaffected
288
+ expect(r.llm_commentary).toBeNull();
289
+ });
290
+
291
+ it("merge-group expansion: vault.getMergeGroupMembers fans the personId out", async () => {
292
+ // Two PersonIds representing the same real-world person across sources;
293
+ // EntityResolver (Phase 8) would normally merge them into a group.
294
+ makePerson(rig.vault, "p-mom-email", ["妈"], { email: ["mom@163.com"] });
295
+ makePerson(rig.vault, "p-mom-alipay", ["陈XX"], { alipay: ["mom@163.com"] });
296
+ makePayment(rig.vault, { id: "e1", occurredAt: ts(2026, 4, 1), counterpartyId: "p-mom-email", counterpartyName: "妈", amount: 200, adapter: "email-imap" });
297
+ makePayment(rig.vault, { id: "e2", occurredAt: ts(2026, 5, 1), counterpartyId: "p-mom-alipay", counterpartyName: "陈XX", amount: 300, adapter: "alipay-bill" });
298
+
299
+ // Stub the resolver hook
300
+ rig.vault.getMergeGroupMembers = (pid) =>
301
+ (pid === "p-mom-email" || pid === "p-mom-alipay")
302
+ ? ["p-mom-email", "p-mom-alipay"]
303
+ : [pid];
304
+
305
+ const skill = new RelationsSkill({ vault: rig.vault });
306
+ const r = await skill.run({ personId: "p-mom-email" });
307
+ expect(r.profile.totalInteractions).toBe(2); // both events counted
308
+ expect(r.profile.totalSpend).toBe(500);
309
+ expect(Object.keys(r.profile.byAdapter).sort()).toEqual(["alipay-bill", "email-imap"]);
310
+ });
253
311
  });
254
312
 
255
313
  // ─── FootprintSkill ──────────────────────────────────────────────────────
@@ -301,6 +359,55 @@ describe("FootprintSkill", () => {
301
359
  expect(r.summary.totalTrips).toBe(0);
302
360
  expect(r.topPlaces).toEqual([]);
303
361
  });
362
+
363
+ it("local LLM commentary fires when trips present", async () => {
364
+ rig.vault.putEvent({
365
+ id: "trip-1", type: "event", subtype: "trip",
366
+ occurredAt: ts(2026, 4, 1),
367
+ actor: "person-self",
368
+ content: { title: "Hangzhou trip" },
369
+ ingestedAt: Date.now(),
370
+ source: defaultSource("travel"),
371
+ extra: { to: "Hangzhou" },
372
+ });
373
+ const llm = { isLocal: true, chat: async () => ({ text: "你这个月去过 1 个地方。" }) };
374
+ const skill = new FootprintSkill({ vault: rig.vault, llm });
375
+ const r = await skill.run({});
376
+ expect(r.llm_commentary).toBe("你这个月去过 1 个地方。");
377
+ });
378
+
379
+ it("non-local LLM gate → llm_commentary null", async () => {
380
+ rig.vault.putEvent({
381
+ id: "trip-1", type: "event", subtype: "trip",
382
+ occurredAt: ts(2026, 4, 1),
383
+ actor: "person-self",
384
+ content: { title: "Beijing" },
385
+ ingestedAt: Date.now(),
386
+ source: defaultSource("travel"),
387
+ extra: { to: "Beijing" },
388
+ });
389
+ const llm = { isLocal: false, chat: async () => ({ text: "should not appear" }) };
390
+ const skill = new FootprintSkill({ vault: rig.vault, llm });
391
+ const r = await skill.run({});
392
+ expect(r.llm_commentary).toBeNull();
393
+ });
394
+
395
+ it("LLM exception swallowed → commentary null but data intact", async () => {
396
+ rig.vault.putEvent({
397
+ id: "trip-1", type: "event", subtype: "trip",
398
+ occurredAt: ts(2026, 4, 1),
399
+ actor: "person-self",
400
+ content: { title: "Tokyo" },
401
+ ingestedAt: Date.now(),
402
+ source: defaultSource("travel"),
403
+ extra: { to: "Tokyo" },
404
+ });
405
+ const llm = { isLocal: true, chat: async () => { throw new Error("net down"); } };
406
+ const skill = new FootprintSkill({ vault: rig.vault, llm });
407
+ const r = await skill.run({});
408
+ expect(r.summary.totalTrips).toBe(1);
409
+ expect(r.llm_commentary).toBeNull();
410
+ });
304
411
  });
305
412
 
306
413
  // ─── InterestsSkill ──────────────────────────────────────────────────────
@@ -349,6 +456,46 @@ describe("InterestsSkill", () => {
349
456
  expect(r.llmInterests).toHaveLength(1);
350
457
  expect(r.llmInterests[0].category).toBe("摄影");
351
458
  });
459
+
460
+ it("empty vault → topTopics + topItems empty, no crash", async () => {
461
+ const skill = new InterestsSkill({ vault: rig.vault });
462
+ const r = await skill.run({});
463
+ expect(r.topTopics).toEqual([]);
464
+ expect(r.topItems).toEqual([]);
465
+ expect(r.llmInterests).toBeNull();
466
+ });
467
+
468
+ it("non-local LLM gate → llmInterests null even with topics present", async () => {
469
+ rig.vault.putTopic({
470
+ id: "topic-b", type: "topic", name: "Cooking",
471
+ derivedFromEvents: ["e-1"],
472
+ ingestedAt: Date.now(), source: defaultSource("test"),
473
+ });
474
+ const llm = {
475
+ isLocal: false,
476
+ chat: async () => ({ text: '[{"category":"烹饪","evidenceCount":1,"examples":["Cooking"]}]' }),
477
+ };
478
+ const skill = new InterestsSkill({ vault: rig.vault, llm });
479
+ const r = await skill.run({});
480
+ expect(r.topTopics[0].name).toBe("Cooking");
481
+ expect(r.llmInterests).toBeNull();
482
+ });
483
+
484
+ it("LLM clustering exception swallowed → llmInterests null but data intact", async () => {
485
+ rig.vault.putTopic({
486
+ id: "topic-c", type: "topic", name: "Travel",
487
+ derivedFromEvents: ["e-1", "e-2"],
488
+ ingestedAt: Date.now(), source: defaultSource("test"),
489
+ });
490
+ const llm = {
491
+ isLocal: true,
492
+ chat: async () => { throw new Error("vllm 500"); },
493
+ };
494
+ const skill = new InterestsSkill({ vault: rig.vault, llm });
495
+ const r = await skill.run({});
496
+ expect(r.topTopics[0].name).toBe("Travel");
497
+ expect(r.llmInterests).toBeNull();
498
+ });
352
499
  });
353
500
 
354
501
  // ─── TimelineSkill ──────────────────────────────────────────────────────
@@ -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
+ });