@holoscript/plugin-fitness-wellness 2.0.1 → 2.0.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/package.json +3 -3
- package/src/__tests__/fitnesssolver.test.ts +5 -5
- package/src/__tests__/runtime-integration.test.ts +4 -12
- package/src/fitnesssolver.ts +110 -50
- package/src/index.ts +39 -5
- package/src/runtime.ts +3 -4
- package/src/traits/ExerciseLibraryTrait.ts +50 -8
- package/src/traits/ProgressTrackerTrait.ts +51 -12
- package/src/traits/RepCounterTrait.ts +51 -11
- package/src/traits/WorkoutTrait.ts +86 -13
- package/src/traits/types.ts +22 -4
- package/tsconfig.json +5 -1
- package/LICENSE +0 -21
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@holoscript/plugin-fitness-wellness",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"main": "src/index.ts",
|
|
5
5
|
"peerDependencies": {
|
|
6
|
-
"@holoscript/core": "8.0.
|
|
6
|
+
"@holoscript/core": ">=8.0.0"
|
|
7
7
|
},
|
|
8
8
|
"license": "MIT",
|
|
9
9
|
"scripts": {
|
|
10
10
|
"test": "vitest run --passWithNoTests",
|
|
11
11
|
"test:coverage": "vitest run --coverage --passWithNoTests"
|
|
12
12
|
}
|
|
13
|
-
}
|
|
13
|
+
}
|
|
@@ -45,11 +45,11 @@ describe('oneRepMax', () => {
|
|
|
45
45
|
|
|
46
46
|
it('Brzycki formula: 100 kg × 5 reps ≈ 112.5 kg', () => {
|
|
47
47
|
const r = oneRepMax(100, 5);
|
|
48
|
-
expect(r.brzycki).toBeCloseTo(100 * 36 / 32, 3);
|
|
48
|
+
expect(r.brzycki).toBeCloseTo((100 * 36) / 32, 3);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
51
|
it('higher reps → higher 1RM estimate', () => {
|
|
52
|
-
const r5
|
|
52
|
+
const r5 = oneRepMax(100, 5);
|
|
53
53
|
const r10 = oneRepMax(100, 10);
|
|
54
54
|
expect(r10.average).toBeGreaterThan(r5.average);
|
|
55
55
|
});
|
|
@@ -125,7 +125,7 @@ describe('vo2MaxNonExercise', () => {
|
|
|
125
125
|
|
|
126
126
|
it('older age → lower VO2max (all else equal)', () => {
|
|
127
127
|
const rYoung = vo2MaxNonExercise(25, 25, 5, true);
|
|
128
|
-
const rOld
|
|
128
|
+
const rOld = vo2MaxNonExercise(55, 25, 5, true);
|
|
129
129
|
expect(rOld.vo2MaxMlKgMin).toBeLessThan(rYoung.vo2MaxMlKgMin);
|
|
130
130
|
});
|
|
131
131
|
|
|
@@ -241,7 +241,7 @@ describe('jacksonPollockSkinfold', () => {
|
|
|
241
241
|
|
|
242
242
|
it('body density ~1.07 (plausible for lean male)', () => {
|
|
243
243
|
expect(male.bodyDensityGcc).toBeGreaterThan(1.04);
|
|
244
|
-
expect(male.bodyDensityGcc).toBeLessThan(1.
|
|
244
|
+
expect(male.bodyDensityGcc).toBeLessThan(1.1);
|
|
245
245
|
});
|
|
246
246
|
|
|
247
247
|
it('higher skinfolds → higher body fat %', () => {
|
|
@@ -357,7 +357,7 @@ describe('acuteChronicWorkloadRatio — risk-band classification', () => {
|
|
|
357
357
|
describe('analyzeFitness', () => {
|
|
358
358
|
it('returns all sub-results when all inputs provided', () => {
|
|
359
359
|
const r = analyzeFitness({
|
|
360
|
-
oneRM:
|
|
360
|
+
oneRM: { weightKg: 100, reps: 5 },
|
|
361
361
|
vo2Max: { method: 'cooper', distanceM: 2400 },
|
|
362
362
|
hrZones: { hrRest: 60, hrMax: 190 },
|
|
363
363
|
calories: { met: 9.8, bodyMassKg: 70, durationMin: 30 },
|
|
@@ -57,9 +57,7 @@ describe('fitness-wellness -> HoloScript runtime integration (one_rep_max)', ()
|
|
|
57
57
|
solved.push(e as Record<string, unknown>);
|
|
58
58
|
});
|
|
59
59
|
|
|
60
|
-
await runtime.executeNode(
|
|
61
|
-
oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never,
|
|
62
|
-
);
|
|
60
|
+
await runtime.executeNode(oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never);
|
|
63
61
|
await flush();
|
|
64
62
|
|
|
65
63
|
expect(solved).toHaveLength(1);
|
|
@@ -79,9 +77,7 @@ describe('fitness-wellness -> HoloScript runtime integration (one_rep_max)', ()
|
|
|
79
77
|
const solved: unknown[] = [];
|
|
80
78
|
runtime.on('one_rep_max_solved', (e: unknown) => solved.push(e));
|
|
81
79
|
|
|
82
|
-
await runtime.executeNode(
|
|
83
|
-
oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never,
|
|
84
|
-
);
|
|
80
|
+
await runtime.executeNode(oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never);
|
|
85
81
|
await flush();
|
|
86
82
|
|
|
87
83
|
expect(solved).toHaveLength(0);
|
|
@@ -91,9 +87,7 @@ describe('fitness-wellness -> HoloScript runtime integration (one_rep_max)', ()
|
|
|
91
87
|
const runtime = new HoloScriptRuntime();
|
|
92
88
|
registerFitnessWellnessTraitHandlers(runtime);
|
|
93
89
|
|
|
94
|
-
await runtime.executeNode(
|
|
95
|
-
oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never,
|
|
96
|
-
);
|
|
90
|
+
await runtime.executeNode(oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: TEST_REPS }) as never);
|
|
97
91
|
await flush();
|
|
98
92
|
|
|
99
93
|
const state = runtime.getState() as Record<string, unknown>;
|
|
@@ -119,9 +113,7 @@ describe('fitness-wellness -> HoloScript runtime integration (one_rep_max)', ()
|
|
|
119
113
|
// reps = 0 is invalid — the real solver throws "reps must be a positive
|
|
120
114
|
// integer", which the handler's try/catch turns into a one_rep_max_error
|
|
121
115
|
// rather than a throw through the runtime.
|
|
122
|
-
await runtime.executeNode(
|
|
123
|
-
oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: 0 }) as never,
|
|
124
|
-
);
|
|
116
|
+
await runtime.executeNode(oneRepMaxOrb({ weightKg: TEST_WEIGHT_KG, reps: 0 }) as never);
|
|
125
117
|
await flush();
|
|
126
118
|
|
|
127
119
|
expect(errors).toHaveLength(1);
|
package/src/fitnesssolver.ts
CHANGED
|
@@ -120,15 +120,23 @@ export function oneRepMax(weightKg: number, reps: number): OneRepMaxResult {
|
|
|
120
120
|
if (weightKg <= 0) throw new Error('weightKg must be positive');
|
|
121
121
|
if (reps < 1 || !Number.isInteger(reps)) throw new Error('reps must be a positive integer');
|
|
122
122
|
if (reps === 1) {
|
|
123
|
-
return {
|
|
123
|
+
return {
|
|
124
|
+
weightKg,
|
|
125
|
+
reps,
|
|
126
|
+
epley: weightKg,
|
|
127
|
+
brzycki: weightKg,
|
|
128
|
+
lander: weightKg,
|
|
129
|
+
lombardi: weightKg,
|
|
130
|
+
average: weightKg,
|
|
131
|
+
};
|
|
124
132
|
}
|
|
125
133
|
if (reps > 30) throw new Error('reps > 30 is outside the validated range');
|
|
126
134
|
|
|
127
|
-
const epley
|
|
128
|
-
const brzycki
|
|
129
|
-
const lander
|
|
130
|
-
const lombardi = weightKg * Math.pow(reps, 0.
|
|
131
|
-
const average
|
|
135
|
+
const epley = weightKg * (1 + reps / 30);
|
|
136
|
+
const brzycki = (weightKg * 36) / (37 - reps);
|
|
137
|
+
const lander = (100 * weightKg) / (101.3 - 2.67123 * reps);
|
|
138
|
+
const lombardi = weightKg * Math.pow(reps, 0.1);
|
|
139
|
+
const average = (epley + brzycki + lander + lombardi) / 4;
|
|
132
140
|
|
|
133
141
|
return { weightKg, reps, epley, brzycki, lander, lombardi, average };
|
|
134
142
|
}
|
|
@@ -142,7 +150,11 @@ export function oneRepMax(weightKg: number, reps: number): OneRepMaxResult {
|
|
|
142
150
|
export function vo2MaxCooper(distanceMeters: number): VO2MaxResult {
|
|
143
151
|
if (distanceMeters <= 0) throw new Error('distanceMeters must be positive');
|
|
144
152
|
const vo2 = (distanceMeters - 504.9) / 44.73;
|
|
145
|
-
return {
|
|
153
|
+
return {
|
|
154
|
+
vo2MaxMlKgMin: Math.max(0, vo2),
|
|
155
|
+
method: 'cooper-12min',
|
|
156
|
+
fitnessClass: classifyVO2(vo2),
|
|
157
|
+
};
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
/**
|
|
@@ -157,7 +169,7 @@ export function vo2MaxAstrand(
|
|
|
157
169
|
workRateWatts: number,
|
|
158
170
|
hrSteadyState: number,
|
|
159
171
|
bodyMassKg: number,
|
|
160
|
-
ageYears: number
|
|
172
|
+
ageYears: number
|
|
161
173
|
): VO2MaxResult {
|
|
162
174
|
if (workRateWatts <= 0) throw new Error('workRateWatts must be positive');
|
|
163
175
|
if (hrSteadyState <= 0 || hrSteadyState > 250) throw new Error('hrSteadyState out of range');
|
|
@@ -172,7 +184,11 @@ export function vo2MaxAstrand(
|
|
|
172
184
|
const correctionFactor = hrMax / hrSteadyState;
|
|
173
185
|
const vo2Max = vo2Gross * correctionFactor * 0.836; // empirical scaling
|
|
174
186
|
|
|
175
|
-
return {
|
|
187
|
+
return {
|
|
188
|
+
vo2MaxMlKgMin: Math.max(0, vo2Max),
|
|
189
|
+
method: 'astrand-rhyming',
|
|
190
|
+
fitnessClass: classifyVO2(vo2Max),
|
|
191
|
+
};
|
|
176
192
|
}
|
|
177
193
|
|
|
178
194
|
/**
|
|
@@ -184,15 +200,21 @@ export function vo2MaxNonExercise(
|
|
|
184
200
|
ageYears: number,
|
|
185
201
|
bmi: number,
|
|
186
202
|
physicalActivityRating: number, // PA-R: 0-10 Likert scale
|
|
187
|
-
isMale: boolean
|
|
203
|
+
isMale: boolean
|
|
188
204
|
): VO2MaxResult {
|
|
189
205
|
if (ageYears < 10 || ageYears > 100) throw new Error('ageYears out of range [10, 100]');
|
|
190
206
|
if (bmi <= 10 || bmi > 70) throw new Error('BMI out of plausible range');
|
|
191
|
-
if (physicalActivityRating < 0 || physicalActivityRating > 10)
|
|
207
|
+
if (physicalActivityRating < 0 || physicalActivityRating > 10)
|
|
208
|
+
throw new Error('PA_R must be in [0, 10]');
|
|
192
209
|
|
|
193
210
|
const sex = isMale ? 1 : 0;
|
|
194
|
-
const vo2 =
|
|
195
|
-
|
|
211
|
+
const vo2 =
|
|
212
|
+
56.363 + 1.921 * physicalActivityRating - 0.381 * ageYears - 0.754 * bmi + 10.987 * sex;
|
|
213
|
+
return {
|
|
214
|
+
vo2MaxMlKgMin: Math.max(10, vo2),
|
|
215
|
+
method: 'non-exercise',
|
|
216
|
+
fitnessClass: classifyVO2(vo2),
|
|
217
|
+
};
|
|
196
218
|
}
|
|
197
219
|
|
|
198
220
|
function classifyVO2(vo2: number): VO2MaxResult['fitnessClass'] {
|
|
@@ -220,13 +242,15 @@ export function heartRateZones(hrRest: number, hrMax: number): HeartRateZoneResu
|
|
|
220
242
|
});
|
|
221
243
|
|
|
222
244
|
return {
|
|
223
|
-
hrRest,
|
|
245
|
+
hrRest,
|
|
246
|
+
hrMax,
|
|
247
|
+
hrr,
|
|
224
248
|
zones: {
|
|
225
|
-
zone1: { ...zone(0.
|
|
226
|
-
zone2: { ...zone(0.
|
|
227
|
-
zone3: { ...zone(0.
|
|
228
|
-
zone4: { ...zone(0.
|
|
229
|
-
zone5: { ...zone(0.
|
|
249
|
+
zone1: { ...zone(0.5, 0.6), label: 'Recovery / Active Rest' },
|
|
250
|
+
zone2: { ...zone(0.6, 0.7), label: 'Aerobic Base / Fat Burn' },
|
|
251
|
+
zone3: { ...zone(0.7, 0.8), label: 'Aerobic / Tempo' },
|
|
252
|
+
zone4: { ...zone(0.8, 0.9), label: 'Lactate Threshold' },
|
|
253
|
+
zone5: { ...zone(0.9, 1.0), label: 'VO2max / Neuromuscular' },
|
|
230
254
|
},
|
|
231
255
|
};
|
|
232
256
|
}
|
|
@@ -235,21 +259,21 @@ export function heartRateZones(hrRest: number, hrMax: number): HeartRateZoneResu
|
|
|
235
259
|
|
|
236
260
|
/** Common activity METs from the Compendium of Physical Activities (2011) */
|
|
237
261
|
export const ACTIVITY_METS: Record<string, number> = {
|
|
238
|
-
running_6mph:
|
|
239
|
-
running_8mph:
|
|
240
|
-
running_10mph:
|
|
241
|
-
cycling_moderate:
|
|
242
|
-
cycling_vigorous:
|
|
243
|
-
swimming_moderate:
|
|
244
|
-
swimming_vigorous:
|
|
245
|
-
walking_3mph:
|
|
246
|
-
walking_4mph:
|
|
247
|
-
strength_training:
|
|
248
|
-
yoga:
|
|
249
|
-
rowing_moderate:
|
|
250
|
-
rowing_vigorous:
|
|
251
|
-
basketball:
|
|
252
|
-
soccer:
|
|
262
|
+
running_6mph: 9.8,
|
|
263
|
+
running_8mph: 11.8,
|
|
264
|
+
running_10mph: 14.5,
|
|
265
|
+
cycling_moderate: 8.0,
|
|
266
|
+
cycling_vigorous: 12.0,
|
|
267
|
+
swimming_moderate: 6.0,
|
|
268
|
+
swimming_vigorous: 9.8,
|
|
269
|
+
walking_3mph: 3.5,
|
|
270
|
+
walking_4mph: 5.0,
|
|
271
|
+
strength_training: 3.5,
|
|
272
|
+
yoga: 2.5,
|
|
273
|
+
rowing_moderate: 7.0,
|
|
274
|
+
rowing_vigorous: 12.0,
|
|
275
|
+
basketball: 8.0,
|
|
276
|
+
soccer: 7.0,
|
|
253
277
|
};
|
|
254
278
|
|
|
255
279
|
/**
|
|
@@ -257,7 +281,11 @@ export const ACTIVITY_METS: Record<string, number> = {
|
|
|
257
281
|
* Gross: MET × bodyMassKg × durationHours
|
|
258
282
|
* Net: gross − BMR_rate × duration (approximate BMR ≈ 1 MET contribution)
|
|
259
283
|
*/
|
|
260
|
-
export function calorieBurn(
|
|
284
|
+
export function calorieBurn(
|
|
285
|
+
met: number,
|
|
286
|
+
bodyMassKg: number,
|
|
287
|
+
durationMin: number
|
|
288
|
+
): CalorieBurnResult {
|
|
261
289
|
if (met <= 0) throw new Error('MET must be positive');
|
|
262
290
|
if (bodyMassKg <= 0) throw new Error('bodyMassKg must be positive');
|
|
263
291
|
if (durationMin <= 0) throw new Error('durationMin must be positive');
|
|
@@ -284,9 +312,10 @@ export function jacksonPollockSkinfold(
|
|
|
284
312
|
s3Mm: number,
|
|
285
313
|
ageYears: number,
|
|
286
314
|
isMale: boolean,
|
|
287
|
-
bodyMassKg: number
|
|
315
|
+
bodyMassKg: number
|
|
288
316
|
): BodyCompositionResult {
|
|
289
|
-
if (s1Mm <= 0 || s2Mm <= 0 || s3Mm <= 0)
|
|
317
|
+
if (s1Mm <= 0 || s2Mm <= 0 || s3Mm <= 0)
|
|
318
|
+
throw new Error('Skinfold measurements must be positive');
|
|
290
319
|
if (ageYears < 18 || ageYears > 80) throw new Error('Age must be in [18, 80]');
|
|
291
320
|
if (bodyMassKg <= 0) throw new Error('bodyMassKg must be positive');
|
|
292
321
|
|
|
@@ -304,8 +333,8 @@ export function jacksonPollockSkinfold(
|
|
|
304
333
|
}
|
|
305
334
|
|
|
306
335
|
// Siri equation
|
|
307
|
-
const bodyFatPct = (4.95 / density - 4.
|
|
308
|
-
const fatMassKg = bodyMassKg * bodyFatPct / 100;
|
|
336
|
+
const bodyFatPct = (4.95 / density - 4.5) * 100;
|
|
337
|
+
const fatMassKg = (bodyMassKg * bodyFatPct) / 100;
|
|
309
338
|
const leanMassKg = bodyMassKg - fatMassKg;
|
|
310
339
|
|
|
311
340
|
return { skinfoldSumMm: sum, bodyDensityGcc: density, bodyFatPct, fatMassKg, leanMassKg };
|
|
@@ -356,12 +385,26 @@ export function acuteChronicWorkloadRatio(workloads: number[]): TrainingLoadResu
|
|
|
356
385
|
|
|
357
386
|
export interface FitnessAnalysisInput {
|
|
358
387
|
oneRM?: { weightKg: number; reps: number };
|
|
359
|
-
vo2Max?:
|
|
360
|
-
|
|
361
|
-
|
|
388
|
+
vo2Max?:
|
|
389
|
+
| { method: 'cooper'; distanceM: number }
|
|
390
|
+
| {
|
|
391
|
+
method: 'astrand';
|
|
392
|
+
workRateW: number;
|
|
393
|
+
hrSteady: number;
|
|
394
|
+
bodyMassKg: number;
|
|
395
|
+
ageYears: number;
|
|
396
|
+
}
|
|
397
|
+
| { method: 'non-exercise'; ageYears: number; bmi: number; paRating: number; isMale: boolean };
|
|
362
398
|
hrZones?: { hrRest: number; hrMax: number };
|
|
363
399
|
calories?: { met: number; bodyMassKg: number; durationMin: number };
|
|
364
|
-
bodyComp?: {
|
|
400
|
+
bodyComp?: {
|
|
401
|
+
s1: number;
|
|
402
|
+
s2: number;
|
|
403
|
+
s3: number;
|
|
404
|
+
ageYears: number;
|
|
405
|
+
isMale: boolean;
|
|
406
|
+
bodyMassKg: number;
|
|
407
|
+
};
|
|
365
408
|
trainingLoad?: { workloads: number[] };
|
|
366
409
|
}
|
|
367
410
|
|
|
@@ -383,17 +426,24 @@ export function analyzeFitness(input: FitnessAnalysisInput): FitnessAnalysisResu
|
|
|
383
426
|
if (input.vo2Max) {
|
|
384
427
|
const v = input.vo2Max;
|
|
385
428
|
if (v.method === 'cooper') result.vo2Max = vo2MaxCooper(v.distanceM);
|
|
386
|
-
else if (v.method === 'astrand')
|
|
429
|
+
else if (v.method === 'astrand')
|
|
430
|
+
result.vo2Max = vo2MaxAstrand(v.workRateW, v.hrSteady, v.bodyMassKg, v.ageYears);
|
|
387
431
|
else result.vo2Max = vo2MaxNonExercise(v.ageYears, v.bmi, v.paRating, v.isMale);
|
|
388
432
|
}
|
|
389
433
|
|
|
390
434
|
if (input.hrZones) result.hrZones = heartRateZones(input.hrZones.hrRest, input.hrZones.hrMax);
|
|
391
|
-
if (input.calories)
|
|
435
|
+
if (input.calories)
|
|
436
|
+
result.calories = calorieBurn(
|
|
437
|
+
input.calories.met,
|
|
438
|
+
input.calories.bodyMassKg,
|
|
439
|
+
input.calories.durationMin
|
|
440
|
+
);
|
|
392
441
|
if (input.bodyComp) {
|
|
393
442
|
const b = input.bodyComp;
|
|
394
443
|
result.bodyComp = jacksonPollockSkinfold(b.s1, b.s2, b.s3, b.ageYears, b.isMale, b.bodyMassKg);
|
|
395
444
|
}
|
|
396
|
-
if (input.trainingLoad)
|
|
445
|
+
if (input.trainingLoad)
|
|
446
|
+
result.trainingLoad = acuteChronicWorkloadRatio(input.trainingLoad.workloads);
|
|
397
447
|
|
|
398
448
|
return result;
|
|
399
449
|
}
|
|
@@ -402,15 +452,21 @@ export function analyzeFitness(input: FitnessAnalysisInput): FitnessAnalysisResu
|
|
|
402
452
|
|
|
403
453
|
export function buildFitnessReceipt(
|
|
404
454
|
result: FitnessAnalysisResult,
|
|
405
|
-
options?: FitnessReceiptOptions
|
|
455
|
+
options?: FitnessReceiptOptions
|
|
406
456
|
): DomainSimulationReceipt {
|
|
407
457
|
const violations: Array<{ criterion: string; message: string }> = [];
|
|
408
458
|
|
|
409
459
|
if (result.bodyComp && (result.bodyComp.bodyFatPct < 3 || result.bodyComp.bodyFatPct > 60)) {
|
|
410
|
-
violations.push({
|
|
460
|
+
violations.push({
|
|
461
|
+
criterion: 'body_fat',
|
|
462
|
+
message: `body fat ${result.bodyComp.bodyFatPct.toFixed(1)}% outside plausible range [3%, 60%]`,
|
|
463
|
+
});
|
|
411
464
|
}
|
|
412
465
|
if (result.trainingLoad && result.trainingLoad.riskCategory === 'very-high') {
|
|
413
|
-
violations.push({
|
|
466
|
+
violations.push({
|
|
467
|
+
criterion: 'acwr',
|
|
468
|
+
message: `ACWR ${result.trainingLoad.acwr.toFixed(2)} is very high (>1.5) — injury risk elevated`,
|
|
469
|
+
});
|
|
414
470
|
}
|
|
415
471
|
|
|
416
472
|
return buildDomainSimulationReceipt({
|
|
@@ -425,7 +481,11 @@ export function buildFitnessReceipt(
|
|
|
425
481
|
bodyFatPct: result.bodyComp?.bodyFatPct ?? null,
|
|
426
482
|
acwr: result.trainingLoad?.acwr ?? null,
|
|
427
483
|
},
|
|
428
|
-
cael: {
|
|
484
|
+
cael: {
|
|
485
|
+
version: 'cael.v1',
|
|
486
|
+
event: 'fitness_wellness.fitness_analysis',
|
|
487
|
+
solverType: 'fitness-wellness.analytics',
|
|
488
|
+
},
|
|
429
489
|
acceptance: { accepted: violations.length === 0, violations },
|
|
430
490
|
});
|
|
431
491
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
createWorkoutHandler,
|
|
3
|
+
type WorkoutConfig,
|
|
4
|
+
type WorkoutType,
|
|
5
|
+
type ExerciseSet,
|
|
6
|
+
} from './traits/WorkoutTrait';
|
|
2
7
|
export { createRepCounterHandler, type RepCounterConfig } from './traits/RepCounterTrait';
|
|
3
|
-
export {
|
|
4
|
-
|
|
8
|
+
export {
|
|
9
|
+
createExerciseLibraryHandler,
|
|
10
|
+
type ExerciseLibraryConfig,
|
|
11
|
+
type Exercise,
|
|
12
|
+
type MuscleGroup,
|
|
13
|
+
} from './traits/ExerciseLibraryTrait';
|
|
14
|
+
export {
|
|
15
|
+
createProgressTrackerHandler,
|
|
16
|
+
type ProgressTrackerConfig,
|
|
17
|
+
type ProgressEntry,
|
|
18
|
+
} from './traits/ProgressTrackerTrait';
|
|
5
19
|
export * from './traits/types';
|
|
6
20
|
|
|
7
21
|
import { createWorkoutHandler } from './traits/WorkoutTrait';
|
|
@@ -25,5 +39,25 @@ export {
|
|
|
25
39
|
type TraitRegistrar,
|
|
26
40
|
} from './runtime';
|
|
27
41
|
|
|
28
|
-
export const pluginMeta = {
|
|
29
|
-
|
|
42
|
+
export const pluginMeta = {
|
|
43
|
+
name: '@holoscript/plugin-fitness-wellness',
|
|
44
|
+
version: '1.0.0',
|
|
45
|
+
traits: [
|
|
46
|
+
'workout',
|
|
47
|
+
'rep_counter',
|
|
48
|
+
'exercise_library',
|
|
49
|
+
'progress_tracker',
|
|
50
|
+
'one_rep_max',
|
|
51
|
+
'vo2max',
|
|
52
|
+
'hr_zones',
|
|
53
|
+
'calorie_burn',
|
|
54
|
+
'body_composition',
|
|
55
|
+
'training_load_acwr',
|
|
56
|
+
],
|
|
57
|
+
};
|
|
58
|
+
export const traitHandlers = [
|
|
59
|
+
createWorkoutHandler(),
|
|
60
|
+
createRepCounterHandler(),
|
|
61
|
+
createExerciseLibraryHandler(),
|
|
62
|
+
createProgressTrackerHandler(),
|
|
63
|
+
];
|
package/src/runtime.ts
CHANGED
|
@@ -66,7 +66,7 @@ export interface RuntimeTraitHandler {
|
|
|
66
66
|
node: unknown,
|
|
67
67
|
config: OneRepMaxTraitConfig,
|
|
68
68
|
context: TraitDispatchContext,
|
|
69
|
-
delta: number
|
|
69
|
+
delta: number
|
|
70
70
|
) => void;
|
|
71
71
|
}
|
|
72
72
|
|
|
@@ -81,7 +81,7 @@ interface OneRepMaxNode {
|
|
|
81
81
|
function solveOntoNode(
|
|
82
82
|
node: unknown,
|
|
83
83
|
config: OneRepMaxTraitConfig | undefined,
|
|
84
|
-
context: TraitDispatchContext
|
|
84
|
+
context: TraitDispatchContext
|
|
85
85
|
): void {
|
|
86
86
|
const carrier = node as OneRepMaxNode;
|
|
87
87
|
const nodeId = carrier.id ?? carrier.name ?? 'unknown';
|
|
@@ -91,8 +91,7 @@ function solveOntoNode(
|
|
|
91
91
|
if (typeof weightKg !== 'number' || typeof reps !== 'number') {
|
|
92
92
|
context.emit('one_rep_max_error', {
|
|
93
93
|
nodeId,
|
|
94
|
-
error:
|
|
95
|
-
'one_rep_max trait requires config.weightKg (number) and config.reps (number)',
|
|
94
|
+
error: 'one_rep_max trait requires config.weightKg (number) and config.reps (number)',
|
|
96
95
|
});
|
|
97
96
|
return;
|
|
98
97
|
}
|
|
@@ -1,20 +1,62 @@
|
|
|
1
1
|
/** @exercise_library Trait — Exercise database. @trait exercise_library */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export type MuscleGroup =
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export type MuscleGroup =
|
|
5
|
+
| 'chest'
|
|
6
|
+
| 'back'
|
|
7
|
+
| 'shoulders'
|
|
8
|
+
| 'biceps'
|
|
9
|
+
| 'triceps'
|
|
10
|
+
| 'quadriceps'
|
|
11
|
+
| 'hamstrings'
|
|
12
|
+
| 'glutes'
|
|
13
|
+
| 'calves'
|
|
14
|
+
| 'core'
|
|
15
|
+
| 'full_body';
|
|
16
|
+
export interface Exercise {
|
|
17
|
+
id: string;
|
|
18
|
+
name: string;
|
|
19
|
+
primaryMuscle: MuscleGroup;
|
|
20
|
+
secondaryMuscles: MuscleGroup[];
|
|
21
|
+
equipment: string[];
|
|
22
|
+
instructions: string;
|
|
23
|
+
videoUrl?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface ExerciseLibraryConfig {
|
|
26
|
+
exercises: Exercise[];
|
|
27
|
+
categories: string[];
|
|
28
|
+
}
|
|
7
29
|
|
|
8
30
|
const defaultConfig: ExerciseLibraryConfig = { exercises: [], categories: [] };
|
|
9
31
|
|
|
10
32
|
export function createExerciseLibraryHandler(): TraitHandler<ExerciseLibraryConfig> {
|
|
11
|
-
return {
|
|
12
|
-
|
|
13
|
-
|
|
33
|
+
return {
|
|
34
|
+
name: 'exercise_library',
|
|
35
|
+
defaultConfig,
|
|
36
|
+
onAttach(n: HSPlusNode, c: ExerciseLibraryConfig, ctx: TraitContext) {
|
|
37
|
+
n.__libState = { loaded: c.exercises.length };
|
|
38
|
+
ctx.emit?.('library:loaded', { exercises: c.exercises.length });
|
|
39
|
+
},
|
|
40
|
+
onDetach(n: HSPlusNode, _c: ExerciseLibraryConfig, ctx: TraitContext) {
|
|
41
|
+
delete n.__libState;
|
|
42
|
+
ctx.emit?.('library:unloaded');
|
|
43
|
+
},
|
|
14
44
|
onUpdate() {},
|
|
15
45
|
onEvent(_n: HSPlusNode, c: ExerciseLibraryConfig, ctx: TraitContext, e: TraitEvent) {
|
|
16
|
-
if (e.type === 'library:search') {
|
|
17
|
-
|
|
46
|
+
if (e.type === 'library:search') {
|
|
47
|
+
const muscle = e.payload?.muscle as MuscleGroup;
|
|
48
|
+
const results = c.exercises.filter(
|
|
49
|
+
(ex) => ex.primaryMuscle === muscle || ex.secondaryMuscles.includes(muscle)
|
|
50
|
+
);
|
|
51
|
+
ctx.emit?.('library:results', {
|
|
52
|
+
count: results.length,
|
|
53
|
+
exercises: results.map((r) => r.name),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (e.type === 'library:random') {
|
|
57
|
+
const ex = c.exercises[Math.floor(Math.random() * c.exercises.length)];
|
|
58
|
+
if (ex) ctx.emit?.('library:suggestion', { exercise: ex.name, muscle: ex.primaryMuscle });
|
|
59
|
+
}
|
|
18
60
|
},
|
|
19
61
|
};
|
|
20
62
|
}
|
|
@@ -1,28 +1,67 @@
|
|
|
1
1
|
/** @progress_tracker Trait — Fitness progress over time. @trait progress_tracker */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface ProgressEntry {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
export interface ProgressEntry {
|
|
5
|
+
date: string;
|
|
6
|
+
metric: string;
|
|
7
|
+
value: number;
|
|
8
|
+
unit: string;
|
|
9
|
+
}
|
|
10
|
+
export interface ProgressTrackerConfig {
|
|
11
|
+
userId: string;
|
|
12
|
+
metrics: string[];
|
|
13
|
+
goalValues: Record<string, number>;
|
|
14
|
+
trackingPeriodDays: number;
|
|
15
|
+
}
|
|
16
|
+
export interface ProgressTrackerState {
|
|
17
|
+
entries: ProgressEntry[];
|
|
18
|
+
streakDays: number;
|
|
19
|
+
lastLogDate: string | null;
|
|
20
|
+
goalsReached: string[];
|
|
21
|
+
}
|
|
7
22
|
|
|
8
|
-
const defaultConfig: ProgressTrackerConfig = {
|
|
23
|
+
const defaultConfig: ProgressTrackerConfig = {
|
|
24
|
+
userId: '',
|
|
25
|
+
metrics: ['weight', 'body_fat', 'bench_press_1rm'],
|
|
26
|
+
goalValues: {},
|
|
27
|
+
trackingPeriodDays: 90,
|
|
28
|
+
};
|
|
9
29
|
|
|
10
30
|
export function createProgressTrackerHandler(): TraitHandler<ProgressTrackerConfig> {
|
|
11
|
-
return {
|
|
12
|
-
|
|
13
|
-
|
|
31
|
+
return {
|
|
32
|
+
name: 'progress_tracker',
|
|
33
|
+
defaultConfig,
|
|
34
|
+
onAttach(n: HSPlusNode, _c: ProgressTrackerConfig, ctx: TraitContext) {
|
|
35
|
+
n.__progressState = { entries: [], streakDays: 0, lastLogDate: null, goalsReached: [] };
|
|
36
|
+
ctx.emit?.('progress:initialized');
|
|
37
|
+
},
|
|
38
|
+
onDetach(n: HSPlusNode, _c: ProgressTrackerConfig, ctx: TraitContext) {
|
|
39
|
+
delete n.__progressState;
|
|
40
|
+
ctx.emit?.('progress:removed');
|
|
41
|
+
},
|
|
14
42
|
onUpdate() {},
|
|
15
43
|
onEvent(n: HSPlusNode, c: ProgressTrackerConfig, ctx: TraitContext, e: TraitEvent) {
|
|
16
|
-
const s = n.__progressState as ProgressTrackerState | undefined;
|
|
44
|
+
const s = n.__progressState as ProgressTrackerState | undefined;
|
|
45
|
+
if (!s) return;
|
|
17
46
|
if (e.type === 'progress:log') {
|
|
18
|
-
const entry: ProgressEntry = {
|
|
47
|
+
const entry: ProgressEntry = {
|
|
48
|
+
date: new Date().toISOString().split('T')[0],
|
|
49
|
+
metric: (e.payload?.metric as string) ?? '',
|
|
50
|
+
value: (e.payload?.value as number) ?? 0,
|
|
51
|
+
unit: (e.payload?.unit as string) ?? '',
|
|
52
|
+
};
|
|
19
53
|
s.entries.push(entry);
|
|
20
54
|
const today = entry.date;
|
|
21
|
-
if (s.lastLogDate && s.lastLogDate !== today) {
|
|
22
|
-
|
|
55
|
+
if (s.lastLogDate && s.lastLogDate !== today) {
|
|
56
|
+
const diff = (new Date(today).getTime() - new Date(s.lastLogDate).getTime()) / 86400000;
|
|
57
|
+
s.streakDays = diff <= 1 ? s.streakDays + 1 : 1;
|
|
58
|
+
} else if (!s.lastLogDate) s.streakDays = 1;
|
|
23
59
|
s.lastLogDate = today;
|
|
24
60
|
const goal = c.goalValues[entry.metric];
|
|
25
|
-
if (goal !== undefined && entry.value >= goal && !s.goalsReached.includes(entry.metric)) {
|
|
61
|
+
if (goal !== undefined && entry.value >= goal && !s.goalsReached.includes(entry.metric)) {
|
|
62
|
+
s.goalsReached.push(entry.metric);
|
|
63
|
+
ctx.emit?.('progress:goal_reached', { metric: entry.metric, value: entry.value });
|
|
64
|
+
}
|
|
26
65
|
ctx.emit?.('progress:logged', { metric: entry.metric, streak: s.streakDays });
|
|
27
66
|
}
|
|
28
67
|
},
|
|
@@ -1,25 +1,65 @@
|
|
|
1
1
|
/** @rep_counter Trait — Repetition counting and form tracking. @trait rep_counter */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export interface RepCounterConfig {
|
|
5
|
-
|
|
4
|
+
export interface RepCounterConfig {
|
|
5
|
+
targetReps: number;
|
|
6
|
+
exerciseName: string;
|
|
7
|
+
formCheckEnabled: boolean;
|
|
8
|
+
tempoUp: number;
|
|
9
|
+
tempoDown: number;
|
|
10
|
+
}
|
|
11
|
+
export interface RepCounterState {
|
|
12
|
+
currentReps: number;
|
|
13
|
+
goodFormReps: number;
|
|
14
|
+
badFormReps: number;
|
|
15
|
+
isTracking: boolean;
|
|
16
|
+
}
|
|
6
17
|
|
|
7
|
-
const defaultConfig: RepCounterConfig = {
|
|
18
|
+
const defaultConfig: RepCounterConfig = {
|
|
19
|
+
targetReps: 10,
|
|
20
|
+
exerciseName: '',
|
|
21
|
+
formCheckEnabled: true,
|
|
22
|
+
tempoUp: 2,
|
|
23
|
+
tempoDown: 3,
|
|
24
|
+
};
|
|
8
25
|
|
|
9
26
|
export function createRepCounterHandler(): TraitHandler<RepCounterConfig> {
|
|
10
|
-
return {
|
|
11
|
-
|
|
12
|
-
|
|
27
|
+
return {
|
|
28
|
+
name: 'rep_counter',
|
|
29
|
+
defaultConfig,
|
|
30
|
+
onAttach(n: HSPlusNode, _c: RepCounterConfig, ctx: TraitContext) {
|
|
31
|
+
n.__repState = { currentReps: 0, goodFormReps: 0, badFormReps: 0, isTracking: false };
|
|
32
|
+
ctx.emit?.('rep:ready');
|
|
33
|
+
},
|
|
34
|
+
onDetach(n: HSPlusNode, _c: RepCounterConfig, ctx: TraitContext) {
|
|
35
|
+
delete n.__repState;
|
|
36
|
+
ctx.emit?.('rep:stopped');
|
|
37
|
+
},
|
|
13
38
|
onUpdate() {},
|
|
14
39
|
onEvent(n: HSPlusNode, c: RepCounterConfig, ctx: TraitContext, e: TraitEvent) {
|
|
15
|
-
const s = n.__repState as RepCounterState | undefined;
|
|
16
|
-
if (
|
|
40
|
+
const s = n.__repState as RepCounterState | undefined;
|
|
41
|
+
if (!s) return;
|
|
42
|
+
if (e.type === 'rep:start') {
|
|
43
|
+
s.isTracking = true;
|
|
44
|
+
s.currentReps = 0;
|
|
45
|
+
s.goodFormReps = 0;
|
|
46
|
+
s.badFormReps = 0;
|
|
47
|
+
ctx.emit?.('rep:tracking');
|
|
48
|
+
}
|
|
17
49
|
if (e.type === 'rep:count' && s.isTracking) {
|
|
18
50
|
s.currentReps++;
|
|
19
51
|
const goodForm = (e.payload?.goodForm as boolean) ?? true;
|
|
20
|
-
if (goodForm) s.goodFormReps++;
|
|
21
|
-
|
|
22
|
-
|
|
52
|
+
if (goodForm) s.goodFormReps++;
|
|
53
|
+
else s.badFormReps++;
|
|
54
|
+
ctx.emit?.('rep:counted', {
|
|
55
|
+
reps: s.currentReps,
|
|
56
|
+
target: c.targetReps,
|
|
57
|
+
formRate: s.goodFormReps / s.currentReps,
|
|
58
|
+
});
|
|
59
|
+
if (s.currentReps >= c.targetReps) {
|
|
60
|
+
s.isTracking = false;
|
|
61
|
+
ctx.emit?.('rep:target_reached', { total: s.currentReps, goodForm: s.goodFormReps });
|
|
62
|
+
}
|
|
23
63
|
}
|
|
24
64
|
},
|
|
25
65
|
};
|
|
@@ -1,23 +1,96 @@
|
|
|
1
1
|
/** @workout Trait — Workout session definition. @trait workout */
|
|
2
2
|
import type { TraitHandler, HSPlusNode, TraitContext, TraitEvent } from './types';
|
|
3
3
|
|
|
4
|
-
export type WorkoutType =
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
4
|
+
export type WorkoutType =
|
|
5
|
+
| 'strength'
|
|
6
|
+
| 'cardio'
|
|
7
|
+
| 'hiit'
|
|
8
|
+
| 'yoga'
|
|
9
|
+
| 'pilates'
|
|
10
|
+
| 'crossfit'
|
|
11
|
+
| 'swimming'
|
|
12
|
+
| 'cycling'
|
|
13
|
+
| 'running';
|
|
14
|
+
export interface ExerciseSet {
|
|
15
|
+
exerciseId: string;
|
|
16
|
+
reps: number;
|
|
17
|
+
weightKg?: number;
|
|
18
|
+
durationS?: number;
|
|
19
|
+
restS: number;
|
|
20
|
+
}
|
|
21
|
+
export interface WorkoutConfig {
|
|
22
|
+
name: string;
|
|
23
|
+
type: WorkoutType;
|
|
24
|
+
exercises: ExerciseSet[];
|
|
25
|
+
estimatedDurationMin: number;
|
|
26
|
+
difficulty: 'beginner' | 'intermediate' | 'advanced';
|
|
27
|
+
calorieTarget?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface WorkoutState {
|
|
30
|
+
isActive: boolean;
|
|
31
|
+
currentExercise: number;
|
|
32
|
+
elapsedS: number;
|
|
33
|
+
caloriesBurned: number;
|
|
34
|
+
completedSets: number;
|
|
35
|
+
}
|
|
8
36
|
|
|
9
|
-
const defaultConfig: WorkoutConfig = {
|
|
37
|
+
const defaultConfig: WorkoutConfig = {
|
|
38
|
+
name: '',
|
|
39
|
+
type: 'strength',
|
|
40
|
+
exercises: [],
|
|
41
|
+
estimatedDurationMin: 30,
|
|
42
|
+
difficulty: 'intermediate',
|
|
43
|
+
};
|
|
10
44
|
|
|
11
45
|
export function createWorkoutHandler(): TraitHandler<WorkoutConfig> {
|
|
12
|
-
return {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
46
|
+
return {
|
|
47
|
+
name: 'workout',
|
|
48
|
+
defaultConfig,
|
|
49
|
+
onAttach(n: HSPlusNode, _c: WorkoutConfig, ctx: TraitContext) {
|
|
50
|
+
n.__workoutState = {
|
|
51
|
+
isActive: false,
|
|
52
|
+
currentExercise: 0,
|
|
53
|
+
elapsedS: 0,
|
|
54
|
+
caloriesBurned: 0,
|
|
55
|
+
completedSets: 0,
|
|
56
|
+
};
|
|
57
|
+
ctx.emit?.('workout:ready');
|
|
58
|
+
},
|
|
59
|
+
onDetach(n: HSPlusNode, _c: WorkoutConfig, ctx: TraitContext) {
|
|
60
|
+
delete n.__workoutState;
|
|
61
|
+
ctx.emit?.('workout:ended');
|
|
62
|
+
},
|
|
63
|
+
onUpdate(n: HSPlusNode, _c: WorkoutConfig, _ctx: TraitContext, delta: number) {
|
|
64
|
+
const s = n.__workoutState as WorkoutState | undefined;
|
|
65
|
+
if (s?.isActive) {
|
|
66
|
+
s.elapsedS += delta / 1000;
|
|
67
|
+
s.caloriesBurned += (delta / 1000) * 0.15;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
16
70
|
onEvent(n: HSPlusNode, c: WorkoutConfig, ctx: TraitContext, e: TraitEvent) {
|
|
17
|
-
const s = n.__workoutState as WorkoutState | undefined;
|
|
18
|
-
if (
|
|
19
|
-
if (e.type === 'workout:
|
|
20
|
-
|
|
71
|
+
const s = n.__workoutState as WorkoutState | undefined;
|
|
72
|
+
if (!s) return;
|
|
73
|
+
if (e.type === 'workout:start') {
|
|
74
|
+
s.isActive = true;
|
|
75
|
+
ctx.emit?.('workout:started', { type: c.type });
|
|
76
|
+
}
|
|
77
|
+
if (e.type === 'workout:complete_set') {
|
|
78
|
+
s.completedSets++;
|
|
79
|
+
if (s.completedSets >= c.exercises.length) {
|
|
80
|
+
s.isActive = false;
|
|
81
|
+
ctx.emit?.('workout:completed', {
|
|
82
|
+
calories: Math.round(s.caloriesBurned),
|
|
83
|
+
duration: Math.round(s.elapsedS),
|
|
84
|
+
});
|
|
85
|
+
} else {
|
|
86
|
+
s.currentExercise++;
|
|
87
|
+
ctx.emit?.('workout:next_exercise', { index: s.currentExercise });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (e.type === 'workout:pause') {
|
|
91
|
+
s.isActive = false;
|
|
92
|
+
ctx.emit?.('workout:paused');
|
|
93
|
+
}
|
|
21
94
|
},
|
|
22
95
|
};
|
|
23
96
|
}
|
package/src/traits/types.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
|
-
export interface HSPlusNode {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export interface HSPlusNode {
|
|
2
|
+
id?: string;
|
|
3
|
+
properties?: Record<string, unknown>;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface TraitContext {
|
|
7
|
+
emit?: (event: string, payload?: unknown) => void;
|
|
8
|
+
[key: string]: unknown;
|
|
9
|
+
}
|
|
10
|
+
export interface TraitEvent {
|
|
11
|
+
type: string;
|
|
12
|
+
payload?: Record<string, unknown>;
|
|
13
|
+
[key: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export interface TraitHandler<T = unknown> {
|
|
16
|
+
name: string;
|
|
17
|
+
defaultConfig: T;
|
|
18
|
+
onAttach(n: HSPlusNode, c: T, ctx: TraitContext): void;
|
|
19
|
+
onDetach(n: HSPlusNode, c: T, ctx: TraitContext): void;
|
|
20
|
+
onUpdate(n: HSPlusNode, c: T, ctx: TraitContext, d: number): void;
|
|
21
|
+
onEvent(n: HSPlusNode, c: T, ctx: TraitContext, e: TraitEvent): void;
|
|
22
|
+
}
|
package/tsconfig.json
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../../tsconfig.json",
|
|
3
|
+
"compilerOptions": { "outDir": "dist", "rootDir": "src", "declaration": true },
|
|
4
|
+
"include": ["src"]
|
|
5
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2025-2026 HoloScript Contributors
|
|
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.
|