8000 fix(collector): read plans in sync and split account-switch gaps for 0.10.3 by InfinityPacer · Pull Request #44 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## [未发布]

## [0.10.3] - 2026-10-21

- 后台同步现在真正从登录凭据识别套餐。0.10.0 起只有 `scan` 会读凭据,每分钟一次的同步一直没读,新版 Claude Code 登录的 Pro、Max 账户因此仍显示未识别套餐。
- 采集器不再每次运行都读凭据,只在凭据被改写(登录、刷新令牌或套餐变化)、登录账户变化或套餐等待确认时读取。判断是否改写只看修改时间,不读令牌。
- 切换 Claude 登录账户时,切换前后那一两分钟的用量不再记到「未归属」,而是按切换时刻分给前后两个账户:会话里执行 `/login` 的以登录成功时间为准,会话外登录的以凭据修改时间为准,都没有时算给新账户。升级后,以前切换时记到「未归属」的用量会自动重新归属一次。
- 改动都在本机采集器,替换本机的 Meterleaf.app 即可生效,服务端不受影响。数据库结构未变,升级无需先备份。

## [0.10.2] - 2026-10-21

- 平板竖屏或较窄的浏览器窗口(约 740–900px 宽)里,首页的时间筛选、账户卡片和消耗趋势保持同一宽度,趋势卡片不再比上方卡片宽出一截。
Expand Down
6 changes: 4 additions & 2 deletions docs/collector.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,14 @@

采集器只读取下面三处,不写入任何一处。

- `~/.claude/projects` 下的会话记录,从中读取每次请求的模型与 Token 用量。
- `~/.claude/projects` 下的会话记录,从中读取每次请求的模型与 Token 用量,以及会话里 `/login` 成功的时间。
- `~/.claude.json`,从中读取登录账户的 UUID、组织类型、席位档位,以及 Claude Code 缓存的最近一次额度数据,包括 5 小时、7 天和 Fable 独立周额度。
- 当前登录账户的套餐字段。新版 Claude Code 只把订阅类型和速率档位保存在登录凭据里,macOS 上是钥匙串中的「Claude Code-credentials」,其他系统是 Claude 配置目录下的 `.credentials.json`。采集器用系统自带的 `security` 命令或直接读文件取出整条凭据,只保留这两个字段,访问令牌和刷新令牌不解析、不保存、不记录、不上传。

它不会在 Claude Code 的目录里写入、改名、加锁或改权限,不改设置、hooks 或状态栏,不运行 Claude Code 命令,也不调用任何 Anthropic 接口,读取凭据不会产生网络请求。上传内容只有计量数字、模型、时间、账户标识和套餐,不包含对话、工具输出、文件路径、工作目录、邮箱、显示名或令牌。

凭据不会每次运行都读。采集器每次运行只看凭据的修改时间,这一步只取钥匙串条目的属性或文件状态,不读令牌。只有凭据被改写过(登录、刷新令牌或套餐变化时 Claude Code 都会改写)、刚登录或切换了账户、`~/.claude.json` 里的账户资料有变化,或者上一次读到的套餐还在等确认时,才读取凭据内容。凭据没有改写,其中的套餐就不会变,所以其余时候沿用已确认的套餐。读不到凭据时,例如钥匙串拒绝访问,等凭据下次改写再试。

凭据只代表当前登录的账户,而 Claude Code 切换账户时会先换凭据、后改 `~/.claude.json`。所以采集器只采用连续两次运行都对同一账户读到的同一套餐,新登录的账户要过一分钟左右才显示凭据里的套餐,切换途中读到的结果不会记到旧账户上。`scan` 只运行一次,会显示从凭据换算出的套餐并注明同步时确认后采用。读不到凭据时,例如钥匙串拒绝访问,采集器沿用此前为该账户记录的套餐;从没读到过时只能从席位档位识别 Team,其余显示为订阅账户,可以在 Meterleaf 中手动设置套餐,见[使用与报表](reporting.md#账户管理)。

## 统计口径
Expand Down Expand Up @@ -126,6 +128,6 @@ meterleaf-collector statusline-cache ~/.cache/claude-statusline/rate-limits.tsv
- **提示密钥无效**:确认 Meterleaf 的 `METERLEAF_INGEST_KEYS` 包含 `init` 输出的那一行并已重启。换电脑或重新生成密钥时,用 `init --force` 并替换服务端对应的行。
- **额度显示为过时**:Claude Code 并不在每次请求后刷新 `~/.claude.json` 里的额度缓存,观察到的刷新发生在打开设置中的用量面板时。采集器不会主动请求额度,可以启用上面的状态栏额度缓存获得更新的数据。
- **Fable 显示「等待更新」**:状态栏只拿得到 5 小时与 7 天额度,Fable 周额度只来自 `~/.claude.json` 的缓存。上一周期结束后,在 Claude Code 里打开一次用量面板,下一轮采集就会带上新周期的 Fable 额度。
- **切换过登录账户**:切换前后正在进行的请求可能无法确定属于哪个账户,会保留在「未归属」下,不会被算到当前账户。
- **切换过登录账户**:用量按登录账户分开记。两次运行之间切换账户时,采集器按切换时刻把这段时间的用量分给前后两个账户。在会话里执行 `/login` 时,以会话记录里登录成功的时间为准;在会话外登录时,以登录凭据的修改时间为准;两者都没有时,这段时间整体算给新账户。切换后其他终端里仍在运行的会话,可能还有几十秒继续使用旧账户,这几十秒的用量会算给新账户。额度读数不做这种推断,切换前后无法判断属于哪个账户的读数直接跳过。

停用时执行 `meterleaf-collector uninstall-launchd`。采集器的数据目录位于 `~/Library/Application Support/Meterleaf Collector`,其中保存写入密钥、读取进度和账户归属记录。不要删除它,否则归属记录丢失后,重新读取的历史会改记到「未归属」。
2 changes: 1 addition & 1 deletion package.json
7438
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meterleaf",
"version": "0.10.2",
"version": "0.10.3",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
44 changes: 41 additions & 3 deletions src/collector/attribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,49 @@ export interface HistoryBinding {
before: string;
}

/**
* 登录发生时刻的线索。transcript 来自会话记录里 /login 成功的那一行,是登录完成的时刻;
* credential 来自凭据的修改时间,令牌刷新也会改写,只在没有会话记录线索时使用。
*/
export interface LoginHint {
at: string;
source: "transcript" | "credential";
}

/**
* 两段不同账户区间之间的切换时刻:取空档内最后一次登录,会话记录优先于凭据修改时间。
* 没有线索时返回 null,整段空档归后一个账户:通常是前一个账户额度用完才切换,
* 空档里能成功的请求多半来自新账户。
*/
function switchMoment(
previous: AccountSegment,
next: AccountSegment,
hints: readonly LoginHint[],
): string | null {
const inGap = hints.filter(
(hint) => hint.at > previous.lastSeen && hint.at <= next.firstSeen,
);
const latest = (source: LoginHint["source"]) =>
inGap
.filter((hint) => hint.source === source)
.map((hint) => hint.at)
.sort()
.at(-1) ?? null;
return latest("transcript") ?? latest("credential");
}

/**
* JSONL 行不带账户,只能用观察时间线推断:区间内归该账户;两段同账户区间之间的空档
* 视为未切换;不同账户之间、首段之前(未绑定)和最后一次观察之后都无法判断,归入未归属。
* 视为未切换;首段之前(未绑定)和最后一次观察之后无法判断,归入未归属。
*
* 不同账户之间的空档:传入 hints 时按切换时刻分给前后两个账户,用于用量;
* 不传时保持无法判断,用于额度采样,避免把切换前账户的额度读数挂到新账户上。
*/
export function attribute(
occurredAt: string,
segments: readonly AccountSegment[],
binding: HistoryBinding | null,
hints: readonly LoginHint[] | null = null,
): string {
const first = segments[0];
if (!first || occurredAt < first.firstSeen) {
Expand All @@ -34,9 +69,12 @@ export function attribute(
if (occurredAt <= segment.lastSeen) return segment.accountUuid;
const next = segments[index + 1];
if (next && occurredAt < next.firstSeen) {
return next.accountUuid === segment.accountUuid
if (next.accountUuid === segment.accountUuid) return segment.accountUuid;
if (!hints) return UNATTRIBUTED_ACCOUNT_ID;
const switchedAt = switchMoment(segment, next, hints);
return switchedAt !== null && occurredAt < switchedAt
? segment.accountUuid
: UNATTRIBUTED_ACCOUNT_ID;
: next.accountUuid;
}
}
return UNATTRIBUTED_ACCOUNT_ID;
Expand Down
61 changes: 60 additions & 1 deletion src/collector/claude-code/credential.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { readFileSync, statSync } from "node:fs";
import { userInfo } from "node:os";
import { join } from "node:path";

Expand Down Expand Up @@ -29,6 +29,14 @@ export type CredentialTextReader = (
location: CredentialLocation,
) => string | null;

/**
* 凭据的修改标记,凭据每次被改写都会变化;读不到返回 null。只取条目属性或文件状态,
* 不读取令牌。测试可替换,生产实现见 readCredentialVersion。
*/
export type CredentialVersionReader = (
location: CredentialLocation,
) => string | null;

const keychainTimeoutMs = 5_000;

/** 与 Claude Code 保持一致的钥匙串条目名和账户名。 */
Expand Down Expand Up @@ -83,6 +91,57 @@ export const readCredentialText: CredentialTextReader = (location) => {
}
};

/**
* 钥匙串条目取修改时间属性;不带 -w 时 security 只输出属性,不输出密码,也不需要授权。
* 与 readCredentialText 取同一来源:钥匙串没有条目时取 .credentials.json 的文件状态。
*/
export const readCredentialVersion: CredentialVersionReader = (location) => {
if (process.platform === "darwin") {
const result = spawnSync(
"/usr/bin/security",
[
"find-generic-password",
"-s",
keychainService(location),
"-a",
keychainAccount(),
],
{ encoding: "utf8", timeout: keychainTimeoutMs, maxBuffer: 1024 * 1024 },
);
if (result.status === 0) {
const modified = /"mdat"<timedate>=0x[0-9A-F]+\s+"(\d{14}Z)/.exec(
result.stdout ?? "",
)?.[1];
return modified ? `keychain:${modified}` : null;
}
}
try {
const stat = statSync(join(location.claudeRoot, ".credentials.json"));
return `file:${stat.mtimeMs}:${stat.size}`;
} catch {
return null;
}
};

/** 从修改标记取出凭据最近一次改写的时刻(ISO UTC);没有或无法解析时返回 null。 */
export function credentialModifiedAt(version: string | null): string | null {
const keychain =
/^keychain:(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})Z$/.exec(
version ?? "",
);
const at = keychain
? Date.UTC(
Number(keychain[1]),
Number(keychain[2]) - 1,
Number(keychain[3]),
Number(keychain[4]),
Number(keychain[5]),
Number(keychain[6]),
)
: Number(/^file:([\d.]+):/.exec(version ?? "")?.[1] ?? NaN);
return Number.isFinite(at) ? new Date(at).toISOString() : null;
}

function text(value: unknown): string | null {
return typeof value === "string" && value.length > 0 && value.length <= 64
? value
Expand Down
24 changes: 24 additions & 0 deletions src/collector/claude-code/login.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** 会话里执行 /login 成功后,Claude Code 写入的本地命令输出。 */
const loginSuccess =
"<local-command-stdout>Login successful</local-command-stdout>";

/**
* 从会话记录行识别 /login 成功的时刻;不是该行时返回 null。只认整条消息正好是这段输出的
* 用户行,工具输出或对话里引用到的同样文字不算。
*/
export function loginMoment(raw: string): string | null {
if (!raw.includes(loginSuccess)) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (parsed === null || typeof parsed !== "object") return null;
const line = parsed as Record<string, unknown>;
const message = line.message as Record<string, unknown> | null | undefined;
if (line.type !== "user" || message?.content !== loginSuccess) return null;
if (typeof line.timestamp !== "string") return null;
const at = Date.parse(line.timestamp);
return Number.isFinite(at) ? new Date(at).toISOString() : null;
}
Loading
Loading
0