@vibe-cafe/vibe-usage 0.10.20 → 0.10.21
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/README.md +8 -2
- package/package.json +1 -1
- package/src/extra-roots.js +207 -2
- package/src/index.js +3 -3
- package/src/parsers/pi-coding-agent.js +17 -2
- package/src/parsers/pi-session-jsonl.js +19 -1
- package/src/pi-roots.js +18 -9
- package/src/tools.js +3 -1
package/README.md
CHANGED
|
@@ -64,7 +64,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
64
64
|
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite, `json_extract` query) |
|
|
65
65
|
| OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments); cache-creation/cache-write tokens are included in input usage |
|
|
66
66
|
| Oh My Pi | `~/.omp/agent/sessions/`, `~/.omp/profiles/*/agent/sessions/`, and `$XDG_DATA_HOME/omp/{sessions,profiles/*/sessions}`; recognizes OMP's `$PI_CODING_AGENT_DIR`, current v3 title slots and path/hashed session directories, deduplicates copied records, includes cache writes in input, and splits reasoning from OMP's inclusive output count |
|
|
67
|
-
| pi | `~/.pi/agent/sessions/` or `$PI_CODING_AGENT_DIR/sessions/`, plus the session directory Pi itself was pointed at via `PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in `~/.pi/agent/settings.json` (fixture/relocation override: `VIBE_USAGE_PI_SESSION_DIRS`). Cache writes are included in input usage; reasoning is read from Pi's `usage.reasoning` (legacy `usage.reasoningTokens` still accepted) and split out of the inclusive output total |
|
|
67
|
+
| pi | `~/.pi/agent/sessions/` or `$PI_CODING_AGENT_DIR/sessions/`, plus the session directory Pi itself was pointed at via `PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in `~/.pi/agent/settings.json`, plus explicitly added `pi-coding-agent` roots for stores only reachable through `pi --session <file>` (fixture/relocation override: `VIBE_USAGE_PI_SESSION_DIRS`). Cache writes are included in input usage; reasoning is read from Pi's `usage.reasoning` (legacy `usage.reasoningTokens` still accepted) and split out of the inclusive output total |
|
|
68
68
|
| Qwen Code | `~/.qwen/tmp/` |
|
|
69
69
|
| Kimi Code | Current `~/.kimi-code/sessions/wd_<slug>_<hash>/session_<id>/agents/<agent>/wire.jsonl` (`usage.record` deltas, including retry/compaction scope and cache creation; main/subagent wires form one session), data root resolved via `$KIMI_CODE_HOME` like the CLI itself, with project names from `session_index.jsonl`; legacy `~/.kimi/sessions/` is parsed alongside (`kimi migrate` never carries usage over, so both stores are always merged) |
|
|
70
70
|
| MiniMax Code (mcode) | `$MCODE_HOME/v2/sqlite/runtime-state.sqlite` (default `~/.minimax/v2/sqlite/runtime-state.sqlite`; fixture override: `VIBE_USAGE_MCODE_DB`). Reads only allow-listed token ledger fields and session workspace/project paths, uses basename-only projects, folds cache writes into input, keeps cache reads and reasoning separate, and never selects raw/message JSON payloads. WAL/lock reads use a disposable snapshot; malformed or incompatible databases are skipped to preserve incremental state. |
|
|
@@ -158,7 +158,7 @@ Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`).
|
|
|
158
158
|
| `apiUrl` | Server URL (default: `https://vibecafe.ai`) |
|
|
159
159
|
| `hostname` | Stable device name for usage tracking (set at init, reused across syncs) |
|
|
160
160
|
| `codexExtraHome` | Optional additional Codex Home scanned together with `$CODEX_HOME` / `~/.codex` |
|
|
161
|
-
| `extraRoots` | Tool-specific additional roots managed by the commands below; currently supports `codex`, `grok`, and `
|
|
161
|
+
| `extraRoots` | Tool-specific additional roots managed by the commands below; currently supports `codex`, `grok`, `antigravity`, and `pi-coding-agent` |
|
|
162
162
|
|
|
163
163
|
The `hostname` is captured once during `init` and reused for all future syncs. This prevents macOS mDNS hostname changes (e.g., `MacBook-Pro` → `MacBook-Pro-2`) from creating duplicate device entries. To change it manually:
|
|
164
164
|
|
|
@@ -179,6 +179,12 @@ npx @vibe-cafe/vibe-usage config add-root grok /path/to/grok-home
|
|
|
179
179
|
# Antigravity expects an alternate HOME containing .gemini/antigravity*/conversations/.
|
|
180
180
|
npx @vibe-cafe/vibe-usage config add-root antigravity /path/to/alternate-home
|
|
181
181
|
|
|
182
|
+
# Pi accepts a directory that holds session .jsonl files directly, or a Pi
|
|
183
|
+
# agent directory containing sessions/. Use this when a harness starts Pi with
|
|
184
|
+
# `pi --session <file>`: that path is recorded nowhere Pi's own settings can
|
|
185
|
+
# report, so it is invisible to discovery.
|
|
186
|
+
npx @vibe-cafe/vibe-usage config add-root pi-coding-agent /path/to/pi-sessions
|
|
187
|
+
|
|
182
188
|
npx @vibe-cafe/vibe-usage config roots
|
|
183
189
|
npx @vibe-cafe/vibe-usage config remove-root grok /path/to/grok-home
|
|
184
190
|
```
|
package/package.json
CHANGED
package/src/extra-roots.js
CHANGED
|
@@ -1,9 +1,17 @@
|
|
|
1
|
-
import { accessSync, constants, readdirSync, statSync } from 'node:fs';
|
|
1
|
+
import { accessSync, closeSync, constants, openSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
3
|
import { basename, join, resolve } from 'node:path';
|
|
4
4
|
import { codexSessionDirs } from './codex-roots.js';
|
|
5
5
|
|
|
6
|
-
export const EXTRA_ROOT_SOURCES = ['antigravity', 'codex', 'grok'];
|
|
6
|
+
export const EXTRA_ROOT_SOURCES = ['antigravity', 'codex', 'grok', 'pi-coding-agent'];
|
|
7
|
+
|
|
8
|
+
// Probing a candidate Pi store has three outcomes, never two: a confirmed
|
|
9
|
+
// session, a directory proven to hold none, and one that could not be read.
|
|
10
|
+
// Collapsing the last two into a single false is what let an unreadable subtree
|
|
11
|
+
// be treated as an empty one.
|
|
12
|
+
const PI_SESSIONS_FOUND = 'found';
|
|
13
|
+
const PI_SESSIONS_ABSENT = 'absent';
|
|
14
|
+
const PI_SESSIONS_UNREADABLE = 'unreadable';
|
|
7
15
|
|
|
8
16
|
export function extraRootList(value) {
|
|
9
17
|
return Array.isArray(value) ? value.filter(root => typeof root === 'string' && root.trim()) : [];
|
|
@@ -81,6 +89,194 @@ export function antigravityConversationDirs(value) {
|
|
|
81
89
|
];
|
|
82
90
|
}
|
|
83
91
|
|
|
92
|
+
// Pi's own discoverable settings (PI_CODING_AGENT_SESSION_DIR, `sessionDir` in
|
|
93
|
+
// settings.json) name a sessions directory directly, and a harness that calls
|
|
94
|
+
// `pi --session <file>` writes bare session trees with no agent home above
|
|
95
|
+
// them. Accept either shape, but resolve to exactly one directory per root:
|
|
96
|
+
// the parser walks nested directories, so returning both a root and its
|
|
97
|
+
// `sessions/` child would read every file twice. Canonical-path dedup in the
|
|
98
|
+
// parser makes the overlap harmless even for a mixed root that holds sessions
|
|
99
|
+
// directly *and* under `sessions/`.
|
|
100
|
+
//
|
|
101
|
+
// Every candidate is confirmed by content, never by name alone. A readable but
|
|
102
|
+
// unconfirmed `sessions/` child used to win unconditionally, so creating an
|
|
103
|
+
// empty `<root>/sessions` was enough to redirect the scan away from a bare
|
|
104
|
+
// store's own files — a successful sync reporting zero, which then pruned the
|
|
105
|
+
// source's incremental state.
|
|
106
|
+
//
|
|
107
|
+
// The shape is re-resolved on every run rather than remembered from add-root
|
|
108
|
+
// time, and an unresolvable root returns null instead of falling back to the
|
|
109
|
+
// root itself. Falling back would turn an agent home that lost its `sessions/`
|
|
110
|
+
// child into a readable directory holding no sessions, i.e. exactly the silent
|
|
111
|
+
// zero this feature exists to prevent.
|
|
112
|
+
//
|
|
113
|
+
// Probing is tri-state on purpose. A boolean collapsed "holds no sessions" into
|
|
114
|
+
// "could not be read", so making one sibling store unreadable was enough to
|
|
115
|
+
// make a container look like an agent home and narrow the scan to `sessions/`,
|
|
116
|
+
// dropping every readable sibling with no `skipped` flag. Absence has to be
|
|
117
|
+
// proven; where it is only assumed, resolution gives up and the caller skips.
|
|
118
|
+
export function piSessionsDir(value) {
|
|
119
|
+
const root = normalizeExtraRoot(value);
|
|
120
|
+
if (!isReadableDirectory(root)) return null;
|
|
121
|
+
// A bare store is identified by the session files it holds directly, and
|
|
122
|
+
// outranks any `sessions/` child: those files are what the user configured.
|
|
123
|
+
const direct = probePiSessions(root, 0);
|
|
124
|
+
if (direct === PI_SESSIONS_FOUND) return root;
|
|
125
|
+
|
|
126
|
+
const outside = probePiSessionsOutsideNested(root);
|
|
127
|
+
// Both decisions below rest on absence: that the root holds no sessions
|
|
128
|
+
// directly, and that no sibling of `sessions/` holds any. An unreadable
|
|
129
|
+
// candidate proves neither, so stop rather than narrow past it.
|
|
130
|
+
if (direct === PI_SESSIONS_UNREADABLE || outside === PI_SESSIONS_UNREADABLE) return null;
|
|
131
|
+
|
|
132
|
+
const nested = join(root, 'sessions');
|
|
133
|
+
// Agent-home shape: narrowing to the child is only safe when every confirmed
|
|
134
|
+
// session lives below it. A container whose per-task stores happen to include
|
|
135
|
+
// one named `sessions` is still a container, and resolving it to that child
|
|
136
|
+
// would drop all its siblings — the same silent undercount as above.
|
|
137
|
+
if (
|
|
138
|
+
outside === PI_SESSIONS_ABSENT
|
|
139
|
+
&& isReadableDirectory(nested)
|
|
140
|
+
&& probePiSessions(nested) === PI_SESSIONS_FOUND
|
|
141
|
+
) {
|
|
142
|
+
return nested;
|
|
143
|
+
}
|
|
144
|
+
// A configured container holding per-task stores somewhere below it. Anything
|
|
145
|
+
// else — no sessions at all, or a subtree that could not be read — resolves
|
|
146
|
+
// to null, which the parser reports as skipped instead of as an empty sync.
|
|
147
|
+
return probePiSessions(root) === PI_SESSIONS_FOUND ? root : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Confirmed sessions in some child other than `sessions/`. The per-child depth
|
|
151
|
+
// is one less than the root scan's own so both reach the same files.
|
|
152
|
+
function probePiSessionsOutsideNested(root) {
|
|
153
|
+
let children;
|
|
154
|
+
try {
|
|
155
|
+
children = readdirSync(root, { withFileTypes: true });
|
|
156
|
+
} catch {
|
|
157
|
+
return PI_SESSIONS_UNREADABLE;
|
|
158
|
+
}
|
|
159
|
+
let unreadable = false;
|
|
160
|
+
for (const child of children) {
|
|
161
|
+
if (!child.isDirectory() || child.name === 'sessions') continue;
|
|
162
|
+
const probe = probePiSessions(join(root, child.name), 1);
|
|
163
|
+
if (probe === PI_SESSIONS_FOUND) return PI_SESSIONS_FOUND;
|
|
164
|
+
if (probe === PI_SESSIONS_UNREADABLE) unreadable = true;
|
|
165
|
+
}
|
|
166
|
+
return unreadable ? PI_SESSIONS_UNREADABLE : PI_SESSIONS_ABSENT;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const PI_PROBE_BYTES = 16 * 1024;
|
|
170
|
+
const PI_PROBE_LINES = 10;
|
|
171
|
+
// Roles the Pi parser turns into events; anything else contributes nothing.
|
|
172
|
+
const PI_MESSAGE_ROLES = new Set(['user', 'assistant', 'toolResult']);
|
|
173
|
+
|
|
174
|
+
function isNonEmptyString(value) {
|
|
175
|
+
return typeof value === 'string' && value.trim() !== '';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Pi's session format opens a store with a full SessionHeader. `version` is
|
|
179
|
+
// written as both a number and a string across real stores, so only its
|
|
180
|
+
// presence is required.
|
|
181
|
+
function isPiSessionHeader(obj) {
|
|
182
|
+
return obj.type === 'session'
|
|
183
|
+
&& obj.version !== undefined
|
|
184
|
+
&& obj.version !== null
|
|
185
|
+
&& isNonEmptyString(obj.id)
|
|
186
|
+
&& isNonEmptyString(obj.timestamp)
|
|
187
|
+
&& typeof obj.cwd === 'string';
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// A store an external harness appends to may carry no header inside the probed
|
|
191
|
+
// prefix, so a message record alone can confirm the directory — but only a
|
|
192
|
+
// complete one. Matching on `type` and an object-valued `message` accepted
|
|
193
|
+
// `{"type":"message","message":{}}`, which the parser reads to exactly zero.
|
|
194
|
+
function isPiSessionMessage(obj) {
|
|
195
|
+
return obj.type === 'message'
|
|
196
|
+
&& isNonEmptyString(obj.id)
|
|
197
|
+
&& 'parentId' in obj
|
|
198
|
+
&& isNonEmptyString(obj.timestamp)
|
|
199
|
+
&& Boolean(obj.message)
|
|
200
|
+
&& typeof obj.message === 'object'
|
|
201
|
+
&& PI_MESSAGE_ROLES.has(obj.message.role);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// A `.jsonl` extension proves nothing: an unrelated log would validate an
|
|
205
|
+
// entirely wrong directory, which the parser then ignores without complaining.
|
|
206
|
+
// Neither does a bare `type` name — validation has to require the fields the
|
|
207
|
+
// parser reads, or a malformed lookalike is accepted and still syncs zero.
|
|
208
|
+
// A file that cannot be opened is reported as unreadable, not as "not a
|
|
209
|
+
// session": it may well be the store the user configured.
|
|
210
|
+
function probePiSessionFile(filePath) {
|
|
211
|
+
let text;
|
|
212
|
+
let fd;
|
|
213
|
+
try {
|
|
214
|
+
fd = openSync(filePath, 'r');
|
|
215
|
+
const buffer = Buffer.alloc(PI_PROBE_BYTES);
|
|
216
|
+
const read = readSync(fd, buffer, 0, PI_PROBE_BYTES, 0);
|
|
217
|
+
text = buffer.subarray(0, read).toString('utf8');
|
|
218
|
+
// A prefix read can cut the final line in half; drop the partial tail.
|
|
219
|
+
if (read === PI_PROBE_BYTES) text = text.slice(0, text.lastIndexOf('\n') + 1);
|
|
220
|
+
} catch {
|
|
221
|
+
return PI_SESSIONS_UNREADABLE;
|
|
222
|
+
} finally {
|
|
223
|
+
if (fd !== undefined) {
|
|
224
|
+
try {
|
|
225
|
+
closeSync(fd);
|
|
226
|
+
} catch { /* already closed */ }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
let checked = 0;
|
|
231
|
+
for (const line of text.split('\n')) {
|
|
232
|
+
if (!line.trim()) continue;
|
|
233
|
+
if (++checked > PI_PROBE_LINES) return PI_SESSIONS_ABSENT;
|
|
234
|
+
let obj;
|
|
235
|
+
try {
|
|
236
|
+
obj = JSON.parse(line);
|
|
237
|
+
} catch {
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
241
|
+
if (isPiSessionHeader(obj) || isPiSessionMessage(obj)) return PI_SESSIONS_FOUND;
|
|
242
|
+
}
|
|
243
|
+
return PI_SESSIONS_ABSENT;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// A session store is only recognizable by the Pi session files in it. Stay
|
|
247
|
+
// shallow: a configured root may sit next to large unrelated trees.
|
|
248
|
+
//
|
|
249
|
+
// `found` outranks `unreadable`: one confirmed session is enough to resolve the
|
|
250
|
+
// shape, and the shared parser reports whatever it cannot read on the way in.
|
|
251
|
+
// `unreadable` outranks `absent`, so a caller never mistakes a subtree it could
|
|
252
|
+
// not open for one it proved empty.
|
|
253
|
+
function probePiSessions(dir, depth = 2) {
|
|
254
|
+
let children;
|
|
255
|
+
try {
|
|
256
|
+
children = readdirSync(dir, { withFileTypes: true });
|
|
257
|
+
} catch {
|
|
258
|
+
return PI_SESSIONS_UNREADABLE;
|
|
259
|
+
}
|
|
260
|
+
let unreadable = false;
|
|
261
|
+
// Dirent#isDirectory is false for symbolic links, matching the parser's own
|
|
262
|
+
// walk: linked session files are read, linked directories are not entered.
|
|
263
|
+
for (const child of children) {
|
|
264
|
+
if (child.isDirectory() || !child.name.endsWith('.jsonl')) continue;
|
|
265
|
+
const probe = probePiSessionFile(join(dir, child.name));
|
|
266
|
+
if (probe === PI_SESSIONS_FOUND) return PI_SESSIONS_FOUND;
|
|
267
|
+
if (probe === PI_SESSIONS_UNREADABLE) unreadable = true;
|
|
268
|
+
}
|
|
269
|
+
if (depth > 0) {
|
|
270
|
+
for (const child of children) {
|
|
271
|
+
if (!child.isDirectory()) continue;
|
|
272
|
+
const probe = probePiSessions(join(dir, child.name), depth - 1);
|
|
273
|
+
if (probe === PI_SESSIONS_FOUND) return PI_SESSIONS_FOUND;
|
|
274
|
+
if (probe === PI_SESSIONS_UNREADABLE) unreadable = true;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return unreadable ? PI_SESSIONS_UNREADABLE : PI_SESSIONS_ABSENT;
|
|
278
|
+
}
|
|
279
|
+
|
|
84
280
|
export function validateExtraRoot(source, value) {
|
|
85
281
|
if (!EXTRA_ROOT_SOURCES.includes(source)) {
|
|
86
282
|
return { ok: false, path: value, reason: `不支持的工具: ${source}` };
|
|
@@ -94,6 +290,15 @@ export function validateExtraRoot(source, value) {
|
|
|
94
290
|
reason: '需要是 Codex Home,或包含 */*/codex-home 的 Multica 容器',
|
|
95
291
|
};
|
|
96
292
|
}
|
|
293
|
+
if (source === 'pi-coding-agent') {
|
|
294
|
+
// piSessionsDir only returns a directory it has already confirmed by
|
|
295
|
+
// content, so there is nothing left to re-check here.
|
|
296
|
+
return {
|
|
297
|
+
ok: piSessionsDir(path) !== null,
|
|
298
|
+
path,
|
|
299
|
+
reason: '需要是直接包含 Pi 会话 .jsonl 的目录,或包含 sessions/ 的 Pi agent 目录',
|
|
300
|
+
};
|
|
301
|
+
}
|
|
97
302
|
const dirs = source === 'grok'
|
|
98
303
|
? [grokSessionsDir(path)]
|
|
99
304
|
: antigravityConversationDirs(path);
|
package/src/index.js
CHANGED
|
@@ -121,7 +121,7 @@ function handleConfig(args) {
|
|
|
121
121
|
const source = args[1];
|
|
122
122
|
const value = args[2];
|
|
123
123
|
if (!source || value === undefined) {
|
|
124
|
-
console.error(
|
|
124
|
+
console.error(`Usage: vibe-usage config add-root <${EXTRA_ROOT_SOURCES.join('|')}> <path>`);
|
|
125
125
|
process.exit(1);
|
|
126
126
|
}
|
|
127
127
|
const validation = validateExtraRoot(source, value);
|
|
@@ -142,7 +142,7 @@ function handleConfig(args) {
|
|
|
142
142
|
const source = args[1];
|
|
143
143
|
const value = args[2];
|
|
144
144
|
if (!EXTRA_ROOT_SOURCES.includes(source) || value === undefined) {
|
|
145
|
-
console.error(
|
|
145
|
+
console.error(`Usage: vibe-usage config remove-root <${EXTRA_ROOT_SOURCES.join('|')}> <path>`);
|
|
146
146
|
process.exit(1);
|
|
147
147
|
}
|
|
148
148
|
const config = loadConfig() || {};
|
|
@@ -297,7 +297,7 @@ export async function run(rawArgs) {
|
|
|
297
297
|
npx @vibe-cafe/vibe-usage config get <key> Get a config value
|
|
298
298
|
npx @vibe-cafe/vibe-usage config set <key> <value> Set a config value
|
|
299
299
|
npx @vibe-cafe/vibe-usage config set codexExtraHome <path> Persist another Codex Home
|
|
300
|
-
npx @vibe-cafe/vibe-usage config add-root <tool> <path> Add a Codex, Grok, or
|
|
300
|
+
npx @vibe-cafe/vibe-usage config add-root <tool> <path> Add a Codex, Grok, Antigravity, or Pi data root
|
|
301
301
|
npx @vibe-cafe/vibe-usage config remove-root <tool> <path> Remove an added data root
|
|
302
302
|
npx @vibe-cafe/vibe-usage config roots Show added data roots as JSON
|
|
303
303
|
npx @vibe-cafe/vibe-usage help Show this help
|
|
@@ -1,12 +1,27 @@
|
|
|
1
|
+
import { normalizeExtraRoot, piSessionsDir } from '../extra-roots.js';
|
|
1
2
|
import { getPiSessionDirs } from '../pi-roots.js';
|
|
2
3
|
import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
|
|
3
4
|
import { parsePiSessionJsonl } from './pi-session-jsonl.js';
|
|
4
5
|
|
|
5
6
|
/** Parse the official Pi agent's Pi-compatible JSONL sessions. */
|
|
6
|
-
export async function parse() {
|
|
7
|
+
export async function parse({ extraRoots = [] } = {}) {
|
|
8
|
+
for (const root of extraRoots) {
|
|
9
|
+
// piSessionsDir re-resolves the root's shape, so an agent home that lost
|
|
10
|
+
// its `sessions/` child is caught here too, not just a root that vanished.
|
|
11
|
+
if (piSessionsDir(root) !== null) continue;
|
|
12
|
+
// An explicitly configured root that is momentarily unreadable is not
|
|
13
|
+
// proof that its usage disappeared, so skip instead of reporting empty.
|
|
14
|
+
return {
|
|
15
|
+
buckets: [],
|
|
16
|
+
sessions: [],
|
|
17
|
+
skipped: true,
|
|
18
|
+
warnings: [`pi-coding-agent: 额外根目录不可用,已跳过本次 Pi 同步: ${normalizeExtraRoot(root)}`],
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
const nativeResult = await parsePiSessionJsonl({
|
|
8
23
|
source: 'pi-coding-agent',
|
|
9
|
-
sessionsDirs: getPiSessionDirs(),
|
|
24
|
+
sessionsDirs: getPiSessionDirs(extraRoots),
|
|
10
25
|
});
|
|
11
26
|
return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('pi'));
|
|
12
27
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { basename, join, relative } from 'node:path';
|
|
3
3
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
4
4
|
import { projectFromCwd, toCount } from './fs-utils.js';
|
|
@@ -38,6 +38,19 @@ export function projectFromFirstDir(filePath, sessionsDir) {
|
|
|
38
38
|
return first.split('-').filter(Boolean).at(-1) || 'unknown';
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
// Configured stores can overlap: an ancestor and its descendant, or two paths
|
|
42
|
+
// that resolve to the same place through a symlink. Record-level dedup only
|
|
43
|
+
// covers entries carrying an `id`, so the same anonymous record would be
|
|
44
|
+
// counted once per path that reaches it. Collapse on the canonical file path
|
|
45
|
+
// instead, which also folds symlinked duplicates of a single file.
|
|
46
|
+
function canonicalFilePath(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
return realpathSync.native(filePath);
|
|
49
|
+
} catch {
|
|
50
|
+
return filePath;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
41
54
|
export async function parsePiSessionJsonl({
|
|
42
55
|
source,
|
|
43
56
|
sessionsDirs,
|
|
@@ -49,9 +62,14 @@ export async function parsePiSessionJsonl({
|
|
|
49
62
|
const anonymousEntries = [];
|
|
50
63
|
const eventsById = new Map();
|
|
51
64
|
const anonymousEvents = [];
|
|
65
|
+
const seenFiles = new Set();
|
|
52
66
|
|
|
53
67
|
for (const sessionsDir of sessionsDirs) {
|
|
54
68
|
for (const filePath of findJsonlFiles(sessionsDir, includeFile, ctx)) {
|
|
69
|
+
const canonical = canonicalFilePath(filePath);
|
|
70
|
+
if (seenFiles.has(canonical)) continue;
|
|
71
|
+
seenFiles.add(canonical);
|
|
72
|
+
|
|
55
73
|
let content;
|
|
56
74
|
try {
|
|
57
75
|
content = readFileSync(filePath, 'utf8');
|
package/src/pi-roots.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import { delimiter, isAbsolute, join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
+
import { piSessionsDir } from './extra-roots.js';
|
|
4
5
|
|
|
5
6
|
function expandHome(value) {
|
|
6
7
|
const trimmed = value.trim();
|
|
@@ -60,22 +61,30 @@ function settingsSessionDir(agentDir) {
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
export function getPiSessionDirs() {
|
|
64
|
+
export function getPiSessionDirs(extraRoots = []) {
|
|
65
|
+
// Explicitly configured roots are user intent, not a fixture: they are always
|
|
66
|
+
// scanned, and they never replace the default store. A root whose shape no
|
|
67
|
+
// longer resolves drops out here; the parser reports that as skipped.
|
|
68
|
+
const extraDirs = extraRoots.map(piSessionsDir).filter(dir => dir !== null);
|
|
69
|
+
|
|
64
70
|
const override = process.env.VIBE_USAGE_PI_SESSION_DIRS?.trim();
|
|
65
|
-
if (override) return uniqueExistingDirs(override.split(delimiter));
|
|
71
|
+
if (override) return uniqueExistingDirs([...override.split(delimiter), ...extraDirs]);
|
|
66
72
|
|
|
67
73
|
const envAgentDir = process.env.PI_CODING_AGENT_DIR?.trim();
|
|
68
74
|
const agentDir = envAgentDir ? expandHome(envAgentDir) : join(homedir(), '.pi', 'agent');
|
|
69
75
|
// OMP inherits PI_CODING_AGENT_DIR from Pi. Do not parse an identifiable
|
|
70
76
|
// OMP store again as source=pi-coding-agent.
|
|
71
|
-
|
|
77
|
+
const isOmpStore = Boolean(envAgentDir) && looksLikeOmpAgentDir(agentDir);
|
|
72
78
|
|
|
73
|
-
const dirs = [
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
+
const dirs = [];
|
|
80
|
+
if (!isOmpStore) {
|
|
81
|
+
dirs.push(join(agentDir, 'sessions'));
|
|
82
|
+
const envSessionDir = process.env.PI_CODING_AGENT_SESSION_DIR?.trim();
|
|
83
|
+
if (envSessionDir) dirs.push(expandHome(envSessionDir));
|
|
84
|
+
const configured = settingsSessionDir(agentDir);
|
|
85
|
+
if (configured) dirs.push(configured);
|
|
86
|
+
}
|
|
87
|
+
return uniqueExistingDirs([...dirs, ...extraDirs]);
|
|
79
88
|
}
|
|
80
89
|
|
|
81
90
|
export function getOmpSessionDirs() {
|
package/src/tools.js
CHANGED
|
@@ -320,7 +320,9 @@ export const TOOLS = [
|
|
|
320
320
|
name: 'pi',
|
|
321
321
|
id: 'pi-coding-agent',
|
|
322
322
|
dataDir: join(homedir(), '.pi', 'agent', 'sessions'),
|
|
323
|
-
detectDataDirs:
|
|
323
|
+
detectDataDirs: ({ extraRoots } = {}) => (
|
|
324
|
+
findPiDataDirs(extraRootList(extraRoots?.['pi-coding-agent']))
|
|
325
|
+
),
|
|
324
326
|
},
|
|
325
327
|
{
|
|
326
328
|
name: 'Qwen Code',
|