@chainlesschain/personal-data-hub 0.2.0 → 0.2.2

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 (59) hide show
  1. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
  2. package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
  3. package/__tests__/adapters/ai-chat-history.test.js +8 -7
  4. package/__tests__/adapters/ai-chat-vendors.test.js +149 -8
  5. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
  6. package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
  7. package/__tests__/adapters/system-data-android.test.js +387 -0
  8. package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
  9. package/__tests__/adapters/wechat-env-probe.test.js +162 -0
  10. package/__tests__/adapters/wechat-frida-agent.test.js +322 -0
  11. package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
  12. package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
  13. package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
  14. package/__tests__/analysis-skills.test.js +147 -0
  15. package/__tests__/analysis.test.js +329 -1
  16. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
  17. package/__tests__/e2e/full-user-journey.test.js +188 -0
  18. package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
  19. package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
  20. package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
  21. package/__tests__/integration/social-bilibili-pipeline.test.js +261 -0
  22. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
  23. package/__tests__/registry.test.js +4 -2
  24. package/__tests__/social-adapters.test.js +63 -14
  25. package/__tests__/social-bilibili-snapshot.test.js +278 -0
  26. package/__tests__/wechat-adapter.test.js +118 -0
  27. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +55 -16
  28. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  29. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  30. package/lib/adapters/ai-chat-history/schema-map.js +42 -5
  31. package/lib/adapters/ai-chat-history/vendor-spec.js +1 -0
  32. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  33. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  34. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +4 -0
  35. package/lib/adapters/social-bilibili/adapter.js +500 -0
  36. package/lib/adapters/social-bilibili/index.js +21 -169
  37. package/lib/adapters/social-kuaishou/index.js +237 -0
  38. package/lib/adapters/social-toutiao/index.js +236 -0
  39. package/lib/adapters/system-data-android/adapter.js +348 -0
  40. package/lib/adapters/system-data-android/index.js +76 -0
  41. package/lib/adapters/wechat/bootstrap.js +146 -0
  42. package/lib/adapters/wechat/content-parser.js +11 -2
  43. package/lib/adapters/wechat/db-reader.js +88 -10
  44. package/lib/adapters/wechat/env-probe.js +218 -0
  45. package/lib/adapters/wechat/frida-agent/loader.js +74 -0
  46. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +248 -0
  47. package/lib/adapters/wechat/index.js +9 -0
  48. package/lib/adapters/wechat/key-providers/frida-key-provider.js +252 -0
  49. package/lib/adapters/wechat/key-providers/index.js +22 -0
  50. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  51. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  52. package/lib/adapters/wechat/normalize.js +12 -3
  53. package/lib/analysis-skills/spending.js +4 -1
  54. package/lib/analysis.js +191 -2
  55. package/lib/index.js +16 -0
  56. package/lib/prompt-builder.js +11 -1
  57. package/lib/query-parser.js +7 -1
  58. package/lib/vault.js +77 -0
  59. package/package.json +8 -1
@@ -0,0 +1,331 @@
1
+ /**
2
+ * AIChat WebView 鉴权向导 — Vendor Cookie Capture Matrix(Phase 10.3.1)
3
+ *
4
+ * 与 `vendors/<name>.js` 的 SPEC 解耦:vendor SPEC 只描述 HTTP 抓数据接口,
5
+ * 本文件描述 wizard 抓 cookie 时需要的"鉴权"维度信息。两层独立,让 adapter
6
+ * 永远纯 HTTP 可测(注入 mock HttpClient 即可),而 wizard 可单独迭代而不
7
+ * 触动 adapter。
8
+ *
9
+ * Reference: docs/design/Personal_Data_Hub_Phase_10_3_AIChat_WebView_Wizard.md §3
10
+ *
11
+ * 每条 entry 的字段约束 (run validateCookieCaptureSpec to enforce):
12
+ * - vendor ∈ KNOWN_VENDORS
13
+ * - loginUrl http(s):// 开头,host 必须出现在 cookieDomains 的主域内
14
+ * - cookieDomains ≥ 1 个非空字符串(含 ".<root>" 形式)
15
+ * - requiredCookies ≥ 1 个;wizard 至少识别 1 个就认为登录成功
16
+ * - postLoginPathHints ≥ 1 个 path(如 "/chat" / "/c/")
17
+ * - cookieMaxAgeHintDays 正整数;UI 提前 3 天预警
18
+ * - notes 中文用户提示
19
+ *
20
+ * 字段都是反向工程结果,**会变**。Phase 10.4 fixture pin 时把每家真实 cookie
21
+ * 截屏入 `__fixtures__/aichat-cookies/<vendor>.txt` 锁住一版;bump
22
+ * `COOKIE_SPEC_VERSION` 后 HealthChecker 会主动标 SPEC_VERSION_MISMATCH。
23
+ */
24
+
25
+ "use strict";
26
+
27
+ const COOKIE_SPEC_VERSION = 1;
28
+
29
+ const KNOWN_VENDORS = Object.freeze([
30
+ "deepseek",
31
+ "kimi",
32
+ "tongyi",
33
+ "zhipu",
34
+ "hunyuan",
35
+ "qianfan",
36
+ "coze",
37
+ "dreamina",
38
+ "doubao",
39
+ ]);
40
+
41
+ const COOKIE_CAPTURE_SPECS = Object.freeze([
42
+ {
43
+ vendor: "deepseek",
44
+ displayName: "DeepSeek",
45
+ loginUrl: "https://chat.deepseek.com/",
46
+ cookieDomains: ["chat.deepseek.com", ".deepseek.com"],
47
+ requiredCookies: ["userToken"],
48
+ optionalCookies: ["intercom-session-deepseek", "session"],
49
+ postLoginPathHints: ["/chat", "/a/"],
50
+ cookieMaxAgeHintDays: 30,
51
+ notes: "需手机号 / 邮箱 + 验证码登录;自动检测 userToken cookie 出现",
52
+ },
53
+ {
54
+ vendor: "kimi",
55
+ displayName: "Kimi",
56
+ loginUrl: "https://kimi.moonshot.cn/",
57
+ cookieDomains: ["kimi.moonshot.cn", ".moonshot.cn"],
58
+ requiredCookies: ["access_token"],
59
+ optionalCookies: ["refresh_token", "session_id"],
60
+ postLoginPathHints: ["/chat", "/"],
61
+ cookieMaxAgeHintDays: 30,
62
+ notes: "手机号验证码登录;access_token 落 cookie 即视为成功",
63
+ },
64
+ {
65
+ vendor: "tongyi",
66
+ displayName: "通义千问",
67
+ loginUrl: "https://tongyi.aliyun.com/",
68
+ cookieDomains: ["tongyi.aliyun.com", ".aliyun.com"],
69
+ requiredCookies: ["login_aliyunid"],
70
+ optionalCookies: ["XSRF-TOKEN", "login_aliyunid_csrf", "tongyi_sso_ticket"],
71
+ postLoginPathHints: ["/qianwen", "/efm"],
72
+ cookieMaxAgeHintDays: 7,
73
+ notes: "阿里云账号 SSO;过期较快(约 7 天)需周期重登",
74
+ },
75
+ {
76
+ vendor: "zhipu",
77
+ displayName: "智谱清言",
78
+ loginUrl: "https://chatglm.cn/",
79
+ cookieDomains: ["chatglm.cn", ".chatglm.cn"],
80
+ requiredCookies: ["chatglm_token"],
81
+ optionalCookies: ["cgsessionid", "chatglm_user_id"],
82
+ postLoginPathHints: ["/main", "/"],
83
+ cookieMaxAgeHintDays: 30,
84
+ notes: "手机号 + 验证码;chatglm_token 出现即认证成功",
85
+ },
86
+ {
87
+ vendor: "hunyuan",
88
+ displayName: "腾讯混元",
89
+ loginUrl: "https://yuanbao.tencent.com/",
90
+ cookieDomains: ["yuanbao.tencent.com", ".tencent.com"],
91
+ requiredCookies: ["hy_token"],
92
+ optionalCookies: ["hy_user", "uin", "skey"],
93
+ postLoginPathHints: ["/chat", "/"],
94
+ cookieMaxAgeHintDays: 14,
95
+ notes: "QQ / 微信 / 手机号;hy_token 落 cookie 视为已登录",
96
+ },
97
+ {
98
+ vendor: "qianfan",
99
+ displayName: "百度文心",
100
+ loginUrl: "https://yiyan.baidu.com/",
101
+ cookieDomains: ["yiyan.baidu.com", ".baidu.com"],
102
+ requiredCookies: ["BDUSS"],
103
+ optionalCookies: ["BAIDUID", "STOKEN", "PTOKEN"],
104
+ postLoginPathHints: ["/chat", "/"],
105
+ cookieMaxAgeHintDays: 7,
106
+ notes: "百度账号;BDUSS 跨域 cookie,可能需在百度首页登录后回来",
107
+ },
108
+ {
109
+ vendor: "coze",
110
+ displayName: "字节扣子 Coze",
111
+ loginUrl: "https://www.coze.cn/",
112
+ cookieDomains: ["www.coze.cn", ".coze.cn"],
113
+ requiredCookies: ["sessionid"],
114
+ optionalCookies: ["passport_csrf_token", "s_v_web_id", "uid_tt"],
115
+ postLoginPathHints: ["/home", "/space"],
116
+ cookieMaxAgeHintDays: 14,
117
+ notes: "字节统一 passport;sessionid 是 bytedance 通用",
118
+ },
119
+ {
120
+ vendor: "dreamina",
121
+ displayName: "即梦 Dreamina",
122
+ loginUrl: "https://jimeng.jianying.com/",
123
+ cookieDomains: ["jimeng.jianying.com", ".jianying.com"],
124
+ requiredCookies: ["sessionid"],
125
+ optionalCookies: ["passport_csrf_token", "s_v_web_id"],
126
+ postLoginPathHints: ["/ai-tool", "/"],
127
+ cookieMaxAgeHintDays: 14,
128
+ notes: "图像生成;与 Coze 共用 bytedance passport sessionid",
129
+ },
130
+ {
131
+ vendor: "doubao",
132
+ displayName: "豆包 Doubao",
133
+ loginUrl: "https://www.doubao.com/chat/",
134
+ cookieDomains: ["www.doubao.com", ".doubao.com"],
135
+ requiredCookies: ["sessionid"],
136
+ optionalCookies: ["sid_guard", "passport_csrf_token", "s_v_web_id"],
137
+ postLoginPathHints: ["/chat"],
138
+ cookieMaxAgeHintDays: 14,
139
+ notes: "字节文本 AI;bytedance 通用 sessionid",
140
+ },
141
+ ]);
142
+
143
+ const SPEC_BY_VENDOR = Object.freeze(
144
+ COOKIE_CAPTURE_SPECS.reduce((acc, s) => {
145
+ acc[s.vendor] = s;
146
+ return acc;
147
+ }, Object.create(null)),
148
+ );
149
+
150
+ function getSpec(vendor) {
151
+ if (!vendor || typeof vendor !== "string") return null;
152
+ return SPEC_BY_VENDOR[vendor] || null;
153
+ }
154
+
155
+ function listVendors() {
156
+ return KNOWN_VENDORS.slice();
157
+ }
158
+
159
+ /**
160
+ * Given a probed cookie jar (`{ name: value }` shape), classify which
161
+ * required cookies are present, which are missing, which optional were
162
+ * also captured. Returns:
163
+ *
164
+ * { ok, foundRequired, missingRequired, foundOptional }
165
+ *
166
+ * `ok === true` iff every required cookie has a non-empty value.
167
+ *
168
+ * `cookies` is tolerated as either a plain object, a Cookie[] (Electron
169
+ * shape), or a raw "k=v; k=v" string (web-shell paste fallback).
170
+ */
171
+ function classifyProbedCookies(vendor, cookies) {
172
+ const spec = getSpec(vendor);
173
+ if (!spec) {
174
+ return { ok: false, foundRequired: [], missingRequired: [], foundOptional: [], reason: "UNKNOWN_VENDOR" };
175
+ }
176
+
177
+ const jar = _normalizeCookieJar(cookies);
178
+
179
+ const foundRequired = [];
180
+ const missingRequired = [];
181
+ for (const name of spec.requiredCookies) {
182
+ const v = jar[name];
183
+ if (typeof v === "string" && v.length > 0) {
184
+ foundRequired.push(name);
185
+ } else {
186
+ missingRequired.push(name);
187
+ }
188
+ }
189
+
190
+ const foundOptional = [];
191
+ for (const name of spec.optionalCookies || []) {
192
+ const v = jar[name];
193
+ if (typeof v === "string" && v.length > 0) foundOptional.push(name);
194
+ }
195
+
196
+ return {
197
+ ok: missingRequired.length === 0 && foundRequired.length > 0,
198
+ foundRequired,
199
+ missingRequired,
200
+ foundOptional,
201
+ };
202
+ }
203
+
204
+ function _normalizeCookieJar(input) {
205
+ if (!input) return {};
206
+ // Already a plain object { name: value }
207
+ if (typeof input === "object" && !Array.isArray(input)) {
208
+ const out = {};
209
+ for (const [k, v] of Object.entries(input)) {
210
+ if (typeof v === "string") out[k] = v;
211
+ }
212
+ return out;
213
+ }
214
+ // Electron `Cookie[]` shape (Cookie has { name, value, ... }).
215
+ if (Array.isArray(input)) {
216
+ const out = {};
217
+ for (const c of input) {
218
+ if (c && typeof c.name === "string" && typeof c.value === "string") {
219
+ out[c.name] = c.value;
220
+ }
221
+ }
222
+ return out;
223
+ }
224
+ // Raw "k=v; k=v" string (web-shell paste). Tolerates `;`, `; ` and stray
225
+ // whitespace; only takes the first `=` so values with `=` survive.
226
+ if (typeof input === "string") {
227
+ const out = {};
228
+ for (const pairRaw of input.split(/;\s*/)) {
229
+ const pair = pairRaw.trim();
230
+ if (!pair) continue;
231
+ const idx = pair.indexOf("=");
232
+ if (idx <= 0) continue;
233
+ const k = pair.slice(0, idx).trim();
234
+ const v = pair.slice(idx + 1).trim();
235
+ if (k && v) out[k] = v;
236
+ }
237
+ return out;
238
+ }
239
+ return {};
240
+ }
241
+
242
+ /**
243
+ * Validate the entire COOKIE_CAPTURE_SPECS array — used both at module
244
+ * load time (defensive) and by the spec test. Throws on the first
245
+ * violation with a vendor-prefixed error so spec mistakes surface fast.
246
+ *
247
+ * Caller can pass `{ throwOnError: false }` to get `{ ok, errors[] }` for
248
+ * defensive validation paths.
249
+ */
250
+ function validateCookieCaptureSpec(specs = COOKIE_CAPTURE_SPECS, opts = {}) {
251
+ const errors = [];
252
+ const seen = new Set();
253
+
254
+ for (const s of specs) {
255
+ const where = `vendor=${s && s.vendor}`;
256
+ if (!s || typeof s !== "object") {
257
+ errors.push(`${where}: not an object`);
258
+ continue;
259
+ }
260
+ if (!KNOWN_VENDORS.includes(s.vendor)) {
261
+ errors.push(`${where}: unknown vendor (not in KNOWN_VENDORS)`);
262
+ }
263
+ if (seen.has(s.vendor)) {
264
+ errors.push(`${where}: duplicate vendor entry`);
265
+ }
266
+ seen.add(s.vendor);
267
+
268
+ if (typeof s.displayName !== "string" || !s.displayName) {
269
+ errors.push(`${where}: displayName required`);
270
+ }
271
+ if (typeof s.loginUrl !== "string" || !/^https?:\/\//.test(s.loginUrl)) {
272
+ errors.push(`${where}: loginUrl must start with http:// or https://`);
273
+ } else {
274
+ try {
275
+ const u = new URL(s.loginUrl);
276
+ const host = u.host;
277
+ const matched = (s.cookieDomains || []).some((d) => {
278
+ if (typeof d !== "string") return false;
279
+ if (d.startsWith(".")) return host.endsWith(d.slice(1));
280
+ return host === d;
281
+ });
282
+ if (!matched) {
283
+ errors.push(
284
+ `${where}: loginUrl host "${host}" does not match any cookieDomain entry`,
285
+ );
286
+ }
287
+ } catch (urlErr) {
288
+ errors.push(`${where}: loginUrl unparseable (${urlErr.message})`);
289
+ }
290
+ }
291
+ if (!Array.isArray(s.cookieDomains) || s.cookieDomains.length < 1) {
292
+ errors.push(`${where}: cookieDomains must be a non-empty array`);
293
+ }
294
+ if (!Array.isArray(s.requiredCookies) || s.requiredCookies.length < 1) {
295
+ errors.push(`${where}: requiredCookies must be a non-empty array`);
296
+ }
297
+ if (s.optionalCookies && !Array.isArray(s.optionalCookies)) {
298
+ errors.push(`${where}: optionalCookies must be an array if present`);
299
+ }
300
+ if (!Array.isArray(s.postLoginPathHints) || s.postLoginPathHints.length < 1) {
301
+ errors.push(`${where}: postLoginPathHints must be a non-empty array`);
302
+ }
303
+ if (!Number.isInteger(s.cookieMaxAgeHintDays) || s.cookieMaxAgeHintDays <= 0) {
304
+ errors.push(`${where}: cookieMaxAgeHintDays must be a positive integer`);
305
+ }
306
+ if (typeof s.notes !== "string" || !s.notes) {
307
+ errors.push(`${where}: notes required`);
308
+ }
309
+ }
310
+
311
+ if (errors.length > 0 && opts.throwOnError !== false) {
312
+ throw new Error("Invalid cookie capture spec: " + errors.join("; "));
313
+ }
314
+ return { ok: errors.length === 0, errors };
315
+ }
316
+
317
+ // Defensive load-time check — if specs ship malformed in a future patch,
318
+ // the module fails fast at require() rather than at first wizard call.
319
+ validateCookieCaptureSpec();
320
+
321
+ module.exports = {
322
+ COOKIE_SPEC_VERSION,
323
+ KNOWN_VENDORS,
324
+ COOKIE_CAPTURE_SPECS,
325
+ getSpec,
326
+ listVendors,
327
+ classifyProbedCookies,
328
+ validateCookieCaptureSpec,
329
+ // exported for tests
330
+ _internal: { _normalizeCookieJar },
331
+ };
@@ -0,0 +1,210 @@
1
+ /**
2
+ * AIChat WebView 鉴权向导 — HealthChecker (Phase 10.3.5).
3
+ *
4
+ * Periodic worker that walks every registered AIChat vendor and re-runs
5
+ * `vendorAdapter.registerVendor(vendor, cookies)` against the *current*
6
+ * cookies in `aichat-accounts.json`. Result writes back to the entry as:
7
+ *
8
+ * entry.lastHealth = { ok, reason?, at }
9
+ *
10
+ * The wizard UI (Step 1 card grid) reads `lastHealth.ok` to render the red
11
+ * dot / "Cookie 即将过期" / "🔴 重登" affordance.
12
+ *
13
+ * Wiring:
14
+ * const hc = createAIChatHealthChecker({
15
+ * accountsStore,
16
+ * vendorAdapter, // same bridge used by Wizard (registerVendor)
17
+ * intervalMs: 6 * 3600_000, // default 6h
18
+ * firstRunDelayMs: 30_000, // first check 30s after start
19
+ * specVersion: 1, // bump on incompatible spec changes
20
+ * });
21
+ * hc.start();
22
+ * …
23
+ * hc.stop();
24
+ *
25
+ * Test seam: pass `_deps.setInterval` / `_deps.clearInterval` /
26
+ * `_deps.setTimeout` / `_deps.clock` so vitest can fast-forward without
27
+ * needing fake timers + real timers in the same suite.
28
+ *
29
+ * Reference: docs/design/Personal_Data_Hub_Phase_10_3_AIChat_WebView_Wizard.md §6
30
+ */
31
+
32
+ "use strict";
33
+
34
+ const DEFAULT_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h
35
+ const DEFAULT_FIRST_RUN_DELAY_MS = 30_000; // 30s after start
36
+
37
+ function createAIChatHealthChecker({
38
+ accountsStore,
39
+ vendorAdapter,
40
+ intervalMs = DEFAULT_INTERVAL_MS,
41
+ firstRunDelayMs = DEFAULT_FIRST_RUN_DELAY_MS,
42
+ specVersion = 1,
43
+ _deps,
44
+ } = {}) {
45
+ if (!accountsStore || typeof accountsStore.list !== "function") {
46
+ throw new Error("aichat-health-checker: accountsStore.list required");
47
+ }
48
+ if (typeof accountsStore.put !== "function") {
49
+ throw new Error("aichat-health-checker: accountsStore.put required");
50
+ }
51
+ if (!vendorAdapter || typeof vendorAdapter.registerVendor !== "function") {
52
+ throw new Error("aichat-health-checker: vendorAdapter.registerVendor required");
53
+ }
54
+
55
+ const defaults = {
56
+ setInterval: (fn, ms) => globalThis.setInterval(fn, ms),
57
+ clearInterval: (h) => globalThis.clearInterval(h),
58
+ setTimeout: (fn, ms) => globalThis.setTimeout(fn, ms),
59
+ clearTimeout: (h) => globalThis.clearTimeout(h),
60
+ clock: () => Date.now(),
61
+ logger: { info: () => {}, warn: () => {}, error: () => {} },
62
+ };
63
+ // Merge instead of replace so tests can supply just the timer slots and
64
+ // inherit a no-op logger / Date.now clock from defaults.
65
+ const deps = { ...defaults, ..._deps };
66
+
67
+ let intervalHandle = null;
68
+ let firstRunHandle = null;
69
+ let running = false;
70
+ let lastRunAt = 0;
71
+ let _runs = 0;
72
+
73
+ /**
74
+ * Walk all registered vendors, refresh their lastHealth.
75
+ *
76
+ * - If `cookieSpecVersion` < current `specVersion` → mark SPEC_VERSION_MISMATCH
77
+ * - Else call vendorAdapter.registerVendor with the stored cookies and
78
+ * propagate the result. The bridge already maps to validateCookie under
79
+ * the hood (matches the registerVendor contract from the wizard).
80
+ *
81
+ * Returns `{ checked, ok, failed, mismatch }` for callers / tests.
82
+ */
83
+ async function runOnce() {
84
+ if (running) {
85
+ deps.logger.warn("[aichat-health] previous run still in flight, skipping");
86
+ return { checked: 0, ok: 0, failed: 0, mismatch: 0, skipped: true };
87
+ }
88
+ running = true;
89
+ const start = deps.clock();
90
+ let checked = 0;
91
+ let ok = 0;
92
+ let failed = 0;
93
+ let mismatch = 0;
94
+ try {
95
+ const entries = (await accountsStore.list()) || [];
96
+ for (const entry of entries) {
97
+ if (!entry || !entry.vendor) continue;
98
+ checked++;
99
+ // Spec version downgrade trap (T7) — the on-disk cookies were
100
+ // captured under an older cookie-capture-spec version; cookies may
101
+ // still work but mark them so the UI flags re-login.
102
+ const entryVer = Number.isFinite(entry.cookieSpecVersion) ? entry.cookieSpecVersion : 0;
103
+ if (entryVer < specVersion) {
104
+ mismatch++;
105
+ await _writeHealth(entry, {
106
+ ok: false,
107
+ reason: "SPEC_VERSION_MISMATCH",
108
+ at: deps.clock(),
109
+ });
110
+ continue;
111
+ }
112
+ let result;
113
+ try {
114
+ result = await vendorAdapter.registerVendor(entry.vendor, entry.cookies || {});
115
+ } catch (err) {
116
+ deps.logger.warn(
117
+ `[aichat-health] ${entry.vendor} registerVendor threw`,
118
+ err && err.message,
119
+ );
120
+ result = { ok: false, reason: "ADAPTER_THREW", error: err && err.message };
121
+ }
122
+ if (result && result.ok === true) {
123
+ ok++;
124
+ await _writeHealth(entry, { ok: true, at: deps.clock() });
125
+ } else {
126
+ failed++;
127
+ await _writeHealth(entry, {
128
+ ok: false,
129
+ reason: (result && result.reason) || "VALIDATE_FAILED",
130
+ at: deps.clock(),
131
+ });
132
+ }
133
+ }
134
+ lastRunAt = start;
135
+ _runs++;
136
+ deps.logger.info(
137
+ `[aichat-health] checked=${checked} ok=${ok} failed=${failed} mismatch=${mismatch}`,
138
+ );
139
+ return { checked, ok, failed, mismatch };
140
+ } finally {
141
+ running = false;
142
+ }
143
+ }
144
+
145
+ async function _writeHealth(entry, lastHealth) {
146
+ const next = { ...entry, lastHealth };
147
+ try {
148
+ await accountsStore.put(entry.vendor, next);
149
+ } catch (err) {
150
+ deps.logger.warn(`[aichat-health] write back ${entry.vendor} failed`, err && err.message);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Begin the periodic loop. Idempotent — second call returns early.
156
+ * Schedules:
157
+ * - first run firstRunDelayMs after start()
158
+ * - subsequent runs every intervalMs
159
+ */
160
+ function start() {
161
+ if (intervalHandle || firstRunHandle) {
162
+ return false;
163
+ }
164
+ firstRunHandle = deps.setTimeout(() => {
165
+ firstRunHandle = null;
166
+ // Fire-and-forget; runOnce is internally guarded.
167
+ runOnce().catch((err) =>
168
+ deps.logger.error("[aichat-health] first run failed", err && err.message),
169
+ );
170
+ intervalHandle = deps.setInterval(() => {
171
+ runOnce().catch((err) =>
172
+ deps.logger.error("[aichat-health] interval run failed", err && err.message),
173
+ );
174
+ }, intervalMs);
175
+ }, firstRunDelayMs);
176
+ return true;
177
+ }
178
+
179
+ function stop() {
180
+ if (firstRunHandle) {
181
+ deps.clearTimeout(firstRunHandle);
182
+ firstRunHandle = null;
183
+ }
184
+ if (intervalHandle) {
185
+ deps.clearInterval(intervalHandle);
186
+ intervalHandle = null;
187
+ }
188
+ return true;
189
+ }
190
+
191
+ function status() {
192
+ return {
193
+ running,
194
+ started: !!(firstRunHandle || intervalHandle),
195
+ lastRunAt,
196
+ runs: _runs,
197
+ intervalMs,
198
+ firstRunDelayMs,
199
+ specVersion,
200
+ };
201
+ }
202
+
203
+ return { start, stop, runOnce, status };
204
+ }
205
+
206
+ module.exports = {
207
+ createAIChatHealthChecker,
208
+ DEFAULT_INTERVAL_MS,
209
+ DEFAULT_FIRST_RUN_DELAY_MS,
210
+ };
@@ -40,6 +40,7 @@ function topicIdForConversation(vendor, originalConvId) {
40
40
  * passes is idempotent.
41
41
  */
42
42
  function buildVendorPerson(vendor, displayName) {
43
+ const now = Date.now();
43
44
  return {
44
45
  id: personIdForVendor(vendor),
45
46
  type: ENTITY_TYPES.PERSON,
@@ -47,6 +48,14 @@ function buildVendorPerson(vendor, displayName) {
47
48
  names: [displayName],
48
49
  identifiers: { vendor },
49
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
+ },
50
59
  };
51
60
  }
52
61
 
@@ -54,10 +63,19 @@ function buildVendorPerson(vendor, displayName) {
54
63
  * Build the conversation Topic entity.
55
64
  */
56
65
  function buildConversationTopic(rawConv) {
66
+ const now = Date.now();
57
67
  return {
58
68
  id: topicIdForConversation(rawConv.vendor, rawConv.originalId),
59
69
  type: ENTITY_TYPES.TOPIC,
60
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
+ },
61
79
  extra: {
62
80
  vendor: rawConv.vendor,
63
81
  kind: "ai-conversation",
@@ -85,11 +103,19 @@ function buildMessageEvent(rawMsg, capturedAt) {
85
103
  rawMsg.content && Array.isArray(rawMsg.content.generatedImages) && rawMsg.content.generatedImages.length > 0
86
104
  ? EVENT_SUBTYPES.AI_IMAGE_GENERATION
87
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).
88
114
  return {
89
- id: newId(),
115
+ id: `evt-aichat-${rawMsg.vendor}-${rawMsg.originalId}`,
90
116
  type: ENTITY_TYPES.EVENT,
91
117
  subtype,
92
- occurredAt: new Date(rawMsg.createdAt).toISOString(),
118
+ occurredAt: Number.isFinite(occurredAtMs) && occurredAtMs > 0 ? occurredAtMs : now,
93
119
  actor,
94
120
  participants: [SELF_PERSON_ID, vendorPersonId],
95
121
  content: {
@@ -99,11 +125,12 @@ function buildMessageEvent(rawMsg, capturedAt) {
99
125
  : undefined,
100
126
  },
101
127
  topics: [topicIdForConversation(rawMsg.vendor, rawMsg.conversationId)],
128
+ ingestedAt: now,
102
129
  source: {
103
130
  adapter: ADAPTER_NAME,
104
131
  adapterVersion: ADAPTER_VERSION,
105
132
  originalId: `${rawMsg.vendor}/${rawMsg.originalId}`,
106
- capturedAt: new Date(capturedAt || Date.now()).toISOString(),
133
+ capturedAt: Number(capturedAt) || now,
107
134
  capturedBy: CAPTURED_BY.API,
108
135
  },
109
136
  extra: {
@@ -124,11 +151,21 @@ function buildMessageEvent(rawMsg, capturedAt) {
124
151
  */
125
152
  function buildGeneratedImageItems(rawMsg) {
126
153
  if (!rawMsg.content || !Array.isArray(rawMsg.content.generatedImages)) return [];
127
- return rawMsg.content.generatedImages.map((img) => ({
128
- id: newId(),
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}`,
129
158
  type: ENTITY_TYPES.ITEM,
130
159
  subtype: ITEM_SUBTYPES.MEDIA,
131
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
+ },
132
169
  extra: {
133
170
  vendor: rawMsg.vendor,
134
171
  kind: "ai-generated-image",
@@ -28,6 +28,7 @@ const SUPPORTED_VENDORS = Object.freeze([
28
28
  "qianfan",
29
29
  "coze",
30
30
  "dreamina",
31
+ "doubao",
31
32
  ]);
32
33
 
33
34
  class NotImplementedYetError extends Error {