@vibe-cafe/vibe-usage 0.10.5 → 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.
@@ -1,144 +1,10 @@
1
- import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
- import { join, basename } from 'node:path';
3
- import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
5
-
6
- /**
7
- * pi-coding-agent parser.
8
- * Reads JSONL session files from ~/.pi/agent/sessions/ (or $PI_CODING_AGENT_DIR/sessions/).
9
- *
10
- * Session file layout:
11
- * sessions/<encoded-cwd>/{timestamp}_{sessionId}.jsonl
12
- *
13
- * Each JSONL line is a session entry:
14
- * - type "session": header with id, cwd, version
15
- * - type "message": contains message object with role, usage, model, timestamp
16
- * - type "model_change", "compaction", etc.: metadata (ignored for usage)
17
- *
18
- * Assistant messages carry per-message token usage:
19
- * message.usage = { input, output, cacheRead, cacheWrite, totalTokens }
20
- */
21
-
22
- function getSessionsDir() {
23
- const envDir = process.env.PI_CODING_AGENT_DIR;
24
- if (envDir) return join(envDir, 'sessions');
25
- return join(homedir(), '.pi', 'agent', 'sessions');
26
- }
27
-
28
- function findJsonlFiles(dir) {
29
- const results = [];
30
- if (!existsSync(dir)) return results;
31
- try {
32
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
33
- const fullPath = join(dir, entry.name);
34
- if (entry.isDirectory()) {
35
- results.push(...findJsonlFiles(fullPath));
36
- } else if (entry.name.endsWith('.jsonl')) {
37
- results.push(fullPath);
38
- }
39
- }
40
- } catch {
41
- // ignore unreadable directories
42
- }
43
- return results;
44
- }
45
-
46
- function extractProjectFromCwd(cwd) {
47
- if (!cwd) return 'unknown';
48
- const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean);
49
- return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
50
- }
51
-
52
- function extractProjectFromDir(filePath, sessionsDir) {
53
- const relative = filePath.slice(sessionsDir.length + 1);
54
- const firstSeg = relative.split('/')[0] || relative.split('\\')[0];
55
- if (!firstSeg) return 'unknown';
56
- const parts = firstSeg.split('-').filter(Boolean);
57
- return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
58
- }
1
+ import { getPiSessionDirs } from '../pi-roots.js';
2
+ import { parsePiSessionJsonl } from './pi-session-jsonl.js';
59
3
 
4
+ /** Parse the official Pi agent's Pi-compatible JSONL sessions. */
60
5
  export async function parse() {
61
- const sessionsDir = getSessionsDir();
62
- const entries = [];
63
- const sessionEvents = [];
64
- const seenEntryIds = new Set();
65
-
66
- const sessionFiles = findJsonlFiles(sessionsDir);
67
-
68
- for (const filePath of sessionFiles) {
69
- let content;
70
- try {
71
- content = readFileSync(filePath, 'utf-8');
72
- } catch {
73
- continue;
74
- }
75
-
76
- let sessionId = basename(filePath, '.jsonl');
77
- let project = extractProjectFromDir(filePath, sessionsDir);
78
-
79
- for (const line of content.split('\n')) {
80
- if (!line.trim()) continue;
81
-
82
- let obj;
83
- try {
84
- obj = JSON.parse(line);
85
- } catch {
86
- continue;
87
- }
88
-
89
- if (obj.type === 'session') {
90
- if (obj.id) sessionId = obj.id;
91
- if (obj.cwd) project = extractProjectFromCwd(obj.cwd);
92
- continue;
93
- }
94
-
95
- if (obj.type !== 'message') continue;
96
-
97
- const msg = obj.message;
98
- if (!msg) continue;
99
-
100
- let ts;
101
- if (obj.timestamp) {
102
- ts = new Date(obj.timestamp);
103
- } else if (msg.timestamp) {
104
- ts = new Date(msg.timestamp);
105
- }
106
- if (!ts || isNaN(ts.getTime())) continue;
107
-
108
- if (msg.role === 'user' || msg.role === 'assistant' || msg.role === 'toolResult') {
109
- sessionEvents.push({
110
- sessionId,
111
- source: 'pi-coding-agent',
112
- project,
113
- timestamp: ts,
114
- role: msg.role === 'user' ? 'user' : 'assistant',
115
- });
116
- }
117
-
118
- if (msg.role !== 'assistant') continue;
119
- if (!msg.usage) continue;
120
-
121
- const usage = msg.usage;
122
- if (usage.input == null && usage.output == null) continue;
123
-
124
- const entryId = obj.id;
125
- if (entryId) {
126
- if (seenEntryIds.has(entryId)) continue;
127
- seenEntryIds.add(entryId);
128
- }
129
-
130
- entries.push({
131
- source: 'pi-coding-agent',
132
- model: msg.model || 'unknown',
133
- project,
134
- timestamp: ts,
135
- inputTokens: usage.input || 0,
136
- outputTokens: usage.output || 0,
137
- cachedInputTokens: usage.cacheRead || 0,
138
- reasoningOutputTokens: 0,
139
- });
140
- }
141
- }
142
-
143
- return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
6
+ return parsePiSessionJsonl({
7
+ source: 'pi-coding-agent',
8
+ sessionsDirs: getPiSessionDirs(),
9
+ });
144
10
  }
@@ -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;