@chainlesschain/personal-data-hub 0.2.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 (50) 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 +191 -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/wechat-bootstrap-end-to-end.test.js +390 -0
  22. package/__tests__/registry.test.js +4 -2
  23. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +55 -16
  24. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  25. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  26. package/lib/adapters/ai-chat-history/schema-map.js +42 -5
  27. package/lib/adapters/ai-chat-history/vendor-spec.js +1 -0
  28. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  29. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  30. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +4 -0
  31. package/lib/adapters/social-kuaishou/index.js +237 -0
  32. package/lib/adapters/social-toutiao/index.js +236 -0
  33. package/lib/adapters/system-data-android/adapter.js +348 -0
  34. package/lib/adapters/system-data-android/index.js +76 -0
  35. package/lib/adapters/wechat/bootstrap.js +146 -0
  36. package/lib/adapters/wechat/env-probe.js +218 -0
  37. package/lib/adapters/wechat/frida-agent/loader.js +67 -0
  38. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
  39. package/lib/adapters/wechat/index.js +9 -0
  40. package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
  41. package/lib/adapters/wechat/key-providers/index.js +22 -0
  42. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  43. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  44. package/lib/analysis-skills/spending.js +4 -1
  45. package/lib/analysis.js +191 -2
  46. package/lib/index.js +16 -0
  47. package/lib/prompt-builder.js +11 -1
  48. package/lib/query-parser.js +7 -1
  49. package/lib/vault.js +77 -0
  50. package/package.json +8 -1
@@ -0,0 +1,390 @@
1
+ /**
2
+ * WeChat Phase 12.6.7-10 end-to-end integration test.
3
+ *
4
+ * Exercises the full chain WITHOUT any real adb / Frida / device:
5
+ *
6
+ * env-probe (injected facts)
7
+ * ↓
8
+ * bootstrap.js (KeyProvider choice + adapter ctor)
9
+ * ↓
10
+ * AdapterRegistry (real, in-memory)
11
+ * ↓
12
+ * wechat-accounts.json persistence (real fs, temp dir)
13
+ * ↓
14
+ * list / unregister flow
15
+ *
16
+ * Three scenarios:
17
+ * A. md5 happy path — pre-WeChat-8 device:
18
+ * probe="md5" → wechatDataPath provided → register OK →
19
+ * registry has "wechat" adapter → persisted row chosenKeyProvider="md5"
20
+ * → unregister → row removed + registry empty
21
+ * B. frida happy path — rooted 8.0+ device:
22
+ * probe="frida" + root + frida-server up → register OK →
23
+ * chosenKeyProvider="frida" → persisted row reflects choice
24
+ * C. unsupported path — 8.0+ without root:
25
+ * probe="unsupported" → bootstrap rejects → no registry change,
26
+ * no row written, ok:false with reasons surfaced
27
+ * D. idempotent re-register — same uin twice:
28
+ * first registration with wechatDataPath A, second with B →
29
+ * single row remains, wechatDataPath=B (replaces, doesn't dupe)
30
+ */
31
+
32
+ "use strict";
33
+
34
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
35
+ import { mkdtempSync, rmSync } from "node:fs";
36
+ import { tmpdir } from "node:os";
37
+ import { join } from "node:path";
38
+
39
+ const {
40
+ bootstrapWechatAdapter,
41
+ } = require("../../lib/adapters/wechat/bootstrap");
42
+ const { AdapterRegistry } = require("../../lib/registry");
43
+ const { LocalVault } = require("../../lib/vault");
44
+ const { InMemoryKeyProvider } = require("../../lib/key-providers");
45
+ const { generateKeyHex } = require("../../lib/key-providers");
46
+
47
+ // Mirror of the hub-side store helpers so the integration test exercises
48
+ // the same persistence shape both desktop + cli wirings use.
49
+ const { readFileSync, writeFileSync, existsSync } = require("node:fs");
50
+ function loadWechatAccounts(filePath) {
51
+ try {
52
+ if (!existsSync(filePath)) return [];
53
+ const raw = readFileSync(filePath, "utf-8");
54
+ const parsed = JSON.parse(raw);
55
+ return Array.isArray(parsed) ? parsed : [];
56
+ } catch (_e) {
57
+ return [];
58
+ }
59
+ }
60
+ function saveWechatAccounts(filePath, accounts) {
61
+ writeFileSync(filePath, JSON.stringify(accounts, null, 2), {
62
+ encoding: "utf-8",
63
+ mode: 0o600,
64
+ });
65
+ }
66
+
67
+ function mkProbe(overrides = {}) {
68
+ return {
69
+ ok: true,
70
+ suggestedKeyProvider: "md5",
71
+ reasons: ["WeChat 7.0.22 (< 8.0) — legacy MD5(IMEI+UIN) path supported"],
72
+ device: { reachable: true, serial: "INTEG_TEST", abi: "arm64-v8a" },
73
+ root: { detected: false, magiskInstalled: false },
74
+ frida: { serverRunning: false, port: null },
75
+ wechat: { installed: true, versionName: "7.0.22", majorVersion: 7 },
76
+ warnings: [],
77
+ ...overrides,
78
+ };
79
+ }
80
+
81
+ // Replay the wiring's registerWechatAdapter() inline — mirroring the
82
+ // closure on the real hub object — so the integration test exercises
83
+ // the exact code path the IPC/WS layer drives in production.
84
+ async function registerWechatViaHub({ registry, hubDir, opts }) {
85
+ const r = await bootstrapWechatAdapter(opts);
86
+ if (!r.ok) return r;
87
+
88
+ if (registry.has(r.adapter.name)) registry.unregister(r.adapter.name);
89
+ registry.register(r.adapter);
90
+
91
+ const accountsPath = join(hubDir, "wechat-accounts.json");
92
+ const accounts = loadWechatAccounts(accountsPath);
93
+ const next = accounts.filter(
94
+ (c) => !(c.account && c.account.uin === opts.account.uin),
95
+ );
96
+ next.push({
97
+ account: { uin: opts.account.uin },
98
+ dbPath: opts.dbPath || null,
99
+ wechatDataPath: opts.wechatDataPath || null,
100
+ chosenKeyProvider: r.keyProvider && r.keyProvider.name,
101
+ registeredAt: Date.now(),
102
+ lastSyncAt: null,
103
+ });
104
+ saveWechatAccounts(accountsPath, next);
105
+
106
+ return {
107
+ ok: true,
108
+ name: r.adapter.name,
109
+ chosenKeyProvider: r.keyProvider.name,
110
+ probe: r.probe,
111
+ };
112
+ }
113
+
114
+ async function unregisterWechatViaHub({ registry, hubDir, uin }) {
115
+ const accountsPath = join(hubDir, "wechat-accounts.json");
116
+ const accounts = loadWechatAccounts(accountsPath);
117
+ const target = accounts.find((c) => c.account && c.account.uin === uin);
118
+ const next = accounts.filter(
119
+ (c) => !(c.account && c.account.uin === uin),
120
+ );
121
+ saveWechatAccounts(accountsPath, next);
122
+ if (target && registry.has("wechat")) registry.unregister("wechat");
123
+ return { ok: true, removed: !!target, uin };
124
+ }
125
+
126
+ function listWechatViaHub({ hubDir }) {
127
+ return loadWechatAccounts(join(hubDir, "wechat-accounts.json")).map((row) => ({
128
+ uin: row.account ? row.account.uin : null,
129
+ dbPath: row.dbPath || null,
130
+ hasWechatDataPath: !!row.wechatDataPath,
131
+ chosenKeyProvider: row.chosenKeyProvider || null,
132
+ registeredAt: row.registeredAt || null,
133
+ }));
134
+ }
135
+
136
+ describe("WeChat Phase 12.6.7-10 — end-to-end integration", () => {
137
+ let hubDir;
138
+ let dataDir;
139
+ let registry;
140
+
141
+ beforeEach(() => {
142
+ hubDir = mkdtempSync(join(tmpdir(), "pdh-wechat-integ-"));
143
+ dataDir = mkdtempSync(join(tmpdir(), "pdh-wechat-data-"));
144
+
145
+ // A real registry without vault/sinks — we don't sync, just
146
+ // register/unregister. Phase 12.6.7 boundary: bootstrap doesn't
147
+ // touch the registry, the wiring does (replicated above).
148
+ const vault = new LocalVault({
149
+ path: join(hubDir, "vault.db"),
150
+ key: generateKeyHex(),
151
+ });
152
+ vault.open();
153
+ registry = new AdapterRegistry({ vault });
154
+ });
155
+
156
+ afterEach(() => {
157
+ try { rmSync(hubDir, { recursive: true, force: true }); } catch (_e) {}
158
+ try { rmSync(dataDir, { recursive: true, force: true }); } catch (_e) {}
159
+ });
160
+
161
+ describe("A. md5 happy path — pre-WeChat-8 device", () => {
162
+ it("probe → register → adapter in registry + row persisted with md5 provider", async () => {
163
+ const r = await registerWechatViaHub({
164
+ registry,
165
+ hubDir,
166
+ opts: {
167
+ account: { uin: "1234567890" },
168
+ wechatDataPath: dataDir,
169
+ _probe: mkProbe(),
170
+ },
171
+ });
172
+
173
+ // Bootstrap chain succeeded
174
+ expect(r.ok).toBe(true);
175
+ expect(r.chosenKeyProvider).toBe("md5");
176
+ expect(r.name).toBe("wechat");
177
+ expect(r.probe.suggestedKeyProvider).toBe("md5");
178
+
179
+ // Registry picked up the adapter
180
+ expect(registry.has("wechat")).toBe(true);
181
+ const adapter = registry.get("wechat");
182
+ expect(adapter.account.uin).toBe("1234567890");
183
+
184
+ // Persistence reflects choice
185
+ const list = listWechatViaHub({ hubDir });
186
+ expect(list).toHaveLength(1);
187
+ expect(list[0]).toMatchObject({
188
+ uin: "1234567890",
189
+ chosenKeyProvider: "md5",
190
+ hasWechatDataPath: true,
191
+ });
192
+ });
193
+
194
+ it("unregister removes row + drops registry entry", async () => {
195
+ await registerWechatViaHub({
196
+ registry,
197
+ hubDir,
198
+ opts: {
199
+ account: { uin: "1234567890" },
200
+ wechatDataPath: dataDir,
201
+ _probe: mkProbe(),
202
+ },
203
+ });
204
+ expect(registry.has("wechat")).toBe(true);
205
+
206
+ const ur = await unregisterWechatViaHub({
207
+ registry,
208
+ hubDir,
209
+ uin: "1234567890",
210
+ });
211
+ expect(ur).toMatchObject({ ok: true, removed: true });
212
+ expect(registry.has("wechat")).toBe(false);
213
+ expect(listWechatViaHub({ hubDir })).toEqual([]);
214
+ });
215
+ });
216
+
217
+ describe("B. frida happy path — rooted 8.0+ device", () => {
218
+ it("probe='frida' + root yields FridaKeyProvider in persisted row", async () => {
219
+ const r = await registerWechatViaHub({
220
+ registry,
221
+ hubDir,
222
+ opts: {
223
+ account: { uin: "wxid_alice" },
224
+ _probe: mkProbe({
225
+ suggestedKeyProvider: "frida",
226
+ wechat: { installed: true, versionName: "8.0.50", majorVersion: 8 },
227
+ root: { detected: true, magiskInstalled: true },
228
+ frida: { serverRunning: true, port: 27042 },
229
+ reasons: ["WeChat 8.0.50 — Frida hook on libwcdb.so"],
230
+ }),
231
+ },
232
+ });
233
+ expect(r.ok).toBe(true);
234
+ expect(r.chosenKeyProvider).toBe("frida");
235
+ expect(registry.has("wechat")).toBe(true);
236
+
237
+ const list = listWechatViaHub({ hubDir });
238
+ expect(list[0].chosenKeyProvider).toBe("frida");
239
+ // Frida path doesn't require wechatDataPath
240
+ expect(list[0].hasWechatDataPath).toBe(false);
241
+ });
242
+ });
243
+
244
+ describe("C. unsupported path", () => {
245
+ it("8.0+ without root → no registry change, no row, ok:false with reasons", async () => {
246
+ const r = await registerWechatViaHub({
247
+ registry,
248
+ hubDir,
249
+ opts: {
250
+ account: { uin: "wxid_bob" },
251
+ _probe: mkProbe({
252
+ ok: false,
253
+ suggestedKeyProvider: "unsupported",
254
+ reasons: [
255
+ "WeChat 8.0.50 requires root for SQLCipher key extraction",
256
+ ],
257
+ wechat: { installed: true, versionName: "8.0.50", majorVersion: 8 },
258
+ root: { detected: false, magiskInstalled: false },
259
+ }),
260
+ },
261
+ });
262
+ expect(r.ok).toBe(false);
263
+ expect(r.reason).toBe("ENV_UNSUPPORTED");
264
+ expect(r.probe.reasons.join(" ")).toMatch(/requires root/);
265
+
266
+ expect(registry.has("wechat")).toBe(false);
267
+ expect(listWechatViaHub({ hubDir })).toEqual([]);
268
+ });
269
+
270
+ it("md5 path missing wechatDataPath → ok:false MD5_NEEDS_WECHAT_DATA_PATH", async () => {
271
+ const r = await registerWechatViaHub({
272
+ registry,
273
+ hubDir,
274
+ opts: {
275
+ account: { uin: "1234567890" },
276
+ _probe: mkProbe(),
277
+ // wechatDataPath intentionally omitted
278
+ },
279
+ });
280
+ expect(r.ok).toBe(false);
281
+ expect(r.reason).toBe("MD5_NEEDS_WECHAT_DATA_PATH");
282
+ expect(registry.has("wechat")).toBe(false);
283
+ });
284
+ });
285
+
286
+ describe("D. idempotent re-register", () => {
287
+ it("same uin twice → single row, latest wechatDataPath wins", async () => {
288
+ const dataA = mkdtempSync(join(tmpdir(), "pdh-wechat-dataA-"));
289
+ const dataB = mkdtempSync(join(tmpdir(), "pdh-wechat-dataB-"));
290
+ try {
291
+ await registerWechatViaHub({
292
+ registry,
293
+ hubDir,
294
+ opts: {
295
+ account: { uin: "1234567890" },
296
+ wechatDataPath: dataA,
297
+ _probe: mkProbe(),
298
+ },
299
+ });
300
+ await registerWechatViaHub({
301
+ registry,
302
+ hubDir,
303
+ opts: {
304
+ account: { uin: "1234567890" },
305
+ wechatDataPath: dataB,
306
+ _probe: mkProbe(),
307
+ },
308
+ });
309
+
310
+ const list = listWechatViaHub({ hubDir });
311
+ expect(list).toHaveLength(1);
312
+ expect(list[0].uin).toBe("1234567890");
313
+ expect(registry.has("wechat")).toBe(true);
314
+
315
+ // Adapter's _dbPath is null in both calls (we didn't pass dbPath),
316
+ // but the persisted row uses the latest wechatDataPath.
317
+ const raw = readFileSync(
318
+ join(hubDir, "wechat-accounts.json"),
319
+ "utf-8",
320
+ );
321
+ const persisted = JSON.parse(raw);
322
+ expect(persisted[0].wechatDataPath).toBe(dataB);
323
+ } finally {
324
+ try { rmSync(dataA, { recursive: true, force: true }); } catch (_e) {}
325
+ try { rmSync(dataB, { recursive: true, force: true }); } catch (_e) {}
326
+ }
327
+ });
328
+
329
+ it("two distinct uins coexist as separate rows", async () => {
330
+ await registerWechatViaHub({
331
+ registry,
332
+ hubDir,
333
+ opts: { account: { uin: "alice" }, wechatDataPath: dataDir, _probe: mkProbe() },
334
+ });
335
+ await registerWechatViaHub({
336
+ registry,
337
+ hubDir,
338
+ opts: { account: { uin: "bob" }, wechatDataPath: dataDir, _probe: mkProbe() },
339
+ });
340
+
341
+ const list = listWechatViaHub({ hubDir });
342
+ expect(list.map((r) => r.uin).sort()).toEqual(["alice", "bob"]);
343
+ // Single registry slot named "wechat" — second register replaces first
344
+ // adapter instance, but registry still has exactly one entry. This is
345
+ // the v0.5 limit: the registry namespaces by adapter.name not by uin.
346
+ // The persisted accounts file is the source of truth for "which uins
347
+ // can sync"; bootstrap re-runs at sync time per account.
348
+ expect(registry.has("wechat")).toBe(true);
349
+ });
350
+ });
351
+
352
+ describe("override semantics (Phase 12.6.7 §18.10)", () => {
353
+ it("keyProviderOverride='frida' wins over probe='md5'", async () => {
354
+ const r = await registerWechatViaHub({
355
+ registry,
356
+ hubDir,
357
+ opts: {
358
+ account: { uin: "wxid_force" },
359
+ keyProviderOverride: "frida",
360
+ _probe: mkProbe(), // suggests md5
361
+ },
362
+ });
363
+ expect(r.ok).toBe(true);
364
+ expect(r.chosenKeyProvider).toBe("frida");
365
+ // Probe transparency: original suggestion still surfaces unchanged
366
+ expect(r.probe.suggestedKeyProvider).toBe("md5");
367
+ });
368
+
369
+ it("keyProviderOverride='md5' wins over probe='frida'", async () => {
370
+ const r = await registerWechatViaHub({
371
+ registry,
372
+ hubDir,
373
+ opts: {
374
+ account: { uin: "1234567890" },
375
+ wechatDataPath: dataDir,
376
+ keyProviderOverride: "md5",
377
+ _probe: mkProbe({
378
+ suggestedKeyProvider: "frida",
379
+ wechat: { installed: true, versionName: "8.0.50", majorVersion: 8 },
380
+ root: { detected: true, magiskInstalled: true },
381
+ frida: { serverRunning: true, port: 27042 },
382
+ }),
383
+ },
384
+ });
385
+ expect(r.ok).toBe(true);
386
+ expect(r.chosenKeyProvider).toBe("md5");
387
+ expect(r.probe.suggestedKeyProvider).toBe("frida");
388
+ });
389
+ });
390
+ });
@@ -178,7 +178,9 @@ describe("AdapterRegistry.syncAdapter", () => {
178
178
  it("refuses concurrent sync of two adapters in one registry", async () => {
179
179
  freshVault();
180
180
  const reg = new AdapterRegistry({ vault });
181
- reg.register(new MockAdapter({ name: "a", count: 5000 })); // long enough to be in-flight
181
+ // Use 500 events (not 5000) still big enough to be mid-flight when
182
+ // the second syncAdapter() lands, but fits comfortably in 10s.
183
+ reg.register(new MockAdapter({ name: "a", count: 500 }));
182
184
  reg.register(new MockAdapter({ name: "b", count: 5 }));
183
185
 
184
186
  const p1 = reg.syncAdapter("a");
@@ -194,7 +196,7 @@ describe("AdapterRegistry.syncAdapter", () => {
194
196
  // assert no double-sync corruption. The active-sync invariant is
195
197
  // additionally enforced by the activeSync flag.
196
198
  expect(racedReject == null || /already syncing/.test(racedReject.message)).toBe(true);
197
- });
199
+ }, 30_000);
198
200
  });
199
201
 
200
202
  // ─── KG + RAG sinks ──────────────────────────────────────────────────────
@@ -45,6 +45,7 @@ const { SPEC: hunyuanSpec } = require("./vendors/hunyuan");
45
45
  const { SPEC: qianfanSpec } = require("./vendors/qianfan");
46
46
  const { SPEC: cozeSpec } = require("./vendors/coze");
47
47
  const { SPEC: dreaminaSpec } = require("./vendors/dreamina");
48
+ const { SPEC: doubaoSpec } = require("./vendors/doubao");
48
49
 
49
50
  const DEFAULT_VENDOR_SPECS = Object.freeze({
50
51
  deepseek: deepseekSpec,
@@ -55,6 +56,7 @@ const DEFAULT_VENDOR_SPECS = Object.freeze({
55
56
  qianfan: qianfanSpec,
56
57
  coze: cozeSpec,
57
58
  dreamina: dreaminaSpec,
59
+ doubao: doubaoSpec,
58
60
  });
59
61
 
60
62
  class AIChatHistoryAdapter {
@@ -186,13 +188,19 @@ class AIChatHistoryAdapter {
186
188
  /**
187
189
  * Stream conversation + message envelopes across all configured vendors.
188
190
  *
189
- * Yields raw events of two shapes:
190
- * { kind: "conversation", vendor, conversation: RawConversation }
191
- * { kind: "message", vendor, message: RawMessage }
191
+ * Yields AdapterRegistry-compliant envelopes:
192
+ * { originalId, capturedAt, payload: { kind, vendor, conversation|message } }
192
193
  *
193
- * The registry calls `normalize(raw)` per yielded event. We deliberately
194
- * keep one Raw per yield (rather than batching) so a slow vendor doesn't
195
- * block faster ones at the registry boundary.
194
+ * The inner `payload.kind` distinguishes:
195
+ * - "conversation" → emit Topic + vendor Person (no Event yet)
196
+ * - "message" → emit Event + items + vendor Person
197
+ * - "vendor-not-wired" → no-op normalize (Phase 10.1 stub trace)
198
+ * - "vendor-cookie-expired" → no-op normalize (401/403 trace)
199
+ * - "vendor-rate-limited" → no-op normalize (429 trace after retries)
200
+ *
201
+ * The registry calls `normalize(raw)` per yielded envelope. One yield per
202
+ * conversation/message keeps registry batches small so a slow vendor
203
+ * doesn't block faster ones at the registry boundary.
196
204
  *
197
205
  * @param {object} [opts]
198
206
  * @param {string[]} [opts.vendors] restrict to a subset
@@ -216,28 +224,54 @@ class AIChatHistoryAdapter {
216
224
 
217
225
  try {
218
226
  for await (const conv of spec.listConversations(ctx, { since: vendorWatermark })) {
219
- yield { kind: "conversation", vendor, conversation: conv };
227
+ yield {
228
+ originalId: `${vendor}:conv:${conv.originalId}`,
229
+ capturedAt: Number(conv.updatedAt) || Number(conv.createdAt) || Date.now(),
230
+ payload: { kind: "conversation", vendor, conversation: conv },
231
+ };
220
232
 
221
233
  for await (const msg of spec.listMessages(ctx, conv.originalId, {})) {
222
- yield { kind: "message", vendor, message: msg };
234
+ yield {
235
+ originalId: `${vendor}:msg:${msg.originalId}`,
236
+ capturedAt: Number(msg.createdAt) || Date.now(),
237
+ payload: { kind: "message", vendor, message: msg },
238
+ };
223
239
  }
224
240
  }
225
241
  } catch (err) {
242
+ const traceCapturedAt = Date.now();
226
243
  if (err instanceof NotImplementedYetError) {
227
244
  this._logger.warn(
228
245
  `[ai-chat] vendor=${vendor} not wired (Phase 10.2+ work): ${err.message}`,
229
246
  );
230
- yield { kind: "vendor-not-wired", vendor, error: err.code };
247
+ yield {
248
+ originalId: `${vendor}:trace:not-wired:${traceCapturedAt}`,
249
+ capturedAt: traceCapturedAt,
250
+ payload: { kind: "vendor-not-wired", vendor, error: err.code },
251
+ };
231
252
  continue;
232
253
  }
233
254
  if (err instanceof CookieExpiredError) {
234
255
  this._logger.warn(`[ai-chat] vendor=${vendor} cookie expired: ${err.message}`);
235
- yield { kind: "vendor-cookie-expired", vendor, error: err.code };
256
+ yield {
257
+ originalId: `${vendor}:trace:cookie-expired:${traceCapturedAt}`,
258
+ capturedAt: traceCapturedAt,
259
+ payload: { kind: "vendor-cookie-expired", vendor, error: err.code },
260
+ };
236
261
  continue;
237
262
  }
238
263
  if (err instanceof RateLimitedError) {
239
264
  this._logger.warn(`[ai-chat] vendor=${vendor} rate limited: ${err.message}`);
240
- yield { kind: "vendor-rate-limited", vendor, error: err.code, retryAfterMs: err.retryAfterMs };
265
+ yield {
266
+ originalId: `${vendor}:trace:rate-limited:${traceCapturedAt}`,
267
+ capturedAt: traceCapturedAt,
268
+ payload: {
269
+ kind: "vendor-rate-limited",
270
+ vendor,
271
+ error: err.code,
272
+ retryAfterMs: err.retryAfterMs,
273
+ },
274
+ };
241
275
  continue;
242
276
  }
243
277
  throw err;
@@ -256,14 +290,19 @@ class AIChatHistoryAdapter {
256
290
  if (!raw || typeof raw !== "object") {
257
291
  return { events: [], persons: [], places: [], items: [], topics: [] };
258
292
  }
293
+ // Registry-compliant envelopes wrap kind inside payload. Adapter-internal
294
+ // tests (Phase 10.1) sometimes pass the inner shape directly — accept
295
+ // both for forward compat.
296
+ const inner = raw.payload && typeof raw.payload === "object" ? raw.payload : raw;
297
+ const kind = inner.kind;
259
298
 
260
- if (raw.kind === "vendor-not-wired") {
299
+ if (kind === "vendor-not-wired" || kind === "vendor-cookie-expired" || kind === "vendor-rate-limited") {
261
300
  // Nothing to write; the warning was already logged by sync().
262
301
  return { events: [], persons: [], places: [], items: [], topics: [] };
263
302
  }
264
303
 
265
- if (raw.kind === "conversation") {
266
- const conv = raw.conversation;
304
+ if (kind === "conversation") {
305
+ const conv = inner.conversation;
267
306
  const spec = this._vendorSpecs[conv.vendor];
268
307
  const displayName = spec ? spec.displayName : conv.vendor;
269
308
  return {
@@ -275,8 +314,8 @@ class AIChatHistoryAdapter {
275
314
  };
276
315
  }
277
316
 
278
- if (raw.kind === "message") {
279
- const msg = raw.message;
317
+ if (kind === "message") {
318
+ const msg = inner.message;
280
319
  const spec = this._vendorSpecs[msg.vendor];
281
320
  const displayName = spec ? spec.displayName : msg.vendor;
282
321
  return {