@fitchmultz/pi-calculator 0.0.0-stage → 4.1.2

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 ADDED
@@ -0,0 +1,97 @@
1
+ # Changelog
2
+
3
+ ## 4.1.2 - 2026-10-31
4
+
5
+ - Refresh installation and reference documentation while preserving unpinned npm and Git sources and migration guidance.
6
+ - Refresh the development lockfile and compatibility workflow references.
7
+
8
+ ## 4.1.1 - 2026-10-04
9
+
10
+ - Publish the existing calculator as the public scoped package `@fitchmultz/pi-calculator`, continuing the 4.1.x version line. The unrelated unscoped npm package is not this project.
11
+ - Lead installation guidance with the scoped npm source while preserving Git installation, existing tags, and the calculator's numerical, schema, structured-result, rendering, and native-composition contracts.
12
+ - Qualify the latest stable official Pi and maintained fork main with once-frozen host identities, and add a main-only gated repository release pipeline.
13
+
14
+ ## 4.1.0 - 2026-10-01
15
+
16
+ - Require Pi 1.0.0 and qualify its exact published cohort with host TypeBox 1.3.27.
17
+ - Expose the existing expression/value outcome through native structured output for nested callers, preserving decimal strings, scalar/array behavior, grammar, work limits, errors, and rendering.
18
+ - Exercise native nested scalar/array receipts and distinct validation, policy, and evaluation failures without provider calls. Keep Git/GitHub-only distribution.
19
+
20
+ ## 4.0.0 - 2026-09-26
21
+
22
+ ### Breaking changes
23
+
24
+ - Require Node.js 24 or later.
25
+
26
+ ### Changed
27
+
28
+ - Qualify development against Pi 0.87.1 and the TypeBox version Pi ships (1.3.27), on Node 24 for both official Pi and the maintained fork.
29
+ - Type-check with Node 24 types and explicit Node module resolution under TypeScript 7.
30
+
31
+ ## 3.0.0 - 2026-09-21
32
+
33
+ ### Breaking changes
34
+
35
+ - Replace `deg(x)` with `radians(x)` for degrees-to-radians conversion, and `rad(x)` with `degrees(x)` for radians-to-degrees conversion. The old names are removed without aliases.
36
+ - Return value-only model content instead of `expression = value`. Details retain the trimmed expression and a value that can now be a decimal string or an array of decimal strings.
37
+
38
+ ### Added
39
+
40
+ - Evaluate independent calculations in one flat array. Reject nested, nonnumeric, or nonfinite results as a whole, and reject serialized expression/value details larger than 50 KiB.
41
+ - Show safely escaped expressions in native Pi tool cards while keeping model output concise.
42
+ - Request native strict-prefer JSON-schema sampling with provider-controlled fallback.
43
+
44
+ ### Changed
45
+
46
+ - Pass Decimal values directly through the parser while preserving decimal-literal precision, numerical algorithms, private contexts, and work limits.
47
+ - Clarify percentage argument order, population/sample standard deviation, logarithm bases, small-argument `expm1`/`log1p`, and when to combine calculations or skip a trivial tool call.
48
+ - Qualify development against Pi 0.87.0 and TypeBox 1.3.34; declare host-provided Pi TUI as an optional peer. The package remains private and Git-distributed.
49
+
50
+ ### Fixed
51
+
52
+ - Require finite inputs consistently for aggregates, including `min`, `max`, and `hypot`/`pyt`.
53
+
54
+ ## 2.0.2 - 2026-09-18
55
+
56
+ ### Fixed
57
+
58
+ - Preserve tangent accuracy near poles by dividing guarded sine and cosine values.
59
+ - Preserve cancellation remainders in sums without allocating across large exponent gaps, and round means and even medians only after division.
60
+ - Center standard-deviation calculations before averaging so identical measurements have zero spread and large common offsets do not distort the result.
61
+ - Reject non-finite measurements in statistics, including medians that previously hid invalid square roots or division by zero.
62
+
63
+ ### Added
64
+
65
+ - Regression checks for numeric correctness and GitHub CI verification on the minimum supported Node version and Node 24.
66
+
67
+ ## 2.0.1 - 2026-08-08
68
+
69
+ ### Fixed
70
+
71
+ - Restrict evaluation to null-prototype deterministic allowlists instead of inheriting broken parser functions, operators, or object properties.
72
+ - Evaluate supported scientific helpers in a private Decimal context, including cancellation-safe `expm1`/`log1p`, hyperbolic functions, `pow`, `atan2`, `sum`, `sign`, and `trunc`.
73
+ - Return the requested element for array indexes instead of always coercing wrapped indexes to zero.
74
+ - Preserve single-quoted numeric strings and quoted `**` text during literal rewriting, and reject unsupported comments.
75
+ - Accept negative zero factorials, correctly round factorials once from an exact BigInt result, and reject extra function arguments.
76
+ - Restore the full 40-digit `E` constant, round input literals to the documented precision, reset Decimal state after every evaluation, cap nesting deterministically, and replace raw dependency errors with stable calculator errors.
77
+ - Disable member access and unadvertised conditional, comparison, logical, higher-order, random, string-escape, comment, and multi-expression operations.
78
+ - Bound circular and hyperbolic trig operands plus modulo exponent gaps with a shared work budget to prevent synchronous CPU amplification.
79
+
80
+ ### Changed
81
+
82
+ - Keep only stable expression and value fields in tool details and trim always-on prompt guidance.
83
+ - Limit npm package contents to runtime source and documentation.
84
+
85
+ ## 2.0.0 - 2026-08-06
86
+
87
+ ### Changed
88
+
89
+ - Require Pi 0.84.0 or later.
90
+ - Import the host-provided TypeBox package directly, following the Pi 0.84 extension contract.
91
+ - Format very large integers in scientific notation so calculator output stays below Pi's 50KB tool-output limit.
92
+ - Keep evaluation details JSON-safe by removing the lossy JavaScript number conversion.
93
+ - Escape line breaks in displayed expressions so tool results stay within Pi's 2,000-line output limit.
94
+
95
+ ### Added
96
+
97
+ - Type checking and an actual Pi 0.84 extension-load check to the release verification command.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mitchell Fultz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,67 @@
1
- # Temporary Holding Version
1
+ # pi-calculator
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+
5
+ ![Pi sends an expression to the calculator, which returns a decimal result or rejects an invalid expression.](.github/readme/calculator-flow.png)
6
+
7
+ ## Install and try it
8
+
9
+ Use Node.js and a supported Pi release. See the [compatibility requirements](https://github.com/fitchmultz/pi-calculator/blob/main/docs/reference.md#installation-options).
10
+
11
+ ```sh
12
+ pi install npm:@fitchmultz/pi-calculator
13
+ pi
14
+ ```
15
+
16
+ Enter this prompt in the new Pi session:
17
+
18
+ ```text
19
+ Use the calculator to evaluate [0.1 + 0.2, 2^64].
20
+ ```
21
+
22
+ The calculator tool returns `["0.3","18446744073709551616"]`.
23
+
24
+ See [installation options](docs/reference.md#installation-options) for Git installs or a change from Git to npm.
25
+
26
+ Pi extensions run with full system access. Review the source before you install the package.
27
+
28
+ ## Expressions
29
+
30
+ Try these expressions:
31
+
32
+ ```text
33
+ percent(15, 200) → 30
34
+ roundTo(-1.25, 1) → -1.3
35
+ mean([2,4,6,8]) → 5
36
+ sin(radians(90)) → 1
37
+ sum([1e40,1,-1e40]) → 1
38
+ (12.5 * 1.0825) ^ 3 → 2477.500518798828125
39
+ ```
40
+
41
+ Arithmetic supports `+`, `-`, `*`, `/`, `%`, `^` and `**`, with `PI` and `E` as constants. Put independent calculations in one flat array, as in the first example.
42
+
43
+ Trig uses radians. Use `radians()` to convert degrees to radians. Use `degrees()` to convert radians to degrees.
44
+
45
+ `roundTo(value, places)` rounds half values away from zero. Use negative places to round to tens, hundreds or larger units.
46
+
47
+ See the [expression reference](docs/reference.md#expressions) for all functions and array indexing.
48
+
49
+ ## Results and limits
50
+
51
+ Results are decimal strings, or a flat array of decimal strings. Input numbers and arithmetic use half-up rounding. Large finite results use scientific notation.
52
+
53
+ The calculator tool rejects invalid expressions, nonfinite results and nested result arrays. If one result in an array is invalid, the calculator tool rejects the whole expression.
54
+
55
+ Keep expressions within 4,096 characters. See [limits](docs/reference.md#limits) for nesting, output size and work budgets.
56
+
57
+ See [result format](docs/reference.md#result-format) to use the calculator tool from another extension.
58
+
59
+ Read the [migration notes](https://github.com/fitchmultz/pi-calculator/blob/main/docs/reference.md#migration) before upgrading.
60
+
61
+ ## Verification
62
+
63
+ See the [development guide](docs/development.md) for local checks and Pi compatibility tests. See the [changelog](CHANGELOG.md) for release history.
64
+
65
+ ## License
66
+
67
+ [MIT](LICENSE) © Mitchell Fultz.
package/decimal.ts ADDED
@@ -0,0 +1,91 @@
1
+ import { Decimal as DecimalBase } from "decimal.js";
2
+
3
+ export const DECIMAL_PRECISION = 40;
4
+ export const MAX_EXPRESSION_DEPTH = 128;
5
+ const DECIMAL_CONFIG = {
6
+ defaults: true,
7
+ precision: DECIMAL_PRECISION,
8
+ rounding: DecimalBase.ROUND_HALF_UP,
9
+ } as const;
10
+ export const Decimal = DecimalBase.clone(DECIMAL_CONFIG);
11
+ export type DecimalValue = DecimalBase;
12
+
13
+ export function resetDecimal(): void {
14
+ Decimal.set(DECIMAL_CONFIG);
15
+ }
16
+
17
+ const DECIMAL_LITERAL = /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?$/i;
18
+
19
+ function decimalFromString(value: string): DecimalValue {
20
+ if (!DECIMAL_LITERAL.test(value)) throw new Error("invalid decimal literal");
21
+ const decimal = new Decimal(value);
22
+ if (!decimal.isFinite()) throw new Error("decimal literal overflow");
23
+ if (decimal.isZero() && /[1-9]/.test(value.replace(/[eE].*$/, ""))) {
24
+ throw new Error("decimal literal underflow");
25
+ }
26
+ 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");
29
+ return rounded;
30
+ }
31
+
32
+ export function toDec(value: unknown): DecimalValue {
33
+ if (value instanceof Decimal) return value;
34
+ if (typeof value === "string") return decimalFromString(value);
35
+ if (typeof value === "number") {
36
+ if (!Number.isFinite(value)) throw new Error("non-finite number");
37
+ return new Decimal(String(value));
38
+ }
39
+ throw new Error(`expected number, got ${typeof value}`);
40
+ }
41
+
42
+ /** Preserve numeric literal precision and normalize ** outside quoted strings. */
43
+ export function decimalizeExpression(expression: string): string {
44
+ if (expression.includes("\\")) throw new Error("escape sequences are not supported");
45
+
46
+ let result = "";
47
+ let i = 0;
48
+ let nestingDepth = 0;
49
+
50
+ while (i < expression.length) {
51
+ const ch = expression[i]!;
52
+
53
+ 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;
58
+ continue;
59
+ }
60
+
61
+ if (ch === "/" && expression[i + 1] === "*") throw new Error("comments are not supported");
62
+ if (ch === ";") throw new Error("multiple expressions are not supported");
63
+
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
+ }
72
+
73
+ if (ch === "*" && expression[i + 1] === "*") {
74
+ result += "^";
75
+ i += 2;
76
+ continue;
77
+ }
78
+
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;
83
+ continue;
84
+ }
85
+
86
+ result += ch;
87
+ i++;
88
+ }
89
+
90
+ return result;
91
+ }
package/eval.ts ADDED
@@ -0,0 +1,452 @@
1
+ import { Parser } from "expr-eval-fork";
2
+ import { DECIMAL_PRECISION, Decimal, decimalizeExpression, resetDecimal, toDec, type DecimalValue } from "./decimal.ts";
3
+
4
+ export const MAX_EXPRESSION_LENGTH = 4096;
5
+ const MAX_FACTORIAL_OPERAND = 1000;
6
+ const MAX_FACTORIAL_WORK = MAX_FACTORIAL_OPERAND;
7
+ const MAX_ROUND_DIGITS = 1_000_000_000;
8
+ const MAX_HYPERBOLIC_ABS = 10_000;
9
+ const MAX_MODULO_EXPONENT_GAP = 10_000;
10
+ const MAX_EXPENSIVE_WORK = 10_000;
11
+ const TRIG_WORK = 100;
12
+ const PI = new Decimal("3.141592653589793238462643383279502884197");
13
+ const E = new Decimal("2.718281828459045235360287471352662497757");
14
+ const MAX_TRIG_ABS = new Decimal("1e100");
15
+ const SMALL_ATAN_ABS = new Decimal("1e-100");
16
+ 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
+
24
+ let factorialWorkLeft = 0;
25
+ let expensiveWorkLeft = 0;
26
+ const EMPTY_VARIABLES: Record<string, never> = Object.create(null);
27
+
28
+ const parser = new Parser({
29
+ allowMemberAccess: false,
30
+ operators: {
31
+ assignment: false,
32
+ comparison: false,
33
+ concatenate: false,
34
+ conditional: false,
35
+ fndef: false,
36
+ in: false,
37
+ logical: false,
38
+ },
39
+ });
40
+
41
+ function nullMap<T extends object>(values: T): T {
42
+ return Object.assign(Object.create(null), values);
43
+ }
44
+
45
+ function requireArity(name: string, args: unknown[], count: number): void {
46
+ if (args.length !== count) {
47
+ throw new Error(`${name}() needs exactly ${count} argument${count === 1 ? "" : "s"}`);
48
+ }
49
+ }
50
+
51
+ function factorial(value: unknown) {
52
+ 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})`);
55
+
56
+ const count = n.toNumber();
57
+ const cost = Math.max(count, 1);
58
+ if (cost > factorialWorkLeft) throw new Error("factorial work budget exceeded");
59
+ factorialWorkLeft -= cost;
60
+
61
+ let result = 1n;
62
+ for (let i = 2; i <= count; i++) result *= BigInt(i);
63
+ return new Decimal(result.toString()).toSignificantDigits(DECIMAL_PRECISION);
64
+ }
65
+
66
+ function decimals(values: unknown, name: string, minLength = 1): DecimalValue[] {
67
+ if (!Array.isArray(values) || values.length < minLength) {
68
+ const need = minLength === 1 ? "a non-empty number array" : `at least ${minLength} numbers`;
69
+ throw new Error(`${name}() needs ${need}`);
70
+ }
71
+ return values.map((value, index) => {
72
+ try {
73
+ const number = toDec(value);
74
+ if (!number.isFinite()) throw new Error("expected a finite number");
75
+ return number;
76
+ } catch {
77
+ throw new Error(`${name}(): invalid number at index ${index}`);
78
+ }
79
+ });
80
+ }
81
+
82
+ function decimalArguments(args: unknown[], name: string): DecimalValue[] {
83
+ 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
+ }
112
+ }
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));
154
+ }
155
+
156
+ function tangent(value: DecimalValue): DecimalValue {
157
+ // Avoid Decimal.tan's cancellation in sqrt(1 - sin(x)^2) near a pole.
158
+ const x = new GuardDecimal(value);
159
+ return new Decimal(x.sin().div(x.cos()).toSignificantDigits(DECIMAL_PRECISION));
160
+ }
161
+
162
+ function arctangent(value: DecimalValue): DecimalValue {
163
+ // Decimal.atan can loop when argument reduction overflows or underflows.
164
+ 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);
167
+ return Decimal.atan(value);
168
+ }
169
+
170
+ function arctangent2(y: DecimalValue, x: DecimalValue): DecimalValue {
171
+ if (x.isZero() || y.isZero() || !x.isFinite() || !y.isFinite()) return Decimal.atan2(y, x);
172
+ 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);
175
+ return Decimal.atan2(y, x);
176
+ }
177
+
178
+ function expm1(value: DecimalValue): DecimalValue {
179
+ const x = new GuardDecimal(value.toString());
180
+ if (x.abs().gte(0.1)) {
181
+ return new Decimal(GuardDecimal.exp(x).minus(1).toSignificantDigits(DECIMAL_PRECISION).toString());
182
+ }
183
+ let term = x;
184
+ let total = x;
185
+ for (let n = 2; n <= DECIMAL_PRECISION + 10; n++) {
186
+ term = term.times(x).div(n);
187
+ const next = total.plus(term);
188
+ if (next.eq(total)) break;
189
+ total = next;
190
+ }
191
+ return new Decimal(total.toSignificantDigits(DECIMAL_PRECISION).toString());
192
+ }
193
+
194
+ function log1p(value: DecimalValue): DecimalValue {
195
+ const x = new GuardDecimal(value.toString());
196
+ if (x.abs().gte(0.1)) {
197
+ return new Decimal(GuardDecimal.ln(x.plus(1)).toSignificantDigits(DECIMAL_PRECISION).toString());
198
+ }
199
+ let power = x;
200
+ let total = x;
201
+ for (let n = 2; n <= DECIMAL_PRECISION + 10; n++) {
202
+ power = power.times(x);
203
+ const next = n % 2 === 0 ? total.minus(power.div(n)) : total.plus(power.div(n));
204
+ if (next.eq(total)) break;
205
+ total = next;
206
+ }
207
+ return new Decimal(total.toSignificantDigits(DECIMAL_PRECISION).toString());
208
+ }
209
+
210
+ function spendExpensiveWork(cost: number): void {
211
+ if (cost > expensiveWorkLeft) throw new Error("expression work budget exceeded");
212
+ expensiveWorkLeft -= cost;
213
+ }
214
+
215
+ function trigonometric(
216
+ name: string,
217
+ fn: (x: DecimalValue) => DecimalValue,
218
+ maxAbs?: DecimalValue,
219
+ ): (x: DecimalValue) => DecimalValue {
220
+ return (x) => {
221
+ if (maxAbs && x.abs().gt(maxAbs)) {
222
+ throw new Error(`${name}() argument too large (max absolute value ${maxAbs.toString()})`);
223
+ }
224
+ spendExpensiveWork(TRIG_WORK);
225
+ return fn(x);
226
+ };
227
+ }
228
+
229
+ function hyperbolic(name: string, fn: (x: DecimalValue) => DecimalValue): (x: DecimalValue) => DecimalValue {
230
+ return (x) => {
231
+ if (x.abs().gt(MAX_HYPERBOLIC_ABS)) {
232
+ throw new Error(`${name}() argument too large (max absolute value ${MAX_HYPERBOLIC_ABS})`);
233
+ }
234
+ spendExpensiveWork(x.isFinite() ? Math.max(1, Math.ceil(x.abs().toNumber())) : 1);
235
+ return fn(x);
236
+ };
237
+ }
238
+
239
+ function modulo(a: unknown, b: unknown) {
240
+ const left = toDec(a);
241
+ const right = toDec(b);
242
+ if (right.isZero()) throw new Error("modulo by zero");
243
+ const exponentGap = left.isFinite() && right.isFinite() ? Math.abs(left.e - right.e) : 0;
244
+ if (exponentGap > MAX_MODULO_EXPONENT_GAP) {
245
+ throw new Error(`modulo exponent gap too large (max ${MAX_MODULO_EXPONENT_GAP})`);
246
+ }
247
+ spendExpensiveWork(Math.max(1, exponentGap));
248
+ return left.mod(right);
249
+ }
250
+
251
+ function roundTo(...args: unknown[]) {
252
+ requireArity("roundTo", args, 2);
253
+ const digits = toDec(args[1]);
254
+ 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}`);
256
+ }
257
+ const places = digits.toNumber();
258
+ const value = toDec(args[0]);
259
+ if (places >= 0) return value.toDecimalPlaces(places);
260
+ const scale = new Decimal(10).pow(-places);
261
+ return value.div(scale).toDecimalPlaces(0).times(scale);
262
+ }
263
+
264
+ function arrayIndex(values: unknown, index: unknown): unknown {
265
+ if (!Array.isArray(values)) throw new Error("indexing needs an array");
266
+ let n: DecimalValue;
267
+ try {
268
+ n = toDec(index);
269
+ } catch {
270
+ throw new Error("array index needs an integer");
271
+ }
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");
274
+ return values[n.toNumber()];
275
+ }
276
+
277
+ const decimalUnary: Record<string, (x: DecimalValue) => DecimalValue> = {
278
+ sin: trigonometric("sin", (x) => Decimal.sin(x), MAX_TRIG_ABS),
279
+ cos: trigonometric("cos", (x) => Decimal.cos(x), MAX_TRIG_ABS),
280
+ tan: trigonometric("tan", tangent, MAX_TRIG_ABS),
281
+ asin: trigonometric("asin", (x) => Decimal.asin(x)),
282
+ acos: trigonometric("acos", (x) => Decimal.acos(x)),
283
+ atan: trigonometric("atan", arctangent),
284
+ sinh: hyperbolic("sinh", (x) => Decimal.sinh(x)),
285
+ cosh: hyperbolic("cosh", (x) => Decimal.cosh(x)),
286
+ tanh: hyperbolic("tanh", (x) => Decimal.tanh(x)),
287
+ asinh: hyperbolic("asinh", (x) => Decimal.asinh(x)),
288
+ acosh: hyperbolic("acosh", (x) => Decimal.acosh(x)),
289
+ atanh: hyperbolic("atanh", (x) => Decimal.atanh(x)),
290
+ sqrt: (x) => Decimal.sqrt(x),
291
+ cbrt: (x) => Decimal.cbrt(x),
292
+ abs: (x) => Decimal.abs(x),
293
+ ln: (x) => Decimal.ln(x),
294
+ log: (x) => Decimal.ln(x),
295
+ lg: (x) => Decimal.log10(x),
296
+ log10: (x) => Decimal.log10(x),
297
+ log2: (x) => Decimal.log2(x),
298
+ exp: (x) => Decimal.exp(x),
299
+ expm1,
300
+ log1p,
301
+ ceil: (x) => Decimal.ceil(x),
302
+ floor: (x) => Decimal.floor(x),
303
+ round: (x) => Decimal.round(x),
304
+ trunc: (x) => Decimal.trunc(x),
305
+ sign: (x) => new Decimal(Decimal.sign(x)),
306
+ };
307
+
308
+ parser.unaryOps = nullMap({
309
+ ...Object.fromEntries(Object.entries(decimalUnary).map(([name, fn]) => [name, (value: unknown) => fn(toDec(value))])),
310
+ "+": toDec,
311
+ "-": (value: unknown) => toDec(value).negated(),
312
+ "!": factorial,
313
+ });
314
+
315
+ parser.binaryOps = nullMap({
316
+ "+": (a, b) => toDec(a).plus(toDec(b)),
317
+ "-": (a, b) => toDec(a).minus(toDec(b)),
318
+ "*": (a, b) => toDec(a).times(toDec(b)),
319
+ "/": (a, b) => {
320
+ const numerator = toDec(a);
321
+ const denominator = toDec(b);
322
+ if (denominator.isZero()) throw new Error("division by zero");
323
+ return numerator.div(denominator);
324
+ },
325
+ "%": modulo,
326
+ "^": (a, b) => toDec(a).pow(toDec(b)),
327
+ "[": arrayIndex,
328
+ });
329
+ parser.ternaryOps = nullMap({});
330
+
331
+ const hypot = (...args: unknown[]) => Decimal.hypot(...decimalArguments(args, "hypot"));
332
+
333
+ parser.functions = nullMap({
334
+ d: (...args: unknown[]) => {
335
+ requireArity("d", args, 1);
336
+ if (typeof args[0] !== "string") throw new Error("invalid decimal literal");
337
+ return toDec(args[0]);
338
+ },
339
+ fac: (...args: unknown[]) => {
340
+ requireArity("fac", args, 1);
341
+ return factorial(args[0]);
342
+ },
343
+ pow: (...args: unknown[]) => {
344
+ requireArity("pow", args, 2);
345
+ return toDec(args[0]).pow(toDec(args[1]));
346
+ },
347
+ atan2: (...args: unknown[]) => {
348
+ requireArity("atan2", args, 2);
349
+ const values = args.map(toDec);
350
+ spendExpensiveWork(TRIG_WORK);
351
+ return arctangent2(values[0]!, values[1]!);
352
+ },
353
+ min: (...args: unknown[]) => Decimal.min(...decimalArguments(args, "min")),
354
+ max: (...args: unknown[]) => Decimal.max(...decimalArguments(args, "max")),
355
+ sum: (...args: unknown[]) => {
356
+ requireArity("sum", args, 1);
357
+ return sum(decimals(args[0], "sum"));
358
+ },
359
+ hypot,
360
+ pyt: hypot,
361
+ roundTo,
362
+ percent: (...args: unknown[]) => {
363
+ requireArity("percent", args, 2);
364
+ const rate = toDec(args[0]);
365
+ const amount = toDec(args[1]);
366
+ // Scale the larger factor first so intermediate arithmetic stays in range.
367
+ return amount.abs().gte(rate.abs())
368
+ ? amount.div(100).times(rate)
369
+ : rate.div(100).times(amount);
370
+ },
371
+ radians: (...args: unknown[]) => {
372
+ requireArity("radians", args, 1);
373
+ return toDec(args[0]).times(PI).div(180);
374
+ },
375
+ degrees: (...args: unknown[]) => {
376
+ requireArity("degrees", args, 1);
377
+ return toDec(args[0]).times(180).div(PI);
378
+ },
379
+ mean: (...args: unknown[]) => {
380
+ requireArity("mean", args, 1);
381
+ return mean(decimals(args[0], "mean"));
382
+ },
383
+ median: (...args: unknown[]) => {
384
+ requireArity("median", args, 1);
385
+ const xs = decimals(args[0], "median").sort((a, b) => a.comparedTo(b));
386
+ const mid = Math.floor(xs.length / 2);
387
+ return xs.length % 2 === 0 ? mean([xs[mid - 1]!, xs[mid]!]) : xs[mid]!;
388
+ },
389
+ stdev: (...args: unknown[]) => {
390
+ requireArity("stdev", args, 1);
391
+ return standardDeviation(decimals(args[0], "stdev"), false);
392
+ },
393
+ stdevs: (...args: unknown[]) => {
394
+ requireArity("stdevs", args, 1);
395
+ return standardDeviation(decimals(args[0], "stdevs", 2), true);
396
+ },
397
+ });
398
+
399
+ parser.consts = nullMap({ PI, E });
400
+
401
+ function normalizeExpression(expression: string): string {
402
+ const trimmed = expression.trim();
403
+ if (!trimmed) throw new Error("Expression is empty");
404
+ if (trimmed.length > MAX_EXPRESSION_LENGTH) {
405
+ throw new Error(`Expression too long (max ${MAX_EXPRESSION_LENGTH} chars)`);
406
+ }
407
+ return trimmed;
408
+ }
409
+
410
+ function resultString(result: unknown): string {
411
+ if (typeof result === "number") result = new Decimal(String(result));
412
+ if (!(result instanceof Decimal)) {
413
+ const type = Array.isArray(result) ? "array" : typeof result;
414
+ throw new Error(`Expression did not evaluate to a number (got ${type})`);
415
+ }
416
+ if (!result.isFinite()) {
417
+ if (result.isNaN()) throw new Error("Result is NaN");
418
+ throw new Error(result.isPositive() ? "Result is Infinity" : "Result is -Infinity");
419
+ }
420
+ return result.toString();
421
+ }
422
+
423
+ export function evaluateExpression(expression: string): { expression: string; value: string | string[] } {
424
+ const normalized = normalizeExpression(expression);
425
+ let result: unknown;
426
+ factorialWorkLeft = MAX_FACTORIAL_WORK;
427
+ expensiveWorkLeft = MAX_EXPENSIVE_WORK;
428
+ try {
429
+ result = parser.evaluate(decimalizeExpression(normalized), EMPTY_VARIABLES);
430
+ } catch (error) {
431
+ let message = error instanceof Error ? error.message : String(error);
432
+ message = message
433
+ .replace(/^\[DecimalError\]\s*/, "")
434
+ .replace(/^parse error \[\d+:\d+\]:\s*/, "")
435
+ .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}`);
439
+ } finally {
440
+ factorialWorkLeft = 0;
441
+ expensiveWorkLeft = 0;
442
+ resetDecimal();
443
+ GuardDecimal.set(GUARD_DECIMAL_CONFIG);
444
+ }
445
+
446
+ const value = Array.isArray(result) ? result.map(resultString) : resultString(result);
447
+ const evaluated = { expression: normalized, value };
448
+ if (Buffer.byteLength(JSON.stringify(evaluated)) > 50 * 1024) {
449
+ throw new Error("Result exceeds 50 KiB output limit");
450
+ }
451
+ return evaluated;
452
+ }
@@ -0,0 +1,8 @@
1
+ import "expr-eval-fork";
2
+
3
+ declare module "expr-eval-fork" {
4
+ interface Parser {
5
+ binaryOps: Record<string, (left: unknown, right: unknown) => unknown>;
6
+ ternaryOps: Record<string, (condition: unknown, consequent: unknown, alternate: unknown) => unknown>;
7
+ }
8
+ }
package/index.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { Type } from "typebox";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
4
+ import { DECIMAL_PRECISION } from "./decimal.ts";
5
+ import { evaluateExpression, MAX_EXPRESSION_LENGTH } from "./eval.ts";
6
+
7
+ const calculatorTool = defineTool({
8
+ name: "calculator",
9
+ label: "Calculator",
10
+ description: `Calculate arithmetic, scientific functions and statistics with ${DECIMAL_PRECISION}-significant-digit decimal precision. Returns one number or a flat array of numbers.`,
11
+ promptSnippet: "Calculate numeric answers and verify arithmetic",
12
+ promptGuidelines: [
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
+ ],
15
+ constrainedSampling: { type: "json_schema", strict: "prefer" },
16
+ parameters: Type.Object(
17
+ {
18
+ expression: Type.String({
19
+ minLength: 1,
20
+ 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
+ }),
23
+ },
24
+ { additionalProperties: false },
25
+ ),
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);
32
+ },
33
+ async execute(_toolCallId, params) {
34
+ const evaluated = evaluateExpression(params.expression);
35
+ const text = Array.isArray(evaluated.value) ? JSON.stringify(evaluated.value) : evaluated.value;
36
+ return {
37
+ content: [{ type: "text", text }],
38
+ details: evaluated,
39
+ structuredContent: evaluated,
40
+ };
41
+ },
42
+ });
43
+
44
+ export default function (pi: ExtensionAPI) {
45
+ pi.registerTool(calculatorTool);
46
+ }
package/package.json CHANGED
@@ -1,6 +1,71 @@
1
1
  {
2
2
  "name": "@fitchmultz/pi-calculator",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "4.1.2",
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.0.0",
66
+ "@earendil-works/pi-tui": "1.0.0",
67
+ "@types/node": "24.19.1",
68
+ "typebox": "1.3.27",
69
+ "typescript": "7.0.2"
70
+ }
71
+ }