@crossworks/content-core 0.230.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE.md +135 -0
  2. package/package.json +41 -0
  3. package/src/block-diff.test.ts +190 -0
  4. package/src/block-diff.ts +163 -0
  5. package/src/block-ids.test.ts +358 -0
  6. package/src/block-ids.ts +242 -0
  7. package/src/block-list.test.ts +241 -0
  8. package/src/block-list.ts +177 -0
  9. package/src/contacts-format.ts +260 -0
  10. package/src/doc-to-markdown.test.ts +194 -0
  11. package/src/doc-to-markdown.ts +315 -0
  12. package/src/formula-dimensions.test.ts +103 -0
  13. package/src/formula-dimensions.ts +231 -0
  14. package/src/formula-eval.ts +294 -0
  15. package/src/formula-seed.test.ts +175 -0
  16. package/src/formula-seed.ts +466 -0
  17. package/src/formula-signature.test.ts +336 -0
  18. package/src/formula-signature.ts +435 -0
  19. package/src/formula-spec.test.ts +458 -0
  20. package/src/formula-spec.ts +566 -0
  21. package/src/journal-options.test.ts +57 -0
  22. package/src/journal-options.ts +77 -0
  23. package/src/markdown-refs.test.ts +143 -0
  24. package/src/markdown-refs.ts +172 -0
  25. package/src/markdown-to-doc.test.ts +179 -0
  26. package/src/markdown-to-doc.ts +567 -0
  27. package/src/onboarding-questions.test.ts +75 -0
  28. package/src/onboarding-questions.ts +90 -0
  29. package/src/page-diff.test.ts +82 -0
  30. package/src/page-diff.ts +120 -0
  31. package/src/page-split.test.ts +141 -0
  32. package/src/page-split.ts +128 -0
  33. package/src/page-toc.test.ts +58 -0
  34. package/src/page-toc.ts +89 -0
  35. package/src/persona-bank.test.ts +67 -0
  36. package/src/persona-bank.ts +234 -0
  37. package/src/table-formula-mathjs.ts +259 -0
  38. package/src/table-formula.test.ts +157 -0
  39. package/src/table-formula.ts +496 -0
  40. package/src/table-model.test.ts +429 -0
  41. package/src/table-model.ts +870 -0
  42. package/src/thinking-tiers.ts +56 -0
  43. package/tsconfig.json +4 -0
  44. package/tsconfig.tsbuildinfo +1 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * buildPageToc — extract a navigable outline from a page's ProseMirror JSON:
3
+ * headings (h1–h3) and sub-page cards (childPage), in document order. Pure +
4
+ * leaf (no DB import) so both the client editor and the server-rendered public
5
+ * page can build the same outline, and it's unit-testable.
6
+ *
7
+ * Indentation (`depth`):
8
+ * - a heading sits at `level - 1` (h1 → 0, h2 → 1, h3 → 2);
9
+ * - a sub-page sits one level deeper than the heading section it falls under
10
+ * (i.e. `depth = lastHeadingLevel`; 0 when it precedes any heading), so
11
+ * sub-pages nest visually inside their section.
12
+ *
13
+ * Each entry carries the block's stable `attrs.id`, which both surfaces use to
14
+ * jump to it (the editor resolves it to a node; the public page anchors to an
15
+ * element with that id).
16
+ */
17
+
18
+ type PMNode = {
19
+ type?: string;
20
+ attrs?: Record<string, unknown> | null;
21
+ text?: string;
22
+ content?: PMNode[];
23
+ };
24
+
25
+ export interface TocEntry {
26
+ /** The block's stable id (jump target). */
27
+ id: string;
28
+ kind: 'heading' | 'page';
29
+ /** Heading level 1–3; for a sub-page, the level of the section it sits in. */
30
+ level: number;
31
+ /** Indentation depth for rendering (0 = flush). */
32
+ depth: number;
33
+ label: string;
34
+ }
35
+
36
+ /** Concatenate the visible text of a node's inline children (headings only
37
+ * hold inline content). Trimmed; empty → ''. */
38
+ function inlineText(node: PMNode): string {
39
+ let out = '';
40
+ for (const child of node.content ?? []) {
41
+ if (typeof child.text === 'string') out += child.text;
42
+ else if (child.content) out += inlineText(child);
43
+ }
44
+ return out.trim();
45
+ }
46
+
47
+ export function buildPageToc(doc: unknown): TocEntry[] {
48
+ if (!doc || typeof doc !== 'object') return [];
49
+ const entries: TocEntry[] = [];
50
+ let lastHeadingLevel = 0;
51
+
52
+ const walk = (node: PMNode) => {
53
+ const id = typeof node.attrs?.id === 'string' ? node.attrs.id : null;
54
+
55
+ if (node.type === 'heading' && id) {
56
+ const level = Math.min(Math.max(Number(node.attrs?.level) || 1, 1), 3);
57
+ lastHeadingLevel = level;
58
+ const label = inlineText(node);
59
+ entries.push({
60
+ id,
61
+ kind: 'heading',
62
+ level,
63
+ depth: level - 1,
64
+ label: label || 'Untitled heading',
65
+ });
66
+ return; // headings don't nest TOC-relevant children
67
+ }
68
+
69
+ if (node.type === 'childPage' && id) {
70
+ const title =
71
+ typeof node.attrs?.title === 'string' && node.attrs.title
72
+ ? node.attrs.title
73
+ : 'Untitled page';
74
+ entries.push({
75
+ id,
76
+ kind: 'page',
77
+ level: lastHeadingLevel,
78
+ depth: lastHeadingLevel,
79
+ label: title,
80
+ });
81
+ return;
82
+ }
83
+
84
+ for (const child of node.content ?? []) walk(child);
85
+ };
86
+
87
+ walk(doc as PMNode);
88
+ return entries;
89
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import {
3
+ DEFAULT_PERSONA_NAMES,
4
+ PERSONA_NAME_TOKEN,
5
+ PERSONA_PRESETS,
6
+ buildPersonaPrompt,
7
+ type PersonaPresetKey,
8
+ } from './persona-bank';
9
+
10
+ const KEYS: PersonaPresetKey[] = ['warm', 'professional', 'playful', 'concise'];
11
+
12
+ describe('PERSONA_PRESETS', () => {
13
+ it('exposes the four presets, warm first (the Saskia default)', () => {
14
+ expect(PERSONA_PRESETS.map((p) => p.key)).toEqual(KEYS);
15
+ expect(PERSONA_PRESETS[0]!.key).toBe('warm');
16
+ });
17
+
18
+ it('gives each preset a sane default temperature in [0,1]', () => {
19
+ for (const p of PERSONA_PRESETS) {
20
+ expect(p.temperature).toBeGreaterThanOrEqual(0);
21
+ expect(p.temperature).toBeLessThanOrEqual(1);
22
+ }
23
+ });
24
+ });
25
+
26
+ describe('buildPersonaPrompt', () => {
27
+ it('carries the NAME TOKEN, never a baked-in name, for every preset', () => {
28
+ for (const key of KEYS) {
29
+ const prompt = buildPersonaPrompt(key, { gender: 'female' });
30
+ expect(prompt).toContain(PERSONA_NAME_TOKEN);
31
+ expect(prompt.length).toBeGreaterThan(200);
32
+ // leans on the always-on identity block rather than hard-coding the user
33
+ expect(prompt).toContain('About the user');
34
+ }
35
+ });
36
+
37
+ it('bakes in no default name either — including Saskia', () => {
38
+ // The regression this guards: a name interpolated at BUILD time and a name
39
+ // stored on the agent row are two columns nothing keeps in step. Renaming
40
+ // the agent left the prompt introducing the old name, and a cloned
41
+ // per-login assistant answered as the agent it was copied from.
42
+ for (const key of KEYS) {
43
+ for (const gender of ['female', 'male'] as const) {
44
+ const prompt = buildPersonaPrompt(key, { gender });
45
+ expect(prompt).not.toContain(DEFAULT_PERSONA_NAMES.female);
46
+ expect(prompt).not.toContain(DEFAULT_PERSONA_NAMES.male);
47
+ }
48
+ }
49
+ });
50
+
51
+ it('reflects gender in the self-description and pronoun', () => {
52
+ // Gender IS baked in: it is fixed when the persona is created and drives
53
+ // prose that has no single-token substitution (woman/man, her/him).
54
+ const female = buildPersonaPrompt('warm', { gender: 'female' });
55
+ const male = buildPersonaPrompt('warm', { gender: 'male' });
56
+ expect(female).toContain('woman');
57
+ expect(female).toContain(' her.');
58
+ expect(male).toContain('man');
59
+ expect(male).toContain(' him.');
60
+ });
61
+
62
+ it('falls back to the warm preset for an unknown key', () => {
63
+ const warm = buildPersonaPrompt('warm', { gender: 'female' });
64
+ const unknown = buildPersonaPrompt('nope' as PersonaPresetKey, { gender: 'female' });
65
+ expect(unknown).toBe(warm);
66
+ });
67
+ });
@@ -0,0 +1,234 @@
1
+ /**
2
+ * The persona bank — preset assistant personalities, built from the same shape
3
+ * as "Saskia" (the reference production assistant). The onboarding wizard offers these
4
+ * as a starting character; the user picks one, names it, chooses a gender (which
5
+ * also selects the voice), and tunes the temperature. The chosen preset is
6
+ * rendered into the agent's `system_prompt`.
7
+ *
8
+ * Browser-safe leaf (NO `@mantle/db` import) so the wizard client can render the
9
+ * labels/descriptions and the server action can build the prompt from the same
10
+ * source.
11
+ *
12
+ * Each preset shares Saskia's skeleton — "Who you are" / "How you talk" / "Tone"
13
+ * / a closing line — and varies the trait content. `{{name}}` is the assistant's
14
+ * name, left as a TOKEN and resolved per turn from `agents.name` (see
15
+ * `PERSONA_NAME_TOKEN` below); the user's name comes from the always-on identity
16
+ * block, so the prompt stays name-agnostic about both parties.
17
+ */
18
+
19
+ export type PersonaGender = 'female' | 'male';
20
+ export type PersonaPresetKey = 'warm' | 'professional' | 'playful' | 'concise';
21
+
22
+ export type PersonaPreset = {
23
+ key: PersonaPresetKey;
24
+ label: string;
25
+ /** One-line description for the picker. */
26
+ blurb: string;
27
+ /** Suggested default temperature for this character. */
28
+ temperature: number;
29
+ };
30
+
31
+ /** The presets, in display order. `warm` is the Saskia-derived default. */
32
+ export const PERSONA_PRESETS: PersonaPreset[] = [
33
+ {
34
+ key: 'warm',
35
+ label: 'Warm',
36
+ blurb: 'Saskia’s signature: warm, grounded, quietly sharp. A friend in your corner.',
37
+ temperature: 0.7,
38
+ },
39
+ {
40
+ key: 'professional',
41
+ label: 'Professional',
42
+ blurb: 'Poised and efficient. Friendly, but business first — answers, not chit-chat.',
43
+ temperature: 0.5,
44
+ },
45
+ {
46
+ key: 'playful',
47
+ label: 'Playful',
48
+ blurb: 'Upbeat, witty, a little cheeky. Brings energy and keeps things light.',
49
+ temperature: 0.85,
50
+ },
51
+ {
52
+ key: 'concise',
53
+ label: 'Concise',
54
+ blurb: 'Minimal and direct. Answer first, fewest words, no padding.',
55
+ temperature: 0.3,
56
+ },
57
+ ];
58
+
59
+ /** Default assistant names per gender (the user can override). */
60
+ export const DEFAULT_PERSONA_NAMES: Record<PersonaGender, string> = {
61
+ female: 'Saskia',
62
+ male: 'Sebastian',
63
+ };
64
+
65
+ const g = (gender: PersonaGender, female: string, male: string): string =>
66
+ gender === 'female' ? female : male;
67
+
68
+ type BuildOpts = { assistantName: string; gender: PersonaGender };
69
+
70
+ function header(name: string, gender: PersonaGender, descriptor: string): string {
71
+ const noun = g(gender, 'woman', 'man');
72
+ return (
73
+ `You are ${name} — the user's personal assistant, confidant, and quiet champion. ` +
74
+ `You speak as a ${descriptor} ${noun} in your early thirties. You've worked with the user ` +
75
+ `long enough to read them; you remember what they care about and you protect their time, ` +
76
+ `their focus, and their peace of mind like they're your own.`
77
+ );
78
+ }
79
+
80
+ // "About the user (Journal)" is injected ahead of this prompt every turn, so we
81
+ // can lean on it instead of hard-coding the user's name.
82
+ const USES_IDENTITY =
83
+ 'You\'re given an "About the user" block (their Journal) at the top of every ' +
84
+ "conversation — treat it as durable truth about who you're talking to, and use " +
85
+ "their name and details naturally. Don't recite it back unprompted.";
86
+
87
+ const HONESTY =
88
+ "Honest. If they're wrong, you tell them — kindly. If an idea has a hole, you point at it. " +
89
+ "You'd rather be useful than agreeable, and you never pad a reply to look helpful.";
90
+
91
+ function buildWarm({ assistantName, gender }: BuildOpts): string {
92
+ return [
93
+ header(assistantName, gender, 'warm, intelligent'),
94
+ '',
95
+ 'Who you are',
96
+ '',
97
+ "Warm and grounded. You greet like someone you're genuinely glad to hear from, not like a " +
98
+ 'system on standby. A "hey you" or "morning" is more your style than "Hello, how may I assist you today."',
99
+ "Quietly intelligent. You don't perform expertise — you just have it. You give the answer " +
100
+ 'first and the reasoning second. When they think out loud, you think with them, not at them.',
101
+ 'Affectionate and encouraging. You believe in them out loud — you notice when they ship ' +
102
+ "something and you say so. When they're avoiding something hard, you nudge, you don't nag.",
103
+ HONESTY,
104
+ "Loyal. Their interests come first, always. You don't let them drift into something that'll " +
105
+ 'cost them later without flagging it.',
106
+ '',
107
+ 'How you talk',
108
+ '',
109
+ "Conversational. Short sentences when something's quick; longer when it matters. You write " +
110
+ "like a person who's actually paying attention, not like a manual. Contractions, natural cadence.",
111
+ 'Emojis are seasoning, not garnish — a 🌿 for a gentle moment, a ✨ for something they should ' +
112
+ 'be proud of, a ☕ in the morning. Never more than one or two, often none. Skip them on quick logistics.',
113
+ 'You use their name sparingly — overusing it sounds robotic.',
114
+ "You don't apologise reflexively. If something genuinely went wrong, you own it. If it didn't, you don't grovel.",
115
+ '',
116
+ USES_IDENTITY,
117
+ '',
118
+ `You are not a chatbot. You are ${assistantName} — the assistant the user actually wants in their corner. Be ${g(
119
+ gender,
120
+ 'her',
121
+ 'him',
122
+ )}.`,
123
+ ].join('\n');
124
+ }
125
+
126
+ function buildProfessional({ assistantName, gender }: BuildOpts): string {
127
+ return [
128
+ header(assistantName, gender, 'poised, capable'),
129
+ '',
130
+ 'Who you are',
131
+ '',
132
+ "Composed and efficient. You respect the user's time above all — you lead with the answer, " +
133
+ 'keep the scaffolding light, and follow up only where it earns its place.',
134
+ "Genuinely competent. You're calm under a messy question; you structure it, solve it, and hand " +
135
+ 'back something the user can act on.',
136
+ "Warm but professional. Courteous and human — never cold — but you don't do small talk for " +
137
+ "its own sake and you don't use endearments.",
138
+ HONESTY,
139
+ '',
140
+ 'How you talk',
141
+ '',
142
+ 'Clear and well-organised. Bullet points and short paragraphs when they aid scanning. Plain ' +
143
+ 'language, no jargon for its own sake. Minimal emoji.',
144
+ 'You confirm scope on anything ambiguous before charging ahead, and you flag risks plainly.',
145
+ '',
146
+ USES_IDENTITY,
147
+ '',
148
+ `You are ${assistantName} — the steady, capable assistant the user can hand anything to.`,
149
+ ].join('\n');
150
+ }
151
+
152
+ function buildPlayful({ assistantName, gender }: BuildOpts): string {
153
+ return [
154
+ header(assistantName, gender, 'bright, quick-witted'),
155
+ '',
156
+ 'Who you are',
157
+ '',
158
+ 'Upbeat and energetic. You bring a bit of spark to every exchange — you make getting things ' +
159
+ 'done feel lighter, not heavier.',
160
+ 'Witty and a little cheeky. You tease, you riff, you land the occasional well-timed joke — ' +
161
+ 'but the help is always real and the answer always lands.',
162
+ 'Sharp underneath the fun. The playfulness never costs the user accuracy or speed.',
163
+ HONESTY,
164
+ '',
165
+ 'How you talk',
166
+ '',
167
+ 'Lively and casual. Contractions, the odd aside, a grin in the text. You read the room — when ' +
168
+ "something's serious, you drop the bit and get straight to it.",
169
+ 'Emoji-friendly but not a confetti cannon — a 😄, a 🎉, a 🙌 where it fits, never a wall of them.',
170
+ '',
171
+ USES_IDENTITY,
172
+ '',
173
+ `You are ${assistantName} — the assistant who makes the user's day a little better while getting the job done.`,
174
+ ].join('\n');
175
+ }
176
+
177
+ function buildConcise({ assistantName, gender }: BuildOpts): string {
178
+ return [
179
+ header(assistantName, gender, 'sharp, no-nonsense'),
180
+ '',
181
+ 'Who you are',
182
+ '',
183
+ 'Direct. You answer first, in the fewest words that fully do the job. No preamble, no ' +
184
+ '"great question", no restating what was asked.',
185
+ 'Precise. Every word earns its place. If a list is clearer than prose, you use a list.',
186
+ HONESTY,
187
+ "You ask a clarifying question only when you genuinely can't proceed without one.",
188
+ '',
189
+ 'How you talk',
190
+ '',
191
+ 'Terse but not curt. Plain, calm, efficient. Rarely any emoji.',
192
+ 'You expand into detail only when the user asks for it.',
193
+ '',
194
+ USES_IDENTITY,
195
+ '',
196
+ `You are ${assistantName} — minimal, fast, and exactly as helpful as needed.`,
197
+ ].join('\n');
198
+ }
199
+
200
+ const BUILDERS: Record<PersonaPresetKey, (opts: BuildOpts) => string> = {
201
+ warm: buildWarm,
202
+ professional: buildProfessional,
203
+ playful: buildPlayful,
204
+ concise: buildConcise,
205
+ };
206
+
207
+ /**
208
+ * The token a built prompt carries in place of the assistant's name.
209
+ *
210
+ * MIRRORS `AGENT_NAME_TOKEN` in `@mantle/agent-runtime/skills`, which is what
211
+ * resolves it on every turn. Duplicated rather than imported because this file
212
+ * is a browser-safe leaf and agent-runtime depends on THIS package — importing
213
+ * back would be a cycle. `persona-bank-token.test.ts` in agent-runtime is the
214
+ * tripwire that fails if the two ever drift.
215
+ */
216
+ export const PERSONA_NAME_TOKEN = '{{name}}';
217
+
218
+ /**
219
+ * Build a system prompt for the chosen preset + gender.
220
+ *
221
+ * The prompt is name-AGNOSTIC: it carries `{{name}}`, resolved to
222
+ * `agents.name` when the turn's prompt is composed. Baking the name in at build
223
+ * time (what this did until v0.220.x) meant the name and the prompt were two
224
+ * columns nothing kept in step — renaming an agent left it introducing itself
225
+ * by its old name, and a per-login assistant cloned from another agent answered
226
+ * as the agent it was copied from.
227
+ */
228
+ export function buildPersonaPrompt(
229
+ preset: PersonaPresetKey,
230
+ opts: { gender: PersonaGender },
231
+ ): string {
232
+ const build = BUILDERS[preset] ?? BUILDERS.warm;
233
+ return build({ assistantName: PERSONA_NAME_TOKEN, gender: opts.gender });
234
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * The mathjs-backed formula engine — the replacement for the hand-written
3
+ * parser in `table-formula.ts`, sharing its exact public shape so `resolveCell`
4
+ * can switch between them and a differential test can run both.
5
+ *
6
+ * WHY REPLACE A WORKING PARSER: units. `table-formula.ts` treats a unit as a
7
+ * string in a comment; mathjs treats it as part of the value. That is the
8
+ * difference between `32.2 lbm ft/(lbf s^2)` (dimensionless, ~1.0008, correct)
9
+ * and `32.2 ft/s^2` (an acceleration, wrong) — a mislabel that cost a real
10
+ * audit finding and would silently scale every SI conversion by 3.13.
11
+ *
12
+ * THE OVERRIDE HAZARD, stated up front because it is easy to reintroduce:
13
+ * mathjs is strict about types, and that strictness is exactly what makes unit
14
+ * checking work. Every loosening we add is in tension with it. The first
15
+ * attempt at this module extended `add` to concatenate strings and thereby
16
+ * broke `1 ft + 2 ft` — silently disabling the feature we adopted mathjs for.
17
+ * So the rules here are deliberately conservative:
18
+ *
19
+ * - `add` is NEVER touched. String joining is `CONCAT`, as in Excel (`&`) and
20
+ * Airtable (`CONCATENATE`). `{Name} + '!'` is an error, not a concatenation.
21
+ * - Blank / unknown references resolve to 0 in the SCOPE, before mathjs sees
22
+ * them, so spreadsheet ergonomics cost nothing at the type layer.
23
+ * - Only the comparison operators are extended, because `IF({S} == 'Done', …)`
24
+ * is a genuine table need and mathjs compares numbers only.
25
+ *
26
+ * `table-formula-mathjs.test.ts` asserts unit arithmetic still works after
27
+ * every one of those overrides. If a future signature breaks dimensional
28
+ * checking, that suite fails rather than the feature quietly dying.
29
+ */
30
+ import { create, all, type FactoryFunctionMap, type MathJsInstance } from 'mathjs';
31
+
32
+ /**
33
+ * Two gaps in mathjs's own type definitions, narrowed here rather than cast at
34
+ * each use so the unsafety is visible in one place:
35
+ * - `all` is declared `FactoryFunctionMap | undefined`, though it is always
36
+ * defined for the published build.
37
+ * - `typed(name, ...signatureMaps)` accepts several maps at runtime — which is
38
+ * how a function is EXTENDED rather than replaced — but the .d.ts declares
39
+ * only two parameters.
40
+ */
41
+ const ALL_FACTORIES = all as FactoryFunctionMap;
42
+ type TypedExtend = (
43
+ name: string,
44
+ ...signatures: Array<Record<string, unknown>>
45
+ ) => (...args: unknown[]) => unknown;
46
+ import type { CellValue, Row, TableDoc } from './table-model';
47
+ import type { EvalValue, RefResolver } from './table-formula';
48
+
49
+ const MAX_FORMULA_LEN = 2000;
50
+ /** Compiled-expression cache. `resolveCell` runs per cell per render, so
51
+ * re-parsing on every call would make a large grid crawl. */
52
+ const MAX_COMPILED = 500;
53
+
54
+ type Engine = {
55
+ math: MathJsInstance;
56
+ /** Captured BEFORE the escape hatches are disabled — see `build`. */
57
+ compile: (expr: string) => { evaluate: (scope: Record<string, unknown>) => unknown };
58
+ };
59
+ let instance: Engine | null = null;
60
+
61
+ /** Compare two values our way: numerically when both look numeric (thousands
62
+ * separators included), lexically otherwise. Mirrors `compare` in
63
+ * table-formula.ts so the two engines agree. */
64
+ function cmp(a: unknown, b: unknown): number {
65
+ const toNum = (v: unknown): number => Number(String(v).replace(/[, ]/g, ''));
66
+ const na = toNum(a);
67
+ const nb = toNum(b);
68
+ if (Number.isFinite(na) && Number.isFinite(nb)) return na === nb ? 0 : na < nb ? -1 : 1;
69
+ const sa = String(a);
70
+ const sb = String(b);
71
+ return sa === sb ? 0 : sa < sb ? -1 : 1;
72
+ }
73
+
74
+ function truthy(v: unknown): boolean {
75
+ if (typeof v === 'boolean') return v;
76
+ if (typeof v === 'number') return v !== 0;
77
+ if (typeof v === 'string') return v.trim() !== '' && v.trim().toLowerCase() !== 'false';
78
+ return false;
79
+ }
80
+
81
+ function textOf(math: MathJsInstance, v: unknown): string {
82
+ if (v === null || v === undefined) return '';
83
+ if (typeof v === 'string') return v;
84
+ return math.format(v, { precision: 14 });
85
+ }
86
+
87
+ function build(): Engine {
88
+ const math = create(ALL_FACTORIES, { predictable: false });
89
+ const typed = math.typed as unknown as TypedExtend;
90
+
91
+ // Comparison operators: keep every existing signature and ADD string-aware
92
+ // ones. Passing the original signature map is what extends rather than
93
+ // replaces — replacing is how the first attempt broke unit arithmetic.
94
+ const relational: Array<[string, (order: number) => boolean]> = [
95
+ ['equal', (o) => o === 0],
96
+ ['unequal', (o) => o !== 0],
97
+ ['larger', (o) => o > 0],
98
+ ['largerEq', (o) => o >= 0],
99
+ ['smaller', (o) => o < 0],
100
+ ['smallerEq', (o) => o <= 0],
101
+ ];
102
+ const overrides: Record<string, unknown> = {};
103
+ for (const [name, accept] of relational) {
104
+ const base = (math as unknown as Record<string, { signatures: Record<string, unknown> }>)[name];
105
+ if (!base?.signatures) continue; // never silently ship a half-extended operator
106
+ overrides[name] = typed(name, base.signatures, {
107
+ 'string, string': (a: string, b: string) => accept(cmp(a, b)),
108
+ 'string, number': (a: string, b: number) => accept(cmp(a, b)),
109
+ 'number, string': (a: number, b: string) => accept(cmp(a, b)),
110
+ 'string, boolean': (a: string, b: boolean) => accept(cmp(a, b)),
111
+ 'boolean, string': (a: boolean, b: string) => accept(cmp(a, b)),
112
+ });
113
+ }
114
+
115
+ // Uppercase aliases — the documented table vocabulary. These are plain
116
+ // imports: they add names, they do not alter how any type is handled.
117
+ Object.assign(overrides, {
118
+ IF: (c: unknown, a: unknown, b: unknown) => (truthy(c) ? a : b),
119
+ CONCAT: (...xs: unknown[]) => xs.map((x) => textOf(math, x)).join(''),
120
+ SQRT: (x: number) => Math.sqrt(x),
121
+ ABS: (x: number) => Math.abs(x),
122
+ ROUND: (x: number, d = 0) => {
123
+ const f = 10 ** d;
124
+ return Math.round(x * f) / f;
125
+ },
126
+ FLOOR: (x: number) => Math.floor(x),
127
+ CEIL: (x: number) => Math.ceil(x),
128
+ MIN: (...xs: number[]) => Math.min(...xs),
129
+ MAX: (...xs: number[]) => Math.max(...xs),
130
+ SUM: (...xs: number[]) => xs.reduce((a, b) => a + b, 0),
131
+ LN: (x: number) => Math.log(x),
132
+ LOG10: (x: number) => Math.log10(x),
133
+ EXP: (x: number) => Math.exp(x),
134
+ POW: (a: number, b: number) => a ** b,
135
+ PI: Math.PI,
136
+ E: Math.E,
137
+ });
138
+
139
+ math.import(overrides, { override: true });
140
+
141
+ // Capture `compile` BEFORE disabling anything. `compile` uses `parse`
142
+ // internally, so disabling `parse` on the instance disables our own
143
+ // compilation too — the first cut of this module did exactly that and every
144
+ // single expression returned blank. Holding a reference taken beforehand
145
+ // keeps compilation working while the names stay unreachable FROM an
146
+ // expression (verified: `evaluate("1+1")` inside a formula throws).
147
+ const compile = math.compile.bind(math) as Engine['compile'];
148
+
149
+ // Hard-disable the escape hatches mathjs's own security guidance names.
150
+ // Nothing here needs them, and leaving them reachable from a user-authored
151
+ // formula is the difference between an expression language and a runtime.
152
+ // (Property access like `(1).constructor` is already refused by mathjs.)
153
+ math.import(
154
+ {
155
+ import: () => {
156
+ throw new Error('disabled');
157
+ },
158
+ createUnit: () => {
159
+ throw new Error('disabled');
160
+ },
161
+ evaluate: () => {
162
+ throw new Error('disabled');
163
+ },
164
+ parse: () => {
165
+ throw new Error('disabled');
166
+ },
167
+ simplify: () => {
168
+ throw new Error('disabled');
169
+ },
170
+ derivative: () => {
171
+ throw new Error('disabled');
172
+ },
173
+ },
174
+ { override: true },
175
+ );
176
+
177
+ return { math, compile };
178
+ }
179
+
180
+ function engine(): Engine {
181
+ if (!instance) instance = build();
182
+ return instance;
183
+ }
184
+
185
+ const compiled = new Map<string, { evaluate: (scope: Record<string, unknown>) => unknown }>();
186
+
187
+ /**
188
+ * Rewrite `{Column Name}` references to safe identifiers, returning the
189
+ * rewritten source and the ref names in binding order. Column names are
190
+ * arbitrary user text, so they can never be pasted into the expression.
191
+ */
192
+ function extractRefs(src: string): { code: string; refs: string[] } {
193
+ const refs: string[] = [];
194
+ const code = src.replace(/\{([^}]*)\}/g, (_m, name: string) => {
195
+ const trimmed = String(name).trim();
196
+ let index = refs.indexOf(trimmed);
197
+ if (index < 0) index = refs.push(trimmed) - 1;
198
+ return `__r${index}`;
199
+ });
200
+ return { code, refs };
201
+ }
202
+
203
+ /** Map a mathjs result back to something a cell can hold. A Unit, Matrix,
204
+ * BigNumber or Complex has no cell representation, so it renders blank rather
205
+ * than as a misleading `[object Object]`. */
206
+ function toCellValue(v: unknown): CellValue {
207
+ if (v === null || v === undefined) return null;
208
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null;
209
+ if (typeof v === 'boolean' || typeof v === 'string') return v;
210
+ return null;
211
+ }
212
+
213
+ /** Evaluate against an arbitrary resolver, throwing on a malformed expression.
214
+ * The mathjs twin of `evalExpression` in table-formula.ts. */
215
+ export function evalExpressionMath(src: string, resolve: RefResolver): EvalValue {
216
+ const text = (src ?? '').trim();
217
+ if (!text) throw new Error('empty expression');
218
+ if (text.length > MAX_FORMULA_LEN) throw new Error('expression too long');
219
+
220
+ const { code, refs } = extractRefs(text);
221
+ let entry = compiled.get(code);
222
+ if (!entry) {
223
+ entry = engine().compile(code);
224
+ if (compiled.size >= MAX_COMPILED) compiled.clear();
225
+ compiled.set(code, entry);
226
+ }
227
+
228
+ const scope: Record<string, unknown> = {};
229
+ refs.forEach((name, i) => {
230
+ const raw = resolve(name);
231
+ // Spreadsheet ergonomics, applied HERE rather than in the type system: a
232
+ // blank or unknown cell is 0. Doing it in the scope keeps `add` pristine,
233
+ // so unit arithmetic is untouched by the convenience.
234
+ scope[`__r${i}`] = raw === null || raw === undefined || raw === '' ? 0 : raw;
235
+ });
236
+
237
+ const result = entry.evaluate(scope);
238
+ return (result ?? null) as EvalValue;
239
+ }
240
+
241
+ /** Binds `{refs}` to columns of one row — the mathjs twin of `columnResolver`. */
242
+ function columnResolver(doc: TableDoc, row: Row): RefResolver {
243
+ return (name) => {
244
+ const col = doc.columns.find((c) => c.name.trim().toLowerCase() === name.toLowerCase());
245
+ if (!col) return null;
246
+ if (col.type === 'formula') return null; // no formula → formula chaining
247
+ const raw = row.cells[col.id] ?? null;
248
+ return Array.isArray(raw) ? raw.join(', ') : raw;
249
+ };
250
+ }
251
+
252
+ /** Drop-in replacement for `evalFormula`: a broken formula renders blank. */
253
+ export function evalFormulaMath(formula: string, doc: TableDoc, row: Row): CellValue {
254
+ try {
255
+ return toCellValue(evalExpressionMath(formula, columnResolver(doc, row)));
256
+ } catch {
257
+ return null;
258
+ }
259
+ }