@vibe-cafe/vibe-usage 0.10.19 → 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 +33 -6
- package/package.json +1 -1
- package/src/daemon-service.js +347 -7
- package/src/extra-roots.js +312 -0
- package/src/index.js +70 -3
- package/src/init.js +6 -2
- package/src/parsers/antigravity-db.js +9 -8
- package/src/parsers/antigravity.js +88 -11
- package/src/parsers/codex.js +62 -11
- package/src/parsers/cursor.js +21 -2
- package/src/parsers/grok.js +102 -18
- package/src/parsers/index.js +2 -0
- package/src/parsers/mcode.js +182 -0
- package/src/parsers/pi-coding-agent.js +17 -2
- package/src/parsers/pi-session-jsonl.js +23 -2
- package/src/pi-roots.js +43 -11
- package/src/sync.js +14 -3
- package/src/tools.js +46 -12
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import { accessSync, closeSync, constants, openSync, readSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { basename, join, resolve } from 'node:path';
|
|
4
|
+
import { codexSessionDirs } from './codex-roots.js';
|
|
5
|
+
|
|
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';
|
|
15
|
+
|
|
16
|
+
export function extraRootList(value) {
|
|
17
|
+
return Array.isArray(value) ? value.filter(root => typeof root === 'string' && root.trim()) : [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function normalizeExtraRoot(value) {
|
|
21
|
+
const trimmed = value.trim();
|
|
22
|
+
if (trimmed === '~') return homedir();
|
|
23
|
+
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
|
24
|
+
return resolve(homedir(), trimmed.slice(2));
|
|
25
|
+
}
|
|
26
|
+
return resolve(trimmed);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isReadableDirectory(path) {
|
|
30
|
+
try {
|
|
31
|
+
if (!statSync(path).isDirectory()) return false;
|
|
32
|
+
accessSync(path, constants.R_OK);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isCodexHome(path) {
|
|
40
|
+
return isReadableDirectory(path) && codexSessionDirs(path).some(isReadableDirectory);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Multica stores task-local Codex homes below a bounded
|
|
44
|
+
// <container>/<workspace>/<task>/codex-home hierarchy. Do not follow symlinks
|
|
45
|
+
// or descend beyond that shape: configured containers may also contain large
|
|
46
|
+
// workdirs that are unrelated to usage logs.
|
|
47
|
+
export function discoverCodexHomes(value, maxDepth = 3) {
|
|
48
|
+
const root = normalizeExtraRoot(value);
|
|
49
|
+
if (isCodexHome(root)) return { root, homes: [root], readable: true };
|
|
50
|
+
if (!isReadableDirectory(root)) return { root, homes: [], readable: false };
|
|
51
|
+
|
|
52
|
+
const homes = [];
|
|
53
|
+
const queue = [{ path: root, depth: 0 }];
|
|
54
|
+
let readable = true;
|
|
55
|
+
while (queue.length > 0) {
|
|
56
|
+
const current = queue.shift();
|
|
57
|
+
let children;
|
|
58
|
+
try {
|
|
59
|
+
children = readdirSync(current.path, { withFileTypes: true });
|
|
60
|
+
} catch {
|
|
61
|
+
readable = false;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
for (const child of children) {
|
|
65
|
+
// Dirent#isDirectory is false for symbolic links, so traversal stays
|
|
66
|
+
// inside the explicitly selected tree.
|
|
67
|
+
if (!child.isDirectory()) continue;
|
|
68
|
+
const childPath = join(current.path, child.name);
|
|
69
|
+
const depth = current.depth + 1;
|
|
70
|
+
if (basename(childPath) === 'codex-home' && isCodexHome(childPath)) {
|
|
71
|
+
homes.push(childPath);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (depth < maxDepth) queue.push({ path: childPath, depth });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return { root, homes: [...new Set(homes)], readable };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function grokSessionsDir(value) {
|
|
81
|
+
return join(normalizeExtraRoot(value), 'sessions');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function antigravityConversationDirs(value) {
|
|
85
|
+
const root = normalizeExtraRoot(value);
|
|
86
|
+
return [
|
|
87
|
+
join(root, '.gemini', 'antigravity', 'conversations'),
|
|
88
|
+
join(root, '.gemini', 'antigravity-cli', 'conversations'),
|
|
89
|
+
];
|
|
90
|
+
}
|
|
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
|
+
|
|
280
|
+
export function validateExtraRoot(source, value) {
|
|
281
|
+
if (!EXTRA_ROOT_SOURCES.includes(source)) {
|
|
282
|
+
return { ok: false, path: value, reason: `不支持的工具: ${source}` };
|
|
283
|
+
}
|
|
284
|
+
const path = normalizeExtraRoot(value);
|
|
285
|
+
if (source === 'codex') {
|
|
286
|
+
const result = discoverCodexHomes(path);
|
|
287
|
+
return {
|
|
288
|
+
ok: result.readable && result.homes.length > 0,
|
|
289
|
+
path,
|
|
290
|
+
reason: '需要是 Codex Home,或包含 */*/codex-home 的 Multica 容器',
|
|
291
|
+
};
|
|
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
|
+
}
|
|
302
|
+
const dirs = source === 'grok'
|
|
303
|
+
? [grokSessionsDir(path)]
|
|
304
|
+
: antigravityConversationDirs(path);
|
|
305
|
+
return {
|
|
306
|
+
ok: dirs.some(isReadableDirectory),
|
|
307
|
+
path,
|
|
308
|
+
reason: source === 'grok'
|
|
309
|
+
? '需要包含 sessions/'
|
|
310
|
+
: '需要包含 .gemini/antigravity*/conversations/',
|
|
311
|
+
};
|
|
312
|
+
}
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,12 @@ import { loadConfig, saveConfig, getConfigPath } from './config.js';
|
|
|
2
2
|
import { detectInstalledTools, TOOLS } from './tools.js';
|
|
3
3
|
import { existsSync } from 'node:fs';
|
|
4
4
|
import { validateExtraCodexHome } from './codex-roots.js';
|
|
5
|
+
import {
|
|
6
|
+
EXTRA_ROOT_SOURCES,
|
|
7
|
+
extraRootList,
|
|
8
|
+
normalizeExtraRoot,
|
|
9
|
+
validateExtraRoot,
|
|
10
|
+
} from './extra-roots.js';
|
|
5
11
|
import { failure, smallHeader } from './output.js';
|
|
6
12
|
|
|
7
13
|
function printSmallHeader() {
|
|
@@ -24,10 +30,18 @@ async function showStatus() {
|
|
|
24
30
|
if (config.codexExtraHome) {
|
|
25
31
|
console.log(` Extra Codex Home: ${config.codexExtraHome}`);
|
|
26
32
|
}
|
|
33
|
+
for (const source of EXTRA_ROOT_SOURCES) {
|
|
34
|
+
for (const root of extraRootList(config.extraRoots?.[source])) {
|
|
35
|
+
console.log(` Extra ${source} Root: ${root}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
27
38
|
}
|
|
28
39
|
|
|
29
40
|
console.log('\n Detected tools:');
|
|
30
|
-
const toolOptions = {
|
|
41
|
+
const toolOptions = {
|
|
42
|
+
codexExtraHome: config?.codexExtraHome,
|
|
43
|
+
extraRoots: config?.extraRoots,
|
|
44
|
+
};
|
|
31
45
|
const detected = detectInstalledTools(toolOptions);
|
|
32
46
|
if (detected.length === 0) {
|
|
33
47
|
console.log(' (none)\n');
|
|
@@ -103,9 +117,59 @@ function handleConfig(args) {
|
|
|
103
117
|
}
|
|
104
118
|
break;
|
|
105
119
|
}
|
|
120
|
+
case 'add-root': {
|
|
121
|
+
const source = args[1];
|
|
122
|
+
const value = args[2];
|
|
123
|
+
if (!source || value === undefined) {
|
|
124
|
+
console.error(`Usage: vibe-usage config add-root <${EXTRA_ROOT_SOURCES.join('|')}> <path>`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
const validation = validateExtraRoot(source, value);
|
|
128
|
+
if (!validation.ok) {
|
|
129
|
+
console.error(failure(`额外 ${source} 根目录无效(${validation.reason}): ${validation.path}`));
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
const config = loadConfig() || {};
|
|
133
|
+
if (!config.extraRoots || typeof config.extraRoots !== 'object' || Array.isArray(config.extraRoots)) {
|
|
134
|
+
config.extraRoots = {};
|
|
135
|
+
}
|
|
136
|
+
const roots = extraRootList(config.extraRoots[source]);
|
|
137
|
+
config.extraRoots[source] = [...new Set([...roots, validation.path])];
|
|
138
|
+
saveConfig(config);
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
case 'remove-root': {
|
|
142
|
+
const source = args[1];
|
|
143
|
+
const value = args[2];
|
|
144
|
+
if (!EXTRA_ROOT_SOURCES.includes(source) || value === undefined) {
|
|
145
|
+
console.error(`Usage: vibe-usage config remove-root <${EXTRA_ROOT_SOURCES.join('|')}> <path>`);
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
const config = loadConfig() || {};
|
|
149
|
+
const path = normalizeExtraRoot(value);
|
|
150
|
+
const roots = extraRootList(config.extraRoots?.[source])
|
|
151
|
+
.filter(root => normalizeExtraRoot(root) !== path);
|
|
152
|
+
if (config.extraRoots && typeof config.extraRoots === 'object' && !Array.isArray(config.extraRoots)) {
|
|
153
|
+
if (roots.length > 0) config.extraRoots[source] = roots;
|
|
154
|
+
else delete config.extraRoots[source];
|
|
155
|
+
if (Object.keys(config.extraRoots).length === 0) delete config.extraRoots;
|
|
156
|
+
}
|
|
157
|
+
saveConfig(config);
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
case 'roots': {
|
|
161
|
+
const config = loadConfig();
|
|
162
|
+
const roots = config?.extraRoots;
|
|
163
|
+
console.log(JSON.stringify(
|
|
164
|
+
roots && typeof roots === 'object' && !Array.isArray(roots) ? roots : {},
|
|
165
|
+
null,
|
|
166
|
+
2,
|
|
167
|
+
));
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
106
170
|
default:
|
|
107
171
|
console.error(`Unknown config subcommand: ${sub || '(none)'}`);
|
|
108
|
-
console.error('Usage: vibe-usage config <get|set|show>');
|
|
172
|
+
console.error('Usage: vibe-usage config <get|set|show|add-root|remove-root|roots>');
|
|
109
173
|
process.exit(1);
|
|
110
174
|
}
|
|
111
175
|
}
|
|
@@ -219,7 +283,7 @@ export async function run(rawArgs) {
|
|
|
219
283
|
npx @vibe-cafe/vibe-usage summary Print last 7 days as markdown (cost/tokens/model/project)
|
|
220
284
|
npx @vibe-cafe/vibe-usage summary --days N Same, but over the last N days (1-90)
|
|
221
285
|
npx @vibe-cafe/vibe-usage daemon Continuous sync (every 30m, foreground)
|
|
222
|
-
npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd)
|
|
286
|
+
npx @vibe-cafe/vibe-usage daemon install Install background service (systemd/launchd/Task Scheduler)
|
|
223
287
|
npx @vibe-cafe/vibe-usage daemon uninstall Remove background service
|
|
224
288
|
npx @vibe-cafe/vibe-usage daemon status Show background service status
|
|
225
289
|
npx @vibe-cafe/vibe-usage daemon stop Stop background service
|
|
@@ -233,6 +297,9 @@ export async function run(rawArgs) {
|
|
|
233
297
|
npx @vibe-cafe/vibe-usage config get <key> Get a config value
|
|
234
298
|
npx @vibe-cafe/vibe-usage config set <key> <value> Set a config value
|
|
235
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, Antigravity, or Pi data root
|
|
301
|
+
npx @vibe-cafe/vibe-usage config remove-root <tool> <path> Remove an added data root
|
|
302
|
+
npx @vibe-cafe/vibe-usage config roots Show added data roots as JSON
|
|
236
303
|
npx @vibe-cafe/vibe-usage help Show this help
|
|
237
304
|
`);
|
|
238
305
|
break;
|
package/src/init.js
CHANGED
|
@@ -27,7 +27,7 @@ function openBrowser(url) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
function isDaemonPlatform() {
|
|
30
|
-
return process.platform === 'linux' || process.platform === 'darwin';
|
|
30
|
+
return process.platform === 'linux' || process.platform === 'darwin' || process.platform === 'win32';
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
export async function runInit(options = {}) {
|
|
@@ -81,10 +81,14 @@ export async function runInit(options = {}) {
|
|
|
81
81
|
apiUrl,
|
|
82
82
|
hostname: host,
|
|
83
83
|
...(existing?.codexExtraHome ? { codexExtraHome: existing.codexExtraHome } : {}),
|
|
84
|
+
...(existing?.extraRoots ? { extraRoots: existing.extraRoots } : {}),
|
|
84
85
|
};
|
|
85
86
|
saveConfig(config);
|
|
86
87
|
|
|
87
|
-
const tools = detectInstalledTools({
|
|
88
|
+
const tools = detectInstalledTools({
|
|
89
|
+
codexExtraHome: config.codexExtraHome,
|
|
90
|
+
extraRoots: config.extraRoots,
|
|
91
|
+
});
|
|
88
92
|
if (tools.length > 0) {
|
|
89
93
|
console.log(success(`检测到 ${tools.length} 款工具: ${dim(tools.map(t => t.name).join(' · '))}`));
|
|
90
94
|
} else {
|
|
@@ -200,14 +200,15 @@ function queryCascadeDb(conversationsDir, cascadeId, sql) {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
/** List cascade IDs backed by a `.db` file in a conversations directory. */
|
|
203
|
-
export function listDbCascades(conversationsDir) {
|
|
203
|
+
export function listDbCascades(conversationsDir, { strict = false } = {}) {
|
|
204
204
|
try {
|
|
205
205
|
const out = [];
|
|
206
206
|
for (const f of readdirSync(conversationsDir)) {
|
|
207
207
|
if (f.endsWith('.db') && f !== 'db.sqlite') out.push(f.slice(0, -3));
|
|
208
208
|
}
|
|
209
209
|
return out;
|
|
210
|
-
} catch {
|
|
210
|
+
} catch (err) {
|
|
211
|
+
if (strict) throw err;
|
|
211
212
|
return [];
|
|
212
213
|
}
|
|
213
214
|
}
|
|
@@ -217,12 +218,12 @@ export function listDbCascades(conversationsDir) {
|
|
|
217
218
|
* records. blob is fetched as hex text so it round-trips through both the
|
|
218
219
|
* node:sqlite and sqlite3-CLI backends uniformly.
|
|
219
220
|
*/
|
|
220
|
-
export function readDbUsageRecords(conversationsDir, cascadeId) {
|
|
221
|
+
export function readDbUsageRecords(conversationsDir, cascadeId, { strict = false } = {}) {
|
|
221
222
|
let rows;
|
|
222
223
|
try {
|
|
223
224
|
rows = queryCascadeDb(conversationsDir, cascadeId, 'SELECT idx, hex(data) AS h FROM gen_metadata ORDER BY idx');
|
|
224
225
|
} catch (err) {
|
|
225
|
-
if (isSqliteUnavailableError(err)) throw err;
|
|
226
|
+
if (isSqliteUnavailableError(err) || strict) throw err;
|
|
226
227
|
return [];
|
|
227
228
|
}
|
|
228
229
|
const records = [];
|
|
@@ -247,7 +248,7 @@ export function readDbUsageRecords(conversationsDir, cascadeId) {
|
|
|
247
248
|
* system/tool steps that parseStepMetadata skips. Used to timestamp 3.7
|
|
248
249
|
* gen_metadata rows that no longer embed chatStartMetadata.
|
|
249
250
|
*/
|
|
250
|
-
export function readDbStepTimestamps(conversationsDir, cascadeId) {
|
|
251
|
+
export function readDbStepTimestamps(conversationsDir, cascadeId, { strict = false } = {}) {
|
|
251
252
|
let rows;
|
|
252
253
|
try {
|
|
253
254
|
rows = queryCascadeDb(
|
|
@@ -256,7 +257,7 @@ export function readDbStepTimestamps(conversationsDir, cascadeId) {
|
|
|
256
257
|
'SELECT idx, hex(metadata) AS h FROM steps WHERE metadata IS NOT NULL ORDER BY idx',
|
|
257
258
|
);
|
|
258
259
|
} catch (err) {
|
|
259
|
-
if (isSqliteUnavailableError(err)) throw err;
|
|
260
|
+
if (isSqliteUnavailableError(err) || strict) throw err;
|
|
260
261
|
return new Map();
|
|
261
262
|
}
|
|
262
263
|
const byIdx = new Map();
|
|
@@ -331,7 +332,7 @@ export function parseStepMetadata(buf) {
|
|
|
331
332
|
* Read session timing events (user/assistant turns) for a cascade from the
|
|
332
333
|
* steps table, chronological by idx.
|
|
333
334
|
*/
|
|
334
|
-
export function readDbSessionEvents(conversationsDir, cascadeId) {
|
|
335
|
+
export function readDbSessionEvents(conversationsDir, cascadeId, { strict = false } = {}) {
|
|
335
336
|
let rows;
|
|
336
337
|
try {
|
|
337
338
|
rows = queryCascadeDb(
|
|
@@ -340,7 +341,7 @@ export function readDbSessionEvents(conversationsDir, cascadeId) {
|
|
|
340
341
|
'SELECT hex(metadata) AS h FROM steps WHERE metadata IS NOT NULL ORDER BY idx',
|
|
341
342
|
);
|
|
342
343
|
} catch (err) {
|
|
343
|
-
if (isSqliteUnavailableError(err)) throw err;
|
|
344
|
+
if (isSqliteUnavailableError(err) || strict) throw err;
|
|
344
345
|
return [];
|
|
345
346
|
}
|
|
346
347
|
const events = [];
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { execSync } from 'node:child_process';
|
|
2
|
-
import { readdirSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
2
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
3
|
+
import { delimiter, join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
+
import { antigravityConversationDirs, normalizeExtraRoot } from '../extra-roots.js';
|
|
5
6
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
6
7
|
import { listDbCascades, readDbUsageRecords, readDbWorkspaceUri, readDbSessionEvents, readDbStepTimestamps, resolveUsageTimestamp } from './antigravity-db.js';
|
|
7
8
|
|
|
@@ -295,10 +296,10 @@ function projectFromUri(uri) {
|
|
|
295
296
|
* List cascade IDs backed by a legacy `.pb` file (App history). `.db` cascades
|
|
296
297
|
* are handled separately via offline parsing.
|
|
297
298
|
*/
|
|
298
|
-
function listPbCascades() {
|
|
299
|
+
function listPbCascades(conversationsDir = CONVERSATIONS_DIR) {
|
|
299
300
|
try {
|
|
300
301
|
const out = [];
|
|
301
|
-
for (const f of readdirSync(
|
|
302
|
+
for (const f of readdirSync(conversationsDir)) {
|
|
302
303
|
if (f.endsWith('.pb')) out.push(f.slice(0, -3));
|
|
303
304
|
}
|
|
304
305
|
return out;
|
|
@@ -316,19 +317,88 @@ function modelFromRecord(rec) {
|
|
|
316
317
|
return 'unknown';
|
|
317
318
|
}
|
|
318
319
|
|
|
319
|
-
export async function parse() {
|
|
320
|
+
export async function parse({ extraRoots = [] } = {}) {
|
|
320
321
|
const entries = [];
|
|
321
322
|
const sessionEvents = [];
|
|
322
323
|
const seenResponseIds = new Set();
|
|
323
324
|
|
|
325
|
+
const extraDirs = [];
|
|
326
|
+
for (const root of extraRoots) {
|
|
327
|
+
const dirs = antigravityConversationDirs(root);
|
|
328
|
+
let found = false;
|
|
329
|
+
for (const dir of dirs) {
|
|
330
|
+
try {
|
|
331
|
+
readdirSync(dir);
|
|
332
|
+
extraDirs.push(dir);
|
|
333
|
+
found = true;
|
|
334
|
+
} catch (err) {
|
|
335
|
+
if (err?.code === 'ENOENT') continue;
|
|
336
|
+
return {
|
|
337
|
+
buckets: [],
|
|
338
|
+
sessions: [],
|
|
339
|
+
skipped: true,
|
|
340
|
+
warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${normalizeExtraRoot(root)}`],
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (!found) {
|
|
345
|
+
return {
|
|
346
|
+
buckets: [],
|
|
347
|
+
sessions: [],
|
|
348
|
+
skipped: true,
|
|
349
|
+
warnings: [`antigravity: 额外根目录不可用,已跳过本次 Antigravity 同步: ${normalizeExtraRoot(root)}`],
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
324
354
|
// ── Path 1: offline .db parsing (App 2.0 + agy CLI, no process needed) ──
|
|
325
355
|
const dbHandled = new Set();
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
356
|
+
const fixtureDirs = process.env.VIBE_USAGE_ANTIGRAVITY_DIRS?.trim();
|
|
357
|
+
const defaultDirs = fixtureDirs
|
|
358
|
+
? fixtureDirs.split(delimiter).filter(Boolean)
|
|
359
|
+
: [CONVERSATIONS_DIR, CLI_CONVERSATIONS_DIR];
|
|
360
|
+
const strictDirs = new Set(extraDirs);
|
|
361
|
+
const conversationDirs = [...new Set([...defaultDirs, ...extraDirs])];
|
|
362
|
+
const candidates = [];
|
|
363
|
+
for (const dir of conversationDirs) {
|
|
364
|
+
const strict = strictDirs.has(dir);
|
|
365
|
+
try {
|
|
366
|
+
for (const cascadeId of listDbCascades(dir, { strict })) {
|
|
367
|
+
candidates.push({ dir, cascadeId, strict });
|
|
368
|
+
}
|
|
369
|
+
} catch {
|
|
370
|
+
return {
|
|
371
|
+
buckets: [], sessions: [], skipped: true,
|
|
372
|
+
warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${dir}`],
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const configuredCascadeIds = new Set(
|
|
377
|
+
candidates.filter(candidate => candidate.strict).map(candidate => candidate.cascadeId),
|
|
378
|
+
);
|
|
379
|
+
const selectedConfiguredCopies = new Map();
|
|
380
|
+
for (const candidate of candidates) {
|
|
381
|
+
if (!configuredCascadeIds.has(candidate.cascadeId)) continue;
|
|
382
|
+
let size = 0;
|
|
383
|
+
try {
|
|
384
|
+
size = statSync(join(candidate.dir, `${candidate.cascadeId}.db`)).size;
|
|
385
|
+
} catch {
|
|
386
|
+
// The DB may move between discovery and stat; the read below will fail
|
|
387
|
+
// open in the existing offline reader.
|
|
388
|
+
}
|
|
389
|
+
const previous = selectedConfiguredCopies.get(candidate.cascadeId);
|
|
390
|
+
if (!previous || size > previous.size) selectedConfiguredCopies.set(candidate.cascadeId, { ...candidate, size });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
for (const { dir, cascadeId, strict } of candidates) {
|
|
394
|
+
const selected = selectedConfiguredCopies.get(cascadeId);
|
|
395
|
+
if (selected && selected.dir !== dir) continue;
|
|
396
|
+
try {
|
|
397
|
+
const options = { strict };
|
|
398
|
+
const records = readDbUsageRecords(dir, cascadeId, options);
|
|
329
399
|
const project = projectFromUri(readDbWorkspaceUri(dir, cascadeId)) || 'unknown';
|
|
330
400
|
const stepTimestampsByIdx = records.some((rec) => !rec.timestamp || isNaN(rec.timestamp.getTime()))
|
|
331
|
-
? readDbStepTimestamps(dir, cascadeId)
|
|
401
|
+
? readDbStepTimestamps(dir, cascadeId, options)
|
|
332
402
|
: new Map();
|
|
333
403
|
|
|
334
404
|
if (records.length > 0) {
|
|
@@ -355,7 +425,7 @@ export async function parse() {
|
|
|
355
425
|
}
|
|
356
426
|
|
|
357
427
|
// Session timing from steps (independent of token usage presence).
|
|
358
|
-
for (const ev of readDbSessionEvents(dir, cascadeId)) {
|
|
428
|
+
for (const ev of readDbSessionEvents(dir, cascadeId, options)) {
|
|
359
429
|
sessionEvents.push({
|
|
360
430
|
sessionId: cascadeId,
|
|
361
431
|
source: SOURCE,
|
|
@@ -364,11 +434,18 @@ export async function parse() {
|
|
|
364
434
|
role: ev.role,
|
|
365
435
|
});
|
|
366
436
|
}
|
|
437
|
+
} catch (err) {
|
|
438
|
+
if (!strict) throw err;
|
|
439
|
+
return {
|
|
440
|
+
buckets: [], sessions: [], skipped: true,
|
|
441
|
+
warnings: [`antigravity: 额外根目录读取失败,已保留上次同步数据: ${dir}`],
|
|
442
|
+
};
|
|
367
443
|
}
|
|
368
444
|
}
|
|
369
445
|
|
|
370
446
|
// ── Path 2: RPC fallback, only for legacy .pb cascades not already parsed ──
|
|
371
|
-
const
|
|
447
|
+
const pbDir = defaultDirs[0] || CONVERSATIONS_DIR;
|
|
448
|
+
const pbCascades = listPbCascades(pbDir).filter((id) => !dbHandled.has(id));
|
|
372
449
|
if (pbCascades.length > 0) {
|
|
373
450
|
const server = findLanguageServer();
|
|
374
451
|
const ports = server ? findListeningPorts(server.pid) : [];
|