prepia 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +312 -0
  3. package/bin/prepia.mjs +119 -0
  4. package/package.json +53 -0
  5. package/skill/SKILL.md +148 -0
  6. package/skill/config.json +29 -0
  7. package/src/analytics/dashboard.mjs +84 -0
  8. package/src/analytics/tracker.mjs +131 -0
  9. package/src/api/middleware.mjs +219 -0
  10. package/src/api/routes.mjs +142 -0
  11. package/src/api/server.mjs +150 -0
  12. package/src/cache/disk-store.mjs +199 -0
  13. package/src/cache/manager.mjs +142 -0
  14. package/src/cache/memory-store.mjs +205 -0
  15. package/src/chain/dag.mjs +209 -0
  16. package/src/chain/executor.mjs +103 -0
  17. package/src/chain/scheduler.mjs +89 -0
  18. package/src/client/adapters.mjs +483 -0
  19. package/src/client/connector.mjs +391 -0
  20. package/src/client/index.mjs +483 -0
  21. package/src/client/websocket.mjs +353 -0
  22. package/src/core/context-packager.mjs +169 -0
  23. package/src/core/engine.mjs +338 -0
  24. package/src/core/event-bus.mjs +84 -0
  25. package/src/core/prepimshot.mjs +120 -0
  26. package/src/core/task-decomposer.mjs +158 -0
  27. package/src/edge/lite.mjs +90 -0
  28. package/src/guard/checker.mjs +123 -0
  29. package/src/guard/fact-checker.mjs +105 -0
  30. package/src/guard/hallucination.mjs +108 -0
  31. package/src/index.mjs +67 -0
  32. package/src/models/local-model.mjs +171 -0
  33. package/src/models/provider.mjs +192 -0
  34. package/src/models/router.mjs +156 -0
  35. package/src/morph/optimizer.mjs +142 -0
  36. package/src/network/p2p.mjs +146 -0
  37. package/src/persona/detector.mjs +118 -0
  38. package/src/plugins/loader.mjs +120 -0
  39. package/src/plugins/registry.mjs +164 -0
  40. package/src/plugins/sandbox.mjs +79 -0
  41. package/src/rate/limiter.mjs +145 -0
  42. package/src/rate/shield.mjs +150 -0
  43. package/src/script/executor.mjs +164 -0
  44. package/src/script/parser.mjs +134 -0
  45. package/src/security/privacy.mjs +108 -0
  46. package/src/security/sanitizer.mjs +133 -0
  47. package/src/shadow/daemon.mjs +128 -0
  48. package/src/stream/handler.mjs +204 -0
  49. package/src/tools/calculator.mjs +312 -0
  50. package/src/tools/file-ops.mjs +138 -0
  51. package/src/tools/http-client.mjs +127 -0
  52. package/src/tools/orchestrator.mjs +205 -0
  53. package/src/tools/web-scraper.mjs +159 -0
  54. package/src/tools/web-search.mjs +129 -0
  55. package/src/vault/knowledge-base.mjs +207 -0
  56. package/src/vault/pattern-learner.mjs +192 -0
  57. package/workflows/analyze.json +32 -0
  58. package/workflows/automate.json +32 -0
  59. package/workflows/research.json +37 -0
  60. package/workflows/summarize.json +32 -0
@@ -0,0 +1,128 @@
1
+ /**
2
+ * @fileoverview Background task daemon for periodic operations.
3
+ * @module shadow/daemon
4
+ */
5
+
6
+ import { EventEmitter } from 'node:events';
7
+
8
+ export class Daemon extends EventEmitter {
9
+ constructor() {
10
+ super();
11
+ /** @type {Map<string, Object>} */
12
+ this._jobs = new Map();
13
+ this._running = false;
14
+ }
15
+
16
+ /**
17
+ * Register a periodic job.
18
+ * @param {string} name - Job name
19
+ * @param {Function} fn - Async function to execute
20
+ * @param {number} intervalMs - Interval in ms
21
+ */
22
+ register(name, fn, intervalMs) {
23
+ if (this._jobs.has(name)) {
24
+ this.unregister(name);
25
+ }
26
+ this._jobs.set(name, {
27
+ name,
28
+ fn,
29
+ intervalMs,
30
+ intervalId: null,
31
+ lastRun: null,
32
+ lastError: null,
33
+ runCount: 0,
34
+ });
35
+ }
36
+
37
+ /**
38
+ * Unregister a job.
39
+ * @param {string} name
40
+ */
41
+ unregister(name) {
42
+ const job = this._jobs.get(name);
43
+ if (job?.intervalId) {
44
+ clearInterval(job.intervalId);
45
+ }
46
+ this._jobs.delete(name);
47
+ }
48
+
49
+ /**
50
+ * Start all registered jobs.
51
+ */
52
+ start() {
53
+ this._running = true;
54
+ for (const [name, job] of this._jobs) {
55
+ if (job.intervalId) continue;
56
+ job.intervalId = setInterval(async () => {
57
+ try {
58
+ this.emit('job:start', { name });
59
+ await job.fn();
60
+ job.lastRun = Date.now();
61
+ job.runCount++;
62
+ this.emit('job:complete', { name, runCount: job.runCount });
63
+ } catch (err) {
64
+ job.lastError = err.message;
65
+ this.emit('job:error', { name, error: err.message });
66
+ }
67
+ }, job.intervalMs);
68
+ // Run immediately on start
69
+ job.fn().then(() => {
70
+ job.lastRun = Date.now();
71
+ job.runCount++;
72
+ }).catch(err => {
73
+ job.lastError = err.message;
74
+ });
75
+ }
76
+ }
77
+
78
+ /**
79
+ * Stop all jobs.
80
+ */
81
+ stop() {
82
+ this._running = false;
83
+ for (const job of this._jobs.values()) {
84
+ if (job.intervalId) {
85
+ clearInterval(job.intervalId);
86
+ job.intervalId = null;
87
+ }
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Get status of all jobs.
93
+ * @returns {Object[]}
94
+ */
95
+ status() {
96
+ return Array.from(this._jobs.values()).map(j => ({
97
+ name: j.name,
98
+ intervalMs: j.intervalMs,
99
+ lastRun: j.lastRun,
100
+ lastError: j.lastError,
101
+ runCount: j.runCount,
102
+ running: j.intervalId !== null,
103
+ }));
104
+ }
105
+
106
+ /**
107
+ * Run a specific job immediately.
108
+ * @param {string} name
109
+ * @returns {Promise<void>}
110
+ */
111
+ async runNow(name) {
112
+ const job = this._jobs.get(name);
113
+ if (!job) throw new Error(`Job "${name}" not found`);
114
+ await job.fn();
115
+ job.lastRun = Date.now();
116
+ job.runCount++;
117
+ }
118
+
119
+ /**
120
+ * Check if daemon is running.
121
+ * @returns {boolean}
122
+ */
123
+ get isRunning() {
124
+ return this._running;
125
+ }
126
+ }
127
+
128
+ export default Daemon;
@@ -0,0 +1,204 @@
1
+ /**
2
+ * @fileoverview Real-time streaming progress handler.
3
+ * @module stream/handler
4
+ */
5
+
6
+ import { EventEmitter } from 'node:events';
7
+
8
+ /**
9
+ * @typedef {Object} ProgressUpdate
10
+ * @property {string} taskId - Task ID
11
+ * @property {string} phase - Current phase
12
+ * @property {number} progress - Progress (0-1)
13
+ * @property {string} message - Human-readable status
14
+ * @property {*} [data] - Phase-specific data
15
+ */
16
+
17
+ /** Standard processing phases */
18
+ export const Phases = {
19
+ RECEIVED: 'received',
20
+ ANALYZING: 'analyzing',
21
+ SEARCHING: 'searching',
22
+ EXTRACTING: 'extracting',
23
+ PROCESSING: 'processing',
24
+ SYNTHESIZING: 'synthesizing',
25
+ VERIFYING: 'verifying',
26
+ COMPLETE: 'complete',
27
+ ERROR: 'error',
28
+ };
29
+
30
+ /** Phase order for progress calculation */
31
+ const PHASE_ORDER = [
32
+ Phases.RECEIVED,
33
+ Phases.ANALYZING,
34
+ Phases.SEARCHING,
35
+ Phases.EXTRACTING,
36
+ Phases.PROCESSING,
37
+ Phases.SYNTHESIZING,
38
+ Phases.VERIFYING,
39
+ Phases.COMPLETE,
40
+ ];
41
+
42
+ export class StreamHandler extends EventEmitter {
43
+ constructor() {
44
+ super();
45
+ /** @type {Map<string, Object>} Active task states */
46
+ this._tasks = new Map();
47
+ }
48
+
49
+ /**
50
+ * Start tracking a task.
51
+ * @param {string} taskId
52
+ * @param {string} [description] - Task description
53
+ */
54
+ start(taskId, description = '') {
55
+ this._tasks.set(taskId, {
56
+ id: taskId,
57
+ description,
58
+ phase: Phases.RECEIVED,
59
+ progress: 0,
60
+ startTime: Date.now(),
61
+ cancelled: false,
62
+ });
63
+ this.emit('progress', {
64
+ taskId,
65
+ phase: Phases.RECEIVED,
66
+ progress: 0,
67
+ message: 'Task received',
68
+ });
69
+ }
70
+
71
+ /**
72
+ * Update the phase of a task.
73
+ * @param {string} taskId
74
+ * @param {string} phase
75
+ * @param {string} [message] - Status message
76
+ * @param {*} [data] - Phase-specific data
77
+ */
78
+ update(taskId, phase, message = '', data = undefined) {
79
+ const task = this._tasks.get(taskId);
80
+ if (!task || task.cancelled) return;
81
+
82
+ task.phase = phase;
83
+ const phaseIdx = PHASE_ORDER.indexOf(phase);
84
+ task.progress = phaseIdx >= 0 ? (phaseIdx + 1) / PHASE_ORDER.length : task.progress;
85
+
86
+ const update = {
87
+ taskId,
88
+ phase,
89
+ progress: task.progress,
90
+ message: message || phase,
91
+ data,
92
+ };
93
+
94
+ this.emit('progress', update);
95
+ }
96
+
97
+ /**
98
+ * Mark a task as complete.
99
+ * @param {string} taskId
100
+ * @param {*} result - Task result
101
+ */
102
+ complete(taskId, result = undefined) {
103
+ const task = this._tasks.get(taskId);
104
+ if (!task) return;
105
+
106
+ task.phase = Phases.COMPLETE;
107
+ task.progress = 1;
108
+ task.endTime = Date.now();
109
+
110
+ this.emit('progress', {
111
+ taskId,
112
+ phase: Phases.COMPLETE,
113
+ progress: 1,
114
+ message: 'Task complete',
115
+ data: { result, duration: task.endTime - task.startTime },
116
+ });
117
+
118
+ this.emit('complete', { taskId, result, duration: task.endTime - task.startTime });
119
+ }
120
+
121
+ /**
122
+ * Mark a task as errored.
123
+ * @param {string} taskId
124
+ * @param {string} error - Error message
125
+ */
126
+ error(taskId, error) {
127
+ const task = this._tasks.get(taskId);
128
+ if (!task) return;
129
+
130
+ task.phase = Phases.ERROR;
131
+ task.endTime = Date.now();
132
+
133
+ this.emit('progress', {
134
+ taskId,
135
+ phase: Phases.ERROR,
136
+ progress: task.progress,
137
+ message: `Error: ${error}`,
138
+ });
139
+
140
+ this.emit('error', { taskId, error, duration: task.endTime - task.startTime });
141
+ }
142
+
143
+ /**
144
+ * Cancel a task.
145
+ * @param {string} taskId
146
+ * @returns {boolean}
147
+ */
148
+ cancel(taskId) {
149
+ const task = this._tasks.get(taskId);
150
+ if (!task) return false;
151
+ task.cancelled = true;
152
+ task.endTime = Date.now();
153
+ this.emit('cancel', { taskId });
154
+ return true;
155
+ }
156
+
157
+ /**
158
+ * Check if a task is cancelled.
159
+ * @param {string} taskId
160
+ * @returns {boolean}
161
+ */
162
+ isCancelled(taskId) {
163
+ return this._tasks.get(taskId)?.cancelled ?? false;
164
+ }
165
+
166
+ /**
167
+ * Get the state of a task.
168
+ * @param {string} taskId
169
+ * @returns {Object|undefined}
170
+ */
171
+ getState(taskId) {
172
+ const task = this._tasks.get(taskId);
173
+ if (!task) return undefined;
174
+ return {
175
+ ...task,
176
+ elapsed: (task.endTime || Date.now()) - task.startTime,
177
+ };
178
+ }
179
+
180
+ /**
181
+ * Get all active tasks.
182
+ * @returns {Object[]}
183
+ */
184
+ getActive() {
185
+ return Array.from(this._tasks.values())
186
+ .filter(t => !t.cancelled && t.phase !== Phases.COMPLETE && t.phase !== Phases.ERROR)
187
+ .map(t => ({ ...t, elapsed: Date.now() - t.startTime }));
188
+ }
189
+
190
+ /**
191
+ * Clean up completed/errored tasks.
192
+ * @param {number} [maxAge=300000] - Max age in ms (5 min)
193
+ */
194
+ cleanup(maxAge = 300000) {
195
+ const now = Date.now();
196
+ for (const [id, task] of this._tasks) {
197
+ if (task.endTime && (now - task.endTime) > maxAge) {
198
+ this._tasks.delete(id);
199
+ }
200
+ }
201
+ }
202
+ }
203
+
204
+ export default StreamHandler;
@@ -0,0 +1,312 @@
1
+ /**
2
+ * @fileoverview Safe math expression evaluator without eval().
3
+ * Supports arithmetic, percentages, unit conversions, and common math functions.
4
+ * @module tools/calculator
5
+ */
6
+
7
+ /**
8
+ * Unit conversion factors (all relative to base unit).
9
+ */
10
+ const UNIT_FACTORS = {
11
+ // Length (base: meters)
12
+ 'mm': 0.001, 'cm': 0.01, 'm': 1, 'km': 1000,
13
+ 'in': 0.0254, 'ft': 0.3048, 'yd': 0.9144, 'mi': 1609.344,
14
+ // Weight (base: grams)
15
+ 'mg': 0.001, 'g': 1, 'kg': 1000,
16
+ 'oz': 28.3495, 'lb': 453.592, 'ton': 907185,
17
+ // Volume (base: liters)
18
+ 'ml': 0.001, 'l': 1, 'gal': 3.78541, 'qt': 0.946353, 'pt': 0.473176,
19
+ // Temperature handled separately
20
+ // Time (base: seconds)
21
+ 'ms': 0.001, 's': 1, 'min': 60, 'hr': 3600, 'day': 86400,
22
+ // Data (base: bytes)
23
+ 'b': 1, 'kb': 1024, 'mb': 1048576, 'gb': 1073741824, 'tb': 1099511627776,
24
+ };
25
+
26
+ /**
27
+ * Temperature conversion units.
28
+ */
29
+ const TEMP_UNITS = ['c', 'f', 'k', 'celsius', 'fahrenheit', 'kelvin'];
30
+
31
+ /**
32
+ * Evaluate a mathematical expression safely.
33
+ * @param {string} expression - Math expression to evaluate
34
+ * @returns {Object} Result with value and metadata
35
+ */
36
+ export function evaluate(expression) {
37
+ if (!expression || typeof expression !== 'string') {
38
+ throw new Error('Expression must be a non-empty string');
39
+ }
40
+
41
+ const cleaned = expression.trim().toLowerCase();
42
+
43
+ // Check for unit conversion: "100 km to miles"
44
+ const convertMatch = cleaned.match(
45
+ /^([\d.]+)\s*([a-z]+)\s+(?:to|in|as)\s+([a-z]+)$/
46
+ );
47
+ if (convertMatch) {
48
+ return convertUnit(parseFloat(convertMatch[1]), convertMatch[2], convertMatch[3]);
49
+ }
50
+
51
+ // Check for percentage: "20% of 150"
52
+ const pctMatch = cleaned.match(/^([\d.]+)\s*%\s*(?:of|off)?\s*([\d.]+)$/);
53
+ if (pctMatch) {
54
+ const value = (parseFloat(pctMatch[1]) / 100) * parseFloat(pctMatch[2]);
55
+ return { value, expression: cleaned, type: 'percentage' };
56
+ }
57
+
58
+ // Parse and evaluate arithmetic expression
59
+ const value = parseExpression(cleaned);
60
+ return { value, expression: cleaned, type: 'arithmetic' };
61
+ }
62
+
63
+ /**
64
+ * Convert between units.
65
+ * @param {number} value
66
+ * @param {string} fromUnit
67
+ * @param {string} toUnit
68
+ * @returns {Object}
69
+ */
70
+ function convertUnit(value, fromUnit, toUnit) {
71
+ // Temperature special cases
72
+ if (isTempUnit(fromUnit) && isTempUnit(toUnit)) {
73
+ const result = convertTemperature(value, normalizeTempUnit(fromUnit), normalizeTempUnit(toUnit));
74
+ return { value: result, expression: `${value} ${fromUnit} to ${toUnit}`, type: 'temperature' };
75
+ }
76
+
77
+ const fromFactor = UNIT_FACTORS[fromUnit];
78
+ const toFactor = UNIT_FACTORS[toUnit];
79
+
80
+ if (!fromFactor) throw new Error(`Unknown unit: ${fromUnit}`);
81
+ if (!toFactor) throw new Error(`Unknown unit: ${toUnit}`);
82
+
83
+ // Check same category
84
+ if (!sameCategory(fromUnit, toUnit)) {
85
+ throw new Error(`Cannot convert ${fromUnit} to ${toUnit}: incompatible unit types`);
86
+ }
87
+
88
+ const result = (value * fromFactor) / toFactor;
89
+ return { value: result, expression: `${value} ${fromUnit} to ${toUnit}`, type: 'conversion' };
90
+ }
91
+
92
+ function isTempUnit(u) {
93
+ return TEMP_UNITS.includes(u);
94
+ }
95
+
96
+ function normalizeTempUnit(u) {
97
+ if (u === 'celsius' || u === 'c') return 'c';
98
+ if (u === 'fahrenheit' || u === 'f') return 'f';
99
+ if (u === 'kelvin' || u === 'k') return 'k';
100
+ return u;
101
+ }
102
+
103
+ function convertTemperature(value, from, to) {
104
+ // Convert to Celsius first
105
+ let celsius;
106
+ switch (from) {
107
+ case 'c': celsius = value; break;
108
+ case 'f': celsius = (value - 32) * 5 / 9; break;
109
+ case 'k': celsius = value - 273.15; break;
110
+ default: throw new Error(`Unknown temperature unit: ${from}`);
111
+ }
112
+ // Convert from Celsius to target
113
+ switch (to) {
114
+ case 'c': return celsius;
115
+ case 'f': return celsius * 9 / 5 + 32;
116
+ case 'k': return celsius + 273.15;
117
+ default: throw new Error(`Unknown temperature unit: ${to}`);
118
+ }
119
+ }
120
+
121
+ function sameCategory(a, b) {
122
+ const categories = [
123
+ ['mm', 'cm', 'm', 'km', 'in', 'ft', 'yd', 'mi'],
124
+ ['mg', 'g', 'kg', 'oz', 'lb', 'ton'],
125
+ ['ml', 'l', 'gal', 'qt', 'pt'],
126
+ ['ms', 's', 'min', 'hr', 'day'],
127
+ ['b', 'kb', 'mb', 'gb', 'tb'],
128
+ ];
129
+ for (const cat of categories) {
130
+ if (cat.includes(a) && cat.includes(b)) return true;
131
+ }
132
+ return false;
133
+ }
134
+
135
+ /**
136
+ * Recursive descent parser for arithmetic expressions.
137
+ * Supports: +, -, *, /, ^, (), unary minus, math functions
138
+ */
139
+ function parseExpression(expr) {
140
+ const tokens = tokenize(expr);
141
+ let pos = 0;
142
+
143
+ function peek() { return tokens[pos]; }
144
+ function consume() { return tokens[pos++]; }
145
+
146
+ function parseExpr() {
147
+ let left = parseTerm();
148
+ while (peek() === '+' || peek() === '-') {
149
+ const op = consume();
150
+ const right = parseTerm();
151
+ left = op === '+' ? left + right : left - right;
152
+ }
153
+ return left;
154
+ }
155
+
156
+ function parseTerm() {
157
+ let left = parsePower();
158
+ while (peek() === '*' || peek() === '/') {
159
+ const op = consume();
160
+ const right = parsePower();
161
+ if (op === '/' && right === 0) throw new Error('Division by zero');
162
+ left = op === '*' ? left * right : left / right;
163
+ }
164
+ return left;
165
+ }
166
+
167
+ function parsePower() {
168
+ let base = parseUnary();
169
+ while (peek() === '^') {
170
+ consume();
171
+ const exp = parseUnary();
172
+ base = Math.pow(base, exp);
173
+ }
174
+ return base;
175
+ }
176
+
177
+ function parseUnary() {
178
+ if (peek() === '-') {
179
+ consume();
180
+ return -parsePrimary();
181
+ }
182
+ if (peek() === '+') {
183
+ consume();
184
+ return parsePrimary();
185
+ }
186
+ return parsePrimary();
187
+ }
188
+
189
+ function parsePrimary() {
190
+ const tok = peek();
191
+
192
+ // Parentheses
193
+ if (tok === '(') {
194
+ consume();
195
+ const val = parseExpr();
196
+ if (peek() !== ')') throw new Error('Mismatched parentheses');
197
+ consume();
198
+ return val;
199
+ }
200
+
201
+ // Math functions
202
+ if (typeof tok === 'string' && tok.match(/^(sqrt|abs|sin|cos|tan|log|ln|ceil|floor|round|exp|pow|min|max)$/)) {
203
+ consume();
204
+ return parseFunction(tok);
205
+ }
206
+
207
+ // Constants
208
+ if (tok === 'pi') { consume(); return Math.PI; }
209
+ if (tok === 'e') { consume(); return Math.E; }
210
+
211
+ // Number
212
+ if (typeof tok === 'number') {
213
+ consume();
214
+ return tok;
215
+ }
216
+
217
+ // Percentage after number: handled in tokenize
218
+ throw new Error(`Unexpected token: ${tok}`);
219
+ }
220
+
221
+ function parseFunction(name) {
222
+ if (peek() !== '(') throw new Error(`Expected ( after ${name}`);
223
+ consume();
224
+
225
+ const args = [parseExpr()];
226
+ while (peek() === ',') {
227
+ consume();
228
+ args.push(parseExpr());
229
+ }
230
+
231
+ if (peek() !== ')') throw new Error(`Expected ) after ${name} arguments`);
232
+ consume();
233
+
234
+ switch (name) {
235
+ case 'sqrt': return Math.sqrt(args[0]);
236
+ case 'abs': return Math.abs(args[0]);
237
+ case 'sin': return Math.sin(args[0]);
238
+ case 'cos': return Math.cos(args[0]);
239
+ case 'tan': return Math.tan(args[0]);
240
+ case 'log': return Math.log10(args[0]);
241
+ case 'ln': return Math.log(args[0]);
242
+ case 'ceil': return Math.ceil(args[0]);
243
+ case 'floor': return Math.floor(args[0]);
244
+ case 'round': return Math.round(args[0]);
245
+ case 'exp': return Math.exp(args[0]);
246
+ case 'pow': return Math.pow(args[0], args[1] ?? 2);
247
+ case 'min': return Math.min(...args);
248
+ case 'max': return Math.max(...args);
249
+ default: throw new Error(`Unknown function: ${name}`);
250
+ }
251
+ }
252
+
253
+ const result = parseExpr();
254
+ if (pos < tokens.length) {
255
+ throw new Error(`Unexpected token at position ${pos}: ${tokens[pos]}`);
256
+ }
257
+ return result;
258
+ }
259
+
260
+ /**
261
+ * Tokenize a math expression string.
262
+ * @param {string} expr
263
+ * @returns {Array<string|number>}
264
+ */
265
+ function tokenize(expr) {
266
+ const tokens = [];
267
+ let i = 0;
268
+ const s = expr.replace(/\s+/g, '');
269
+
270
+ while (i < s.length) {
271
+ const ch = s[i];
272
+
273
+ // Numbers (including decimals)
274
+ if (ch >= '0' && ch <= '9' || (ch === '.' && i + 1 < s.length && s[i + 1] >= '0' && s[i + 1] <= '9')) {
275
+ let num = '';
276
+ while (i < s.length && (s[i] >= '0' && s[i] <= '9' || s[i] === '.')) {
277
+ num += s[i++];
278
+ }
279
+ // Handle percentage: "20%" -> 0.2
280
+ if (i < s.length && s[i] === '%') {
281
+ i++;
282
+ tokens.push(parseFloat(num) / 100);
283
+ } else {
284
+ tokens.push(parseFloat(num));
285
+ }
286
+ continue;
287
+ }
288
+
289
+ // Operators and parentheses
290
+ if ('+-*/^(),'.includes(ch)) {
291
+ tokens.push(ch);
292
+ i++;
293
+ continue;
294
+ }
295
+
296
+ // Words (functions, constants, units)
297
+ if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) {
298
+ let word = '';
299
+ while (i < s.length && ((s[i] >= 'a' && s[i] <= 'z') || (s[i] >= 'A' && s[i] <= 'Z') || (s[i] >= '0' && s[i] <= '9'))) {
300
+ word += s[i++];
301
+ }
302
+ tokens.push(word);
303
+ continue;
304
+ }
305
+
306
+ throw new Error(`Unexpected character: ${ch}`);
307
+ }
308
+
309
+ return tokens;
310
+ }
311
+
312
+ export default { evaluate };