@chainlesschain/personal-data-hub 0.2.4 → 0.3.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 (55) hide show
  1. package/__tests__/adapters/browser-history-chrome.test.js +377 -0
  2. package/__tests__/adapters/browser-history-edge.test.js +159 -0
  3. package/__tests__/adapters/git-activity.test.js +216 -0
  4. package/__tests__/adapters/local-files.test.js +264 -0
  5. package/__tests__/adapters/shell-history.test.js +180 -0
  6. package/__tests__/adapters/system-data-android.test.js +104 -3
  7. package/__tests__/adapters/vscode.test.js +299 -0
  8. package/__tests__/adapters/win-recent.test.js +192 -0
  9. package/__tests__/analysis.test.js +840 -1
  10. package/__tests__/categories.test.js +92 -0
  11. package/__tests__/e2e/local-data-adapters-cli.e2e.test.js +146 -0
  12. package/__tests__/entity-resolver-vault.test.js +5 -2
  13. package/__tests__/integration/local-data-adapters-pipeline.test.js +373 -0
  14. package/__tests__/query-parser.test.js +66 -0
  15. package/__tests__/registry.test.js +114 -0
  16. package/__tests__/sidecar-contacts-cross-validate.test.js +24 -1
  17. package/__tests__/sidecar-supervisor.test.js +9 -1
  18. package/__tests__/social-kuaishou-snapshot.test.js +55 -2
  19. package/__tests__/social-toutiao-snapshot.test.js +54 -2
  20. package/__tests__/vault-search-helpers.test.js +104 -0
  21. package/__tests__/vault-search.test.js +423 -0
  22. package/__tests__/vault.test.js +77 -3
  23. package/lib/adapters/browser-history-chrome/adapter.js +247 -0
  24. package/lib/adapters/browser-history-chrome/bookmarks-reader.js +79 -0
  25. package/lib/adapters/browser-history-chrome/chrome-db-reader.js +223 -0
  26. package/lib/adapters/browser-history-chrome/index.js +23 -0
  27. package/lib/adapters/browser-history-edge/adapter.js +34 -0
  28. package/lib/adapters/browser-history-edge/index.js +13 -0
  29. package/lib/adapters/git-activity/adapter.js +155 -0
  30. package/lib/adapters/git-activity/git-reader.js +125 -0
  31. package/lib/adapters/git-activity/index.js +17 -0
  32. package/lib/adapters/local-files/adapter.js +149 -0
  33. package/lib/adapters/local-files/file-walker.js +125 -0
  34. package/lib/adapters/local-files/index.js +18 -0
  35. package/lib/adapters/shell-history/adapter.js +137 -0
  36. package/lib/adapters/shell-history/index.js +17 -0
  37. package/lib/adapters/shell-history/shell-reader.js +100 -0
  38. package/lib/adapters/social-kuaishou/index.js +57 -1
  39. package/lib/adapters/social-toutiao/index.js +59 -1
  40. package/lib/adapters/system-data-android/adapter.js +220 -3
  41. package/lib/adapters/vscode/adapter.js +285 -0
  42. package/lib/adapters/vscode/index.js +18 -0
  43. package/lib/adapters/vscode/vscode-reader.js +191 -0
  44. package/lib/adapters/win-recent/adapter.js +150 -0
  45. package/lib/adapters/win-recent/index.js +16 -0
  46. package/lib/adapters/win-recent/win-recent-reader.js +72 -0
  47. package/lib/analysis.js +227 -9
  48. package/lib/categories.js +101 -0
  49. package/lib/index.js +61 -0
  50. package/lib/migrations.js +146 -0
  51. package/lib/query-parser.js +74 -0
  52. package/lib/registry.js +162 -0
  53. package/lib/vault.js +363 -2
  54. package/package.json +2 -1
  55. package/scripts/run-native-tests-sandbox.sh +53 -0
@@ -23,11 +23,18 @@ const {
23
23
  ENTITY_TYPES,
24
24
  PERSON_SUBTYPES,
25
25
  ITEM_SUBTYPES,
26
+ EVENT_SUBTYPES,
26
27
  CAPTURED_BY,
27
28
  } = require("../../constants");
28
29
 
29
30
  const NAME = "system-data-android";
30
- const VERSION = "0.1.0";
31
+ // v0.3.0 (2026-05-24): added kind="media-file" via bridge mode
32
+ // (host-adb-bridge media.list across 5 /sdcard categories). Metadata
33
+ // only — path/size/mtime/ext, no file content.
34
+ // v0.2.0 (2026-05-24): added kind="sms" + kind="call" via bridge mode.
35
+ // Snapshot mode still v1 schema — sms/calls/media only land via
36
+ // bridge path until Android snapshot writer is updated to include them.
37
+ const VERSION = "0.3.0";
31
38
  const SNAPSHOT_SCHEMA_VERSION = 1;
32
39
 
33
40
  // Stable per-source originalId — registry.putRawEvent rejects null originalId
@@ -48,6 +55,25 @@ function appOriginalId(a) {
48
55
  `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
49
56
  return `android-app:${k}`;
50
57
  }
58
+ function smsOriginalId(s) {
59
+ // Stable across re-syncs: use SMS _id from the system content provider.
60
+ const k = (s && s.id != null && String(s.id)) ||
61
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
62
+ return `android-sms:${k}`;
63
+ }
64
+ function callOriginalId(c) {
65
+ // Stable across re-syncs: use call_log _id from the system content provider.
66
+ const k = (c && c.id != null && String(c.id)) ||
67
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
68
+ return `android-call:${k}`;
69
+ }
70
+ function mediaOriginalId(m) {
71
+ // Full filesystem path is stable as long as the file isn't moved/renamed.
72
+ // Path is unique within the device.
73
+ const k = (m && typeof m.path === "string" && m.path) ||
74
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
75
+ return `android-media:${k}`;
76
+ }
51
77
 
52
78
  class SystemDataAndroidAdapter {
53
79
  constructor(opts = {}) {
@@ -56,6 +82,9 @@ class SystemDataAndroidAdapter {
56
82
  this.capabilities = [
57
83
  "sync:android-content-provider",
58
84
  "sync:android-package-manager",
85
+ "sync:android-sms",
86
+ "sync:android-call-log",
87
+ "sync:android-media-files",
59
88
  ];
60
89
  this.extractMode = "device-pull";
61
90
  this.rateLimits = { perDay: 24 };
@@ -63,10 +92,20 @@ class SystemDataAndroidAdapter {
63
92
  fields: [
64
93
  "contacts:displayName,phones,emails,starred,organization,photoUri",
65
94
  "installed_apps:packageName,label,versionName,versionCode,firstInstallTime,lastUpdateTime,isSystem",
95
+ "sms:id,address,body,date,dateSent,type,threadId,read,subject",
96
+ "callLog:id,number,name,duration,date,type,geocoded",
97
+ // Media is metadata-only — file content never leaves the device.
98
+ "media:path,size,mtimeMs,ext,category(photos|pictures|videos|downloads|documents)",
66
99
  ],
67
- sensitivity: "medium",
100
+ sensitivity: "high",
68
101
  legalGate: false,
69
- defaultInclude: { contacts: true, apps: true },
102
+ defaultInclude: {
103
+ contacts: true,
104
+ apps: true,
105
+ sms: true,
106
+ calls: true,
107
+ media: { photos: true, pictures: true, videos: true, downloads: true, documents: true },
108
+ },
70
109
  };
71
110
 
72
111
  // _deps for test injection — mirrors the pattern in cli-dev.md so test
@@ -191,6 +230,68 @@ class SystemDataAndroidAdapter {
191
230
  emitted += 1;
192
231
  }
193
232
  }
233
+
234
+ const includeSms = opts.include?.sms !== false;
235
+ if (includeSms) {
236
+ const res = await bridge.invoke("sms.query", {
237
+ since: Number.isInteger(opts.since) ? opts.since : undefined,
238
+ });
239
+ const arr = Array.isArray(res) ? res : Array.isArray(res?.sms) ? res.sms : [];
240
+ for (const s of arr) {
241
+ if (emitted >= limit) return;
242
+ yield {
243
+ kind: "sms",
244
+ originalId: smsOriginalId(s),
245
+ capturedAt,
246
+ payload: s,
247
+ };
248
+ emitted += 1;
249
+ }
250
+ }
251
+
252
+ const includeCalls = opts.include?.calls !== false;
253
+ if (includeCalls) {
254
+ const res = await bridge.invoke("call.query", {
255
+ since: Number.isInteger(opts.since) ? opts.since : undefined,
256
+ });
257
+ const arr = Array.isArray(res) ? res : Array.isArray(res?.calls) ? res.calls : [];
258
+ for (const c of arr) {
259
+ if (emitted >= limit) return;
260
+ yield {
261
+ kind: "call",
262
+ originalId: callOriginalId(c),
263
+ capturedAt,
264
+ payload: c,
265
+ };
266
+ emitted += 1;
267
+ }
268
+ }
269
+
270
+ // Media files — metadata only (path, size, mtime, ext). Never reads
271
+ // file content off the device. UI uses per-category include keys so
272
+ // a privacy-conscious user can keep photos but skip downloads, etc.
273
+ const MEDIA_CATEGORIES = ["photos", "pictures", "videos", "downloads", "documents"];
274
+ for (const cat of MEDIA_CATEGORIES) {
275
+ // Per-category include key: include.media.photos, include.media.videos, ...
276
+ // Top-level `include.media === false` disables ALL media in one switch.
277
+ if (opts.include?.media === false) break;
278
+ if (opts.include?.media?.[cat] === false) continue;
279
+ const res = await bridge.invoke("media.list", {
280
+ category: cat,
281
+ since: Number.isInteger(opts.since) ? opts.since : undefined,
282
+ });
283
+ const arr = Array.isArray(res) ? res : Array.isArray(res?.files) ? res.files : [];
284
+ for (const f of arr) {
285
+ if (emitted >= limit) return;
286
+ yield {
287
+ kind: "media-file",
288
+ originalId: mediaOriginalId(f),
289
+ capturedAt,
290
+ payload: f,
291
+ };
292
+ emitted += 1;
293
+ }
294
+ }
194
295
  }
195
296
 
196
297
  async *_syncViaSnapshot(opts) {
@@ -336,6 +437,122 @@ class SystemDataAndroidAdapter {
336
437
  };
337
438
  }
338
439
 
440
+ if (raw.kind === "sms") {
441
+ const p = raw.payload || {};
442
+ // SMS type from Android SDK Telephony.Sms (Inbox.MESSAGE_TYPE_*):
443
+ // 1 INBOX, 2 SENT, 3 DRAFT, 4 OUTBOX, 5 FAILED, 6 QUEUED
444
+ const direction = p.type === 2 || p.type === 4 ? "out" : "in";
445
+ const eventId = `event-android-sms-${p.id || raw.capturedAt}`;
446
+ const occurredAt = Number.isInteger(p.date) ? p.date : raw.capturedAt;
447
+ const bodyText = typeof p.body === "string" ? p.body : "";
448
+ const event = {
449
+ id: eventId,
450
+ type: ENTITY_TYPES.EVENT,
451
+ subtype: EVENT_SUBTYPES.MESSAGE,
452
+ occurredAt,
453
+ ingestedAt,
454
+ source: source(`android-sms:${p.id || raw.capturedAt}`),
455
+ actor: direction === "in" ? p.address : "self",
456
+ // Participants on the OTHER side of the message.
457
+ participants: p.address ? [p.address] : [],
458
+ // Validator (lib/schemas.js validateEvent) requires `content` to be
459
+ // a plain object — title/text/etc go INSIDE this object, not on the
460
+ // event root.
461
+ content: {
462
+ title: bodyText.length > 0
463
+ ? (bodyText.length > 80 ? bodyText.substring(0, 80) + "…" : bodyText)
464
+ : "(空短信)",
465
+ text: bodyText,
466
+ },
467
+ };
468
+ const extra = { direction, threadId: p.threadId };
469
+ if (typeof p.dateSent === "number") extra.dateSent = p.dateSent;
470
+ if (typeof p.read === "boolean") extra.read = p.read;
471
+ if (typeof p.subject === "string" && p.subject.length > 0) extra.subject = p.subject;
472
+ if (Number.isInteger(p.type)) extra.smsType = p.type;
473
+ event.extra = extra;
474
+
475
+ return { events: [event], persons: [], places: [], items: [], topics: [] };
476
+ }
477
+
478
+ if (raw.kind === "call") {
479
+ const p = raw.payload || {};
480
+ // Call type from Android SDK CallLog.Calls.TYPE:
481
+ // 1 INCOMING, 2 OUTGOING, 3 MISSED, 4 VOICEMAIL, 5 REJECTED, 6 BLOCKED
482
+ const direction = p.type === 2 ? "out" : "in";
483
+ const eventId = `event-android-call-${p.id || raw.capturedAt}`;
484
+ const occurredAt = Number.isInteger(p.date) ? p.date : raw.capturedAt;
485
+ const callTypeName =
486
+ p.type === 1 ? "incoming" :
487
+ p.type === 2 ? "outgoing" :
488
+ p.type === 3 ? "missed" :
489
+ p.type === 4 ? "voicemail" :
490
+ p.type === 5 ? "rejected" :
491
+ p.type === 6 ? "blocked" : "unknown";
492
+ const titleName =
493
+ (typeof p.name === "string" && p.name.trim().length > 0) ? p.name.trim() : (p.number || "未知号码");
494
+ const title =
495
+ `${callTypeName === "missed" ? "未接 " : ""}${callTypeName === "outgoing" ? "拨打 " : ""}${callTypeName === "incoming" ? "来电 " : ""}${titleName}`;
496
+ const event = {
497
+ id: eventId,
498
+ type: ENTITY_TYPES.EVENT,
499
+ subtype: EVENT_SUBTYPES.CALL,
500
+ occurredAt,
501
+ ingestedAt,
502
+ source: source(`android-call:${p.id || raw.capturedAt}`),
503
+ actor: direction === "in" ? p.number : "self",
504
+ participants: p.number ? [p.number] : [],
505
+ // Schema-required `content` object — title goes here, not on root.
506
+ content: { title },
507
+ };
508
+ if (Number.isInteger(p.duration) && p.duration > 0) {
509
+ event.durationMs = p.duration * 1000;
510
+ }
511
+ const extra = { direction, callType: callTypeName };
512
+ if (Number.isInteger(p.type)) extra.androidCallType = p.type;
513
+ if (typeof p.geocoded === "string" && p.geocoded.length > 0) extra.geocoded = p.geocoded;
514
+ if (typeof p.name === "string" && p.name.length > 0) extra.name = p.name;
515
+ event.extra = extra;
516
+
517
+ return { events: [event], persons: [], places: [], items: [], topics: [] };
518
+ }
519
+
520
+ if (raw.kind === "media-file") {
521
+ const p = raw.payload || {};
522
+ const path = typeof p.path === "string" ? p.path : "";
523
+ const fileName = path.includes("/") ? path.substring(path.lastIndexOf("/") + 1) : path;
524
+ // Category → item subtype + category string
525
+ let subtype = ITEM_SUBTYPES.OTHER;
526
+ let category = "media";
527
+ if (p.category === "photos" || p.category === "pictures" || p.category === "videos") {
528
+ subtype = ITEM_SUBTYPES.MEDIA;
529
+ category = p.category;
530
+ } else if (p.category === "documents") {
531
+ subtype = ITEM_SUBTYPES.DOCUMENT;
532
+ category = "documents";
533
+ } else if (p.category === "downloads") {
534
+ subtype = ITEM_SUBTYPES.OTHER;
535
+ category = "downloads";
536
+ }
537
+ const item = {
538
+ id: `item-android-media-${path}`,
539
+ type: ENTITY_TYPES.ITEM,
540
+ subtype,
541
+ name: fileName || "(无名)",
542
+ category,
543
+ ingestedAt,
544
+ source: source(`android-media:${path}`),
545
+ extra: {
546
+ path,
547
+ size: Number.isInteger(p.size) ? p.size : null,
548
+ mtimeMs: Number.isInteger(p.mtimeMs) ? p.mtimeMs : null,
549
+ ext: typeof p.ext === "string" ? p.ext : null,
550
+ androidCategory: p.category,
551
+ },
552
+ };
553
+ return { events: [], persons: [], places: [], items: [item], topics: [] };
554
+ }
555
+
339
556
  throw new Error(`system-data-android.normalize: unknown raw.kind=${raw.kind}`);
340
557
  }
341
558
  }
@@ -0,0 +1,285 @@
1
+ "use strict";
2
+
3
+ // VSCodeAdapter — pulls VSCode workspace history + global terminal history
4
+ // from on-disk state. Desktop-local, zero network, no extension required.
5
+ //
6
+ // Sources (all under `%APPDATA%\Code\` on Win, equivalent on macOS/Linux):
7
+ // - User/workspaceStorage/<hash>/workspace.json → each opened project
8
+ // - User/globalStorage/state.vscdb → terminal command history
9
+ //
10
+ // Yields:
11
+ // kind="workspace" → Item(LINK, category="code-project")
12
+ // kind="terminal-command" → Event(OTHER, content.title=cmd[0..80])
13
+ // kind="terminal-dir" → Event(OTHER, content.title=cd <dir>)
14
+ //
15
+ // Caveat: terminal history has NO per-entry timestamp in VSCode — only a
16
+ // single "snapshot updated" ts. We anchor every command/dir to that ts and
17
+ // add `sourceIndex` to extra so callers can reconstruct order. Re-syncing
18
+ // after a new command lands gives that command (and only that command) a
19
+ // fresher snapshot ts.
20
+
21
+ const path = require("node:path");
22
+
23
+ const {
24
+ ENTITY_TYPES,
25
+ EVENT_SUBTYPES,
26
+ ITEM_SUBTYPES,
27
+ CAPTURED_BY,
28
+ } = require("../../constants");
29
+
30
+ const {
31
+ defaultVscodeRoot,
32
+ decodeFileUri,
33
+ readWorkspaces,
34
+ readTerminalHistory,
35
+ } = require("./vscode-reader");
36
+
37
+ const NAME = "vscode";
38
+ const VERSION = "0.1.0";
39
+
40
+ class VSCodeAdapter {
41
+ constructor(opts = {}) {
42
+ this.name = NAME;
43
+ this.version = VERSION;
44
+ this.capabilities = [
45
+ "sync:vscode-workspace-storage",
46
+ "sync:vscode-globalstorage-sqlite",
47
+ ];
48
+ this.extractMode = "file-import";
49
+ this.rateLimits = { perDay: 96 };
50
+ this.dataDisclosure = {
51
+ fields: [
52
+ "workspaces:hash,folderUri,folderPath,lastOpenedMs",
53
+ "terminal-commands:command,shellType,sourceIndex,snapshotTs",
54
+ "terminal-dirs:dir,shellType,sourceIndex,snapshotTs",
55
+ ],
56
+ sensitivity: "high",
57
+ legalGate: false,
58
+ defaultInclude: { workspaces: true, terminal: true },
59
+ };
60
+ this._deps = {
61
+ fs: require("node:fs"),
62
+ defaultRoot: defaultVscodeRoot,
63
+ };
64
+ this._rootOverride = typeof opts.vscodeRoot === "string" ? opts.vscodeRoot : null;
65
+ }
66
+
67
+ _resolveRoot(opts) {
68
+ if (typeof opts?.vscodeRoot === "string" && opts.vscodeRoot.length > 0) {
69
+ return opts.vscodeRoot;
70
+ }
71
+ if (this._rootOverride) return this._rootOverride;
72
+ return this._deps.defaultRoot();
73
+ }
74
+
75
+ async authenticate(ctx = {}) {
76
+ const root = this._resolveRoot(ctx);
77
+ if (!root) {
78
+ return {
79
+ ok: false,
80
+ reason: "VSCODE_ROOT_UNRESOLVED",
81
+ message: "no default VSCode root on this platform; pass opts.vscodeRoot",
82
+ };
83
+ }
84
+ const wsRoot = path.join(root, "User", "workspaceStorage");
85
+ const stateDb = path.join(root, "User", "globalStorage", "state.vscdb");
86
+ const wsExists = this._deps.fs.existsSync(wsRoot);
87
+ const stateExists = this._deps.fs.existsSync(stateDb);
88
+ if (!wsExists && !stateExists) {
89
+ return {
90
+ ok: false,
91
+ reason: "VSCODE_NOT_FOUND",
92
+ message: `no VSCode state at ${root} — install VS Code / open it at least once, or pass opts.vscodeRoot`,
93
+ };
94
+ }
95
+ return {
96
+ ok: true,
97
+ mode: "file-import",
98
+ vscodeRoot: root,
99
+ hasWorkspaces: wsExists,
100
+ hasTerminalHistory: stateExists,
101
+ };
102
+ }
103
+
104
+ async healthCheck() {
105
+ const root = this._resolveRoot({});
106
+ const ok =
107
+ !!root &&
108
+ (this._deps.fs.existsSync(path.join(root, "User", "workspaceStorage")) ||
109
+ this._deps.fs.existsSync(path.join(root, "User", "globalStorage", "state.vscdb")));
110
+ return { ok, lastChecked: Date.now() };
111
+ }
112
+
113
+ async *sync(opts = {}) {
114
+ const root = this._resolveRoot(opts);
115
+ if (!root) {
116
+ throw new Error("vscode.sync: no VSCode root resolved — pass opts.vscodeRoot");
117
+ }
118
+ const includeWorkspaces = opts.include?.workspaces !== false;
119
+ const includeTerminal = opts.include?.terminal !== false;
120
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
121
+ const capturedAt = Date.now();
122
+ let emitted = 0;
123
+
124
+ if (includeWorkspaces) {
125
+ for (const w of readWorkspaces(root, { fs: this._deps.fs, since: opts.since })) {
126
+ if (emitted >= limit) return;
127
+ yield {
128
+ kind: "workspace",
129
+ originalId: `vscode-workspace:${w.hash}`,
130
+ capturedAt,
131
+ payload: w,
132
+ };
133
+ emitted += 1;
134
+ }
135
+ }
136
+
137
+ if (includeTerminal) {
138
+ const hist = readTerminalHistory(root, { fs: this._deps.fs });
139
+ const cmdTs = Number.isInteger(hist.commandsTimestampMs)
140
+ ? hist.commandsTimestampMs
141
+ : capturedAt;
142
+ const dirTs = Number.isInteger(hist.dirsTimestampMs)
143
+ ? hist.dirsTimestampMs
144
+ : capturedAt;
145
+ if (opts.include?.terminalCommands !== false) {
146
+ for (const c of hist.commands) {
147
+ if (emitted >= limit) return;
148
+ if (Number.isInteger(opts.since) && cmdTs < opts.since) break;
149
+ yield {
150
+ kind: "terminal-command",
151
+ // Index disambiguates entries that re-occur with identical command
152
+ // text — keeps registry.putRawEvent's UNIQUE(source.originalId) happy.
153
+ originalId: `vscode-terminal-cmd:${c.sourceIndex}:${hashCommand(c.value)}`,
154
+ capturedAt,
155
+ payload: { ...c, snapshotTs: cmdTs },
156
+ };
157
+ emitted += 1;
158
+ }
159
+ }
160
+ if (opts.include?.terminalDirs !== false) {
161
+ for (const d of hist.dirs) {
162
+ if (emitted >= limit) return;
163
+ if (Number.isInteger(opts.since) && dirTs < opts.since) break;
164
+ yield {
165
+ kind: "terminal-dir",
166
+ originalId: `vscode-terminal-dir:${d.sourceIndex}:${hashCommand(d.value)}`,
167
+ capturedAt,
168
+ payload: { ...d, snapshotTs: dirTs },
169
+ };
170
+ emitted += 1;
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ normalize(raw) {
177
+ const ingestedAt = Date.now();
178
+ const source = (originalId) => ({
179
+ adapter: NAME,
180
+ adapterVersion: VERSION,
181
+ capturedAt: raw.capturedAt,
182
+ capturedBy: CAPTURED_BY.SQLITE,
183
+ originalId,
184
+ });
185
+
186
+ if (raw.kind === "workspace") {
187
+ const p = raw.payload || {};
188
+ const uri = p.folderUri || p.workspaceUri || `vscode-hash:${p.hash}`;
189
+ const name =
190
+ (p.folderPath && p.folderPath.split(/[\\/]/).filter(Boolean).pop()) ||
191
+ decodeFileUri(uri) ||
192
+ uri;
193
+ const item = {
194
+ id: `item-vscode-workspace-${p.hash}`,
195
+ type: ENTITY_TYPES.ITEM,
196
+ subtype: ITEM_SUBTYPES.LINK,
197
+ name: name || "(无名工程)",
198
+ category: "code-project",
199
+ ingestedAt,
200
+ source: source(`vscode-workspace:${p.hash}`),
201
+ extra: {
202
+ folderUri: p.folderUri || null,
203
+ workspaceUri: p.workspaceUri || null,
204
+ folderPath: p.folderPath || null,
205
+ lastOpenedMs: Number.isInteger(p.lastOpenedMs) ? p.lastOpenedMs : null,
206
+ editor: "vscode",
207
+ },
208
+ };
209
+ return { events: [], persons: [], places: [], items: [item], topics: [] };
210
+ }
211
+
212
+ if (raw.kind === "terminal-command") {
213
+ const p = raw.payload || {};
214
+ const cmd = typeof p.value === "string" ? p.value : "";
215
+ const event = {
216
+ id: `event-vscode-terminal-cmd-${p.sourceIndex}-${shortHash(cmd)}`,
217
+ type: ENTITY_TYPES.EVENT,
218
+ subtype: EVENT_SUBTYPES.OTHER,
219
+ occurredAt: Number.isInteger(p.snapshotTs) ? p.snapshotTs : raw.capturedAt,
220
+ ingestedAt,
221
+ source: source(raw.originalId),
222
+ actor: "self",
223
+ content: {
224
+ title: cmd.length > 80 ? cmd.substring(0, 80) + "…" : cmd,
225
+ text: cmd,
226
+ },
227
+ extra: {
228
+ kind: "terminal-command",
229
+ shellType: p.shellType || null,
230
+ sourceIndex: p.sourceIndex,
231
+ editor: "vscode",
232
+ },
233
+ };
234
+ return { events: [event], persons: [], places: [], items: [], topics: [] };
235
+ }
236
+
237
+ if (raw.kind === "terminal-dir") {
238
+ const p = raw.payload || {};
239
+ const dir = typeof p.value === "string" ? p.value : "";
240
+ const event = {
241
+ id: `event-vscode-terminal-dir-${p.sourceIndex}-${shortHash(dir)}`,
242
+ type: ENTITY_TYPES.EVENT,
243
+ subtype: EVENT_SUBTYPES.OTHER,
244
+ occurredAt: Number.isInteger(p.snapshotTs) ? p.snapshotTs : raw.capturedAt,
245
+ ingestedAt,
246
+ source: source(raw.originalId),
247
+ actor: "self",
248
+ content: {
249
+ title: `cd ${dir.length > 76 ? dir.substring(0, 76) + "…" : dir}`,
250
+ text: dir,
251
+ },
252
+ extra: {
253
+ kind: "terminal-dir",
254
+ shellType: p.shellType || null,
255
+ sourceIndex: p.sourceIndex,
256
+ editor: "vscode",
257
+ },
258
+ };
259
+ return { events: [event], persons: [], places: [], items: [], topics: [] };
260
+ }
261
+
262
+ throw new Error(`vscode.normalize: unknown raw.kind=${raw.kind}`);
263
+ }
264
+ }
265
+
266
+ // Cheap non-crypto hash for de-duping originalId + event id collisions.
267
+ // Don't need cryptographic strength here — the registry UNIQUE constraint
268
+ // gives the real guarantee.
269
+ function hashCommand(s) {
270
+ let h = 5381;
271
+ for (let i = 0; i < s.length; i++) {
272
+ h = ((h << 5) + h + s.charCodeAt(i)) >>> 0;
273
+ }
274
+ return h.toString(36);
275
+ }
276
+
277
+ function shortHash(s) {
278
+ return hashCommand(s).substring(0, 8);
279
+ }
280
+
281
+ module.exports = {
282
+ VSCodeAdapter,
283
+ VSCODE_NAME: NAME,
284
+ VSCODE_VERSION: VERSION,
285
+ };
@@ -0,0 +1,18 @@
1
+ "use strict";
2
+
3
+ const {
4
+ VSCodeAdapter,
5
+ VSCODE_NAME,
6
+ VSCODE_VERSION,
7
+ } = require("./adapter");
8
+ const reader = require("./vscode-reader");
9
+
10
+ module.exports = {
11
+ VSCodeAdapter,
12
+ VSCODE_NAME,
13
+ VSCODE_VERSION,
14
+ defaultVscodeRoot: reader.defaultVscodeRoot,
15
+ decodeFileUri: reader.decodeFileUri,
16
+ readWorkspaces: reader.readWorkspaces,
17
+ readTerminalHistory: reader.readTerminalHistory,
18
+ };