@chainlesschain/personal-data-hub 0.3.6 → 0.3.8

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 (29) hide show
  1. package/__tests__/adapters/social-kuaishou-adb-api-client.test.js +432 -0
  2. package/__tests__/adapters/social-kuaishou-adb-collector.test.js +276 -0
  3. package/__tests__/adapters/social-kuaishou-adb-cookies-extension.test.js +141 -0
  4. package/__tests__/adapters/social-kuaishou-adb-snapshot-builder.test.js +178 -0
  5. package/__tests__/adapters/social-toutiao-adb-api-client.test.js +537 -0
  6. package/__tests__/adapters/social-toutiao-adb-collector.test.js +285 -0
  7. package/__tests__/adapters/social-toutiao-adb-cookies-extension.test.js +163 -0
  8. package/__tests__/adapters/social-toutiao-adb-snapshot-builder.test.js +196 -0
  9. package/__tests__/adapters/social-xiaohongshu-adb-sign-provider-injection.test.js +351 -0
  10. package/__tests__/analysis.test.js +239 -14
  11. package/__tests__/query-parser.test.js +86 -0
  12. package/__tests__/vault.test.js +88 -0
  13. package/lib/adapters/ai-chat-history/health-checker.js +11 -0
  14. package/lib/adapters/social-kuaishou-adb/api-client.js +397 -0
  15. package/lib/adapters/social-kuaishou-adb/collector.js +196 -0
  16. package/lib/adapters/social-kuaishou-adb/cookies-extension.js +261 -0
  17. package/lib/adapters/social-kuaishou-adb/index.js +53 -0
  18. package/lib/adapters/social-kuaishou-adb/snapshot-builder.js +145 -0
  19. package/lib/adapters/social-toutiao-adb/api-client.js +377 -0
  20. package/lib/adapters/social-toutiao-adb/collector.js +200 -0
  21. package/lib/adapters/social-toutiao-adb/cookies-extension.js +266 -0
  22. package/lib/adapters/social-toutiao-adb/index.js +52 -0
  23. package/lib/adapters/social-toutiao-adb/snapshot-builder.js +148 -0
  24. package/lib/adapters/social-xiaohongshu-adb/api-client.js +36 -5
  25. package/lib/adapters/social-xiaohongshu-adb/collector.js +102 -51
  26. package/lib/analysis.js +154 -17
  27. package/lib/query-parser.js +93 -0
  28. package/lib/vault.js +64 -0
  29. package/package.json +5 -1
@@ -0,0 +1,351 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Phase 6b — verify signProvider injection in XhsApiClient + collector.
5
+ *
6
+ * Tests both paths:
7
+ * - Bridge path (signedHeaders returns non-empty) → uses bridge headers
8
+ * - Fallback path (NullSignProvider OR bridge returns {}) → in-process md5
9
+ *
10
+ * Uses a fake fetch + fake signProvider to verify wiring without spawning
11
+ * Electron WebContentsView.
12
+ */
13
+
14
+ import { describe, it, expect, vi } from "vitest";
15
+
16
+ const {
17
+ XhsApiClient,
18
+ } = require("../../lib/adapters/social-xiaohongshu-adb/api-client");
19
+ const {
20
+ collect,
21
+ } = require("../../lib/adapters/social-xiaohongshu-adb/collector");
22
+ const { NULL_SIGN_PROVIDER } = require("../../lib/sign-providers");
23
+
24
+ function makeFakeFetch(responses) {
25
+ const calls = [];
26
+ const fakeFetch = async (urlStr, opts) => {
27
+ calls.push({ url: urlStr, opts });
28
+ for (const [pattern, payload] of responses) {
29
+ if (urlStr.includes(pattern)) {
30
+ const resolved =
31
+ typeof payload === "function" ? await payload(urlStr, opts) : payload;
32
+ return {
33
+ ok: resolved.status == null || resolved.status === 200,
34
+ status: resolved.status || 200,
35
+ text: async () => resolved.body,
36
+ };
37
+ }
38
+ }
39
+ throw new Error("fake fetch: no response for " + urlStr);
40
+ };
41
+ return { fakeFetch, calls };
42
+ }
43
+
44
+ const HAPPY_RESPONSES = [
45
+ [
46
+ "/user/me",
47
+ {
48
+ body: JSON.stringify({
49
+ code: 0,
50
+ data: { user_id: "5e8c8f7e", nickname: "Alice" },
51
+ }),
52
+ },
53
+ ],
54
+ [
55
+ "user_posted",
56
+ {
57
+ body: JSON.stringify({
58
+ code: 0,
59
+ data: { notes: [{ note_id: "N1", title: "n", time: 1 }] },
60
+ }),
61
+ },
62
+ ],
63
+ [
64
+ "note/like/page",
65
+ {
66
+ body: JSON.stringify({
67
+ code: 0,
68
+ data: { notes: [{ note_id: "L1", title: "l" }] },
69
+ }),
70
+ },
71
+ ],
72
+ [
73
+ "user/follow/list",
74
+ {
75
+ body: JSON.stringify({
76
+ code: 0,
77
+ data: { users: [{ user_id: "U1", nickname: "x" }] },
78
+ }),
79
+ },
80
+ ],
81
+ ];
82
+
83
+ // ─── XhsApiClient direct injection ──────────────────────────────────────
84
+
85
+ describe("XhsApiClient — signProvider injection", () => {
86
+ it("defaults to NULL_SIGN_PROVIDER when no opts.signProvider", () => {
87
+ const client = new XhsApiClient({ fetch: () => {} });
88
+ expect(client.signProvider).toBe(NULL_SIGN_PROVIDER);
89
+ });
90
+
91
+ it("uses opts.signProvider verbatim when provided", () => {
92
+ const fakeProvider = { signedHeaders: vi.fn(async () => ({})) };
93
+ const client = new XhsApiClient({
94
+ fetch: () => {},
95
+ signProvider: fakeProvider,
96
+ });
97
+ expect(client.signProvider).toBe(fakeProvider);
98
+ });
99
+
100
+ it("falls back to in-process md5 when provider returns {}", async () => {
101
+ const { fakeFetch, calls } = makeFakeFetch(HAPPY_RESPONSES);
102
+ const fakeProvider = { signedHeaders: vi.fn(async () => ({})) };
103
+ const client = new XhsApiClient({
104
+ fetch: fakeFetch,
105
+ signProvider: fakeProvider,
106
+ });
107
+ await client.fetchNotes("a1=fp; web_session=s", "fp", "5e8c8f7e");
108
+ const notesCall = calls.find((c) => c.url.includes("user_posted"));
109
+ // Fallback used → in-process X-S/X-T headers should be set
110
+ expect(notesCall.opts.headers["X-S"]).toMatch(/^XYW_/);
111
+ expect(notesCall.opts.headers["X-T"]).toMatch(/^\d+$/);
112
+ expect(client._bridgeHits).toBe(0);
113
+ expect(client._fallbackHits).toBe(1);
114
+ });
115
+
116
+ it("uses bridge headers when provider returns non-empty", async () => {
117
+ const { fakeFetch, calls } = makeFakeFetch(HAPPY_RESPONSES);
118
+ const fakeProvider = {
119
+ signedHeaders: vi.fn(async () => ({
120
+ "X-s": "XYW_bridge_value",
121
+ "X-t": "1716383021000",
122
+ "X-s-common": "common_value",
123
+ })),
124
+ };
125
+ const client = new XhsApiClient({
126
+ fetch: fakeFetch,
127
+ signProvider: fakeProvider,
128
+ });
129
+ await client.fetchNotes("a1=fp; web_session=s", "fp", "5e8c8f7e");
130
+ const notesCall = calls.find((c) => c.url.includes("user_posted"));
131
+ expect(notesCall.opts.headers["X-s"]).toBe("XYW_bridge_value");
132
+ expect(notesCall.opts.headers["X-t"]).toBe("1716383021000");
133
+ expect(notesCall.opts.headers["X-s-common"]).toBe("common_value");
134
+ // Bridge headers used — no fallback X-S/X-T injected
135
+ expect(notesCall.opts.headers["X-S"]).toBeUndefined();
136
+ expect(client._bridgeHits).toBe(1);
137
+ expect(client._fallbackHits).toBe(0);
138
+ });
139
+
140
+ it("does NOT call signedHeaders for /user/me (no X-S required)", async () => {
141
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
142
+ const fakeProvider = { signedHeaders: vi.fn(async () => ({})) };
143
+ const client = new XhsApiClient({
144
+ fetch: fakeFetch,
145
+ signProvider: fakeProvider,
146
+ });
147
+ await client.fetchMe("a1=fp; web_session=s");
148
+ expect(fakeProvider.signedHeaders).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it("calls signedHeaders for fetchNotes / fetchLiked / fetchFollows", async () => {
152
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
153
+ const fakeProvider = { signedHeaders: vi.fn(async () => ({})) };
154
+ const client = new XhsApiClient({
155
+ fetch: fakeFetch,
156
+ signProvider: fakeProvider,
157
+ });
158
+ await client.fetchNotes("a1=fp; web_session=s", "fp", "5e8c8f7e");
159
+ await client.fetchLiked("a1=fp; web_session=s", "fp");
160
+ await client.fetchFollows("a1=fp; web_session=s", "fp", "5e8c8f7e");
161
+ expect(fakeProvider.signedHeaders).toHaveBeenCalledTimes(3);
162
+ });
163
+
164
+ it("forwards `<path>|` as purpose to signedHeaders", async () => {
165
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
166
+ const fakeProvider = { signedHeaders: vi.fn(async () => ({})) };
167
+ const client = new XhsApiClient({
168
+ fetch: fakeFetch,
169
+ signProvider: fakeProvider,
170
+ });
171
+ await client.fetchNotes("a1=fp; web_session=s", "fp", "5e8c8f7e");
172
+ const [, purpose] = fakeProvider.signedHeaders.mock.calls[0];
173
+ expect(purpose).toMatch(/^\/api\/sns\/web\/v2\/user_posted.*\|$/);
174
+ });
175
+ });
176
+
177
+ // ─── collector lifecycle ────────────────────────────────────────────────
178
+
179
+ describe("collector — signProvider lifecycle", () => {
180
+ it("warms up bridge before X-S endpoints", async () => {
181
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
182
+ const apiClient = new XhsApiClient({ fetch: fakeFetch });
183
+ const warmedUp = [];
184
+ const fakeProvider = {
185
+ warmUp: vi.fn(async (cookie) => warmedUp.push(cookie)),
186
+ signedHeaders: vi.fn(async () => ({})),
187
+ shutdown: vi.fn(async () => {}),
188
+ };
189
+ const bridge = {
190
+ invoke: vi.fn(async () => ({
191
+ cookie: "a1=fp; web_session=s",
192
+ a1: "fp",
193
+ diagnostic: {},
194
+ })),
195
+ };
196
+ await collect(bridge, {
197
+ apiClient,
198
+ signProvider: fakeProvider,
199
+ stagingDir: require("node:os").tmpdir(),
200
+ });
201
+ expect(fakeProvider.warmUp).toHaveBeenCalledOnce();
202
+ expect(warmedUp[0]).toBe("a1=fp; web_session=s");
203
+ });
204
+
205
+ it("shuts down bridge in finally — happy path", async () => {
206
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
207
+ const apiClient = new XhsApiClient({ fetch: fakeFetch });
208
+ const fakeProvider = {
209
+ warmUp: vi.fn(async () => {}),
210
+ signedHeaders: vi.fn(async () => ({})),
211
+ shutdown: vi.fn(async () => {}),
212
+ };
213
+ const bridge = {
214
+ invoke: vi.fn(async () => ({
215
+ cookie: "a1=fp; web_session=s",
216
+ a1: "fp",
217
+ diagnostic: {},
218
+ })),
219
+ };
220
+ await collect(bridge, {
221
+ apiClient,
222
+ signProvider: fakeProvider,
223
+ stagingDir: require("node:os").tmpdir(),
224
+ });
225
+ expect(fakeProvider.shutdown).toHaveBeenCalledOnce();
226
+ });
227
+
228
+ it("shuts down bridge in finally — even on fetchMe failure", async () => {
229
+ const { fakeFetch } = makeFakeFetch([
230
+ ["/user/me", { body: JSON.stringify({ code: 0, data: {} }) }],
231
+ ]);
232
+ const apiClient = new XhsApiClient({ fetch: fakeFetch });
233
+ const fakeProvider = {
234
+ warmUp: vi.fn(async () => {}),
235
+ signedHeaders: vi.fn(async () => ({})),
236
+ shutdown: vi.fn(async () => {}),
237
+ };
238
+ const bridge = {
239
+ invoke: vi.fn(async () => ({
240
+ cookie: "a1=fp; web_session=s",
241
+ a1: "fp",
242
+ diagnostic: {},
243
+ })),
244
+ };
245
+ const result = await collect(bridge, {
246
+ apiClient,
247
+ signProvider: fakeProvider,
248
+ stagingDir: require("node:os").tmpdir(),
249
+ });
250
+ expect(result.meFetchFailed).toBe(true);
251
+ expect(fakeProvider.shutdown).toHaveBeenCalledOnce();
252
+ });
253
+
254
+ it("tolerates warmUp throw (falls back to in-process md5)", async () => {
255
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
256
+ const fakeProvider = {
257
+ warmUp: vi.fn(async () => {
258
+ throw new Error("xhs.com 403 — anti-bot blocked");
259
+ }),
260
+ signedHeaders: vi.fn(async () => ({})),
261
+ shutdown: vi.fn(async () => {}),
262
+ };
263
+ // Inject same provider into the apiClient too — real wiring path
264
+ // creates them together inside collector.
265
+ const apiClient = new XhsApiClient({
266
+ fetch: fakeFetch,
267
+ signProvider: fakeProvider,
268
+ });
269
+ const bridge = {
270
+ invoke: vi.fn(async () => ({
271
+ cookie: "a1=fp; web_session=s",
272
+ a1: "fp",
273
+ diagnostic: {},
274
+ })),
275
+ };
276
+ const result = await collect(bridge, {
277
+ apiClient,
278
+ signProvider: fakeProvider,
279
+ stagingDir: require("node:os").tmpdir(),
280
+ });
281
+ // Sync proceeded — fallback md5 used instead of bridge for all 3
282
+ // X-S endpoints (bridge.signedHeaders returns {} so client falls
283
+ // back). Note lastErrorCode gets cleared by successful subsequent
284
+ // fetchMe so we check _fallbackHits instead.
285
+ expect(result.meFetchFailed).toBe(false);
286
+ expect(apiClient._fallbackHits).toBeGreaterThanOrEqual(3);
287
+ expect(apiClient._bridgeHits).toBe(0);
288
+ // shutdown still runs in finally
289
+ expect(fakeProvider.shutdown).toHaveBeenCalledOnce();
290
+ });
291
+
292
+ it("reports signProvider diagnostic in collect result", async () => {
293
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
294
+ // Subclass to control constructor.name (vitest mocks can't override it
295
+ // on plain object literals — constructor.name is read-only on Object).
296
+ class XhsSignBridge {
297
+ constructor() {
298
+ this.warmUp = vi.fn(async () => {});
299
+ this.signedHeaders = vi.fn(async () => ({
300
+ "X-s": "bridge",
301
+ "X-t": "1",
302
+ "X-s-common": "c",
303
+ }));
304
+ this.shutdown = vi.fn(async () => {});
305
+ }
306
+ }
307
+ const fakeProvider = new XhsSignBridge();
308
+ const apiClient = new XhsApiClient({
309
+ fetch: fakeFetch,
310
+ signProvider: fakeProvider,
311
+ });
312
+ const bridge = {
313
+ invoke: vi.fn(async () => ({
314
+ cookie: "a1=fp; web_session=s",
315
+ a1: "fp",
316
+ diagnostic: {},
317
+ })),
318
+ };
319
+ const result = await collect(bridge, {
320
+ apiClient,
321
+ signProvider: fakeProvider,
322
+ stagingDir: require("node:os").tmpdir(),
323
+ });
324
+ expect(result.signProviderUsed).toBe("XhsSignBridge");
325
+ expect(result.signProviderHits).toBe(3); // 3 X-S endpoints all hit bridge
326
+ expect(result.signProviderFallbacks).toBe(0);
327
+ });
328
+
329
+ it("undefined signProvider → no warmUp/shutdown, fallback md5 used", async () => {
330
+ const { fakeFetch } = makeFakeFetch(HAPPY_RESPONSES);
331
+ // Pre-construct apiClient with fakeFetch but no signProvider — client
332
+ // defaults to NULL_SIGN_PROVIDER which returns {} from signedHeaders.
333
+ const apiClient = new XhsApiClient({ fetch: fakeFetch });
334
+ const bridge = {
335
+ invoke: vi.fn(async () => ({
336
+ cookie: "a1=fp; web_session=s",
337
+ a1: "fp",
338
+ diagnostic: {},
339
+ })),
340
+ };
341
+ const result = await collect(bridge, {
342
+ apiClient,
343
+ stagingDir: require("node:os").tmpdir(),
344
+ });
345
+ // No bridge → in-process fallback throughout
346
+ expect(result.signProviderUsed).toBe("none");
347
+ expect(result.signProviderHits).toBe(0);
348
+ // 3 X-S endpoints called fallback md5 each
349
+ expect(result.signProviderFallbacks).toBeGreaterThanOrEqual(3);
350
+ });
351
+ });
@@ -403,11 +403,16 @@ describe("AnalysisEngine.ask cache bypass", () => {
403
403
  // + items into facts within the maxFacts budget.
404
404
 
405
405
  describe("AnalysisEngine._gatherFacts includes persons and items", () => {
406
- it("returns persons + items even when events are empty (contacts-only vault)", async () => {
406
+ it("contact question routes via entityFocus=persons persons only, no items competition", async () => {
407
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.
408
+ // 2026-05-27 fix: "我有几个联系人" now matches parseEntityFocus "persons",
409
+ // which intentionally skips the items table to give the full prompt
410
+ // budget to contacts. Pre-fix this test asserted 5 persons + 3 items
411
+ // (8 facts) because _gatherFacts always pulled both tables; post-fix
412
+ // items are deliberately excluded — the user asked about contacts, not
413
+ // apps. Items still surface for generic "what's in my vault" questions
414
+ // (entityFocus=null) and for explicit "我装了哪些 app" (entityFocus=
415
+ // "items"). Verified at __tests__:_gatherFacts entityFocus routing.
411
416
  const fakeVault = {
412
417
  queryEvents: () => [],
413
418
  queryPersons: ({ limit }) => {
@@ -448,9 +453,8 @@ describe("AnalysisEngine._gatherFacts includes persons and items", () => {
448
453
  const llm = new MockLLMClient({ reply: "你共有 5 个联系人" });
449
454
  const engine = new AnalysisEngine({ vault: fakeVault, llm });
450
455
  const r = await engine.ask("我有几个联系人");
451
- expect(r.facts.length).toBe(8); // 0 events + 5 persons + 3 items
452
456
  expect(r.facts.filter((f) => f.type === "person").length).toBe(5);
453
- expect(r.facts.filter((f) => f.type === "item").length).toBe(3);
457
+ expect(r.facts.filter((f) => f.type === "item").length).toBe(0);
454
458
  });
455
459
 
456
460
  it("respects maxFacts budget — events get majority, persons + items split remainder", async () => {
@@ -498,7 +502,11 @@ describe("AnalysisEngine._gatherFacts includes persons and items", () => {
498
502
  expect(r.warning).toBe("no-facts");
499
503
  });
500
504
 
501
- it("events take majority when budget < events.length (no person/item budget left)", async () => {
505
+ it("events overflow + empty side tables events refill the reserved slots", async () => {
506
+ // 2026-05-27 fix: when events would monopolize effMaxFacts the engine
507
+ // reserves slots for persons + items; if BOTH side tables return 0 rows
508
+ // the reserve is refilled with events so a contact-less vault still
509
+ // sees the full event budget.
502
510
  const fakeVault = {
503
511
  queryEvents: () => Array.from({ length: 80 }, (_, i) => ({
504
512
  id: "e" + i, type: "event", subtype: "order",
@@ -515,10 +523,225 @@ describe("AnalysisEngine._gatherFacts includes persons and items", () => {
515
523
  const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 80 });
516
524
  const r = await engine.ask("hi");
517
525
  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();
526
+ expect(r.facts.filter((f) => f.type === "event").length).toBe(80);
527
+ // Side queries WERE called (different from pre-fix); they just returned [].
528
+ expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 16 });
529
+ expect(fakeVault.queryItems).toHaveBeenCalledWith({ limit: 8 });
530
+ });
531
+
532
+ it("Android small-model budget — events overflow cap, persons survive", async () => {
533
+ // Regression: Android local path (effMaxFacts=20, effMaxQueryLimit=50).
534
+ // Vault returns 50 events; pre-fix _gatherFacts shipped 50 events,
535
+ // buildPrompt sliced to first 20 events, persons = 0 → "几个联系人"
536
+ // hallucinated zero. Now events cap at 14 (20*0.7), persons get 3,
537
+ // items get 3 → contact rows reach the LLM.
538
+ const fakeVault = {
539
+ queryEvents: () => Array.from({ length: 50 }, (_, i) => ({
540
+ id: "e" + i, type: "event", subtype: "message",
541
+ occurredAt: Date.now(), actor: "self",
542
+ ingestedAt: Date.now(),
543
+ source: { adapter: "wechat", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
544
+ })),
545
+ queryPersons: ({ limit }) => Array.from({ length: limit }, (_, i) => ({
546
+ id: "p" + i, type: "person", subtype: "contact",
547
+ names: ["联系人" + i], ingestedAt: Date.now(),
548
+ source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
549
+ })),
550
+ queryItems: ({ limit }) => Array.from({ length: limit }, (_, i) => ({
551
+ id: "i" + i, type: "item", subtype: "other", name: "App" + i,
552
+ ingestedAt: Date.now(),
553
+ source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
554
+ })),
555
+ getEvent: () => null,
556
+ audit: () => {},
557
+ };
558
+ const llm = new MockLLMClient({ reply: "" });
559
+ const engine = new AnalysisEngine({
560
+ vault: fakeVault, llm,
561
+ maxFacts: 20, maxQueryLimit: 50,
562
+ });
563
+ const r = await engine.ask("hi"); // generic question — default path
564
+ // 20 * 0.2 = 4 persons, 20 * 0.1 = 2 items, remainder 14 for events.
565
+ expect(r.facts.filter((f) => f.type === "event").length).toBe(14);
566
+ expect(r.facts.filter((f) => f.type === "person").length).toBe(4);
567
+ expect(r.facts.filter((f) => f.type === "item").length).toBe(2);
568
+ });
569
+ });
570
+
571
+ // ─── entityFocus routing — persons / items table priority ────────────────
572
+ //
573
+ // 2026-05-27 fix: when the question is explicitly about contacts ("我有
574
+ // 哪些联系人", "妈手机号"), _gatherFacts must NOT compete persons against
575
+ // the events pool. Pre-fix Android small-model budgets (20 facts / 50 row
576
+ // cap) had events drown out the contact slice → user saw "没数据" even
577
+ // when the vault held hundreds of contacts.
578
+
579
+ describe("AnalysisEngine._gatherFacts entityFocus routing", () => {
580
+ it("entityFocus=persons skips events broad scan, prioritizes persons", async () => {
581
+ const fakeVault = {
582
+ queryEvents: vi.fn(() => Array.from({ length: 50 }, (_, i) => ({
583
+ id: "e" + i, type: "event", subtype: "message",
584
+ occurredAt: Date.now(), actor: "self",
585
+ ingestedAt: Date.now(),
586
+ source: { adapter: "wechat", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
587
+ }))),
588
+ queryPersons: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
589
+ id: "p" + i, type: "person", subtype: "contact",
590
+ names: ["联系人" + i], ingestedAt: Date.now(),
591
+ source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
592
+ }))),
593
+ queryItems: vi.fn(() => []),
594
+ getEvent: () => null,
595
+ audit: () => {},
596
+ };
597
+ const llm = new MockLLMClient({ reply: "" });
598
+ const engine = new AnalysisEngine({
599
+ vault: fakeVault, llm,
600
+ maxFacts: 20, maxQueryLimit: 50,
601
+ });
602
+ const r = await engine.ask("我有哪些联系人");
603
+ // 95% goes to persons (19), 5% headroom = 1 event slot.
604
+ expect(r.facts.filter((f) => f.type === "person").length).toBe(19);
605
+ expect(r.facts.filter((f) => f.type === "event").length).toBeLessThanOrEqual(1);
606
+ expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 19 });
607
+ });
608
+
609
+ it("entityFocus=persons falls through to default path when persons table is empty", async () => {
610
+ const fakeVault = {
611
+ queryEvents: () => Array.from({ length: 5 }, (_, i) => ({
612
+ id: "e" + i, type: "event", subtype: "message",
613
+ occurredAt: Date.now(), actor: "self",
614
+ ingestedAt: Date.now(),
615
+ source: { adapter: "wechat", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
616
+ })),
617
+ queryPersons: () => [], // empty contacts table
618
+ queryItems: () => [],
619
+ getEvent: () => null,
620
+ audit: () => {},
621
+ };
622
+ const llm = new MockLLMClient({ reply: "" });
623
+ const engine = new AnalysisEngine({ vault: fakeVault, llm });
624
+ const r = await engine.ask("我有哪些联系人");
625
+ // Fell through to default → 5 events surfaced (no cap since 5 < 80).
626
+ expect(r.facts.filter((f) => f.type === "event").length).toBe(5);
627
+ });
628
+
629
+ it("entityFocus=persons with name candidate → searchPersons short-circuit", async () => {
630
+ // 2026-05-27 S3 治本 — "妈手机号" must hit searchPersons LIKE search
631
+ // even when vault holds 500 contacts. Pre-S3 _gatherFacts dumped the
632
+ // first N by ingest_at; the target person rarely landed in the slice.
633
+ const fakeVault = {
634
+ queryEvents: () => [],
635
+ queryPersons: vi.fn(() => [
636
+ { id: "p-other", type: "person", subtype: "contact", names: ["张三"], ingestedAt: 0,
637
+ source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" } },
638
+ ]),
639
+ searchPersons: vi.fn(({ q, limit }) => {
640
+ if (q === "妈") {
641
+ return [{
642
+ id: "p-mom", type: "person", subtype: "contact", names: ["妈妈"],
643
+ identifiers: { phone: ["13800138000"] }, ingestedAt: 0,
644
+ source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
645
+ }];
646
+ }
647
+ return [];
648
+ }),
649
+ queryItems: () => [],
650
+ getEvent: () => null,
651
+ audit: () => {},
652
+ };
653
+ const llm = new MockLLMClient({ reply: "妈手机号是 13800138000" });
654
+ const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
655
+ const r = await engine.ask("妈手机号是多少");
656
+ expect(fakeVault.searchPersons).toHaveBeenCalledWith({ q: "妈", limit: 19 });
657
+ expect(fakeVault.queryPersons).not.toHaveBeenCalled(); // search hit → skip fallback
658
+ expect(r.facts.filter((f) => f.type === "person").length).toBe(1);
659
+ expect(r.facts.find((f) => f.id === "p-mom")).toBeDefined();
660
+ });
661
+
662
+ it("entityFocus=persons with name candidate but 0 search hits → falls back to queryPersons", async () => {
663
+ const fakeVault = {
664
+ queryEvents: () => [],
665
+ queryPersons: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
666
+ id: "p" + i, type: "person", subtype: "contact", names: ["P" + i], ingestedAt: 0,
667
+ source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
668
+ }))),
669
+ searchPersons: vi.fn(() => []), // 0 hits
670
+ queryItems: () => [],
671
+ getEvent: () => null,
672
+ audit: () => {},
673
+ };
674
+ const llm = new MockLLMClient({ reply: "" });
675
+ const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
676
+ await engine.ask("张三的电话号码");
677
+ expect(fakeVault.searchPersons).toHaveBeenCalled();
678
+ expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 19 });
679
+ });
680
+
681
+ it("entityFocus=persons without name candidate (pure list) skips searchPersons", async () => {
682
+ const fakeVault = {
683
+ queryEvents: () => [],
684
+ queryPersons: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
685
+ id: "p" + i, type: "person", subtype: "contact", names: ["P" + i], ingestedAt: 0,
686
+ source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
687
+ }))),
688
+ searchPersons: vi.fn(() => []),
689
+ queryItems: () => [],
690
+ getEvent: () => null,
691
+ audit: () => {},
692
+ };
693
+ const llm = new MockLLMClient({ reply: "" });
694
+ const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
695
+ await engine.ask("我有哪些联系人");
696
+ // Pure list — no name in question → skip searchPersons, go straight to queryPersons.
697
+ expect(fakeVault.searchPersons).not.toHaveBeenCalled();
698
+ expect(fakeVault.queryPersons).toHaveBeenCalledWith({ limit: 19 });
699
+ });
700
+
701
+ it("entityFocus=persons tolerates vault without searchPersons (legacy)", async () => {
702
+ const fakeVault = {
703
+ queryEvents: () => [],
704
+ queryPersons: vi.fn(({ limit }) => Array.from({ length: Math.min(limit, 3) }, (_, i) => ({
705
+ id: "p" + i, type: "person", subtype: "contact", names: ["P" + i], ingestedAt: 0,
706
+ source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: 0, capturedBy: "api" },
707
+ }))),
708
+ // No searchPersons method
709
+ queryItems: () => [],
710
+ getEvent: () => null,
711
+ audit: () => {},
712
+ };
713
+ const llm = new MockLLMClient({ reply: "" });
714
+ const engine = new AnalysisEngine({ vault: fakeVault, llm, maxFacts: 20 });
715
+ const r = await engine.ask("妈手机号");
716
+ expect(fakeVault.queryPersons).toHaveBeenCalled();
717
+ expect(r.facts.filter((f) => f.type === "person").length).toBe(3);
718
+ });
719
+
720
+ it("entityFocus=items prioritizes items table over events", async () => {
721
+ const fakeVault = {
722
+ queryEvents: () => Array.from({ length: 100 }, (_, i) => ({
723
+ id: "e" + i, type: "event", subtype: "browse",
724
+ occurredAt: Date.now(), actor: "self",
725
+ ingestedAt: Date.now(),
726
+ source: { adapter: "browser-history", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
727
+ })),
728
+ queryPersons: () => [],
729
+ queryItems: vi.fn(({ limit }) => Array.from({ length: limit }, (_, i) => ({
730
+ id: "i" + i, type: "item", subtype: "other", name: "App" + i,
731
+ ingestedAt: Date.now(),
732
+ source: { adapter: "system-data-android", adapterVersion: "0", capturedAt: Date.now(), capturedBy: "api" },
733
+ }))),
734
+ getEvent: () => null,
735
+ audit: () => {},
736
+ };
737
+ const llm = new MockLLMClient({ reply: "" });
738
+ const engine = new AnalysisEngine({
739
+ vault: fakeVault, llm,
740
+ maxFacts: 20, maxQueryLimit: 50,
741
+ });
742
+ const r = await engine.ask("我装了哪些 app");
743
+ expect(r.facts.filter((f) => f.type === "item").length).toBe(19);
744
+ expect(fakeVault.queryItems).toHaveBeenCalledWith({ limit: 19 });
522
745
  });
523
746
  });
524
747
 
@@ -682,10 +905,12 @@ describe("AnalysisEngine per-call budget overrides", () => {
682
905
  const llm = { isLocal: true, chat: () => { throw new Error("nope"); } };
683
906
  const engine = new AnalysisEngine({ vault: fakeVault, llm });
684
907
  const r = await engine.retrieveContext("hi", { maxFacts: 10, maxQueryLimit: 50 });
685
- // _gatherFacts returns 50 events, but buildPrompt caps factCount to maxFacts=10.
686
- // `truncated` is a count of dropped facts, not a boolean.
908
+ // 2026-05-27 fix: _gatherFacts now respects effMaxFacts upstream
909
+ // (events would have overflowed reservation branch; persons/items
910
+ // returned [] → refill back to events.slice(0,10)). buildPrompt sees
911
+ // exactly 10 facts, nothing to truncate.
687
912
  expect(r.factCount).toBe(10);
688
- expect(r.truncated).toBe(40); // 50 gathered - 10 kept = 40 truncated
913
+ expect(r.truncated).toBe(0);
689
914
  });
690
915
 
691
916
  it("retrieveContext() honors options.maxFacts and options.maxQueryLimit", async () => {