@chainlesschain/personal-data-hub 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/__tests__/adapters/ai-chat-history.test.js +395 -0
  2. package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
  3. package/__tests__/adapters/ai-chat-vendors.test.js +733 -0
  4. package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
  5. package/__tests__/adapters/email-adapter.test.js +138 -1
  6. package/__tests__/adapters/email-classifier.test.js +347 -0
  7. package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
  8. package/__tests__/adapters/email-retry-progress.test.js +294 -0
  9. package/__tests__/adapters/email-templates.test.js +699 -0
  10. package/__tests__/adapters/system-data-adapter.test.js +440 -0
  11. package/__tests__/adapters/system-data-disclosure.test.js +153 -0
  12. package/__tests__/analysis-skills.test.js +409 -0
  13. package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
  14. package/__tests__/entity-resolver-stages.test.js +411 -0
  15. package/__tests__/entity-resolver-vault.test.js +246 -0
  16. package/__tests__/entity-resolver.test.js +526 -0
  17. package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
  18. package/__tests__/longtail-adapters.test.js +217 -0
  19. package/__tests__/mobile-extractor.test.js +288 -0
  20. package/__tests__/shopping-adapters.test.js +296 -0
  21. package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
  22. package/__tests__/sidecar-supervisor.test.js +120 -0
  23. package/__tests__/social-adapters.test.js +206 -0
  24. package/__tests__/travel-adapters.test.js +325 -0
  25. package/__tests__/vault.test.js +3 -3
  26. package/__tests__/wechat-adapter.test.js +476 -0
  27. package/__tests__/whatsapp-adapter.test.js +135 -0
  28. package/lib/adapter-spec.js +12 -0
  29. package/lib/adapters/_python-sidecar-base.js +207 -0
  30. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +335 -0
  31. package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
  32. package/lib/adapters/ai-chat-history/http-client.js +211 -0
  33. package/lib/adapters/ai-chat-history/index.js +28 -0
  34. package/lib/adapters/ai-chat-history/schema-map.js +221 -0
  35. package/lib/adapters/ai-chat-history/vendor-spec.js +85 -0
  36. package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
  37. package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
  38. package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
  39. package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
  40. package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
  41. package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
  42. package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
  43. package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
  44. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +307 -0
  45. package/lib/adapters/alipay-bill/counterparty.js +129 -0
  46. package/lib/adapters/alipay-bill/csv-parser.js +217 -0
  47. package/lib/adapters/alipay-bill/index.js +41 -0
  48. package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
  49. package/lib/adapters/email-imap/classifier.js +495 -0
  50. package/lib/adapters/email-imap/email-adapter.js +419 -8
  51. package/lib/adapters/email-imap/index.js +42 -0
  52. package/lib/adapters/email-imap/pdf-extractor.js +192 -0
  53. package/lib/adapters/email-imap/templates/bill.js +232 -0
  54. package/lib/adapters/email-imap/templates/government.js +120 -0
  55. package/lib/adapters/email-imap/templates/index.js +78 -0
  56. package/lib/adapters/email-imap/templates/order.js +186 -0
  57. package/lib/adapters/email-imap/templates/other.js +114 -0
  58. package/lib/adapters/email-imap/templates/register.js +113 -0
  59. package/lib/adapters/email-imap/templates/travel.js +157 -0
  60. package/lib/adapters/email-imap/templates/utils.js +275 -0
  61. package/lib/adapters/email-imap/transactions.js +234 -0
  62. package/lib/adapters/messaging-qq/index.js +158 -0
  63. package/lib/adapters/messaging-telegram/index.js +142 -0
  64. package/lib/adapters/messaging-whatsapp/index.js +189 -0
  65. package/lib/adapters/shopping-base/index.js +208 -0
  66. package/lib/adapters/shopping-jd/index.js +150 -0
  67. package/lib/adapters/shopping-meituan/index.js +154 -0
  68. package/lib/adapters/shopping-taobao/index.js +176 -0
  69. package/lib/adapters/social-bilibili/index.js +171 -0
  70. package/lib/adapters/social-douyin/index.js +116 -0
  71. package/lib/adapters/social-weibo/index.js +164 -0
  72. package/lib/adapters/social-xiaohongshu/index.js +96 -0
  73. package/lib/adapters/system-data/disclosure.js +166 -0
  74. package/lib/adapters/system-data/index.js +34 -0
  75. package/lib/adapters/system-data/system-data-adapter.js +344 -0
  76. package/lib/adapters/travel-12306/index.js +151 -0
  77. package/lib/adapters/travel-amap/index.js +164 -0
  78. package/lib/adapters/travel-baidu-map/index.js +162 -0
  79. package/lib/adapters/travel-base/index.js +240 -0
  80. package/lib/adapters/travel-ctrip/index.js +151 -0
  81. package/lib/adapters/wechat/content-parser.js +326 -0
  82. package/lib/adapters/wechat/db-reader.js +209 -0
  83. package/lib/adapters/wechat/index.js +28 -0
  84. package/lib/adapters/wechat/key-extractor.js +158 -0
  85. package/lib/adapters/wechat/normalize.js +220 -0
  86. package/lib/adapters/wechat/wechat-adapter.js +205 -0
  87. package/lib/analysis-skills/base.js +113 -0
  88. package/lib/analysis-skills/footprint.js +167 -0
  89. package/lib/analysis-skills/index.js +58 -0
  90. package/lib/analysis-skills/interests.js +161 -0
  91. package/lib/analysis-skills/relations.js +226 -0
  92. package/lib/analysis-skills/spending.js +216 -0
  93. package/lib/analysis-skills/timeline.js +167 -0
  94. package/lib/entity-resolver/embedding-stage.js +198 -0
  95. package/lib/entity-resolver/entity-resolver.js +384 -0
  96. package/lib/entity-resolver/index.js +42 -0
  97. package/lib/entity-resolver/llm-stage.js +191 -0
  98. package/lib/entity-resolver/rule-stage.js +208 -0
  99. package/lib/entity-resolver/worker.js +149 -0
  100. package/lib/index.js +115 -0
  101. package/lib/migrations.js +73 -0
  102. package/lib/mobile-extractor/android.js +193 -0
  103. package/lib/mobile-extractor/index.js +9 -0
  104. package/lib/mobile-extractor/ios.js +223 -0
  105. package/lib/registry.js +42 -0
  106. package/lib/sidecar/index.js +15 -0
  107. package/lib/sidecar/supervisor.js +359 -0
  108. package/lib/vault.js +266 -0
  109. package/package.json +29 -3
  110. package/scripts/_make-fixture-all.js +126 -0
  111. package/scripts/_make-fixture-contacts.js +84 -0
  112. package/scripts/evaluate-entity-resolver.js +213 -0
  113. package/scripts/smoke-phase-5-5.js +196 -0
  114. package/scripts/smoke-phase-5-7.js +181 -0
  115. package/scripts/smoke-system-data-contacts.js +309 -0
  116. package/scripts/smoke-system-data.js +312 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Phase 11 — analysis.footprint skill.
3
+ *
4
+ * Travel + location patterns based on Place entities + Event records
5
+ * with location data. Phase 9 (Travel adapters) will substantially
6
+ * expand the source data; this v0 works with whatever Place rows
7
+ * adapters already produce (Phase 6 Alipay travel transactions /
8
+ * Phase 5 Email travel template).
9
+ *
10
+ * Inputs:
11
+ * - timeWindow: { since, until } | { sinceDays N } | { sinceMonths N }
12
+ * - topN: default 10
13
+ * - groupBy: "city" | "place" | "country" (default "place")
14
+ *
15
+ * Output:
16
+ * {
17
+ * summary: {
18
+ * totalTrips, uniquePlaces, period,
19
+ * },
20
+ * topPlaces: [{ name, visits, lastVisit, eventIds }, ...],
21
+ * monthlyDistribution: [{ monthKey, trips }, ...],
22
+ * citations,
23
+ * llm_commentary,
24
+ * }
25
+ */
26
+
27
+ "use strict";
28
+
29
+ const { AnalysisSkill } = require("./base");
30
+
31
+ const TRAVEL_SUBTYPES = new Set(["travel", "visit", "checkin"]);
32
+
33
+ class FootprintSkill extends AnalysisSkill {
34
+ constructor(opts) {
35
+ super({ ...opts, name: "analysis.footprint" });
36
+ }
37
+
38
+ async run(options = {}) {
39
+ const { since, until } = this.resolveTimeWindow(options);
40
+ const topN = Number.isFinite(options.topN) && options.topN > 0 ? options.topN : 10;
41
+ const groupBy = options.groupBy || "place";
42
+
43
+ const events = this._fetchTravelEvents({ since, until });
44
+ const visits = this._extractVisits(events);
45
+ const topPlaces = this._topPlaces(visits, groupBy, topN);
46
+ const monthly = this._monthlyDistribution(visits);
47
+
48
+ const summary = {
49
+ totalTrips: visits.length,
50
+ uniquePlaces: new Set(visits.map((v) => v.key)).size,
51
+ period: { since: since || null, until: until || null },
52
+ };
53
+
54
+ let llmCommentary = null;
55
+ if (options.commentary !== false && this.llm && visits.length > 0) {
56
+ llmCommentary = await this._llmCommentary(summary, topPlaces, options);
57
+ }
58
+
59
+ return {
60
+ skill: "analysis.footprint",
61
+ summary,
62
+ topPlaces,
63
+ monthlyDistribution: monthly,
64
+ citations: events.slice(0, 50).map((e) => e.id),
65
+ llm_commentary: llmCommentary,
66
+ };
67
+ }
68
+
69
+ _fetchTravelEvents({ since, until }) {
70
+ const events = [];
71
+ for (const subtype of TRAVEL_SUBTYPES) {
72
+ const q = { subtype, limit: 5000 };
73
+ if (since != null) q.since = since;
74
+ if (until != null) q.until = until;
75
+ const batch = this.vault.queryEvents(q) || [];
76
+ events.push(...batch);
77
+ }
78
+ // Also include events with explicit Place participants
79
+ const general = this.vault.queryEvents({
80
+ limit: 10_000,
81
+ ...(since != null ? { since } : {}),
82
+ ...(until != null ? { until } : {}),
83
+ }) || [];
84
+ for (const e of general) {
85
+ if (e.extra && (e.extra.placeId || e.extra.from || e.extra.to)) events.push(e);
86
+ }
87
+ // Dedup by id
88
+ const seen = new Set();
89
+ return events.filter((e) => {
90
+ if (seen.has(e.id)) return false;
91
+ seen.add(e.id);
92
+ return true;
93
+ });
94
+ }
95
+
96
+ _extractVisits(events) {
97
+ const visits = [];
98
+ for (const e of events) {
99
+ const places = [];
100
+ // Travel adapters typically store from/to in extra
101
+ if (e.extra) {
102
+ if (typeof e.extra.from === "string") places.push({ key: e.extra.from, role: "from" });
103
+ if (typeof e.extra.to === "string") places.push({ key: e.extra.to, role: "to" });
104
+ if (typeof e.extra.placeId === "string") places.push({ key: e.extra.placeId, role: "visit" });
105
+ if (typeof e.extra.city === "string") places.push({ key: e.extra.city, role: "city" });
106
+ }
107
+ // Event with location in content
108
+ if (e.content && typeof e.content.location === "string") {
109
+ places.push({ key: e.content.location, role: "location" });
110
+ }
111
+ for (const p of places) {
112
+ if (!p.key || p.key === "(unknown)") continue;
113
+ visits.push({
114
+ key: p.key,
115
+ role: p.role,
116
+ eventId: e.id,
117
+ occurredAt: e.occurredAt,
118
+ });
119
+ }
120
+ }
121
+ return visits;
122
+ }
123
+
124
+ _topPlaces(visits, _groupBy, topN) {
125
+ const buckets = new Map();
126
+ for (const v of visits) {
127
+ const cur = buckets.get(v.key) || { name: v.key, visits: 0, lastVisit: 0, eventIds: [] };
128
+ cur.visits += 1;
129
+ if (v.occurredAt > cur.lastVisit) cur.lastVisit = v.occurredAt;
130
+ if (cur.eventIds.length < 5) cur.eventIds.push(v.eventId);
131
+ buckets.set(v.key, cur);
132
+ }
133
+ return Array.from(buckets.values())
134
+ .sort((a, b) => b.visits - a.visits)
135
+ .slice(0, topN);
136
+ }
137
+
138
+ _monthlyDistribution(visits) {
139
+ const buckets = new Map();
140
+ for (const v of visits) {
141
+ const d = new Date(v.occurredAt);
142
+ if (!Number.isFinite(d.getTime())) continue;
143
+ const m = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
144
+ buckets.set(m, (buckets.get(m) || 0) + 1);
145
+ }
146
+ return Array.from(buckets.entries())
147
+ .map(([monthKey, trips]) => ({ monthKey, trips }))
148
+ .sort((a, b) => a.monthKey.localeCompare(b.monthKey));
149
+ }
150
+
151
+ async _llmCommentary(summary, topPlaces, options) {
152
+ if (summary.totalTrips === 0) return "No travel events in this period.";
153
+ const topList = topPlaces.slice(0, 5).map((p) => `${p.name}(${p.visits}次)`).join(", ");
154
+ const userMsg = `用户的出行数据:
155
+ - 期间: ${summary.period.since ? new Date(summary.period.since).toISOString().slice(0,10) : "全部"} 至 ${summary.period.until ? new Date(summary.period.until).toISOString().slice(0,10) : "现在"}
156
+ - 共 ${summary.totalTrips} 次足迹, ${summary.uniquePlaces} 个独特地点
157
+ - Top 5: ${topList}
158
+
159
+ 请用 2-3 句话总结出行模式(常去、稀去、季节性 etc.)。中文回答。`;
160
+ return await this.callLlmCommentary([
161
+ { role: "system", content: "你是一个克制的足迹分析助手。基于事实简短描述出行模式。" },
162
+ { role: "user", content: userMsg },
163
+ ], { acceptNonLocal: options.acceptNonLocal });
164
+ }
165
+ }
166
+
167
+ module.exports = { FootprintSkill };
@@ -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 };