@m13v/s4l 1.6.197-rc.10
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 +143 -0
- package/SKILL.md +342 -0
- package/bin/cli.js +980 -0
- package/bin/cookie-helper.js +315 -0
- package/bin/platform.js +59 -0
- package/bin/scheduler/index.js +12 -0
- package/bin/scheduler/launchd.js +518 -0
- package/browser-agent-configs/all-agents-mcp.json +68 -0
- package/browser-agent-configs/linkedin-agent-mcp.json +16 -0
- package/browser-agent-configs/linkedin-agent.json +17 -0
- package/browser-agent-configs/linkedin-harness-mcp.json +21 -0
- package/browser-agent-configs/reddit-agent-mcp.json +16 -0
- package/browser-agent-configs/reddit-agent.json +17 -0
- package/browser-agent-configs/twitter-harness-mcp.json +18 -0
- package/config.example.json +45 -0
- package/mcp/dist/index.js +4212 -0
- package/mcp/dist/onboarding.js +200 -0
- package/mcp/dist/panel.html +176 -0
- package/mcp/dist/product-link.html +102 -0
- package/mcp/dist/repo.js +222 -0
- package/mcp/dist/runtime.js +1079 -0
- package/mcp/dist/screencast.js +323 -0
- package/mcp/dist/setup.js +545 -0
- package/mcp/dist/telemetry.js +306 -0
- package/mcp/dist/twitterAuth.js +138 -0
- package/mcp/dist/version.js +271 -0
- package/mcp/dist/version.json +4 -0
- package/mcp/install-runtime.mjs +70 -0
- package/mcp/install.mjs +169 -0
- package/mcp/manifest.json +80 -0
- package/mcp/menubar/dashboard_server.py +213 -0
- package/mcp/menubar/s4l_card.py +1336 -0
- package/mcp/menubar/s4l_log_relay.py +179 -0
- package/mcp/menubar/s4l_menubar.py +2439 -0
- package/mcp/menubar/s4l_state.py +891 -0
- package/mcp/package.json +34 -0
- package/mcp/shared/doctor.cjs +437 -0
- package/mcp/shared/onboarding-ledger.cjs +324 -0
- package/mcp-servers/browser-harness/server.py +968 -0
- package/package.json +160 -0
- package/requirements.txt +20 -0
- package/scripts/_compute_allowlist.py +58 -0
- package/scripts/_db_update.py +20 -0
- package/scripts/_filt.py +9 -0
- package/scripts/_li_notif_match.py +76 -0
- package/scripts/_li_notif_orchestrate.py +126 -0
- package/scripts/_lock_preempt_test.py +60 -0
- package/scripts/_run_icp_precheck.py +57 -0
- package/scripts/a16z_pearx_calendar_reminders.py +99 -0
- package/scripts/account_resolver.py +141 -0
- package/scripts/active_campaigns.py +114 -0
- package/scripts/active_users.py +190 -0
- package/scripts/amplitude_24h_signups.py +468 -0
- package/scripts/amplitude_signups.py +177 -0
- package/scripts/apply_onboarding_selections.py +131 -0
- package/scripts/audience_pages.py +243 -0
- package/scripts/audit_helper.py +120 -0
- package/scripts/author_history_block.py +353 -0
- package/scripts/autopilot_stall_watch.py +284 -0
- package/scripts/backfill_twitter_attempts_topic.py +81 -0
- package/scripts/backfill_twitter_log_post_no_id.py +322 -0
- package/scripts/bench_dashboard.sh +138 -0
- package/scripts/bh_send.py +39 -0
- package/scripts/build_persona.py +409 -0
- package/scripts/bulk_icp.py +18 -0
- package/scripts/campaign_bump.py +51 -0
- package/scripts/capture_thread_media.py +288 -0
- package/scripts/check_browser_lock_health.sh +81 -0
- package/scripts/check_external_pool_depth.py +253 -0
- package/scripts/check_unread_web_chats.py +28 -0
- package/scripts/claim_web_chat.py +47 -0
- package/scripts/classify_run_error.py +158 -0
- package/scripts/claude_job.py +988 -0
- package/scripts/clean_stale_singleton.sh +56 -0
- package/scripts/cleanup_harness_tabs.py +68 -0
- package/scripts/copy_browser_cookies.py +454 -0
- package/scripts/counterparty_history.py +350 -0
- package/scripts/db.py +57 -0
- package/scripts/discover_claude_profiles.py +120 -0
- package/scripts/discover_linkedin_candidates.py +984 -0
- package/scripts/dm_conversation.py +682 -0
- package/scripts/dm_db_update.py +69 -0
- package/scripts/dm_engage_helper.py +161 -0
- package/scripts/dm_outreach_helper.py +147 -0
- package/scripts/dm_outreach_twitter_helper.py +129 -0
- package/scripts/dm_send_log.py +106 -0
- package/scripts/dm_short_links.py +1084 -0
- package/scripts/dump_web_chat_history.py +47 -0
- package/scripts/engage_github.py +640 -0
- package/scripts/engage_reddit.py +1235 -0
- package/scripts/engage_twitter_helper.py +301 -0
- package/scripts/engagement_styles.py +1787 -0
- package/scripts/enrich_twitter_candidates.py +82 -0
- package/scripts/feedback_digest.py +448 -0
- package/scripts/fetch_prospect_profile.py +312 -0
- package/scripts/fetch_twitter_t1.py +134 -0
- package/scripts/find_threads.py +530 -0
- package/scripts/follow_gate_log.py +59 -0
- package/scripts/funnel_per_day.py +194 -0
- package/scripts/generate_daily_human_style.py +494 -0
- package/scripts/generation_trace.py +173 -0
- package/scripts/get_run_cost.py +107 -0
- package/scripts/github_engage_helper.py +93 -0
- package/scripts/github_tools.py +509 -0
- package/scripts/harness_overlay.py +556 -0
- package/scripts/harvest_twitter_following.py +243 -0
- package/scripts/heartbeat.sh +70 -0
- package/scripts/history_context.py +284 -0
- package/scripts/http_api.py +206 -0
- package/scripts/human_dm_replies_helper.py +169 -0
- package/scripts/identity.py +302 -0
- package/scripts/ig_batch_creator.sh +93 -0
- package/scripts/ig_post_type_picker.py +243 -0
- package/scripts/ig_scrape_transcribe.sh +91 -0
- package/scripts/ingest_human_dm_replies.py +271 -0
- package/scripts/ingest_web_chat_replies.py +229 -0
- package/scripts/install_fleet.py +187 -0
- package/scripts/invent_mcp_server.py +350 -0
- package/scripts/invent_topics.py +1462 -0
- package/scripts/learned_preferences.py +263 -0
- package/scripts/li_discovery.py +161 -0
- package/scripts/link_edit_helper.py +142 -0
- package/scripts/link_tail.py +592 -0
- package/scripts/linkedin_api.py +561 -0
- package/scripts/linkedin_browser.py +730 -0
- package/scripts/linkedin_cooldown.py +128 -0
- package/scripts/linkedin_exclusions.py +234 -0
- package/scripts/linkedin_killswitch.py +1333 -0
- package/scripts/linkedin_search_topic_schema.py +49 -0
- package/scripts/linkedin_unipile.py +658 -0
- package/scripts/linkedin_url.py +228 -0
- package/scripts/log_claude_session.py +636 -0
- package/scripts/log_draft.py +143 -0
- package/scripts/log_linkedin_search_attempts.py +126 -0
- package/scripts/log_post.py +651 -0
- package/scripts/log_run.py +364 -0
- package/scripts/log_thread_media.py +108 -0
- package/scripts/log_twitter_search_attempts.py +150 -0
- package/scripts/log_twitter_skips.py +211 -0
- package/scripts/lookup_post.py +78 -0
- package/scripts/mark_web_chat_processed.py +32 -0
- package/scripts/mcp_lock_proxy.py +370 -0
- package/scripts/memory_snapshot.py +972 -0
- package/scripts/merge_review_queue.py +215 -0
- package/scripts/mint_external_pool.py +182 -0
- package/scripts/mint_kent_pool.py +249 -0
- package/scripts/moltbook_post.py +320 -0
- package/scripts/moltbook_tools.py +159 -0
- package/scripts/pending_threads.py +188 -0
- package/scripts/pick_ig_account.py +177 -0
- package/scripts/pick_project.py +208 -0
- package/scripts/pick_search_topic.py +771 -0
- package/scripts/pick_thread_target.py +279 -0
- package/scripts/pick_twitter_thread_target.py +202 -0
- package/scripts/podlog_fetch_batch.sh +32 -0
- package/scripts/post_github.py +1311 -0
- package/scripts/post_reddit.py +2668 -0
- package/scripts/precompute_dashboard_stats.py +204 -0
- package/scripts/preflight.sh +297 -0
- package/scripts/progress.py +88 -0
- package/scripts/project_excludes.py +353 -0
- package/scripts/project_slugs.py +91 -0
- package/scripts/project_stats.py +241 -0
- package/scripts/project_stats_json.py +1563 -0
- package/scripts/project_topics.py +192 -0
- package/scripts/qualified_query_bank.py +436 -0
- package/scripts/reap_stale_claude_sessions.py +867 -0
- package/scripts/reddit_browser.py +2549 -0
- package/scripts/reddit_browser_fetch.py +141 -0
- package/scripts/reddit_browser_lock.py +593 -0
- package/scripts/reddit_chat_sync.py +710 -0
- package/scripts/reddit_query_bank.py +200 -0
- package/scripts/reddit_threads_helper.py +151 -0
- package/scripts/reddit_tools.py +956 -0
- package/scripts/refresh_instagram_tokens.py +280 -0
- package/scripts/release-mcpb.sh +513 -0
- package/scripts/reply_db.py +334 -0
- package/scripts/reply_insert.py +98 -0
- package/scripts/reply_risk_digest.py +761 -0
- package/scripts/reset-test-machine.sh +602 -0
- package/scripts/restore_twitter_session.py +177 -0
- package/scripts/ripen_reddit_plan.py +478 -0
- package/scripts/run_claude.sh +433 -0
- package/scripts/run_moltbook_cycle.py +555 -0
- package/scripts/s4l_box_update.sh +226 -0
- package/scripts/s4l_channel.py +103 -0
- package/scripts/s4l_ctl.sh +75 -0
- package/scripts/s4l_env.py +47 -0
- package/scripts/saps_activity.py +126 -0
- package/scripts/saps_mode.py +328 -0
- package/scripts/scan_dm_candidates.py +580 -0
- package/scripts/scan_github_replies.py +168 -0
- package/scripts/scan_instagram_comments.py +481 -0
- package/scripts/scan_moltbook_replies.py +252 -0
- package/scripts/scan_pii.py +190 -0
- package/scripts/scan_reddit_replies.py +377 -0
- package/scripts/scan_twitter_mentions_browser.py +327 -0
- package/scripts/scan_twitter_thread_followups.py +299 -0
- package/scripts/scan_x_profile.py +384 -0
- package/scripts/schedule_state.py +202 -0
- package/scripts/scheduled_tasks_snapshot.py +123 -0
- package/scripts/score_linkedin_candidates.py +419 -0
- package/scripts/score_twitter_candidates.py +718 -0
- package/scripts/scrape_linkedin_comment_stats.py +1755 -0
- package/scripts/scrape_linkedin_stats_browser.py +52 -0
- package/scripts/scrape_reddit_views.py +365 -0
- package/scripts/seed_search_queries.py +453 -0
- package/scripts/seed_search_topics.py +127 -0
- package/scripts/send_web_chat_reply.py +130 -0
- package/scripts/sentry_init.py +128 -0
- package/scripts/setup_twitter_auth.py +1320 -0
- package/scripts/snapshot.py +583 -0
- package/scripts/stats.py +2702 -0
- package/scripts/stats_helper.py +52 -0
- package/scripts/strike_alert.py +783 -0
- package/scripts/sweep_post_link_clicks.py +107 -0
- package/scripts/sync_ig_to_posts.py +147 -0
- package/scripts/test_browser_lock.py +189 -0
- package/scripts/test_installation_api.sh +52 -0
- package/scripts/test_percard_posting.py +142 -0
- package/scripts/top_dud_linkedin_queries.py +71 -0
- package/scripts/top_dud_reddit_queries.py +67 -0
- package/scripts/top_dud_twitter_queries.py +71 -0
- package/scripts/top_dud_twitter_topics.py +102 -0
- package/scripts/top_linkedin_queries.py +55 -0
- package/scripts/top_omitted_reddit_topics.py +91 -0
- package/scripts/top_performers.py +588 -0
- package/scripts/top_search_topics.py +180 -0
- package/scripts/top_twitter_queries.py +190 -0
- package/scripts/twitter_access_check.py +382 -0
- package/scripts/twitter_account.py +41 -0
- package/scripts/twitter_batch_phase.py +126 -0
- package/scripts/twitter_browser.py +2804 -0
- package/scripts/twitter_cookie_mirror.py +130 -0
- package/scripts/twitter_cycle_helper.py +310 -0
- package/scripts/twitter_gen_links.py +287 -0
- package/scripts/twitter_post_plan.py +1188 -0
- package/scripts/twitter_scan.py +324 -0
- package/scripts/twitter_supply_signal.py +57 -0
- package/scripts/twitter_threads_helper.py +152 -0
- package/scripts/unclaim_web_chat.py +29 -0
- package/scripts/update_instagram_stats.py +261 -0
- package/scripts/update_linkedin_stats_from_feed.py +328 -0
- package/scripts/version.py +72 -0
- package/scripts/watchdog_hung_runs.py +343 -0
- package/scripts/write_generation_trace.py +73 -0
- package/setup/SKILL.md +277 -0
- package/skill/amplitude-24h-signups.sh +38 -0
- package/skill/archive-old-logs.sh +40 -0
- package/skill/audit-dm-staleness.sh +42 -0
- package/skill/audit-linkedin.sh +14 -0
- package/skill/audit-moltbook.sh +4 -0
- package/skill/audit-reddit-resurrect.sh +67 -0
- package/skill/audit-reddit.sh +4 -0
- package/skill/audit-twitter.sh +4 -0
- package/skill/audit.sh +287 -0
- package/skill/backfill-twitter-attempts-topic.sh +19 -0
- package/skill/backfill-twitter-ghost-posts.sh +24 -0
- package/skill/check-external-pool-depth.sh +7 -0
- package/skill/check-web-chats.sh +203 -0
- package/skill/dm-outreach-linkedin.sh +250 -0
- package/skill/dm-outreach-reddit.sh +274 -0
- package/skill/dm-outreach-twitter.sh +265 -0
- package/skill/engage-dm-replies-linkedin.sh +4 -0
- package/skill/engage-dm-replies-reddit.sh +4 -0
- package/skill/engage-dm-replies-twitter.sh +4 -0
- package/skill/engage-dm-replies.sh +1597 -0
- package/skill/engage-linkedin.sh +581 -0
- package/skill/engage-moltbook.sh +36 -0
- package/skill/engage-reddit.sh +146 -0
- package/skill/engage-twitter.sh +467 -0
- package/skill/github-engage.sh +176 -0
- package/skill/ingest-web-chat-replies.sh +38 -0
- package/skill/invent-supply-test.sh +100 -0
- package/skill/invent-topics.sh +50 -0
- package/skill/lib/linkedin-backend.sh +364 -0
- package/skill/lib/platform.sh +48 -0
- package/skill/lib/reddit-backend.sh +234 -0
- package/skill/lib/twitter-backend.sh +314 -0
- package/skill/link-edit-github.sh +136 -0
- package/skill/link-edit-moltbook.sh +117 -0
- package/skill/link-edit-reddit.sh +201 -0
- package/skill/linkedin-presence.sh +182 -0
- package/skill/linkedin-recovery.sh +282 -0
- package/skill/lock.sh +647 -0
- package/skill/memory-snapshot.sh +39 -0
- package/skill/precompute-stats.sh +35 -0
- package/skill/prewarm-funnel.sh +104 -0
- package/skill/refresh-instagram-tokens.sh +57 -0
- package/skill/refresh-twitter-following.sh +52 -0
- package/skill/reply-risk-digest.sh +31 -0
- package/skill/run-cycle-update-guard.sh +44 -0
- package/skill/run-draft-and-publish.sh +123 -0
- package/skill/run-generate-daily-style.sh +50 -0
- package/skill/run-github-launchd.sh +62 -0
- package/skill/run-github.sh +102 -0
- package/skill/run-instagram-daily.sh +149 -0
- package/skill/run-instagram-render.sh +875 -0
- package/skill/run-linkedin-launchd.sh +81 -0
- package/skill/run-linkedin-unipile.sh +130 -0
- package/skill/run-linkedin.sh +1593 -0
- package/skill/run-moltbook-launchd.sh +61 -0
- package/skill/run-moltbook.sh +38 -0
- package/skill/run-overlay-watch.sh +100 -0
- package/skill/run-reddit-search-launchd.sh +64 -0
- package/skill/run-reddit-search.sh +505 -0
- package/skill/run-reddit-threads-double.sh +32 -0
- package/skill/run-reddit-threads.sh +847 -0
- package/skill/run-scan-moltbook-replies.sh +57 -0
- package/skill/run-twitter-cycle-launchd.sh +63 -0
- package/skill/run-twitter-cycle-singleton.sh +62 -0
- package/skill/run-twitter-cycle.sh +2408 -0
- package/skill/run-twitter-threads.sh +592 -0
- package/skill/scan-instagram-replies.sh +61 -0
- package/skill/scan-twitter-followups.sh +57 -0
- package/skill/social-autoposter-update.sh +66 -0
- package/skill/stats-instagram.sh +72 -0
- package/skill/stats-linkedin.sh +271 -0
- package/skill/stats-moltbook.sh +4 -0
- package/skill/stats-reddit.sh +4 -0
- package/skill/stats-twitter.sh +4 -0
- package/skill/stats.sh +521 -0
- package/skill/strike-alert.sh +18 -0
- package/skill/styles.sh +87 -0
- package/skill/sweep-link-clicks.sh +40 -0
- package/skill/topics.sh +51 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Log a Claude Code session's cost into the claude_sessions table.
|
|
3
|
+
|
|
4
|
+
Reads the session transcript at ~/.claude/projects/<encoded-cwd>/<session_id>.jsonl,
|
|
5
|
+
sums per-model token usage from each assistant turn, applies a local pricing
|
|
6
|
+
table to compute total cost, and inserts one row.
|
|
7
|
+
|
|
8
|
+
Usage:
|
|
9
|
+
python3 scripts/log_claude_session.py \\
|
|
10
|
+
--session-id <uuid> \\
|
|
11
|
+
--script run-linkedin \\
|
|
12
|
+
[--started-at ISO8601] [--ended-at ISO8601]
|
|
13
|
+
|
|
14
|
+
Designed to be called by run_claude.sh after `claude -p --session-id $UUID` exits.
|
|
15
|
+
Idempotent: ON CONFLICT DO NOTHING on session_id.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import glob
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import datetime
|
|
24
|
+
|
|
25
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
PROJECTS_ROOT = os.path.expanduser("~/.claude/projects")
|
|
29
|
+
|
|
30
|
+
# Archive root for post-facto investigation. ~/.claude/projects/ is Claude
|
|
31
|
+
# Code's own scratch — it survives normal runs but is not under our control
|
|
32
|
+
# for retention or rotation, and the encoded-cwd subdirectory layout is
|
|
33
|
+
# annoying to navigate after the fact. We hardlink each finished session's
|
|
34
|
+
# transcript here so investigations of watchdog-killed phases are
|
|
35
|
+
# `tail skill/logs/claude-sessions/<date>/<HHMMSS>_<script>_<sid>.jsonl`
|
|
36
|
+
# instead of forensics across `~/.claude/projects/-/<sid>.jsonl` candidates.
|
|
37
|
+
ARCHIVE_ROOT = os.path.expanduser("~/social-autoposter/skill/logs/claude-sessions")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def find_transcript(session_id: str):
|
|
41
|
+
"""Locate the transcript .jsonl for a session id.
|
|
42
|
+
|
|
43
|
+
Claude Code writes transcripts under `~/.claude/projects/<encoded-cwd>/<uuid>.jsonl`.
|
|
44
|
+
The encoded-cwd depends on the working directory at invocation time:
|
|
45
|
+
interactive runs land under `-Users-matthewdi-social-autoposter`, but
|
|
46
|
+
launchd-fired runs (cwd=/) land under `-`. Glob across all project dirs.
|
|
47
|
+
"""
|
|
48
|
+
matches = glob.glob(os.path.join(PROJECTS_ROOT, "*", f"{session_id}.jsonl"))
|
|
49
|
+
return matches[0] if matches else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def archive_transcript(transcript_path, session_id: str, script: str, started_iso):
|
|
53
|
+
"""Hardlink (or copy) the live transcript into ARCHIVE_ROOT.
|
|
54
|
+
|
|
55
|
+
Best-effort: any failure returns None and the caller proceeds. The
|
|
56
|
+
archive lives under <date>/<HHMMSS>_<script>_<session_id>.jsonl so
|
|
57
|
+
investigators can navigate by day. Hardlink first (free, atomic, and
|
|
58
|
+
keeps the archive in sync if claude appends final bytes between our
|
|
59
|
+
archive call and parse_transcript); fall back to copy across volumes.
|
|
60
|
+
Idempotent: returns the existing path if already archived.
|
|
61
|
+
"""
|
|
62
|
+
if not transcript_path or not os.path.exists(transcript_path):
|
|
63
|
+
return None
|
|
64
|
+
try:
|
|
65
|
+
dt = None
|
|
66
|
+
if started_iso:
|
|
67
|
+
try:
|
|
68
|
+
dt = datetime.fromisoformat(started_iso.replace("Z", "+00:00"))
|
|
69
|
+
except (ValueError, AttributeError):
|
|
70
|
+
dt = None
|
|
71
|
+
if dt is None:
|
|
72
|
+
try:
|
|
73
|
+
dt = datetime.utcfromtimestamp(os.path.getmtime(transcript_path))
|
|
74
|
+
except OSError:
|
|
75
|
+
dt = datetime.utcnow()
|
|
76
|
+
|
|
77
|
+
date_subdir = dt.strftime("%Y-%m-%d")
|
|
78
|
+
time_part = dt.strftime("%H%M%S")
|
|
79
|
+
safe_script = "".join(
|
|
80
|
+
c if (c.isalnum() or c in ("-", "_")) else "_"
|
|
81
|
+
for c in (script or "unknown")
|
|
82
|
+
) or "unknown"
|
|
83
|
+
|
|
84
|
+
archive_dir = os.path.join(ARCHIVE_ROOT, date_subdir)
|
|
85
|
+
os.makedirs(archive_dir, exist_ok=True)
|
|
86
|
+
archive_path = os.path.join(
|
|
87
|
+
archive_dir, f"{time_part}_{safe_script}_{session_id}.jsonl"
|
|
88
|
+
)
|
|
89
|
+
if os.path.exists(archive_path):
|
|
90
|
+
return archive_path
|
|
91
|
+
try:
|
|
92
|
+
os.link(transcript_path, archive_path)
|
|
93
|
+
except OSError:
|
|
94
|
+
import shutil
|
|
95
|
+
shutil.copy2(transcript_path, archive_path)
|
|
96
|
+
return archive_path
|
|
97
|
+
except Exception:
|
|
98
|
+
return None
|
|
99
|
+
|
|
100
|
+
# USD per 1M tokens. Cache_5m / cache_1h are the WRITE rates (Anthropic charges
|
|
101
|
+
# a premium for caching writes); cache_read is the discounted re-read rate.
|
|
102
|
+
# Fallback (unknown model) uses Opus rates so we never underestimate.
|
|
103
|
+
PRICING = {
|
|
104
|
+
"opus": {"input": 15.0, "output": 75.0, "cache_5m": 18.75, "cache_1h": 30.0, "cache_read": 1.5},
|
|
105
|
+
"sonnet": {"input": 3.0, "output": 15.0, "cache_5m": 3.75, "cache_1h": 6.0, "cache_read": 0.3},
|
|
106
|
+
"haiku": {"input": 1.0, "output": 5.0, "cache_5m": 1.25, "cache_1h": 2.0, "cache_read": 0.1},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def price_for_model(model_id: str) -> dict:
|
|
111
|
+
m = (model_id or "").lower()
|
|
112
|
+
if "opus" in m:
|
|
113
|
+
return PRICING["opus"]
|
|
114
|
+
if "sonnet" in m:
|
|
115
|
+
return PRICING["sonnet"]
|
|
116
|
+
if "haiku" in m:
|
|
117
|
+
return PRICING["haiku"]
|
|
118
|
+
return PRICING["opus"]
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def cost_from_usage(model: str, usage: dict) -> float:
|
|
122
|
+
p = price_for_model(model)
|
|
123
|
+
inp = usage.get("input_tokens", 0) or 0
|
|
124
|
+
out = usage.get("output_tokens", 0) or 0
|
|
125
|
+
cache_read = usage.get("cache_read_input_tokens", 0) or 0
|
|
126
|
+
cache_5m = (usage.get("cache_creation") or {}).get("ephemeral_5m_input_tokens", 0) or 0
|
|
127
|
+
cache_1h = (usage.get("cache_creation") or {}).get("ephemeral_1h_input_tokens", 0) or 0
|
|
128
|
+
if not (cache_5m or cache_1h):
|
|
129
|
+
cache_5m = usage.get("cache_creation_input_tokens", 0) or 0
|
|
130
|
+
return (
|
|
131
|
+
inp * p["input"]
|
|
132
|
+
+ out * p["output"]
|
|
133
|
+
+ cache_read * p["cache_read"]
|
|
134
|
+
+ cache_5m * p["cache_5m"]
|
|
135
|
+
+ cache_1h * p["cache_1h"]
|
|
136
|
+
) / 1_000_000
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _parse_subagent_transcript(path: str, meta: dict):
|
|
140
|
+
"""Parse a single agent-<id>.jsonl into a cost summary.
|
|
141
|
+
|
|
142
|
+
Subagent transcripts have the same per-turn shape as the orchestrator
|
|
143
|
+
(assistant turns with usage), but every event carries ``isSidechain:
|
|
144
|
+
true``, an ``agentId``, and references back to the parent via
|
|
145
|
+
``sessionId`` (matches orchestrator's session id).
|
|
146
|
+
"""
|
|
147
|
+
if not os.path.exists(path):
|
|
148
|
+
return None
|
|
149
|
+
by_model = {}
|
|
150
|
+
first_ts = None
|
|
151
|
+
last_ts = None
|
|
152
|
+
turns = 0
|
|
153
|
+
with open(path) as f:
|
|
154
|
+
for line in f:
|
|
155
|
+
try:
|
|
156
|
+
ev = json.loads(line)
|
|
157
|
+
except json.JSONDecodeError:
|
|
158
|
+
continue
|
|
159
|
+
ts = ev.get("timestamp")
|
|
160
|
+
if ts:
|
|
161
|
+
first_ts = first_ts or ts
|
|
162
|
+
last_ts = ts
|
|
163
|
+
if ev.get("type") != "assistant":
|
|
164
|
+
continue
|
|
165
|
+
msg = ev.get("message") or {}
|
|
166
|
+
usage = msg.get("usage") or {}
|
|
167
|
+
if not usage:
|
|
168
|
+
continue
|
|
169
|
+
model = msg.get("model") or "unknown"
|
|
170
|
+
entry = by_model.setdefault(model, {
|
|
171
|
+
"input_tokens": 0, "output_tokens": 0,
|
|
172
|
+
"cache_read_tokens": 0, "cache_creation_tokens": 0,
|
|
173
|
+
"cost_usd": 0.0,
|
|
174
|
+
})
|
|
175
|
+
inp = usage.get("input_tokens", 0) or 0
|
|
176
|
+
out = usage.get("output_tokens", 0) or 0
|
|
177
|
+
cr = usage.get("cache_read_input_tokens", 0) or 0
|
|
178
|
+
cc = usage.get("cache_creation_input_tokens", 0) or 0
|
|
179
|
+
entry["input_tokens"] += inp
|
|
180
|
+
entry["output_tokens"] += out
|
|
181
|
+
entry["cache_read_tokens"] += cr
|
|
182
|
+
entry["cache_creation_tokens"] += cc
|
|
183
|
+
entry["cost_usd"] += cost_from_usage(model, usage)
|
|
184
|
+
turns += 1
|
|
185
|
+
cost = sum(v["cost_usd"] for v in by_model.values())
|
|
186
|
+
primary = None
|
|
187
|
+
if by_model:
|
|
188
|
+
primary = max(
|
|
189
|
+
by_model.items(),
|
|
190
|
+
key=lambda kv: (kv[1].get("output_tokens", 0), kv[1].get("input_tokens", 0)),
|
|
191
|
+
)[0]
|
|
192
|
+
return {
|
|
193
|
+
"by_model": {
|
|
194
|
+
m: {**v, "cost_usd": round(v["cost_usd"], 6)}
|
|
195
|
+
for m, v in by_model.items()
|
|
196
|
+
},
|
|
197
|
+
"cost_usd": round(cost, 6),
|
|
198
|
+
"turns": turns,
|
|
199
|
+
"first_ts": first_ts,
|
|
200
|
+
"last_ts": last_ts,
|
|
201
|
+
"model": primary or "unknown",
|
|
202
|
+
"agent_type": (meta or {}).get("agentType"),
|
|
203
|
+
"description": (meta or {}).get("description"),
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _find_subagent_dir(orchestrator_path: str) -> str:
|
|
208
|
+
"""Given path .../<session_id>.jsonl, return .../<session_id>/subagents/."""
|
|
209
|
+
if not orchestrator_path:
|
|
210
|
+
return None
|
|
211
|
+
base = orchestrator_path[:-len(".jsonl")] if orchestrator_path.endswith(".jsonl") else orchestrator_path
|
|
212
|
+
candidate = os.path.join(base, "subagents")
|
|
213
|
+
return candidate if os.path.isdir(candidate) else None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def parse_transcript(path: str):
|
|
217
|
+
"""Parse a Claude Code transcript .jsonl into per-session cost.
|
|
218
|
+
|
|
219
|
+
Subagent (Agent tool) handling
|
|
220
|
+
------------------------------
|
|
221
|
+
Claude Code SDK >= 2.1.x writes subagent transcripts to a SEPARATE
|
|
222
|
+
sibling directory, NOT inline in the orchestrator's .jsonl. Layout:
|
|
223
|
+
|
|
224
|
+
~/.claude/projects/<encoded-cwd>/<orchestrator-session-id>.jsonl
|
|
225
|
+
~/.claude/projects/<encoded-cwd>/<orchestrator-session-id>/
|
|
226
|
+
subagents/
|
|
227
|
+
agent-<short-id>.jsonl
|
|
228
|
+
agent-<short-id>.meta.json
|
|
229
|
+
|
|
230
|
+
The orchestrator's .jsonl only records the ``tool_use`` block (name
|
|
231
|
+
"Agent", with ``subagent_type``/``description``/``prompt`` input) and
|
|
232
|
+
the consolidated ``tool_result`` carrying the agent's final reply. The
|
|
233
|
+
Agent's internal chain of assistant turns lives entirely in
|
|
234
|
+
``agent-<id>.jsonl`` with ``isSidechain: true`` on every event.
|
|
235
|
+
|
|
236
|
+
This parser:
|
|
237
|
+
* Sums orchestrator-only token usage and cost into by_model/totals
|
|
238
|
+
(so total_cost_usd matches the parent thread only — subagent cost
|
|
239
|
+
is broken out separately so the user can see exactly what the
|
|
240
|
+
subagents added).
|
|
241
|
+
* Counts ``Agent`` tool_use blocks in orchestrator turns -> the
|
|
242
|
+
legacy ``task_call_count`` field (kept the name for back-compat
|
|
243
|
+
even though the tool name is "Agent", not "Task").
|
|
244
|
+
* Scans the sibling ``subagents/`` directory; for each
|
|
245
|
+
agent-<id>.jsonl, parses it as an independent transcript and adds
|
|
246
|
+
its cost to ``subagent_cost_usd``. Per-subagent details land in
|
|
247
|
+
``subagent_breakdown`` keyed by short agent id, with the
|
|
248
|
+
agentType/description from the .meta.json sidecar.
|
|
249
|
+
* As a defensive fallback, also detects legacy ``isSidechain: true``
|
|
250
|
+
events inside the same .jsonl (older SDK versions may have used
|
|
251
|
+
that layout). Currently zero hits across 14k+ historical sessions,
|
|
252
|
+
but kept so we don't have to revisit when the SDK changes again.
|
|
253
|
+
|
|
254
|
+
Historical note (2026-05-10): the prior parser version looked for
|
|
255
|
+
tool_use name="Task" and inline isSidechain entries. Both miss the
|
|
256
|
+
actual SDK layout. The corpus has 2041 Agent invocations (mostly in
|
|
257
|
+
seo_generate_page sessions) whose cost was previously invisible.
|
|
258
|
+
"""
|
|
259
|
+
if not os.path.exists(path):
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
by_model = {}
|
|
263
|
+
totals = {"input": 0, "output": 0, "cache_read": 0, "cache_creation": 0}
|
|
264
|
+
first_ts = None
|
|
265
|
+
last_ts = None
|
|
266
|
+
|
|
267
|
+
# Subagent (sidechain) accounting. Keyed by chain-root uuid so chained
|
|
268
|
+
# sidechain turns under the same Task() invocation aggregate together.
|
|
269
|
+
# When parentUuid linkage is ambiguous we fall back to a single synthetic
|
|
270
|
+
# group ("unknown") so the cost still gets counted.
|
|
271
|
+
sidechain_groups = {} # root_uuid -> {model: per-model dict, cost_usd, turns, first_ts, last_ts, description}
|
|
272
|
+
# parentUuid -> root_uuid map, built as we walk the transcript. The first
|
|
273
|
+
# sidechain turn we see introduces its uuid as a root candidate; later
|
|
274
|
+
# turns chain by parentUuid.
|
|
275
|
+
uuid_to_root = {}
|
|
276
|
+
|
|
277
|
+
task_call_count = 0
|
|
278
|
+
|
|
279
|
+
def _bump_model_bucket(bucket, model, usage):
|
|
280
|
+
entry = bucket.setdefault(model, {
|
|
281
|
+
"input_tokens": 0, "output_tokens": 0,
|
|
282
|
+
"cache_read_tokens": 0, "cache_creation_tokens": 0,
|
|
283
|
+
"cost_usd": 0.0,
|
|
284
|
+
})
|
|
285
|
+
inp = usage.get("input_tokens", 0) or 0
|
|
286
|
+
out = usage.get("output_tokens", 0) or 0
|
|
287
|
+
cr = usage.get("cache_read_input_tokens", 0) or 0
|
|
288
|
+
cc = usage.get("cache_creation_input_tokens", 0) or 0
|
|
289
|
+
entry["input_tokens"] += inp
|
|
290
|
+
entry["output_tokens"] += out
|
|
291
|
+
entry["cache_read_tokens"] += cr
|
|
292
|
+
entry["cache_creation_tokens"] += cc
|
|
293
|
+
entry["cost_usd"] += cost_from_usage(model, usage)
|
|
294
|
+
return inp, out, cr, cc
|
|
295
|
+
|
|
296
|
+
with open(path) as f:
|
|
297
|
+
for line in f:
|
|
298
|
+
line = line.strip()
|
|
299
|
+
if not line:
|
|
300
|
+
continue
|
|
301
|
+
try:
|
|
302
|
+
ev = json.loads(line)
|
|
303
|
+
except json.JSONDecodeError:
|
|
304
|
+
continue
|
|
305
|
+
|
|
306
|
+
ts = ev.get("timestamp")
|
|
307
|
+
if ts:
|
|
308
|
+
first_ts = first_ts or ts
|
|
309
|
+
last_ts = ts
|
|
310
|
+
|
|
311
|
+
is_sidechain = bool(ev.get("isSidechain"))
|
|
312
|
+
ev_uuid = ev.get("uuid")
|
|
313
|
+
parent_uuid = ev.get("parentUuid")
|
|
314
|
+
|
|
315
|
+
# Count subagent tool_use blocks (both legacy "Task" name and
|
|
316
|
+
# current "Agent" name) in orchestrator turns. This gives us an
|
|
317
|
+
# authoritative subagent-invocation count, independent of whether
|
|
318
|
+
# the sibling subagents/ transcripts came through fully (a
|
|
319
|
+
# watchdog SIGTERM mid-subagent can leave the tool_use stamped
|
|
320
|
+
# but the sibling .jsonl never finished). 2026-05-10 the actual
|
|
321
|
+
# SDK tool name is "Agent"; "Task" kept for forward-compat if
|
|
322
|
+
# the SDK ever renames it again.
|
|
323
|
+
msg = ev.get("message") or {}
|
|
324
|
+
if not is_sidechain and ev.get("type") == "assistant":
|
|
325
|
+
content = msg.get("content")
|
|
326
|
+
if isinstance(content, list):
|
|
327
|
+
for c in content:
|
|
328
|
+
if (isinstance(c, dict)
|
|
329
|
+
and c.get("type") == "tool_use"
|
|
330
|
+
and c.get("name") in ("Task", "Agent")):
|
|
331
|
+
task_call_count += 1
|
|
332
|
+
|
|
333
|
+
if ev.get("type") != "assistant":
|
|
334
|
+
continue
|
|
335
|
+
usage = msg.get("usage") or {}
|
|
336
|
+
model = msg.get("model") or "unknown"
|
|
337
|
+
|
|
338
|
+
if not is_sidechain:
|
|
339
|
+
# Orchestrator turn.
|
|
340
|
+
inp, out, cr, cc = _bump_model_bucket(by_model, model, usage)
|
|
341
|
+
totals["input"] += inp
|
|
342
|
+
totals["output"] += out
|
|
343
|
+
totals["cache_read"] += cr
|
|
344
|
+
totals["cache_creation"] += cc
|
|
345
|
+
else:
|
|
346
|
+
# Sidechain (subagent) turn. Resolve to a chain root: if
|
|
347
|
+
# parentUuid is already mapped to a root, attach there;
|
|
348
|
+
# otherwise this is a new root.
|
|
349
|
+
root = uuid_to_root.get(parent_uuid)
|
|
350
|
+
if root is None:
|
|
351
|
+
root = ev_uuid or "unknown"
|
|
352
|
+
if ev_uuid:
|
|
353
|
+
uuid_to_root[ev_uuid] = root
|
|
354
|
+
|
|
355
|
+
grp = sidechain_groups.setdefault(root, {
|
|
356
|
+
"by_model": {},
|
|
357
|
+
"cost_usd": 0.0,
|
|
358
|
+
"turns": 0,
|
|
359
|
+
"first_ts": None,
|
|
360
|
+
"last_ts": None,
|
|
361
|
+
"root_uuid": root,
|
|
362
|
+
})
|
|
363
|
+
_bump_model_bucket(grp["by_model"], model, usage)
|
|
364
|
+
grp["cost_usd"] += cost_from_usage(model, usage)
|
|
365
|
+
grp["turns"] += 1
|
|
366
|
+
if ts:
|
|
367
|
+
grp["first_ts"] = grp["first_ts"] or ts
|
|
368
|
+
grp["last_ts"] = ts
|
|
369
|
+
|
|
370
|
+
if not by_model and not sidechain_groups:
|
|
371
|
+
return None
|
|
372
|
+
|
|
373
|
+
total_cost = sum(m["cost_usd"] for m in by_model.values())
|
|
374
|
+
|
|
375
|
+
# Dominant model = the one that produced the most output tokens in this
|
|
376
|
+
# session. Claude Code's transcript emits `"model": "<synthetic>"` on
|
|
377
|
+
# interrupted/stopped events with zero usage; those shouldn't win just
|
|
378
|
+
# because they sort alphabetically when all real candidates tie.
|
|
379
|
+
real_models = {k: v for k, v in by_model.items() if not k.startswith("<")}
|
|
380
|
+
pool = real_models or by_model
|
|
381
|
+
if pool:
|
|
382
|
+
primary_model = max(
|
|
383
|
+
pool.items(),
|
|
384
|
+
key=lambda kv: (kv[1].get("output_tokens", 0), kv[1].get("input_tokens", 0)),
|
|
385
|
+
)[0]
|
|
386
|
+
else:
|
|
387
|
+
# Subagents-only session (no orchestrator turns logged — unusual but
|
|
388
|
+
# possible if the orchestrator was SIGTERMed before its first
|
|
389
|
+
# assistant turn yet a sidechain had already started). Fall back to
|
|
390
|
+
# the dominant model across sidechains.
|
|
391
|
+
all_models = {}
|
|
392
|
+
for grp in sidechain_groups.values():
|
|
393
|
+
for m, v in grp["by_model"].items():
|
|
394
|
+
e = all_models.setdefault(m, {"output_tokens": 0, "input_tokens": 0})
|
|
395
|
+
e["output_tokens"] += v.get("output_tokens", 0)
|
|
396
|
+
e["input_tokens"] += v.get("input_tokens", 0)
|
|
397
|
+
primary_model = max(
|
|
398
|
+
all_models.items(),
|
|
399
|
+
key=lambda kv: (kv[1].get("output_tokens", 0), kv[1].get("input_tokens", 0)),
|
|
400
|
+
)[0] if all_models else "unknown"
|
|
401
|
+
|
|
402
|
+
# Compact per-subagent breakdown for the subagent_breakdown jsonb column.
|
|
403
|
+
# Two sources feed this map:
|
|
404
|
+
# 1. Inline isSidechain entries (legacy SDK layout, ~0 hits today).
|
|
405
|
+
# Keyed by chain-root uuid.
|
|
406
|
+
# 2. Sibling agent-<id>.jsonl files (current SDK layout, post-2.1.x).
|
|
407
|
+
# Keyed by short agent id (e.g. "ab24e352623c7d99b").
|
|
408
|
+
subagent_breakdown = {}
|
|
409
|
+
for root, grp in sidechain_groups.items():
|
|
410
|
+
# Dominant model for this subagent group.
|
|
411
|
+
bm = grp["by_model"]
|
|
412
|
+
if bm:
|
|
413
|
+
sg_model = max(
|
|
414
|
+
bm.items(),
|
|
415
|
+
key=lambda kv: (kv[1].get("output_tokens", 0), kv[1].get("input_tokens", 0)),
|
|
416
|
+
)[0]
|
|
417
|
+
else:
|
|
418
|
+
sg_model = "unknown"
|
|
419
|
+
subagent_breakdown[root] = {
|
|
420
|
+
"source": "inline_sidechain",
|
|
421
|
+
"cost_usd": round(grp["cost_usd"], 6),
|
|
422
|
+
"turns": grp["turns"],
|
|
423
|
+
"first_ts": grp["first_ts"],
|
|
424
|
+
"last_ts": grp["last_ts"],
|
|
425
|
+
"model": sg_model,
|
|
426
|
+
"by_model": {
|
|
427
|
+
m: {
|
|
428
|
+
"input_tokens": v["input_tokens"],
|
|
429
|
+
"output_tokens": v["output_tokens"],
|
|
430
|
+
"cache_read_tokens": v["cache_read_tokens"],
|
|
431
|
+
"cache_creation_tokens": v["cache_creation_tokens"],
|
|
432
|
+
"cost_usd": round(v["cost_usd"], 6),
|
|
433
|
+
}
|
|
434
|
+
for m, v in bm.items()
|
|
435
|
+
},
|
|
436
|
+
}
|
|
437
|
+
subagent_cost_usd = sum(grp["cost_usd"] for grp in sidechain_groups.values())
|
|
438
|
+
|
|
439
|
+
# ------- Scan sibling subagents/ directory for current-SDK transcripts -------
|
|
440
|
+
sub_dir = _find_subagent_dir(path)
|
|
441
|
+
if sub_dir:
|
|
442
|
+
for agent_file in sorted(os.listdir(sub_dir)):
|
|
443
|
+
if not agent_file.endswith(".jsonl"):
|
|
444
|
+
continue
|
|
445
|
+
short_id = agent_file[len("agent-"):-len(".jsonl")] if agent_file.startswith("agent-") else agent_file
|
|
446
|
+
meta_path = os.path.join(sub_dir, agent_file.replace(".jsonl", ".meta.json"))
|
|
447
|
+
meta = {}
|
|
448
|
+
try:
|
|
449
|
+
if os.path.exists(meta_path):
|
|
450
|
+
with open(meta_path) as mf:
|
|
451
|
+
meta = json.load(mf)
|
|
452
|
+
except Exception:
|
|
453
|
+
meta = {}
|
|
454
|
+
sub = _parse_subagent_transcript(os.path.join(sub_dir, agent_file), meta)
|
|
455
|
+
if not sub:
|
|
456
|
+
continue
|
|
457
|
+
subagent_breakdown[short_id] = {
|
|
458
|
+
"source": "sibling_dir",
|
|
459
|
+
"cost_usd": sub["cost_usd"],
|
|
460
|
+
"turns": sub["turns"],
|
|
461
|
+
"first_ts": sub["first_ts"],
|
|
462
|
+
"last_ts": sub["last_ts"],
|
|
463
|
+
"model": sub["model"],
|
|
464
|
+
"agent_type": sub["agent_type"],
|
|
465
|
+
"description": sub["description"],
|
|
466
|
+
"by_model": sub["by_model"],
|
|
467
|
+
}
|
|
468
|
+
subagent_cost_usd += sub["cost_usd"]
|
|
469
|
+
# Total distinct subagents (inline + sibling-dir) for the count column.
|
|
470
|
+
subagent_count = len(subagent_breakdown)
|
|
471
|
+
|
|
472
|
+
return {
|
|
473
|
+
"by_model": by_model,
|
|
474
|
+
"totals": totals,
|
|
475
|
+
"total_cost_usd": total_cost,
|
|
476
|
+
"primary_model": primary_model,
|
|
477
|
+
"first_ts": first_ts,
|
|
478
|
+
"last_ts": last_ts,
|
|
479
|
+
"task_call_count": task_call_count,
|
|
480
|
+
"subagent_count": subagent_count,
|
|
481
|
+
"subagent_cost_usd": round(subagent_cost_usd, 6),
|
|
482
|
+
"subagent_breakdown": subagent_breakdown,
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
_BACKFILL_TABLES = (
|
|
487
|
+
"posts", "replies", "dms", "dm_messages",
|
|
488
|
+
"seo_escalations", "seo_keywords", "seo_page_improvements", "gsc_queries",
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _persist_via_api(args, parsed, started, ended, duration_ms, orch_cost, cycle_id):
|
|
493
|
+
"""Upsert claude_sessions row + backfill model column via HTTP routes.
|
|
494
|
+
|
|
495
|
+
Two calls:
|
|
496
|
+
POST /api/v1/claude-sessions -> upsert by session_id
|
|
497
|
+
POST /api/v1/claude-sessions/backfill-model -> stamp model on activity rows
|
|
498
|
+
"""
|
|
499
|
+
from http_api import api_post
|
|
500
|
+
api_post(
|
|
501
|
+
"/api/v1/claude-sessions",
|
|
502
|
+
{
|
|
503
|
+
"session_id": args.session_id,
|
|
504
|
+
"script": args.script,
|
|
505
|
+
"started_at": started,
|
|
506
|
+
"ended_at": ended,
|
|
507
|
+
"duration_ms": duration_ms,
|
|
508
|
+
"total_cost_usd": round(parsed["total_cost_usd"], 6),
|
|
509
|
+
"orchestrator_cost_usd": orch_cost,
|
|
510
|
+
"input_tokens": parsed["totals"]["input"],
|
|
511
|
+
"output_tokens": parsed["totals"]["output"],
|
|
512
|
+
"cache_read_tokens": parsed["totals"]["cache_read"],
|
|
513
|
+
"cache_creation_tokens": parsed["totals"]["cache_creation"],
|
|
514
|
+
"model_breakdown": parsed["by_model"],
|
|
515
|
+
"model": parsed["primary_model"],
|
|
516
|
+
"cycle_id": cycle_id,
|
|
517
|
+
"task_call_count": parsed.get("task_call_count", 0),
|
|
518
|
+
"subagent_count": parsed.get("subagent_count", 0),
|
|
519
|
+
"subagent_cost_usd": parsed.get("subagent_cost_usd", 0.0),
|
|
520
|
+
"subagent_breakdown": parsed.get("subagent_breakdown") or None,
|
|
521
|
+
},
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
resp = api_post(
|
|
525
|
+
"/api/v1/claude-sessions/backfill-model",
|
|
526
|
+
{
|
|
527
|
+
"session_id": args.session_id,
|
|
528
|
+
"model": parsed["primary_model"],
|
|
529
|
+
"tables": list(_BACKFILL_TABLES),
|
|
530
|
+
},
|
|
531
|
+
)
|
|
532
|
+
data = (resp or {}).get("data") or {}
|
|
533
|
+
backfill_counts = data.get("backfilled") or {}
|
|
534
|
+
for t in _BACKFILL_TABLES:
|
|
535
|
+
backfill_counts.setdefault(t, 0)
|
|
536
|
+
return backfill_counts
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def main():
|
|
540
|
+
parser = argparse.ArgumentParser()
|
|
541
|
+
parser.add_argument("--session-id", required=True)
|
|
542
|
+
parser.add_argument("--script", required=True)
|
|
543
|
+
parser.add_argument("--started-at", default=None,
|
|
544
|
+
help="ISO8601 timestamp; falls back to first transcript ts")
|
|
545
|
+
parser.add_argument("--ended-at", default=None,
|
|
546
|
+
help="ISO8601 timestamp; falls back to last transcript ts")
|
|
547
|
+
parser.add_argument("--orchestrator-cost-usd", default=None, type=float,
|
|
548
|
+
help="Native SDK cost (streamRes.total_cost_usd) for the "
|
|
549
|
+
"orchestrator session, captured from claude -p stdout. "
|
|
550
|
+
"Stored in claude_sessions.orchestrator_cost_usd. "
|
|
551
|
+
"Authoritative (matches Anthropic billing for the "
|
|
552
|
+
"orchestrator), but excludes Task subagent costs "
|
|
553
|
+
"(see anthropics/claude-code #43945). When omitted, "
|
|
554
|
+
"the column stays NULL and dashboards fall back to "
|
|
555
|
+
"total_cost_usd (manual transcript-derived estimate).")
|
|
556
|
+
parser.add_argument("--cycle-id", default=None,
|
|
557
|
+
help="Optional per-cycle batch identifier (e.g. "
|
|
558
|
+
"'rdcycle-20260510-110005'). Lets get_run_cost.py / "
|
|
559
|
+
"the dashboard scope cost to ONE pipeline cycle "
|
|
560
|
+
"even when multiple cycles of the same script "
|
|
561
|
+
"(double-forked run-reddit-search.sh / "
|
|
562
|
+
"run-twitter-cycle.sh) overlap in wall-clock time. "
|
|
563
|
+
"Falls back to env SA_CYCLE_ID; NULL if unset.")
|
|
564
|
+
args = parser.parse_args()
|
|
565
|
+
|
|
566
|
+
# Allow callers (run_claude.sh, post_reddit.py spawning a child claude) to
|
|
567
|
+
# propagate cycle_id via env without re-plumbing every call site. CLI flag
|
|
568
|
+
# takes precedence so explicit overrides still work.
|
|
569
|
+
cycle_id = args.cycle_id or os.environ.get("SA_CYCLE_ID") or None
|
|
570
|
+
if cycle_id == "":
|
|
571
|
+
cycle_id = None
|
|
572
|
+
|
|
573
|
+
transcript = find_transcript(args.session_id)
|
|
574
|
+
# Archive the transcript BEFORE parsing so even an empty/short session
|
|
575
|
+
# leaves a forensics trail. This is the only path that runs reliably on
|
|
576
|
+
# watchdog SIGTERM — once the wrapper's EXIT trap fires, log_claude_session
|
|
577
|
+
# is the last chance to capture what claude was doing before death.
|
|
578
|
+
archive_path = archive_transcript(
|
|
579
|
+
transcript, args.session_id, args.script, args.started_at
|
|
580
|
+
)
|
|
581
|
+
parsed = parse_transcript(transcript) if transcript else None
|
|
582
|
+
|
|
583
|
+
if parsed is None:
|
|
584
|
+
print(json.dumps({
|
|
585
|
+
"logged": False,
|
|
586
|
+
"reason": "no-transcript-or-empty",
|
|
587
|
+
"transcript": transcript,
|
|
588
|
+
"archive_path": archive_path,
|
|
589
|
+
"session_id": args.session_id,
|
|
590
|
+
}))
|
|
591
|
+
return
|
|
592
|
+
|
|
593
|
+
started = args.started_at or parsed["first_ts"]
|
|
594
|
+
ended = args.ended_at or parsed["last_ts"]
|
|
595
|
+
duration_ms = None
|
|
596
|
+
try:
|
|
597
|
+
if started and ended:
|
|
598
|
+
s = datetime.fromisoformat(started.replace("Z", "+00:00"))
|
|
599
|
+
e = datetime.fromisoformat(ended.replace("Z", "+00:00"))
|
|
600
|
+
duration_ms = int((e - s).total_seconds() * 1000)
|
|
601
|
+
except (ValueError, AttributeError):
|
|
602
|
+
pass
|
|
603
|
+
|
|
604
|
+
# Orchestrator cost: prefer the native SDK value passed via flag (from
|
|
605
|
+
# streamRes.total_cost_usd in the caller); fall back to NULL so the column
|
|
606
|
+
# only holds authoritative values. Manual transcript-derived estimate goes
|
|
607
|
+
# into total_cost_usd unchanged.
|
|
608
|
+
orch_cost = (
|
|
609
|
+
round(args.orchestrator_cost_usd, 6)
|
|
610
|
+
if args.orchestrator_cost_usd is not None
|
|
611
|
+
else None
|
|
612
|
+
)
|
|
613
|
+
|
|
614
|
+
backfill_counts = _persist_via_api(args, parsed, started, ended, duration_ms,
|
|
615
|
+
orch_cost, cycle_id)
|
|
616
|
+
|
|
617
|
+
print(json.dumps({
|
|
618
|
+
"logged": True,
|
|
619
|
+
"session_id": args.session_id,
|
|
620
|
+
"script": args.script,
|
|
621
|
+
"cycle_id": cycle_id,
|
|
622
|
+
"total_cost_usd": round(parsed["total_cost_usd"], 6),
|
|
623
|
+
"orchestrator_cost_usd": orch_cost,
|
|
624
|
+
"duration_ms": duration_ms,
|
|
625
|
+
"model": parsed["primary_model"],
|
|
626
|
+
"models": list(parsed["by_model"].keys()),
|
|
627
|
+
"task_call_count": parsed.get("task_call_count", 0),
|
|
628
|
+
"subagent_count": parsed.get("subagent_count", 0),
|
|
629
|
+
"subagent_cost_usd": parsed.get("subagent_cost_usd", 0.0),
|
|
630
|
+
"backfilled": backfill_counts,
|
|
631
|
+
"archive_path": archive_path,
|
|
632
|
+
}))
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
if __name__ == "__main__":
|
|
636
|
+
main()
|