vibeusage 0.2.16 → 0.2.17

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.
@@ -12,9 +12,11 @@ const {
12
12
  parseRolloutIncremental,
13
13
  parseClaudeIncremental,
14
14
  parseGeminiIncremental,
15
- parseOpencodeIncremental
15
+ parseOpencodeIncremental,
16
+ parseOpenclawIncremental
16
17
  } = require('../lib/rollout');
17
18
  const { drainQueueToCloud } = require('../lib/uploader');
19
+ const { collectLocalSubscriptions } = require('../lib/subscriptions');
18
20
  const { createProgress, renderBar, formatNumber, formatBytes } = require('../lib/progress');
19
21
  const { syncHeartbeat } = require('../lib/vibeusage-api');
20
22
  const {
@@ -34,6 +36,9 @@ async function cmdSync(argv) {
34
36
  const { trackerDir } = await resolveTrackerPaths({ home });
35
37
 
36
38
  await ensureDir(trackerDir);
39
+ if (opts.fromOpenclaw) {
40
+ await writeOpenclawSignal(trackerDir);
41
+ }
37
42
 
38
43
  const lockPath = path.join(trackerDir, 'sync.lock');
39
44
  const lock = await openLock(lockPath, { quietIfLocked: opts.auto });
@@ -64,10 +69,16 @@ async function cmdSync(argv) {
64
69
  const opencodeHome = process.env.OPENCODE_HOME || path.join(xdgDataHome, 'opencode');
65
70
  const opencodeStorageDir = path.join(opencodeHome, 'storage');
66
71
 
67
- const sources = [
68
- { source: 'codex', sessionsDir: path.join(codexHome, 'sessions') },
69
- { source: 'every-code', sessionsDir: path.join(codeHome, 'sessions') }
70
- ];
72
+ // OpenClaw hook integration: allow a hook to request incremental parsing for a single session jsonl.
73
+ // When present, we skip all other sources to keep hook-triggered sync fast and deterministic.
74
+ const openclawSignal = opts.fromOpenclaw ? resolveOpenclawSignal({ home, env: process.env }) : null;
75
+
76
+ const sources = openclawSignal
77
+ ? []
78
+ : [
79
+ { source: 'codex', sessionsDir: path.join(codexHome, 'sessions') },
80
+ { source: 'every-code', sessionsDir: path.join(codeHome, 'sessions') }
81
+ ];
71
82
 
72
83
  const rolloutFiles = [];
73
84
  const seenSessions = new Set();
@@ -80,6 +91,8 @@ async function cmdSync(argv) {
80
91
  }
81
92
  }
82
93
 
94
+ const openclawFiles = openclawSignal?.sessionFile ? [{ path: openclawSignal.sessionFile, source: 'openclaw' }] : [];
95
+
83
96
  if (progress?.enabled) {
84
97
  progress.start(`Parsing ${renderBar(0)} 0/${formatNumber(rolloutFiles.length)} files | buckets 0`);
85
98
  }
@@ -100,9 +113,21 @@ async function cmdSync(argv) {
100
113
  }
101
114
  });
102
115
 
103
- const claudeFiles = await listClaudeProjectFiles(claudeProjectsDir);
116
+ let openclawResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
117
+ if (openclawFiles.length > 0) {
118
+ // Only runs when explicitly triggered by OpenClaw hooks.
119
+ openclawResult = await parseOpenclawIncremental({
120
+ sessionFiles: openclawFiles,
121
+ cursors,
122
+ queuePath,
123
+ projectQueuePath,
124
+ source: 'openclaw'
125
+ });
126
+ }
127
+
128
+ const claudeFiles = openclawSignal ? [] : await listClaudeProjectFiles(claudeProjectsDir);
104
129
  let claudeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
105
- if (claudeFiles.length > 0) {
130
+ if (!openclawSignal && claudeFiles.length > 0) {
106
131
  if (progress?.enabled) {
107
132
  progress.start(`Parsing Claude ${renderBar(0)} 0/${formatNumber(claudeFiles.length)} files | buckets 0`);
108
133
  }
@@ -124,9 +149,9 @@ async function cmdSync(argv) {
124
149
  });
125
150
  }
126
151
 
127
- const geminiFiles = await listGeminiSessionFiles(geminiTmpDir);
152
+ const geminiFiles = openclawSignal ? [] : await listGeminiSessionFiles(geminiTmpDir);
128
153
  let geminiResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
129
- if (geminiFiles.length > 0) {
154
+ if (!openclawSignal && geminiFiles.length > 0) {
130
155
  if (progress?.enabled) {
131
156
  progress.start(`Parsing Gemini ${renderBar(0)} 0/${formatNumber(geminiFiles.length)} files | buckets 0`);
132
157
  }
@@ -148,9 +173,9 @@ async function cmdSync(argv) {
148
173
  });
149
174
  }
150
175
 
151
- const opencodeFiles = await listOpencodeMessageFiles(opencodeStorageDir);
176
+ const opencodeFiles = openclawSignal ? [] : await listOpencodeMessageFiles(opencodeStorageDir);
152
177
  let opencodeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
153
- if (opencodeFiles.length > 0) {
178
+ if (!openclawSignal && opencodeFiles.length > 0) {
154
179
  if (progress?.enabled) {
155
180
  progress.start(`Parsing Opencode ${renderBar(0)} 0/${formatNumber(opencodeFiles.length)} files | buckets 0`);
156
181
  }
@@ -244,10 +269,17 @@ async function cmdSync(argv) {
244
269
 
245
270
  if (allowUpload && maxBatches > 0) {
246
271
  uploadAttempted = true;
272
+ const deviceSubscriptions = await collectLocalSubscriptions({
273
+ home,
274
+ env: process.env,
275
+ probeKeychain: true,
276
+ probeKeychainDetails: true
277
+ });
247
278
  try {
248
279
  uploadResult = await drainQueueToCloud({
249
280
  baseUrl,
250
281
  deviceToken,
282
+ deviceSubscriptions,
251
283
  queuePath,
252
284
  queueStatePath,
253
285
  projectQueuePath,
@@ -330,11 +362,13 @@ async function cmdSync(argv) {
330
362
  if (!opts.auto) {
331
363
  const totalParsed =
332
364
  parseResult.filesProcessed +
365
+ openclawResult.filesProcessed +
333
366
  claudeResult.filesProcessed +
334
367
  geminiResult.filesProcessed +
335
368
  opencodeResult.filesProcessed;
336
369
  const totalBuckets =
337
370
  parseResult.bucketsQueued +
371
+ openclawResult.bucketsQueued +
338
372
  claudeResult.bucketsQueued +
339
373
  geminiResult.bucketsQueued +
340
374
  opencodeResult.bucketsQueued;
@@ -367,6 +401,7 @@ function parseArgs(argv) {
367
401
  auto: false,
368
402
  fromNotify: false,
369
403
  fromRetry: false,
404
+ fromOpenclaw: false,
370
405
  drain: false
371
406
  };
372
407
  for (let i = 0; i < argv.length; i++) {
@@ -374,6 +409,7 @@ function parseArgs(argv) {
374
409
  if (a === '--auto') out.auto = true;
375
410
  else if (a === '--from-notify') out.fromNotify = true;
376
411
  else if (a === '--from-retry') out.fromRetry = true;
412
+ else if (a === '--from-openclaw') out.fromOpenclaw = true;
377
413
  else if (a === '--drain') out.drain = true;
378
414
  else throw new Error(`Unknown option: ${a}`);
379
415
  }
@@ -382,6 +418,26 @@ function parseArgs(argv) {
382
418
 
383
419
  module.exports = { cmdSync };
384
420
 
421
+ function normalizeString(value) {
422
+ if (typeof value !== 'string') return null;
423
+ const trimmed = value.trim();
424
+ return trimmed.length > 0 ? trimmed : null;
425
+ }
426
+
427
+ function resolveOpenclawSignal({ home, env } = {}) {
428
+ if (!env) return null;
429
+
430
+ const agentId = normalizeString(env.VIBEUSAGE_OPENCLAW_AGENT_ID);
431
+ const sessionId = normalizeString(env.VIBEUSAGE_OPENCLAW_PREV_SESSION_ID);
432
+ if (!agentId || !sessionId) return null;
433
+
434
+ const openclawHome = normalizeString(env.VIBEUSAGE_OPENCLAW_HOME) || path.join(home || os.homedir(), '.openclaw');
435
+ const sessionFile = path.join(openclawHome, 'agents', agentId, 'sessions', `${sessionId}.jsonl`);
436
+
437
+ return { agentId, sessionId, openclawHome, sessionFile };
438
+ }
439
+
440
+
385
441
  async function safeStatSize(p) {
386
442
  try {
387
443
  const st = await fs.stat(p);
@@ -509,6 +565,15 @@ function coerceRetryMs(v) {
509
565
  return Math.floor(n);
510
566
  }
511
567
 
568
+ async function writeOpenclawSignal(trackerDir) {
569
+ const openclawSignalPath = path.join(trackerDir, 'openclaw.signal');
570
+ try {
571
+ await fs.writeFile(openclawSignalPath, new Date().toISOString(), 'utf8');
572
+ } catch (_e) {
573
+ // best-effort marker
574
+ }
575
+ }
576
+
512
577
  const HEARTBEAT_MIN_INTERVAL_MINUTES = 30;
513
578
  const HEARTBEAT_MIN_INTERVAL_MS = HEARTBEAT_MIN_INTERVAL_MINUTES * 60 * 1000;
514
579
  const AUTO_RETRY_FILENAME = 'auto.retry.json';
@@ -117,14 +117,25 @@ async function readEveryCodeNotify(codeConfigPath) {
117
117
 
118
118
  function extractNotify(text) {
119
119
  // Heuristic parse: find a line that starts with "notify =".
120
+ // Supports single-line arrays:
121
+ // - notify = ["a", "b"]
122
+ // And multi-line arrays:
123
+ // - notify = [
124
+ // "a",
125
+ // "b"
126
+ // ]
120
127
  const lines = text.split(/\r?\n/);
121
- for (const line of lines) {
122
- const m = line.match(/^\s*notify\s*=\s*(.+)\s*$/);
123
- if (m) {
124
- const rhs = m[1].trim();
125
- const parsed = parseTomlStringArray(rhs);
126
- if (parsed) return parsed;
127
- }
128
+ for (let i = 0; i < lines.length; i++) {
129
+ const line = lines[i];
130
+ const m = line.match(/^\s*notify\s*=\s*(.*)\s*$/);
131
+ if (!m) continue;
132
+
133
+ const rhs = (m[1] || '').trim();
134
+ const literal = readTomlArrayLiteral(lines, i, rhs);
135
+ if (!literal) continue;
136
+
137
+ const parsed = parseTomlStringArray(literal);
138
+ if (parsed) return parsed;
128
139
  }
129
140
  return null;
130
141
  }
@@ -137,12 +148,15 @@ function setNotify(text, notifyCmd) {
137
148
  let replaced = false;
138
149
  for (let i = 0; i < lines.length; i++) {
139
150
  const line = lines[i];
140
- const isNotify = /^\s*notify\s*=/.test(line);
141
- if (isNotify) {
151
+ const m = line.match(/^\s*notify\s*=\s*(.*)\s*$/);
152
+ if (m) {
142
153
  if (!replaced) {
143
154
  out.push(notifyLine);
144
155
  replaced = true;
145
156
  }
157
+
158
+ const rhs = (m[1] || '').trim();
159
+ i = findTomlArrayBlockEnd(lines, i, rhs);
146
160
  continue;
147
161
  }
148
162
  out.push(line);
@@ -160,7 +174,17 @@ function setNotify(text, notifyCmd) {
160
174
 
161
175
  function removeNotify(text) {
162
176
  const lines = text.split(/\r?\n/);
163
- const out = lines.filter((l) => !/^\s*notify\s*=/.test(l));
177
+ const out = [];
178
+ for (let i = 0; i < lines.length; i++) {
179
+ const line = lines[i];
180
+ const m = line.match(/^\s*notify\s*=\s*(.*)\s*$/);
181
+ if (m) {
182
+ const rhs = (m[1] || '').trim();
183
+ i = findTomlArrayBlockEnd(lines, i, rhs);
184
+ continue;
185
+ }
186
+ out.push(line);
187
+ }
164
188
  return out.join('\n').replace(/\n+$/, '\n');
165
189
  }
166
190
 
@@ -201,6 +225,104 @@ function formatTomlStringArray(arr) {
201
225
  return `[${arr.map((s) => JSON.stringify(String(s))).join(', ')}]`;
202
226
  }
203
227
 
228
+ function readTomlArrayLiteral(lines, startIndex, rhs) {
229
+ const first = rhs.trim();
230
+ if (!first.startsWith('[')) return null;
231
+
232
+ let inString = false;
233
+ let quote = null;
234
+ let depth = 0;
235
+ let sawOpen = false;
236
+
237
+ function scanChunk(chunk) {
238
+ for (let i = 0; i < chunk.length; i++) {
239
+ const ch = chunk[i];
240
+ if (!inString) {
241
+ if (ch === '"' || ch === "'") {
242
+ inString = true;
243
+ quote = ch;
244
+ continue;
245
+ }
246
+ if (ch === '[') {
247
+ depth += 1;
248
+ sawOpen = true;
249
+ continue;
250
+ }
251
+ if (ch === ']') {
252
+ depth -= 1;
253
+ if (sawOpen && depth === 0) return i;
254
+ }
255
+ continue;
256
+ }
257
+ if (ch === quote) {
258
+ inString = false;
259
+ quote = null;
260
+ }
261
+ }
262
+ return -1;
263
+ }
264
+
265
+ const parts = [first];
266
+ let endPos = scanChunk(first);
267
+ if (endPos !== -1) return first.slice(0, endPos + 1).trim();
268
+
269
+ for (let j = startIndex + 1; j < lines.length; j++) {
270
+ const line = lines[j];
271
+ endPos = scanChunk(line);
272
+ if (endPos !== -1) {
273
+ parts.push(line.slice(0, endPos + 1));
274
+ return parts.join('\n').trim();
275
+ }
276
+ parts.push(line);
277
+ }
278
+
279
+ return null;
280
+ }
281
+
282
+ function findTomlArrayBlockEnd(lines, startIndex, rhs) {
283
+ const first = rhs.trim();
284
+ if (!first.startsWith('[')) return startIndex;
285
+
286
+ let inString = false;
287
+ let quote = null;
288
+ let depth = 0;
289
+ let sawOpen = false;
290
+
291
+ function scanChunk(chunk) {
292
+ for (let i = 0; i < chunk.length; i++) {
293
+ const ch = chunk[i];
294
+ if (!inString) {
295
+ if (ch === '"' || ch === "'") {
296
+ inString = true;
297
+ quote = ch;
298
+ continue;
299
+ }
300
+ if (ch === '[') {
301
+ depth += 1;
302
+ sawOpen = true;
303
+ continue;
304
+ }
305
+ if (ch === ']') {
306
+ depth -= 1;
307
+ if (sawOpen && depth === 0) return true;
308
+ }
309
+ continue;
310
+ }
311
+ if (ch === quote) {
312
+ inString = false;
313
+ quote = null;
314
+ }
315
+ }
316
+ return false;
317
+ }
318
+
319
+ if (scanChunk(first)) return startIndex;
320
+ for (let j = startIndex + 1; j < lines.length; j++) {
321
+ if (scanChunk(lines[j])) return j;
322
+ }
323
+ return startIndex;
324
+ }
325
+
204
326
  function arraysEqual(a, b) {
205
327
  if (!Array.isArray(a) || !Array.isArray(b)) return false;
206
328
  if (a.length !== b.length) return false;
@@ -26,6 +26,7 @@ async function collectTrackerDiagnostics({
26
26
  const queueStatePath = path.join(trackerDir, 'queue.state.json');
27
27
  const cursorsPath = path.join(trackerDir, 'cursors.json');
28
28
  const notifySignalPath = path.join(trackerDir, 'notify.signal');
29
+ const openclawSignalPath = path.join(trackerDir, 'openclaw.signal');
29
30
  const throttlePath = path.join(trackerDir, 'sync.throttle');
30
31
  const uploadThrottlePath = path.join(trackerDir, 'upload.throttle.json');
31
32
  const autoRetryPath = path.join(trackerDir, 'auto.retry.json');
@@ -47,6 +48,7 @@ async function collectTrackerDiagnostics({
47
48
  const pendingBytes = Math.max(0, queueSize - offsetBytes);
48
49
 
49
50
  const lastNotify = (await safeReadText(notifySignalPath))?.trim() || null;
51
+ const lastOpenclawSync = (await safeReadText(openclawSignalPath))?.trim() || null;
50
52
  const lastNotifySpawn = parseEpochMsToIso((await safeReadText(throttlePath))?.trim() || null);
51
53
 
52
54
  const codexNotifyRaw = await readCodexNotify(codexConfigPath);
@@ -107,6 +109,7 @@ async function collectTrackerDiagnostics({
107
109
  },
108
110
  notify: {
109
111
  last_notify: lastNotify,
112
+ last_openclaw_triggered_sync: lastOpenclawSync,
110
113
  last_notify_triggered_sync: lastNotifySpawn,
111
114
  codex_notify_configured: notifyConfigured,
112
115
  codex_notify: codexNotify,
@@ -527,6 +527,159 @@ async function parseOpencodeIncremental({
527
527
  return { filesProcessed, eventsAggregated, bucketsQueued, projectBucketsQueued };
528
528
  }
529
529
 
530
+ async function parseOpenclawIncremental({
531
+ sessionFiles,
532
+ cursors,
533
+ queuePath,
534
+ projectQueuePath,
535
+ onProgress,
536
+ source
537
+ }) {
538
+ await ensureDir(path.dirname(queuePath));
539
+ let filesProcessed = 0;
540
+ let eventsAggregated = 0;
541
+
542
+ const cb = typeof onProgress === 'function' ? onProgress : null;
543
+ const files = Array.isArray(sessionFiles) ? sessionFiles : [];
544
+ const totalFiles = files.length;
545
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
546
+ const projectEnabled = typeof projectQueuePath === 'string' && projectQueuePath.length > 0;
547
+ const projectState = projectEnabled ? normalizeProjectState(cursors?.projectHourly) : null;
548
+ const projectTouchedBuckets = projectEnabled ? new Set() : null;
549
+ const touchedBuckets = new Set();
550
+ const defaultSource = normalizeSourceInput(source) || 'openclaw';
551
+
552
+ if (!cursors.files || typeof cursors.files !== 'object') {
553
+ cursors.files = {};
554
+ }
555
+
556
+ for (let idx = 0; idx < files.length; idx++) {
557
+ const entry = files[idx];
558
+ const filePath = typeof entry === 'string' ? entry : entry?.path;
559
+ if (!filePath) continue;
560
+ const fileSource =
561
+ typeof entry === 'string' ? defaultSource : normalizeSourceInput(entry?.source) || defaultSource;
562
+ const st = await fs.stat(filePath).catch(() => null);
563
+ if (!st || !st.isFile()) continue;
564
+
565
+ const key = filePath;
566
+ const prev = cursors.files[key] || null;
567
+ const inode = st.ino || 0;
568
+ const startOffset = prev && prev.inode === inode ? prev.offset || 0 : 0;
569
+
570
+ const result = await parseOpenclawSessionFile({
571
+ filePath,
572
+ startOffset,
573
+ hourlyState,
574
+ touchedBuckets,
575
+ source: fileSource,
576
+ projectState,
577
+ projectTouchedBuckets
578
+ });
579
+
580
+ cursors.files[key] = {
581
+ inode,
582
+ offset: result.endOffset,
583
+ updatedAt: new Date().toISOString()
584
+ };
585
+
586
+ filesProcessed += 1;
587
+ eventsAggregated += result.eventsAggregated;
588
+
589
+ if (cb) {
590
+ cb({
591
+ index: idx + 1,
592
+ total: totalFiles,
593
+ filePath,
594
+ filesProcessed,
595
+ eventsAggregated,
596
+ bucketsQueued: touchedBuckets.size
597
+ });
598
+ }
599
+ }
600
+
601
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
602
+ const projectBucketsQueued = projectEnabled
603
+ ? await enqueueTouchedProjectBuckets({ projectQueuePath, projectState, projectTouchedBuckets })
604
+ : 0;
605
+ hourlyState.updatedAt = new Date().toISOString();
606
+ cursors.hourly = hourlyState;
607
+ if (projectState) {
608
+ projectState.updatedAt = new Date().toISOString();
609
+ cursors.projectHourly = projectState;
610
+ }
611
+
612
+ return { filesProcessed, eventsAggregated, bucketsQueued, projectBucketsQueued };
613
+ }
614
+
615
+ async function parseOpenclawSessionFile({
616
+ filePath,
617
+ startOffset,
618
+ hourlyState,
619
+ touchedBuckets,
620
+ source,
621
+ projectState,
622
+ projectTouchedBuckets
623
+ }) {
624
+ const st = await fs.stat(filePath);
625
+ const endOffset = st.size;
626
+ if (startOffset >= endOffset) return { endOffset, eventsAggregated: 0 };
627
+
628
+ const stream = fssync.createReadStream(filePath, { encoding: 'utf8', start: startOffset });
629
+ const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
630
+
631
+ let eventsAggregated = 0;
632
+ for await (const line of rl) {
633
+ if (!line) continue;
634
+ // Fast-path filter: OpenClaw assistant messages include message.usage.totalTokens.
635
+ if (!line.includes('"usage"') || !line.includes('totalTokens')) continue;
636
+
637
+ let obj;
638
+ try {
639
+ obj = JSON.parse(line);
640
+ } catch (_e) {
641
+ continue;
642
+ }
643
+
644
+ if (obj?.type !== 'message') continue;
645
+ const msg = obj?.message;
646
+ if (!msg || typeof msg !== 'object') continue;
647
+
648
+ const usage = msg.usage;
649
+ if (!usage || typeof usage !== 'object') continue;
650
+
651
+ const tokenTimestamp = typeof obj?.timestamp === 'string' ? obj.timestamp : null;
652
+ if (!tokenTimestamp) continue;
653
+
654
+ const model = normalizeModelInput(msg.model) || DEFAULT_MODEL;
655
+
656
+ const delta = {
657
+ input_tokens: Number(usage.input || 0),
658
+ cached_input_tokens: Number((usage.cacheRead || 0) + (usage.cacheWrite || 0)),
659
+ output_tokens: Number(usage.output || 0),
660
+ reasoning_output_tokens: 0,
661
+ total_tokens: Number(usage.totalTokens || 0)
662
+ };
663
+
664
+ if (isAllZeroUsage(delta)) continue;
665
+
666
+ const bucketStart = toUtcHalfHourStart(tokenTimestamp);
667
+ if (!bucketStart) continue;
668
+
669
+ const bucket = getHourlyBucket(hourlyState, source, model, bucketStart);
670
+ addTotals(bucket.totals, delta);
671
+ touchedBuckets.add(bucketKey(source, model, bucketStart));
672
+
673
+ // Project-level OpenClaw attribution is not supported yet (no stable cwd info).
674
+ // If OpenClaw later records cwd per event, we can mirror rollout's project logic.
675
+ eventsAggregated += 1;
676
+ }
677
+
678
+ rl.close();
679
+ stream.close?.();
680
+ return { endOffset, eventsAggregated };
681
+ }
682
+
530
683
  async function parseRolloutFile({
531
684
  filePath,
532
685
  startOffset,
@@ -1972,5 +2125,6 @@ module.exports = {
1972
2125
  parseRolloutIncremental,
1973
2126
  parseClaudeIncremental,
1974
2127
  parseGeminiIncremental,
1975
- parseOpencodeIncremental
2128
+ parseOpencodeIncremental,
2129
+ parseOpenclawIncremental
1976
2130
  };