agentacct 本地 Agent 工作账本
agentacct 是一个 local-first 的 coding-agent 工作账本:它读取 Codex、Claude Code、Hermes、OpenCode 等本地使用证据,却刻意不保存 prompt、response 或工具参数;再把使用、工作事件、证据链接和受控执行投影到只在本机暴露的 dashboard。
它解决什么,凭什么值得拆
agentacct 解决的是 coding-agent 时代一个很具体的问题:团队开了很多 Codex、Claude Code、OpenCode 或自建代理后,谁做了什么、花了多少 token、哪些工作有机器证据,往往散落在不同客户端日志里。源码入口 src/agent_chronicle/source_discovery.py:37 先把这些客户端按来源探测出来,src/agent_chronicle/usage_truth.py:49 又把每种来源的可信等级写成表,而不是把所有数字都当成账单真相。src/agent_chronicle/source_discovery.py:37,这里再看 src/agent_chronicle/source_discovery.py:48,这里再看 src/agent_chronicle/source_discovery.py:58,这里再看 src/agent_chronicle/source_discovery.py:62,这里再看 src/agent_chronicle/source_discovery.py:65,这里再看 src/agent_chronicle/source_discovery.py:86,这里再看 src/agent_chronicle/source_discovery.py:95,这里再看 src/agent_chronicle/source_discovery.py:126,这里再看 src/agent_chronicle/source_discovery.py:140,这里再看 src/agent_chronicle/source_discovery.py:149
更值得拆的是它的克制:src/agent_chronicle/capture_runtime.py:18 明确 raw hook input 不回显,src/agent_chronicle/control_web.py:50 对控制台投影做脱敏,src/agent_chronicle/canonical/sqlite.py:50 用 application id、schema version 和 owner-only 文件权限约束候选库。这个项目不是另一个自动调度器,而是试图把本地证据、成本估算和可控执行分层。src/agent_chronicle/log_evidence.py:54,这里再看 src/agent_chronicle/log_evidence.py:62,这里再看 src/agent_chronicle/log_evidence.py:74,这里再看 src/agent_chronicle/log_evidence.py:113,这里再看 src/agent_chronicle/log_evidence.py:124,这里再看 src/agent_chronicle/log_evidence.py:133,这里再看 src/agent_chronicle/log_evidence.py:141,这里再看 src/agent_chronicle/log_evidence.py:159,这里再看 src/agent_chronicle/log_evidence.py:167,这里再看 src/agent_chronicle/log_evidence.py:189
▸核心数据
深度阅读 → 入口展开。
本地日志发现与隐私边界
第一层不是导入,而是发现。src/agent_chronicle/source_discovery.py:45 返回六类来源的状态,src/agent_chronicle/source_discovery.py:58 和 src/agent_chronicle/source_discovery.py:86 分别处理 Claude Code 与 Codex,重要的是它只证明“来源存在”和“可导入路径”,不把未解析内容包装成确定成本。tests/test_source_discovery.py:63,这里再看 tests/test_source_discovery.py:82,这里再看 tests/test_source_discovery.py:118,这里再看 tests/test_source_discovery.py:141,这里再看 tests/test_source_discovery.py:151,这里再看 tests/test_source_discovery.py:172,这里再看 tests/test_source_discovery.py:179,这里再看 tests/test_source_discovery.py:201
▸系统拓扑
▸模块清单
模块分工很清楚:src/agent_chronicle/source_discovery.py:37 管发现,src/agent_chronicle/log_evidence.py:113 管从客户端日志里抽取可信 event id,src/agent_chronicle/canonical/sqlite.py:228 管候选库迁移,src/agent_chronicle/control_web.py:50 管 dashboard 投影脱敏。src/agent_chronicle/canonical/sqlite.py:50,这里再看 src/agent_chronicle/canonical/sqlite.py:56,这里再看 src/agent_chronicle/canonical/sqlite.py:61,这里再看 src/agent_chronicle/canonical/sqlite.py:162,这里再看 src/agent_chronicle/canonical/sqlite.py:228,这里再看 src/agent_chronicle/canonical/sqlite.py:257,这里再看 src/agent_chronicle/canonical/sqlite.py:281,这里再看 src/agent_chronicle/canonical/sqlite.py:291,这里再看 src/agent_chronicle/canonical/sqlite.py:300,这里再看 src/agent_chronicle/canonical/sqlite.py:303
▸关键数据结构
18@dataclass(frozen=True) 19class UsageSourceDiscovery: 20 client: str 21 display_name: str 22 status: SourceStatus 23 evidence: str 24 paths: list[str] 25 file_count: int 26 session_count: int | None 27 latest_updated_at: int | None 28 usage_confidence: str 29 cost_confidence: str 30 importer: str | None 31 notes: list[str] 32 33 def to_dict(self) -> dict[str, object]: 34 return asdict(self) 35
证据绑定与 canonical truth model
▸Usage source discovery:发现但不越权
src/agent_chronicle/source_discovery.py:86 的 Codex 分支同时看 state_5.sqlite 和 rollout JSONL,src/agent_chronicle/source_discovery.py:128 的注释说明 rollout identity 可以形成 session observation,但 usage truth 仍交给 ingestion health。这个设计把“有 session”与“有可信 token/cost”拆开。src/agent_chronicle/control_web.py:50,这里再看 src/agent_chronicle/control_web.py:57,这里再看 src/agent_chronicle/control_web.py:62,这里再看 src/agent_chronicle/control_web.py:77,这里再看 src/agent_chronicle/control_web.py:100,这里再看 src/agent_chronicle/control_web.py:130,这里再看 src/agent_chronicle/control_web.py:150,这里再看 src/agent_chronicle/control_web.py:172,这里再看 src/agent_chronicle/control_web.py:181,这里再看 src/agent_chronicle/control_web.py:208
86def _discover_codex_source(codex_home: Path | None) -> UsageSourceDiscovery: 87 source_plan = resolve_usage_source_paths("codex", explicit_home=codex_home) 88 homes = list(source_plan.homes) 89 session_roots = [path for home in homes for path in (home / "sessions", home / "archived_sessions")] 90 active_session_roots = [home / "sessions" for home in homes] 91 files_by_home = [ 92 _dedupe_paths(_matching_files(root, ["rollout-*.jsonl"])) 93 for root in active_session_roots 94 ] 95 files = _dedupe_paths(path for home_files in files_by_home for path in home_files) 96 db_paths_by_home = [ 97 home / "state_5.sqlite" if _is_file(home / "state_5.sqlite") else None 98 for home in homes 99 ] 100 db_paths = [path for path in db_paths_by_home if path is not None] 101 db_session_ids_by_home = [ 102 _sqlite_text_values(db_path, "threads", "id") if db_path is not None else set() 103 for db_path in db_paths_by_home 104 ] 105 db_usage_counts = [ 106 _sqlite_count(db_path, "threads", where="tokens_used > 0") if db_path is not None else 0 107 for db_path in db_paths_by_home 108 ] 109 db_error_count = sum( 110 session_ids is None or usage_count is None 111 for session_ids, usage_count in zip(db_session_ids_by_home, db_usage_counts, strict=True) 112 ) 113 db_usage_count = sum(count or 0 for count in db_usage_counts) 114 rollout_session_ids_by_home = [ 115 _codex_rollout_session_ids(home_files, root=root) 116 for home_files, root in zip(files_by_home, active_session_roots, strict=True) 117 ] 118 session_count = sum( 119 len((db_session_ids or set()) | rollout_session_ids) 120 for db_session_ids, rollout_session_ids in zip( 121 db_session_ids_by_home, 122 rollout_session_ids_by_home, 123 strict=True, 124 ) 125 ) 126 paths = _existing_or_attempted_paths([*db_paths, *session_roots]) 127 latest = _latest_mtime([*files, *db_paths]) 128 # Rollout identity now produces an honest session observation even when a 129 # state-db row or token_count event is absent. Source discovery is only a 130 # presence scan, so a rollout file is enough to expose the import path; 131 # parse/usage truth remains the ingestion health endpoint's job. 132 found = session_count > 0 133 notes = ["rollout session identity and token_count events; local cost usually unavailable"] 134 if db_error_count: 135 notes.append( 136 f"{db_error_count} Codex state database(s) could not be read; inspect or repair state_5.sqlite" 137 ) 138 if source_plan.omitted_home_count: 139 notes.append(f"source path plan omitted {source_plan.omitted_home_count} path(s) after its safety limit") 140 return UsageSourceDiscovery( 141 client="codex", 142 display_name="Codex", 143 status="error" if db_error_count else "found" if found else "missing", 144 evidence="state-db+rollout-jsonl", 145 paths=paths, 146 file_count=len(files), 147 session_count=session_count or None, 148 latest_updated_at=latest, 149 usage_confidence=USAGE_CLIENT_REPORTED if db_usage_count else USAGE_UNKNOWN, 150 cost_confidence=COST_UNKNOWN, 151 importer="agentacct usage import-local --client codex" if found else None, 152 notes=notes, 153 )
▸Log evidence:allowlist-first 的事件配对
src/agent_chronicle/log_evidence.py:54 固定五个 creation tools,src/agent_chronicle/log_evidence.py:113 严格检查单个 created event shape,src/agent_chronicle/log_evidence.py:141 和 src/agent_chronicle/log_evidence.py:189 又分别处理 Codex namespace 与新 MCP invocation 形态。src/agent_chronicle/capture_runtime.py:11,这里再看 src/agent_chronicle/capture_runtime.py:18,这里再看 src/agent_chronicle/capture_runtime.py:20,这里再看 src/agent_chronicle/capture_runtime.py:33,这里再看 src/agent_chronicle/capture_runtime.py:41,这里再看 src/agent_chronicle/capture_runtime.py:53,这里再看 src/agent_chronicle/capture_runtime.py:73,这里再看 src/agent_chronicle/capture_runtime.py:85,这里再看 src/agent_chronicle/capture_runtime.py:96,这里再看 src/agent_chronicle/capture_runtime.py:99
54# The ONLY tools whose responses may donate an evidenced event id. Every one 55# of them returns exactly one created event under a top-level "event" key 56# (mcp.py); read tools (sentinel_list_events, sentinel_get_event_summary, 57# sentinel_list_runs, sentinel_get_report, ...) echo foreign ids and must 58# never appear here. 59# frozen: the sentinel_* tool names survive the Agent Chronicle rename — 60# historical stores/logs/files carry them forever, and three shipped 61# instruction surfaces plus real user CLAUDE.md/AGENTS.md files quote them. 62SENTINEL_CREATION_TOOLS = frozenset( 63 { 64 "sentinel_record_event", 65 "sentinel_attach_client_context", 66 "sentinel_record_section", 67 "sentinel_record_agent_usage_debug", 68 "sentinel_record_machine_check", 69 } 70) 71 72# \Z (not $) so a trailing newline is rejected: 'evt_deadbeef\n' must NOT 73# pass at any of the .match() call sites ($ matches before a final newline). 74EVIDENCED_EVENT_ID_RE = re.compile(r"^evt_[0-9a-f]{6,64}\Z") 75 76# Real max observed in one session: 157 distinct created ids. The cap bounds 77# hostile/huge sessions; the true total is always preserved alongside so 78# overflow is visible, never silent. 79EVIDENCED_EVENT_IDS_CAP = 200 80 81# Codex fork rollouts can begin by replaying the parent's transcript, including 82# old MCP creation responses. A small clock-skew allowance keeps near-boundary 83# facts joinable while still rejecting the impossible case: an event recorded 84# well before the alleged donor session existed. 85CODEX_REPLAY_CLOCK_SKEW_SECONDS = 5.0 86CODEX_REPLAY_REJECTION_REASON = "codex_event_before_session_start" 87CODEX_REPLAY_TIME_UNPROVEN_REASON = "codex_replay_timestamps_unproven" 88SOURCE_NAMESPACE_CONFLICT_REASON = "source_namespace_conflict" 89CROSS_KIND_DONOR_IDENTITY_CONFLICT_REASON = "cross_kind_donor_identity_conflict" 90CANONICAL_EVENT_IDENTITY_CONFLICT_REASON = "canonical_event_identity_conflict" 91 92# The documented MCP registration names (every current setup path writes 93# "agentacct"). Claude Code transcripts carry the registration key verbatim in 94# `mcp__<server>__<tool>`; Codex normalizes the config key to underscores in the 95# rollout `namespace` field (`mcp__agent_chronicle` / `mcp__agent_sentinel`; the 96# hyphen-free `agentacct` normalizes to itself). A custom registration name 97# loses evidence links (skipped-but-counted, never guessed) — documented in 98# INSTALL.md. 99# 100# ALL THREE names are accepted FOREVER: `agentacct` is what new installs now 101# register, and the pre-rename `agent-chronicle` / `agent-sentinel` keys stay 102# accepted because historical client logs carry them forever and pre-rename 103# registrations keep producing them in new sessions — dropping an old key would 104# silently unlink every pre-rename session on the next import. 105SENTINEL_SERVER_KEY = "agentacct" # the name new installs register 106ACCEPTED_SERVER_KEYS = frozenset({"agentacct", "agent-chronicle", "agent-sentinel"}) 107_CODEX_ACCEPTED_NAMESPACES = frozenset( 108 {"agentacct", "agent-chronicle", "agent_chronicle", "agent-sentinel", "agent_sentinel"} 109) 110_CLAUDE_TOOL_PREFIX = "mcp__" 111 112 113def extract_created_event_id(text: str) -> str | None: 114 """Strict single-created-event shape check shared by every wire adapter. 115 116 Accepts exactly the creation-tool payload shape: a JSON dict with a 117 top-level ``"event"`` dict whose ``event_id`` is a well-formed evt id. 118 Rejects (belt-and-braces — the tool-name allowlist already excludes read 119 tools): list-shaped payloads carrying ``"events"``/``"runs"`` lists, 120 error texts, truncated/torn JSON, outcome-only machine_check payloads, 121 and any non-dict shape. 122 """ 123 124 try: 125 payload = json.loads(text) 126 except (json.JSONDecodeError, ValueError): 127 return None 128 if not isinstance(payload, dict): 129 return None 130 event = payload.get("event") 131 if not isinstance(event, dict): 132 return None 133 if any(isinstance(payload.get(key), list) for key in ("events", "runs")): 134 return None 135 event_id = event.get("event_id") 136 if not isinstance(event_id, str) or not EVIDENCED_EVENT_ID_RE.match(event_id): 137 return None 138 return event_id
从采集到控制台的读写流
写入路径和展示路径都在强调 fail closed。src/agent_chronicle/capture_runtime.py:41 捕获 host hook payload,异常时返回 fail_open receipt 但不让 host hook 失败;src/agent_chronicle/control_web.py:172 又把外部 Codex/Claude 标为 observed_only,避免 dashboard 暗示它能控制未由 agentacct 启动的进程。src/agent_chronicle/source_discovery.py:37,这里再看 src/agent_chronicle/source_discovery.py:48,这里再看 src/agent_chronicle/source_discovery.py:58,这里再看 src/agent_chronicle/source_discovery.py:62,这里再看 src/agent_chronicle/source_discovery.py:65,这里再看 src/agent_chronicle/source_discovery.py:86,这里再看 src/agent_chronicle/source_discovery.py:95,这里再看 src/agent_chronicle/source_discovery.py:126,这里再看 src/agent_chronicle/source_discovery.py:140,这里再看 src/agent_chronicle/source_discovery.py:149
▸Usage evidence pipeline
▸Owned execution control pipeline
测试把安全声明钉到行为上
测试覆盖不是装饰。tests/test_source_discovery.py:63 验证 found/missing/importer/confidence,tests/test_log_evidenced_linking.py:188 钉住五个 creation tools 的配对规则,tests/test_canonical_schema.py:49 明确 schema 不得出现 prompt/response/tool_results 字段,tests/test_control_api.py:81 验证控制 API 不泄露路径、argv 或 nonce。
▸测试覆盖
下面这段测试证明 source discovery 把 Codex、Claude、Hermes、OpenClaw 和 OpenCode 分别报告,不会因某个来源缺失就制造成功。它还让 tests/test_source_discovery.py:82 到 tests/test_source_discovery.py:91 的断言覆盖 session_count、cost_confidence 与 importer。tests/test_source_discovery.py:63,这里再看 tests/test_source_discovery.py:82,这里再看 tests/test_source_discovery.py:118,这里再看 tests/test_source_discovery.py:141,这里再看 tests/test_source_discovery.py:151,这里再看 tests/test_source_discovery.py:172,这里再看 tests/test_source_discovery.py:179,这里再看 tests/test_source_discovery.py:201
63def test_discover_usage_sources_reports_found_and_pending_sources(tmp_path): 64 codex_home = _make_codex_source(tmp_path) 65 claude_home = _make_claude_source(tmp_path) 66 hermes_home = _make_hermes_source(tmp_path) 67 openclaw_home = tmp_path / "openclaw-home" 68 openclaw_home.mkdir() 69 (openclaw_home / "session.jsonl").write_text("{}", encoding="utf-8") 70 71 sources = { 72 source.client: source 73 for source in discover_usage_sources( 74 codex_home=codex_home, 75 claude_home=claude_home, 76 hermes_home=hermes_home, 77 openclaw_home=openclaw_home, 78 opencode_home=tmp_path / "missing-opencode", 79 ) 80 } 81 82 assert sources["codex"].status == "found" 83 assert sources["codex"].session_count == 2 84 assert sources["codex"].importer == "agentacct usage import-local --client codex" 85 assert sources["claude-code"].status == "found" 86 assert sources["hermes"].status == "found" 87 assert sources["hermes"].cost_confidence == "client_reported" 88 assert sources["openclaw"].status == "found" 89 assert sources["openclaw"].usage_confidence == "unknown" 90 assert sources["openclaw"].importer == "agentacct usage import-local --client openclaw" 91 assert sources["opencode"].status == "missing"
▸已知边界 / 失败模式
- Codex state DB 可读但 tokens_used 全为 0 时,
tests/test_source_discovery.py:179仍只标 usage_confidence unknown,不假装有用量真相。 - Read tool 输出即使包含 event_id,也在
tests/test_log_evidenced_linking.py:222被证明不能捐赠证据,避免 foreign id 污染。 - 候选 SQLite 路径若落在 live agentacct 或 Codex state 下,
tests/test_canonical_schema.py:203期望直接拒绝创建。 - 控制 API 的 JSON 投影在
tests/test_control_api.py:81检查不得包含 store_dir、argv_template、process_group_id、nonce 等执行权柄。
▸捕获 receipt 不回显原始 payload
src/agent_chronicle/capture_runtime.py:11 到 src/agent_chronicle/capture_runtime.py:38 把 capture 结果转成 body-safe receipt,明确记录 src/agent_chronicle/capture_runtime.py:34 的 metadata_only 和 src/agent_chronicle/capture_runtime.py:35 的 raw_payload_returned=false;这让 hook 集成可以反馈状态,却不把原始提示、响应或工具内容带回页面。
11def capture_write_result_payload( 12 result: CaptureWriteResult, 13 *, 14 vendor: str, 15 host_event: str, 16 enabled: bool = True, 17) -> dict[str, Any]: 18 """Return a body-safe receipt; raw hook input is never echoed.""" 19 20 return { 21 "schema_version": "agent-chronicle.capture-receipt.v1", 22 "enabled": enabled, 23 "vendor": vendor, 24 "host_event": host_event, 25 "ok": result.ok, 26 "fail_open": result.fail_open, 27 "attempted_count": result.attempted_count, 28 "stored_count": result.stored_count, 29 "ignored_reason": result.capture.ignored_reason or None, 30 "warnings": list(result.capture.warnings), 31 "write_errors": list(result.write_errors), 32 "observations": [observation.to_dict() for observation in result.capture.observations], 33 "privacy": { 34 "capture_mode": "metadata_only", 35 "raw_payload_returned": False, 36 "hook_exit_policy": "always_zero", 37 }, 38 }
▸控制台投影只保留决策状态
src/agent_chronicle/control_web.py:50 到 src/agent_chronicle/control_web.py:178 生成 dashboard payload,但 src/agent_chronicle/control_web.py:172 到 src/agent_chronicle/control_web.py:177 明确区分 owned_execution 与 observed_only;这也是 tests/test_control_api.py:81 不允许路径、argv 和进程权柄泄露的原因。
150 active_execution = {"pending", "launching", "running", "cancel_requested"} 151 summary = { 152 "task_count": len(tasks), 153 "attempt_count": len(attempts), 154 "active_attempt_count": sum(row["execution_state"] in active_execution for row in attempts), 155 "running_attempt_count": sum(row["execution_state"] in {"launching", "running", "cancel_requested"} for row in attempts), 156 "pending_approval_count": sum(row["state"] == "pending" for row in approvals), 157 } 158 return { 159 "schema_version": CONTROL_WEB_SCHEMA_VERSION, 160 "summary": summary, 161 "tasks": tasks, 162 "contracts": contracts, 163 "agents": agents, 164 "workspaces": workspaces, 165 "attempts": attempts, 166 "approvals": approvals, 167 "budget_policies": budgets, 168 "schedules": schedules, 169 "events": events, 170 "issues": [{"code": item.code, "line_number": item.line_number} for item in projection.issues], 171 "observed_tasks": observed, 172 "authority_boundary": { 173 "owned_execution": "controllable", 174 "external_codex": "observed_only", 175 "external_claude_code": "observed_only", 176 "external_orchestrators": "observed_only", 177 }, 178 }
为什么它值得进入工具箱
技术亮点之外,别忽略这些
最大风险是项目很新且表面较大:src/agent_chronicle/api.py:19 到 src/agent_chronicle/api.py:169 的 import 扇入显示它已经聚合大量模块,长期维护需要防止 API 层变成中心泥球。另一个风险是本地日志格式由上游客户端决定,src/agent_chronicle/log_evidence.py:189 也能看到它必须追踪 Codex wire shape 变化。