@vibe-cafe/vibe-usage 0.13.1 → 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 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
- | MiniMax Code (mcode) | `$MCODE_HOME/v2/sqlite/runtime-state.sqlite` (default `~/.minimax/v2/sqlite/runtime-state.sqlite`; fixture override: `VIBE_USAGE_MCODE_DB`). Reads only allow-listed token ledger fields and session workspace/project paths, uses basename-only projects, folds cache writes into input, keeps cache reads and reasoning separate, and never selects raw/message JSON payloads. WAL/lock reads use a disposable snapshot; malformed or incompatible databases are skipped to preserve incremental state. |
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
- | `extraRoots` | Tool-specific additional roots managed by the commands below; currently supports `codex`, `grok`, `antigravity`, and `pi-coding-agent` |
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.13.1",
3
+ "version": "0.14.0",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -135,15 +135,18 @@ const PRESERVED_SERVICE_ENV = [
135
135
  'DSH_HOME',
136
136
  'GROK_HOME',
137
137
  'HERMES_HOME',
138
+ 'KIKI_HOME',
138
139
  'KIMI_CODE_HOME',
139
140
  'KIRO_BASE_PATH',
140
141
  'KIRO_CLI_DB_PATH',
141
142
  'KIRO_CLI_SESSIONS_DIR',
142
143
  'KIRO_SESSIONS_DIR',
143
144
  'KIRO_USER_PATH',
145
+ 'MAVIS_DATA_DIR',
144
146
  'MCODE_HOME',
145
147
  'MIMOCODE_HOME',
146
148
  'MIMOCODE_DB',
149
+ 'MINIMAX_DATA_DIR',
147
150
  'PI_CONFIG_DIR',
148
151
  'PI_CODING_AGENT_DIR',
149
152
  'PI_CODING_AGENT_SESSION_DIR',
@@ -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);
@@ -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
- // Parsing must fail on unreadable stores: a partial result would allow sync
30
- // to prune that profile's previous state. Detection alone is best-effort.
31
- export function discoverHermesDatabases({ onError = err => { throw err; } } = {}) {
32
- const home = getHermesHome({ onError });
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
- export function findHermesDataDirs() {
58
- return discoverHermesDatabases({ onError: () => {} }).map(db => db.path);
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 Pi data root
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
+ }
@@ -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
- * Each profile is an independent HERMES_HOME with its own state.db, so we scan all of them.
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
- const entries = [];
23
- const sessionEvents = [];
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
+ }
@@ -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
+ }
@@ -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
- function findKimiCodeWireFiles(baseDir) {
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
- if (!existsSync(baseDir)) return results;
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 resolveKimiCodeRoots()) {
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
- function parseKimiCode() {
169
- const wireFiles = findKimiCodeWireFilesInAllRoots();
170
- if (wireFiles.length === 0) return null;
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
- for (const { wireFile, sessionDir, project } of wireFiles) {
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: 'kimi-code', project, timestamp: ts, role: 'user' });
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: 'kimi-code',
230
- model: evt.model || 'unknown',
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: 'kimi-code', project, timestamp: ts, role: 'assistant' });
291
+ sessionEvents.push({ sessionId: sessionDir, source, project, timestamp: ts, role: 'assistant' });
242
292
  }
243
293
  }
244
294
 
245
- return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
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 = parseKimiCode();
493
+ const current = parseCurrentKimiRoots(resolveKimiCodeRoots());
436
494
  const legacy = parseLegacyKimi();
437
- return {
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
  }
@@ -6,7 +6,7 @@ import {
6
6
  isSqliteUnavailableError,
7
7
  sqliteUnavailableError,
8
8
  } from './sqlite.js';
9
- import { getMcodeDbPath } from '../tools.js';
9
+ import { getMcodeDbPath, getMcodeDbPaths } from '../tools.js';
10
10
 
11
11
  const SOURCE = 'mcode';
12
12
 
@@ -47,7 +47,8 @@ const USAGE_SQL = `
47
47
  /**
48
48
  * Resolve the mcode runtime-state SQLite database. Mirrors the precedence used
49
49
  * by sibling tools (MiMoCode, DimAgent): explicit env var wins, then a
50
- * tool-specific HOME, then the default layout.
50
+ * tool-specific HOME (MCODE_HOME), then the mcode CLI's own relocation
51
+ * variables (MINIMAX_DATA_DIR / MAVIS_DATA_DIR), then the default layout.
51
52
  *
52
53
  * Defaults to `<homedir()>/.minimax/v2/sqlite/runtime-state.sqlite`, which is
53
54
  * where the mcode CLI keeps its WAL database on macOS / Linux.
@@ -56,6 +57,16 @@ export function resolveMcodeDbPath(env = process.env) {
56
57
  return getMcodeDbPath(env);
57
58
  }
58
59
 
60
+ /**
61
+ * Every mcode store worth reading: the resolved default above, plus a
62
+ * `~/.minimax-<profile>` per active profile, the pre-npm `~/.minimax-code`
63
+ * and the pre-rename `~/.mavis` homes (and their profiles) when they hold a
64
+ * runtime-state database.
65
+ */
66
+ export function resolveMcodeDbPaths(env = process.env, home) {
67
+ return getMcodeDbPaths(env, home);
68
+ }
69
+
59
70
  /**
60
71
  * Token count (ms vs seconds). The mcode runtime writes ts as integer
61
72
  * milliseconds — confirmed against the live schema (`typeof(ts)=integer`,
@@ -89,92 +100,116 @@ function dbHasColumns(dbPath, table, columns) {
89
100
  }
90
101
 
91
102
  export async function parse() {
92
- const dbPath = resolveMcodeDbPath();
93
- if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
94
-
95
- // Schema guard: every allow-listed column must exist. If the mcode
96
- // runtime ever renames / drops a column, fail soft (skipped) so the
97
- // incremental sync keeps the last good upload state for this source.
98
- let schemaOk;
99
- try {
100
- schemaOk =
101
- dbHasColumns(dbPath, 'local_runtime_token_usage', TOKEN_COLUMNS) &&
102
- dbHasColumns(dbPath, 'local_runtime_sessions', SESSION_COLUMNS);
103
- } catch (err) {
104
- if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('mcode');
105
- return { buckets: [], sessions: [], skipped: true };
106
- }
107
- if (!schemaOk) {
108
- return { buckets: [], sessions: [], skipped: true };
109
- }
110
-
111
- // Read tokens and session project metadata from one statement/snapshot.
112
- let usageRows;
113
- try {
114
- usageRows = queryDbJsonSnapshotOnLock(dbPath, USAGE_SQL, {
115
- tempPrefix: 'vibe-usage-mcode',
116
- });
117
- } catch (err) {
118
- if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('mcode');
119
- return { buckets: [], sessions: [], skipped: true };
120
- }
103
+ const dbPaths = getMcodeDbPaths();
104
+ // The first store that exists is the one the running CLI would use; schema
105
+ // or read failures there keep the fail-soft contract below. Later stores are
106
+ // best-effort extras (profiles, pre-npm / pre-rename homes) and a broken one
107
+ // must not suppress the live store.
108
+ const primary = dbPaths.find(path => existsSync(path));
121
109
 
122
110
  const entries = [];
111
+ const warnings = [];
112
+ // mcode's own migration can COPY a relocated legacy tree into ~/.minimax, so
113
+ // both copies exist. Collapse rows that reappear unchanged in a later store;
114
+ // duplicates inside one store are left alone — there they are the ledger.
115
+ const seenAcrossStores = new Set();
116
+
117
+ for (const dbPath of dbPaths) {
118
+ if (!existsSync(dbPath)) continue;
119
+ const isPrimary = dbPath === primary;
120
+
121
+ // Schema guard: every allow-listed column must exist. If the mcode
122
+ // runtime ever renames / drops a column, fail soft (skipped) so the
123
+ // incremental sync keeps the last good upload state for this source.
124
+ let schemaOk;
125
+ try {
126
+ schemaOk =
127
+ dbHasColumns(dbPath, 'local_runtime_token_usage', TOKEN_COLUMNS) &&
128
+ dbHasColumns(dbPath, 'local_runtime_sessions', SESSION_COLUMNS);
129
+ } catch (err) {
130
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('mcode');
131
+ schemaOk = false;
132
+ }
133
+ if (!schemaOk) {
134
+ if (isPrimary) return { buckets: [], sessions: [], skipped: true };
135
+ warnings.push(`MiniMax Code: 跳过结构不兼容的数据库 ${dbPath}`);
136
+ continue;
137
+ }
123
138
 
124
- for (const row of usageRows) {
125
- const sessionId = row.session_id != null ? String(row.session_id) : '';
126
- if (!sessionId) continue;
127
- const ts = tsToDate(row.ts);
128
- if (!ts) continue;
129
-
130
- // MCode stores output and reasoning as separate counters. Its own
131
- // summary code computes total = input + output + reasoning, so do not
132
- // subtract reasoning from output here.
133
- const inputRaw = toNonNegative(row.input_tokens);
134
- const cacheWrite = toNonNegative(row.cache_write_tokens);
135
- const outputRaw = toNonNegative(row.output_tokens);
136
- const reasoningRaw = toNonNegative(row.reasoning_tokens);
137
- const cacheRead = toNonNegative(row.cache_read_tokens);
138
-
139
- const inputTokens = inputRaw + cacheWrite;
140
- const reasoningOutputTokens = reasoningRaw;
141
- const outputTokens = outputRaw;
142
- const cachedInputTokens = cacheRead;
143
-
144
- if (
145
- inputTokens +
146
- outputTokens +
147
- cachedInputTokens +
148
- reasoningOutputTokens ===
149
- 0
150
- ) {
139
+ // Read tokens and session project metadata from one statement/snapshot.
140
+ let usageRows;
141
+ try {
142
+ usageRows = queryDbJsonSnapshotOnLock(dbPath, USAGE_SQL, {
143
+ tempPrefix: 'vibe-usage-mcode',
144
+ });
145
+ } catch (err) {
146
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('mcode');
147
+ if (isPrimary) return { buckets: [], sessions: [], skipped: true };
148
+ warnings.push(`MiniMax Code: 无法读取 ${dbPath},已跳过: ${err.message}`);
151
149
  continue;
152
150
  }
153
151
 
154
- const projectPath = row.project_workspace_dir || row.workspace_dir;
155
- const project = projectPath ? projectFromPath(String(projectPath)) : 'unknown';
156
- const model = row.model != null && String(row.model).trim()
157
- ? String(row.model).trim()
158
- : 'unknown';
159
-
160
- entries.push({
161
- source: SOURCE,
162
- model,
163
- project,
164
- timestamp: ts,
165
- inputTokens,
166
- outputTokens,
167
- cachedInputTokens,
168
- reasoningOutputTokens,
169
- });
152
+ const identitiesInStore = new Set();
153
+ for (const row of usageRows) {
154
+ // Identity covers every selected column; only a byte-identical ledger
155
+ // row can collapse, and only against an earlier store.
156
+ const identity = JSON.stringify(row);
157
+ if (seenAcrossStores.has(identity)) continue;
158
+ identitiesInStore.add(identity);
159
+
160
+ const sessionId = row.session_id != null ? String(row.session_id) : '';
161
+ if (!sessionId) continue;
162
+ const ts = tsToDate(row.ts);
163
+ if (!ts) continue;
164
+
165
+ // MCode stores output and reasoning as separate counters. Its own
166
+ // summary code computes total = input + output + reasoning, so do not
167
+ // subtract reasoning from output here.
168
+ const inputRaw = toNonNegative(row.input_tokens);
169
+ const cacheWrite = toNonNegative(row.cache_write_tokens);
170
+ const outputRaw = toNonNegative(row.output_tokens);
171
+ const reasoningRaw = toNonNegative(row.reasoning_tokens);
172
+ const cacheRead = toNonNegative(row.cache_read_tokens);
173
+
174
+ const inputTokens = inputRaw + cacheWrite;
175
+ const reasoningOutputTokens = reasoningRaw;
176
+ const outputTokens = outputRaw;
177
+ const cachedInputTokens = cacheRead;
178
+
179
+ if (
180
+ inputTokens +
181
+ outputTokens +
182
+ cachedInputTokens +
183
+ reasoningOutputTokens ===
184
+ 0
185
+ ) {
186
+ continue;
187
+ }
188
+
189
+ const projectPath = row.project_workspace_dir || row.workspace_dir;
190
+ const project = projectPath ? projectFromPath(String(projectPath)) : 'unknown';
191
+ const model = row.model != null && String(row.model).trim()
192
+ ? String(row.model).trim()
193
+ : 'unknown';
194
+
195
+ entries.push({
196
+ source: SOURCE,
197
+ model,
198
+ project,
199
+ timestamp: ts,
200
+ inputTokens,
201
+ outputTokens,
202
+ cachedInputTokens,
203
+ reasoningOutputTokens,
204
+ });
205
+ }
206
+ for (const identity of identitiesInStore) seenAcrossStores.add(identity);
170
207
  }
171
208
 
172
209
  return {
173
210
  buckets: aggregateToBuckets(entries),
174
- // The token ledger contains assistant usage rows only. Reconstructing
175
- // user prompts would require reading message payloads, which this parser
176
- // deliberately never selects, so mcode emits buckets only like Alma.
177
211
  sessions: [],
212
+ ...(warnings.length > 0 ? { warnings } : {}),
178
213
  };
179
214
  }
180
215
 
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 allSessions) {
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
@@ -1,4 +1,4 @@
1
- import { existsSync, readdirSync, statSync } from 'node:fs';
1
+ import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
2
2
  import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { getOpenCodeStores } from './opencode-roots.js';
@@ -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';
@@ -176,15 +178,75 @@ export function findDshDataDirs() {
176
178
  return [getDshSessionsDir()].filter(existsSync);
177
179
  }
178
180
 
179
- /** mcode runtime database: VIBE_USAGE_MCODE_DB wins, then MCODE_HOME, then ~/.minimax. */
181
+ const MCODE_DB_RELATIVE = join('v2', 'sqlite', 'runtime-state.sqlite');
182
+
183
+ // Data-root variables the mcode CLI itself resolves, in its own precedence.
184
+ // MCODE_HOME stays first as vibe-usage's pre-existing fixture override, then
185
+ // the two public relocation variables from the mcode README.
186
+ const MCODE_ROOT_ENV = ['MCODE_HOME', 'MINIMAX_DATA_DIR', 'MAVIS_DATA_DIR'];
187
+
188
+ function mcodeRootFromEnv(env) {
189
+ for (const key of MCODE_ROOT_ENV) {
190
+ const value = env[key]?.trim();
191
+ if (!value) continue;
192
+ if (!isAbsolute(value)) {
193
+ throw new Error(`${key} must be an absolute path, got: ${JSON.stringify(value)}`);
194
+ }
195
+ return value;
196
+ }
197
+ return null;
198
+ }
199
+
200
+ /** mcode runtime database: VIBE_USAGE_MCODE_DB wins, then MCODE_HOME / MINIMAX_DATA_DIR / MAVIS_DATA_DIR, then ~/.minimax. */
180
201
  export function getMcodeDbPath(env = process.env, home = homedir()) {
181
202
  const override = env.VIBE_USAGE_MCODE_DB?.trim();
182
203
  if (override) return isAbsolute(override) ? override : resolve(override);
183
- if (env.MCODE_HOME && !isAbsolute(env.MCODE_HOME)) {
184
- throw new Error(`MCODE_HOME must be an absolute path, got: ${JSON.stringify(env.MCODE_HOME)}`);
204
+ const root = mcodeRootFromEnv(env) || join(home, '.minimax');
205
+ return join(root, MCODE_DB_RELATIVE);
206
+ }
207
+
208
+ /**
209
+ * Every mcode runtime database worth parsing.
210
+ *
211
+ * The mcode CLI relocates its whole data root with MINIMAX_DATA_DIR /
212
+ * MAVIS_DATA_DIR, scopes it to ~/.minimax-<profile> when a profile is active,
213
+ * and earlier source builds kept user data at ~/.minimax-code; the pre-rename
214
+ * home (~/.mavis) stays readable through the CLI's own compat link. Reading
215
+ * only ~/.minimax silently misses every session written in those layouts.
216
+ *
217
+ * Explicit roots keep returning their single unresolved path so a missing
218
+ * file still yields an empty result; the default layout only returns stores
219
+ * that exist, deduplicated by physical file so a compat symlink cannot make
220
+ * the same ledger count twice.
221
+ */
222
+ export function getMcodeDbPaths(env = process.env, home = homedir()) {
223
+ if (env.VIBE_USAGE_MCODE_DB?.trim() || mcodeRootFromEnv(env)) return [getMcodeDbPath(env, home)];
224
+ const candidates = [
225
+ join(home, '.minimax'),
226
+ join(home, '.minimax-code'),
227
+ join(home, '.mavis'),
228
+ ];
229
+ try {
230
+ const profiles = readdirSync(home, { withFileTypes: true })
231
+ .filter(entry => entry.isDirectory() && /^\.(?:minimax|mavis)-.+$/.test(entry.name))
232
+ .map(entry => join(home, entry.name))
233
+ .sort();
234
+ candidates.push(...profiles);
235
+ } catch {
236
+ // Unreadable home directory: fall back to the default layout below.
237
+ }
238
+ const paths = [];
239
+ const seen = new Set();
240
+ for (const dir of candidates) {
241
+ const dbPath = join(dir, MCODE_DB_RELATIVE);
242
+ if (!existsSync(dbPath)) continue;
243
+ let identity = dbPath;
244
+ try { identity = realpathSync(dbPath); } catch { /* keep the literal path */ }
245
+ if (seen.has(identity)) continue;
246
+ seen.add(identity);
247
+ paths.push(dbPath);
185
248
  }
186
- const root = env.MCODE_HOME || join(home, '.minimax');
187
- return join(root, 'v2', 'sqlite', 'runtime-state.sqlite');
249
+ return paths;
188
250
  }
189
251
 
190
252
  // Devin (CLI and Desktop share one agent backend) keeps all sessions in a
@@ -405,11 +467,15 @@ export const TOOLS = [
405
467
  id: 'qwen-code',
406
468
  dataDir: join(homedir(), '.qwen', 'tmp'),
407
469
  },
470
+ {
471
+ name: 'Kiki',
472
+ id: KIKI_SOURCE_ID,
473
+ dataDir: join(resolveKikiRoots()[0], 'sessions'),
474
+ detectDataDirs: findKikiDataDirs,
475
+ },
408
476
  {
409
477
  name: 'Kimi Code',
410
478
  id: 'kimi-code',
411
- // Current layout is ~/.kimi-code/sessions; ~/.kimi/sessions is the legacy
412
- // path. The parser reads whichever exists (preferring ~/.kimi-code).
413
479
  dataDir: join(homedir(), '.kimi-code', 'sessions'),
414
480
  detectDataDirs: findKimiCodeDataDirs,
415
481
  },
@@ -417,7 +483,7 @@ export const TOOLS = [
417
483
  name: 'MiniMax Code',
418
484
  id: 'mcode',
419
485
  dataDir: join(homedir(), '.minimax', 'v2', 'sqlite', 'runtime-state.sqlite'),
420
- detectDataDirs: () => [getMcodeDbPath()].filter(existsSync),
486
+ detectDataDirs: () => getMcodeDbPaths().filter(existsSync),
421
487
  },
422
488
  {
423
489
  name: 'MiMoCode',
@@ -460,7 +526,9 @@ export const TOOLS = [
460
526
  name: 'Hermes',
461
527
  id: 'hermes',
462
528
  dataDir: join(getHermesHome(), 'state.db'),
463
- detectDataDirs: findHermesDataDirs,
529
+ detectDataDirs: ({ extraRoots } = {}) => findHermesDataDirs(
530
+ extraRootList(extraRoots?.hermes).map(root => normalizeExtraRoot(root)),
531
+ ),
464
532
  },
465
533
  {
466
534
  name: 'Kiro',