@vibe-cafe/vibe-usage 0.13.2 → 0.14.0
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 +32 -2
- package/package.json +1 -1
- package/src/daemon-service.js +1 -0
- package/src/extra-roots.js +12 -1
- package/src/hermes-roots.js +62 -8
- package/src/index.js +28 -2
- package/src/kiki-migration.js +109 -0
- package/src/kiki-roots.js +30 -0
- package/src/parsers/hermes.js +89 -10
- package/src/parsers/index.js +2 -0
- package/src/parsers/kiki.js +29 -0
- package/src/parsers/kimi-code.js +88 -23
- package/src/sync.js +51 -5
- package/src/tools.js +11 -3
package/README.md
CHANGED
|
@@ -92,13 +92,14 @@ Quota results are never uploaded or added to incremental sync state. Credential-
|
|
|
92
92
|
| 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 |
|
|
93
93
|
| 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 |
|
|
94
94
|
| Qwen Code | `~/.qwen/tmp/` |
|
|
95
|
+
| Kiki | `$KIKI_HOME/sessions/` (default `~/.kiki/sessions/`; fixture override: `VIBE_USAGE_KIKI_DIR` sets the home). Reports **`kiki` / Kiki**, independently of Kimi Code, using the shared per-agent `usage.record` reader. Main/subagent deltas, retries and compaction are counted; cache creation joins input because no TTL is recorded, while cache reads stay separate. Exact copied records for the same session/agent count once, without collapsing repeated calls inside a wire. Known routing prefixes in `KIKI_MODEL_PREFIXES` are removed; other slash-bearing model ids are preserved. No estimated cache ratios or source-data rewrites. |
|
|
95
96
|
| Kimi Code | Current `<home>/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), with project names from `session_index.jsonl`. Every home with that layout is scanned: the CLI home (`$KIMI_CODE_HOME`, else `~/.kimi-code`) plus the Kimi Work desktop app's embedded runtime home (`<Electron userData>/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home`), which the app never writes into the CLI home; legacy `~/.kimi/sessions/` is parsed alongside (`kimi migrate` never carries usage over, so both stores are always merged) |
|
|
96
97
|
| MiniMax Code (mcode) | `$MCODE_HOME/v2/sqlite/runtime-state.sqlite` (default `~/.minimax/v2/sqlite/runtime-state.sqlite`; also reads the roots the CLI relocates to — `$MINIMAX_DATA_DIR` / `$MAVIS_DATA_DIR`, each `~/.minimax-<profile>`, the pre-npm `~/.minimax-code` user-data dir and the pre-rename `~/.mavis` home — and merges them, dropping rows a store only holds as a copy; 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 (a broken extra store is reported as a warning instead of blanking the live one). |
|
|
97
98
|
| MiMoCode | `$MIMOCODE_HOME/data/mimocode.db`, `$XDG_DATA_HOME/mimocode/mimocode.db`, or `~/.local/share/mimocode/mimocode.db` (SQLite; exact input, output, reasoning, and cache-read tokens from assistant messages; honors `MIMOCODE_DB`; cache-write tokens are included in input usage) |
|
|
98
99
|
| Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
|
|
99
100
|
| Droid | `~/.factory/sessions/` |
|
|
100
101
|
| DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads V0–V4 logs, including `session.v4.jsonl.zstd` from DSH's V4 integration format, with multi-frame Zstandard support (Node ≥ 22.15 built-in, `zstd` CLI fallback) and plain JSONL support. Each session uses its highest `session[.vN].jsonl[.zstd]` generation once, so frozen pre-migration logs are not double-counted. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork history uses V0/V1 `seedLength` or V2–V4's last `session/end-seed` tagged `inherited: true`, and is skipped only when the parent copy confirms it; missing parents retain the sole local history. Unknown versions warn and protect sync state. |
|
|
101
|
-
| Hermes (CLI / Desktop) | `<home>/state.db` + `<home>/profiles/<name>/state.db` (SQLite, multi-profile). Home: `$HERMES_HOME`, otherwise `~/.hermes` on macOS/Linux or `%LOCALAPPDATA%\hermes` on Windows (falls back to an existing `~/.hermes` only when the Windows native root is absent). Cache writes join input; reasoning is separated from inclusive output. Usage is currently a cumulative session total attributed to session start: a session spanning several days does **not** yet provide an accurate daily breakdown. |
|
|
102
|
+
| Hermes (CLI / Desktop) | `<home>/state.db` + `<home>/profiles/<name>/state.db` (SQLite, multi-profile). Home: `$HERMES_HOME`, otherwise `~/.hermes` on macOS/Linux or `%LOCALAPPDATA%\hermes` on Windows (falls back to an existing `~/.hermes` only when the Windows native root is absent). Additional homes: `config add-root hermes <path>` (a directory containing `state.db` or `profiles/*/state.db`), scanned alongside the default and deduped. Cache writes join input; reasoning is separated from inclusive output. Usage is currently a cumulative session total attributed to session start: a session spanning several days does **not** yet provide an accurate daily breakdown. |
|
|
102
103
|
| Kiro | Kiro CLI native event streams `~/.kiro/sessions/cli/*.jsonl` (estimated tokens from message text: input = prompt + tool results, output = reply + tool calls, reasoning = thinking, cacheRead = re-sent context; thinking-block signatures excluded). Falls back to `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` + optional `~/.kiro_sessions/*.json` archives, then IDE `q-client.log` whole-credit deltas as `kiro-credits` (floored cumulative diff — the server stores token counts as bigint); legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
|
|
103
104
|
| Cline (CLI / SDK / Desktop app) | All variants write the same `~/.cline/data/sessions/*/*.messages.json` per-call metrics (the desktop app's manifest says `source: "desktop"`; its Electron userData holds only WebView caches), plus legacy `~/.cline/{,data/}state/taskHistory.json` and editor extension stores. Honors `CLINE_DIR`, `CLINE_DATA_DIR`, and `CLINE_SESSION_DATA_DIR`; copied history is deduplicated |
|
|
104
105
|
| Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
|
|
@@ -111,6 +112,30 @@ Quota results are never uploaded or added to incremental sync state. Credential-
|
|
|
111
112
|
| Qoder CN | China edition (qoder.com.cn, separate account). Same two shapes under `~/Library/Application Support/QoderCN/SharedClientCache/cache/db/local.db` (`QODER_CN_HOME` / `VIBE_USAGE_QODER_CN_DB`) and `~/.qoder-cn/projects/` (`QODERCN_CONFIG_DIR` / `VIBE_USAGE_QODER_CN_PROJECTS`); reported as source `qoder-cn` |
|
|
112
113
|
| Devin | `$XDG_DATA_HOME/devin/cli/sessions.db` (default `~/.local/share/devin/cli/sessions.db`; fixture override `VIBE_USAGE_DEVIN_DB`). Devin CLI and Devin Desktop share this one WAL store. Reads only allow-listed fields from `message_nodes.chat_message` via `json_extract` — per-request `metadata.metrics` token counters on assistant messages, `generation_model`, `is_user_input`, timestamps — joined to `sessions.working_directory`/`model`; message content and the session credit/ACU billing fields are never selected. Cache writes fold into input, cache reads stay separate. The node forest stores some logical messages at several nodes, so rows are deduplicated by session + message id. Synthetic user records (cache keepalives) are not counted as human prompts |
|
|
113
114
|
|
|
115
|
+
### Kiki setup and compatibility-collector migration
|
|
116
|
+
|
|
117
|
+
For a relocated Kiki home, set `KIKI_HOME` before installing the background service; reinstall an existing service to capture a changed home. A home already collected by Kimi Code is not collected again as Kiki. Server deployment must register `kiki` with display name **Kiki** before this support is published; an unregistered source is reported in `dropped.unknownSources`, not retried under `kimi-code`. This collects token usage, not a Kiki subscription balance.
|
|
118
|
+
|
|
119
|
+
If you used a compatibility collector that uploaded Kiki as `kimi-code`, **keep its `state.json`, hostname and project-upload setting**. On the first independent Kiki upload, matching legacy bucket/session keys pause Kiki only; other tools continue and overlapping old Kimi buckets are not overwritten with smaller Kimi-only totals. The legacy match recognises both the CLI's own bucket coordinates and the published collector's identity: Kiki's `kap-server` export (`vibe-kimi-bucket-v1`) uploaded source `kimi-code` with `project: 'unknown'` and `hostname: kiki-<stream_id>`, which never equals the session project and machine hostname this CLI writes, so a row with the same model and half-hour window but a different project/hostname and a different recorded hash also triggers the guard. An unchanged Kimi-only bucket does not trigger it. An unrelated Kimi history does not trigger it. Missing state or changed identity cannot prove whether cloud history overlaps; resolve that with the maintainer before importing history.
|
|
120
|
+
|
|
121
|
+
Choose an explicit UTC half-hour cut **T** with no compatibility uploads at or after T. Finish and verify the old collector's coverage below T, stop that collector, then configure the new CLI before its first sync:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
npx @vibe-cafe/vibe-usage config set kikiStartAt 2026-10-05T12:30:00Z # replace with your verified cut
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
A cut (or the guard) withholds real Kiki history and freezes the overlapping old `kimi-code` rows, so those server cells stop following local growth. `sync` reports this on stderr even in quiet/daemon runs: the withheld bucket/session counts and time range, the frozen row counts, and both remedies. If the cut overlaps recorded legacy buckets, Kiki stays paused; set a later verified cut. Kiki tokens at/after T are sent as `kiki`; old mixed buckets below T remain untouched under `kimi-code`. Sessions starting before T retain their old timing identity, even if their later tokens are in new Kiki buckets.
|
|
128
|
+
|
|
129
|
+
Once independent Kiki uploads are recorded, the cut cannot be changed to a *different* time by `config set`. It can always be cleared with:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npx @vibe-cafe/vibe-usage config set kikiStartAt none # only if you are certain no compatibility collector ever ran
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`none` deletes the cut and re-admits the previously withheld pre-cut Kiki history under `kiki`; if a legacy collector had in fact run, that history double counts, so only use it when you know it never did. Do not run both collectors for the same interval, delete state to bypass the check, or use a whole-account reset as a migration shortcut.
|
|
136
|
+
|
|
137
|
+
To relabel old history, the maintainer needs the old bucket keys, raw per-tool token contributions, the cut and the target account/device. A hash of a mixed Kimi/Kiki bucket cannot be split client-side. This CLI neither deletes cloud rows nor copies that history into a second source.
|
|
138
|
+
|
|
114
139
|
## How It Works
|
|
115
140
|
|
|
116
141
|
- Parses local session logs from each AI coding tool
|
|
@@ -206,7 +231,8 @@ Config stored at `~/.vibe-usage/config.json` (dev: `config.dev.json`).
|
|
|
206
231
|
| `apiUrl` | Server URL (default: `https://vibecafe.ai`) |
|
|
207
232
|
| `hostname` | Stable device name for usage tracking (set at init, reused across syncs) |
|
|
208
233
|
| `codexExtraHome` | Optional additional Codex Home scanned together with `$CODEX_HOME` / `~/.codex` |
|
|
209
|
-
| `
|
|
234
|
+
| `kikiStartAt` | Explicit UTC half-hour cut for migrating a collector that reported Kiki as `kimi-code`; see the Kiki migration steps above. Unset for new installations; `config set kikiStartAt none` clears an existing cut |
|
|
235
|
+
| `extraRoots` | Tool-specific additional roots managed by the commands below; currently supports `antigravity`, `claude-code`, `codex`, `grok`, `hermes`, `opencode`, and `pi-coding-agent` |
|
|
210
236
|
|
|
211
237
|
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:
|
|
212
238
|
|
|
@@ -231,6 +257,10 @@ npx @vibe-cafe/vibe-usage config add-root opencode /path/to/other/opencode
|
|
|
231
257
|
# Grok expects a Grok Home containing sessions/.
|
|
232
258
|
npx @vibe-cafe/vibe-usage config add-root grok /path/to/grok-home
|
|
233
259
|
|
|
260
|
+
# Hermes expects a Hermes home containing state.db or profiles/*/state.db.
|
|
261
|
+
# This is in addition to ~/.hermes and $HERMES_HOME, not a replacement.
|
|
262
|
+
npx @vibe-cafe/vibe-usage config add-root hermes /path/to/hermes-home
|
|
263
|
+
|
|
234
264
|
# Antigravity expects an alternate HOME containing .gemini/antigravity*/conversations/.
|
|
235
265
|
npx @vibe-cafe/vibe-usage config add-root antigravity /path/to/alternate-home
|
|
236
266
|
|
package/package.json
CHANGED
package/src/daemon-service.js
CHANGED
package/src/extra-roots.js
CHANGED
|
@@ -3,8 +3,9 @@ import { homedir } from 'node:os';
|
|
|
3
3
|
import { basename, join, resolve } from 'node:path';
|
|
4
4
|
import { openCodeStore } from './opencode-roots.js';
|
|
5
5
|
import { codexSessionDirs } from './codex-roots.js';
|
|
6
|
+
import { inspectHermesHome } from './hermes-roots.js';
|
|
6
7
|
|
|
7
|
-
export const EXTRA_ROOT_SOURCES = ['antigravity', 'claude-code', 'codex', 'grok', 'opencode', 'pi-coding-agent'];
|
|
8
|
+
export const EXTRA_ROOT_SOURCES = ['antigravity', 'claude-code', 'codex', 'grok', 'opencode', 'pi-coding-agent', 'hermes'];
|
|
8
9
|
|
|
9
10
|
// Probing a candidate Pi store has three outcomes, never two: a confirmed
|
|
10
11
|
// session, a directory proven to hold none, and one that could not be read.
|
|
@@ -322,6 +323,16 @@ export function validateExtraRoot(source, value) {
|
|
|
322
323
|
reason: '需要是直接包含 Pi 会话 .jsonl 的目录,或包含 sessions/ 的 Pi agent 目录',
|
|
323
324
|
};
|
|
324
325
|
}
|
|
326
|
+
if (source === 'hermes') {
|
|
327
|
+
const inspected = inspectHermesHome(path);
|
|
328
|
+
return {
|
|
329
|
+
ok: inspected.ok,
|
|
330
|
+
path,
|
|
331
|
+
reason: inspected.error
|
|
332
|
+
? `无法读取 Hermes 目录: ${inspected.error.message}`
|
|
333
|
+
: '需要包含可读的 state.db 或 profiles/*/state.db',
|
|
334
|
+
};
|
|
335
|
+
}
|
|
325
336
|
const dirs = source === 'grok'
|
|
326
337
|
? [grokSessionsDir(path)]
|
|
327
338
|
: antigravityConversationDirs(path);
|
package/src/hermes-roots.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { accessSync, constants, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
|
|
5
5
|
// Match the Hermes CLI/Desktop layout. Windows installers use LOCALAPPDATA;
|
|
@@ -26,10 +26,27 @@ function statIfPresent(path, onError) {
|
|
|
26
26
|
}
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
29
|
+
function isReadableFile(path) {
|
|
30
|
+
try {
|
|
31
|
+
if (!statSync(path).isFile()) return false;
|
|
32
|
+
accessSync(path, constants.R_OK);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Identity for dedup: symlinks and relative spellings of one database are one store.
|
|
40
|
+
export function hermesPathKey(path) {
|
|
41
|
+
const resolved = resolve(path);
|
|
42
|
+
try {
|
|
43
|
+
return realpathSync(resolved);
|
|
44
|
+
} catch {
|
|
45
|
+
return resolved;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function collectHermesDatabases(home, onError) {
|
|
33
50
|
const dbs = [];
|
|
34
51
|
|
|
35
52
|
const defaultDb = join(home, 'state.db');
|
|
@@ -54,6 +71,43 @@ export function discoverHermesDatabases({ onError = err => { throw err; } } = {}
|
|
|
54
71
|
return dbs;
|
|
55
72
|
}
|
|
56
73
|
|
|
57
|
-
|
|
58
|
-
|
|
74
|
+
// Parsing must fail on unreadable stores: a partial result would allow sync
|
|
75
|
+
// to prune that profile's previous state. Detection alone is best-effort.
|
|
76
|
+
export function discoverHermesDatabases({ onError = err => { throw err; } } = {}) {
|
|
77
|
+
return collectHermesDatabases(getHermesHome({ onError }), onError);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Scoped to the given home, not getHermesHome(). `ok` requires a readable
|
|
81
|
+
// state.db or profiles/<name>/state.db. `error` means the read could not be
|
|
82
|
+
// finished (anything other than a missing path); callers must skip the source
|
|
83
|
+
// instead of reporting an empty success.
|
|
84
|
+
export function inspectHermesHome(home) {
|
|
85
|
+
let error = null;
|
|
86
|
+
const dbs = collectHermesDatabases(home, (err) => {
|
|
87
|
+
error = error || err;
|
|
88
|
+
});
|
|
89
|
+
return {
|
|
90
|
+
dbs,
|
|
91
|
+
error,
|
|
92
|
+
ok: error == null && dbs.some(db => isReadableFile(db.path)),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function findHermesDataDirs(extraHomes = []) {
|
|
97
|
+
const dbs = discoverHermesDatabases({ onError: () => {} });
|
|
98
|
+
const seen = new Set(dbs.map(db => hermesPathKey(db.path)));
|
|
99
|
+
for (const home of extraHomes) {
|
|
100
|
+
if (typeof home !== 'string' || !home.trim()) continue;
|
|
101
|
+
const inspected = inspectHermesHome(home);
|
|
102
|
+
// Detection is best-effort. The parser reports an unreadable extra home
|
|
103
|
+
// as skipped so a failed stat cannot be uploaded as "no data".
|
|
104
|
+
if (!inspected.ok) continue;
|
|
105
|
+
for (const db of inspected.dbs) {
|
|
106
|
+
const key = hermesPathKey(db.path);
|
|
107
|
+
if (seen.has(key)) continue;
|
|
108
|
+
seen.add(key);
|
|
109
|
+
dbs.push(db);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return dbs.map(db => db.path);
|
|
59
113
|
}
|
package/src/index.js
CHANGED
|
@@ -12,6 +12,7 @@ import { dim as dimText, failure, hint, smallHeader, warn } from './output.js';
|
|
|
12
12
|
import { loadState } from './state.js';
|
|
13
13
|
import { fetchAccount } from './api.js';
|
|
14
14
|
import { COLLECTOR_VERSION } from './client-meta.js';
|
|
15
|
+
import { kikiStartTime } from './kiki-migration.js';
|
|
15
16
|
|
|
16
17
|
function printSmallHeader() {
|
|
17
18
|
console.log();
|
|
@@ -123,7 +124,7 @@ async function printBoundAccount(apiUrl, apiKey) {
|
|
|
123
124
|
}
|
|
124
125
|
}
|
|
125
126
|
|
|
126
|
-
const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl', 'hostname', 'codexExtraHome'];
|
|
127
|
+
const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl', 'hostname', 'codexExtraHome', 'kikiStartAt'];
|
|
127
128
|
|
|
128
129
|
function handleConfig(args) {
|
|
129
130
|
const sub = args[0];
|
|
@@ -165,6 +166,31 @@ function handleConfig(args) {
|
|
|
165
166
|
value = validation.path;
|
|
166
167
|
}
|
|
167
168
|
const config = loadConfig() || {};
|
|
169
|
+
if (key === 'kikiStartAt') {
|
|
170
|
+
// `none` is the documented escape hatch for a user who asserts no
|
|
171
|
+
// compatibility collector ever ran. It clears the cut (deleting the
|
|
172
|
+
// key so nothing ever stores or prints the literal) and is allowed at
|
|
173
|
+
// any time, unlike changing to a different time once `kiki|` state
|
|
174
|
+
// exists — otherwise a user who set a cut could never back it out.
|
|
175
|
+
if (value === 'none') {
|
|
176
|
+
if ('kikiStartAt' in config) delete config.kikiStartAt;
|
|
177
|
+
saveConfig(config);
|
|
178
|
+
console.log(warn('已清除 Kiki 切点:早于原切点的 Kiki 历史现在会作为 kiki 上传。若实际上运行过把 Kiki 记为 kimi-code 的兼容采集器,这会与原记录双计,请自行确认。'));
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
const time = kikiStartTime(value);
|
|
183
|
+
const state = loadState();
|
|
184
|
+
const hasKikiState = [...Object.keys(state.buckets), ...Object.keys(state.sessions)].some(key => key.startsWith('kiki|'));
|
|
185
|
+
if (hasKikiState && kikiStartTime(config.kikiStartAt) !== time) {
|
|
186
|
+
throw new Error('已保存的 Kiki 切点不能直接更改;请先与服务端维护者协调历史迁移。');
|
|
187
|
+
}
|
|
188
|
+
value = new Date(time).toISOString();
|
|
189
|
+
} catch (err) {
|
|
190
|
+
console.error(failure(err.message));
|
|
191
|
+
process.exit(1);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
168
194
|
config[key] = value;
|
|
169
195
|
saveConfig(config);
|
|
170
196
|
break;
|
|
@@ -305,7 +331,7 @@ const FULL_HELP = `
|
|
|
305
331
|
${BARE} config get <key> Get a config value
|
|
306
332
|
${BARE} config set <key> <value> Set a config value
|
|
307
333
|
${BARE} config set codexExtraHome <path> Persist another Codex Home
|
|
308
|
-
${BARE} config add-root <tool> <path> Add a Claude Code, Codex, Grok, OpenCode, Antigravity, or
|
|
334
|
+
${BARE} config add-root <tool> <path> Add a Claude Code, Codex, Grok, OpenCode, Antigravity, Pi, or Hermes data root
|
|
309
335
|
${BARE} config remove-root <tool> <path> Remove an added data root
|
|
310
336
|
${BARE} config roots Show added data roots as JSON
|
|
311
337
|
${BARE} help Show the short help
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { bucketKey, bucketHash, sessionKey } from './state.js';
|
|
2
|
+
|
|
3
|
+
const HALF_HOUR_MS = 1_800_000;
|
|
4
|
+
|
|
5
|
+
export function kikiStartTime(value) {
|
|
6
|
+
if (value === undefined) return null;
|
|
7
|
+
const time = typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:(?:00|30):00(?:\.000)?Z$/.test(value)
|
|
8
|
+
? Date.parse(value) : NaN;
|
|
9
|
+
const canonical = typeof value === 'string' ? value.replace(/(?:\.000)?Z$/, '.000Z') : '';
|
|
10
|
+
if (!Number.isSafeInteger(time) || time < 0 || time % HALF_HOUR_MS !== 0
|
|
11
|
+
|| new Date(time).toISOString() !== canonical) {
|
|
12
|
+
throw new Error('kikiStartAt 必须是 UTC 半小时边界,例如 2026-10-05T12:30:00Z');
|
|
13
|
+
}
|
|
14
|
+
return time;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// State bucket keys are `source|model|project|hostname|bucketStart`
|
|
18
|
+
// (state.js bucketKey). The one collector path that ever reported Kiki as
|
|
19
|
+
// kimi-code is Kiki's own kap-server export (`vibe-kimi-bucket-v1`): it uploads
|
|
20
|
+
// source `kimi-code` with project 'unknown' and hostname `kiki-<stream_id>`.
|
|
21
|
+
// Neither of those can ever equal the coordinates this CLI writes (the session
|
|
22
|
+
// project and the machine hostname), so an exact-key lookup alone silently
|
|
23
|
+
// misses that history and lets it double count. Treat a current Kiki bucket as
|
|
24
|
+
// legacy-overlapping when a `kimi-code|` state row shares its model and
|
|
25
|
+
// bucketStart but was recorded under a different project/hostname and its
|
|
26
|
+
// recorded hash differs from this CLI's own current Kimi-only hash for the
|
|
27
|
+
// coordinate. That hash guard keeps an unchanged genuine Kimi snapshot (or an
|
|
28
|
+
// unrelated Kimi history) from being mistaken for compatibility evidence.
|
|
29
|
+
function legacyBucketKeys(state, kikiBucket, kimiHashes) {
|
|
30
|
+
const coordinate = bucketKey({ ...kikiBucket, source: 'kimi-code' });
|
|
31
|
+
const keys = [];
|
|
32
|
+
// Exact-key rule: the row uses this CLI's own coordinates, and its hash no
|
|
33
|
+
// longer matches the genuine Kimi-only bucket (a changed or absent snapshot).
|
|
34
|
+
if (coordinate in state.buckets && state.buckets[coordinate] !== kimiHashes.get(coordinate)) {
|
|
35
|
+
keys.push(coordinate);
|
|
36
|
+
}
|
|
37
|
+
const [source, model] = coordinate.split('|');
|
|
38
|
+
const bucketStart = coordinate.slice(coordinate.lastIndexOf('|') + 1);
|
|
39
|
+
for (const key of Object.keys(state.buckets)) {
|
|
40
|
+
if (!key.startsWith('kimi-code|')) continue;
|
|
41
|
+
const parts = key.split('|');
|
|
42
|
+
if (parts[0] !== source || parts[1] !== model || parts[parts.length - 1] !== bucketStart) continue;
|
|
43
|
+
// Same model and window, but a different project/hostname: recorded by
|
|
44
|
+
// something other than this CLI's own current Kimi snapshot. The coordinate
|
|
45
|
+
// key itself is excluded here — it is the exact-key rule above.
|
|
46
|
+
if (parts[2] === kikiBucket.project && parts[3] === kikiBucket.hostname) continue;
|
|
47
|
+
if (state.buckets[key] === kimiHashes.get(coordinate)) continue;
|
|
48
|
+
keys.push(key);
|
|
49
|
+
}
|
|
50
|
+
return keys;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The unpublished compatibility collector reported Kiki as kimi-code. Its
|
|
54
|
+
// state contains hashes, not source contributions: overlap is a conservative
|
|
55
|
+
// migration signal, never proof that a particular Kimi bucket contains Kiki.
|
|
56
|
+
export function planKikiMigration(buckets, sessions, state, startAt) {
|
|
57
|
+
const start = kikiStartTime(startAt);
|
|
58
|
+
const kikiBuckets = buckets.filter(b => b.source === 'kiki');
|
|
59
|
+
const kikiSessions = sessions.filter(s => s.source === 'kiki');
|
|
60
|
+
const kimiHashes = new Map(buckets.filter(b => b.source === 'kimi-code').map(b => [bucketKey(b), bucketHash(b)]));
|
|
61
|
+
const legacyBuckets = new Set();
|
|
62
|
+
for (const b of kikiBuckets) {
|
|
63
|
+
for (const key of legacyBucketKeys(state, b, kimiHashes)) legacyBuckets.add(key);
|
|
64
|
+
}
|
|
65
|
+
// Sessions are keyed `source|sessionHash`, so the collector's project and
|
|
66
|
+
// hostname never enter the key — the exact rule already matches it.
|
|
67
|
+
const legacySessions = new Set(kikiSessions.map(s => sessionKey({ ...s, source: 'kimi-code' }))
|
|
68
|
+
.filter(key => key in state.sessions));
|
|
69
|
+
const hasKikiState = [...Object.keys(state.buckets), ...Object.keys(state.sessions)]
|
|
70
|
+
.some(key => key.startsWith('kiki|'));
|
|
71
|
+
const overlapAfterCut = start !== null && [...legacyBuckets]
|
|
72
|
+
.some(key => Date.parse(key.slice(key.lastIndexOf('|') + 1)) >= start);
|
|
73
|
+
const blocked = !hasKikiState && (start === null
|
|
74
|
+
? legacyBuckets.size > 0 || legacySessions.size > 0 : overlapAfterCut);
|
|
75
|
+
const migrating = blocked || start !== null;
|
|
76
|
+
const preserveBuckets = migrating ? new Set([...legacyBuckets].filter(key => blocked
|
|
77
|
+
|| Date.parse(key.slice(key.lastIndexOf('|') + 1)) < start)) : new Set();
|
|
78
|
+
const preserveSessions = migrating ? legacySessions : new Set();
|
|
79
|
+
const nextBuckets = buckets.filter(b => b.source !== 'kiki' || (!blocked && (start === null || Date.parse(b.bucketStart) >= start)));
|
|
80
|
+
// Freeze a session straddling the cut rather than upload its old timing
|
|
81
|
+
// under a new source. All post-cut token records still contribute buckets.
|
|
82
|
+
const nextSessions = sessions.filter(s => s.source !== 'kiki' || (!blocked && (start === null || Date.parse(s.firstMessageAt) >= start)));
|
|
83
|
+
// Everything Kiki the plan drops — blocked rows, or rows before the cut —
|
|
84
|
+
// is reported so the user learns real history is being withheld rather than
|
|
85
|
+
// silently lost. `frozenBuckets`/`frozenSessions` count the kimi-code rows
|
|
86
|
+
// this run holds at their recorded hash: their growth stops uploading, so
|
|
87
|
+
// the server cell goes stale until the user resolves the migration.
|
|
88
|
+
const plannedKikiBuckets = new Set(nextBuckets.filter(b => b.source === 'kiki'));
|
|
89
|
+
const plannedKikiSessions = new Set(nextSessions.filter(s => s.source === 'kiki'));
|
|
90
|
+
const withheldBuckets = kikiBuckets.filter(b => !plannedKikiBuckets.has(b));
|
|
91
|
+
const withheldSessions = kikiSessions.filter(s => !plannedKikiSessions.has(s));
|
|
92
|
+
const starts = withheldBuckets.map(b => Date.parse(b.bucketStart)).filter(Number.isFinite);
|
|
93
|
+
return {
|
|
94
|
+
blocked,
|
|
95
|
+
preserveBuckets,
|
|
96
|
+
preserveSessions,
|
|
97
|
+
buckets: nextBuckets,
|
|
98
|
+
sessions: nextSessions,
|
|
99
|
+
withheld: {
|
|
100
|
+
buckets: withheldBuckets.length,
|
|
101
|
+
sessions: withheldSessions.length,
|
|
102
|
+
totalTokens: withheldBuckets.reduce((sum, b) => sum + (Number(b.totalTokens) || 0), 0),
|
|
103
|
+
earliest: starts.length > 0 ? new Date(Math.min(...starts)).toISOString() : null,
|
|
104
|
+
latest: starts.length > 0 ? new Date(Math.max(...starts)).toISOString() : null,
|
|
105
|
+
},
|
|
106
|
+
frozenBuckets: preserveBuckets.size,
|
|
107
|
+
frozenSessions: preserveSessions.size,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join, posix, win32 } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { resolveKimiCodeRoots } from './kimi-roots.js';
|
|
5
|
+
|
|
6
|
+
// Kiki is a separate tool, even when it uses a Kimi model/provider.
|
|
7
|
+
export const KIKI_SOURCE_ID = 'kiki';
|
|
8
|
+
|
|
9
|
+
export function resolveKikiRoots(env = process.env, platform = process.platform, home = homedir()) {
|
|
10
|
+
const pathImpl = platform === 'win32' ? win32 : posix;
|
|
11
|
+
return [env.VIBE_USAGE_KIKI_DIR?.trim() || env.KIKI_HOME?.trim() || pathImpl.join(home, '.kiki')];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// Never collect the same physical home under both tool identities.
|
|
15
|
+
export function independentKikiRoots(excludeRoots = [], roots = resolveKikiRoots()) {
|
|
16
|
+
const identity = path => {
|
|
17
|
+
try { return realpathSync(path); } catch { return path; }
|
|
18
|
+
};
|
|
19
|
+
const seen = new Set(excludeRoots.map(identity));
|
|
20
|
+
return roots.filter(root => {
|
|
21
|
+
const key = identity(root);
|
|
22
|
+
if (seen.has(key)) return false;
|
|
23
|
+
seen.add(key);
|
|
24
|
+
return true;
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function findKikiDataDirs() {
|
|
29
|
+
return independentKikiRoots(resolveKimiCodeRoots()).map(root => join(root, 'sessions')).filter(existsSync);
|
|
30
|
+
}
|
package/src/parsers/hermes.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { discoverHermesDatabases } from '../hermes-roots.js';
|
|
1
|
+
import { discoverHermesDatabases, hermesPathKey, inspectHermesHome } from '../hermes-roots.js';
|
|
2
|
+
import { normalizeExtraRoot } from '../extra-roots.js';
|
|
2
3
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
3
4
|
import { toCount } from './fs-utils.js';
|
|
4
5
|
import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
|
|
@@ -9,24 +10,43 @@ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from '.
|
|
|
9
10
|
* Hermes supports multiple profiles — the default profile lives at
|
|
10
11
|
* <home>/state.db, while named profiles live at <home>/profiles/<name>/state.db.
|
|
11
12
|
* The home is shared by CLI/Desktop: ~/.hermes on macOS/Linux, LOCALAPPDATA/hermes
|
|
12
|
-
* on Windows, or an explicit HERMES_HOME.
|
|
13
|
-
*
|
|
13
|
+
* on Windows, or an explicit HERMES_HOME. Additional homes configured with
|
|
14
|
+
* `config add-root hermes <path>` are scanned too and deduped against that default.
|
|
15
|
+
* Each profile is an independent store with its own state.db, so we scan all of them.
|
|
14
16
|
*
|
|
15
17
|
* Token buckets come from the sessions table (cumulative per-session totals).
|
|
16
18
|
* Session timing comes from the messages table (per-message role + timestamp).
|
|
17
19
|
*/
|
|
18
|
-
export async function parse() {
|
|
20
|
+
export async function parse({ extraRoots = [] } = {}) {
|
|
19
21
|
const dbs = discoverHermesDatabases();
|
|
22
|
+
const seen = new Set(dbs.map(db => hermesPathKey(db.path)));
|
|
23
|
+
for (const root of extraRoots) {
|
|
24
|
+
const path = normalizeExtraRoot(root);
|
|
25
|
+
const inspected = inspectHermesHome(path);
|
|
26
|
+
// Missing or unreadable configured homes must not look like an empty sync,
|
|
27
|
+
// even when the path is the default home / HERMES_HOME already scanned above.
|
|
28
|
+
// Databases already reached through that default are skipped via `seen`.
|
|
29
|
+
if (inspected.error || !inspected.ok) return skippedExtra(path, inspected.error);
|
|
30
|
+
for (const db of inspected.dbs) {
|
|
31
|
+
const key = hermesPathKey(db.path);
|
|
32
|
+
if (seen.has(key)) continue;
|
|
33
|
+
seen.add(key);
|
|
34
|
+
dbs.push(db);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
20
37
|
if (dbs.length === 0) return { buckets: [], sessions: [] };
|
|
21
38
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
39
|
+
// Read every store first: which copy of a session we count is decided across
|
|
40
|
+
// all of them, not per store.
|
|
41
|
+
const stores = [];
|
|
25
42
|
for (const { path: dbPath, profile } of dbs) {
|
|
26
43
|
let sessionRows;
|
|
27
44
|
try {
|
|
28
45
|
const columns = new Set(queryDb(dbPath, 'PRAGMA table_info(sessions)').map(row => row.name));
|
|
29
46
|
const cacheWriteColumn = columns.has('cache_write_tokens') ? 'cache_write_tokens' : '0';
|
|
47
|
+
// No WHERE filter here: choosing the richest copy below needs every row,
|
|
48
|
+
// and `sessions` holds one row per session. The liveness filter the old
|
|
49
|
+
// query applied is kept in JS, so the emitted set is unchanged.
|
|
30
50
|
sessionRows = queryDb(dbPath, `SELECT
|
|
31
51
|
id,
|
|
32
52
|
model,
|
|
@@ -36,15 +56,51 @@ export async function parse() {
|
|
|
36
56
|
cache_read_tokens as cacheReadTokens,
|
|
37
57
|
${cacheWriteColumn} as cacheWriteTokens,
|
|
38
58
|
reasoning_tokens as reasoningTokens
|
|
39
|
-
FROM sessions
|
|
40
|
-
WHERE input_tokens > 0 OR output_tokens > 0
|
|
41
|
-
OR cache_read_tokens > 0 OR ${cacheWriteColumn} > 0 OR reasoning_tokens > 0`);
|
|
59
|
+
FROM sessions`);
|
|
42
60
|
} catch (err) {
|
|
43
61
|
if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Hermes');
|
|
44
62
|
throw err;
|
|
45
63
|
}
|
|
64
|
+
stores.push({ dbPath, profile, sessionRows });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const sessionId = row => (typeof row.id === 'string' ? row.id.trim() : '');
|
|
68
|
+
const tokenTotal = row => toCount(row.inputTokens) + toCount(row.outputTokens)
|
|
69
|
+
+ toCount(row.cacheReadTokens) + toCount(row.cacheWriteTokens) + toCount(row.reasoningTokens);
|
|
46
70
|
|
|
71
|
+
// A session's id is its stable record identity and its token columns are
|
|
72
|
+
// cumulative per-session totals, so one session reached through two databases
|
|
73
|
+
// (a copied or migrated home, a backup directory, a symlinked store) must be
|
|
74
|
+
// counted once. Path identity above only stops the same file being read twice
|
|
75
|
+
// -- it cannot see two files holding the same session, which counted that
|
|
76
|
+
// session's tokens twice while the timing stream, grouped by session hash,
|
|
77
|
+
// still reported a single session. Every other extra-root source dedups by
|
|
78
|
+
// its own record identity and keeps the most complete copy (opencode by
|
|
79
|
+
// session+message id, grok by session id, pi by message id); this is the same
|
|
80
|
+
// rule, with the earliest store winning a tie exactly as opencode lets the
|
|
81
|
+
// default root win.
|
|
82
|
+
const owners = new Map();
|
|
83
|
+
for (const store of stores) {
|
|
84
|
+
for (const row of store.sessionRows) {
|
|
85
|
+
const id = sessionId(row);
|
|
86
|
+
// A row with no id cannot be proven to be a copy of another row, so it
|
|
87
|
+
// stays scoped to its own store rather than collapsing with its siblings.
|
|
88
|
+
if (!id) continue;
|
|
89
|
+
const previous = owners.get(id);
|
|
90
|
+
if (!previous || tokenTotal(row) > tokenTotal(previous.row)) owners.set(id, { store, row });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const entries = [];
|
|
95
|
+
const sessionEvents = [];
|
|
96
|
+
|
|
97
|
+
for (const { profile, sessionRows } of stores) {
|
|
47
98
|
for (const row of sessionRows) {
|
|
99
|
+
const id = sessionId(row);
|
|
100
|
+
if (id && owners.get(id).row !== row) continue; // a richer copy owns this session
|
|
101
|
+
// Rows with no tokens at all contribute no bucket, as in the old query.
|
|
102
|
+
if (tokenTotal(row) === 0) continue;
|
|
103
|
+
|
|
48
104
|
// started_at is a Unix timestamp (float)
|
|
49
105
|
const timestamp = new Date(row.startedAt * 1000);
|
|
50
106
|
if (isNaN(timestamp.getTime())) continue;
|
|
@@ -65,7 +121,16 @@ export async function parse() {
|
|
|
65
121
|
reasoningOutputTokens: reasoning,
|
|
66
122
|
});
|
|
67
123
|
}
|
|
124
|
+
}
|
|
68
125
|
|
|
126
|
+
// Timing events carry their own record identity: one (session, role,
|
|
127
|
+
// timestamp) triple is one message, and a copied store repeats it verbatim.
|
|
128
|
+
// extractSessions counts every event it is handed, so without this the copy
|
|
129
|
+
// inflated messageCount while the session hash -- and therefore the session
|
|
130
|
+
// count -- stayed the same, leaving the two streams disagreeing about the
|
|
131
|
+
// same history. Dedup by that triple, first store winning.
|
|
132
|
+
const seenMessages = new Set();
|
|
133
|
+
for (const { dbPath, profile } of stores) {
|
|
69
134
|
// A failed query is not an empty session history. Let sync protect this
|
|
70
135
|
// source's previous state instead of uploading/pruning a partial result.
|
|
71
136
|
const messageRows = queryDb(dbPath, `SELECT
|
|
@@ -79,6 +144,9 @@ export async function parse() {
|
|
|
79
144
|
for (const row of messageRows) {
|
|
80
145
|
const timestamp = new Date(row.timestamp * 1000);
|
|
81
146
|
if (isNaN(timestamp.getTime())) continue;
|
|
147
|
+
const key = `${row.sessionId ?? ''}|${row.role}|${timestamp.getTime()}`;
|
|
148
|
+
if (seenMessages.has(key)) continue;
|
|
149
|
+
seenMessages.add(key);
|
|
82
150
|
|
|
83
151
|
sessionEvents.push({
|
|
84
152
|
sessionId: row.sessionId,
|
|
@@ -96,3 +164,14 @@ export async function parse() {
|
|
|
96
164
|
function queryDb(dbPath, sql) {
|
|
97
165
|
return queryDbJson(dbPath, sql);
|
|
98
166
|
}
|
|
167
|
+
|
|
168
|
+
function skippedExtra(path, error) {
|
|
169
|
+
return {
|
|
170
|
+
buckets: [],
|
|
171
|
+
sessions: [],
|
|
172
|
+
skipped: true,
|
|
173
|
+
warnings: [error
|
|
174
|
+
? `hermes: 额外根目录读取失败,已保留上次同步数据: ${path}`
|
|
175
|
+
: `hermes: 额外根目录不可用,已跳过本次 Hermes 同步: ${path}`],
|
|
176
|
+
};
|
|
177
|
+
}
|
package/src/parsers/index.js
CHANGED
|
@@ -14,6 +14,7 @@ import { parse as parseOpenclaw } from './openclaw.js';
|
|
|
14
14
|
import { parse as parseOmp } from './omp.js';
|
|
15
15
|
import { parse as parseQwenCode } from './qwen-code.js';
|
|
16
16
|
import { parse as parseKimiCode } from './kimi-code.js';
|
|
17
|
+
import { parse as parseKiki } from './kiki.js';
|
|
17
18
|
import { parse as parseAmp } from './amp.js';
|
|
18
19
|
import { parse as parseAlma } from './alma.js';
|
|
19
20
|
import { parse as parseDroid } from './droid.js';
|
|
@@ -50,6 +51,7 @@ export const parsers = {
|
|
|
50
51
|
'qoder': parseQoder,
|
|
51
52
|
'qoder-cn': parseQoderCn,
|
|
52
53
|
'qwen-code': parseQwenCode,
|
|
54
|
+
'kiki': parseKiki,
|
|
53
55
|
'kimi-code': parseKimiCode,
|
|
54
56
|
'amp': parseAmp,
|
|
55
57
|
'alma': parseAlma,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { KIKI_SOURCE_ID, independentKikiRoots } from '../kiki-roots.js';
|
|
2
|
+
import { parseCurrentKimiRoots } from './kimi-code.js';
|
|
3
|
+
import { resolveKimiCodeRoots } from '../kimi-roots.js';
|
|
4
|
+
|
|
5
|
+
// Strip only known routing/provider prefixes, never arbitrary slash-bearing
|
|
6
|
+
// model ids (e.g. Qwen/Qwen3.8-Flash). Nested known routes are stripped in order.
|
|
7
|
+
export const KIKI_MODEL_PREFIXES = Object.freeze([
|
|
8
|
+
'axon', 'axon-message', 'axon-chat', 'kimi-code', 'anthropic',
|
|
9
|
+
'deepseek', 'z-ai', 'hub', 'stealth', 'st', 'minimax',
|
|
10
|
+
]);
|
|
11
|
+
|
|
12
|
+
export function normalizeKikiModel(model, prefixes = KIKI_MODEL_PREFIXES) {
|
|
13
|
+
if (typeof model !== 'string' || !model) return 'unknown';
|
|
14
|
+
let normalized = model;
|
|
15
|
+
for (;;) {
|
|
16
|
+
const slash = normalized.indexOf('/');
|
|
17
|
+
if (slash < 0 || slash === normalized.length - 1
|
|
18
|
+
|| !prefixes.includes(normalized.slice(0, slash))) return normalized;
|
|
19
|
+
normalized = normalized.slice(slash + 1);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function parse({ excludeRoots = [] } = {}) {
|
|
24
|
+
return parseCurrentKimiRoots(independentKikiRoots([...resolveKimiCodeRoots(), ...excludeRoots]), {
|
|
25
|
+
source: KIKI_SOURCE_ID,
|
|
26
|
+
normalizeModel: normalizeKikiModel,
|
|
27
|
+
deduplicateCopies: true,
|
|
28
|
+
}) ?? { buckets: [], sessions: [] };
|
|
29
|
+
}
|
package/src/parsers/kimi-code.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, realpathSync, statSync } from 'node:fs';
|
|
2
2
|
import { join, basename } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
@@ -97,14 +97,25 @@ function usageTokens(value) {
|
|
|
97
97
|
}
|
|
98
98
|
|
|
99
99
|
// Collect every agents/<id>/wire.jsonl under sessions/wd_<...>/session_<...>/.
|
|
100
|
-
|
|
100
|
+
// `failures` collects read errors on paths that exist: an absent home is a
|
|
101
|
+
// normal empty result, but an existing-and-unreadable one must surface as a
|
|
102
|
+
// skip (see parseCurrentKimiRoots) so sync.js cannot prune that source's state.
|
|
103
|
+
function findKimiCodeWireFiles(baseDir, failures) {
|
|
101
104
|
const results = [];
|
|
102
|
-
|
|
105
|
+
let baseStat;
|
|
106
|
+
try {
|
|
107
|
+
baseStat = statSync(baseDir);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
if (err.code !== 'ENOENT') failures.push({ path: baseDir, code: err.code || err.message });
|
|
110
|
+
return results;
|
|
111
|
+
}
|
|
112
|
+
if (!baseStat.isDirectory()) return results;
|
|
103
113
|
|
|
104
114
|
let workDirs;
|
|
105
115
|
try {
|
|
106
116
|
workDirs = readdirSync(baseDir, { withFileTypes: true });
|
|
107
|
-
} catch {
|
|
117
|
+
} catch (err) {
|
|
118
|
+
failures.push({ path: baseDir, code: err.code || err.message });
|
|
108
119
|
return results;
|
|
109
120
|
}
|
|
110
121
|
|
|
@@ -116,7 +127,8 @@ function findKimiCodeWireFiles(baseDir) {
|
|
|
116
127
|
let sessions;
|
|
117
128
|
try {
|
|
118
129
|
sessions = readdirSync(workDirPath, { withFileTypes: true });
|
|
119
|
-
} catch {
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (err.code !== 'ENOENT') failures.push({ path: workDirPath, code: err.code || err.message });
|
|
120
132
|
continue;
|
|
121
133
|
}
|
|
122
134
|
|
|
@@ -128,7 +140,10 @@ function findKimiCodeWireFiles(baseDir) {
|
|
|
128
140
|
let agents;
|
|
129
141
|
try {
|
|
130
142
|
agents = readdirSync(agentsDir, { withFileTypes: true });
|
|
131
|
-
} catch {
|
|
143
|
+
} catch (err) {
|
|
144
|
+
// A missing agents/ directory is a normal empty session; any other
|
|
145
|
+
// error means data we would otherwise have collected is unreadable.
|
|
146
|
+
if (err.code !== 'ENOENT') failures.push({ path: agentsDir, code: err.code || err.message });
|
|
132
147
|
continue;
|
|
133
148
|
}
|
|
134
149
|
|
|
@@ -149,12 +164,12 @@ function findKimiCodeWireFiles(baseDir) {
|
|
|
149
164
|
* Two roots can resolve to the same store (symlink, relocated home), so the
|
|
150
165
|
* same physical wire file is returned once.
|
|
151
166
|
*/
|
|
152
|
-
function findKimiCodeWireFilesInAllRoots() {
|
|
167
|
+
function findKimiCodeWireFilesInAllRoots(roots, failures) {
|
|
153
168
|
const results = [];
|
|
154
169
|
const seen = new Set();
|
|
155
|
-
for (const root of
|
|
170
|
+
for (const root of roots) {
|
|
156
171
|
const sessionIndex = loadSessionIndex(join(root, 'session_index.jsonl'));
|
|
157
|
-
for (const { wireFile, sessionDir, bucketProject } of findKimiCodeWireFiles(join(root, 'sessions'))) {
|
|
172
|
+
for (const { wireFile, sessionDir, bucketProject } of findKimiCodeWireFiles(join(root, 'sessions'), failures)) {
|
|
158
173
|
let identity = wireFile;
|
|
159
174
|
try { identity = realpathSync(wireFile); } catch { /* keep the literal path */ }
|
|
160
175
|
if (seen.has(identity)) continue;
|
|
@@ -165,18 +180,48 @@ function findKimiCodeWireFilesInAllRoots() {
|
|
|
165
180
|
return results;
|
|
166
181
|
}
|
|
167
182
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
183
|
+
/** Shared current wire reader; Kiki uses the same per-agent delta protocol. */
|
|
184
|
+
export function parseCurrentKimiRoots(roots, {
|
|
185
|
+
source = 'kimi-code',
|
|
186
|
+
normalizeModel = model => model || 'unknown',
|
|
187
|
+
deduplicateCopies = false,
|
|
188
|
+
} = {}) {
|
|
189
|
+
const failures = [];
|
|
190
|
+
const wireFiles = findKimiCodeWireFilesInAllRoots(roots, failures);
|
|
191
|
+
if (wireFiles.length === 0 && failures.length === 0) return null;
|
|
171
192
|
|
|
172
193
|
const entries = [];
|
|
173
194
|
const sessionEvents = [];
|
|
174
|
-
|
|
175
|
-
|
|
195
|
+
const seenRecords = new Set();
|
|
196
|
+
const logicalSessions = new Map();
|
|
197
|
+
|
|
198
|
+
for (const file of wireFiles) {
|
|
199
|
+
const { wireFile } = file;
|
|
200
|
+
let { sessionDir, project } = file;
|
|
201
|
+
const sessionId = basename(sessionDir);
|
|
202
|
+
const agentId = basename(join(wireFile, '..'));
|
|
203
|
+
if (deduplicateCopies) {
|
|
204
|
+
// Copied homes retain session/agent directory ids. Keep first-copy
|
|
205
|
+
// attribution and merge exact accounting/timing records, not whole files.
|
|
206
|
+
if (!logicalSessions.has(sessionId)) logicalSessions.set(sessionId, { sessionDir, project });
|
|
207
|
+
({ sessionDir, project } = logicalSessions.get(sessionId));
|
|
208
|
+
}
|
|
209
|
+
const occurrences = new Map();
|
|
210
|
+
const copied = fields => {
|
|
211
|
+
if (!deduplicateCopies) return false;
|
|
212
|
+
const fingerprint = JSON.stringify([sessionId, agentId, ...fields]);
|
|
213
|
+
const occurrence = (occurrences.get(fingerprint) || 0) + 1;
|
|
214
|
+
occurrences.set(fingerprint, occurrence);
|
|
215
|
+
const key = JSON.stringify([fingerprint, occurrence]);
|
|
216
|
+
if (seenRecords.has(key)) return true;
|
|
217
|
+
seenRecords.add(key);
|
|
218
|
+
return false;
|
|
219
|
+
};
|
|
176
220
|
let content;
|
|
177
221
|
try {
|
|
178
222
|
content = readFileSync(wireFile, 'utf-8');
|
|
179
|
-
} catch {
|
|
223
|
+
} catch (err) {
|
|
224
|
+
failures.push({ path: wireFile, code: err.code || err.message });
|
|
180
225
|
continue;
|
|
181
226
|
}
|
|
182
227
|
|
|
@@ -200,8 +245,8 @@ function parseKimiCode() {
|
|
|
200
245
|
// grouping by wireFile would create separate zero-user sessions for
|
|
201
246
|
// subagents instead of attributing their work to the parent turn.
|
|
202
247
|
if (type === 'turn.prompt' && evt.origin?.kind === 'user') {
|
|
203
|
-
if (tsValid) {
|
|
204
|
-
sessionEvents.push({ sessionId: sessionDir, source
|
|
248
|
+
if (tsValid && !copied([type, time, evt.turnId, evt.origin?.kind])) {
|
|
249
|
+
sessionEvents.push({ sessionId: sessionDir, source, project, timestamp: ts, role: 'user' });
|
|
205
250
|
}
|
|
206
251
|
continue;
|
|
207
252
|
}
|
|
@@ -224,10 +269,15 @@ function parseKimiCode() {
|
|
|
224
269
|
const outputTokens = usageTokens(usage.output);
|
|
225
270
|
const cachedInputTokens = usageTokens(usage.inputCacheRead);
|
|
226
271
|
if (!inputTokens && !outputTokens && !cachedInputTokens) continue;
|
|
272
|
+
// Wire records have no request UUID. Match exact allow-listed payloads
|
|
273
|
+
// only across copies of the same session/agent, retaining occurrence
|
|
274
|
+
// counts so identical real calls within one wire are never collapsed.
|
|
275
|
+
if (copied([type, time, evt.model, evt.usageScope, evt.turnId,
|
|
276
|
+
usage.inputOther, usage.inputCacheRead, usage.inputCacheCreation, usage.output])) continue;
|
|
227
277
|
|
|
228
278
|
entries.push({
|
|
229
|
-
source
|
|
230
|
-
model: evt.model
|
|
279
|
+
source,
|
|
280
|
+
model: normalizeModel(evt.model),
|
|
231
281
|
project,
|
|
232
282
|
timestamp: ts,
|
|
233
283
|
inputTokens,
|
|
@@ -238,11 +288,19 @@ function parseKimiCode() {
|
|
|
238
288
|
|
|
239
289
|
// Each usage.record marks an assistant step completing — use it as an
|
|
240
290
|
// assistant timing event so active-time math has both sides of a turn.
|
|
241
|
-
sessionEvents.push({ sessionId: sessionDir, source
|
|
291
|
+
sessionEvents.push({ sessionId: sessionDir, source, project, timestamp: ts, role: 'assistant' });
|
|
242
292
|
}
|
|
243
293
|
}
|
|
244
294
|
|
|
245
|
-
|
|
295
|
+
const result = { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
296
|
+
if (failures.length > 0) {
|
|
297
|
+
// A read failure must never look like an empty success: sync.js would then
|
|
298
|
+
// mark the source ok and prune its incremental state, forcing a full
|
|
299
|
+
// re-upload of untouched history. Wording matches the parser conventions.
|
|
300
|
+
result.skipped = true;
|
|
301
|
+
result.warnings = failures.map(({ path, code }) => `${source}: 无法读取 ${path}: ${code}`);
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
246
304
|
}
|
|
247
305
|
|
|
248
306
|
// ---------------------------------------------------------------------------
|
|
@@ -432,10 +490,17 @@ export async function parse() {
|
|
|
432
490
|
// Always parse both stores and merge (see the header comment): legacy usage
|
|
433
491
|
// is never carried into ~/.kimi-code by `kimi migrate`, so a migrated user's
|
|
434
492
|
// history exists only in ~/.kimi.
|
|
435
|
-
const current =
|
|
493
|
+
const current = parseCurrentKimiRoots(resolveKimiCodeRoots());
|
|
436
494
|
const legacy = parseLegacyKimi();
|
|
437
|
-
|
|
495
|
+
const result = {
|
|
438
496
|
buckets: [...(current?.buckets ?? []), ...legacy.buckets],
|
|
439
497
|
sessions: [...(current?.sessions ?? []), ...legacy.sessions],
|
|
440
498
|
};
|
|
499
|
+
// A current-home read failure must protect this source's incremental state;
|
|
500
|
+
// legacy buckets that did parse may still upload, like other partial parsers.
|
|
501
|
+
if (current?.skipped) {
|
|
502
|
+
result.skipped = true;
|
|
503
|
+
result.warnings = current.warnings ?? [];
|
|
504
|
+
}
|
|
505
|
+
return result;
|
|
441
506
|
}
|
package/src/sync.js
CHANGED
|
@@ -10,6 +10,7 @@ import { parsers } from './parsers/index.js';
|
|
|
10
10
|
import { aggregateToBuckets } from './parsers/aggregate.js';
|
|
11
11
|
import { normalizeParserResult } from './parsers/contract.js';
|
|
12
12
|
import { extraRootList } from './extra-roots.js';
|
|
13
|
+
import { planKikiMigration } from './kiki-migration.js';
|
|
13
14
|
import { success, failure, warn, arrow, link, dim } from './output.js';
|
|
14
15
|
|
|
15
16
|
const BATCH_SIZE = 100;
|
|
@@ -297,10 +298,55 @@ export async function runSync({
|
|
|
297
298
|
if (state.identityChanged && !quiet) {
|
|
298
299
|
console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
|
|
299
300
|
}
|
|
301
|
+
let migration;
|
|
302
|
+
try {
|
|
303
|
+
migration = planKikiMigration(allBuckets, allSessions, state, config.kikiStartAt);
|
|
304
|
+
} catch (err) {
|
|
305
|
+
process.stderr.write(`${dim(` kiki: ${err.message}`)}\n`);
|
|
306
|
+
migration = planKikiMigration(allBuckets, allSessions, state);
|
|
307
|
+
migration.buckets = migration.buckets.filter(b => b.source !== 'kiki');
|
|
308
|
+
migration.sessions = migration.sessions.filter(s => s.source !== 'kiki');
|
|
309
|
+
// The invalid cut drops every Kiki row regardless of what the fallback
|
|
310
|
+
// plan did; report that as withheld so the diagnostic is not silent.
|
|
311
|
+
const droppedBuckets = allBuckets.filter(b => b.source === 'kiki');
|
|
312
|
+
const droppedSessions = allSessions.filter(s => s.source === 'kiki');
|
|
313
|
+
const droppedStarts = droppedBuckets.map(b => Date.parse(b.bucketStart)).filter(Number.isFinite);
|
|
314
|
+
migration.withheld = {
|
|
315
|
+
buckets: droppedBuckets.length,
|
|
316
|
+
sessions: droppedSessions.length,
|
|
317
|
+
totalTokens: droppedBuckets.reduce((sum, b) => sum + (Number(b.totalTokens) || 0), 0),
|
|
318
|
+
earliest: droppedStarts.length > 0 ? new Date(Math.min(...droppedStarts)).toISOString() : null,
|
|
319
|
+
latest: droppedStarts.length > 0 ? new Date(Math.max(...droppedStarts)).toISOString() : null,
|
|
320
|
+
};
|
|
321
|
+
okSources.delete('kiki');
|
|
322
|
+
}
|
|
323
|
+
if (migration.blocked) {
|
|
324
|
+
okSources.delete('kiki');
|
|
325
|
+
process.stderr.write(`${dim(' kiki: 历史与 kimi-code 同步记录重叠,暂停 Kiki 上传以避免双计。请保留 state.json,停用旧兼容采集器并按 README 迁移,显式设置 config set kikiStartAt <UTC半小时切点>。')}\n`);
|
|
326
|
+
}
|
|
327
|
+
// A cut (or the guard) withholds real Kiki history and freezes legacy
|
|
328
|
+
// kimi-code rows; neither is silent, even in quiet/daemon runs. The user
|
|
329
|
+
// learns the counts and time range, and both ways out: set a verified cut, or
|
|
330
|
+
// clear the guard when they know no compatibility collector ever ran.
|
|
331
|
+
const withheld = migration.withheld;
|
|
332
|
+
if (withheld.buckets > 0 || withheld.sessions > 0
|
|
333
|
+
|| migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
|
|
334
|
+
const parts = [];
|
|
335
|
+
if (withheld.buckets > 0 || withheld.sessions > 0) {
|
|
336
|
+
const range = withheld.earliest ? `,时间范围 ${withheld.earliest} 至 ${withheld.latest}` : '';
|
|
337
|
+
parts.push(`本次未上传 ${withheld.buckets} 个桶 / ${withheld.sessions} 个会话(${withheld.totalTokens} tokens${range})`);
|
|
338
|
+
}
|
|
339
|
+
if (migration.frozenBuckets > 0 || migration.frozenSessions > 0) {
|
|
340
|
+
parts.push(`已冻结 ${migration.frozenBuckets} 个旧 kimi-code 桶 / ${migration.frozenSessions} 个会话,其增长不再上传(服务端记录会停留在旧值)`);
|
|
341
|
+
}
|
|
342
|
+
process.stderr.write(`${dim(` kiki: ${parts.join(';')}。若这段历史应归入 Kiki,请设置切点 config set kikiStartAt <UTC半小时切点>;若确认从未运行过兼容采集器,可用 config set kikiStartAt none 解除保护。`)}\n`);
|
|
343
|
+
}
|
|
344
|
+
allBuckets = migration.buckets;
|
|
345
|
+
const uploadSessions = migration.sessions;
|
|
300
346
|
const changedBuckets = [];
|
|
301
347
|
const changedSessions = [];
|
|
302
|
-
const liveBucketKeys = new Set();
|
|
303
|
-
const liveSessionKeys = new Set();
|
|
348
|
+
const liveBucketKeys = new Set(migration.preserveBuckets);
|
|
349
|
+
const liveSessionKeys = new Set(migration.preserveSessions);
|
|
304
350
|
// key -> hash, committed to state only after the owning batch's upload
|
|
305
351
|
// succeeds (a failed batch re-sends next sync — no silent gap).
|
|
306
352
|
const pendingBucketState = new Map();
|
|
@@ -310,15 +356,15 @@ export async function runSync({
|
|
|
310
356
|
const key = bucketKey(b);
|
|
311
357
|
const h = bucketHash(b);
|
|
312
358
|
liveBucketKeys.add(key);
|
|
313
|
-
if (state.buckets[key] === h) continue;
|
|
359
|
+
if (migration.preserveBuckets.has(key) || state.buckets[key] === h) continue;
|
|
314
360
|
changedBuckets.push(b);
|
|
315
361
|
pendingBucketState.set(key, h);
|
|
316
362
|
}
|
|
317
|
-
for (const s of
|
|
363
|
+
for (const s of uploadSessions) {
|
|
318
364
|
const key = sessionKey(s);
|
|
319
365
|
const h = sessionHash(s);
|
|
320
366
|
liveSessionKeys.add(key);
|
|
321
|
-
if (state.sessions[key] === h) continue;
|
|
367
|
+
if (migration.preserveSessions.has(key) || state.sessions[key] === h) continue;
|
|
322
368
|
changedSessions.push(s);
|
|
323
369
|
pendingSessionState.set(key, h);
|
|
324
370
|
}
|
package/src/tools.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
discoverCodexHomes,
|
|
11
11
|
extraRootList,
|
|
12
12
|
grokSessionsDir,
|
|
13
|
+
normalizeExtraRoot,
|
|
13
14
|
} from './extra-roots.js';
|
|
14
15
|
import { findClineDataDirs } from './cline-roots.js';
|
|
15
16
|
import { findCodeartsAgentDbs, resolveCodeartsAgentRoots } from './codearts-roots.js';
|
|
@@ -17,6 +18,7 @@ import { findColaDataDirs, getColaSessionsDir } from './cola-roots.js';
|
|
|
17
18
|
import { findCraftDataDirs } from './craft-roots.js';
|
|
18
19
|
import { findHermesDataDirs, getHermesHome } from './hermes-roots.js';
|
|
19
20
|
import { findKimiCodeDataDirs } from './kimi-roots.js';
|
|
21
|
+
import { findKikiDataDirs, KIKI_SOURCE_ID, resolveKikiRoots } from './kiki-roots.js';
|
|
20
22
|
import { findOmpDataDirs, findPiDataDirs } from './pi-roots.js';
|
|
21
23
|
import { findQoderDataDirs, getQoderProjectsDir } from './qoder-roots.js';
|
|
22
24
|
import { findWorkbuddyDataDirs } from './workbuddy-roots.js';
|
|
@@ -465,11 +467,15 @@ export const TOOLS = [
|
|
|
465
467
|
id: 'qwen-code',
|
|
466
468
|
dataDir: join(homedir(), '.qwen', 'tmp'),
|
|
467
469
|
},
|
|
470
|
+
{
|
|
471
|
+
name: 'Kiki',
|
|
472
|
+
id: KIKI_SOURCE_ID,
|
|
473
|
+
dataDir: join(resolveKikiRoots()[0], 'sessions'),
|
|
474
|
+
detectDataDirs: findKikiDataDirs,
|
|
475
|
+
},
|
|
468
476
|
{
|
|
469
477
|
name: 'Kimi Code',
|
|
470
478
|
id: 'kimi-code',
|
|
471
|
-
// Current layout is ~/.kimi-code/sessions; ~/.kimi/sessions is the legacy
|
|
472
|
-
// path. The parser reads whichever exists (preferring ~/.kimi-code).
|
|
473
479
|
dataDir: join(homedir(), '.kimi-code', 'sessions'),
|
|
474
480
|
detectDataDirs: findKimiCodeDataDirs,
|
|
475
481
|
},
|
|
@@ -520,7 +526,9 @@ export const TOOLS = [
|
|
|
520
526
|
name: 'Hermes',
|
|
521
527
|
id: 'hermes',
|
|
522
528
|
dataDir: join(getHermesHome(), 'state.db'),
|
|
523
|
-
detectDataDirs: findHermesDataDirs
|
|
529
|
+
detectDataDirs: ({ extraRoots } = {}) => findHermesDataDirs(
|
|
530
|
+
extraRootList(extraRoots?.hermes).map(root => normalizeExtraRoot(root)),
|
|
531
|
+
),
|
|
524
532
|
},
|
|
525
533
|
{
|
|
526
534
|
name: 'Kiro',
|