@vibe-cafe/vibe-usage 0.10.9 → 0.10.11

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.
package/src/sync.js CHANGED
@@ -6,8 +6,10 @@ import {
6
6
  } from './state.js';
7
7
  import { ingest, fetchSettings } from './api.js';
8
8
  import { createSyncClient, forBatch } from './client-meta.js';
9
- import { aggregateToBuckets, parsers } from './parsers/index.js';
10
- import { success, failure, arrow, link, dim } from './output.js';
9
+ import { parsers } from './parsers/index.js';
10
+ import { aggregateToBuckets } from './parsers/aggregate.js';
11
+ import { normalizeParserResult } from './parsers/contract.js';
12
+ import { success, failure, warn, arrow, link, dim } from './output.js';
11
13
 
12
14
  const BATCH_SIZE = 100;
13
15
  const SESSION_BATCH_SIZE = 500;
@@ -27,6 +29,13 @@ export function resolveUploadProjectSetting(settings) {
27
29
  return settings.uploadProject;
28
30
  }
29
31
 
32
+ export function resolveCachedUploadProjectSetting(config, apiUrl) {
33
+ if (config?.lastUploadProjectApiUrl !== apiUrl) return undefined;
34
+ return typeof config.lastUploadProject === 'boolean'
35
+ ? config.lastUploadProject
36
+ : undefined;
37
+ }
38
+
30
39
  export function resolveCodexExtraHome(configured, temporary) {
31
40
  return temporary ?? configured;
32
41
  }
@@ -41,6 +50,27 @@ export function reaggregateHiddenProjectBuckets(buckets) {
41
50
  })));
42
51
  }
43
52
 
53
+ // Parser execution is I/O bound (log reads, occasional network calls). Run a
54
+ // bounded number at once to cut wall-clock sync time without the memory spike
55
+ // of loading every tool's logs simultaneously.
56
+ export const PARSER_CONCURRENCY = 4;
57
+
58
+ // Run `fn` over `items` with at most `limit` in flight, preserving order.
59
+ export async function mapWithConcurrency(items, limit, fn) {
60
+ const results = new Array(items.length);
61
+ let nextIndex = 0;
62
+ const workerCount = Math.max(1, Math.min(limit, items.length));
63
+ const workers = Array.from({ length: workerCount }, async () => {
64
+ while (true) {
65
+ const index = nextIndex++;
66
+ if (index >= items.length) return;
67
+ results[index] = await fn(items[index], index);
68
+ }
69
+ });
70
+ await Promise.all(workers);
71
+ return results;
72
+ }
73
+
44
74
  export async function runSync({
45
75
  throws = false,
46
76
  quiet = false,
@@ -70,14 +100,34 @@ export async function runSync({
70
100
  try {
71
101
  const settings = await fetchSettings(apiUrl, config.apiKey);
72
102
  uploadProject = resolveUploadProjectSetting(settings);
103
+ // Scope the cached privacy choice to the server that returned it. Reusing
104
+ // the value after `apiUrl` changes could expose project names to a
105
+ // different server during its first settings outage.
106
+ if (
107
+ config.lastUploadProject !== uploadProject
108
+ || config.lastUploadProjectApiUrl !== apiUrl
109
+ ) {
110
+ config.lastUploadProject = uploadProject;
111
+ config.lastUploadProjectApiUrl = apiUrl;
112
+ saveConfig(config);
113
+ }
73
114
  } catch (err) {
74
115
  if (err.message === 'UNAUTHORIZED') {
75
116
  console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
117
+ if (throws) throw err;
118
+ process.exit(1);
119
+ }
120
+ // Settings endpoint unreachable (not auth): degrade to the last confirmed
121
+ // choice for this same server rather than hard-aborting every upload.
122
+ const cachedUploadProject = resolveCachedUploadProjectSetting(config, apiUrl);
123
+ if (typeof cachedUploadProject === 'boolean') {
124
+ uploadProject = cachedUploadProject;
125
+ if (!quiet) console.log(warn('设置接口不可用,沿用上次的项目名设置。'));
76
126
  } else {
77
127
  console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
128
+ if (throws) throw err;
129
+ process.exit(1);
78
130
  }
79
- if (throws) throw err;
80
- process.exit(1);
81
131
  }
82
132
 
83
133
  let allBuckets = [];
@@ -89,36 +139,52 @@ export async function runSync({
89
139
  // state and force a full re-upload next run.
90
140
  const okSources = new Set();
91
141
 
92
- for (const [source, parse] of Object.entries(parsers)) {
93
- try {
94
- const result = source === 'codex'
95
- ? await parse({ codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome) })
96
- : await parse();
97
- const buckets = Array.isArray(result) ? result : result.buckets;
98
- const sessions = Array.isArray(result) ? [] : (result.sessions || []);
99
- if (!Array.isArray(buckets) || !Array.isArray(sessions)) {
100
- throw new TypeError('Parser returned an invalid result');
142
+ // Run parsers concurrently (bounded) so one slow parser (Cursor's network
143
+ // fetch, a cold Codex index) can't stall the rest. Results are collected in
144
+ // registry order so output and merged arrays stay deterministic.
145
+ const parserOutcomes = await mapWithConcurrency(
146
+ Object.entries(parsers),
147
+ PARSER_CONCURRENCY,
148
+ async ([source, parse]) => {
149
+ try {
150
+ const result = source === 'codex'
151
+ ? await parse({ codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome) })
152
+ : await parse();
153
+ return { source, result };
154
+ } catch (err) {
155
+ return { source, error: err };
101
156
  }
102
- if (result?.indexing) {
103
- parserProgress.push({ source, ...result.indexing });
104
- }
105
- if (Array.isArray(result?.warnings)) {
106
- for (const message of result.warnings) {
107
- process.stderr.write(`${dim(` ${message}`)}\n`);
108
- }
109
- }
110
- // A parser may deliberately suppress a transient error (Cursor network
111
- // timeout) to keep daemon logs quiet. Its empty result is not proof that
112
- // its prior data disappeared, so it must not be pruned this run.
113
- if (!result?.skipped) okSources.add(source);
114
- if (buckets.length > 0) allBuckets.push(...buckets);
115
- if (sessions.length > 0) allSessions.push(...sessions);
116
- if (buckets.length > 0 || sessions.length > 0) {
117
- parserResults.push({ source, buckets: buckets.length, sessions: sessions.length });
118
- }
119
- } catch (err) {
157
+ },
158
+ );
159
+
160
+ for (const { source, result, error } of parserOutcomes) {
161
+ if (error) {
120
162
  // Parser errors are non-fatal — pass-through in dim gray (no translation).
163
+ process.stderr.write(`${dim(` ${source}: ${error.message}`)}\n`);
164
+ continue;
165
+ }
166
+ let normalized;
167
+ try {
168
+ normalized = normalizeParserResult(source, result);
169
+ } catch (err) {
121
170
  process.stderr.write(`${dim(` ${source}: ${err.message}`)}\n`);
171
+ continue;
172
+ }
173
+ const { buckets, sessions, skipped, warnings, indexing } = normalized;
174
+ if (indexing) {
175
+ parserProgress.push({ source, ...indexing });
176
+ }
177
+ for (const message of warnings) {
178
+ process.stderr.write(`${dim(` ${message}`)}\n`);
179
+ }
180
+ // A parser may deliberately suppress a transient error (Cursor network
181
+ // timeout) to keep daemon logs quiet. Its empty result is not proof that
182
+ // its prior data disappeared, so it must not be pruned this run.
183
+ if (!skipped) okSources.add(source);
184
+ if (buckets.length > 0) allBuckets.push(...buckets);
185
+ if (sessions.length > 0) allSessions.push(...sessions);
186
+ if (buckets.length > 0 || sessions.length > 0) {
187
+ parserResults.push({ source, buckets: buckets.length, sessions: sessions.length });
122
188
  }
123
189
  }
124
190
 
@@ -271,10 +337,10 @@ export async function runSync({
271
337
  }
272
338
  totalProtectedBuckets += Number(result.protected?.buckets) || 0;
273
339
 
274
- // Commit only this batch's hashes, only after it uploaded successfully.
275
- // A batch that throws aborts the loop with its keys still absent from
276
- // state, so the next sync re-sends exactly those items — no data loss,
277
- // no silent gaps.
340
+ // Commit only hashes from this successful batch. Persist before starting
341
+ // the next batch: if a later upload fails or the process exits abruptly,
342
+ // the next sync retries only the uncommitted suffix.
343
+ let batchStateChanged = false;
278
344
  for (const b of batch) {
279
345
  // A source unknown to an older backend may become valid after deploy.
280
346
  // Leave those hashes uncommitted so the next sync retries them instead
@@ -282,14 +348,20 @@ export async function runSync({
282
348
  if (batchUnknownSources.has(b.source)) continue;
283
349
  const key = bucketKey(b);
284
350
  const entry = pendingBucketState.get(key);
285
- if (entry) state.buckets[key] = entry;
351
+ if (entry) {
352
+ state.buckets[key] = entry;
353
+ batchStateChanged = true;
354
+ }
286
355
  }
287
356
  for (const s of batchSessions) {
288
357
  const key = sessionKey(s);
289
358
  const entry = pendingSessionState.get(key);
290
- if (entry) state.sessions[key] = entry;
359
+ if (entry) {
360
+ state.sessions[key] = entry;
361
+ batchStateChanged = true;
362
+ }
291
363
  }
292
- saveState(state);
364
+ if (batchStateChanged) saveState(state);
293
365
  }
294
366
 
295
367
  if (totalBatches > 1 || allBucketsToSend.length > 0) {
package/src/tools.js CHANGED
@@ -120,6 +120,28 @@ function findKimiCodeDataDirs() {
120
120
  ].filter(existsSync);
121
121
  }
122
122
 
123
+ /** DeepSeek Harness home: DSH_HOME env (same as the dsh CLI) or ~/.dsh. */
124
+ export function getDshHome(env = process.env) {
125
+ const explicit = env.DSH_HOME?.trim();
126
+ if (!explicit) return join(homedir(), '.dsh');
127
+ if (explicit === '~') return homedir();
128
+ if (explicit.startsWith('~/') || explicit.startsWith('~\\')) {
129
+ return resolve(homedir(), explicit.slice(2));
130
+ }
131
+ return resolve(explicit);
132
+ }
133
+
134
+ export function getDshSessionsDir() {
135
+ const testDir = process.env.VIBE_USAGE_DSH_SESSIONS?.trim();
136
+ if (testDir) return testDir;
137
+ return join(getDshHome(), 'sessions');
138
+ }
139
+
140
+ // Detect DeepSeek Harness when its sessions tree exists (or the test override).
141
+ export function findDshDataDirs() {
142
+ return [getDshSessionsDir()].filter(existsSync);
143
+ }
144
+
123
145
  export function getMimocodeDbPath(env = process.env) {
124
146
  if (env.MIMOCODE_HOME && !isAbsolute(env.MIMOCODE_HOME)) {
125
147
  throw new Error(`MIMOCODE_HOME must be an absolute path, got: ${JSON.stringify(env.MIMOCODE_HOME)}`);
@@ -298,6 +320,12 @@ export const TOOLS = [
298
320
  id: 'droid',
299
321
  dataDir: join(homedir(), '.factory', 'sessions'),
300
322
  },
323
+ {
324
+ name: 'DeepSeek Harness',
325
+ id: 'dsh',
326
+ dataDir: getDshSessionsDir(),
327
+ detectDataDirs: findDshDataDirs,
328
+ },
301
329
  {
302
330
  name: 'Antigravity',
303
331
  id: 'antigravity',