@fitchmultz/pi-calculator 4.1.6 → 4.1.7

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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.1.7 - 2026-11-01
4
+
5
+ <!-- npm-release:unreleased:start -->
6
+ <!-- npm-release:unreleased:end -->
7
+
8
+ - Adopt the strict quality policy baseline and current Pi practice (#48) ([50e0e33](https://github.com/fitchmultz/pi-calculator/commit/50e0e33a0443a2d0ec1c12a5cf4080342ba61850))
9
+
10
+ ### What's Changed
11
+
12
+ - Adopt the strict quality policy baseline and current Pi practice by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/48
13
+
14
+ **Full Changelog**: https://github.com/fitchmultz/pi-calculator/compare/v4.1.6...v4.1.7
15
+
3
16
  ## 4.1.6 - 2026-11-01
4
17
 
5
18
  <!-- npm-release:unreleased:start -->
@@ -8,8 +21,8 @@
8
21
  - Use direct Flash for automatic release remediation (#49) ([544c540](https://github.com/fitchmultz/pi-calculator/commit/544c540e8d12cbe0ec8c6cde7050628f41700a4e))
9
22
 
10
23
  ### What's Changed
11
- * Use direct Flash for necessary release remediation by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/49
12
24
 
25
+ - Use direct Flash for necessary release remediation by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/49
13
26
 
14
27
  **Full Changelog**: https://github.com/fitchmultz/pi-calculator/compare/v4.1.5...v4.1.6
15
28
 
@@ -22,8 +35,8 @@
22
35
  - Pin reviewed release source binding (#46) ([2a75d8b](https://github.com/fitchmultz/pi-calculator/commit/2a75d8beebdf247c7703d35b8a5944ab7b9cf7cb))
23
36
 
24
37
  ### What's Changed
25
- * Bind automatic releases to the reviewed version by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/46
26
38
 
39
+ - Bind automatic releases to the reviewed version by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/46
27
40
 
28
41
  **Full Changelog**: https://github.com/fitchmultz/pi-calculator/compare/v4.1.4...v4.1.5
29
42
 
@@ -35,8 +48,8 @@
35
48
  - Enable reviewed npm version remediation (#44) ([20c90cf](https://github.com/fitchmultz/pi-calculator/commit/20c90cfb5e28452cfa3f7bf2d7923659b6b18a86))
36
49
 
37
50
  ### What's Changed
38
- * Enable reviewed npm version remediation by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/44
39
51
 
52
+ - Enable reviewed npm version remediation by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/44
40
53
 
41
54
  **Full Changelog**: https://github.com/fitchmultz/pi-calculator/compare/v4.1.3...v4.1.4
42
55
 
@@ -48,8 +61,8 @@
48
61
  - Enable reviewed automatic npm releases (#42) ([f7ce653](https://github.com/fitchmultz/pi-calculator/commit/f7ce6539ac13a509750e4960c4ef5182c5530b40))
49
62
 
50
63
  ### What's Changed
51
- * Enable reviewed automatic npm releases by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/42
52
64
 
65
+ - Enable reviewed automatic npm releases by @fitchmultz in https://github.com/fitchmultz/pi-calculator/pull/42
53
66
 
54
67
  **Full Changelog**: https://github.com/fitchmultz/pi-calculator/compare/v4.1.2...v4.1.3
55
68
 
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  pi-calculator adds a calculator tool to [Pi](https://github.com/earendil-works/pi), with 40 significant digits of decimal precision. Use it to check totals or evaluate scientific expressions.
4
4
 
5
- ![Pi sends an expression to the calculator, which returns a decimal result or rejects an invalid expression.](.github/readme/calculator-flow.png)
5
+ ![Pi sends an expression to the calculator, which returns a decimal result or rejects an invalid expression.](https://raw.githubusercontent.com/fitchmultz/pi-calculator/main/.github/readme/calculator-flow.png)
6
6
 
7
7
  ## Install and try it
8
8
 
@@ -56,7 +56,7 @@ Keep expressions within 4,096 characters. See [limits](docs/reference.md#limits)
56
56
 
57
57
  See [result format](docs/reference.md#result-format) to use the calculator tool from another extension.
58
58
 
59
- Read the [migration notes](https://github.com/fitchmultz/pi-calculator/blob/main/docs/reference.md#migration) before upgrading.
59
+ Read the [migration notes](https://github.com/fitchmultz/pi-calculator/blob/main/docs/reference.md#upgrading-from-v2) before upgrading.
60
60
 
61
61
  ## Verification
62
62
 
package/decimal.ts CHANGED
@@ -14,61 +14,127 @@ export function resetDecimal(): void {
14
14
  Decimal.set(DECIMAL_CONFIG);
15
15
  }
16
16
 
17
+ const GUARD_DECIMAL_CONFIG = {
18
+ defaults: true,
19
+ precision: DECIMAL_PRECISION + 10,
20
+ rounding: DecimalBase.ROUND_HALF_UP,
21
+ } as const;
22
+
23
+ /** Wider working clone; its extra digits keep intermediate results exact. */
24
+ export const GuardDecimal = DecimalBase.clone(GUARD_DECIMAL_CONFIG);
25
+
26
+ /** Restore the guard clone's configuration once an expression run finishes. */
27
+ export function resetGuardDecimal(): void {
28
+ GuardDecimal.set(GUARD_DECIMAL_CONFIG);
29
+ }
30
+
17
31
  const DECIMAL_LITERAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
18
32
 
19
33
  function decimalFromString(value: string): DecimalValue {
20
- if (!DECIMAL_LITERAL.test(value)) throw new Error("invalid decimal literal");
34
+ if (!DECIMAL_LITERAL.test(value)) {
35
+ throw new Error("invalid decimal literal");
36
+ }
21
37
  const decimal = new Decimal(value);
22
- if (!decimal.isFinite()) throw new Error("decimal literal overflow");
38
+ if (!decimal.isFinite()) {
39
+ throw new Error("decimal literal overflow");
40
+ }
23
41
  if (decimal.isZero() && /[1-9]/.test(value.replace(/[eE].*$/, ""))) {
24
42
  throw new Error("decimal literal underflow");
25
43
  }
26
44
  const rounded = decimal.toSignificantDigits(DECIMAL_PRECISION);
27
- if (!rounded.isFinite()) throw new Error("decimal literal overflow");
28
- if (rounded.isZero() && !decimal.isZero()) throw new Error("decimal literal underflow");
45
+ if (!rounded.isFinite()) {
46
+ throw new Error("decimal literal overflow");
47
+ }
48
+ if (rounded.isZero() && !decimal.isZero()) {
49
+ throw new Error("decimal literal underflow");
50
+ }
29
51
  return rounded;
30
52
  }
31
53
 
32
54
  export function toDec(value: unknown): DecimalValue {
33
- if (value instanceof Decimal) return value;
34
- if (typeof value === "string") return decimalFromString(value);
55
+ if (value instanceof Decimal) {
56
+ return value;
57
+ }
58
+ if (typeof value === "string") {
59
+ return decimalFromString(value);
60
+ }
35
61
  if (typeof value === "number") {
36
- if (!Number.isFinite(value)) throw new Error("non-finite number");
62
+ if (!Number.isFinite(value)) {
63
+ throw new Error("non-finite number");
64
+ }
37
65
  return new Decimal(String(value));
38
66
  }
39
67
  throw new Error(`expected number, got ${typeof value}`);
40
68
  }
41
69
 
70
+ /** Span of the quoted literal starting at `start`, or 0 when there is none. */
71
+ function quotedSpan(expression: string, start: number, quote: string): number {
72
+ const end = expression.indexOf(quote, start + 1);
73
+ if (end < 0) {
74
+ throw new Error("unclosed string in expression");
75
+ }
76
+ return end + 1 - start;
77
+ }
78
+
79
+ /** Decimal literal text at `start`, or undefined when those digits belong to a name. */
80
+ function decimalLiteral(expression: string, start: number): string | undefined {
81
+ const previous = expression[start - 1];
82
+ if (previous !== undefined && /[\w.]/.test(previous)) {
83
+ return undefined;
84
+ }
85
+ return expression.slice(start).match(/^(?:\d+\.\d+|\d+\.|\.\d+|\d+)(?:[eE][+-]?\d+)?/)?.[0];
86
+ }
87
+
88
+ /** Reject syntax the calculator refuses rather than normalizes. */
89
+ function rejectUnsupported(expression: string, start: number, ch: string): void {
90
+ if (ch === ";") {
91
+ throw new Error("multiple expressions are not supported");
92
+ }
93
+ if (ch === "/" && expression[start + 1] === "*") {
94
+ throw new Error("comments are not supported");
95
+ }
96
+ }
97
+
98
+ /** Nesting depth after one bracket, rejecting the limit as it is crossed. */
99
+ function trackDepth(depth: number, ch: string): number {
100
+ if (ch === "(" || ch === "[") {
101
+ if (depth + 1 > MAX_EXPRESSION_DEPTH) {
102
+ throw new Error(`expression is too deeply nested (max ${MAX_EXPRESSION_DEPTH})`);
103
+ }
104
+ return depth + 1;
105
+ }
106
+ if ((ch === ")" || ch === "]") && depth > 0) {
107
+ return depth - 1;
108
+ }
109
+ return depth;
110
+ }
111
+
42
112
  /** Preserve numeric literal precision and normalize ** outside quoted strings. */
43
113
  export function decimalizeExpression(expression: string): string {
44
- if (expression.includes("\\")) throw new Error("escape sequences are not supported");
114
+ if (expression.includes("\\")) {
115
+ throw new Error("escape sequences are not supported");
116
+ }
45
117
 
46
118
  let result = "";
47
119
  let i = 0;
48
120
  let nestingDepth = 0;
49
121
 
50
122
  while (i < expression.length) {
51
- const ch = expression[i]!;
123
+ const ch = expression[i];
124
+ if (ch === undefined) {
125
+ break; // the loop bound keeps the index inside the string
126
+ }
52
127
 
53
128
  if (ch === '"' || ch === "'") {
54
- const end = expression.indexOf(ch, i + 1);
55
- if (end < 0) throw new Error("unclosed string in expression");
56
- result += expression.slice(i, end + 1);
57
- i = end + 1;
129
+ const span = quotedSpan(expression, i, ch);
130
+ result += expression.slice(i, i + span);
131
+ i += span;
58
132
  continue;
59
133
  }
60
134
 
61
- if (ch === "/" && expression[i + 1] === "*") throw new Error("comments are not supported");
62
- if (ch === ";") throw new Error("multiple expressions are not supported");
135
+ rejectUnsupported(expression, i, ch);
63
136
 
64
- if (ch === "(" || ch === "[") {
65
- nestingDepth++;
66
- if (nestingDepth > MAX_EXPRESSION_DEPTH) {
67
- throw new Error(`expression is too deeply nested (max ${MAX_EXPRESSION_DEPTH})`);
68
- }
69
- } else if ((ch === ")" || ch === "]") && nestingDepth > 0) {
70
- nestingDepth--;
71
- }
137
+ nestingDepth = trackDepth(nestingDepth, ch);
72
138
 
73
139
  if (ch === "*" && expression[i + 1] === "*") {
74
140
  result += "^";
@@ -76,10 +142,10 @@ export function decimalizeExpression(expression: string): string {
76
142
  continue;
77
143
  }
78
144
 
79
- const match = expression.slice(i).match(/^(?:\d+\.\d+|\d+\.|\.\d+|\d+)(?:[eE][+-]?\d+)?/);
80
- if (match && (i === 0 || !/[\w.]/.test(expression[i - 1]!))) {
81
- result += `d("${match[0]}")`;
82
- i += match[0].length;
145
+ const literal = decimalLiteral(expression, i);
146
+ if (literal !== undefined) {
147
+ result += `d("${literal}")`;
148
+ i += literal.length;
83
149
  continue;
84
150
  }
85
151
 
@@ -0,0 +1,32 @@
1
+ # Development
2
+
3
+ [Back to the README](../README.md)
4
+
5
+ ## Local verification
6
+
7
+ Requires Node.js 24 or later. From a checkout:
8
+
9
+ ```sh
10
+ npm ci --ignore-scripts
11
+ npm run verify
12
+ ```
13
+
14
+ `verify` runs the quality gates first — both lint passes, the format check, both compiler projects, the baseline drift check and the policy verifier — then the committed harness, the numerical and work-limit checks, and an actual Pi extension-load check. It covers schema and structured result contracts, native nested validation/policy/evaluation errors and native tool-card rendering.
15
+
16
+ The individual gates run on their own: `npm run lint` (the semantic pass, then compiler diagnostics over the files a compiler project owns), `npm run lint:agent`, `npm run lint:fix`, `npm run format`, `npm run format:check`, `npm test`, `npm run lint:baseline`, and `npm run policy`. The configuration, the baseline it is checked against, and the harness probes are described in [AGENTS.md](../AGENTS.md).
17
+
18
+ The type-aware lint pass and `tsc` need a host with native builds for `oxlint-tsgolint` and TypeScript. On a host without them (Android on ARM64, for example) the compiler-dependent probes report an explicit skip, so the acceptance gate has to be run on a qualified host or in CI.
19
+
20
+ The locked development cohort uses Pi 1.4.0 and TypeBox 1.3.27. Pi supplies the schema and terminal UI libraries; `decimal.js` and `expr-eval-fork` are runtime dependencies. No evaluator or grammar compatibility layer is needed.
21
+
22
+ Update the development TypeBox pin alongside the qualified Pi host, using the version that host ships. Independent TypeBox updates are disabled in Renovate because they install duplicate host schema libraries and slow development startup. Each compatibility lane selects its host's TypeBox version.
23
+
24
+ ## Compatibility qualification
25
+
26
+ The locked development cohort is reproducible local tooling. Release qualification selects the latest stable official Pi and the [fitchmultz/pi fork](https://github.com/fitchmultz/pi) main once per run, freezing the official version and fork commit throughout the checks.
27
+
28
+ Each host is qualified separately; a matching version string alone does not establish compatibility. CI runs the full verification against both frozen hosts, including fresh Git and packed npm consumers. See the [compatibility workflow](../.github/workflows/pi-compatibility.yml).
29
+
30
+ ## Release process
31
+
32
+ When `NPM_RELEASE_ENABLED` is `true`, each main merge enters the automatic version queue. Its version PR must pass current-head compatibility and reviews before the main-only pipeline publishes matching npm and GitHub releases. The release caller reuses the same frozen host inputs for qualification and publishing. See the [version workflow](../.github/workflows/npm-version.yml), [release workflow](../.github/workflows/npm-release.yml), and [changelog](../CHANGELOG.md).
@@ -0,0 +1,83 @@
1
+ # Calculator reference
2
+
3
+ [Back to the README](../README.md)
4
+
5
+ ## Installation options
6
+
7
+ Requires Node.js 24 or later and Pi 1.0.0 or later. The published npm package is `@fitchmultz/pi-calculator`.
8
+
9
+ ```sh
10
+ pi install npm:@fitchmultz/pi-calculator
11
+ ```
12
+
13
+ Git installation is also supported:
14
+
15
+ ```sh
16
+ pi install git:github.com/fitchmultz/pi-calculator
17
+ ```
18
+
19
+ When switching from Git to npm, run `pi list`, remove the exact Git source it shows with `pi remove <source>`, then install the scoped npm package. This avoids loading the calculator twice. Existing Git installs can stay on Git.
20
+
21
+ Start a new Pi session with `pi` after installation. The package adds one `calculator` tool with one required `expression` string. Pi supplies the schema and terminal UI libraries; evaluation uses `decimal.js` and `expr-eval-fork`.
22
+
23
+ Pi extensions execute with full system access. Review the source before installing.
24
+
25
+ ## Expressions
26
+
27
+ Operators are `+`, `-`, `*`, `/`, `%`, `^` and `**`; constants are `PI` and `E`. Arrays support zero-based indexing: `[1,2,3][2]` returns `3`. Combine independent results in one flat array:
28
+
29
+ ```text
30
+ [0.1 + 0.2, 2^64]
31
+ [stdev([2,4,4,4,5,5,7,9]), stdevs([2,4,4,4,5,5,7,9])]
32
+ ```
33
+
34
+ ### Functions
35
+
36
+ - **Trigonometry:** `sin`, `cos`, `tan`, `asin`, `acos`, `atan`, `atan2`, `sinh`, `cosh`, `tanh`, `asinh`, `acosh` and `atanh`. Trig uses radians. `radians(degrees)` and `degrees(radians)` convert angles.
37
+ - **Roots, powers and logarithms:** `sqrt`, `cbrt`, `abs`, `pow`, `exp`, `expm1`, `ln`/`log`, `log1p`, `log2`, `log10`/`lg`. Both `ln` and `log` are natural logarithms. For small inputs, use `expm1(x)` for `exp(x)-1` and `log1p(x)` for `ln(1+x)` to preserve precision.
38
+ - **Rounding:** `ceil`, `floor`, `round`, `roundTo`, `trunc` and `sign`. `roundTo(value, places)` rounds halves away from zero; negative places round to tens, hundreds and so on.
39
+ - **Percentages:** `percent(rate, amount)` gives `rate%` of `amount`: `percent(15, 200)` returns `30`.
40
+ - **Statistics:** `sum`, `mean`, `median`, `stdev` and `stdevs` take numeric arrays. `stdev` is population standard deviation; `stdevs` is sample standard deviation and requires at least two elements.
41
+ - **Extremes and distance:** `min`, `max`, `hypot`/`pyt` take either an array or individual arguments.
42
+ - **Factorials:** `n!` and `fac(n)` compute factorials for integers from 0 through 1000.
43
+
44
+ Aggregates require finite numeric elements. Sums preserve cancellation remainders before rounding; means and even medians round after division.
45
+
46
+ Input literals and arithmetic are rounded to 40 significant digits using half-up rounding. Results are rounded decimal strings. For example, `exp(ln(1000))` can differ slightly from `1000` because intermediate calculations round.
47
+
48
+ ## Result format
49
+
50
+ The tool returns a finite scalar or a flat array of finite scalars, represented as decimal strings. A nested array, nonnumeric result or nonfinite member fails the whole expression. Large finite values use scientific notation.
51
+
52
+ Model-facing content contains only the value, such as `0.3`, or a JSON array of strings, such as `["0.3","18446744073709551616"]`.
53
+
54
+ Both `details` and native `structuredContent` contain the same object:
55
+
56
+ ```json
57
+ {
58
+ "expression": "[0.1 + 0.2, 2^64]",
59
+ "value": ["0.3", "18446744073709551616"]
60
+ }
61
+ ```
62
+
63
+ `expression` is trimmed. `value` is a string or string array. The tool declares an `outputSchema`, so nested and codemode callers can receive these values without reparsing display text.
64
+
65
+ Evaluation failures throw and become native error results. Schema and policy checks remain Pi-owned. The tool requests native strict JSON-schema sampling where the provider supports it and uses Pi's normal fallback elsewhere. Pi's tool card displays the safely escaped expression above the result.
66
+
67
+ ## Limits
68
+
69
+ - Expressions: at most 4,096 characters and 128 nesting levels.
70
+ - Output: serialized expression/value details must fit within 50 KiB. Oversized results are rejected rather than truncated.
71
+ - Factorials: operands from 0 through 1000, with a shared 1,000-step factorial budget per expression. Each factorial costs at least one step.
72
+ - Circular trig: `sin`, `cos` and `tan` reject absolute inputs above `1e100`.
73
+ - Hyperbolic functions: reject absolute inputs above 10,000.
74
+ - Modulo: `%` rejects operand exponent gaps above 10,000.
75
+ - Expensive operations: share a 10,000-unit work budget. Circular trig and `atan2` calls cost 100 units each, allowing at most 100 per expression when no other operation uses that budget.
76
+
77
+ <a id="migration"></a>
78
+
79
+ ## Upgrading from v2
80
+
81
+ Version 3 removed the old angle-conversion names: replace `deg(x)` with `radians(x)` and `rad(x)` with `degrees(x)`. There are no compatibility aliases.
82
+
83
+ Tool content no longer includes `expression =`. Consumers should read the expression from details and handle both string and string-array values.
package/eval.ts CHANGED
@@ -1,5 +1,15 @@
1
1
  import { Parser } from "expr-eval-fork";
2
- import { DECIMAL_PRECISION, Decimal, decimalizeExpression, resetDecimal, toDec, type DecimalValue } from "./decimal.ts";
2
+ import {
3
+ DECIMAL_PRECISION,
4
+ Decimal,
5
+ decimalizeExpression,
6
+ GuardDecimal,
7
+ resetDecimal,
8
+ resetGuardDecimal,
9
+ toDec,
10
+ type DecimalValue,
11
+ } from "./decimal.ts";
12
+ import { exactSum, mean, standardDeviation } from "./statistics.ts";
3
13
 
4
14
  export const MAX_EXPRESSION_LENGTH = 4096;
5
15
  const MAX_FACTORIAL_OPERAND = 1000;
@@ -14,16 +24,10 @@ const E = new Decimal("2.718281828459045235360287471352662497757");
14
24
  const MAX_TRIG_ABS = new Decimal("1e100");
15
25
  const SMALL_ATAN_ABS = new Decimal("1e-100");
16
26
  const LARGE_ATAN_ABS = new Decimal("1e100");
17
- const GUARD_DECIMAL_CONFIG = {
18
- defaults: true,
19
- precision: DECIMAL_PRECISION + 10,
20
- rounding: Decimal.ROUND_HALF_UP,
21
- } as const;
22
- const GuardDecimal = Decimal.clone(GUARD_DECIMAL_CONFIG);
23
27
 
24
28
  let factorialWorkLeft = 0;
25
29
  let expensiveWorkLeft = 0;
26
- const EMPTY_VARIABLES: Record<string, never> = Object.create(null);
30
+ const EMPTY_VARIABLES: Record<string, never> = nullMap({});
27
31
 
28
32
  const parser = new Parser({
29
33
  allowMemberAccess: false,
@@ -38,11 +42,22 @@ const parser = new Parser({
38
42
  },
39
43
  });
40
44
 
41
- function nullMap<T extends object>(values: T): T {
42
- return Object.assign(Object.create(null), values);
45
+ function nullMap<T extends object>(values: Readonly<T>): T {
46
+ const target = { ...values };
47
+ Object.setPrototypeOf(target, null);
48
+ return target;
43
49
  }
44
50
 
45
- function requireArity(name: string, args: unknown[], count: number): void {
51
+ /** Read an argument position the arity check has already guaranteed. */
52
+ function argumentAt<T>(values: readonly T[], index: number): T {
53
+ const value = values[index];
54
+ if (value === undefined) {
55
+ throw new Error(`missing argument at index ${index}`);
56
+ }
57
+ return value;
58
+ }
59
+
60
+ function requireArity(name: string, args: readonly unknown[], count: number): void {
46
61
  if (args.length !== count) {
47
62
  throw new Error(`${name}() needs exactly ${count} argument${count === 1 ? "" : "s"}`);
48
63
  }
@@ -50,16 +65,24 @@ function requireArity(name: string, args: unknown[], count: number): void {
50
65
 
51
66
  function factorial(value: unknown) {
52
67
  const n = toDec(value);
53
- if (!n.isInteger() || n.lt(0)) throw new Error("factorial needs a non-negative integer");
54
- if (n.gt(MAX_FACTORIAL_OPERAND)) throw new Error(`factorial operand too large (max ${MAX_FACTORIAL_OPERAND})`);
68
+ if (!n.isInteger() || n.lt(0)) {
69
+ throw new Error("factorial needs a non-negative integer");
70
+ }
71
+ if (n.gt(MAX_FACTORIAL_OPERAND)) {
72
+ throw new Error(`factorial operand too large (max ${MAX_FACTORIAL_OPERAND})`);
73
+ }
55
74
 
56
75
  const count = n.toNumber();
57
76
  const cost = Math.max(count, 1);
58
- if (cost > factorialWorkLeft) throw new Error("factorial work budget exceeded");
77
+ if (cost > factorialWorkLeft) {
78
+ throw new Error("factorial work budget exceeded");
79
+ }
59
80
  factorialWorkLeft -= cost;
60
81
 
61
82
  let result = 1n;
62
- for (let i = 2; i <= count; i++) result *= BigInt(i);
83
+ for (let i = 2; i <= count; i++) {
84
+ result *= BigInt(i);
85
+ }
63
86
  return new Decimal(result.toString()).toSignificantDigits(DECIMAL_PRECISION);
64
87
  }
65
88
 
@@ -71,7 +94,9 @@ function decimals(values: unknown, name: string, minLength = 1): DecimalValue[]
71
94
  return values.map((value, index) => {
72
95
  try {
73
96
  const number = toDec(value);
74
- if (!number.isFinite()) throw new Error("expected a finite number");
97
+ if (!number.isFinite()) {
98
+ throw new Error("expected a finite number");
99
+ }
75
100
  return number;
76
101
  } catch {
77
102
  throw new Error(`${name}(): invalid number at index ${index}`);
@@ -79,78 +104,12 @@ function decimals(values: unknown, name: string, minLength = 1): DecimalValue[]
79
104
  });
80
105
  }
81
106
 
82
- function decimalArguments(args: unknown[], name: string): DecimalValue[] {
107
+ function decimalArguments(args: readonly unknown[], name: string): DecimalValue[] {
83
108
  const values = args.length === 1 && Array.isArray(args[0]) ? args[0] : args;
84
- if (values.length === 0) throw new Error(`${name}() needs at least one number`);
85
- return decimals(values, name);
86
- }
87
-
88
- function sum(xs: DecimalValue[], Ctor = Decimal, divisor = 1): DecimalValue {
89
- if (xs.some((x) => !x.isFinite())) return Ctor.sum(...xs).div(divisor);
90
-
91
- // Decimal.sum also truncates across large exponent gaps. Signed BigInt chunks
92
- // keep the exact sum sparse, including intermediate values outside Decimal's range.
93
- const width = BigInt(Ctor.precision);
94
- const base = 10n ** width;
95
- const bins = new Map<bigint, bigint>();
96
- const add = (key: bigint, value: bigint) => bins.set(key, (bins.get(key) ?? 0n) + value);
97
- for (const x of xs) {
98
- if (x.isZero()) continue;
99
- const digits = x.toExponential().split("e")[0]!.replace(".", "");
100
- const exponent = BigInt(x.e) - BigInt(x.sd() - 1);
101
- const key = exponent >= 0n ? exponent / width : (exponent - width + 1n) / width;
102
- add(key, BigInt(digits) * 10n ** (exponent - key * width));
103
- }
104
- for (let key of [...bins.keys()].sort((a, b) => Number(a - b))) {
105
- for (;;) {
106
- const value = bins.get(key)!;
107
- const carry = value / base;
108
- bins.set(key, value % base);
109
- if (!carry) break;
110
- add(++key, carry);
111
- }
109
+ if (values.length === 0) {
110
+ throw new Error(`${name}() needs at least one number`);
112
111
  }
113
-
114
- const keys = [...bins.keys()].filter((key) => bins.get(key) !== 0n).sort((a, b) => Number(b - a));
115
- if (keys.length === 0) return new Ctor(0);
116
- let key = keys[0]!;
117
- let coefficient = bins.get(key)!;
118
- let i = 1;
119
- const digitCount = (n: bigint) => n.toString().replace("-", "").length;
120
- const retained = Ctor.precision + Math.ceil(Math.log10(divisor)) + 2;
121
- for (; i < keys.length; i++) {
122
- const next = keys[i]!;
123
- if (digitCount(coefficient) >= retained || key - next > 2n) break;
124
- coefficient = coefficient * base ** (key - next) + bins.get(next)!;
125
- key = next;
126
- }
127
-
128
- // Normalized chunks have magnitude < base, so the leading remaining chunk
129
- // determines the tail's sign. A sticky digit preserves which side of a tie it lies on.
130
- let shift = 0n;
131
- if (i < keys.length) {
132
- shift = BigInt(Math.max(2, retained - digitCount(coefficient)));
133
- coefficient = coefficient * 10n ** shift + (bins.get(keys[i]!)! > 0n ? 1n : -1n);
134
- }
135
- // Divide and round before restoring the exponent to avoid intermediate overflow/underflow.
136
- const [mantissa, exponent] = new Ctor(coefficient.toString()).div(divisor).toExponential().split("e");
137
- return new Ctor(`${mantissa}e${BigInt(exponent!) + key * width - shift}`);
138
- }
139
-
140
- function mean(xs: DecimalValue[]): DecimalValue {
141
- return sum(xs, Decimal, xs.length);
142
- }
143
-
144
- function standardDeviation(xs: DecimalValue[], sample: boolean): DecimalValue {
145
- // Scale before subtraction; a power of ten preserves small spreads around a large offset.
146
- const scale = new GuardDecimal(`1e${GuardDecimal.max(...xs.map((x) => x.abs())).e}`);
147
- const scaled = xs.map((x) => new GuardDecimal(x).div(scale));
148
- // Center on an input first so a large common offset cannot round away the spread.
149
- const shifted = scaled.map((x) => x.minus(scaled[0]!));
150
- const center = sum(shifted, GuardDecimal, xs.length);
151
- const squared = shifted.map((x) => x.minus(center).pow(2));
152
- return new Decimal(sum(squared, GuardDecimal, sample ? xs.length - 1 : xs.length)
153
- .sqrt().times(scale).toSignificantDigits(DECIMAL_PRECISION));
112
+ return decimals(values, name);
154
113
  }
155
114
 
156
115
  function tangent(value: DecimalValue): DecimalValue {
@@ -162,30 +121,47 @@ function tangent(value: DecimalValue): DecimalValue {
162
121
  function arctangent(value: DecimalValue): DecimalValue {
163
122
  // Decimal.atan can loop when argument reduction overflows or underflows.
164
123
  const abs = value.abs();
165
- if (abs.lt(SMALL_ATAN_ABS)) return value;
166
- if (abs.gt(LARGE_ATAN_ABS)) return PI.div(value.isNegative() ? -2 : 2);
124
+ if (abs.lt(SMALL_ATAN_ABS)) {
125
+ return value;
126
+ }
127
+ if (abs.gt(LARGE_ATAN_ABS)) {
128
+ return PI.div(value.isNegative() ? -2 : 2);
129
+ }
167
130
  return Decimal.atan(value);
168
131
  }
169
132
 
170
133
  function arctangent2(y: DecimalValue, x: DecimalValue): DecimalValue {
171
- if (x.isZero() || y.isZero() || !x.isFinite() || !y.isFinite()) return Decimal.atan2(y, x);
134
+ if (x.isZero() || y.isZero() || !x.isFinite() || !y.isFinite()) {
135
+ return Decimal.atan2(y, x);
136
+ }
172
137
  const ratio = y.div(x);
173
- if (ratio.abs().lt(SMALL_ATAN_ABS)) return x.isNegative() ? (y.isNegative() ? PI.negated() : PI) : ratio;
174
- if (ratio.abs().gt(LARGE_ATAN_ABS)) return PI.div(y.isNegative() ? -2 : 2);
138
+ if (ratio.abs().lt(SMALL_ATAN_ABS)) {
139
+ if (x.isNegative()) {
140
+ return y.isNegative() ? PI.negated() : PI;
141
+ }
142
+ return ratio;
143
+ }
144
+ if (ratio.abs().gt(LARGE_ATAN_ABS)) {
145
+ return PI.div(y.isNegative() ? -2 : 2);
146
+ }
175
147
  return Decimal.atan2(y, x);
176
148
  }
177
149
 
178
150
  function expm1(value: DecimalValue): DecimalValue {
179
151
  const x = new GuardDecimal(value.toString());
180
152
  if (x.abs().gte(0.1)) {
181
- return new Decimal(GuardDecimal.exp(x).minus(1).toSignificantDigits(DECIMAL_PRECISION).toString());
153
+ return new Decimal(
154
+ GuardDecimal.exp(x).minus(1).toSignificantDigits(DECIMAL_PRECISION).toString(),
155
+ );
182
156
  }
183
157
  let term = x;
184
158
  let total = x;
185
159
  for (let n = 2; n <= DECIMAL_PRECISION + 10; n++) {
186
160
  term = term.times(x).div(n);
187
161
  const next = total.plus(term);
188
- if (next.eq(total)) break;
162
+ if (next.eq(total)) {
163
+ break;
164
+ }
189
165
  total = next;
190
166
  }
191
167
  return new Decimal(total.toSignificantDigits(DECIMAL_PRECISION).toString());
@@ -194,21 +170,27 @@ function expm1(value: DecimalValue): DecimalValue {
194
170
  function log1p(value: DecimalValue): DecimalValue {
195
171
  const x = new GuardDecimal(value.toString());
196
172
  if (x.abs().gte(0.1)) {
197
- return new Decimal(GuardDecimal.ln(x.plus(1)).toSignificantDigits(DECIMAL_PRECISION).toString());
173
+ return new Decimal(
174
+ GuardDecimal.ln(x.plus(1)).toSignificantDigits(DECIMAL_PRECISION).toString(),
175
+ );
198
176
  }
199
177
  let power = x;
200
178
  let total = x;
201
179
  for (let n = 2; n <= DECIMAL_PRECISION + 10; n++) {
202
180
  power = power.times(x);
203
181
  const next = n % 2 === 0 ? total.minus(power.div(n)) : total.plus(power.div(n));
204
- if (next.eq(total)) break;
182
+ if (next.eq(total)) {
183
+ break;
184
+ }
205
185
  total = next;
206
186
  }
207
187
  return new Decimal(total.toSignificantDigits(DECIMAL_PRECISION).toString());
208
188
  }
209
189
 
210
190
  function spendExpensiveWork(cost: number): void {
211
- if (cost > expensiveWorkLeft) throw new Error("expression work budget exceeded");
191
+ if (cost > expensiveWorkLeft) {
192
+ throw new Error("expression work budget exceeded");
193
+ }
212
194
  expensiveWorkLeft -= cost;
213
195
  }
214
196
 
@@ -218,18 +200,25 @@ function trigonometric(
218
200
  maxAbs?: DecimalValue,
219
201
  ): (x: DecimalValue) => DecimalValue {
220
202
  return (x) => {
221
- if (maxAbs && x.abs().gt(maxAbs)) {
222
- throw new Error(`${name}() argument too large (max absolute value ${maxAbs.toString()})`);
203
+ if (maxAbs !== undefined && x.abs().gt(maxAbs)) {
204
+ throw new Error(
205
+ `${name}() argument too large (max absolute value ${maxAbs.toString()})`,
206
+ );
223
207
  }
224
208
  spendExpensiveWork(TRIG_WORK);
225
209
  return fn(x);
226
210
  };
227
211
  }
228
212
 
229
- function hyperbolic(name: string, fn: (x: DecimalValue) => DecimalValue): (x: DecimalValue) => DecimalValue {
213
+ function hyperbolic(
214
+ name: string,
215
+ fn: (x: DecimalValue) => DecimalValue,
216
+ ): (x: DecimalValue) => DecimalValue {
230
217
  return (x) => {
231
218
  if (x.abs().gt(MAX_HYPERBOLIC_ABS)) {
232
- throw new Error(`${name}() argument too large (max absolute value ${MAX_HYPERBOLIC_ABS})`);
219
+ throw new Error(
220
+ `${name}() argument too large (max absolute value ${MAX_HYPERBOLIC_ABS})`,
221
+ );
233
222
  }
234
223
  spendExpensiveWork(x.isFinite() ? Math.max(1, Math.ceil(x.abs().toNumber())) : 1);
235
224
  return fn(x);
@@ -239,7 +228,9 @@ function hyperbolic(name: string, fn: (x: DecimalValue) => DecimalValue): (x: De
239
228
  function modulo(a: unknown, b: unknown) {
240
229
  const left = toDec(a);
241
230
  const right = toDec(b);
242
- if (right.isZero()) throw new Error("modulo by zero");
231
+ if (right.isZero()) {
232
+ throw new Error("modulo by zero");
233
+ }
243
234
  const exponentGap = left.isFinite() && right.isFinite() ? Math.abs(left.e - right.e) : 0;
244
235
  if (exponentGap > MAX_MODULO_EXPONENT_GAP) {
245
236
  throw new Error(`modulo exponent gap too large (max ${MAX_MODULO_EXPONENT_GAP})`);
@@ -248,29 +239,39 @@ function modulo(a: unknown, b: unknown) {
248
239
  return left.mod(right);
249
240
  }
250
241
 
251
- function roundTo(...args: unknown[]) {
242
+ function roundTo(...args: readonly unknown[]) {
252
243
  requireArity("roundTo", args, 2);
253
244
  const digits = toDec(args[1]);
254
245
  if (!digits.isInteger() || digits.abs().gt(MAX_ROUND_DIGITS)) {
255
- throw new Error(`roundTo() digits must be an integer from -${MAX_ROUND_DIGITS} to ${MAX_ROUND_DIGITS}`);
246
+ throw new Error(
247
+ `roundTo() digits must be an integer from -${MAX_ROUND_DIGITS} to ${MAX_ROUND_DIGITS}`,
248
+ );
256
249
  }
257
250
  const places = digits.toNumber();
258
251
  const value = toDec(args[0]);
259
- if (places >= 0) return value.toDecimalPlaces(places);
252
+ if (places >= 0) {
253
+ return value.toDecimalPlaces(places);
254
+ }
260
255
  const scale = new Decimal(10).pow(-places);
261
256
  return value.div(scale).toDecimalPlaces(0).times(scale);
262
257
  }
263
258
 
264
259
  function arrayIndex(values: unknown, index: unknown): unknown {
265
- if (!Array.isArray(values)) throw new Error("indexing needs an array");
260
+ if (!Array.isArray(values)) {
261
+ throw new Error("indexing needs an array");
262
+ }
266
263
  let n: DecimalValue;
267
264
  try {
268
265
  n = toDec(index);
269
266
  } catch {
270
267
  throw new Error("array index needs an integer");
271
268
  }
272
- if (!n.isInteger()) throw new Error("array index needs an integer");
273
- if (n.lt(0) || n.gte(values.length)) throw new Error("array index out of range");
269
+ if (!n.isInteger()) {
270
+ throw new Error("array index needs an integer");
271
+ }
272
+ if (n.lt(0) || n.gte(values.length)) {
273
+ throw new Error("array index out of range");
274
+ }
274
275
  return values[n.toNumber()];
275
276
  }
276
277
 
@@ -306,7 +307,12 @@ const decimalUnary: Record<string, (x: DecimalValue) => DecimalValue> = {
306
307
  };
307
308
 
308
309
  parser.unaryOps = nullMap({
309
- ...Object.fromEntries(Object.entries(decimalUnary).map(([name, fn]) => [name, (value: unknown) => fn(toDec(value))])),
310
+ ...Object.fromEntries(
311
+ Object.entries(decimalUnary).map(([name, fn]) => [
312
+ name,
313
+ (value: unknown) => fn(toDec(value)),
314
+ ]),
315
+ ),
310
316
  "+": toDec,
311
317
  "-": (value: unknown) => toDec(value).negated(),
312
318
  "!": factorial,
@@ -319,7 +325,9 @@ parser.binaryOps = nullMap({
319
325
  "/": (a, b) => {
320
326
  const numerator = toDec(a);
321
327
  const denominator = toDec(b);
322
- if (denominator.isZero()) throw new Error("division by zero");
328
+ if (denominator.isZero()) {
329
+ throw new Error("division by zero");
330
+ }
323
331
  return numerator.div(denominator);
324
332
  },
325
333
  "%": modulo,
@@ -328,38 +336,40 @@ parser.binaryOps = nullMap({
328
336
  });
329
337
  parser.ternaryOps = nullMap({});
330
338
 
331
- const hypot = (...args: unknown[]) => Decimal.hypot(...decimalArguments(args, "hypot"));
339
+ const hypot = (...args: readonly unknown[]) => Decimal.hypot(...decimalArguments(args, "hypot"));
332
340
 
333
341
  parser.functions = nullMap({
334
- d: (...args: unknown[]) => {
342
+ d: (...args: readonly unknown[]) => {
335
343
  requireArity("d", args, 1);
336
- if (typeof args[0] !== "string") throw new Error("invalid decimal literal");
344
+ if (typeof args[0] !== "string") {
345
+ throw new Error("invalid decimal literal");
346
+ }
337
347
  return toDec(args[0]);
338
348
  },
339
- fac: (...args: unknown[]) => {
349
+ fac: (...args: readonly unknown[]) => {
340
350
  requireArity("fac", args, 1);
341
351
  return factorial(args[0]);
342
352
  },
343
- pow: (...args: unknown[]) => {
353
+ pow: (...args: readonly unknown[]) => {
344
354
  requireArity("pow", args, 2);
345
355
  return toDec(args[0]).pow(toDec(args[1]));
346
356
  },
347
- atan2: (...args: unknown[]) => {
357
+ atan2: (...args: readonly unknown[]) => {
348
358
  requireArity("atan2", args, 2);
349
359
  const values = args.map(toDec);
350
360
  spendExpensiveWork(TRIG_WORK);
351
- return arctangent2(values[0]!, values[1]!);
361
+ return arctangent2(argumentAt(values, 0), argumentAt(values, 1));
352
362
  },
353
- min: (...args: unknown[]) => Decimal.min(...decimalArguments(args, "min")),
354
- max: (...args: unknown[]) => Decimal.max(...decimalArguments(args, "max")),
355
- sum: (...args: unknown[]) => {
363
+ min: (...args: readonly unknown[]) => Decimal.min(...decimalArguments(args, "min")),
364
+ max: (...args: readonly unknown[]) => Decimal.max(...decimalArguments(args, "max")),
365
+ sum: (...args: readonly unknown[]) => {
356
366
  requireArity("sum", args, 1);
357
- return sum(decimals(args[0], "sum"));
367
+ return exactSum(decimals(args[0], "sum"));
358
368
  },
359
369
  hypot,
360
370
  pyt: hypot,
361
371
  roundTo,
362
- percent: (...args: unknown[]) => {
372
+ percent: (...args: readonly unknown[]) => {
363
373
  requireArity("percent", args, 2);
364
374
  const rate = toDec(args[0]);
365
375
  const amount = toDec(args[1]);
@@ -368,29 +378,32 @@ parser.functions = nullMap({
368
378
  ? amount.div(100).times(rate)
369
379
  : rate.div(100).times(amount);
370
380
  },
371
- radians: (...args: unknown[]) => {
381
+ radians: (...args: readonly unknown[]) => {
372
382
  requireArity("radians", args, 1);
373
383
  return toDec(args[0]).times(PI).div(180);
374
384
  },
375
- degrees: (...args: unknown[]) => {
385
+ degrees: (...args: readonly unknown[]) => {
376
386
  requireArity("degrees", args, 1);
377
387
  return toDec(args[0]).times(180).div(PI);
378
388
  },
379
- mean: (...args: unknown[]) => {
389
+ mean: (...args: readonly unknown[]) => {
380
390
  requireArity("mean", args, 1);
381
391
  return mean(decimals(args[0], "mean"));
382
392
  },
383
- median: (...args: unknown[]) => {
393
+ median: (...args: readonly unknown[]) => {
384
394
  requireArity("median", args, 1);
385
395
  const xs = decimals(args[0], "median").sort((a, b) => a.comparedTo(b));
386
396
  const mid = Math.floor(xs.length / 2);
387
- return xs.length % 2 === 0 ? mean([xs[mid - 1]!, xs[mid]!]) : xs[mid]!;
397
+ if (xs.length % 2 !== 0) {
398
+ return argumentAt(xs, mid);
399
+ }
400
+ return mean([argumentAt(xs, mid - 1), argumentAt(xs, mid)]);
388
401
  },
389
- stdev: (...args: unknown[]) => {
402
+ stdev: (...args: readonly unknown[]) => {
390
403
  requireArity("stdev", args, 1);
391
404
  return standardDeviation(decimals(args[0], "stdev"), false);
392
405
  },
393
- stdevs: (...args: unknown[]) => {
406
+ stdevs: (...args: readonly unknown[]) => {
394
407
  requireArity("stdevs", args, 1);
395
408
  return standardDeviation(decimals(args[0], "stdevs", 2), true);
396
409
  },
@@ -400,27 +413,34 @@ parser.consts = nullMap({ PI, E });
400
413
 
401
414
  function normalizeExpression(expression: string): string {
402
415
  const trimmed = expression.trim();
403
- if (!trimmed) throw new Error("Expression is empty");
416
+ if (trimmed === "") {
417
+ throw new Error("Expression is empty");
418
+ }
404
419
  if (trimmed.length > MAX_EXPRESSION_LENGTH) {
405
420
  throw new Error(`Expression too long (max ${MAX_EXPRESSION_LENGTH} chars)`);
406
421
  }
407
422
  return trimmed;
408
423
  }
409
424
 
410
- function resultString(result: unknown): string {
411
- if (typeof result === "number") result = new Decimal(String(result));
425
+ function resultString(value: unknown): string {
426
+ const result = typeof value === "number" ? new Decimal(String(value)) : value;
412
427
  if (!(result instanceof Decimal)) {
413
428
  const type = Array.isArray(result) ? "array" : typeof result;
414
429
  throw new Error(`Expression did not evaluate to a number (got ${type})`);
415
430
  }
416
431
  if (!result.isFinite()) {
417
- if (result.isNaN()) throw new Error("Result is NaN");
432
+ if (result.isNaN()) {
433
+ throw new Error("Result is NaN");
434
+ }
418
435
  throw new Error(result.isPositive() ? "Result is Infinity" : "Result is -Infinity");
419
436
  }
420
437
  return result.toString();
421
438
  }
422
439
 
423
- export function evaluateExpression(expression: string): { expression: string; value: string | string[] } {
440
+ export function evaluateExpression(expression: string): {
441
+ expression: string;
442
+ value: string | string[];
443
+ } {
424
444
  const normalized = normalizeExpression(expression);
425
445
  let result: unknown;
426
446
  factorialWorkLeft = MAX_FACTORIAL_WORK;
@@ -433,14 +453,18 @@ export function evaluateExpression(expression: string): { expression: string; va
433
453
  .replace(/^\[DecimalError\]\s*/, "")
434
454
  .replace(/^parse error \[\d+:\d+\]:\s*/, "")
435
455
  .replace(/\bT[A-Z]+:\s*/g, "");
436
- if (/precision limit exceeded/i.test(message)) message = "numeric argument exceeds precision limit";
437
- if (error instanceof RangeError && /call stack/i.test(message)) message = "expression is too deeply nested";
438
- throw new Error(`Invalid expression: ${message}`);
456
+ if (/precision limit exceeded/i.test(message)) {
457
+ message = "numeric argument exceeds precision limit";
458
+ }
459
+ if (error instanceof RangeError && /call stack/i.test(message)) {
460
+ message = "expression is too deeply nested";
461
+ }
462
+ throw new Error(`Invalid expression: ${message}`, { cause: error });
439
463
  } finally {
440
464
  factorialWorkLeft = 0;
441
465
  expensiveWorkLeft = 0;
442
466
  resetDecimal();
443
- GuardDecimal.set(GUARD_DECIMAL_CONFIG);
467
+ resetGuardDecimal();
444
468
  }
445
469
 
446
470
  const value = Array.isArray(result) ? result.map(resultString) : resultString(result);
@@ -1,8 +1,14 @@
1
- import "expr-eval-fork";
1
+ // Re-exporting keeps this file a module, so the declaration augments the package's typings.
2
+ import type { Parser } from "expr-eval-fork";
3
+
4
+ export type { Parser };
2
5
 
3
6
  declare module "expr-eval-fork" {
4
7
  interface Parser {
5
8
  binaryOps: Record<string, (left: unknown, right: unknown) => unknown>;
6
- ternaryOps: Record<string, (condition: unknown, consequent: unknown, alternate: unknown) => unknown>;
9
+ ternaryOps: Record<
10
+ string,
11
+ (condition: unknown, consequent: unknown, alternate: unknown) => unknown
12
+ >;
7
13
  }
8
14
  }
package/index.ts CHANGED
@@ -13,26 +13,38 @@ const calculatorTool = defineTool({
13
13
  "Use calculator for non-trivial calculations; simple arithmetic does not need a tool call. Results are rounded decimal strings. In calculator expressions, use sum([...]) for totals and roundTo(value, places) for requested rounding. Put independent results in a flat array.",
14
14
  ],
15
15
  constrainedSampling: { type: "json_schema", strict: "prefer" },
16
+ annotations: { readOnlyHint: true, idempotentHint: true },
16
17
  parameters: Type.Object(
17
18
  {
18
19
  expression: Type.String({
19
20
  minLength: 1,
20
21
  maxLength: MAX_EXPRESSION_LENGTH,
21
- description: "Numeric expression: + - * / % ^ **, PI, E, roots, trig, n! (0–1000), sum/mean/median. log/ln are natural; log2/log10 choose a base. Use expm1(x) for exp(x)-1 and log1p(x) for ln(1+x) when x is small. Trig uses radians; radians(degrees) and degrees(radians) convert angles. percent(rate, amount) gives rate% of amount. stdev(array) is population; stdevs(array) is sample. roundTo(value, places) rounds halves away from zero.",
22
+ description:
23
+ "Numeric expression: + - * / % ^ **, PI, E, roots, trig, n! (0–1000), sum/mean/median. log/ln are natural; log2/log10 choose a base. Use expm1(x) for exp(x)-1 and log1p(x) for ln(1+x) when x is small. Trig uses radians; radians(degrees) and degrees(radians) convert angles. percent(rate, amount) gives rate% of amount. stdev(array) is population; stdevs(array) is sample. roundTo(value, places) rounds halves away from zero.",
22
24
  }),
23
25
  },
24
26
  { additionalProperties: false },
25
27
  ),
26
- outputSchema: Type.Object({
27
- expression: Type.String(),
28
- value: Type.Union([Type.String(), Type.Array(Type.String())]),
29
- }, { additionalProperties: false }),
30
- renderCall(args, theme) {
31
- return new Text(theme.fg("toolTitle", theme.bold("calculator ")) + theme.fg("dim", JSON.stringify(args.expression ?? "")), 0, 0);
28
+ outputSchema: Type.Object(
29
+ {
30
+ expression: Type.String(),
31
+ value: Type.Union([Type.String(), Type.Array(Type.String())]),
32
+ },
33
+ { additionalProperties: false },
34
+ ),
35
+ renderCall(args: { readonly expression?: string }, theme) {
36
+ return new Text(
37
+ theme.fg("toolTitle", theme.bold("calculator ")) +
38
+ theme.fg("dim", JSON.stringify(args.expression ?? "")),
39
+ 0,
40
+ 0,
41
+ );
32
42
  },
33
43
  async execute(_toolCallId, params) {
34
44
  const evaluated = evaluateExpression(params.expression);
35
- const text = Array.isArray(evaluated.value) ? JSON.stringify(evaluated.value) : evaluated.value;
45
+ const text = Array.isArray(evaluated.value)
46
+ ? JSON.stringify(evaluated.value)
47
+ : evaluated.value;
36
48
  return {
37
49
  content: [{ type: "text", text }],
38
50
  details: evaluated,
@@ -41,6 +53,6 @@ const calculatorTool = defineTool({
41
53
  },
42
54
  });
43
55
 
44
- export default function (pi: ExtensionAPI) {
56
+ export default function (pi: ExtensionAPI): void {
45
57
  pi.registerTool(calculatorTool);
46
58
  }
package/package.json CHANGED
@@ -1,71 +1,90 @@
1
1
  {
2
- "name": "@fitchmultz/pi-calculator",
3
- "version": "4.1.6",
4
- "publishConfig": {
5
- "access": "public"
6
- },
7
- "license": "MIT",
8
- "description": "Deterministic high-precision calculator tool for Pi",
9
- "type": "module",
10
- "files": [
11
- "index.ts",
12
- "eval.ts",
13
- "decimal.ts",
14
- "expr-eval-fork.d.ts",
15
- "README.md",
16
- "CHANGELOG.md",
17
- "LICENSE"
18
- ],
19
- "keywords": [
20
- "pi-package",
21
- "pi",
22
- "calculator",
23
- "decimal"
24
- ],
25
- "repository": {
26
- "type": "git",
27
- "url": "git+https://github.com/fitchmultz/pi-calculator.git"
28
- },
29
- "engines": {
30
- "node": ">=24.0.0"
31
- },
32
- "scripts": {
33
- "check": "node check.ts",
34
- "check:load": "node check-extension.ts",
35
- "typecheck": "tsc",
36
- "verify": "npm run typecheck && npm run check && npm run check:load",
37
- "check:compat": "npm run verify"
38
- },
39
- "pi": {
40
- "extensions": [
41
- "./index.ts"
42
- ]
43
- },
44
- "dependencies": {
45
- "decimal.js": "10.6.0",
46
- "expr-eval-fork": "3.0.3"
47
- },
48
- "peerDependencies": {
49
- "@earendil-works/pi-coding-agent": "*",
50
- "@earendil-works/pi-tui": "*",
51
- "typebox": "*"
52
- },
53
- "peerDependenciesMeta": {
54
- "@earendil-works/pi-coding-agent": {
55
- "optional": true
56
- },
57
- "@earendil-works/pi-tui": {
58
- "optional": true
59
- },
60
- "typebox": {
61
- "optional": true
62
- }
63
- },
64
- "devDependencies": {
65
- "@earendil-works/pi-coding-agent": "1.4.0",
66
- "@earendil-works/pi-tui": "1.4.0",
67
- "@types/node": "24.19.1",
68
- "typebox": "1.3.27",
69
- "typescript": "7.0.2"
70
- }
2
+ "name": "@fitchmultz/pi-calculator",
3
+ "version": "4.1.7",
4
+ "publishConfig": {
5
+ "access": "public"
6
+ },
7
+ "license": "MIT",
8
+ "description": "Deterministic high-precision calculator tool for Pi",
9
+ "type": "module",
10
+ "files": [
11
+ "index.ts",
12
+ "eval.ts",
13
+ "decimal.ts",
14
+ "expr-eval-fork.d.ts",
15
+ "README.md",
16
+ "CHANGELOG.md",
17
+ "LICENSE",
18
+ "statistics.ts",
19
+ "docs"
20
+ ],
21
+ "keywords": [
22
+ "pi-package",
23
+ "pi",
24
+ "calculator",
25
+ "decimal"
26
+ ],
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/fitchmultz/pi-calculator.git"
30
+ },
31
+ "engines": {
32
+ "node": ">=24.0.0"
33
+ },
34
+ "scripts": {
35
+ "check": "node check.ts",
36
+ "check:load": "node check-extension.ts",
37
+ "typecheck": "tsc",
38
+ "typecheck:declarations": "tsc -p tsconfig.declarations.json",
39
+ "lint": "node scripts/lint.mjs all",
40
+ "lint:agent": "node scripts/lint.mjs agent",
41
+ "lint:fix": "node scripts/lint.mjs fix",
42
+ "format": "oxfmt --write .",
43
+ "format:check": "oxfmt --check .",
44
+ "policy": "node scripts/policy.ts",
45
+ "test": "node --test \"test/**/*.test.ts\"",
46
+ "verify": "node scripts/checks.mjs lint format:check typecheck typecheck:declarations lint:baseline policy test check check:load",
47
+ "check:compat": "npm run verify",
48
+ "lint:baseline": "node scripts/lint-baseline.ts",
49
+ "lint:scope": "node scripts/lint.mjs scope",
50
+ "format:write": "oxfmt --write .",
51
+ "check:quality": "node scripts/checks.mjs lint format:check typecheck typecheck:declarations lint:baseline policy"
52
+ },
53
+ "pi": {
54
+ "extensions": [
55
+ "./index.ts"
56
+ ],
57
+ "image": "https://raw.githubusercontent.com/fitchmultz/pi-calculator/main/.github/readme/calculator-flow.png"
58
+ },
59
+ "dependencies": {
60
+ "decimal.js": "10.6.0",
61
+ "expr-eval-fork": "3.0.3"
62
+ },
63
+ "peerDependencies": {
64
+ "@earendil-works/pi-coding-agent": "*",
65
+ "@earendil-works/pi-tui": "*",
66
+ "typebox": "*"
67
+ },
68
+ "peerDependenciesMeta": {
69
+ "@earendil-works/pi-coding-agent": {
70
+ "optional": true
71
+ },
72
+ "@earendil-works/pi-tui": {
73
+ "optional": true
74
+ },
75
+ "typebox": {
76
+ "optional": true
77
+ }
78
+ },
79
+ "devDependencies": {
80
+ "@earendil-works/pi-ai": "^1.4.0",
81
+ "@earendil-works/pi-coding-agent": "1.4.0",
82
+ "@earendil-works/pi-tui": "1.4.0",
83
+ "@types/node": "24.19.1",
84
+ "oxfmt": "0.75.0",
85
+ "oxlint": "1.90.0",
86
+ "oxlint-tsgolint": "7.0.2005",
87
+ "typebox": "1.3.27",
88
+ "typescript": "7.0.2"
89
+ }
71
90
  }
package/statistics.ts ADDED
@@ -0,0 +1,151 @@
1
+ import { DECIMAL_PRECISION, Decimal, GuardDecimal, type DecimalValue } from "./decimal.ts";
2
+
3
+ /** Absent bins hold zero, which is what the sparse sum below assumes. */
4
+ function bin(bins: Map<bigint, bigint>, key: bigint): bigint {
5
+ return bins.get(key) ?? 0n;
6
+ }
7
+
8
+ function addBin(bins: Map<bigint, bigint>, key: bigint, value: bigint): void {
9
+ bins.set(key, (bins.get(key) ?? 0n) + value);
10
+ }
11
+
12
+ /** Exact significand digits as a signed integer, keeping every input digit. */
13
+ function significandDigits(value: DecimalValue): string {
14
+ const [digits] = value.toExponential().split("e");
15
+ if (digits === undefined) {
16
+ throw new Error("expected an exact decimal significand");
17
+ }
18
+ return digits.replace(".", "");
19
+ }
20
+
21
+ /** Spread each value across the bins its exponent falls in, keeping the exact sum sparse. */
22
+ function fillBins(xs: readonly DecimalValue[], bins: Map<bigint, bigint>, width: bigint): void {
23
+ for (const x of xs) {
24
+ if (x.isZero()) {
25
+ continue;
26
+ }
27
+ const exponent = BigInt(x.e) - BigInt(x.sd() - 1);
28
+ const key = exponent >= 0n ? exponent / width : (exponent - width + 1n) / width;
29
+ addBin(bins, key, BigInt(significandDigits(x)) * 10n ** (exponent - key * width));
30
+ }
31
+ }
32
+
33
+ /** Carry each bin into the next so every stored bin stays below the base. */
34
+ function carryBins(bins: Map<bigint, bigint>, base: bigint): void {
35
+ for (const start of [...bins.keys()].sort((a, b) => Number(a - b))) {
36
+ let key = start;
37
+ for (;;) {
38
+ const value = bin(bins, key);
39
+ const carry = value / base;
40
+ bins.set(key, value % base);
41
+ if (carry === 0n) {
42
+ break;
43
+ }
44
+ key += 1n;
45
+ addBin(bins, key, carry);
46
+ }
47
+ }
48
+ }
49
+
50
+ type DecimalConstructor = typeof Decimal;
51
+
52
+ const digitCount = (n: bigint) => n.toString().replace("-", "").length;
53
+
54
+ /** Fold the leading bins into one coefficient; a sticky digit keeps the tail's side of a tie. */
55
+ function leadingCoefficient(
56
+ bins: Map<bigint, bigint>,
57
+ keys: readonly bigint[],
58
+ base: bigint,
59
+ retained: number,
60
+ ): [coefficient: bigint, key: bigint, shift: bigint] {
61
+ const leading = keys[0];
62
+ if (leading === undefined) {
63
+ return [0n, 0n, 0n];
64
+ }
65
+ let key = leading;
66
+ let coefficient = bin(bins, key);
67
+ let index = 1;
68
+ while (index < keys.length) {
69
+ const next = keys[index];
70
+ if (next === undefined) {
71
+ break;
72
+ }
73
+ if (digitCount(coefficient) >= retained || key - next > 2n) {
74
+ break;
75
+ }
76
+ coefficient = coefficient * base ** (key - next) + bin(bins, next);
77
+ key = next;
78
+ index += 1;
79
+ }
80
+ // Normalized chunks have magnitude < base, so the leading remaining chunk
81
+ // determines the tail's sign.
82
+ let shift = 0n;
83
+ const tail = index < keys.length ? keys[index] : undefined;
84
+ if (tail !== undefined) {
85
+ shift = BigInt(Math.max(2, retained - digitCount(coefficient)));
86
+ coefficient = coefficient * 10n ** shift + (bin(bins, tail) > 0n ? 1n : -1n);
87
+ }
88
+ return [coefficient, key, shift];
89
+ }
90
+
91
+ /** Sum `xs` exactly, then divide, so large exponent gaps keep every input digit. */
92
+ export function exactSum(
93
+ xs: readonly DecimalValue[],
94
+ Ctor: DecimalConstructor = Decimal,
95
+ divisor = 1,
96
+ ): DecimalValue {
97
+ if (xs.some((x) => !x.isFinite())) {
98
+ return Ctor.sum(...xs).div(divisor);
99
+ }
100
+
101
+ // Decimal.sum also truncates across large exponent gaps. Signed BigInt chunks
102
+ // keep the exact sum sparse, including intermediate values outside Decimal's range.
103
+ const width = BigInt(Ctor.precision);
104
+ const base = 10n ** width;
105
+ const bins = new Map<bigint, bigint>();
106
+ fillBins(xs, bins, width);
107
+ carryBins(bins, base);
108
+
109
+ const keys = [...bins.keys()]
110
+ .filter((key) => bins.get(key) !== 0n)
111
+ .sort((a, b) => Number(b - a));
112
+ if (keys.length === 0) {
113
+ return new Ctor(0);
114
+ }
115
+ const retained = Ctor.precision + Math.ceil(Math.log10(divisor)) + 2;
116
+ const [coefficient, key, shift] = leadingCoefficient(bins, keys, base, retained);
117
+ // Divide and round before restoring the exponent to avoid intermediate overflow/underflow.
118
+ const [mantissa, exponent] = new Ctor(coefficient.toString())
119
+ .div(divisor)
120
+ .toExponential()
121
+ .split("e");
122
+ if (mantissa === undefined || exponent === undefined) {
123
+ throw new Error("expected an exact decimal exponent form");
124
+ }
125
+ return new Ctor(`${mantissa}e${BigInt(exponent) + key * width - shift}`);
126
+ }
127
+
128
+ export function mean(xs: readonly DecimalValue[]): DecimalValue {
129
+ return exactSum(xs, Decimal, xs.length);
130
+ }
131
+
132
+ export function standardDeviation(xs: readonly DecimalValue[], sample: boolean): DecimalValue {
133
+ // Scale before subtraction; a power of ten preserves small spreads around a large offset.
134
+ const widest = GuardDecimal.max(...xs.map((x) => x.abs()));
135
+ const scale = new GuardDecimal(`1e${widest.e}`);
136
+ const scaled = xs.map((x) => new GuardDecimal(x).div(scale));
137
+ const anchor = scaled[0];
138
+ if (anchor === undefined) {
139
+ throw new Error("statistics need at least one number");
140
+ }
141
+ // Center on an input first so a large common offset cannot round away the spread.
142
+ const shifted = scaled.map((x) => x.minus(anchor));
143
+ const center = exactSum(shifted, GuardDecimal, xs.length);
144
+ const squared = shifted.map((x) => x.minus(center).pow(2));
145
+ return new Decimal(
146
+ exactSum(squared, GuardDecimal, sample ? xs.length - 1 : xs.length)
147
+ .sqrt()
148
+ .times(scale)
149
+ .toSignificantDigits(DECIMAL_PRECISION),
150
+ );
151
+ }