@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
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Phase 11 — internal analysis skills entry point.
3
+ *
4
+ * Each skill is a small focused class that the hub dispatches to via
5
+ * `runAnalysisSkill(name, options)`. Skills work over the vault +
6
+ * optional LLM and respect the same privacy gate as AnalysisEngine.
7
+ */
8
+
9
+ "use strict";
10
+
11
+ const { AnalysisSkill } = require("./base");
12
+ const { SpendingSkill, SUPPORTED_DIMENSIONS: SPENDING_DIMENSIONS } = require("./spending");
13
+ const { RelationsSkill } = require("./relations");
14
+ const { FootprintSkill } = require("./footprint");
15
+ const { InterestsSkill } = require("./interests");
16
+ const { TimelineSkill } = require("./timeline");
17
+
18
+ const SKILL_REGISTRY = Object.freeze({
19
+ "analysis.spending": SpendingSkill,
20
+ "analysis.relations": RelationsSkill,
21
+ "analysis.footprint": FootprintSkill,
22
+ "analysis.interests": InterestsSkill,
23
+ "analysis.timeline": TimelineSkill,
24
+ });
25
+
26
+ const SKILL_NAMES = Object.freeze(Object.keys(SKILL_REGISTRY));
27
+
28
+ /**
29
+ * Run a single skill by name. Convenience over instantiating Skill
30
+ * classes directly — the same {vault, llm} pair gets reused.
31
+ *
32
+ * @param {{vault, llm?}} deps
33
+ * @param {string} skillName
34
+ * @param {object} options
35
+ */
36
+ async function runAnalysisSkill(deps, skillName, options = {}) {
37
+ if (!deps || !deps.vault) throw new Error("runAnalysisSkill: deps.vault required");
38
+ const Cls = SKILL_REGISTRY[skillName];
39
+ if (!Cls) {
40
+ throw new Error(`unknown analysis skill: ${skillName}. Known: ${SKILL_NAMES.join(", ")}`);
41
+ }
42
+ const skill = new Cls({ vault: deps.vault, llm: deps.llm });
43
+ return await skill.run(options);
44
+ }
45
+
46
+ module.exports = {
47
+ AnalysisSkill,
48
+ SpendingSkill,
49
+ RelationsSkill,
50
+ FootprintSkill,
51
+ InterestsSkill,
52
+ TimelineSkill,
53
+ SKILL_REGISTRY,
54
+ SKILL_NAMES,
55
+ ANALYSIS_SKILL_NAMES: SKILL_NAMES,
56
+ SPENDING_DIMENSIONS,
57
+ runAnalysisSkill,
58
+ };
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Phase 11 — analysis.interests skill.
3
+ *
4
+ * Extracts the user's interest profile from:
5
+ * - Topic entities (already-categorized by adapter)
6
+ * - Item entities (product / content names)
7
+ * - Event content.title from order/payment/visit events
8
+ *
9
+ * LLM is used to cluster + name interest categories. Without LLM,
10
+ * falls back to topic-frequency + most-purchased-item ranking (no
11
+ * generalization).
12
+ *
13
+ * Inputs:
14
+ * - timeWindow: optional; default all-time
15
+ * - topN: default 15
16
+ *
17
+ * Output:
18
+ * {
19
+ * topTopics: [{ name, eventCount, lastSeen }, ...],
20
+ * topItems: [{ name, occurrences, totalSpend }, ...],
21
+ * llmInterests?: [{ category, evidenceCount, examples }, ...],
22
+ * citations,
23
+ * llm_commentary,
24
+ * }
25
+ */
26
+
27
+ "use strict";
28
+
29
+ const { AnalysisSkill } = require("./base");
30
+
31
+ class InterestsSkill extends AnalysisSkill {
32
+ constructor(opts) {
33
+ super({ ...opts, name: "analysis.interests" });
34
+ }
35
+
36
+ async run(options = {}) {
37
+ const { since, until } = this.resolveTimeWindow(options);
38
+ const topN = Number.isFinite(options.topN) && options.topN > 0 ? options.topN : 15;
39
+
40
+ const topTopics = this._topTopics(since, until, topN);
41
+ const topItems = this._topItems(since, until, topN);
42
+ const events = this._sampleEvents(since, until, 200);
43
+ const llmInterests = (options.commentary !== false && this.llm)
44
+ ? await this._clusterInterests(topTopics, topItems, events, options)
45
+ : null;
46
+
47
+ return {
48
+ skill: "analysis.interests",
49
+ topTopics,
50
+ topItems,
51
+ llmInterests,
52
+ citations: events.slice(0, 50).map((e) => e.id),
53
+ llm_commentary: null,
54
+ };
55
+ }
56
+
57
+ _topTopics(since, until, topN) {
58
+ // Topics are stored in their own table — eventCount is derived from
59
+ // the JSON `derived_from_events` array length; lastSeen is the
60
+ // topic's ingested_at (proxy until we add a real last_seen column).
61
+ let topics = [];
62
+ try {
63
+ const db = this.vault._requireOpen();
64
+ topics = db.prepare(
65
+ "SELECT id, name, derived_from_events, ingested_at FROM topics ORDER BY ingested_at DESC LIMIT ?"
66
+ ).all(topN * 3);
67
+ } catch (_e) {
68
+ // Older vaults may not have topics; non-fatal.
69
+ }
70
+ const mapped = topics.map((t) => {
71
+ let eventCount = 0;
72
+ try {
73
+ const arr = t.derived_from_events ? JSON.parse(t.derived_from_events) : [];
74
+ if (Array.isArray(arr)) eventCount = arr.length;
75
+ } catch (_e) {}
76
+ return {
77
+ id: t.id,
78
+ name: t.name,
79
+ eventCount,
80
+ lastSeen: t.ingested_at || null,
81
+ };
82
+ });
83
+ return mapped
84
+ .sort((a, b) => (b.eventCount - a.eventCount) || ((b.lastSeen || 0) - (a.lastSeen || 0)))
85
+ .slice(0, topN);
86
+ }
87
+
88
+ _topItems(since, until, topN) {
89
+ let items = [];
90
+ try {
91
+ const db = this.vault._requireOpen();
92
+ items = db.prepare(
93
+ "SELECT id, name FROM items ORDER BY ingested_at DESC LIMIT ?"
94
+ ).all(topN * 3);
95
+ } catch (_e) {}
96
+ // Re-bucket by name (multiple Item rows often share the same product
97
+ // name across adapters). Phase 8 EntityResolver doesn't dedup items
98
+ // yet — that's Phase 9+.
99
+ const buckets = new Map();
100
+ for (const row of items) {
101
+ const item = this.vault.getItem ? this.vault.getItem(row.id) : null;
102
+ if (!item) continue;
103
+ const key = item.name || "(unknown)";
104
+ const cur = buckets.get(key) || { name: key, occurrences: 0, totalSpend: 0 };
105
+ cur.occurrences += 1;
106
+ if (item.price && Number.isFinite(item.price.value)) cur.totalSpend += item.price.value;
107
+ buckets.set(key, cur);
108
+ }
109
+ return Array.from(buckets.values())
110
+ .sort((a, b) => b.occurrences - a.occurrences)
111
+ .slice(0, topN)
112
+ .map((b) => ({ ...b, totalSpend: Math.round(b.totalSpend * 100) / 100 }));
113
+ }
114
+
115
+ _sampleEvents(since, until, limit) {
116
+ const q = { limit };
117
+ if (since != null) q.since = since;
118
+ if (until != null) q.until = until;
119
+ return this.vault.queryEvents(q) || [];
120
+ }
121
+
122
+ async _clusterInterests(topTopics, topItems, events, options) {
123
+ if (topTopics.length === 0 && topItems.length === 0) return null;
124
+ const userMsg = `用户的互动数据样本:
125
+
126
+ Topics (按出现频次):
127
+ ${topTopics.slice(0, 10).map((t) => `- ${t.name} (${t.eventCount}次)`).join("\n") || "(无)"}
128
+
129
+ Items (购买/收到):
130
+ ${topItems.slice(0, 10).map((i) => `- ${i.name} (${i.occurrences}次, ¥${i.totalSpend})`).join("\n") || "(无)"}
131
+
132
+ 最近事件 titles (抽样):
133
+ ${events.slice(0, 20).map((e) => `- ${e.content?.title || "(无标题)"}`).join("\n")}
134
+
135
+ 请将以上抽 3-5 个兴趣类别(如"咖啡"、"科技阅读"、"户外旅行"),每个给出 1-2 个 evidence 引用。
136
+ 输出 JSON 数组:[{"category": "类别名", "evidenceCount": N, "examples": ["..."]}, ...]
137
+ 只输出 JSON,不要其它文字。`;
138
+
139
+ const resp = await this.callLlmCommentary([
140
+ { role: "system", content: "你是一个克制的兴趣画像分析助手。基于明示数据归纳类别,不臆造。" },
141
+ { role: "user", content: userMsg },
142
+ ], { acceptNonLocal: options.acceptNonLocal });
143
+
144
+ if (!resp) return null;
145
+ // Parse JSON array (strict → fenced → regex)
146
+ try {
147
+ return JSON.parse(resp.trim());
148
+ } catch (_e) {}
149
+ const fence = resp.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/);
150
+ if (fence) {
151
+ try { return JSON.parse(fence[1].trim()); } catch (_e) {}
152
+ }
153
+ const arrMatch = resp.match(/\[\s*\{[\s\S]*?\}\s*\]/);
154
+ if (arrMatch) {
155
+ try { return JSON.parse(arrMatch[0]); } catch (_e) {}
156
+ }
157
+ return null;
158
+ }
159
+ }
160
+
161
+ module.exports = { InterestsSkill };
@@ -0,0 +1,226 @@
1
+ /**
2
+ * Phase 11 — analysis.relations skill.
3
+ *
4
+ * Per-Person interaction profile. Either:
5
+ * - `personId`: scope to one specific Person (uses merge-group expansion
6
+ * so cross-source identities count together) — returns single
7
+ * person's interaction profile vs self.
8
+ * - no `personId`: ranks ALL Persons by total interaction count and
9
+ * returns the top-N.
10
+ *
11
+ * "Interaction" = any Event where the Person is actor or participant.
12
+ * Counts include payments, messages, emails — adapter-agnostic.
13
+ *
14
+ * Output:
15
+ * {
16
+ * mode: "single" | "ranked",
17
+ * personId: ...,
18
+ * profile?: { // single mode
19
+ * personId, names,
20
+ * totalInteractions, byAdapter, byMonth,
21
+ * outboundCount, inboundCount, outboundShare,
22
+ * totalSpend, totalIncome,
23
+ * firstInteraction, lastInteraction,
24
+ * },
25
+ * ranked?: [...], // ranked mode
26
+ * citations,
27
+ * llm_commentary,
28
+ * }
29
+ */
30
+
31
+ "use strict";
32
+
33
+ const { AnalysisSkill } = require("./base");
34
+
35
+ class RelationsSkill extends AnalysisSkill {
36
+ constructor(opts) {
37
+ super({ ...opts, name: "analysis.relations" });
38
+ }
39
+
40
+ async run(options = {}) {
41
+ if (typeof options.personId === "string" && options.personId.length > 0) {
42
+ return await this._runSingle(options);
43
+ }
44
+ return await this._runRanked(options);
45
+ }
46
+
47
+ async _runSingle(options) {
48
+ const { since, until } = this.resolveTimeWindow(options);
49
+ const members = this.expandToMergeGroup(options.personId);
50
+ const memberSet = new Set(members);
51
+
52
+ const events = this._fetchAllRelevant({ since, until, memberSet });
53
+ const profile = this._buildProfile(options.personId, members, events);
54
+ const citations = events.slice(0, 50).map((e) => e.id);
55
+
56
+ let llmCommentary = null;
57
+ if (options.commentary !== false && this.llm) {
58
+ llmCommentary = await this._llmCommentary(profile, options);
59
+ }
60
+
61
+ return {
62
+ skill: "analysis.relations",
63
+ mode: "single",
64
+ personId: options.personId,
65
+ profile,
66
+ citations,
67
+ llm_commentary: llmCommentary,
68
+ };
69
+ }
70
+
71
+ async _runRanked(options) {
72
+ const { since, until } = this.resolveTimeWindow(options);
73
+ const topN = Number.isFinite(options.topN) && options.topN > 0 ? options.topN : 20;
74
+
75
+ // Pull all events in window then bucket by counterparty
76
+ const allEvents = this._fetchAllRelevant({ since, until, memberSet: null });
77
+ const buckets = new Map();
78
+ for (const e of allEvents) {
79
+ const ids = (e.participants || []).concat(e.actor ? [e.actor] : []);
80
+ for (const pid of new Set(ids)) {
81
+ if (pid === "person-self" || !pid) continue;
82
+ const cur = buckets.get(pid) || {
83
+ personId: pid, totalInteractions: 0, totalSpend: 0, totalIncome: 0,
84
+ byAdapter: {}, firstSeen: e.occurredAt, lastSeen: e.occurredAt,
85
+ };
86
+ cur.totalInteractions += 1;
87
+ if (e.content?.amount?.direction === "out") cur.totalSpend += e.content.amount.value;
88
+ if (e.content?.amount?.direction === "in") cur.totalIncome += e.content.amount.value;
89
+ const adapter = (e.source && e.source.adapter) || "unknown";
90
+ cur.byAdapter[adapter] = (cur.byAdapter[adapter] || 0) + 1;
91
+ if (e.occurredAt < cur.firstSeen) cur.firstSeen = e.occurredAt;
92
+ if (e.occurredAt > cur.lastSeen) cur.lastSeen = e.occurredAt;
93
+ buckets.set(pid, cur);
94
+ }
95
+ }
96
+ // Resolve display names per top bucket; ignore self if it sneaks in
97
+ const ranked = Array.from(buckets.values())
98
+ .sort((a, b) => b.totalInteractions - a.totalInteractions)
99
+ .slice(0, topN)
100
+ .map((b) => ({
101
+ ...b,
102
+ totalSpend: Math.round(b.totalSpend * 100) / 100,
103
+ totalIncome: Math.round(b.totalIncome * 100) / 100,
104
+ name: this._lookupName(b.personId),
105
+ }));
106
+
107
+ return {
108
+ skill: "analysis.relations",
109
+ mode: "ranked",
110
+ ranked,
111
+ citations: allEvents.slice(0, 50).map((e) => e.id),
112
+ llm_commentary: null,
113
+ };
114
+ }
115
+
116
+ _fetchAllRelevant({ since, until, memberSet }) {
117
+ // No subtype filter — relations cares about ALL events. Limit guards
118
+ // memory for big vaults.
119
+ const q = { limit: 10_000 };
120
+ if (since != null) q.since = since;
121
+ if (until != null) q.until = until;
122
+ const events = this.vault.queryEvents(q) || [];
123
+ if (!memberSet) return events;
124
+ return events.filter((e) => {
125
+ if (memberSet.has(e.actor)) return true;
126
+ if (Array.isArray(e.participants) && e.participants.some((p) => memberSet.has(p))) return true;
127
+ return false;
128
+ });
129
+ }
130
+
131
+ _buildProfile(personId, members, events) {
132
+ let outboundCount = 0;
133
+ let inboundCount = 0;
134
+ let totalSpend = 0;
135
+ let totalIncome = 0;
136
+ let firstInteraction = Infinity;
137
+ let lastInteraction = -Infinity;
138
+ const byAdapter = {};
139
+ const byMonth = {};
140
+ const memberSet = new Set(members);
141
+
142
+ for (const e of events) {
143
+ const t = e.occurredAt || 0;
144
+ if (t < firstInteraction) firstInteraction = t;
145
+ if (t > lastInteraction) lastInteraction = t;
146
+ // Outbound = self → them (actor=self, target=them); inbound = them → self
147
+ if (e.actor === "person-self" || memberSet.has(e.actor)) {
148
+ if (e.actor === "person-self") outboundCount += 1;
149
+ else inboundCount += 1;
150
+ } else {
151
+ // Participant-only event; counts as both? Most adapters keep
152
+ // actor+participants consistent so this branch is rare.
153
+ outboundCount += 1;
154
+ }
155
+ if (e.content?.amount) {
156
+ if (e.content.amount.direction === "out") totalSpend += e.content.amount.value;
157
+ else if (e.content.amount.direction === "in") totalIncome += e.content.amount.value;
158
+ }
159
+ const adapter = (e.source && e.source.adapter) || "unknown";
160
+ byAdapter[adapter] = (byAdapter[adapter] || 0) + 1;
161
+ const d = new Date(t);
162
+ if (Number.isFinite(d.getTime())) {
163
+ const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
164
+ byMonth[m] = (byMonth[m] || 0) + 1;
165
+ }
166
+ }
167
+ const total = outboundCount + inboundCount;
168
+
169
+ return {
170
+ personId,
171
+ members,
172
+ names: this._lookupNames(members),
173
+ totalInteractions: total,
174
+ byAdapter,
175
+ byMonth,
176
+ outboundCount,
177
+ inboundCount,
178
+ outboundShare: total > 0 ? Math.round((outboundCount / total) * 100) / 100 : 0,
179
+ totalSpend: Math.round(totalSpend * 100) / 100,
180
+ totalIncome: Math.round(totalIncome * 100) / 100,
181
+ firstInteraction: firstInteraction === Infinity ? null : firstInteraction,
182
+ lastInteraction: lastInteraction === -Infinity ? null : lastInteraction,
183
+ };
184
+ }
185
+
186
+ _lookupName(personId) {
187
+ try {
188
+ const p = this.vault.getPerson ? this.vault.getPerson(personId) : null;
189
+ return (p && p.names && p.names[0]) || personId;
190
+ } catch (_e) {
191
+ return personId;
192
+ }
193
+ }
194
+
195
+ _lookupNames(personIds) {
196
+ const set = new Set();
197
+ for (const id of personIds) {
198
+ try {
199
+ const p = this.vault.getPerson ? this.vault.getPerson(id) : null;
200
+ if (p && Array.isArray(p.names)) {
201
+ for (const n of p.names) if (n) set.add(n);
202
+ }
203
+ } catch (_e) {}
204
+ }
205
+ return Array.from(set);
206
+ }
207
+
208
+ async _llmCommentary(profile, options) {
209
+ if (!profile.totalInteractions) return "No interactions found in this period.";
210
+ const userMsg = `分析与某人的关系:
211
+ - 姓名/别名: ${profile.names.join(", ") || profile.personId}
212
+ - 互动总数: ${profile.totalInteractions}
213
+ - 主动占比: ${(profile.outboundShare * 100).toFixed(0)}% (${profile.outboundCount} 主动 vs ${profile.inboundCount} 收到)
214
+ - 钱款来往: 支出 ¥${profile.totalSpend} / 收入 ¥${profile.totalIncome}
215
+ - 跨源: ${Object.keys(profile.byAdapter).join(", ")}
216
+ - 时间跨度: ${profile.firstInteraction ? new Date(profile.firstInteraction).toISOString().slice(0,10) : "?"} 到 ${profile.lastInteraction ? new Date(profile.lastInteraction).toISOString().slice(0,10) : "?"}
217
+
218
+ 请用 2-3 句话总结关系特征(亲密 / 疏远 / 单向 / 平等)。中文回答。`;
219
+ return await this.callLlmCommentary([
220
+ { role: "system", content: "你是一个克制的人际分析助手。仅基于提供的数据给出温和的描述性总结,不评价、不臆断情感。" },
221
+ { role: "user", content: userMsg },
222
+ ], { acceptNonLocal: options.acceptNonLocal });
223
+ }
224
+ }
225
+
226
+ module.exports = { RelationsSkill };
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Phase 11 — analysis.spending skill.
3
+ *
4
+ * Inputs:
5
+ * - timeWindow: { since, until } | { sinceDays N } | { sinceMonths N }
6
+ * - dimension: "merchant" | "category" | "counterparty" | "month"
7
+ * Default "merchant".
8
+ * - merchantFilter: optional substring (e.g. "美团" to scope to one
9
+ * merchant family)
10
+ * - personId: optional — scope to spending TO this person (uses
11
+ * merge-group expansion)
12
+ * - topN: default 10
13
+ *
14
+ * Output:
15
+ * {
16
+ * summary: {
17
+ * totalSpend, totalIncome, netFlow, currency,
18
+ * eventCount, uniqueCounterparties, period,
19
+ * },
20
+ * breakdown: [{ key, totalSpend, eventCount, percentOfTotal }, ...],
21
+ * trend: [{ monthKey, totalSpend, eventCount }, ...],
22
+ * citations: [eventId, ...],
23
+ * llm_commentary: "..." | null,
24
+ * }
25
+ */
26
+
27
+ "use strict";
28
+
29
+ const { AnalysisSkill } = require("./base");
30
+
31
+ const SUPPORTED_DIMENSIONS = new Set(["merchant", "category", "counterparty", "month"]);
32
+
33
+ class SpendingSkill extends AnalysisSkill {
34
+ constructor(opts) {
35
+ super({ ...opts, name: "analysis.spending" });
36
+ }
37
+
38
+ async run(options = {}) {
39
+ const { since, until } = this.resolveTimeWindow(options);
40
+ const dimension = SUPPORTED_DIMENSIONS.has(options.dimension)
41
+ ? options.dimension
42
+ : "merchant";
43
+ const topN = Number.isFinite(options.topN) && options.topN > 0 ? options.topN : 10;
44
+
45
+ // Pull events with subtype = payment / transfer / refund / utility /
46
+ // redenvelope / investment / income. These are the ones with content.amount.
47
+ const events = this._fetchPaymentEvents({ since, until });
48
+ const filtered = this._applyFilters(events, options);
49
+
50
+ const summary = this._summarize(filtered, since, until);
51
+ const breakdown = this._breakdown(filtered, dimension, topN);
52
+ const trend = this._monthlyTrend(filtered);
53
+ const citations = filtered.slice(0, 50).map((e) => e.id);
54
+
55
+ let llmCommentary = null;
56
+ if (options.commentary !== false && this.llm) {
57
+ llmCommentary = await this._llmCommentary(summary, breakdown, dimension, options);
58
+ }
59
+
60
+ return {
61
+ skill: "analysis.spending",
62
+ summary,
63
+ breakdown,
64
+ trend,
65
+ citations,
66
+ llm_commentary: llmCommentary,
67
+ };
68
+ }
69
+
70
+ _fetchPaymentEvents({ since, until }) {
71
+ const events = [];
72
+ // Phase 7 shopping adapters emit subtype="order" — must include so
73
+ // spending aggregates cover Taobao/JD/Meituan along with Alipay
74
+ // (payment/transfer) + Email (refund) etc.
75
+ const subtypes = ["payment", "transfer", "refund", "utility", "redenvelope", "investment", "income", "order"];
76
+ for (const subtype of subtypes) {
77
+ const q = { subtype, limit: 5000 };
78
+ if (since != null) q.since = since;
79
+ if (until != null) q.until = until;
80
+ const batch = this.vault.queryEvents(q) || [];
81
+ for (const e of batch) {
82
+ // queryEvents may strip extra; we already get full row from vault
83
+ if (e && e.content && e.content.amount && Number.isFinite(e.content.amount.value)) {
84
+ events.push(e);
85
+ }
86
+ }
87
+ }
88
+ return events;
89
+ }
90
+
91
+ _applyFilters(events, options) {
92
+ let out = events;
93
+ if (typeof options.merchantFilter === "string" && options.merchantFilter.length > 0) {
94
+ const needle = options.merchantFilter.toLowerCase();
95
+ out = out.filter((e) => {
96
+ const title = (e.content && e.content.title) || "";
97
+ const counterparty = (e.extra && e.extra.counterparty) || "";
98
+ return title.toLowerCase().includes(needle)
99
+ || counterparty.toLowerCase().includes(needle);
100
+ });
101
+ }
102
+ if (typeof options.personId === "string" && options.personId.length > 0) {
103
+ const memberSet = new Set(this.expandToMergeGroup(options.personId));
104
+ out = out.filter((e) => {
105
+ if (memberSet.has(e.actor)) return true;
106
+ if (Array.isArray(e.participants) && e.participants.some((p) => memberSet.has(p))) return true;
107
+ return false;
108
+ });
109
+ }
110
+ if (options.direction === "out" || options.direction === "in") {
111
+ out = out.filter((e) => e.content.amount.direction === options.direction);
112
+ }
113
+ return out;
114
+ }
115
+
116
+ _summarize(events, since, until) {
117
+ let totalSpend = 0;
118
+ let totalIncome = 0;
119
+ const counterparties = new Set();
120
+ for (const e of events) {
121
+ const v = e.content.amount.value;
122
+ if (e.content.amount.direction === "in") totalIncome += v;
123
+ else if (e.content.amount.direction === "out") totalSpend += v;
124
+ // Identify counterparty for distinctness
125
+ const cp = (e.extra && e.extra.counterparty) || e.actor;
126
+ if (cp && cp !== "person-self") counterparties.add(cp);
127
+ }
128
+ return {
129
+ totalSpend: Math.round(totalSpend * 100) / 100,
130
+ totalIncome: Math.round(totalIncome * 100) / 100,
131
+ netFlow: Math.round((totalIncome - totalSpend) * 100) / 100,
132
+ currency: events[0]?.content?.amount?.currency || "CNY",
133
+ eventCount: events.length,
134
+ uniqueCounterparties: counterparties.size,
135
+ period: { since: since || null, until: until || null },
136
+ };
137
+ }
138
+
139
+ _breakdown(events, dimension, topN) {
140
+ const buckets = new Map();
141
+ for (const e of events) {
142
+ // Only count "out" for spending breakdown — income tracked separately
143
+ if (e.content.amount.direction !== "out") continue;
144
+ const key = this._keyFor(e, dimension);
145
+ if (!key) continue;
146
+ const cur = buckets.get(key) || { key, totalSpend: 0, eventCount: 0 };
147
+ cur.totalSpend += e.content.amount.value;
148
+ cur.eventCount += 1;
149
+ buckets.set(key, cur);
150
+ }
151
+ const totalOut = Array.from(buckets.values()).reduce((s, b) => s + b.totalSpend, 0);
152
+ const sorted = Array.from(buckets.values())
153
+ .map((b) => ({
154
+ ...b,
155
+ totalSpend: Math.round(b.totalSpend * 100) / 100,
156
+ percentOfTotal: totalOut > 0 ? Math.round((b.totalSpend / totalOut) * 1000) / 10 : 0,
157
+ }))
158
+ .sort((a, b) => b.totalSpend - a.totalSpend)
159
+ .slice(0, topN);
160
+ return sorted;
161
+ }
162
+
163
+ _monthlyTrend(events) {
164
+ const buckets = new Map();
165
+ for (const e of events) {
166
+ if (e.content.amount.direction !== "out") continue;
167
+ const d = new Date(e.occurredAt);
168
+ if (!Number.isFinite(d.getTime())) continue;
169
+ const monthKey = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
170
+ const cur = buckets.get(monthKey) || { monthKey, totalSpend: 0, eventCount: 0 };
171
+ cur.totalSpend += e.content.amount.value;
172
+ cur.eventCount += 1;
173
+ buckets.set(monthKey, cur);
174
+ }
175
+ return Array.from(buckets.values())
176
+ .map((b) => ({ ...b, totalSpend: Math.round(b.totalSpend * 100) / 100 }))
177
+ .sort((a, b) => a.monthKey.localeCompare(b.monthKey));
178
+ }
179
+
180
+ _keyFor(event, dimension) {
181
+ if (dimension === "merchant" || dimension === "counterparty") {
182
+ return (event.extra && event.extra.counterparty)
183
+ || (event.content && event.content.title)
184
+ || "(unknown)";
185
+ }
186
+ if (dimension === "category") {
187
+ return (event.extra && event.extra.category)
188
+ || event.subtype
189
+ || "(uncategorized)";
190
+ }
191
+ if (dimension === "month") {
192
+ const d = new Date(event.occurredAt);
193
+ if (!Number.isFinite(d.getTime())) return "(unknown date)";
194
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
195
+ }
196
+ return null;
197
+ }
198
+
199
+ async _llmCommentary(summary, breakdown, dimension, options) {
200
+ if (summary.eventCount === 0) return "No spending events found in this period.";
201
+ const topItems = breakdown.slice(0, 5).map((b) => `${b.key} ¥${b.totalSpend} (${b.percentOfTotal}%)`).join(", ");
202
+ const periodStr = summary.period.since
203
+ ? `${new Date(summary.period.since).toISOString().slice(0, 10)} 至 ${new Date(summary.period.until).toISOString().slice(0, 10)}`
204
+ : "全部时间";
205
+ const userMsg = `用户的消费数据:
206
+ - 期间:${periodStr}
207
+ - 总支出 ¥${summary.totalSpend} (${summary.currency}), 总收入 ¥${summary.totalIncome}, 净流 ¥${summary.netFlow}
208
+ - 共 ${summary.eventCount} 笔交易, ${summary.uniqueCounterparties} 个独特对方
209
+ - 按 ${dimension} 排名 top 5:${topItems}
210
+
211
+ 请用 2-3 句话点评消费习惯,指出最大支出方向和异常(如有)。中文回答。`;
212
+ return await this.callLlmCommentary([
213
+ { role: "system", content: "你是一个理性、克制的财务分析助手。基于事实给出简短结论,不夸张、不臆断。" },
214
+ { role: "user", content: userMsg },
215
+ ], { acceptNonLocal: options.acceptNonLocal });
216
+ }
217
+ }
218
+
219
+ module.exports = { SpendingSkill, SUPPORTED_DIMENSIONS };