@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,211 @@
1
+ /**
2
+ * HTTP client wrapper used by AI-chat vendor sub-adapters.
3
+ *
4
+ * Three responsibilities:
5
+ *
6
+ * 1. Inject cookies from a CookieAuthSession (per request, per domain).
7
+ * 2. Apply per-vendor `rateLimits` — at least minIntervalMs between calls
8
+ * and at most perMinute calls in any rolling 60s window. Vendor wiring
9
+ * shares one HttpClient instance so all its endpoints obey the same gate.
10
+ * 3. Retry on 5xx / network-timeout with bounded exponential backoff.
11
+ *
12
+ * The actual network call is delegated to `opts.fetch` (defaults to global
13
+ * fetch in Node 22+). Tests inject a stub that records requests + returns
14
+ * canned responses, so the whole vendor wiring is verifiable without real
15
+ * cookies / live servers.
16
+ *
17
+ * No external dependency — same pattern as `lib/llm-client.js`.
18
+ */
19
+
20
+ "use strict";
21
+
22
+ const DEFAULT_RATE_LIMITS = Object.freeze({
23
+ perMinute: 30,
24
+ minIntervalMs: 1500,
25
+ });
26
+
27
+ class RateLimitedError extends Error {
28
+ constructor(retryAfterMs, vendor) {
29
+ super(`rate limited (vendor=${vendor || "?"}, retryAfterMs=${retryAfterMs})`);
30
+ this.code = "RATE_LIMITED";
31
+ this.retryAfterMs = retryAfterMs;
32
+ this.vendor = vendor;
33
+ }
34
+ }
35
+
36
+ class CookieExpiredError extends Error {
37
+ constructor(vendor, hint) {
38
+ super(`cookie expired or invalid (vendor=${vendor})${hint ? ": " + hint : ""}`);
39
+ this.code = "COOKIE_EXPIRED";
40
+ this.vendor = vendor;
41
+ }
42
+ }
43
+
44
+ class HttpClient {
45
+ /**
46
+ * @param {object} opts
47
+ * @param {string} opts.vendor Vendor name (for error messages + logs).
48
+ * @param {object} [opts.rateLimits] { perMinute, minIntervalMs } — defaults to DEFAULT_RATE_LIMITS.
49
+ * @param {function} [opts.fetch] Fetch impl override. Defaults to global fetch.
50
+ * @param {function} [opts.sleep] Sleep impl override (test seam). Defaults to setTimeout.
51
+ * @param {function} [opts.now] Clock override (test seam). Defaults to Date.now.
52
+ * @param {number} [opts.maxRetries] Max retries on 5xx / timeout. Default 3.
53
+ * @param {number} [opts.baseBackoffMs] Initial retry backoff. Default 500.
54
+ * @param {object} [opts.logger]
55
+ */
56
+ constructor(opts = {}) {
57
+ if (typeof opts.vendor !== "string" || opts.vendor.length === 0) {
58
+ throw new Error("HttpClient: opts.vendor required");
59
+ }
60
+ this.vendor = opts.vendor;
61
+ this.rateLimits = {
62
+ ...DEFAULT_RATE_LIMITS,
63
+ ...(opts.rateLimits || {}),
64
+ };
65
+ this._fetch = opts.fetch || (typeof fetch !== "undefined" ? fetch : null);
66
+ if (!this._fetch) {
67
+ throw new Error("HttpClient: no fetch available. Node 22+ required, or pass opts.fetch.");
68
+ }
69
+ this._sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
70
+ this._now = opts.now || (() => Date.now());
71
+ this._maxRetries = Number.isFinite(opts.maxRetries) ? opts.maxRetries : 3;
72
+ this._baseBackoffMs = Number.isFinite(opts.baseBackoffMs) ? opts.baseBackoffMs : 500;
73
+ this._logger = opts.logger || { info: () => {}, warn: () => {}, error: () => {} };
74
+
75
+ // Sliding window of recent request timestamps for perMinute enforcement.
76
+ this._recent = [];
77
+ this._lastCallAt = 0;
78
+ }
79
+
80
+ /**
81
+ * Make one rate-limited + cookie-injected request. Throws CookieExpiredError
82
+ * on 401/403, RateLimitedError on 429 after exhausting retries, generic
83
+ * Error on other failures.
84
+ *
85
+ * @param {string} url
86
+ * @param {object} [reqInit]
87
+ * @param {CookieAuthSession} [reqInit.session] Cookies to inject. Must match opts.vendor.
88
+ * @param {string} [reqInit.matchDomain] Cookie domain filter.
89
+ */
90
+ async request(url, reqInit = {}) {
91
+ await this._enforceRateLimit();
92
+
93
+ const headers = { ...(reqInit.headers || {}) };
94
+ if (reqInit.session) {
95
+ if (reqInit.session.vendor !== this.vendor) {
96
+ throw new Error(
97
+ `HttpClient(${this.vendor}): session vendor "${reqInit.session.vendor}" mismatch`,
98
+ );
99
+ }
100
+ reqInit.session.applyTo(headers, reqInit.matchDomain);
101
+ }
102
+
103
+ const fetchInit = {
104
+ method: reqInit.method || "GET",
105
+ headers,
106
+ ...(reqInit.body !== undefined ? { body: reqInit.body } : {}),
107
+ ...(reqInit.signal ? { signal: reqInit.signal } : {}),
108
+ };
109
+
110
+ let lastErr;
111
+ for (let attempt = 0; attempt <= this._maxRetries; attempt++) {
112
+ let resp;
113
+ try {
114
+ resp = await this._fetch(url, fetchInit);
115
+ } catch (err) {
116
+ lastErr = err;
117
+ if (attempt < this._maxRetries) {
118
+ await this._sleep(this._backoffMs(attempt));
119
+ continue;
120
+ }
121
+ throw err;
122
+ }
123
+
124
+ if (resp.status === 401 || resp.status === 403) {
125
+ throw new CookieExpiredError(this.vendor, `HTTP ${resp.status}`);
126
+ }
127
+ if (resp.status === 429) {
128
+ const retryHdr = resp.headers && typeof resp.headers.get === "function"
129
+ ? resp.headers.get("retry-after")
130
+ : null;
131
+ const retryAfterMs = retryHdr ? (Number(retryHdr) * 1000 || this._backoffMs(attempt)) : this._backoffMs(attempt);
132
+ if (attempt < this._maxRetries) {
133
+ this._logger.warn(`[${this.vendor}] 429 retry-after=${retryAfterMs}ms attempt=${attempt}`);
134
+ await this._sleep(retryAfterMs);
135
+ continue;
136
+ }
137
+ throw new RateLimitedError(retryAfterMs, this.vendor);
138
+ }
139
+ if (resp.status >= 500 && resp.status < 600) {
140
+ lastErr = new Error(`HTTP ${resp.status}`);
141
+ if (attempt < this._maxRetries) {
142
+ await this._sleep(this._backoffMs(attempt));
143
+ continue;
144
+ }
145
+ throw lastErr;
146
+ }
147
+ return resp;
148
+ }
149
+ throw lastErr || new Error("HttpClient: exhausted retries");
150
+ }
151
+
152
+ /**
153
+ * GET shorthand that decodes JSON.
154
+ */
155
+ async getJson(url, reqInit = {}) {
156
+ const resp = await this.request(url, reqInit);
157
+ if (!resp.ok) {
158
+ throw new Error(`HttpClient(${this.vendor}) GET ${url} → HTTP ${resp.status}`);
159
+ }
160
+ return resp.json();
161
+ }
162
+
163
+ async postJson(url, body, reqInit = {}) {
164
+ const headers = { "content-type": "application/json", ...(reqInit.headers || {}) };
165
+ const resp = await this.request(url, { ...reqInit, method: "POST", headers, body: JSON.stringify(body) });
166
+ if (!resp.ok) {
167
+ throw new Error(`HttpClient(${this.vendor}) POST ${url} → HTTP ${resp.status}`);
168
+ }
169
+ return resp.json();
170
+ }
171
+
172
+ async _enforceRateLimit() {
173
+ const now = this._now();
174
+
175
+ // perMinute window
176
+ if (this.rateLimits.perMinute > 0) {
177
+ const cutoff = now - 60_000;
178
+ this._recent = this._recent.filter((t) => t >= cutoff);
179
+ if (this._recent.length >= this.rateLimits.perMinute) {
180
+ const wait = this._recent[0] + 60_000 - now;
181
+ if (wait > 0) await this._sleep(wait);
182
+ }
183
+ }
184
+
185
+ // minIntervalMs between consecutive calls
186
+ if (this.rateLimits.minIntervalMs > 0 && this._lastCallAt > 0) {
187
+ const since = now - this._lastCallAt;
188
+ if (since < this.rateLimits.minIntervalMs) {
189
+ await this._sleep(this.rateLimits.minIntervalMs - since);
190
+ }
191
+ }
192
+
193
+ const tick = this._now();
194
+ this._recent.push(tick);
195
+ this._lastCallAt = tick;
196
+ }
197
+
198
+ _backoffMs(attempt) {
199
+ // 500 → 1000 → 2000 → 4000 (capped at 4 attempts) + ±20% jitter
200
+ const base = this._baseBackoffMs * Math.pow(2, attempt);
201
+ const jitter = base * 0.2 * (Math.random() * 2 - 1);
202
+ return Math.max(0, Math.floor(base + jitter));
203
+ }
204
+ }
205
+
206
+ module.exports = {
207
+ HttpClient,
208
+ RateLimitedError,
209
+ CookieExpiredError,
210
+ DEFAULT_RATE_LIMITS,
211
+ };
@@ -0,0 +1,28 @@
1
+ "use strict";
2
+
3
+ const {
4
+ AIChatHistoryAdapter,
5
+ SUPPORTED_VENDORS,
6
+ DEFAULT_VENDOR_SPECS,
7
+ ADAPTER_NAME,
8
+ ADAPTER_VERSION,
9
+ } = require("./ai-chat-adapter");
10
+ const { CookieAuthSession } = require("./cookie-auth");
11
+ const { NotImplementedYetError, assertVendorSpec } = require("./vendor-spec");
12
+ const { HttpClient, RateLimitedError, CookieExpiredError } = require("./http-client");
13
+ const schemaMap = require("./schema-map");
14
+
15
+ module.exports = {
16
+ AIChatHistoryAdapter,
17
+ SUPPORTED_VENDORS,
18
+ DEFAULT_VENDOR_SPECS,
19
+ ADAPTER_NAME,
20
+ ADAPTER_VERSION,
21
+ CookieAuthSession,
22
+ NotImplementedYetError,
23
+ HttpClient,
24
+ RateLimitedError,
25
+ CookieExpiredError,
26
+ assertVendorSpec,
27
+ schemaMap,
28
+ };
@@ -0,0 +1,258 @@
1
+ /**
2
+ * RawConversation / RawMessage → UnifiedSchema normalized batch.
3
+ *
4
+ * Mirrors `docs/design/Adapter_AIChat_History.md` §5.2. This module is
5
+ * pure-transform: given a RawConversation + its RawMessage[], it returns a
6
+ * {events, persons, topics, items} bundle ready for the AdapterRegistry to
7
+ * write into LocalVault.
8
+ *
9
+ * Why pure: it's the easiest part to test exhaustively without any HTTP
10
+ * mocks. Vendor sub-adapters (skeletons in Phase 10.1) only need to produce
11
+ * Raw* objects matching the documented shape; this module handles the rest.
12
+ */
13
+
14
+ "use strict";
15
+
16
+ const {
17
+ ENTITY_TYPES,
18
+ PERSON_SUBTYPES,
19
+ EVENT_SUBTYPES,
20
+ ITEM_SUBTYPES,
21
+ CAPTURED_BY,
22
+ } = require("../../constants");
23
+ const { newId } = require("../../ids");
24
+
25
+ const SELF_PERSON_ID = "person-self";
26
+ const ADAPTER_NAME = "ai-chat-history";
27
+ const ADAPTER_VERSION = "0.1.0";
28
+
29
+ function personIdForVendor(vendor) {
30
+ return `person-ai-${vendor}`;
31
+ }
32
+
33
+ function topicIdForConversation(vendor, originalConvId) {
34
+ return `topic-aiconv-${vendor}-${originalConvId}`;
35
+ }
36
+
37
+ /**
38
+ * Build the vendor "AI agent" Person entity. The hub upserts these once per
39
+ * vendor — the consistent `id` form means re-emitting from multiple sync
40
+ * passes is idempotent.
41
+ */
42
+ function buildVendorPerson(vendor, displayName) {
43
+ const now = Date.now();
44
+ return {
45
+ id: personIdForVendor(vendor),
46
+ type: ENTITY_TYPES.PERSON,
47
+ subtype: PERSON_SUBTYPES.AI_AGENT,
48
+ names: [displayName],
49
+ identifiers: { vendor },
50
+ notes: `${displayName} — 用户的 ${displayName} AI 助手账户`,
51
+ ingestedAt: now,
52
+ source: {
53
+ adapter: ADAPTER_NAME,
54
+ adapterVersion: ADAPTER_VERSION,
55
+ originalId: `vendor:${vendor}`,
56
+ capturedAt: now,
57
+ capturedBy: CAPTURED_BY.API,
58
+ },
59
+ };
60
+ }
61
+
62
+ /**
63
+ * Build the conversation Topic entity.
64
+ */
65
+ function buildConversationTopic(rawConv) {
66
+ const now = Date.now();
67
+ return {
68
+ id: topicIdForConversation(rawConv.vendor, rawConv.originalId),
69
+ type: ENTITY_TYPES.TOPIC,
70
+ name: rawConv.title || "(无标题对话)",
71
+ ingestedAt: now,
72
+ source: {
73
+ adapter: ADAPTER_NAME,
74
+ adapterVersion: ADAPTER_VERSION,
75
+ originalId: `${rawConv.vendor}:conv:${rawConv.originalId}`,
76
+ capturedAt: Number(rawConv.updatedAt) || Number(rawConv.createdAt) || now,
77
+ capturedBy: CAPTURED_BY.API,
78
+ },
79
+ extra: {
80
+ vendor: rawConv.vendor,
81
+ kind: "ai-conversation",
82
+ modelName: rawConv.modelName,
83
+ conversationOriginalId: rawConv.originalId,
84
+ createdAt: rawConv.createdAt,
85
+ updatedAt: rawConv.updatedAt,
86
+ messageCount: rawConv.messageCount,
87
+ archived: Boolean(rawConv.archived),
88
+ },
89
+ };
90
+ }
91
+
92
+ /**
93
+ * Build an Event entity for one message.
94
+ *
95
+ * - role "user" → actor = self, subtype = ai-message
96
+ * - role "assistant" → actor = person-ai-vendor, subtype = ai-message
97
+ * - vendor=dreamina + generatedImages → subtype overridden to ai-image-generation
98
+ */
99
+ function buildMessageEvent(rawMsg, capturedAt) {
100
+ const vendorPersonId = personIdForVendor(rawMsg.vendor);
101
+ const actor = rawMsg.role === "user" ? SELF_PERSON_ID : vendorPersonId;
102
+ const subtype =
103
+ rawMsg.content && Array.isArray(rawMsg.content.generatedImages) && rawMsg.content.generatedImages.length > 0
104
+ ? EVENT_SUBTYPES.AI_IMAGE_GENERATION
105
+ : EVENT_SUBTYPES.AI_MESSAGE;
106
+ const now = Date.now();
107
+ // Schema requires positive integer ms timestamps for occurredAt / ingestedAt
108
+ // / source.capturedAt — see lib/schemas.js validateEvent + validateBaseEntity.
109
+ const occurredAtMs = Number(rawMsg.createdAt);
110
+ // Deterministic id keyed on (vendor, originalId) so re-syncs hit
111
+ // ON CONFLICT(id) DO UPDATE in putEvent and stay idempotent (instead of
112
+ // throwing on the secondary UNIQUE(source_adapter, source_original_id)
113
+ // constraint with a fresh newId() each time).
114
+ return {
115
+ id: `evt-aichat-${rawMsg.vendor}-${rawMsg.originalId}`,
116
+ type: ENTITY_TYPES.EVENT,
117
+ subtype,
118
+ occurredAt: Number.isFinite(occurredAtMs) && occurredAtMs > 0 ? occurredAtMs : now,
119
+ actor,
120
+ participants: [SELF_PERSON_ID, vendorPersonId],
121
+ content: {
122
+ text: (rawMsg.content && rawMsg.content.text) || undefined,
123
+ mediaRefs: rawMsg.content && Array.isArray(rawMsg.content.attachments)
124
+ ? rawMsg.content.attachments.map((a) => a.url).filter(Boolean)
125
+ : undefined,
126
+ },
127
+ topics: [topicIdForConversation(rawMsg.vendor, rawMsg.conversationId)],
128
+ ingestedAt: now,
129
+ source: {
130
+ adapter: ADAPTER_NAME,
131
+ adapterVersion: ADAPTER_VERSION,
132
+ originalId: `${rawMsg.vendor}/${rawMsg.originalId}`,
133
+ capturedAt: Number(capturedAt) || now,
134
+ capturedBy: CAPTURED_BY.API,
135
+ },
136
+ extra: {
137
+ vendor: rawMsg.vendor,
138
+ conversationOriginalId: rawMsg.conversationId,
139
+ role: rawMsg.role,
140
+ modelName: rawMsg.modelName,
141
+ parentMessageId: rawMsg.parentMessageId,
142
+ toolCalls: rawMsg.content ? rawMsg.content.toolCalls : undefined,
143
+ generatedImages: rawMsg.content ? rawMsg.content.generatedImages : undefined,
144
+ },
145
+ };
146
+ }
147
+
148
+ /**
149
+ * Build Item entities for any generated images. Each image becomes a `media`
150
+ * Item so KG queries like "what images did I generate this month" work.
151
+ */
152
+ function buildGeneratedImageItems(rawMsg) {
153
+ if (!rawMsg.content || !Array.isArray(rawMsg.content.generatedImages)) return [];
154
+ const now = Date.now();
155
+ const capturedAt = Number(rawMsg.createdAt) || now;
156
+ return rawMsg.content.generatedImages.map((img, idx) => ({
157
+ id: `item-aichat-${rawMsg.vendor}-${rawMsg.originalId}-${idx}`,
158
+ type: ENTITY_TYPES.ITEM,
159
+ subtype: ITEM_SUBTYPES.MEDIA,
160
+ name: (img.prompt || "AI image").slice(0, 80),
161
+ ingestedAt: now,
162
+ source: {
163
+ adapter: ADAPTER_NAME,
164
+ adapterVersion: ADAPTER_VERSION,
165
+ originalId: `${rawMsg.vendor}:img:${rawMsg.originalId}:${idx}`,
166
+ capturedAt,
167
+ capturedBy: CAPTURED_BY.API,
168
+ },
169
+ extra: {
170
+ vendor: rawMsg.vendor,
171
+ kind: "ai-generated-image",
172
+ prompt: img.prompt,
173
+ model: img.model,
174
+ url: img.url,
175
+ params: img.params,
176
+ },
177
+ }));
178
+ }
179
+
180
+ /**
181
+ * Convert one RawConversation + its messages into a NormalizedBatch.
182
+ *
183
+ * @param {object} rawConv
184
+ * @param {Array} rawMessages
185
+ * @param {object} [vendorMeta] { displayName: string }
186
+ * @param {number} [capturedAt]
187
+ * @returns {{events:Array,persons:Array,places:Array,items:Array,topics:Array}}
188
+ */
189
+ function conversationToBatch(rawConv, rawMessages, vendorMeta, capturedAt) {
190
+ if (!rawConv || typeof rawConv !== "object") {
191
+ throw new Error("conversationToBatch: rawConv required");
192
+ }
193
+ if (!Array.isArray(rawMessages)) {
194
+ throw new Error("conversationToBatch: rawMessages must be an array");
195
+ }
196
+ const vendorPerson = buildVendorPerson(rawConv.vendor, (vendorMeta && vendorMeta.displayName) || rawConv.vendor);
197
+ const topic = buildConversationTopic(rawConv);
198
+
199
+ const events = [];
200
+ const items = [];
201
+ for (const msg of rawMessages) {
202
+ if (msg.vendor !== rawConv.vendor) {
203
+ throw new Error(
204
+ `conversationToBatch: message vendor "${msg.vendor}" != conv vendor "${rawConv.vendor}"`,
205
+ );
206
+ }
207
+ events.push(buildMessageEvent(msg, capturedAt));
208
+ items.push(...buildGeneratedImageItems(msg));
209
+ }
210
+
211
+ return {
212
+ events,
213
+ persons: [vendorPerson],
214
+ places: [],
215
+ items,
216
+ topics: [topic],
217
+ };
218
+ }
219
+
220
+ /**
221
+ * Merge multiple NormalizedBatch results. Vendor Person is deduped by id
222
+ * (every batch declares the same `person-ai-<vendor>` so we collapse).
223
+ */
224
+ function mergeBatches(batches) {
225
+ const events = [];
226
+ const personsById = new Map();
227
+ const places = [];
228
+ const items = [];
229
+ const topicsById = new Map();
230
+ for (const b of batches) {
231
+ for (const e of b.events) events.push(e);
232
+ for (const p of b.persons) personsById.set(p.id, p);
233
+ for (const pl of b.places) places.push(pl);
234
+ for (const it of b.items) items.push(it);
235
+ for (const t of b.topics) topicsById.set(t.id, t);
236
+ }
237
+ return {
238
+ events,
239
+ persons: [...personsById.values()],
240
+ places,
241
+ items,
242
+ topics: [...topicsById.values()],
243
+ };
244
+ }
245
+
246
+ module.exports = {
247
+ SELF_PERSON_ID,
248
+ ADAPTER_NAME,
249
+ ADAPTER_VERSION,
250
+ personIdForVendor,
251
+ topicIdForConversation,
252
+ buildVendorPerson,
253
+ buildConversationTopic,
254
+ buildMessageEvent,
255
+ buildGeneratedImageItems,
256
+ conversationToBatch,
257
+ mergeBatches,
258
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * VendorSpec — per-AI-vendor contract used by AIChatHistoryAdapter.
3
+ *
4
+ * Each of the 8 supported vendors (DeepSeek / Kimi / 通义 / 智谱 / 混元 / 千帆 /
5
+ * 扣子 / Dreamina) provides one VendorSpec object describing:
6
+ *
7
+ * - Identity (`name`, `displayName`, `androidPackage`)
8
+ * - Login surface (`loginUrl`, `cookieDomains`)
9
+ * - Sync surface (`listConversations(ctx, opts)`, `listMessages(ctx, convId, opts)`)
10
+ * - Health (`validateCookie(ctx)`)
11
+ * - Rate limits (`rateLimits`)
12
+ *
13
+ * The parent adapter fans out to all configured vendors and reuses one cookie
14
+ * jar per vendor. Vendor implementations live in `./vendors/<name>.js` and are
15
+ * SKELETONS in Phase 10.1 — they advertise the right shape and ratelimit so
16
+ * the AdapterRegistry contract holds, but the actual HTTP wiring is wired in
17
+ * later (Phase 10.2+). See `Adapter_AIChat_History.md` §6 per-vendor detail.
18
+ */
19
+
20
+ "use strict";
21
+
22
+ const SUPPORTED_VENDORS = Object.freeze([
23
+ "deepseek",
24
+ "kimi",
25
+ "tongyi",
26
+ "zhipu",
27
+ "hunyuan",
28
+ "qianfan",
29
+ "coze",
30
+ "dreamina",
31
+ "doubao",
32
+ ]);
33
+
34
+ class NotImplementedYetError extends Error {
35
+ constructor(vendor, capability) {
36
+ super(`vendor "${vendor}" capability "${capability}" not wired yet (Phase 10.2+)`);
37
+ this.code = "VENDOR_NOT_WIRED";
38
+ this.vendor = vendor;
39
+ this.capability = capability;
40
+ }
41
+ }
42
+
43
+ function assertVendorSpec(spec) {
44
+ const errors = [];
45
+ if (spec == null || typeof spec !== "object") {
46
+ return { ok: false, errors: ["vendor spec must be an object"] };
47
+ }
48
+ if (typeof spec.name !== "string" || !SUPPORTED_VENDORS.includes(spec.name)) {
49
+ errors.push(`name must be one of ${SUPPORTED_VENDORS.join("|")}`);
50
+ }
51
+ if (typeof spec.displayName !== "string" || spec.displayName.length === 0) {
52
+ errors.push("displayName required");
53
+ }
54
+ if (typeof spec.androidPackage !== "string" || spec.androidPackage.length === 0) {
55
+ errors.push("androidPackage required");
56
+ }
57
+ if (typeof spec.loginUrl !== "string" || !/^https:\/\//.test(spec.loginUrl)) {
58
+ errors.push("loginUrl required and must be https URL");
59
+ }
60
+ if (!Array.isArray(spec.cookieDomains) || spec.cookieDomains.length === 0) {
61
+ errors.push("cookieDomains must be a non-empty array");
62
+ }
63
+ if (typeof spec.listConversations !== "function") {
64
+ errors.push("listConversations must be an async generator function");
65
+ }
66
+ if (typeof spec.listMessages !== "function") {
67
+ errors.push("listMessages must be an async generator function");
68
+ }
69
+ if (typeof spec.validateCookie !== "function") {
70
+ errors.push("validateCookie must be an async function");
71
+ }
72
+ if (
73
+ spec.rateLimits == null
74
+ || typeof spec.rateLimits !== "object"
75
+ || typeof spec.rateLimits.perMinute !== "number"
76
+ ) {
77
+ errors.push("rateLimits.perMinute (number) required");
78
+ }
79
+ return errors.length === 0 ? { ok: true } : { ok: false, errors };
80
+ }
81
+
82
+ module.exports = {
83
+ SUPPORTED_VENDORS,
84
+ NotImplementedYetError,
85
+ assertVendorSpec,
86
+ };