nilometer 0.0.0-stage → 0.2.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.
Files changed (81) hide show
  1. package/CHANGELOG.md +68 -0
  2. package/LICENSE +21 -0
  3. package/README.md +464 -2
  4. package/core/pricing/prices.json +348 -0
  5. package/core/schema/001_ingestion.sql +57 -0
  6. package/core/schema/002_line_count.sql +7 -0
  7. package/core/schema/003_derived.sql +70 -0
  8. package/core/schema/004_requests_dedup.sql +50 -0
  9. package/core/schema/005_repositories.sql +25 -0
  10. package/core/schema/006_status_readings.sql +33 -0
  11. package/core/schema/007_pricing.sql +95 -0
  12. package/core/schema/008_interruptions.sql +263 -0
  13. package/core/schema/009_headroom.sql +84 -0
  14. package/core/schema/010_attribution.sql +106 -0
  15. package/core/schema/011_projections.sql +83 -0
  16. package/core/schema/012_unsplit_cache_writes.sql +49 -0
  17. package/core/schema/013_repository_revalidation.sql +104 -0
  18. package/core/schema/014_observed_readings.sql +204 -0
  19. package/core/schema/015_response_observations.sql +127 -0
  20. package/core/schema/016_windows_repository_paths.sql +41 -0
  21. package/core/schema/017_unattributed_usage_once.sql +47 -0
  22. package/core/schema/018_request_time_index.sql +21 -0
  23. package/core/schema/019_dedup_earliest_of_equals.sql +61 -0
  24. package/core/schema/020_limit_quota_fields.sql +131 -0
  25. package/core/schema/021_plan_periods.sql +121 -0
  26. package/core/schema/022_attachment_walk.sql +223 -0
  27. package/core/schema/023_auto_continuations.sql +129 -0
  28. package/core/schema/024_counted_limit_groups.sql +29 -0
  29. package/core/schema/025_model_fallback.sql +77 -0
  30. package/core/schema/026_changed_readings.sql +106 -0
  31. package/core/schema/027_weekly_crossings.sql +32 -0
  32. package/core/schema/028_stale_readings.sql +196 -0
  33. package/core/schema/029_lockout_sessions.sql +68 -0
  34. package/core/schema/030_repository_aliases.sql +81 -0
  35. package/core/schema/031_cte_cursor_budget.sql +153 -0
  36. package/core/schema/032_stored_views.sql +233 -0
  37. package/dist/cli/main.cli.js +39 -0
  38. package/dist/cli/program.js +881 -0
  39. package/dist/core/db/database.js +182 -0
  40. package/dist/core/db/fingerprint.js +71 -0
  41. package/dist/core/ingest/attribution.js +131 -0
  42. package/dist/core/ingest/classify.js +382 -0
  43. package/dist/core/ingest/command.js +95 -0
  44. package/dist/core/ingest/derive.js +219 -0
  45. package/dist/core/ingest/discover.js +126 -0
  46. package/dist/core/ingest/ingest.js +160 -0
  47. package/dist/core/ingest/reader.js +89 -0
  48. package/dist/core/ingest/reset-time.js +97 -0
  49. package/dist/core/ingest/results.js +201 -0
  50. package/dist/core/ingest/spool.js +165 -0
  51. package/dist/core/ingest/store.js +99 -0
  52. package/dist/core/ingest/stored-views.js +65 -0
  53. package/dist/core/install/install.js +185 -0
  54. package/dist/core/install/locations.js +89 -0
  55. package/dist/core/install/private-files.js +259 -0
  56. package/dist/core/install/record.js +103 -0
  57. package/dist/core/plans/plan-prices.js +128 -0
  58. package/dist/core/plans/repository-aliases.js +136 -0
  59. package/dist/core/pricing/prices.js +147 -0
  60. package/dist/core/settings/settings-file.js +215 -0
  61. package/dist/core/settings/statusline.js +240 -0
  62. package/dist/core/util/commands.js +91 -0
  63. package/dist/core/util/node-version.js +58 -0
  64. package/dist/core/util/open-page.js +69 -0
  65. package/dist/core/util/paths.js +36 -0
  66. package/dist/core/verify/compare.js +77 -0
  67. package/dist/core/verify/verify.js +220 -0
  68. package/dist/core/view/server.js +142 -0
  69. package/dist/viewer/explain.js +574 -0
  70. package/dist/viewer/format.js +150 -0
  71. package/dist/viewer/html-charts.js +1292 -0
  72. package/dist/viewer/html-contract.js +2 -0
  73. package/dist/viewer/html-data.js +718 -0
  74. package/dist/viewer/html.js +1717 -0
  75. package/dist/viewer/queries.js +128 -0
  76. package/dist/viewer/render.js +602 -0
  77. package/dist/viewer/report.js +120 -0
  78. package/dist/viewer/save.js +110 -0
  79. package/docs/requirements.md +113 -0
  80. package/hooks/statusline.sh +104 -0
  81. package/package.json +61 -4
@@ -0,0 +1,131 @@
1
+ -- 020_limit_quota_fields: a limit hit's window and reset come from quotaLimits when Claude Code writes it (D-067).
2
+ --
3
+ -- D-004 read the window and reset only from the message text, because that was the only place
4
+ -- they existed (Claude Code 2.1.214). By 2.1.281 every limit-hit line also carries a structured
5
+ -- quotaLimits object with rateLimitType and resetsAt (observed 2026-09-24; they agreed with the
6
+ -- text on every hit examined). The text parse stays for older lines, and events.window,
7
+ -- events.reset_text, and events.reset_at_utc keep meaning "from the text". The structured values
8
+ -- get their own columns, the views prefer them, and each logged hit says which source its window
9
+ -- and reset came from. Where the two disagree, ingestion records a line problem (report, don't
10
+ -- fix): 'quota_window_disagrees' and 'quota_reset_disagrees'; a member present but unusable is
11
+ -- 'unusable_quota_field'. line_problems is rebuilt to admit those codes, because SQLite can't
12
+ -- change a CHECK constraint in place; it's derived data, and the parser version bump that ships
13
+ -- with this migration re-derives it anyway.
14
+
15
+ ALTER TABLE events ADD COLUMN quota_window TEXT CHECK (quota_window IN ('five_hour', 'seven_day')); -- source: quotaLimits.rateLimitType when it's 'five_hour' or 'seven_day' (limit hits only, D-067)
16
+ ALTER TABLE events ADD COLUMN quota_resets_at_utc TEXT; -- source: quotaLimits.resetsAt (Unix seconds, whole, 1..253402300799) as ISO-8601 UTC (limit hits only, D-067)
17
+
18
+ CREATE TABLE line_problems_020 (
19
+ id INTEGER PRIMARY KEY, -- derived: row identity
20
+ raw_line_id INTEGER NOT NULL REFERENCES raw_lines (id), -- derived: the line with the problem
21
+ problem TEXT NOT NULL, -- derived: 'missing_field', 'unparsed_timestamp', 'non_message_iteration', 'unusable_quota_field', 'quota_window_disagrees', or 'quota_reset_disagrees'
22
+ detail TEXT, -- derived: the field name, raw timestamp JSON, iteration type, quotaLimits member, or both disagreeing values
23
+ CHECK (problem IN ('missing_field', 'unparsed_timestamp', 'non_message_iteration', 'unusable_quota_field', 'quota_window_disagrees', 'quota_reset_disagrees'))
24
+ ) STRICT;
25
+ INSERT INTO line_problems_020 (id, raw_line_id, problem, detail)
26
+ SELECT id, raw_line_id, problem, detail FROM line_problems;
27
+ DROP TABLE line_problems;
28
+ ALTER TABLE line_problems_020 RENAME TO line_problems;
29
+ CREATE INDEX line_problems_line ON line_problems (raw_line_id);
30
+
31
+ DROP VIEW logged_limit_hits;
32
+
33
+ -- Observed: logged limit hits, one per distinct line (copies of a uuid in one session count once), with the D-021 position.
34
+ -- Window and reset prefer quotaLimits over the message text (D-067), and say which one they came from.
35
+ CREATE VIEW logged_limit_hits AS
36
+ WITH copies AS (
37
+ SELECT
38
+ p.raw_line_id, p.session_id, p.uuid, p.parent_uuid, p.timestamp_utc, p.is_sidechain,
39
+ e.window AS text_window, e.quota_window, e.reset_text,
40
+ e.reset_at_utc AS text_reset_at_utc, e.quota_resets_at_utc,
41
+ ROW_NUMBER() OVER (
42
+ -- A line without a uuid is its own group: NULL uuids must not merge with each other.
43
+ PARTITION BY p.session_id, p.uuid, CASE WHEN p.uuid IS NULL THEN p.raw_line_id END
44
+ ORDER BY f.root, f.relative_path, l.first_run_id, l.line_number
45
+ ) AS copy_rank
46
+ FROM events e
47
+ JOIN parsed_lines p ON p.raw_line_id = e.raw_line_id
48
+ JOIN raw_lines l ON l.id = e.raw_line_id
49
+ JOIN source_files f ON f.id = l.source_file_id
50
+ WHERE e.class = 'limit_hit'
51
+ )
52
+ SELECT
53
+ c.raw_line_id, c.session_id, c.uuid, c.parent_uuid, c.timestamp_utc, c.is_sidechain,
54
+ -- The structured field is exact and doesn't depend on wording; the text is the fallback for
55
+ -- lines written before Claude Code added quotaLimits.
56
+ COALESCE(c.quota_window, c.text_window) AS window,
57
+ c.reset_text,
58
+ COALESCE(c.quota_resets_at_utc, c.text_reset_at_utc) AS reset_at_utc,
59
+ COALESCE((
60
+ SELECT CASE
61
+ WHEN par.user_content = 'tool_result' THEN 'mid_task'
62
+ WHEN par.user_content = 'prompt' AND par.is_meta = 0 THEN 'turn_start'
63
+ ELSE 'unknown'
64
+ END
65
+ FROM parsed_lines par
66
+ JOIN raw_lines pl ON pl.id = par.raw_line_id
67
+ JOIN source_files pf ON pf.id = pl.source_file_id
68
+ WHERE par.session_id = c.session_id AND par.uuid = c.parent_uuid
69
+ ORDER BY pf.root, pf.relative_path, pl.first_run_id, pl.line_number
70
+ LIMIT 1
71
+ ), 'unknown') AS position,
72
+ CASE WHEN c.quota_window IS NOT NULL THEN 'log_field'
73
+ WHEN c.text_window IS NOT NULL THEN 'log_text' END AS window_source,
74
+ CASE WHEN c.quota_resets_at_utc IS NOT NULL THEN 'log_field'
75
+ WHEN c.text_reset_at_utc IS NOT NULL THEN 'log_text' END AS reset_source
76
+ FROM copies c
77
+ WHERE c.copy_rank = 1;
78
+
79
+ DROP VIEW obs_limit_hits_events;
80
+
81
+ -- Observed: every rate-limit interruption, logged hits merged with status line groups without double-counting (D-004, D-023).
82
+ CREATE VIEW obs_limit_hits_events AS
83
+ WITH logged AS (
84
+ SELECT
85
+ h.*,
86
+ (SELECT g.raw_line_id FROM status_limit_groups g
87
+ WHERE (h.window IS NULL OR h.window = g.window)
88
+ AND unixepoch(h.timestamp_utc, 'subsec') > g.resets_at - g.window_seconds
89
+ AND unixepoch(h.timestamp_utc, 'subsec') <= g.resets_at
90
+ ORDER BY g.resets_at, g.window
91
+ LIMIT 1) AS status_raw_line_id
92
+ FROM logged_limit_hits h
93
+ ),
94
+ merged AS (
95
+ SELECT
96
+ 'session_log' AS source,
97
+ l.raw_line_id,
98
+ l.status_raw_line_id,
99
+ l.session_id,
100
+ l.timestamp_utc AS hit_at_utc,
101
+ COALESCE(l.window, g.window) AS window,
102
+ -- The payload's resets_at is exact; so is quotaLimits.resetsAt, and reset text has minute precision.
103
+ COALESCE(g.reset_at_utc, l.reset_at_utc) AS reset_at_utc,
104
+ CASE WHEN g.raw_line_id IS NOT NULL THEN 'status_line' ELSE l.reset_source END AS reset_source,
105
+ l.reset_text,
106
+ l.position,
107
+ l.is_sidechain
108
+ FROM logged l
109
+ LEFT JOIN status_limit_groups g ON g.raw_line_id = l.status_raw_line_id
110
+ UNION ALL
111
+ SELECT 'status_line', g.raw_line_id, g.raw_line_id, g.session_id, g.first_at_utc, g.window,
112
+ g.reset_at_utc, 'status_line', NULL, 'unknown', 0
113
+ FROM status_limit_groups g
114
+ WHERE NOT EXISTS (SELECT 1 FROM logged l WHERE l.status_raw_line_id = g.raw_line_id)
115
+ )
116
+ SELECT
117
+ m.*,
118
+ (round(unixepoch(m.reset_at_utc, 'subsec') * 1000) - round(unixepoch(m.hit_at_utc, 'subsec') * 1000)) / 1000.0 AS hit_to_reset_seconds,
119
+ (SELECT MIN(d.timestamp_utc) FROM requests_dedup d
120
+ WHERE d.session_id = m.session_id AND d.timestamp_utc > m.hit_at_utc) AS next_session_request_at_utc,
121
+ (SELECT p.timestamp_utc FROM parsed_lines p
122
+ WHERE p.session_id = m.session_id AND p.user_content = 'prompt' AND p.is_meta = 0
123
+ AND p.timestamp_utc > m.hit_at_utc
124
+ ORDER BY p.timestamp_utc, p.raw_line_id LIMIT 1) AS next_session_prompt_at_utc,
125
+ -- origin.kind as written (D-022): never relabeled as automatic or manual.
126
+ (SELECT p.origin_kind FROM parsed_lines p
127
+ WHERE p.session_id = m.session_id AND p.user_content = 'prompt' AND p.is_meta = 0
128
+ AND p.timestamp_utc > m.hit_at_utc
129
+ ORDER BY p.timestamp_utc, p.raw_line_id LIMIT 1) AS next_session_prompt_origin_kind,
130
+ m.reset_at_utc > (SELECT covers_to FROM source_coverage WHERE source = 'session_logs') AS reset_after_coverage
131
+ FROM merged m;
@@ -0,0 +1,121 @@
1
+ -- 021_plan_periods: limit hits, lockouts, and elapsed lockout time per plan period (D-075, step G2.2).
2
+ --
3
+ -- The interruption counts span the whole log history (D-004), so after a plan change one count
4
+ -- mixed two plans: a hit on the plan before an upgrade counted beside hits on the plan after it.
5
+ -- The plan in effect comes from what the user entered with plan-price set (D-027): a plan applies
6
+ -- from the first day of its entered month, in the process's local zone, until the next entry's.
7
+ --
8
+ -- Observed views may not read plan_prices, which holds prices (structure.test.ts). plan_periods
9
+ -- reads only its names and months, and the structure test lets an observed view reach plan_prices
10
+ -- through plan_periods alone, and fails if plan_periods ever reads a price. The counts stay
11
+ -- observations: each hit and lockout is an event, and the plan is a label the user supplied.
12
+
13
+ -- Helper: each entered plan's months, from its effective month until the next entry's (D-027, D-075).
14
+ CREATE VIEW plan_periods AS
15
+ SELECT
16
+ effective_month AS from_month, -- source: plan_prices.effective_month, the first local month the plan applies
17
+ -- derived: the next entry's month, where this plan stops applying (exclusive); NULL while it applies.
18
+ LEAD(effective_month) OVER (ORDER BY effective_month) AS until_month,
19
+ plan_name -- source: plan_prices.plan_name, as the user wrote it
20
+ FROM plan_prices;
21
+
22
+ -- Observed: each limit hit, with the plan period its local month falls in (D-075).
23
+ CREATE VIEW obs_limit_hits_by_plan_events AS
24
+ SELECT
25
+ e.raw_line_id,
26
+ e.session_id,
27
+ e.hit_at_utc,
28
+ e.window,
29
+ e.source,
30
+ -- 'plan' inside an entered plan's months, 'none' before the first entry (or with none at all),
31
+ -- and 'unknown' for a hit with no time, which no month can place.
32
+ CASE
33
+ WHEN e.hit_at_utc IS NULL THEN 'unknown'
34
+ WHEN p.from_month IS NULL THEN 'none'
35
+ ELSE 'plan'
36
+ END AS period_kind,
37
+ p.from_month,
38
+ p.until_month,
39
+ p.plan_name
40
+ FROM obs_limit_hits_events e
41
+ -- Periods never overlap, so a hit matches at most one; months compare as YYYY-MM text.
42
+ LEFT JOIN plan_periods p
43
+ ON e.hit_at_utc IS NOT NULL
44
+ AND strftime('%Y-%m', e.hit_at_utc, 'localtime') >= p.from_month
45
+ AND (p.until_month IS NULL OR strftime('%Y-%m', e.hit_at_utc, 'localtime') < p.until_month);
46
+
47
+ -- Observed: each lockout interval, with the plan period its first hit falls in (D-075).
48
+ -- An interval crossing a period boundary belongs to the period where it began, so it is never
49
+ -- split or counted twice.
50
+ CREATE VIEW obs_lockout_by_plan_events AS
51
+ SELECT
52
+ i.*,
53
+ CASE WHEN p.from_month IS NULL THEN 'none' ELSE 'plan' END AS period_kind,
54
+ p.from_month,
55
+ p.until_month,
56
+ p.plan_name
57
+ FROM obs_lockout_intervals i
58
+ LEFT JOIN plan_periods p
59
+ ON strftime('%Y-%m', i.locked_from_utc, 'localtime') >= p.from_month
60
+ AND (p.until_month IS NULL OR strftime('%Y-%m', i.locked_from_utc, 'localtime') < p.until_month);
61
+
62
+ -- Observed: limit hits, lockouts, and elapsed lockout time per plan period, each with its own span (D-075).
63
+ CREATE VIEW obs_interruptions_by_plan AS
64
+ WITH periods AS (
65
+ SELECT 'plan' AS period_kind, from_month, until_month, plan_name FROM plan_periods
66
+ UNION ALL
67
+ -- Before the first entered plan; with no plan entered, the whole history.
68
+ SELECT 'none', NULL, (SELECT MIN(from_month) FROM plan_periods), NULL
69
+ UNION ALL
70
+ -- Hits with no time: counted, so the rows add up to obs_limit_hits, but in no period.
71
+ SELECT 'unknown', NULL, NULL, NULL
72
+ ),
73
+ -- Materialized: the hit and lockout views are among the costliest on real data (D-059), and every
74
+ -- period reads them.
75
+ hits AS MATERIALIZED (
76
+ SELECT period_kind, from_month, window, hit_at_utc FROM obs_limit_hits_by_plan_events
77
+ ),
78
+ locks AS MATERIALIZED (
79
+ SELECT period_kind, from_month, lockout_seconds FROM obs_lockout_by_plan_events
80
+ ),
81
+ -- What a period's span is drawn from: request times and hit times, each with its local month.
82
+ dated AS MATERIALIZED (
83
+ SELECT timestamp_utc AS at_utc, strftime('%Y-%m', timestamp_utc, 'localtime') AS month
84
+ FROM requests_dedup WHERE timestamp_utc IS NOT NULL
85
+ UNION ALL
86
+ SELECT hit_at_utc, strftime('%Y-%m', hit_at_utc, 'localtime') FROM hits WHERE hit_at_utc IS NOT NULL
87
+ ),
88
+ counted AS (
89
+ SELECT
90
+ p.period_kind,
91
+ p.from_month,
92
+ -- The last month the plan applies, for display: the month before until_month.
93
+ CASE WHEN p.until_month IS NOT NULL
94
+ THEN strftime('%Y-%m', p.until_month || '-01', '-1 month') END AS to_month,
95
+ p.until_month,
96
+ p.plan_name,
97
+ (SELECT COUNT(*) FROM hits h WHERE h.period_kind = p.period_kind AND h.from_month IS p.from_month) AS hits,
98
+ (SELECT COUNT(*) FROM hits h WHERE h.period_kind = p.period_kind AND h.from_month IS p.from_month
99
+ AND h.window = 'five_hour') AS five_hour_hits,
100
+ (SELECT COUNT(*) FROM hits h WHERE h.period_kind = p.period_kind AND h.from_month IS p.from_month
101
+ AND h.window = 'seven_day') AS seven_day_hits,
102
+ (SELECT COUNT(*) FROM hits h WHERE h.period_kind = p.period_kind AND h.from_month IS p.from_month
103
+ AND h.window IS NULL) AS unknown_window_hits,
104
+ (SELECT COUNT(*) FROM locks l WHERE l.period_kind = p.period_kind AND l.from_month IS p.from_month) AS lockouts,
105
+ -- TOTAL, as obs_lockout_time totals intervals: 0 over none.
106
+ (SELECT TOTAL(l.lockout_seconds) FROM locks l
107
+ WHERE l.period_kind = p.period_kind AND l.from_month IS p.from_month) AS lockout_seconds,
108
+ -- The span the period's data covers, from what is in it (principle 5); none for 'unknown'.
109
+ (SELECT MIN(d.at_utc) FROM dated d WHERE p.period_kind <> 'unknown'
110
+ AND (p.from_month IS NULL OR d.month >= p.from_month)
111
+ AND (p.until_month IS NULL OR d.month < p.until_month)) AS covers_from,
112
+ (SELECT MAX(d.at_utc) FROM dated d WHERE p.period_kind <> 'unknown'
113
+ AND (p.from_month IS NULL OR d.month >= p.from_month)
114
+ AND (p.until_month IS NULL OR d.month < p.until_month)) AS covers_to
115
+ FROM periods p
116
+ )
117
+ SELECT *
118
+ FROM counted
119
+ -- A period appears when it holds a hit or any dated data; an entry for a month with no data, or
120
+ -- a plan entered for the future, has nothing to report.
121
+ WHERE hits > 0 OR covers_from IS NOT NULL;
@@ -0,0 +1,223 @@
1
+ -- 022_attachment_walk: a limit hit's position looks past attachment lines of known metadata types (D-076, step G2.3).
2
+ --
3
+ -- D-021 decides a hit's position by its direct parent: a tool result means the model was inside
4
+ -- its own loop (mid-task), a non-meta prompt means a turn had just started, anything else is
5
+ -- unknown. Newer Claude Code versions write attachment lines between a request and what it
6
+ -- answered: token reminders, environment and memory context, hook output, and the like. By the
7
+ -- end of Phase 8 the direct parent of 24 of 31 hits was such a line, so "unknown" held most hits.
8
+ --
9
+ -- The walk follows a hit's parent chain past attachment lines whose type is classified as
10
+ -- metadata Claude Code adds by itself, and decides the position by D-021's rule at the first line
11
+ -- that isn't one. attachment_walk lists every type seen in real logs on 2026-10-13, classified by
12
+ -- its keys only (no values). A type not listed stops the walk, so a type a future Claude Code
13
+ -- adds can only leave a hit unknown; it can never move one to mid-task.
14
+
15
+ -- Reference: which attachment types the position walk passes (D-076). Seeded here, not derived.
16
+ CREATE TABLE attachment_walk (
17
+ attachment_type TEXT PRIMARY KEY, -- source: attachment.type, as written in session logs (classified by structure, D-076)
18
+ rule TEXT NOT NULL, -- source: D-076: 'pass' walks past it; 'by_fields' passes it only when its own fields say it's automatic
19
+ CHECK (rule IN ('pass', 'by_fields'))
20
+ ) STRICT;
21
+
22
+ INSERT INTO attachment_walk (attachment_type, rule) VALUES
23
+ -- Reminders Claude Code adds.
24
+ ('total_tokens_reminder', 'pass'),
25
+ ('batching_reminder_sent', 'pass'),
26
+ ('silent_turn_reminder', 'pass'),
27
+ ('todo_reminder', 'pass'),
28
+ ('task_reminder', 'pass'),
29
+ ('plan_mode', 'pass'),
30
+ -- Session context.
31
+ ('environment', 'pass'),
32
+ ('date', 'pass'),
33
+ ('date_change', 'pass'),
34
+ ('model', 'pass'),
35
+ ('session_context', 'pass'),
36
+ ('instructions', 'pass'),
37
+ ('nested_memory', 'pass'),
38
+ ('credential_org', 'pass'),
39
+ ('auto_mode', 'pass'),
40
+ ('command_permissions', 'pass'),
41
+ ('remote_session_change', 'pass'),
42
+ ('prompt_snapshot', 'pass'),
43
+ -- Tool and skill listings.
44
+ ('deferred_tools_record', 'pass'),
45
+ ('deferred_tools_delta', 'pass'),
46
+ ('mcp_instructions_delta', 'pass'),
47
+ ('skill_listing', 'pass'),
48
+ ('agent_listing_delta', 'pass'),
49
+ ('invoked_skills', 'pass'),
50
+ -- Notes on a tool call; the walk continues to the tool result they annotate.
51
+ ('hook_success', 'pass'),
52
+ ('hook_system_message', 'pass'),
53
+ ('hook_additional_context', 'pass'),
54
+ ('bash_output_audience_note', 'pass'),
55
+ ('read_truncation_notice', 'pass'),
56
+ ('structured_output', 'pass'),
57
+ -- Notes on files and thinking.
58
+ ('edited_text_file', 'pass'),
59
+ ('compact_file_reference', 'pass'),
60
+ ('thinking_drop', 'pass'),
61
+ ('thinking_stripped', 'pass'),
62
+ -- Carried with a prompt or a tool result; the walk continues to the line they belong to.
63
+ ('file', 'pass'),
64
+ ('plan_mode_exit', 'pass'),
65
+ -- A message delivered into a running loop: automatic when it's a task notification (247 of 271
66
+ -- on real data); a human or peer message is input, so it stops the walk.
67
+ ('queued_command', 'by_fields');
68
+
69
+ DROP VIEW logged_limit_hits;
70
+
71
+ -- Observed: logged limit hits, one per distinct line (copies of a uuid in one session count once), with the
72
+ -- D-021 position decided past metadata attachment lines (D-076). Window and reset prefer quotaLimits (D-067).
73
+ CREATE VIEW logged_limit_hits AS
74
+ WITH RECURSIVE copies AS (
75
+ SELECT
76
+ p.raw_line_id, p.session_id, p.uuid, p.parent_uuid, p.timestamp_utc, p.is_sidechain,
77
+ e.window AS text_window, e.quota_window, e.reset_text,
78
+ e.reset_at_utc AS text_reset_at_utc, e.quota_resets_at_utc,
79
+ ROW_NUMBER() OVER (
80
+ -- A line without a uuid is its own group: NULL uuids must not merge with each other.
81
+ PARTITION BY p.session_id, p.uuid, CASE WHEN p.uuid IS NULL THEN p.raw_line_id END
82
+ ORDER BY f.root, f.relative_path, l.first_run_id, l.line_number
83
+ ) AS copy_rank
84
+ FROM events e
85
+ JOIN parsed_lines p ON p.raw_line_id = e.raw_line_id
86
+ JOIN raw_lines l ON l.id = e.raw_line_id
87
+ JOIN source_files f ON f.id = l.source_file_id
88
+ WHERE e.class = 'limit_hit'
89
+ ),
90
+ firsts AS (
91
+ SELECT * FROM copies WHERE copy_rank = 1
92
+ ),
93
+ -- Each hit's parent chain. A step finds its line as D-021 did: the first copy, in canonical
94
+ -- order, of the parent uuid in the hit's session. The walk takes another step only past an
95
+ -- attachment that attachment_walk passes; 50 steps is far past any chain seen (8), and stops a
96
+ -- loop of uuids, which nothing guarantees can't occur.
97
+ walk (raw_line_id, session_id, line_id, passed) AS (
98
+ SELECT
99
+ h.raw_line_id,
100
+ h.session_id,
101
+ (SELECT par.raw_line_id FROM parsed_lines par
102
+ JOIN raw_lines pl ON pl.id = par.raw_line_id
103
+ JOIN source_files pf ON pf.id = pl.source_file_id
104
+ WHERE par.session_id = h.session_id AND par.uuid = h.parent_uuid
105
+ ORDER BY pf.root, pf.relative_path, pl.first_run_id, pl.line_number
106
+ LIMIT 1),
107
+ 0
108
+ FROM firsts h
109
+ UNION ALL
110
+ SELECT
111
+ w.raw_line_id,
112
+ w.session_id,
113
+ (SELECT par.raw_line_id FROM parsed_lines par
114
+ JOIN raw_lines pl ON pl.id = par.raw_line_id
115
+ JOIN source_files pf ON pf.id = pl.source_file_id
116
+ WHERE par.session_id = w.session_id AND par.uuid = cur.parent_uuid
117
+ ORDER BY pf.root, pf.relative_path, pl.first_run_id, pl.line_number
118
+ LIMIT 1),
119
+ w.passed + 1
120
+ FROM walk w
121
+ JOIN parsed_lines cur ON cur.raw_line_id = w.line_id
122
+ JOIN raw_lines cb ON cb.id = cur.raw_line_id
123
+ -- The attachment's type is read from its raw bytes: parsed_lines doesn't keep it, and only the
124
+ -- few lines on a hit's chain are read this way.
125
+ JOIN attachment_walk aw
126
+ ON aw.attachment_type = json_extract(CAST(cb.bytes AS TEXT), '$.attachment.type')
127
+ WHERE cur.type = 'attachment'
128
+ AND (aw.rule = 'pass'
129
+ OR json_extract(CAST(cb.bytes AS TEXT), '$.attachment.commandMode') = 'task-notification')
130
+ AND w.passed < 50
131
+ ),
132
+ -- Where each walk stopped: its last step.
133
+ ends AS (
134
+ SELECT raw_line_id, line_id, passed
135
+ FROM (
136
+ SELECT w.*, ROW_NUMBER() OVER (PARTITION BY w.raw_line_id ORDER BY w.passed DESC) AS step_rank
137
+ FROM walk w
138
+ )
139
+ WHERE step_rank = 1
140
+ )
141
+ SELECT
142
+ c.raw_line_id, c.session_id, c.uuid, c.parent_uuid, c.timestamp_utc, c.is_sidechain,
143
+ -- The structured field is exact and doesn't depend on wording; the text is the fallback for
144
+ -- lines written before Claude Code added quotaLimits.
145
+ COALESCE(c.quota_window, c.text_window) AS window,
146
+ c.reset_text,
147
+ COALESCE(c.quota_resets_at_utc, c.text_reset_at_utc) AS reset_at_utc,
148
+ -- D-021's rule, at the line the walk stopped on. A missing parent, a meta line, another type, or
149
+ -- an attachment the walk doesn't pass all leave the position unknown.
150
+ COALESCE((
151
+ SELECT CASE
152
+ WHEN par.user_content = 'tool_result' THEN 'mid_task'
153
+ WHEN par.user_content = 'prompt' AND par.is_meta = 0 THEN 'turn_start'
154
+ ELSE 'unknown'
155
+ END
156
+ FROM parsed_lines par
157
+ WHERE par.raw_line_id = e.line_id
158
+ ), 'unknown') AS position,
159
+ -- How many attachment lines the walk passed to decide it (D-076): 0 is D-021's direct parent.
160
+ e.passed AS attachments_passed,
161
+ CASE WHEN c.quota_window IS NOT NULL THEN 'log_field'
162
+ WHEN c.text_window IS NOT NULL THEN 'log_text' END AS window_source,
163
+ CASE WHEN c.quota_resets_at_utc IS NOT NULL THEN 'log_field'
164
+ WHEN c.text_reset_at_utc IS NOT NULL THEN 'log_text' END AS reset_source
165
+ FROM firsts c
166
+ JOIN ends e ON e.raw_line_id = c.raw_line_id;
167
+
168
+ DROP VIEW obs_limit_hits_events;
169
+
170
+ -- Observed: every rate-limit interruption, logged hits merged with status line groups without double-counting
171
+ -- (D-004, D-023), each with the attachment lines its position looked past (D-076).
172
+ CREATE VIEW obs_limit_hits_events AS
173
+ WITH logged AS (
174
+ SELECT
175
+ h.*,
176
+ (SELECT g.raw_line_id FROM status_limit_groups g
177
+ WHERE (h.window IS NULL OR h.window = g.window)
178
+ AND unixepoch(h.timestamp_utc, 'subsec') > g.resets_at - g.window_seconds
179
+ AND unixepoch(h.timestamp_utc, 'subsec') <= g.resets_at
180
+ ORDER BY g.resets_at, g.window
181
+ LIMIT 1) AS status_raw_line_id
182
+ FROM logged_limit_hits h
183
+ ),
184
+ merged AS (
185
+ SELECT
186
+ 'session_log' AS source,
187
+ l.raw_line_id,
188
+ l.status_raw_line_id,
189
+ l.session_id,
190
+ l.timestamp_utc AS hit_at_utc,
191
+ COALESCE(l.window, g.window) AS window,
192
+ -- The payload's resets_at is exact; so is quotaLimits.resetsAt, and reset text has minute precision.
193
+ COALESCE(g.reset_at_utc, l.reset_at_utc) AS reset_at_utc,
194
+ CASE WHEN g.raw_line_id IS NOT NULL THEN 'status_line' ELSE l.reset_source END AS reset_source,
195
+ l.reset_text,
196
+ l.position,
197
+ l.is_sidechain,
198
+ l.attachments_passed
199
+ FROM logged l
200
+ LEFT JOIN status_limit_groups g ON g.raw_line_id = l.status_raw_line_id
201
+ UNION ALL
202
+ -- A hit seen only in the status line has no parent to walk: unknown, past nothing.
203
+ SELECT 'status_line', g.raw_line_id, g.raw_line_id, g.session_id, g.first_at_utc, g.window,
204
+ g.reset_at_utc, 'status_line', NULL, 'unknown', 0, 0
205
+ FROM status_limit_groups g
206
+ WHERE NOT EXISTS (SELECT 1 FROM logged l WHERE l.status_raw_line_id = g.raw_line_id)
207
+ )
208
+ SELECT
209
+ m.*,
210
+ (round(unixepoch(m.reset_at_utc, 'subsec') * 1000) - round(unixepoch(m.hit_at_utc, 'subsec') * 1000)) / 1000.0 AS hit_to_reset_seconds,
211
+ (SELECT MIN(d.timestamp_utc) FROM requests_dedup d
212
+ WHERE d.session_id = m.session_id AND d.timestamp_utc > m.hit_at_utc) AS next_session_request_at_utc,
213
+ (SELECT p.timestamp_utc FROM parsed_lines p
214
+ WHERE p.session_id = m.session_id AND p.user_content = 'prompt' AND p.is_meta = 0
215
+ AND p.timestamp_utc > m.hit_at_utc
216
+ ORDER BY p.timestamp_utc, p.raw_line_id LIMIT 1) AS next_session_prompt_at_utc,
217
+ -- origin.kind as written (D-022): never relabeled as automatic or manual.
218
+ (SELECT p.origin_kind FROM parsed_lines p
219
+ WHERE p.session_id = m.session_id AND p.user_content = 'prompt' AND p.is_meta = 0
220
+ AND p.timestamp_utc > m.hit_at_utc
221
+ ORDER BY p.timestamp_utc, p.raw_line_id LIMIT 1) AS next_session_prompt_origin_kind,
222
+ m.reset_at_utc > (SELECT covers_to FROM source_coverage WHERE source = 'session_logs') AS reset_after_coverage
223
+ FROM merged m;
@@ -0,0 +1,129 @@
1
+ -- 023_auto_continuations: Claude Code's own resumption after a lockout is shown where it was logged (D-077, step G2.4).
2
+ --
3
+ -- After a limit's window resets, Claude Code continues a stopped session by itself and marks it:
4
+ -- a user line with isMeta true and origin.kind "auto-continuation" (D-022, observed from 2.1.281).
5
+ -- The report said only "reset to next Claude Code request", which doesn't say who continued.
6
+ -- Only the logged marker earns the label; nothing is inferred from timing (D-022), so a workflow
7
+ -- restarting its subagents, which writes no marker, stays unlabeled.
8
+ --
9
+ -- A marker belongs to the latest logged limit hit before it in its own session, and through that
10
+ -- hit to the hit's lockout interval (D-023). A marker with no earlier hit in its session, or whose
11
+ -- hit has no known reset and so no interval, follows no lockout and isn't counted.
12
+ --
13
+ -- Cost: the lockout views are read by several metrics, and the merged hit views behind them are
14
+ -- the costliest on real data (D-059). So the markers and their hits are found from the raw lines
15
+ -- (auto_continuation_marks), and each view computes interval membership once.
16
+
17
+ -- Helper: each logged auto-continuation, once per line, with the latest logged limit hit before it in its session (D-077).
18
+ CREATE VIEW auto_continuation_marks AS
19
+ WITH marks AS (
20
+ SELECT
21
+ p.raw_line_id, p.session_id, p.timestamp_utc,
22
+ -- Copies of one line (a uuid written twice in a session) count once, as limit hits do.
23
+ ROW_NUMBER() OVER (
24
+ PARTITION BY p.session_id, p.uuid, CASE WHEN p.uuid IS NULL THEN p.raw_line_id END
25
+ ORDER BY f.root, f.relative_path, l.first_run_id, l.line_number
26
+ ) AS copy_rank
27
+ FROM parsed_lines p
28
+ JOIN raw_lines l ON l.id = p.raw_line_id
29
+ JOIN source_files f ON f.id = l.source_file_id
30
+ WHERE p.origin_kind = 'auto-continuation' AND p.is_meta = 1 AND p.timestamp_utc IS NOT NULL
31
+ ),
32
+ -- The logged limit-hit lines, one per distinct line, chosen as logged_limit_hits chooses them.
33
+ hit_lines AS MATERIALIZED (
34
+ SELECT raw_line_id, session_id, timestamp_utc
35
+ FROM (
36
+ SELECT
37
+ p.raw_line_id, p.session_id, p.timestamp_utc,
38
+ ROW_NUMBER() OVER (
39
+ PARTITION BY p.session_id, p.uuid, CASE WHEN p.uuid IS NULL THEN p.raw_line_id END
40
+ ORDER BY f.root, f.relative_path, l.first_run_id, l.line_number
41
+ ) AS copy_rank
42
+ FROM events e
43
+ JOIN parsed_lines p ON p.raw_line_id = e.raw_line_id
44
+ JOIN raw_lines l ON l.id = e.raw_line_id
45
+ JOIN source_files f ON f.id = l.source_file_id
46
+ WHERE e.class = 'limit_hit'
47
+ )
48
+ WHERE copy_rank = 1
49
+ )
50
+ SELECT
51
+ m.raw_line_id, -- source: the marker line
52
+ m.session_id, -- source: its sessionId
53
+ m.timestamp_utc AS continued_at_utc, -- source: its timestamp
54
+ (SELECT h.raw_line_id FROM hit_lines h
55
+ WHERE h.session_id = m.session_id AND h.timestamp_utc < m.timestamp_utc
56
+ ORDER BY h.timestamp_utc DESC, h.raw_line_id DESC
57
+ LIMIT 1) AS hit_raw_line_id -- derived: the latest logged hit before it in the session
58
+ FROM marks m
59
+ WHERE m.copy_rank = 1;
60
+
61
+ DROP VIEW obs_lockout_intervals;
62
+
63
+ -- Observed: merged lockout intervals, hit → reset, with the first Claude Code request at or after each reset (D-023),
64
+ -- and the sessions Claude Code itself resumed after it, as logged (D-077).
65
+ CREATE VIEW obs_lockout_intervals AS
66
+ WITH interval_hits AS MATERIALIZED (
67
+ -- Computed once for everything below: interval membership is the costly part.
68
+ SELECT raw_line_id, start_utc, end_utc, interval_number FROM obs_lockout_interval_hits
69
+ ),
70
+ grouped AS (
71
+ SELECT
72
+ interval_number,
73
+ MIN(start_utc) AS locked_from_utc,
74
+ MAX(end_utc) AS locked_until_utc,
75
+ COUNT(*) AS hits
76
+ FROM interval_hits
77
+ GROUP BY interval_number
78
+ ),
79
+ with_next AS (
80
+ SELECT
81
+ g.*,
82
+ (SELECT MIN(d.timestamp_utc) FROM requests_dedup d WHERE d.timestamp_utc >= g.locked_until_utc) AS next_request_at_utc
83
+ FROM grouped g
84
+ ),
85
+ resumed AS (
86
+ SELECT
87
+ h.interval_number,
88
+ COUNT(*) AS auto_continuations,
89
+ MIN((round(unixepoch(m.continued_at_utc, 'subsec') * 1000) - round(unixepoch(g.locked_until_utc, 'subsec') * 1000)) / 1000.0)
90
+ AS first_seconds
91
+ FROM auto_continuation_marks m
92
+ JOIN interval_hits h ON h.raw_line_id = m.hit_raw_line_id
93
+ JOIN grouped g ON g.interval_number = h.interval_number
94
+ GROUP BY h.interval_number
95
+ )
96
+ SELECT
97
+ w.*,
98
+ (round(unixepoch(w.locked_until_utc, 'subsec') * 1000) - round(unixepoch(w.locked_from_utc, 'subsec') * 1000)) / 1000.0 AS lockout_seconds,
99
+ (round(unixepoch(w.next_request_at_utc, 'subsec') * 1000) - round(unixepoch(w.locked_until_utc, 'subsec') * 1000)) / 1000.0 AS reset_to_next_request_seconds,
100
+ w.locked_until_utc > (SELECT covers_to FROM source_coverage WHERE source = 'session_logs') AS reset_after_coverage,
101
+ -- How many sessions Claude Code resumed by itself after this reset, as logged; 0 when none.
102
+ COALESCE(r.auto_continuations, 0) AS auto_continuations,
103
+ -- The earliest of those, seconds after the reset; NULL when none.
104
+ r.first_seconds AS first_auto_continuation_seconds
105
+ FROM with_next w
106
+ LEFT JOIN resumed r ON r.interval_number = w.interval_number;
107
+
108
+ -- Observed: each logged auto-continuation that followed a lockout, with its interval and how long after the reset (D-077).
109
+ CREATE VIEW obs_auto_continuation_events AS
110
+ WITH interval_hits AS MATERIALIZED (
111
+ SELECT raw_line_id, end_utc, interval_number FROM obs_lockout_interval_hits
112
+ ),
113
+ resets AS (
114
+ -- Each interval's reset as obs_lockout_intervals takes it: the latest end of its hits' spans.
115
+ SELECT interval_number, MAX(end_utc) AS locked_until_utc FROM interval_hits GROUP BY interval_number
116
+ )
117
+ SELECT
118
+ m.raw_line_id,
119
+ m.session_id,
120
+ m.continued_at_utc,
121
+ m.hit_raw_line_id,
122
+ h.interval_number,
123
+ r.locked_until_utc,
124
+ -- As written: a marker logged before the reset would show a negative time (none observed).
125
+ (round(unixepoch(m.continued_at_utc, 'subsec') * 1000) - round(unixepoch(r.locked_until_utc, 'subsec') * 1000)) / 1000.0
126
+ AS seconds_after_reset
127
+ FROM auto_continuation_marks m
128
+ JOIN interval_hits h ON h.raw_line_id = m.hit_raw_line_id
129
+ JOIN resets r ON r.interval_number = h.interval_number;
@@ -0,0 +1,29 @@
1
+ -- 024_counted_limit_groups: a status-line-only limit hit starts from a counted reading (D-078, step G2.6).
2
+ --
3
+ -- status_limit_groups took every reading at or over 100%, including readings observed after their
4
+ -- own window's reset (the payload lag D-024 describes: a session's reading repeats its last
5
+ -- response until it makes another request). A group whose first at-limit reading came after the
6
+ -- reset drew a lockout of 0 s, from a hit to a reset it had already passed. Every other window
7
+ -- metric counts only readings before the reset (after_reset = 0); this view now does too.
8
+
9
+ DROP VIEW status_limit_groups;
10
+
11
+ -- Observed: status line windows observed at 100%, one row per (window, resets_at), from init onward, counting only
12
+ -- readings before the window's reset (D-023, D-044, D-078).
13
+ CREATE VIEW status_limit_groups AS
14
+ WITH at_limit AS (
15
+ SELECT
16
+ r.raw_line_id, r.window, r.resets_at, r.session_id, r.observed_at_utc,
17
+ ROW_NUMBER() OVER (PARTITION BY r.window, r.resets_at ORDER BY r.observed_at_s, r.raw_line_id) AS reading_rank,
18
+ COUNT(*) OVER (PARTITION BY r.window, r.resets_at) AS readings_at_limit
19
+ FROM window_readings r
20
+ -- A reading after its reset belongs to no window (D-024), so it can't show that window at its limit.
21
+ WHERE r.used_percentage >= 100 AND r.after_reset = 0
22
+ )
23
+ SELECT
24
+ raw_line_id, window, resets_at, session_id, readings_at_limit,
25
+ observed_at_utc AS first_at_utc,
26
+ strftime('%Y-%m-%dT%H:%M:%fZ', resets_at, 'unixepoch') AS reset_at_utc,
27
+ CASE window WHEN 'five_hour' THEN 5 * 3600 ELSE 7 * 86400 END AS window_seconds
28
+ FROM at_limit
29
+ WHERE reading_rank = 1;