@vibe-cafe/vibe-usage 0.10.6 → 0.10.8

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.
@@ -0,0 +1,148 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { basename, join, relative } from 'node:path';
3
+ import { aggregateToBuckets, extractSessions } from './index.js';
4
+
5
+ const MAX_WARNINGS = 20;
6
+
7
+ function warn(ctx, message) {
8
+ ctx.incomplete = true;
9
+ if (ctx.warnings.length < MAX_WARNINGS) ctx.warnings.push(message);
10
+ }
11
+
12
+ function findJsonlFiles(dir, includeFile, ctx) {
13
+ if (!existsSync(dir)) return [];
14
+ let children;
15
+ try {
16
+ children = readdirSync(dir, { withFileTypes: true });
17
+ } catch (err) {
18
+ warn(ctx, `${ctx.source}: cannot read directory ${dir}: ${err.message}`);
19
+ return [];
20
+ }
21
+
22
+ const files = [];
23
+ for (const child of children) {
24
+ const filePath = join(dir, child.name);
25
+ if (child.isDirectory()) files.push(...findJsonlFiles(filePath, includeFile, ctx));
26
+ else if (child.name.endsWith('.jsonl') && includeFile(filePath)) files.push(filePath);
27
+ }
28
+ return files;
29
+ }
30
+
31
+ function tokenCount(value) {
32
+ const number = Number(value);
33
+ return Number.isFinite(number) && number > 0 ? number : 0;
34
+ }
35
+
36
+ export function projectFromCwd(cwd) {
37
+ if (typeof cwd !== 'string') return 'unknown';
38
+ const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean);
39
+ return parts.at(-1) || 'unknown';
40
+ }
41
+
42
+ export function projectFromFirstDir(filePath, sessionsDir) {
43
+ const first = relative(sessionsDir, filePath).split(/[\\/]/)[0];
44
+ if (!first) return 'unknown';
45
+ return first.split('-').filter(Boolean).at(-1) || 'unknown';
46
+ }
47
+
48
+ export async function parsePiSessionJsonl({
49
+ source,
50
+ sessionsDirs,
51
+ includeFile = () => true,
52
+ projectFromPath = projectFromFirstDir,
53
+ }) {
54
+ const ctx = { source, warnings: [], incomplete: false };
55
+ const entriesById = new Map();
56
+ const anonymousEntries = [];
57
+ const eventsById = new Map();
58
+ const anonymousEvents = [];
59
+
60
+ for (const sessionsDir of sessionsDirs) {
61
+ for (const filePath of findJsonlFiles(sessionsDir, includeFile, ctx)) {
62
+ let content;
63
+ try {
64
+ content = readFileSync(filePath, 'utf8');
65
+ } catch (err) {
66
+ warn(ctx, `${source}: cannot read ${filePath}: ${err.message}`);
67
+ continue;
68
+ }
69
+
70
+ let sessionId = basename(filePath, '.jsonl');
71
+ let project = projectFromPath(filePath, sessionsDir) || 'unknown';
72
+
73
+ for (const line of content.split('\n')) {
74
+ if (!line.trim()) continue;
75
+ let obj;
76
+ try {
77
+ obj = JSON.parse(line);
78
+ } catch {
79
+ continue;
80
+ }
81
+
82
+ if (obj.type === 'session') {
83
+ if (obj.id) sessionId = String(obj.id);
84
+ if (obj.cwd) project = projectFromCwd(obj.cwd);
85
+ continue;
86
+ }
87
+ if (obj.type !== 'message' || !obj.message) continue;
88
+
89
+ const message = obj.message;
90
+ const timestamp = new Date(obj.timestamp || message.timestamp || 0);
91
+ if (Number.isNaN(timestamp.getTime())) continue;
92
+ const recordId = obj.id ? `${sessionId}:${obj.id}` : null;
93
+
94
+ if (message.role === 'user' || message.role === 'assistant' || message.role === 'toolResult') {
95
+ const event = {
96
+ sessionId,
97
+ source,
98
+ project,
99
+ timestamp,
100
+ role: message.role === 'user' ? 'user' : 'assistant',
101
+ };
102
+ if (recordId) eventsById.set(recordId, event);
103
+ else anonymousEvents.push(event);
104
+ }
105
+
106
+ if (message.role !== 'assistant' || !message.usage) continue;
107
+ const usage = message.usage;
108
+ const inputTokens = tokenCount(usage.input) + tokenCount(usage.cacheWrite);
109
+ const reasoningOutputTokens = tokenCount(usage.reasoningTokens);
110
+ // OMP/Pi usage.output includes reasoning; the shared bucket contract
111
+ // stores non-reasoning output and reasoning separately.
112
+ const outputTokens = Math.max(0, tokenCount(usage.output) - reasoningOutputTokens);
113
+ const cachedInputTokens = tokenCount(usage.cacheRead);
114
+ const score = inputTokens + outputTokens + cachedInputTokens + reasoningOutputTokens;
115
+ if (score === 0) continue;
116
+
117
+ const entry = {
118
+ source,
119
+ model: message.model || message.modelId || obj.model || obj.modelId || 'unknown',
120
+ project,
121
+ timestamp,
122
+ inputTokens,
123
+ outputTokens,
124
+ cachedInputTokens,
125
+ reasoningOutputTokens,
126
+ };
127
+ if (!recordId) {
128
+ anonymousEntries.push(entry);
129
+ } else {
130
+ const current = entriesById.get(recordId);
131
+ if (!current || score > current.score) entriesById.set(recordId, { score, entry });
132
+ }
133
+ }
134
+ }
135
+ }
136
+
137
+ const entries = [
138
+ ...anonymousEntries,
139
+ ...[...entriesById.values()].map(({ entry }) => entry),
140
+ ];
141
+ const events = [...anonymousEvents, ...eventsById.values()];
142
+ return {
143
+ buckets: aggregateToBuckets(entries),
144
+ sessions: extractSessions(events),
145
+ ...(ctx.incomplete ? { skipped: true } : {}),
146
+ ...(ctx.warnings.length > 0 ? { warnings: ctx.warnings } : {}),
147
+ };
148
+ }
@@ -0,0 +1,287 @@
1
+ import { createReadStream, readdirSync, statSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { basename, join, relative, sep } from 'node:path';
4
+ import { findWorkbuddyDataDirs } from '../workbuddy-roots.js';
5
+ import { aggregateToBuckets, extractSessions } from './index.js';
6
+
7
+ const SOURCE = 'workbuddy';
8
+ const MAX_WARNINGS = 20;
9
+
10
+ function warn(ctx, message) {
11
+ ctx.skipped = true;
12
+ if (ctx.warnings.length < MAX_WARNINGS && !ctx.warnings.includes(message)) {
13
+ ctx.warnings.push(message);
14
+ }
15
+ }
16
+
17
+ function finite(value) {
18
+ const number = Number(value);
19
+ return Number.isFinite(number) && number >= 0 ? number : 0;
20
+ }
21
+
22
+ function dateFrom(value) {
23
+ if (value instanceof Date && !Number.isNaN(value.getTime())) return value;
24
+ if (typeof value === 'number' && Number.isFinite(value)) {
25
+ const date = new Date(value < 1e12 ? value * 1000 : value);
26
+ return Number.isNaN(date.getTime()) ? null : date;
27
+ }
28
+ if (typeof value === 'string' && value.trim()) {
29
+ const date = new Date(value);
30
+ return Number.isNaN(date.getTime()) ? null : date;
31
+ }
32
+ return null;
33
+ }
34
+
35
+ function projectFromFile(filePath, projectsDir) {
36
+ const first = relative(projectsDir, filePath).split(sep).filter(Boolean)[0];
37
+ return first ? basename(first) : 'unknown';
38
+ }
39
+
40
+ function projectFromRecord(record) {
41
+ const cwd = typeof record.cwd === 'string' ? record.cwd.trim() : '';
42
+ if (!cwd) return null;
43
+ return basename(cwd.replace(/[\\/]+$/, '')) || null;
44
+ }
45
+
46
+ function findJsonlFiles(dir, ctx) {
47
+ let children;
48
+ try {
49
+ children = readdirSync(dir, { withFileTypes: true })
50
+ .sort((a, b) => a.name.localeCompare(b.name));
51
+ } catch (error) {
52
+ if (error?.code === 'ENOENT') return [];
53
+ warn(ctx, 'workbuddy: cannot read a data directory');
54
+ return [];
55
+ }
56
+
57
+ const files = [];
58
+ for (const child of children) {
59
+ const filePath = join(dir, child.name);
60
+ if (child.isDirectory()) files.push(...findJsonlFiles(filePath, ctx));
61
+ else if (child.isFile() && child.name.endsWith('.jsonl')) files.push(filePath);
62
+ }
63
+ return files;
64
+ }
65
+
66
+ async function readJsonl(filePath, size, onRecord, ctx) {
67
+ if (size <= 0) return;
68
+ const stream = createReadStream(filePath, {
69
+ encoding: 'utf8',
70
+ start: 0,
71
+ end: size - 1,
72
+ });
73
+ let streamError = null;
74
+ stream.on('error', error => { streamError = error; });
75
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
76
+
77
+ try {
78
+ for await (const line of lines) {
79
+ if (!line.trim()) continue;
80
+ let record;
81
+ try {
82
+ record = JSON.parse(line);
83
+ } catch {
84
+ continue;
85
+ }
86
+ if (record && typeof record === 'object') onRecord(record);
87
+ }
88
+ if (streamError) throw streamError;
89
+ } catch {
90
+ warn(ctx, 'workbuddy: cannot read a session file');
91
+ } finally {
92
+ lines.close();
93
+ stream.destroy();
94
+ }
95
+ }
96
+
97
+ function recordId(record) {
98
+ if (typeof record.id !== 'string' && typeof record.id !== 'number') return null;
99
+ const id = String(record.id).trim();
100
+ return id || null;
101
+ }
102
+
103
+ function roleFor(record) {
104
+ const role = record.role ?? record.message?.role;
105
+ if (role === 'user') return 'user';
106
+ if (role === 'assistant' || role === 'assistant_message') return 'assistant';
107
+ return null;
108
+ }
109
+
110
+ function isCompletedAssistant(record) {
111
+ if (record.type !== 'message' || roleFor(record) !== 'assistant') return false;
112
+ const status = String(
113
+ record.status ?? record.message?.status ?? record.state ?? record.message?.state ?? ''
114
+ ).toLowerCase();
115
+ return status === 'completed' || status === 'complete' || status === 'success';
116
+ }
117
+
118
+ function modelFor(record) {
119
+ const providerData = record.providerData && typeof record.providerData === 'object'
120
+ ? record.providerData
121
+ : {};
122
+ for (const value of [
123
+ providerData.requestModelId,
124
+ record.requestModelName,
125
+ providerData.requestModelName,
126
+ providerData.model,
127
+ ]) {
128
+ if (typeof value === 'string' && value.trim()) return value.trim();
129
+ }
130
+ return 'unknown';
131
+ }
132
+
133
+ function firstDetailValue(details, ...keys) {
134
+ for (const detail of Array.isArray(details) ? details : [details]) {
135
+ if (!detail || typeof detail !== 'object') continue;
136
+ for (const key of keys) {
137
+ if (detail[key] != null) return finite(detail[key]);
138
+ }
139
+ }
140
+ return 0;
141
+ }
142
+
143
+ function usageFor(record) {
144
+ const providerData = record.providerData && typeof record.providerData === 'object'
145
+ ? record.providerData
146
+ : {};
147
+ const primary = providerData.usage && typeof providerData.usage === 'object'
148
+ ? providerData.usage
149
+ : record.message?.usage && typeof record.message.usage === 'object'
150
+ ? record.message.usage
151
+ : null;
152
+ const raw = providerData.rawUsage && typeof providerData.rawUsage === 'object'
153
+ ? providerData.rawUsage
154
+ : null;
155
+ if (!primary && !raw) return null;
156
+
157
+ const inputDetails = primary?.input_details
158
+ ?? primary?.inputDetails
159
+ ?? primary?.inputTokensDetails;
160
+ const outputDetails = primary?.output_details
161
+ ?? primary?.outputDetails
162
+ ?? primary?.outputTokensDetails;
163
+ const cachedInputTokens = firstDetailValue(inputDetails, 'cached_tokens', 'cachedTokens')
164
+ || finite(
165
+ primary?.cache_read_input_tokens
166
+ ?? primary?.cacheReadInputTokens
167
+ ?? raw?.prompt_cache_hit_tokens
168
+ ?? raw?.cache_read_input_tokens
169
+ );
170
+ const reasoningOutputTokens = firstDetailValue(outputDetails, 'reasoning_tokens', 'reasoningTokens')
171
+ || finite(
172
+ primary?.completion_thinking_tokens
173
+ ?? primary?.reasoning_tokens
174
+ ?? primary?.reasoningTokens
175
+ ?? raw?.completion_thinking_tokens
176
+ );
177
+ const inclusiveInput = finite(primary?.inputTokens ?? primary?.input_tokens ?? raw?.prompt_tokens);
178
+ const inclusiveOutput = finite(primary?.outputTokens ?? primary?.output_tokens ?? raw?.completion_tokens);
179
+ const cacheMiss = finite(raw?.prompt_cache_miss_tokens);
180
+
181
+ // WorkBuddy's aggregate input/output fields include cache reads/reasoning.
182
+ // Prefer the provider's exclusive cache-miss field when available.
183
+ const inputTokens = cacheMiss > 0
184
+ ? cacheMiss
185
+ : Math.max(0, inclusiveInput - cachedInputTokens);
186
+ const outputTokens = Math.max(0, inclusiveOutput - reasoningOutputTokens);
187
+ const score = inputTokens + outputTokens + cachedInputTokens + reasoningOutputTokens;
188
+ if (score === 0) return null;
189
+
190
+ return {
191
+ inputTokens,
192
+ outputTokens,
193
+ cachedInputTokens,
194
+ reasoningOutputTokens,
195
+ score,
196
+ };
197
+ }
198
+
199
+ function timestampFor(record) {
200
+ return dateFrom(
201
+ record.completedAt
202
+ ?? record.completed_at
203
+ ?? record.timestamp
204
+ ?? record.createdAt
205
+ ?? record.created_at
206
+ ?? record.message?.createdAt
207
+ );
208
+ }
209
+
210
+ export async function parse() {
211
+ const entriesById = new Map();
212
+ const eventsById = new Map();
213
+ const anonymousEvents = [];
214
+ const ctx = { skipped: false, warnings: [] };
215
+ const projectDirs = [...new Set(findWorkbuddyDataDirs().map(root => (
216
+ basename(root) === 'projects' ? root : join(root, 'projects')
217
+ )))];
218
+
219
+ for (const projectsDir of projectDirs) {
220
+ for (const filePath of findJsonlFiles(projectsDir, ctx)) {
221
+ let size;
222
+ try {
223
+ size = statSync(filePath).size;
224
+ } catch {
225
+ warn(ctx, 'workbuddy: cannot stat a session file');
226
+ continue;
227
+ }
228
+
229
+ const sessionId = basename(filePath, '.jsonl');
230
+ let project = projectFromFile(filePath, projectsDir);
231
+ const fileEntries = [];
232
+ const fileEvents = [];
233
+
234
+ await readJsonl(filePath, size, record => {
235
+ project = projectFromRecord(record) || project;
236
+ const timestamp = timestampFor(record);
237
+ const id = recordId(record);
238
+ const role = roleFor(record);
239
+
240
+ if (timestamp && (role === 'user' || isCompletedAssistant(record))) {
241
+ fileEvents.push({ id, sessionId, timestamp, role });
242
+ }
243
+
244
+ if (!id || !timestamp || !isCompletedAssistant(record)) return;
245
+ const usage = usageFor(record);
246
+ if (!usage) return;
247
+ fileEntries.push({
248
+ id,
249
+ score: usage.score,
250
+ entry: {
251
+ source: SOURCE,
252
+ model: modelFor(record),
253
+ timestamp,
254
+ inputTokens: usage.inputTokens,
255
+ outputTokens: usage.outputTokens,
256
+ cachedInputTokens: usage.cachedInputTokens,
257
+ reasoningOutputTokens: usage.reasoningOutputTokens,
258
+ },
259
+ });
260
+ }, ctx);
261
+
262
+ for (const candidate of fileEntries) {
263
+ candidate.entry.project = project;
264
+ const current = entriesById.get(candidate.id);
265
+ if (!current || candidate.score > current.score) entriesById.set(candidate.id, candidate);
266
+ }
267
+ for (const candidate of fileEvents) {
268
+ const event = {
269
+ sessionId: candidate.sessionId,
270
+ source: SOURCE,
271
+ project,
272
+ timestamp: candidate.timestamp,
273
+ role: candidate.role,
274
+ };
275
+ if (candidate.id) eventsById.set(candidate.id, event);
276
+ else anonymousEvents.push(event);
277
+ }
278
+ }
279
+ }
280
+
281
+ return {
282
+ buckets: aggregateToBuckets([...entriesById.values()].map(({ entry }) => entry)),
283
+ sessions: extractSessions([...eventsById.values(), ...anonymousEvents]),
284
+ ...(ctx.skipped ? { skipped: true } : {}),
285
+ ...(ctx.warnings.length > 0 ? { warnings: ctx.warnings } : {}),
286
+ };
287
+ }
@@ -0,0 +1,89 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { delimiter, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ function expandHome(value) {
6
+ const trimmed = value.trim();
7
+ if (trimmed === '~') return homedir();
8
+ if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
9
+ return join(homedir(), trimmed.slice(2));
10
+ }
11
+ return trimmed;
12
+ }
13
+
14
+ function uniqueExistingDirs(paths) {
15
+ return [...new Set(paths.map(expandHome))].filter(existsSync);
16
+ }
17
+
18
+ function profileSessionDirs(profilesRoot, includesAgentDir) {
19
+ const dirs = [];
20
+ let profiles;
21
+ try {
22
+ profiles = readdirSync(profilesRoot, { withFileTypes: true });
23
+ } catch {
24
+ return dirs;
25
+ }
26
+ for (const profile of profiles) {
27
+ if (!profile.isDirectory()) continue;
28
+ dirs.push(join(
29
+ profilesRoot,
30
+ profile.name,
31
+ ...(includesAgentDir ? ['agent', 'sessions'] : ['sessions']),
32
+ ));
33
+ }
34
+ return dirs;
35
+ }
36
+
37
+ export function looksLikeOmpAgentDir(agentDir) {
38
+ const normalized = agentDir.replace(/\\/g, '/');
39
+ return normalized.includes('/.omp/')
40
+ || existsSync(join(agentDir, 'config.yml'))
41
+ || existsSync(join(agentDir, 'agent.db'));
42
+ }
43
+
44
+ export function getPiSessionDirs() {
45
+ const override = process.env.VIBE_USAGE_PI_SESSION_DIRS?.trim();
46
+ if (override) return uniqueExistingDirs(override.split(delimiter));
47
+
48
+ const agentDir = process.env.PI_CODING_AGENT_DIR?.trim();
49
+ if (agentDir) {
50
+ const expanded = expandHome(agentDir);
51
+ // OMP inherits PI_CODING_AGENT_DIR from Pi. Do not parse an identifiable
52
+ // OMP store again as source=pi-coding-agent.
53
+ return looksLikeOmpAgentDir(expanded) ? [] : uniqueExistingDirs([join(expanded, 'sessions')]);
54
+ }
55
+ return uniqueExistingDirs([join(homedir(), '.pi', 'agent', 'sessions')]);
56
+ }
57
+
58
+ export function getOmpSessionDirs() {
59
+ const override = process.env.VIBE_USAGE_OMP_SESSION_DIRS?.trim();
60
+ if (override) return uniqueExistingDirs(override.split(delimiter));
61
+
62
+ const dirs = [];
63
+ const configName = process.env.PI_CONFIG_DIR?.trim() || '.omp';
64
+ const configRoot = join(homedir(), configName);
65
+ dirs.push(join(configRoot, 'agent', 'sessions'));
66
+ dirs.push(...profileSessionDirs(join(configRoot, 'profiles'), true));
67
+
68
+ const agentOverride = process.env.PI_CODING_AGENT_DIR?.trim();
69
+ if (agentOverride) {
70
+ const expanded = expandHome(agentOverride);
71
+ if (looksLikeOmpAgentDir(expanded)) dirs.push(join(expanded, 'sessions'));
72
+ }
73
+
74
+ // OMP's XDG migration flattens the agent/ segment:
75
+ // ~/.omp/agent/sessions -> $XDG_DATA_HOME/omp/sessions.
76
+ if (process.platform === 'linux' || process.platform === 'darwin') {
77
+ const xdgDataHome = process.env.XDG_DATA_HOME?.trim();
78
+ if (xdgDataHome) {
79
+ const xdgRoot = join(expandHome(xdgDataHome), 'omp');
80
+ dirs.push(join(xdgRoot, 'sessions'));
81
+ dirs.push(...profileSessionDirs(join(xdgRoot, 'profiles'), false));
82
+ }
83
+ }
84
+
85
+ return uniqueExistingDirs(dirs);
86
+ }
87
+
88
+ export const findPiDataDirs = getPiSessionDirs;
89
+ export const findOmpDataDirs = getOmpSessionDirs;
package/src/sync.js CHANGED
@@ -6,7 +6,7 @@ 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 { parsers } from './parsers/index.js';
9
+ import { aggregateToBuckets, parsers } from './parsers/index.js';
10
10
  import { success, failure, arrow, link, dim } from './output.js';
11
11
 
12
12
  const BATCH_SIZE = 100;
@@ -31,6 +31,16 @@ export function resolveCodexExtraHome(configured, temporary) {
31
31
  return temporary ?? configured;
32
32
  }
33
33
 
34
+ // Hiding project names can collapse multiple parser buckets onto one server
35
+ // identity. Merge those buckets before hashing/uploading so no project's usage
36
+ // wins by iteration order.
37
+ export function reaggregateHiddenProjectBuckets(buckets) {
38
+ return aggregateToBuckets(buckets.map(bucket => ({
39
+ ...bucket,
40
+ timestamp: new Date(bucket.bucketStart),
41
+ })));
42
+ }
43
+
34
44
  export async function runSync({
35
45
  throws = false,
36
46
  quiet = false,
@@ -70,7 +80,7 @@ export async function runSync({
70
80
  process.exit(1);
71
81
  }
72
82
 
73
- const allBuckets = [];
83
+ let allBuckets = [];
74
84
  const allSessions = [];
75
85
  const parserResults = [];
76
86
  const parserProgress = [];
@@ -166,6 +176,7 @@ export async function runSync({
166
176
  if (!uploadProject) {
167
177
  for (const b of allBuckets) b.project = 'unknown';
168
178
  for (const s of allSessions) s.project = 'unknown';
179
+ allBuckets = reaggregateHiddenProjectBuckets(allBuckets);
169
180
  }
170
181
 
171
182
  // Incremental upload diff: parsers above emit a complete view of live local
package/src/tools.js CHANGED
@@ -1,8 +1,29 @@
1
1
  import { existsSync, readdirSync, statSync } from 'node:fs';
2
- import { isAbsolute, join, resolve } from 'node:path';
2
+ import { isAbsolute, join, posix, resolve, win32 } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { findClaudeCodeDataDirs } from './claude-roots.js';
5
5
  import { codexSessionDirs, resolveCodexHomes } from './codex-roots.js';
6
+ import { findClineDataDirs } from './cline-roots.js';
7
+ import { findCraftDataDirs } from './craft-roots.js';
8
+ import { findOmpDataDirs, findPiDataDirs } from './pi-roots.js';
9
+ import { findWorkbuddyDataDirs } from './workbuddy-roots.js';
10
+
11
+ export function getAlmaDbPath(env = process.env, platform = process.platform, home = homedir()) {
12
+ const pathImpl = platform === 'win32' ? win32 : posix;
13
+ const override = env.VIBE_USAGE_ALMA_DB?.trim();
14
+ if (override) {
15
+ return platform === process.platform ? resolve(override) : pathImpl.resolve(override);
16
+ }
17
+ if (platform === 'darwin') {
18
+ return pathImpl.join(home, 'Library', 'Application Support', 'alma', 'chat_threads.db');
19
+ }
20
+ if (platform === 'win32') {
21
+ const appData = env.APPDATA?.trim() || pathImpl.join(home, 'AppData', 'Roaming');
22
+ return pathImpl.join(appData, 'alma', 'chat_threads.db');
23
+ }
24
+ const configHome = env.XDG_CONFIG_HOME?.trim() || pathImpl.join(home, '.config');
25
+ return pathImpl.join(configHome, 'alma', 'chat_threads.db');
26
+ }
6
27
 
7
28
  function getCursorStateDbPath() {
8
29
  const rel = join('User', 'globalStorage', 'state.vscdb');
@@ -61,7 +82,6 @@ function findExtensionDirs(extensionId) {
61
82
  return dirs;
62
83
  }
63
84
 
64
- const findClineDataDirs = () => findExtensionDirs('saoudrizwan.claude-dev');
65
85
  const findRooCodeDataDirs = () => findExtensionDirs('rooveterinaryinc.roo-cline');
66
86
 
67
87
  /** Find all OpenClaw data roots: ~/.openclaw and ~/.openclaw-<profile> */
@@ -175,6 +195,12 @@ export function findDimAgentDataDirs() {
175
195
  }
176
196
 
177
197
  export const TOOLS = [
198
+ {
199
+ name: 'Alma',
200
+ id: 'alma',
201
+ dataDir: getAlmaDbPath(),
202
+ detectDataDirs: () => [getAlmaDbPath()].filter(existsSync),
203
+ },
178
204
  {
179
205
  name: 'Claude Code',
180
206
  id: 'claude-code',
@@ -198,6 +224,12 @@ export const TOOLS = [
198
224
  id: 'copilot-cli',
199
225
  dataDir: join(homedir(), '.copilot', 'session-state'),
200
226
  },
227
+ {
228
+ name: 'CraftAgent',
229
+ id: 'craft-agent',
230
+ dataDir: join(homedir(), '.craft-agent', 'workspaces'),
231
+ detectDataDirs: findCraftDataDirs,
232
+ },
201
233
  {
202
234
  name: 'Cursor',
203
235
  id: 'cursor',
@@ -225,10 +257,17 @@ export const TOOLS = [
225
257
  dataDir: join(homedir(), '.openclaw', 'agents'),
226
258
  detectDataDirs: findOpenclawDataDirs,
227
259
  },
260
+ {
261
+ name: 'Oh My Pi',
262
+ id: 'omp',
263
+ dataDir: join(homedir(), '.omp', 'agent', 'sessions'),
264
+ detectDataDirs: findOmpDataDirs,
265
+ },
228
266
  {
229
267
  name: 'pi',
230
268
  id: 'pi-coding-agent',
231
269
  dataDir: join(homedir(), '.pi', 'agent', 'sessions'),
270
+ detectDataDirs: findPiDataDirs,
232
271
  },
233
272
  {
234
273
  name: 'Qwen Code',
@@ -284,7 +323,7 @@ export const TOOLS = [
284
323
  {
285
324
  name: 'Cline',
286
325
  id: 'cline',
287
- dataDir: join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'),
326
+ dataDir: join(homedir(), '.cline'),
288
327
  detectDataDirs: findClineDataDirs,
289
328
  },
290
329
  {
@@ -293,6 +332,12 @@ export const TOOLS = [
293
332
  dataDir: join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'rooveterinaryinc.roo-cline'),
294
333
  detectDataDirs: findRooCodeDataDirs,
295
334
  },
335
+ {
336
+ name: 'WorkBuddy',
337
+ id: 'workbuddy',
338
+ dataDir: join(homedir(), '.workbuddy', 'projects'),
339
+ detectDataDirs: () => findWorkbuddyDataDirs().filter(existsSync),
340
+ },
296
341
  {
297
342
  name: 'ZCode',
298
343
  id: 'zcode',
@@ -0,0 +1,19 @@
1
+ import { delimiter, join } from 'node:path';
2
+ import { homedir } from 'node:os';
3
+
4
+ export function getDefaultWorkbuddyProjectsDir() {
5
+ return join(homedir(), '.workbuddy', 'projects');
6
+ }
7
+
8
+ // Fixture/relocation hook. Entries may name either the WorkBuddy home or its
9
+ // projects/ directory; the parser normalizes both forms.
10
+ export function findWorkbuddyDataDirs() {
11
+ const override = process.env.VIBE_USAGE_WORKBUDDY_DIRS?.trim();
12
+ if (!override) return [getDefaultWorkbuddyProjectsDir()];
13
+ return [...new Set(
14
+ override
15
+ .split(delimiter)
16
+ .map(entry => entry.trim())
17
+ .filter(Boolean)
18
+ )];
19
+ }