coding-tool-x 3.6.7 → 3.6.9

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 (54) hide show
  1. package/dist/web/assets/{Analytics-CKycBI0G.js → Analytics-BuBlsbcI.js} +1 -1
  2. package/dist/web/assets/{ConfigTemplates-CACLQb8e.js → ConfigTemplates-DQczxuOX.js} +1 -1
  3. package/dist/web/assets/Home-BvAzzWnB.js +1 -0
  4. package/dist/web/assets/Home-CIua-b-o.css +1 -0
  5. package/dist/web/assets/{PluginManager-CQJl_uso.js → PluginManager-C8gAlaps.js} +1 -1
  6. package/dist/web/assets/{ProjectList-DSkvQFEP.js → ProjectList-nqgi6_cR.js} +1 -1
  7. package/dist/web/assets/{SessionList-Czjs1GN1.js → SessionList-D8AxMYy6.js} +1 -1
  8. package/dist/web/assets/{SkillManager-Cg1xZ5Cv.js → SkillManager-BDbNbNkK.js} +1 -1
  9. package/dist/web/assets/{WorkspaceManager-COjYeKvN.js → WorkspaceManager-DGGaGW2c.js} +1 -1
  10. package/dist/web/assets/index-Dsh4Q5Cg.css +1 -0
  11. package/dist/web/assets/index-y-eEayxR.js +2 -0
  12. package/dist/web/assets/{naive-ui-DM897mIP.js → naive-ui-kcWDeJwl.js} +1 -1
  13. package/dist/web/index.html +3 -3
  14. package/package.json +1 -1
  15. package/src/commands/stats.js +1 -1
  16. package/src/config/channel-preset-websites.js +2 -1
  17. package/src/config/paths.js +8 -0
  18. package/src/server/api/agents.js +16 -54
  19. package/src/server/api/claude-hooks.js +1 -1
  20. package/src/server/api/claude-statistics.js +57 -0
  21. package/src/server/api/commands.js +16 -54
  22. package/src/server/api/dashboard.js +2 -2
  23. package/src/server/api/gemini-channels.js +2 -0
  24. package/src/server/api/hooks.js +4 -1
  25. package/src/server/api/plugins.js +46 -69
  26. package/src/server/api/skills.js +21 -77
  27. package/src/server/api/validation-errors.js +34 -0
  28. package/src/server/gemini-proxy-server.js +205 -21
  29. package/src/server/index.js +1 -0
  30. package/src/server/services/agents-service.js +166 -75
  31. package/src/server/services/base/base-channel-service.js +15 -0
  32. package/src/server/services/base/response-usage-parser.js +44 -0
  33. package/src/server/services/channel-balance.js +86 -1
  34. package/src/server/services/claude-statistics-service.js +139 -0
  35. package/src/server/services/commands-service.js +203 -121
  36. package/src/server/services/config-artifact-paths.js +116 -0
  37. package/src/server/services/config-export-service.js +7 -6
  38. package/src/server/services/config-registry-service.js +2 -2
  39. package/src/server/services/config-sync-manager.js +45 -11
  40. package/src/server/services/config-templates-service.js +21 -19
  41. package/src/server/services/format-converter.js +42 -4
  42. package/src/server/services/gemini-channels.js +30 -1
  43. package/src/server/services/gemini-sessions.js +347 -61
  44. package/src/server/services/mcp-client.js +236 -49
  45. package/src/server/services/mcp-service.js +51 -64
  46. package/src/server/services/notification-hooks.js +659 -88
  47. package/src/server/services/opencode-channels.js +15 -0
  48. package/src/server/services/plugins-service.js +1137 -86
  49. package/src/server/services/repo-scanner-base.js +6 -29
  50. package/src/server/services/skill-service.js +198 -65
  51. package/dist/web/assets/Home-BeGkC4bb.css +0 -1
  52. package/dist/web/assets/Home-Bk6sP5Ed.js +0 -1
  53. package/dist/web/assets/index-0isNzXNY.css +0 -1
  54. package/dist/web/assets/index-fWgvlZQx.js +0 -2
@@ -5,6 +5,9 @@ const { HOME_DIR } = require('../../config/paths');
5
5
  const { getGeminiDir } = require('./gemini-config');
6
6
  const { resolveModelPricing } = require('../utils/pricing');
7
7
 
8
+ const HASH_RE = /^[a-f0-9]{64}$/;
9
+ const SESSION_FILE_RE = /^session-(.*)-([a-f0-9]+)\.(json|jsonl)$/;
10
+
8
11
  // 路径映射缓存
9
12
  let pathMappingCache = null;
10
13
  let pathMappingCacheTime = 0;
@@ -26,6 +29,266 @@ function getFilePathHash(filePath) {
26
29
  return crypto.createHash('sha256').update(filePath).digest('hex');
27
30
  }
28
31
 
32
+ function isDirectory(dirPath) {
33
+ try {
34
+ return fs.statSync(dirPath).isDirectory();
35
+ } catch {
36
+ return false;
37
+ }
38
+ }
39
+
40
+ function readTextFile(filePath) {
41
+ try {
42
+ return fs.readFileSync(filePath, 'utf8');
43
+ } catch {
44
+ return '';
45
+ }
46
+ }
47
+
48
+ function readProjectRootFile(projectDir) {
49
+ const projectRoot = readTextFile(path.join(projectDir, '.project_root')).trim();
50
+ return projectRoot || null;
51
+ }
52
+
53
+ function loadProjectRootsByStorageName() {
54
+ const projectsPath = path.join(getGeminiDir(), 'projects.json');
55
+ const content = readTextFile(projectsPath);
56
+ if (!content) {
57
+ return new Map();
58
+ }
59
+
60
+ try {
61
+ const parsed = JSON.parse(content);
62
+ const projects = parsed && typeof parsed.projects === 'object' && parsed.projects
63
+ ? parsed.projects
64
+ : {};
65
+ return new Map(
66
+ Object.entries(projects)
67
+ .filter(([projectRoot, storageName]) => projectRoot && typeof storageName === 'string')
68
+ .map(([projectRoot, storageName]) => [storageName, projectRoot])
69
+ );
70
+ } catch {
71
+ return new Map();
72
+ }
73
+ }
74
+
75
+ function extractContentText(value) {
76
+ if (typeof value === 'string') {
77
+ return value;
78
+ }
79
+
80
+ if (Array.isArray(value)) {
81
+ return value
82
+ .map((item) => {
83
+ if (typeof item === 'string') return item;
84
+ if (item && typeof item.text === 'string') return item.text;
85
+ if (item && typeof item.content === 'string') return item.content;
86
+ if (item && typeof item === 'object') return JSON.stringify(item);
87
+ return '';
88
+ })
89
+ .filter(Boolean)
90
+ .join('\n');
91
+ }
92
+
93
+ if (value && typeof value.text === 'string') {
94
+ return value.text;
95
+ }
96
+
97
+ if (value && typeof value === 'object') {
98
+ return JSON.stringify(value);
99
+ }
100
+
101
+ return '';
102
+ }
103
+
104
+ function normalizeMessageRecord(record) {
105
+ if (!record || typeof record !== 'object') {
106
+ return null;
107
+ }
108
+
109
+ const normalized = { ...record };
110
+ if (Object.prototype.hasOwnProperty.call(record, 'content')) {
111
+ normalized.content = extractContentText(record.content);
112
+ }
113
+ return normalized;
114
+ }
115
+
116
+ function upsertMessage(messages, indexById, message) {
117
+ if (!message) {
118
+ return;
119
+ }
120
+
121
+ if (message.id && indexById.has(message.id)) {
122
+ const index = indexById.get(message.id);
123
+ messages[index] = {
124
+ ...messages[index],
125
+ ...message
126
+ };
127
+ return;
128
+ }
129
+
130
+ if (message.id) {
131
+ indexById.set(message.id, messages.length);
132
+ }
133
+ messages.push(message);
134
+ }
135
+
136
+ function normalizeSessionObject(session) {
137
+ if (!session || typeof session !== 'object') {
138
+ return null;
139
+ }
140
+
141
+ const messages = [];
142
+ const indexById = new Map();
143
+ if (Array.isArray(session.messages)) {
144
+ session.messages.forEach((message) => {
145
+ upsertMessage(messages, indexById, normalizeMessageRecord(message));
146
+ });
147
+ }
148
+
149
+ return {
150
+ ...session,
151
+ messages
152
+ };
153
+ }
154
+
155
+ function parseSessionContent(content) {
156
+ const trimmed = content.trim();
157
+ if (!trimmed) {
158
+ return null;
159
+ }
160
+
161
+ try {
162
+ return normalizeSessionObject(JSON.parse(trimmed));
163
+ } catch {
164
+ // Current Gemini CLI stores sessions as JSONL. Fall through to line parsing.
165
+ }
166
+
167
+ const session = {};
168
+ const messages = [];
169
+ const indexById = new Map();
170
+ const lines = trimmed.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
171
+
172
+ lines.forEach((line) => {
173
+ const record = JSON.parse(line);
174
+ if (record.$set && typeof record.$set === 'object') {
175
+ Object.assign(session, record.$set);
176
+ return;
177
+ }
178
+
179
+ if (record.type) {
180
+ upsertMessage(messages, indexById, normalizeMessageRecord(record));
181
+ return;
182
+ }
183
+
184
+ if (record && typeof record === 'object') {
185
+ Object.assign(session, record);
186
+ }
187
+ });
188
+
189
+ return normalizeSessionObject({
190
+ ...session,
191
+ messages
192
+ });
193
+ }
194
+
195
+ function readSessionHeader(filePath) {
196
+ const content = readTextFile(filePath).trim();
197
+ if (!content) {
198
+ return null;
199
+ }
200
+
201
+ try {
202
+ return JSON.parse(content);
203
+ } catch {
204
+ const firstLine = content.split(/\r?\n/).find(line => line.trim());
205
+ if (!firstLine) {
206
+ return null;
207
+ }
208
+ try {
209
+ return JSON.parse(firstLine);
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+ }
215
+
216
+ function scanProjectEntries() {
217
+ const tmpDir = getTmpDir();
218
+
219
+ if (!isDirectory(tmpDir)) {
220
+ return [];
221
+ }
222
+
223
+ const projectRootsByStorageName = loadProjectRootsByStorageName();
224
+ const entries = fs.readdirSync(tmpDir, { withFileTypes: true });
225
+
226
+ return entries
227
+ .filter(entry => entry.isDirectory())
228
+ .map((entry) => {
229
+ const projectDir = path.join(tmpDir, entry.name);
230
+ const chatsDir = path.join(projectDir, 'chats');
231
+ const projectRoot = readProjectRootFile(projectDir) || projectRootsByStorageName.get(entry.name) || null;
232
+ const projectHash = HASH_RE.test(entry.name)
233
+ ? entry.name
234
+ : (projectRoot ? getFilePathHash(projectRoot) : null);
235
+
236
+ if (!isDirectory(chatsDir) && !HASH_RE.test(entry.name)) {
237
+ return null;
238
+ }
239
+
240
+ return {
241
+ storageName: entry.name,
242
+ projectDir,
243
+ chatsDir,
244
+ projectRoot,
245
+ projectHash
246
+ };
247
+ })
248
+ .filter(Boolean);
249
+ }
250
+
251
+ function scanEntrySessionFiles(projectEntry) {
252
+ if (!projectEntry || !isDirectory(projectEntry.chatsDir)) {
253
+ return [];
254
+ }
255
+
256
+ const entries = fs.readdirSync(projectEntry.chatsDir, { withFileTypes: true });
257
+
258
+ return entries
259
+ .filter(entry => entry.isFile() && SESSION_FILE_RE.test(entry.name))
260
+ .map(entry => {
261
+ const match = entry.name.match(SESSION_FILE_RE);
262
+
263
+ return {
264
+ filePath: path.join(projectEntry.chatsDir, entry.name),
265
+ timestamp: match[1],
266
+ shortId: match[2],
267
+ extension: match[3],
268
+ projectHash: projectEntry.projectHash,
269
+ storageName: projectEntry.storageName,
270
+ projectDir: projectEntry.projectDir,
271
+ projectRoot: projectEntry.projectRoot
272
+ };
273
+ });
274
+ }
275
+
276
+ function resolveProjectHashForEntry(projectEntry) {
277
+ if (projectEntry.projectHash) {
278
+ return projectEntry.projectHash;
279
+ }
280
+
281
+ const sessionFiles = scanEntrySessionFiles(projectEntry);
282
+ for (const file of sessionFiles) {
283
+ const header = readSessionHeader(file.filePath);
284
+ if (header?.projectHash) {
285
+ return header.projectHash;
286
+ }
287
+ }
288
+
289
+ return projectEntry.storageName;
290
+ }
291
+
29
292
  /**
30
293
  * 扫描目录及其子目录,建立 hash → path 映射
31
294
  * @param {string} dir - 要扫描的目录
@@ -94,15 +357,35 @@ function buildPathMapping() {
94
357
  return pathMappingCache;
95
358
  }
96
359
 
97
- const projectHashes = scanProjects();
98
- if (projectHashes.length === 0) {
360
+ const projectEntries = scanProjectEntries();
361
+ if (projectEntries.length === 0) {
99
362
  pathMappingCache = new Map();
100
363
  pathMappingCacheTime = now;
101
364
  return pathMappingCache;
102
365
  }
103
366
 
104
- const targetHashes = new Set(projectHashes);
105
367
  const results = new Map();
368
+ const targetHashes = new Set();
369
+ projectEntries.forEach((entry) => {
370
+ const projectHash = resolveProjectHashForEntry(entry);
371
+ if (!projectHash) {
372
+ return;
373
+ }
374
+ if (entry.projectRoot) {
375
+ results.set(projectHash, entry.projectRoot);
376
+ return;
377
+ }
378
+ if (HASH_RE.test(projectHash)) {
379
+ targetHashes.add(projectHash);
380
+ }
381
+ });
382
+
383
+ if (targetHashes.size === 0) {
384
+ pathMappingCache = results;
385
+ pathMappingCacheTime = now;
386
+ return results;
387
+ }
388
+
106
389
  const homeDir = HOME_DIR;
107
390
 
108
391
  // 定义要扫描的目录及其最大深度
@@ -144,19 +427,17 @@ function buildPathMapping() {
144
427
  * @returns {Array} 项目 hash 数组
145
428
  */
146
429
  function scanProjects() {
147
- const tmpDir = getTmpDir();
148
-
149
- if (!fs.existsSync(tmpDir)) {
150
- return [];
151
- }
152
-
153
- const entries = fs.readdirSync(tmpDir, { withFileTypes: true });
154
-
155
- return entries
156
- .filter(entry => entry.isDirectory())
157
- // 过滤掉非项目目录(如 bin)- projectHash 是 64 位十六进制字符串
158
- .filter(entry => /^[a-f0-9]{64}$/.test(entry.name))
159
- .map(entry => entry.name); // 项目 hash
430
+ const seen = new Set();
431
+ return scanProjectEntries()
432
+ .map(resolveProjectHashForEntry)
433
+ .filter(Boolean)
434
+ .filter((projectHash) => {
435
+ if (seen.has(projectHash)) {
436
+ return false;
437
+ }
438
+ seen.add(projectHash);
439
+ return true;
440
+ });
160
441
  }
161
442
 
162
443
  /**
@@ -165,32 +446,12 @@ function scanProjects() {
165
446
  * @returns {Array} 会话文件路径数组
166
447
  */
167
448
  function scanProjectSessions(projectHash) {
168
- const chatsDir = path.join(getTmpDir(), projectHash, 'chats');
169
-
170
- if (!fs.existsSync(chatsDir)) {
171
- return [];
172
- }
173
-
174
- const entries = fs.readdirSync(chatsDir, { withFileTypes: true });
175
-
176
- return entries
177
- .filter(entry => entry.isFile() && entry.name.match(/^session-.*\.json$/))
178
- .map(entry => {
179
- const filePath = path.join(chatsDir, entry.name);
180
- // 文件名格式:session-2025-11-23T02-09-87570eb4.json
181
- // session-{timestamp}-{shortId}.json
182
- const match = entry.name.match(/^session-(.*)-([a-f0-9]+)\.json$/);
183
-
184
- if (!match) return null;
185
-
186
- return {
187
- filePath,
188
- timestamp: match[1],
189
- shortId: match[2],
190
- projectHash
191
- };
192
- })
193
- .filter(Boolean);
449
+ return scanProjectEntries()
450
+ .filter((entry) => entry.storageName === projectHash || resolveProjectHashForEntry(entry) === projectHash)
451
+ .flatMap((entry) => scanEntrySessionFiles({
452
+ ...entry,
453
+ projectHash: resolveProjectHashForEntry(entry)
454
+ }));
194
455
  }
195
456
 
196
457
  /**
@@ -201,7 +462,10 @@ function scanProjectSessions(projectHash) {
201
462
  function readSessionMeta(filePath) {
202
463
  try {
203
464
  const content = fs.readFileSync(filePath, 'utf8');
204
- const session = JSON.parse(content);
465
+ const session = parseSessionContent(content);
466
+ if (!session) {
467
+ return null;
468
+ }
205
469
 
206
470
  // Gemini 会话文件结构
207
471
  // {
@@ -212,7 +476,7 @@ function readSessionMeta(filePath) {
212
476
  // messages: [...]
213
477
  // }
214
478
 
215
- const messages = session.messages || [];
479
+ const messages = Array.isArray(session.messages) ? session.messages : [];
216
480
  const firstUserMessage = messages.find(msg => msg.type === 'user');
217
481
 
218
482
  // 计算总 tokens(从所有消息中累加)
@@ -243,7 +507,7 @@ function readSessionMeta(filePath) {
243
507
  startTime: session.startTime,
244
508
  lastUpdated: session.lastUpdated,
245
509
  messageCount: messages.length,
246
- firstMessage: firstUserMessage ? firstUserMessage.content : '',
510
+ firstMessage: firstUserMessage ? extractContentText(firstUserMessage.content) : '',
247
511
  tokens: totalTokens,
248
512
  cost: totalCost,
249
513
  model: model || 'gemini-2.5-pro',
@@ -263,7 +527,7 @@ function readSessionMeta(filePath) {
263
527
  function readSessionFull(filePath) {
264
528
  try {
265
529
  const content = fs.readFileSync(filePath, 'utf8');
266
- return JSON.parse(content);
530
+ return parseSessionContent(content);
267
531
  } catch (err) {
268
532
  console.error(`[Gemini Sessions] Failed to read session: ${filePath}`, err);
269
533
  return null;
@@ -275,11 +539,15 @@ function readSessionFull(filePath) {
275
539
  * @returns {Array} 会话对象数组
276
540
  */
277
541
  function getAllSessions() {
278
- const projectHashes = scanProjects();
542
+ const projectEntries = scanProjectEntries();
279
543
  const allSessions = [];
280
544
 
281
- projectHashes.forEach(projectHash => {
282
- const sessionFiles = scanProjectSessions(projectHash);
545
+ projectEntries.forEach(projectEntry => {
546
+ const resolvedProjectHash = resolveProjectHashForEntry(projectEntry);
547
+ const sessionFiles = scanEntrySessionFiles({
548
+ ...projectEntry,
549
+ projectHash: resolvedProjectHash
550
+ });
283
551
 
284
552
  sessionFiles.forEach(file => {
285
553
  const meta = readSessionMeta(file.filePath);
@@ -299,6 +567,10 @@ function getAllSessions() {
299
567
 
300
568
  allSessions.push({
301
569
  ...meta,
570
+ projectHash: meta.projectHash || resolvedProjectHash,
571
+ storageName: projectEntry.storageName,
572
+ projectDir: projectEntry.projectDir,
573
+ projectRoot: projectEntry.projectRoot,
302
574
  filePath: file.filePath,
303
575
  size,
304
576
  mtime,
@@ -332,7 +604,9 @@ function normalizeSession(geminiSession) {
332
604
  cost: geminiSession.cost,
333
605
  model: geminiSession.model,
334
606
  projectHash: geminiSession.projectHash,
335
- projectName: geminiSession.projectHash // 兼容前端统一使用 projectName
607
+ projectName: geminiSession.projectHash, // 兼容前端统一使用 projectName
608
+ storageName: geminiSession.storageName,
609
+ projectRoot: geminiSession.projectRoot
336
610
  };
337
611
  }
338
612
 
@@ -358,7 +632,7 @@ function getProjects() {
358
632
  const projectHash = session.projectHash;
359
633
 
360
634
  if (!projectMap.has(projectHash)) {
361
- const projectPath = getProjectPath(projectHash);
635
+ const projectPath = session.projectRoot || getProjectPath(projectHash);
362
636
 
363
637
  // 如果找到了真实路径,使用目录名作为显示名称
364
638
  let displayName;
@@ -377,6 +651,8 @@ function getProjects() {
377
651
  name: projectHash,
378
652
  displayName,
379
653
  path: projectPath,
654
+ fullPath: projectPath,
655
+ storageName: session.storageName,
380
656
  sessionCount: 0,
381
657
  lastUpdated: session.lastUpdated,
382
658
  source: 'gemini'
@@ -454,7 +730,9 @@ function deleteSession(sessionId) {
454
730
  * @returns {Object} 删除结果
455
731
  */
456
732
  function deleteProject(projectHash) {
457
- const projectDir = path.join(getTmpDir(), projectHash);
733
+ const projectEntry = scanProjectEntries()
734
+ .find(entry => entry.storageName === projectHash || resolveProjectHashForEntry(entry) === projectHash);
735
+ const projectDir = projectEntry ? projectEntry.projectDir : path.join(getTmpDir(), projectHash);
458
736
 
459
737
  if (!fs.existsSync(projectDir)) {
460
738
  throw new Error('Project not found');
@@ -542,8 +820,8 @@ function searchSessions(keyword, contextLength = 35) {
542
820
  let content = '';
543
821
 
544
822
  // 提取消息内容
545
- if (msg.type === 'user' || msg.type === 'assistant') {
546
- content = msg.content || '';
823
+ if (msg.type === 'user' || msg.type === 'assistant' || msg.type === 'gemini') {
824
+ content = extractContentText(msg.content);
547
825
  }
548
826
 
549
827
  // 搜索关键词
@@ -654,7 +932,8 @@ function forkSession(sessionId, options = {}) {
654
932
  const newSessionId = crypto.randomUUID();
655
933
  const timestamp = new Date().toISOString().replace(/:/g, '-').replace(/\..+/, '');
656
934
  const shortId = crypto.randomBytes(4).toString('hex');
657
- const newFileName = `session-${timestamp}-${shortId}.json`;
935
+ const newExtension = path.extname(sourceSession.filePath) === '.jsonl' ? 'jsonl' : 'json';
936
+ const newFileName = `session-${timestamp}-${shortId}.${newExtension}`;
658
937
 
659
938
  // 创建新会话
660
939
  const newSession = {
@@ -667,11 +946,16 @@ function forkSession(sessionId, options = {}) {
667
946
  };
668
947
 
669
948
  // 写入新文件
670
- const chatsDir = path.join(getTmpDir(), sourceSession.projectHash, 'chats');
949
+ const chatsDir = path.dirname(sourceSession.filePath);
671
950
  const newFilePath = path.join(chatsDir, newFileName);
672
951
 
673
952
  try {
674
- fs.writeFileSync(newFilePath, JSON.stringify(newSession, null, 2), 'utf8');
953
+ if (newExtension === 'jsonl') {
954
+ const { messages, ...sessionHeader } = newSession;
955
+ fs.writeFileSync(newFilePath, `${JSON.stringify(sessionHeader)}\n${truncatedMessages.map(message => JSON.stringify(message)).join('\n')}\n`, 'utf8');
956
+ } else {
957
+ fs.writeFileSync(newFilePath, JSON.stringify(newSession, null, 2), 'utf8');
958
+ }
675
959
  if (options.alias) {
676
960
  const { setAlias } = require('./alias');
677
961
  setAlias(newSessionId, options.alias);
@@ -695,13 +979,15 @@ function forkSession(sessionId, options = {}) {
695
979
  */
696
980
  function getProjectAndSessionCounts() {
697
981
  try {
698
- const projectHashes = scanProjects();
982
+ const projectEntries = scanProjectEntries();
699
983
  let sessionCount = 0;
700
- projectHashes.forEach((hash) => {
701
- sessionCount += scanProjectSessions(hash).length;
984
+ const projectHashes = new Set();
985
+ projectEntries.forEach((entry) => {
986
+ projectHashes.add(resolveProjectHashForEntry(entry));
987
+ sessionCount += scanEntrySessionFiles(entry).length;
702
988
  });
703
989
  return {
704
- projectCount: projectHashes.length,
990
+ projectCount: Array.from(projectHashes).filter(Boolean).length,
705
991
  sessionCount
706
992
  };
707
993
  } catch (err) {