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.
- package/LICENSE +21 -0
- package/README.md +312 -0
- package/bin/prepia.mjs +119 -0
- package/package.json +53 -0
- package/skill/SKILL.md +148 -0
- package/skill/config.json +29 -0
- package/src/analytics/dashboard.mjs +84 -0
- package/src/analytics/tracker.mjs +131 -0
- package/src/api/middleware.mjs +219 -0
- package/src/api/routes.mjs +142 -0
- package/src/api/server.mjs +150 -0
- package/src/cache/disk-store.mjs +199 -0
- package/src/cache/manager.mjs +142 -0
- package/src/cache/memory-store.mjs +205 -0
- package/src/chain/dag.mjs +209 -0
- package/src/chain/executor.mjs +103 -0
- package/src/chain/scheduler.mjs +89 -0
- package/src/client/adapters.mjs +483 -0
- package/src/client/connector.mjs +391 -0
- package/src/client/index.mjs +483 -0
- package/src/client/websocket.mjs +353 -0
- package/src/core/context-packager.mjs +169 -0
- package/src/core/engine.mjs +338 -0
- package/src/core/event-bus.mjs +84 -0
- package/src/core/prepimshot.mjs +120 -0
- package/src/core/task-decomposer.mjs +158 -0
- package/src/edge/lite.mjs +90 -0
- package/src/guard/checker.mjs +123 -0
- package/src/guard/fact-checker.mjs +105 -0
- package/src/guard/hallucination.mjs +108 -0
- package/src/index.mjs +67 -0
- package/src/models/local-model.mjs +171 -0
- package/src/models/provider.mjs +192 -0
- package/src/models/router.mjs +156 -0
- package/src/morph/optimizer.mjs +142 -0
- package/src/network/p2p.mjs +146 -0
- package/src/persona/detector.mjs +118 -0
- package/src/plugins/loader.mjs +120 -0
- package/src/plugins/registry.mjs +164 -0
- package/src/plugins/sandbox.mjs +79 -0
- package/src/rate/limiter.mjs +145 -0
- package/src/rate/shield.mjs +150 -0
- package/src/script/executor.mjs +164 -0
- package/src/script/parser.mjs +134 -0
- package/src/security/privacy.mjs +108 -0
- package/src/security/sanitizer.mjs +133 -0
- package/src/shadow/daemon.mjs +128 -0
- package/src/stream/handler.mjs +204 -0
- package/src/tools/calculator.mjs +312 -0
- package/src/tools/file-ops.mjs +138 -0
- package/src/tools/http-client.mjs +127 -0
- package/src/tools/orchestrator.mjs +205 -0
- package/src/tools/web-scraper.mjs +159 -0
- package/src/tools/web-search.mjs +129 -0
- package/src/vault/knowledge-base.mjs +207 -0
- package/src/vault/pattern-learner.mjs +192 -0
- package/workflows/analyze.json +32 -0
- package/workflows/automate.json +32 -0
- package/workflows/research.json +37 -0
- 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 };
|