acute 1.0.0 → 1.0.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6db307d10cb36e05af59196730d200f9bb4680343f8f01768a852a5bd5aba5de
4
- data.tar.gz: 2b757ac98e117777b6637d42b6f0e4611b8503b8c6b12a8180fab70ff2425e4b
3
+ metadata.gz: 6c84cad41fb5b9704bc324f3412f532d56a003159e2d23386c9b4adb8e58b55a
4
+ data.tar.gz: 0e5ee273250d73191e35621f265c330d8d5a12e0573c63d9b0e3d1f5374ece7f
5
5
  SHA512:
6
- metadata.gz: e9b66b6696ab31eace0667d150e9762c30accc9e15846f8843553b547abfd729fa43ed3abf95c90a80a8ac6a9fe2faa5bf29f0962268852265c6749deb754311
7
- data.tar.gz: 642748802a82a076608387006200c10ea324e146eb55607d650af08c5f8589a960eb7ad08713eb33605aad2730b113ac8ef1441706237d72bace4105effbe4f2
6
+ metadata.gz: 9796bf94a816424b8d405bfa96ce88830114e56eac34a352f916a969281eb0066031c3bbe47fdd3b257346fa8f5d302f22e3aecdf274a0e4781591cdcf8696ad
7
+ data.tar.gz: 4dac4cd449312e682c1105083cac7cd1277f77250fb511b963bd48aac230fadfad4b9283afcfab8e3be6faf3eea48222c47d8a323e175de45392f9af9394b18c
data/CHANGELOG.md ADDED
@@ -0,0 +1,26 @@
1
+ # Changelog
2
+
3
+ ## 1.0.1
4
+
5
+ - **Changed:** the gem includes the README, CHANGELOG and docs/, so they can be read offline beside the code.
6
+ - **Fixed:** a `now:` with only an offset, such as `Time.new(2026, 10, 17, 14, 0, 0, "-04:00")`, was read in the system zone, though the docs promise the zone of `now:`. Without `zone:`, its offset is now the zone (D45).
7
+ - **Fixed:** the `acute` command printed a failure's hint only with `--json`. Its plain output now adds it as `hint: …`, naming the command's flag, as the JSON does: a line of its own after `acute: <reason>`, or after three spaces on an `error:` line from stdin.
8
+ - **Fixed:** two rules with the same iCal were `==`, with one hash, though they listed or read differently: under another `dst:` policy or read in another zone, or one saying "starting" where the other didn't. Equality now covers all of these, and still not the text or the `now:` (only the default `after:` for `next`), so two phrasings of one rule stay equal.
9
+
10
+ ## 1.0.0
11
+
12
+ The first release. From here on, what may change in a minor or patch release is in [docs/stability.md](docs/stability.md).
13
+
14
+ - **Parsing.** `Acute.parse`, `parse!` and `Acute.with` read a date, time or period in plain English against the `now:` and `zone:` each call is given, with daylight saving handled, and say which reading they chose and why: its assumptions, each tagged with its rule, and the alternatives. A failure is a value with a code and a hint; `parse!` raises it. `Acute.duration` reads lengths of time. [docs/usage.md](docs/usage.md), [docs/rules.md](docs/rules.md)
15
+ - **Recurrence.** `Acute.recurrence` reads a repeating time as an `Acute::Rule`, with its occurrences (`next`, `between`, `each`), iCalendar RRULE text, and cron lines both ways. [docs/recurrence.md](docs/recurrence.md)
16
+ - **Extract.** `Acute.extract` finds the dates, times, durations and rules inside longer text, in time linear in its length. [docs/usage.md](docs/usage.md#dates-inside-other-text)
17
+ - **Writing and checking dates.** `Acute.describe` writes a time, duration or rule back in English; `Acute.verify` checks a claim such as "next friday is the 23rd". [docs/usage.md](docs/usage.md#writing-and-checking-dates)
18
+ - **Agents.** `Acute::Tool` gives the calls to an LLM as tools with JSON schemas, and `acute mcp` serves them over MCP; the app, not the model, fixes the zone and now. [docs/agents.md](docs/agents.md)
19
+ - **The `acute` command** parses phrases from arguments or stdin, for people or as JSON Lines. [docs/cli.md](docs/cli.md)
20
+ - **chronic compatibility.** `require "acute/chronic"` and `"acute/chronic_duration"` keep those gems' APIs, answered by acute. [docs/chronic.md](docs/chronic.md)
21
+ - **No runtime dependencies.** IANA zone data (2026e, `Acute::TZDATA`) ships with the gem, so there is no tzinfo dependency. Ruby 3.3 or later.
22
+ - **Hardening.** Every entry point's cost is bounded (512 bytes a phrase, budgets on occurrences and business days), the inputs were probed against a threat model, and messages escape what they echo. The gem is published from GitHub by trusted publishing with a build provenance attestation. [SECURITY.md](SECURITY.md)
23
+
24
+ - **Fixed:** Cairo kept standard time all year after 2076. Its summer, which ends at 24:00 on the last Thursday of October, now carries on to 2200 (D47).
25
+ - **Fixed:** an hourly or finer rule kept an excepted time: "every hour except october 17 at 4pm" still listed 4pm. A time is now that instant, and a date leaves out each of the rule's occurrences that day; one needing more than 24 is `:conflict` (R12).
26
+ - **Fixed:** "week before last" and "the month before last" were incomplete; they are now the one before last week or month (D44). `extract` found only "fri" in "mon, wed and fri": a weekday in a list of weekdays is now a day, though "mon" and "wed" are also words (D18).
data/README.md ADDED
@@ -0,0 +1,177 @@
1
+ # acute
2
+
3
+ [![check](https://github.com/skorks/acute/actions/workflows/check.yml/badge.svg)](https://github.com/skorks/acute/actions/workflows/check.yml)
4
+
5
+ Plain-English date and time parsing for Ruby: a successor to [chronic](https://github.com/mojombo/chronic).
6
+
7
+ ```ruby
8
+ require "acute"
9
+
10
+ r = Acute.parse("next friday at 3pm", now: "2026-10-17T14:00", zone: "America/New_York")
11
+ r.time # => 2026-10-23 15:00:00 -0400
12
+ ```
13
+
14
+ `r.time` is a plain Ruby `Time` at the offset of the zone you asked for, here New York's -04:00. `parse` returns nil when the text isn't a time, and `parse!` raises.
15
+
16
+ Ruby's own `Time.parse` reads formats, not phrases: it takes "next tuesday at 3pm" as 3pm today. chronic reads phrases, but it is unmaintained, has one global time zone, and silently guesses at words it doesn't know. acute is a rewrite from scratch: every call takes its own `now` and zone, daylight saving is handled properly, it reads a wide range of phrases (checked against more than 18,000 cases), it says which reading it chose and why, and it runs 20–100× faster ([measured](#performance)). Why, with examples, and the name: [docs/chronic.md](docs/chronic.md#why-acute-exists).
17
+
18
+ ### When to use something else
19
+
20
+ - Your input is always ISO 8601 or one fixed format: Ruby's `Time.iso8601` and `Time.strptime` read it, with nothing to install.
21
+ - You need languages other than English: acute reads English only. Its locales (en-US, en-GB, en-AU…) set date order, week start and holidays, not the words.
22
+ - You want a best guess at vague or garbled text: acute fails rather than guess. "soon", "the day after the meeting", "mon, wed and fri" and "satterday" return nil; it corrects only a small table of common misspellings, with no fuzzy matching. Everything it won't read: [gotchas](docs/gotchas.md#soon-gave-nil).
23
+ - You need meaning beyond dates, times, lengths and repeats, such as intents or places: that's a job for an NLP library or an LLM, which can call acute for the dates ([agents](docs/agents.md)).
24
+ - You need chronic's answers exactly: acute differs where chronic is wrong or ambiguous ([the differences](docs/chronic.md#moving-over)).
25
+
26
+ ## Installation
27
+
28
+ <!-- no-doc-test -->
29
+ ```ruby
30
+ gem "acute"
31
+ ```
32
+
33
+ Or `gem install acute`. acute needs Ruby 3.3 or later and has no runtime dependencies. Its zone data is IANA 2026e, shipped with the gem (`Acute::TZDATA`).
34
+
35
+ What each kind of release may change: [docs/stability.md](docs/stability.md).
36
+
37
+ ## Usage
38
+
39
+ The examples pin `now:` to Saturday 17 October 2026, 14:00, in New York. `now:` takes a Time or an ISO 8601 string, and without it acute uses the clock. Every call takes the same options (`now:`, `zone:`, `context:`, `locale:`, `strict:`, `dst:`…) and checks them, and `Acute.with` checks them once and returns a frozen parser to share across threads and Ractors. Every call, option and result field: [docs/usage.md](docs/usage.md).
40
+
41
+ ```ruby
42
+ now = "2026-10-17T14:00"
43
+ zone = "America/New_York"
44
+
45
+ r = Acute.parse("tomorrow", now:, zone:) # nil when the text isn't a time
46
+ r.begin # => 2026-10-18 00:00:00 -0400
47
+ r.end # => 2026-10-19 00:00:00 -0400
48
+ Acute.parse("tomorrow 3pm PST", now:, zone:).time # => 2026-10-18 19:00:00 -0400
49
+ ```
50
+
51
+ A phrase means the whole period it names: `time` is its start, and `end` is the first moment after it. A zone written in the text wins, and the answer comes back in yours. Without `zone:` acute falls back to the zone of `now:` (a TimeWithZone's, or a Time's fixed offset), Rails' `Time.zone`, then the system's; on a server, pass the zone you mean. Daylight saving and the options: [docs/usage.md](docs/usage.md#time-zones).
52
+
53
+ Times are plain `Time` values with a fixed offset, even when `now:` is a `TimeWithZone`; under Rails, `r.time.in_time_zone(r.zone)` before adding days ([why](docs/usage.md#time-zones)). Answers people trip on, such as "next friday", midnight, two-digit years and daylight saving, are on [the gotchas page](docs/gotchas.md).
54
+
55
+ ### Ambiguity
56
+
57
+ When a phrase can be read more than one way, acute settles it with a numbered rule, records the rule, and keeps the other readings. Said at 14:00, "at 9" is 9am tomorrow by rule D4, and 9pm tonight is the alternative:
58
+
59
+ ```ruby
60
+ r = Acute.parse("at 9", now:, zone:)
61
+ r.time # => 2026-10-18 09:00:00 -0400
62
+ r.alternatives.map(&:time) # => [2026-10-17 21:00:00 -0400]
63
+ r.assumptions.map(&:code) # => [:bare_hour_window, :next_occurrence]
64
+ r.assumptions.first.rule # => "D4"
65
+ ```
66
+
67
+ Every rule is on [the rules page](docs/rules.md).
68
+
69
+ ### Failures
70
+
71
+ ```ruby
72
+ Acute.parse!("tomorow at 3", now:, zone:)
73
+ # Acute::ParseError: unknown word "tomorow"
74
+ # e.code => :unknown_word, e.hint => "did you mean \"tomorrow\"?"
75
+
76
+ Acute.parse("meet bob tuesday", now:, zone:) # => nil
77
+ Acute.parse("meet bob tuesday", now:, zone:, strict: false) # => #<Acute::Result Tue, Oct 20, 2026, all day>
78
+ ```
79
+
80
+ `parse` returns nil, and `parse!` raises. Both errors, and skipping unknown words: [docs/usage.md](docs/usage.md#failures).
81
+
82
+ ### Durations and dates inside text
83
+
84
+ ```ruby
85
+ Acute.duration("1h 30m").iso8601 # => "PT1H30M"
86
+
87
+ x = Acute.extract("Lunch with Sam next friday at noon, then a 2 hour review", now:, zone:)
88
+ x.mentions.map { [_1.offset, _1.value.to_s] }
89
+ # => [[15...34, "Fri, Oct 23, 2026, 12pm EDT"], [43...49, "2h"]]
90
+ x.remainder # => "Lunch with Sam, then a review"
91
+ ```
92
+
93
+ More: [durations](docs/usage.md#durations), [extract](docs/usage.md#dates-inside-other-text).
94
+
95
+ ### Writing and checking dates
96
+
97
+ `describe` writes a time as the most natural phrase that reads back to it exactly. `verify` checks a time something else worked out, such as an LLM, with a message to pass straight back:
98
+
99
+ ```ruby
100
+ Acute.describe(Time.new(2026, 10, 20, 15, 0, 0, "-04:00"), now:, zone:)
101
+ # => "Tuesday at 3pm"
102
+
103
+ Acute.verify("next friday at noon", "2026-10-24T12:00", now:, zone:).message
104
+ # => "\"next friday at noon\" is Fri 23 Oct, 12pm, not Sat 24 Oct, 12pm."
105
+ ```
106
+
107
+ More: [docs/usage.md](docs/usage.md#writing-and-checking-dates).
108
+
109
+ ### Repeating times
110
+
111
+ ```ruby
112
+ r = Acute.recurrence("every weekday at 9am", now:, zone:)
113
+ r.rrule # => "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0"
114
+ r.cron # => "0 9 * * 1-5"
115
+ r.next # => 2026-10-19 09:00:00 -0400
116
+ r.next(2)
117
+ # => [2026-10-19 09:00:00 -0400, 2026-10-20 09:00:00 -0400]
118
+ ```
119
+
120
+ iCalendar rules, their occurrences and cron: [docs/recurrence.md](docs/recurrence.md).
121
+
122
+ ### Command line
123
+
124
+ ```
125
+ $ acute --now 2026-10-17T14:00 --zone America/New_York "next friday at 3pm"
126
+ Fri, Oct 23, 3pm EDT
127
+
128
+ $ acute --now 2026-10-17T14:00 --zone America/New_York --json "next friday at 3pm"
129
+ {"kind":"time","text":"next friday at 3pm","time":"2026-10-23T15:00:00-04:00","begin":"2026-10-23T15:00:00-04:00","end":"2026-10-23T15:01:00-04:00","grain":"minute","zone":"America/New_York","assumptions":[],"alternatives":[]}
130
+ ```
131
+
132
+ Without `--json` it prints the reading for people to read, in `describe`'s calendar style; with it, the Result's `to_json` for scripts. Every option, stdin and failures: [docs/cli.md](docs/cli.md).
133
+
134
+ ### Agents and LLMs
135
+
136
+ ```
137
+ $ claude mcp add acute -- acute mcp --zone America/New_York # an MCP server over stdio
138
+ ```
139
+
140
+ ```ruby
141
+ require "acute/tool"
142
+ tool = Acute::Tool.new(now:, zone:) # the app sets the zone and now, never the model
143
+ tool.definitions(:anthropic).map { _1[:name] } # or :openai, for any SDK
144
+ # => ["parse", "duration", "recurrence", "extract", "verify", "describe", "now"]
145
+ tool.call("parse", { "text" => "next friday at 3pm" })
146
+ # => {"kind" => "time", "time" => "2026-10-23T15:00:00-04:00", "end" => "2026-10-23T15:01:00-04:00", "grain" => "minute", "zone" => "America/New_York"}
147
+ ```
148
+
149
+ The tools, their failures and how zone and now are set: [docs/agents.md](docs/agents.md). With RubyLLM it is `chat.with_tools(*tool.ruby_llm)`: [docs/agents.md#rubyllm](docs/agents.md#rubyllm).
150
+
151
+ ## Coming from chronic
152
+
153
+ `require "acute/chronic"` gives you chronic's API, `Chronic.parse` with chronic's options, answered by acute; `require "acute/chronic_duration"` does the same for chronic_duration. A few answers differ, following acute's rules: [docs/chronic.md](docs/chronic.md#moving-over).
154
+
155
+ ## Performance
156
+
157
+ Over ten everyday phrases on Ruby 4.0.7, acute takes 5–19 µs a parse to chronic 0.10.2's 260–490 µs, 20–100× faster (median about 50×), and 1.5–8 µs under YJIT, 80–300× faster. `bin/bench-chronic` measures it on your machine: [docs/chronic.md](docs/chronic.md#performance).
158
+
159
+ ## Documentation
160
+
161
+ - [docs/usage.md](docs/usage.md): every call, option and result field, with examples.
162
+ - [docs/recurrence.md](docs/recurrence.md): repeating times as iCalendar rules, their occurrences, and cron.
163
+ - [docs/cli.md](docs/cli.md): the `acute` command and its options.
164
+ - [docs/agents.md](docs/agents.md): acute as tools for an LLM, through `Acute::Tool` or `acute mcp`.
165
+ - [docs/chronic.md](docs/chronic.md): why acute exists, moving from chronic, and speed.
166
+ - [docs/gotchas.md](docs/gotchas.md): the answers people don't expect, why, the option that changes each, and what acute won't read.
167
+ - [docs/rules.md](docs/rules.md): every convention acute follows, by topic, with examples.
168
+ - [docs/stability.md](docs/stability.md): what SemVer covers, and what a minor or patch release may change.
169
+ - [schema/](schema): JSON Schemas for the JSON of a Result, Duration, Extraction and Verdict, and for the agent tools.
170
+
171
+ ## Contributing
172
+
173
+ `bin/check` runs everything: lint, unit tests, the phrase corpus, chronic's tests, fuzzing, coverage and speed. To add a phrase, see [test/cases/FORMAT.md](test/cases/FORMAT.md).
174
+
175
+ ## License
176
+
177
+ MIT; see [LICENSE](LICENSE). The test suite draws on other projects' work, credited in [NOTICE](NOTICE).
data/docs/agents.md ADDED
@@ -0,0 +1,101 @@
1
+ # acute for agents
2
+
3
+ Use this page to let an LLM hand date phrases to acute instead of working out dates itself. acute offers itself as seven tools. `Acute::Tool` gives them to an app that calls a model through an SDK; `acute mcp` serves the same tools to any MCP client. Both take their names, descriptions and argument schemas from [schema/tools/](../schema/tools).
4
+
5
+ Said on Saturday 17 October 2026 at 14:00 in New York:
6
+
7
+ ```ruby
8
+ require "acute/tool"
9
+ tool = Acute::Tool.new(now: "2026-10-17T14:00", zone: "America/New_York")
10
+ tool.call("verify", { "text" => "next friday at noon", "value" => "2026-10-24T12:00" })
11
+ # => {"kind" => "verdict", "status" => "mismatch", "ok" => false, "message" => "The text is Fri 23 Oct, 12pm, not Sat 24 Oct, 12pm.", "expected" => {"kind" => "time", "time" => "2026-10-23T12:00:00-04:00", "end" => "2026-10-23T12:01:00-04:00", "grain" => "minute", "zone" => "America/New_York"}}
12
+ ```
13
+
14
+ ## The tools
15
+
16
+ | Tool | Arguments ([schema/tools/](../schema/tools)) | Returns |
17
+ |---|---|---|
18
+ | `parse` | `text`, `context` | [result.json](../schema/result.json) |
19
+ | `duration` | `text` | [duration.json](../schema/duration.json) |
20
+ | `recurrence` | `text` | [rule.json](../schema/rule.json), plus `next`: its first three occurrences |
21
+ | `extract` | `text`, `context` | [extraction.json](../schema/extraction.json) |
22
+ | `verify` | `text`, `value`, `context` | [verdict.json](../schema/verdict.json) |
23
+ | `describe` | `value`, `style` | [description.json](../schema/description.json): the wording as `description`, as a rule's is |
24
+ | `now` | none | [now.json](../schema/now.json) |
25
+
26
+ Any of them may instead return a [failure](../schema/failure.json).
27
+
28
+ `recurrence`'s `next` is the rule's first three occurrences, from `dtstart` on, as ISO 8601 times, or dates for a rule of whole days. A rule that ends sooner lists fewer. `cron`, when the rule has one, runs in the wall time of `zone` (`CRON_TZ`).
29
+
30
+ ## Compact results
31
+
32
+ The tools return the hashes described in [schema/](../schema), with what repeats left out, since the model pays for every char of a result on every call. A result leaves out the input's `text` (on a parse, recurrence, extract or verify result, a mention's value and a verdict's `expected`), so a rule keeps only its own wording, `description`; a `begin` equal to `time`; a mention's `kind`, which its value's `kind` repeats; a verdict's `expected` when `ok` is true; `now`'s `offset`, which the `now` string ends with; a rule's `ical` unless it has an EXDATE, since `dtstart` and `rrule` hold the rest, and whichever of `cron` and `cron_reason` is null; and `assumptions`, `alternatives` and `unread` when they're empty, a failure's `unread` included. `extract` gives at most 50 mentions; past that, `more` counts the ones left out. An assumption's `message` is its short form, `short` in [data/assumptions.yml](../data/assumptions.yml): the gist in about 50 chars rather than 80. The library's own JSON keeps them all, and the long messages.
33
+
34
+ ## Failures and `unread`
35
+
36
+ A tool never raises on what the model sends. Wrong arguments, or text acute can't read, come back as `{kind: "failure", code, error, unread}`, [schema/failure.json](../schema/failure.json). `error` is acute's own words and never quotes the input: text from the arguments could carry instructions, so the words a failure points at go in `unread`, which every tool's description marks as user data. A failure to read the text adds `position`, the characters (codepoints) of `text` it points at as `[begin, end]`, when that is less than the whole phrase, and, for a misspelt word, `hint`: `"did you mean \"tomorrow\"?"`, a word from acute's own table. The `acute` command with `--json` gives the same shape, `position` included, plus the input as `text` and hints for every code, naming its flags. More codes may come: treat one you don't know as a failure, and ignore fields you don't know. This holds for every `error`, `message` and `description` acute writes, including the MCP server's JSON-RPC errors.
37
+
38
+ ## In an app: `Acute::Tool`
39
+
40
+ <!-- no-doc-test -->
41
+ ```ruby
42
+ require "acute/tool"
43
+ tool = Acute::Tool.new(zone: user.zone, locale: user.locale)
44
+ tool.definitions(:anthropic) # or :openai
45
+ tool.call(name, arguments) # arguments as the SDK hands them: a Hash or OpenAI's JSON string
46
+ ```
47
+
48
+ The app fixes every option at construction, with the same keywords as `Acute.with`. The input schemas leave them out, so the model can set neither the zone nor the time: an argument named `zone` or `now` is an `unknown_argument` failure. `prefix:` namespaces the tool names, such as `prefix: "dates_"`.
49
+
50
+ ### RubyLLM
51
+
52
+ `require "acute/tool/ruby_llm"` adds `tool.ruby_llm`, one `RubyLLM::Tool` per tool. ruby_llm isn't a dependency of acute: add `gem "ruby_llm"` to your Gemfile. Without it the require fails with
53
+
54
+ ```
55
+ acute/tool/ruby_llm needs the ruby_llm gem; add it to your Gemfile (cannot load such file -- ruby_llm) (LoadError)
56
+ ```
57
+
58
+ Build the tool per user or per request, with their zone:
59
+
60
+ <!-- no-doc-test -->
61
+ ```ruby
62
+ require "acute/tool/ruby_llm"
63
+
64
+ tool = Acute::Tool.new(zone: current_user.time_zone) # or Acute::Tool.new(Acute.with(zone: current_user.time_zone, locale: "en-GB"))
65
+ chat = RubyLLM.chat
66
+ chat.with_tools(*tool.ruby_llm)
67
+ chat.ask("Move my dentist appointment to the second monday of may at 3pm")
68
+ ```
69
+
70
+ It's per user because the app, not the model, fixes the zone and now: the schemas give the model no way to set them, so every answer is in the zone the tool was built with.
71
+
72
+ An app that already has a tool called `parse` can rename acute's with `prefix:`: `Acute::Tool.new(zone: current_user.time_zone, prefix: "dates_")` gives the chat `dates_parse`, `dates_extract`, `dates_now` and so on.
73
+
74
+ Each call hands the model the compact hash [described above](#compact-results). A failure is a value too, never an exception, so ruby_llm passes it back and the model can correct its arguments and try again ([Failures and `unread`](#failures-and-unread)).
75
+
76
+ One caveat: when the model calls a tool name the chat doesn't have, ruby_llm answers, not acute, with ``Model tried to call unavailable tool `<name>`. Available tools: [...]``. That message quotes the name the model sent, and acute can't change it.
77
+
78
+ ## Over MCP: `acute mcp`
79
+
80
+ ```
81
+ $ claude mcp add acute -- acute mcp --zone America/New_York
82
+ ```
83
+
84
+ `acute mcp` reads one JSON-RPC message per line on stdin and writes one per line on stdout, and exits when stdin closes. It speaks MCP 2026-07-28 to a client whose requests carry `_meta`, and 2025-11-25 or 2025-06-18 to one that opens with `initialize`.
85
+
86
+ | Option | Sets |
87
+ |---|---|
88
+ | `--zone ZONE` | the zone when a call names none (default: the system's) |
89
+ | `--locale LOCALE` | how numeric dates read, such as en-GB (default: from the zone) |
90
+ | `--context future\|past` | the default way a phrase that could point either way points |
91
+ | `--week-start sunday\|monday` | the first day of a week |
92
+
93
+ Zone and now differ from the library. The person at the other end of an MCP client may be anywhere, so every tool but `duration` takes an optional `zone`, and an invalid one is an `invalid_argument` failure. `now` is always the server's clock: no option or argument sets it.
94
+
95
+ Each tool's result is `structuredContent`, with a JSON copy in `content`. A failure is a result with `isError: true`. Since a failure is a structured result too, each tool's `outputSchema` is a `oneOf` of its own schema and the failure schema, self-contained so a client resolves it without fetching: a schema referred to once is written in place and one referred to more than once goes under `$defs`. It describes the result as the tool gives it, with the fields above left out or optional, and keeps the structure the result must conform to and drops the descriptions, which the model would pay for on every listing; [schema/](../schema) has them. An unknown tool, method or malformed request is a JSON-RPC error instead; a line over 64 KiB is refused and the server carries on.
96
+
97
+ ## Security
98
+
99
+ - **The user's words.** No `error`, `message` or `description` quotes the arguments. The user's words come back in two fields only: a failure's `unread` (at most 5 items of 60 chars) and an extraction's `remainder`. Each tool's description tells the model they are user data, never instructions. The output schemas say so too, but a client may not show those to the model.
100
+ - **Limits.** `text` is at most 512 chars and 512 bytes, or 4096 chars for `extract`; `value` is at most 64 chars. A call counts at most 10,000 business days in all, across every count in it; past that a count fails with `business_day_limit`, and in `extract` that mention is not a time. Over MCP, a line over 64 KiB and a batch are refused, an `id` must be a string or an integer, JSON nested more than 100 deep is a parse error, and an internal error is -32603 with nothing of the input.
101
+ - **Zone and now.** Through `Acute::Tool`, only the app sets them. Over MCP the model may pass `zone`, which must be an IANA name or a UTC offset of at most 64 chars; anything else is an `invalid_argument` failure that shows it only in `unread`. `now` is always the server's clock.
data/docs/chronic.md ADDED
@@ -0,0 +1,57 @@
1
+ # acute and chronic
2
+
3
+ Read this if you use chronic: why acute replaces it, how to move over, and how much faster it is.
4
+
5
+ ## Why acute exists
6
+
7
+ chronic made parsing "next tuesday at 3pm" a one-liner for more than a decade, and it has over 130 million downloads. But it has stood still:
8
+
9
+ - **It is unmaintained.** There has been no release since 0.10.2 in 2013, and more than 120 issues are open.
10
+ - **It doesn't really do time zones.** The zone is one global setting, `Chronic.time_class`, so there is no per-call zone and no thread-safe way to change it (its top open issue, [#182](https://github.com/mojombo/chronic/issues/182)). It also assumes every day has 24 hours, which breaks around daylight-saving changes.
11
+ - **It guesses silently.** Words it doesn't know are skipped, so "a banana" parses as a time and "4/32" as a date in 2032.
12
+ - **It isn't comprehensive enough.** Many everyday phrases return nil or raise.
13
+
14
+ Said on Saturday 17 October 2026 at 14:00 in New York:
15
+
16
+ | Phrase | chronic 0.10.2 | acute |
17
+ |---|---|---|
18
+ | "a banana" | today 13:00 | nil |
19
+ | "4/32" | 16 April 2032 | nil |
20
+ | "last 14 days" | yesterday 14:00 | 3 Oct 14:00 to 17 Oct 14:00 |
21
+ | "2nd thursday of june" | nil | 10 June 2027 |
22
+ | "christmas eve" | nil | 24 December 2026 |
23
+ | "1 year, 7 months ago" | raises NoMethodError | 17 March 2025, 14:00 |
24
+ | "1 day from now", at noon on 31 Oct | 11:00 on 1 Nov (24 hours across the clock change) | 12:00 on 1 Nov |
25
+ | "march 8 2026 2:30am" (skipped by DST) | 01:30 EST | 03:30 EDT |
26
+
27
+ acute is a rewrite from scratch, with a different design:
28
+
29
+ - every call takes its own `now` and zone, and daylight saving is handled properly;
30
+ - it reads a wide range of phrases, checked against more than 18,000 cases gathered from chronic and a dozen other date parsers;
31
+ - when a phrase could mean more than one thing, it says which reading it chose, and why;
32
+ - it runs 20–100× faster than chronic, 80–300× under YJIT ([measured](#performance)).
33
+
34
+ **About the name.** In medicine, a *chronic* condition lingers for years, and an *acute* one is short and sharp. chronic has lingered since 2013. acute is short, sharp and over in a few microseconds.
35
+
36
+ ## Moving over
37
+
38
+ `require "acute/chronic"` gives you chronic's API, `Chronic.parse` with chronic's options, answered by acute. `require "acute/chronic_duration"` does the same for chronic_duration.
39
+
40
+ ```ruby
41
+ require "acute/chronic"
42
+
43
+ Chronic.parse("tomorrow 3pm", now: Time.now)
44
+ ```
45
+
46
+ The answers follow acute's rules, so a few differ from chronic's. chronic's own test suite runs against this layer, and every place it disagrees is listed, with its rule, in [test/chronic/differences.yml](../test/chronic/differences.yml). The [compatibility notes](rules.md#compatibility-layers) explain how each option is read.
47
+
48
+ ## Performance
49
+
50
+ `bin/bench-chronic` times chronic 0.10.2, the gem itself, and acute on ten everyday phrases ("tomorrow", "next tuesday 3pm", "3 days ago", "27/5/1979"…), in CPU time per parse, best of five rounds. Run it with `ruby bin/bench-chronic`, then `ruby --yjit bin/bench-chronic`; it needs `gem install chronic -v 0.10.2`. On Ruby 4.0.7 on an i5-13600K:
51
+
52
+ | | chronic 0.10.2 | acute | faster by |
53
+ |---|---|---|---|
54
+ | interpreter | 260–490 µs | 5–19 µs | 20–100×, median about 50× |
55
+ | YJIT | 350–650 µs | 1.5–8 µs | 80–300×, median about 150× |
56
+
57
+ The machine was busy, so read the ratios rather than the last digit; runs moved them by about 20%. Roughly, chronic manages two or three thousand parses a second per core, and acute around a hundred thousand, or several hundred thousand with YJIT. acute's own speed budget is checked on every commit.
data/docs/cli.md ADDED
@@ -0,0 +1,61 @@
1
+ # The acute command
2
+
3
+ `acute` reads phrases in a shell or a script and prints when they are. The examples pin `--now` to Saturday 17 October 2026 at 14:00, so they give the same answers whenever you run them.
4
+
5
+ ```
6
+ $ acute --now 2026-10-17T14:00 --zone America/New_York "next friday at 3pm"
7
+ Fri, Oct 23, 3pm EDT
8
+
9
+ $ acute --now 2026-10-17T14:00 --zone America/New_York --dst reject "2026-03-08 02:30"
10
+ acute: "2026-03-08 02:30" names a wall time the clocks skip as they go forward
11
+ hint: --dst earlier, later or compatible picks an instant for it
12
+
13
+ $ acute --json < phrases.txt # one JSON line per phrase
14
+
15
+ $ acute --now 2026-10-17T14:00 --zone America/New_York --next 2 "every weekday at 9am"
16
+ every weekday at 9am
17
+ DTSTART;TZID=America/New_York:20261019T090000
18
+ RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR=9;BYMINUTE=0
19
+ cron: 0 9 * * 1-5 (CRON_TZ=America/New_York)
20
+ Mon, Oct 19, 9am EDT
21
+ Tue, Oct 20, 9am EDT
22
+ ```
23
+
24
+ `acute PHRASE` prints the reading in describe's calendar style, with the zone's abbreviation, then any alternatives as "(also: …)". A duration prints as "1h 30m". A repeating time prints its words, its iCalendar lines (DTSTART, RRULE and any EXDATE) and its cron line when cron can express it, in the zone's wall time. `--next N` adds the rule's first N occurrences, from DTSTART on; N is 1 to 1,000, and a phrase that isn't a rule fails. With no phrase, it reads one phrase per line from stdin and prints exactly one line for each, a rule's lines joined by three spaces; it skips blank lines, which print nothing and don't fail. A failure prints `error: <reason>` on stdout, or `acute: <reason>` on stderr for a phrase in the arguments, then any hint as `hint: …`: on a line of its own, or after three spaces on a line from stdin. The JSON of a reading is that of `to_json`: [schema/result.json](../schema/result.json), [schema/duration.json](../schema/duration.json) or [schema/rule.json](../schema/rule.json), and `--next` adds a `next` array of ISO 8601 times, or dates for a rule of whole days.
25
+
26
+ With `--json`, every phrase gets one JSON line on stdout, a failure included. A failure has the tools' shape, [schema/failure.json](../schema/failure.json), plus the phrase as `text`. As in the tools, `position` is there when it points at less than the whole phrase, and counts characters (codepoints), not bytes or UTF-16 units. A `hint` names the flag that fixes the failure, such as `--dst earlier` for a wall time the clocks skip:
27
+
28
+ ```
29
+ $ acute --now 2026-10-17T14:00 --json "next fridya 3pm"
30
+ {"kind":"failure","text":"next fridya 3pm","code":"unknown_word","error":"unknown word \"fridya\"","position":[5,11],"hint":"did you mean \"friday\"?"}
31
+ ```
32
+
33
+ Exit codes: 0 when everything parsed, 1 when a phrase failed, 2 for bad options. A bad option prints `acute: <reason>` on stderr, with `--json` too.
34
+
35
+ ## Options
36
+
37
+ The options are those of [the library](usage.md#options), with dashes:
38
+
39
+ | Option | Sets |
40
+ |---|---|
41
+ | `--json` | print JSON (JSON Lines for stdin) |
42
+ | `--next N` | a rule's first N occurrences, 1–1,000 |
43
+ | `--now TIME` | now, in ISO 8601 (default: the clock) |
44
+ | `--zone ZONE` | an IANA zone or offset (default: the system's) |
45
+ | `--locale LOCALE` | en-US, en-GB, en-AU… (default: from the zone) |
46
+ | `--context CONTEXT` | future or past |
47
+ | `--week-start DAY` | sunday or monday |
48
+ | `--hemisphere SIDE` | north or south, for seasons |
49
+ | `--fiscal-year-start MONTH` | 1–12 |
50
+ | `--lenient` | skip unknown words (`strict: false`) |
51
+ | `--dst POLICY` | compatible, earlier, later or reject |
52
+ | `--holidays REGION` | a holidays-gem region, such as us (needs the gem) |
53
+ | `--year-bias YEARS` | two-digit years: the 100 from this many years ago (default 50) |
54
+ | `-v`, `--version` | the version |
55
+ | `-h`, `--help` | the usage |
56
+
57
+ ## acute mcp
58
+
59
+ `mcp` is acute's only subcommand, and stays so: new modes come as flags. It counts only as the first argument.
60
+
61
+ `acute mcp [options]` serves acute's tools to an MCP client over stdio, one JSON-RPC message per line. It takes `--zone`, `--locale`, `--context` and `--week-start`; see [agents.md](agents.md#over-mcp-acute-mcp).
data/docs/gotchas.md ADDED
@@ -0,0 +1,133 @@
1
+ # Gotchas
2
+
3
+ Read this when acute gave an answer you didn't expect. Each answer follows a numbered rule, and each rule has an option or an alternative reading when the other answer is the one you meant. The examples are said on Saturday 17 October 2026 at 14:00, in New York:
4
+
5
+ ```ruby
6
+ now = "2026-10-17T14:00"
7
+ zone = "America/New_York"
8
+ ```
9
+
10
+ ## "at 9" gave tomorrow
11
+
12
+ A time that could be past or future means the future one, the next time the clock shows it. Said at 14:00, "at 9" is 9am tomorrow, with 9pm tonight as the alternative ([D4](rules.md#d4-a-time-of-day-without-ampm)), and "at 3pm" is today. For things that already happened, pass `context: :past` ([D23](rules.md#d23-future-context-two-digit-years-whole-units-arithmetic-and-dst)):
13
+
14
+ ```ruby
15
+ Acute.parse("at 9", now:, zone:).time # => 2026-10-18 09:00:00 -0400
16
+ Acute.parse("at 9", now:, zone:).alternatives.map(&:time) # => [2026-10-17 21:00:00 -0400]
17
+ Acute.parse("at 3pm", now:, zone:, context: :past).time # => 2026-10-16 15:00:00 -0400
18
+ ```
19
+
20
+ ## "next friday" has two answers
21
+
22
+ Said early in the week, "next friday" can mean the coming Friday or the one a week later. acute gives the coming one first and the other as an alternative. Said on a Saturday, the coming Friday is already next week's, so there is one answer ([D20](rules.md#d20-next-friday)):
23
+
24
+ ```ruby
25
+ wednesday = "2026-10-14T14:00"
26
+ r = Acute.parse("next friday", now: wednesday, zone:)
27
+ r.time # => 2026-10-16 00:00:00 -0400
28
+ r.alternatives.map(&:time) # => [2026-10-23 00:00:00 -0400]
29
+ Acute.parse("next friday", now:, zone:).alternatives # => []
30
+ ```
31
+
32
+ If your users mean the later Friday, check `alternatives`, or confirm with them using `Acute.describe`.
33
+
34
+ ## "monday midnight" is Tuesday
35
+
36
+ Midnight with a day is the end of that day, so "monday midnight" is Tuesday 00:00, with Monday 00:00 as the alternative. "12am" written out is the start of the day ([D6](rules.md#d6-midnight-with-a-day)). Any 12 o'clock without am/pm is noon ([D30](rules.md#d30-the-12-oclock-hour)):
37
+
38
+ ```ruby
39
+ Acute.parse("monday midnight", now:, zone:).time # => 2026-10-20 00:00:00 -0400
40
+ Acute.parse("at 12", now:, zone:).time # => 2026-10-18 12:00:00 -0400
41
+ Acute.parse("12am", now:, zone:).time # => 2026-10-18 00:00:00 -0400
42
+ ```
43
+
44
+ ## "1/2/2015" depends on the zone
45
+
46
+ Slashed dates follow the locale's order, and the locale comes from the zone: month first in the US, day first in the UK and Australia. The other order is an alternative when it is a valid date. Dotted dates are always day first ([D10](rules.md#d10-numeric-dates)). Pass `locale:` to fix the order:
47
+
48
+ ```ruby
49
+ Acute.parse("1/2/2015", now:, zone:).time # => 2015-01-02 00:00:00 -0500
50
+ Acute.parse("1/2/2015", now:, zone: "Australia/Sydney").time # => 2015-02-01 00:00:00 +1100
51
+ Acute.parse("1/2/2015", now:, zone:, locale: "en-GB").time # => 2015-02-01 00:00:00 -0500
52
+ Acute.parse("1.2.2015", now:, zone:).time # => 2015-02-01 00:00:00 -0500
53
+ ```
54
+
55
+ ## "1/2/75" is 2075
56
+
57
+ A two-digit year falls in the 100 years that start 50 years before now: said in 2026, 75 is 2075 and 76 is 1976. For birthdays and other past dates, move the window back with `year_bias:` ([D23](rules.md#d23-future-context-two-digit-years-whole-units-arithmetic-and-dst)):
58
+
59
+ ```ruby
60
+ Acute.parse("1/2/75", now:, zone:).time.year # => 2075
61
+ Acute.parse("1/2/76", now:, zone:).time.year # => 1976
62
+ Acute.parse("1/2/75", now:, zone:, year_bias: 80).time.year # => 1975
63
+ ```
64
+
65
+ ## "this week" starts on Sunday
66
+
67
+ The week starts on Sunday in the US and on Monday in the UK, Europe and Australia, again from the zone's locale. `week_start:` overrides it ([D19](rules.md#d19-week-start)). ISO week numbers always start on Monday:
68
+
69
+ ```ruby
70
+ Acute.parse("this week", now:, zone:).begin # => 2026-10-11 00:00:00 -0400
71
+ Acute.parse("this week", now:, zone: "Europe/London").begin # => 2026-10-12 00:00:00 +0100
72
+ Acute.parse("this week", now:, zone:, week_start: :monday).begin # => 2026-10-12 00:00:00 -0400
73
+ ```
74
+
75
+ ## "tomorrow" ends at midnight, and "mon to wed" ends on Thursday
76
+
77
+ A phrase means the whole period it names. `time` is its start and `end` is the first moment after it, so a day ends at the next day's 00:00. A range of days includes its last day, so "mon to wed" ends at Thursday 00:00; a range of clock times ends at the stated time ([D9](rules.md#d9-where-ranges-end)):
78
+
79
+ ```ruby
80
+ Acute.parse("tomorrow", now:, zone:).end # => 2026-10-19 00:00:00 -0400
81
+ Acute.parse("mon to wed", now:, zone:).end # => 2026-10-22 00:00:00 -0400
82
+ Acute.parse("2 to 4pm", now:, zone:).end # => 2026-10-17 16:00:00 -0400
83
+ ```
84
+
85
+ ## 2:30am on the night the clocks go forward is 3:30
86
+
87
+ New York's clocks skip from 2:00 to 3:00 on 8 March 2026, so 2:30 that night never happens. acute moves it forward by the gap. On 1 November the clocks go back and 1:30 happens twice; acute takes the first. `dst:` picks another instant, or `dst: :reject` fails instead ([D23](rules.md#d23-future-context-two-digit-years-whole-units-arithmetic-and-dst)):
88
+
89
+ ```ruby
90
+ Acute.parse("march 8 2026 2:30am", now:, zone:).time # => 2026-03-08 03:30:00 -0400
91
+ Acute.parse("march 8 2026 2:30am", now:, zone:, dst: :earlier).time # => 2026-03-08 01:30:00 -0500
92
+ Acute.parse("nov 1 2026 1:30am", now:, zone:).time # => 2026-11-01 01:30:00 -0400
93
+ Acute.parse("nov 1 2026 1:30am", now:, zone:, dst: :later).time # => 2026-11-01 01:30:00 -0500
94
+
95
+ Acute.parse!("march 8 2026 2:30am", now:, zone:, dst: :reject)
96
+ # Acute::ParseError: "march 8 2026 2:30am" names a wall time the clocks skip as they go forward
97
+ # e.code => :dst_skipped_time
98
+ ```
99
+
100
+ ## "1530" is a year
101
+
102
+ Four digits alone are a year. "at", a leading zero, am/pm or "hrs" make them a time ([D5](rules.md#d5-a-bare-four-digit-number)):
103
+
104
+ ```ruby
105
+ Acute.parse("1530", now:, zone:).grain # => :year
106
+ Acute.parse("at 1530", now:, zone:).time # => 2026-10-17 15:30:00 -0400
107
+ ```
108
+
109
+ ## Adding days under Rails jumps an hour
110
+
111
+ Times come back as plain `Time` values with a fixed offset, so `+ 86_400` across a clock change keeps the old offset. Under Rails, convert first: `r.time.in_time_zone(r.zone)` ([time zones](usage.md#time-zones)).
112
+
113
+ ## "soon" gave nil
114
+
115
+ acute fails rather than guess. A phrase it can't pin to an exact time returns nil, and `parse!` raises with the reason ([failures](usage.md#failures)):
116
+
117
+ ```ruby
118
+ Acute.parse("soon", now:, zone:) # => nil
119
+ Acute.parse("the day after the meeting", now:, zone:) # => nil
120
+ Acute.parse("satterday", now:, zone:) # => nil
121
+ Acute.parse("mon, wed and fri", now:, zone:) # => nil
122
+ Acute.parse("FY27", now:, zone:) # => nil
123
+ Acute.parse("FY27", now:, zone:, fiscal_year_start: 7).date.to_s # => "2026-07-01"
124
+ ```
125
+
126
+ - **No time named**: "soon", "sometime next week".
127
+ - **Words it doesn't know**, such as an event or a name: "the day after the meeting". `strict: false` skips unknown words ([D22](rules.md#d22-strict-by-default)).
128
+ - **Misspellings** outside a small fixed table. There is no fuzzy matching: "tommorow" reads, "satterday" doesn't (D22).
129
+ - **Several dates in one phrase**: "mon, wed and fri", "tonight or tomorrow" (D22). `extract` reads dates out of text instead.
130
+ - **Other languages**: "mañana", "demain". A locale sets date order, week start and holidays, not the words.
131
+ - **Holidays outside the built-in set**, such as "chinese new year", without a `holidays:` calendar ([D15](rules.md#d15-holidays)); fiscal years without `fiscal_year_start:` ([D17](rules.md#d17-business-days-and-fiscal-years)); year 0 and BC ([D12](rules.md#d12-centuries-and-decades)).
132
+ - **A repeat that says how often but not when**: "twice a week", "every morning" ([what isn't a rule](recurrence.md#what-isnt-a-rule)).
133
+ - **Text over 512 bytes** fails with `too_long`.