@gehlich/calicomp-mcp 1.0.1 → 1.1.1

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 (3) hide show
  1. package/README.md +23 -0
  2. package/dist/index.js +40 -15
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -94,6 +94,29 @@ exclusive JSON-RPC channel.
94
94
  }
95
95
  ```
96
96
 
97
+ ## Exercise Catalog: Curated Entries and Custom Exercises (since 1.1.0)
98
+
99
+ `get_exercise_catalog` — and every training-state calculation that resolves an exercise id —
100
+ returns two kinds of entries, distinguished by the `origin` field:
101
+
102
+ - **`"CATALOG"`** — the curated, admin-maintained catalog (`GET /api/exercises`). These carry
103
+ muscle-group, equipment and capability-axis ratings.
104
+ - **`"CUSTOM"`** — a user's own exercise, created because they cannot write to the curated
105
+ catalog at all. For the closed test circle this is the normal case, not an exception: a custom
106
+ exercise is real training data, but it carries **no** muscle, equipment or capability rating —
107
+ there is nothing to invent, so those fields are always empty. Treat an empty rating as "not
108
+ rated", never as a measured zero.
109
+
110
+ **This does not replace curating the catalog.** If you have admin access and can run the
111
+ catalog round trip (`tools/exercise.mjs` in the super-repo), your own exercises still belong in
112
+ the curated catalog — cleanly, with real ratings. The `CUSTOM` read path exists for everyone who
113
+ cannot do that, so their training data is not silently invisible to the coach. Two user groups,
114
+ two answers; this section covers the one that has no other option.
115
+
116
+ `propose_new_exercise`'s duplicate check only ever matches against `"CATALOG"` entries — a
117
+ proposal for a new catalog exercise can never be blocked by someone else's same-named custom
118
+ exercise.
119
+
97
120
  ## Building
98
121
 
99
122
  ```bash
package/dist/index.js CHANGED
@@ -828,18 +828,15 @@ function normalizeWireTemplateExercise(te) {
828
828
  function decryptMerge(raw, keyB642) {
829
829
  return {
830
830
  syncedAt: raw.syncedAt,
831
- exercises: raw.exercises,
832
- exerciseTranslations: raw.exerciseTranslations,
833
- templates: raw.templates,
834
- blocks: raw.blocks.map(normalizeWireBlock),
835
- templateExercises: raw.templateExercises.map(normalizeWireTemplateExercise),
836
- sessions: raw.sessions.map((s) => decryptSession(s, keyB642)),
837
- setLogs: raw.setLogs.map((sl) => decryptSetLog(sl, keyB642)),
838
- hrSamples: raw.hrSamples.map((hr) => decryptHrSample(hr, keyB642)),
839
- // `?? []` is what makes the optional wire key (SyncPullResponse.plannedWorkouts)
840
- // safe for a user with no planned workouts — see that field's own doc in types.ts.
831
+ exercises: raw.exercises ?? [],
832
+ exerciseTranslations: raw.exerciseTranslations ?? [],
833
+ templates: raw.templates ?? [],
834
+ blocks: (raw.blocks ?? []).map(normalizeWireBlock),
835
+ templateExercises: (raw.templateExercises ?? []).map(normalizeWireTemplateExercise),
836
+ sessions: (raw.sessions ?? []).map((s) => decryptSession(s, keyB642)),
837
+ setLogs: (raw.setLogs ?? []).map((sl) => decryptSetLog(sl, keyB642)),
838
+ hrSamples: (raw.hrSamples ?? []).map((hr) => decryptHrSample(hr, keyB642)),
841
839
  plannedWorkouts: (raw.plannedWorkouts ?? []).map((pw) => decryptPlannedWorkout(pw, keyB642)),
842
- // `?? []` — same reasoning as plannedWorkouts above, for a user with no synced settings.
843
840
  settings: (raw.settings ?? []).map((s) => decryptSetting(s, keyB642))
844
841
  };
845
842
  }
@@ -848,9 +845,34 @@ function normalizeCatalog(wireCatalog) {
848
845
  ...ex,
849
846
  capabilities: ex.capabilities ?? [],
850
847
  equipment: ex.equipment ?? [],
851
- isSkill: ex.isSkill ?? false
848
+ isSkill: ex.isSkill ?? false,
849
+ origin: "CATALOG"
852
850
  }));
853
851
  }
852
+ function projectUserExerciseToCatalog(ex, allTranslations) {
853
+ const translations = allTranslations.filter((t) => t.exerciseId === ex.id).map((t) => ({
854
+ languageCode: t.languageCode,
855
+ name: t.name,
856
+ description: t.description
857
+ }));
858
+ return {
859
+ id: ex.id,
860
+ key: `user_exercise:${ex.id}`,
861
+ nameEn: ex.name,
862
+ mode: ex.mode,
863
+ usesWeight: ex.usesWeight,
864
+ description: ex.description,
865
+ lastModifiedAt: ex.updatedAt,
866
+ translations,
867
+ // Never invented — an unrated exercise contributes nothing to a weighted evaluation, and a
868
+ // fabricated non-empty value here would look like a real measurement (T-141-80).
869
+ muscleGroups: [],
870
+ equipment: [],
871
+ isSkill: false,
872
+ capabilities: [],
873
+ origin: "CUSTOM"
874
+ };
875
+ }
854
876
  async function doFetch(pat2, keyB642, serverUrl2) {
855
877
  const [rawSnapshot, profile, rawCatalog] = await Promise.all([
856
878
  fetchSnapshot(pat2, serverUrl2),
@@ -858,7 +880,10 @@ async function doFetch(pat2, keyB642, serverUrl2) {
858
880
  fetchCatalog(serverUrl2)
859
881
  ]);
860
882
  const snapshot = decryptMerge(rawSnapshot, keyB642);
861
- const catalog = normalizeCatalog(rawCatalog);
883
+ const curatedCatalog = normalizeCatalog(rawCatalog);
884
+ const curatedIds = new Set(curatedCatalog.map((ex) => ex.id));
885
+ const projectedUserExercises = snapshot.exercises.filter((ex) => !ex.deletedAt && !curatedIds.has(ex.id)).map((ex) => projectUserExerciseToCatalog(ex, snapshot.exerciseTranslations));
886
+ const catalog = [...curatedCatalog, ...projectedUserExercises];
862
887
  return { snapshot, catalog, profile, fetchedAt: Date.now() };
863
888
  }
864
889
  async function getSnapshot(pat2, keyB642, serverUrl2) {
@@ -1437,7 +1462,7 @@ function registerToolGetExerciseCatalog(server2, cfg) {
1437
1462
  "get_exercise_catalog",
1438
1463
  {
1439
1464
  title: "Get Exercise Catalog",
1440
- description: 'Return the full exercise catalog with muscle groups, equipment, and optional localized names. Provide `lang` (e.g. "de", "fr") to add a `localizedName` field (falls back to `nameEn` when no translation exists). Use `muscleGroups[].key` with `get_stats { by: "muscle" }` to query aggregates by muscle group.',
1465
+ description: 'Return the full exercise catalog with muscle groups, equipment, and optional localized names. Provide `lang` (e.g. "de", "fr") to add a `localizedName` field (falls back to `nameEn` when no translation exists). Use `muscleGroups[].key` with `get_stats { by: "muscle" }` to query aggregates by muscle group. The result includes both curated catalog entries AND the user\'s own custom exercises \u2014 check `origin` ("CATALOG" vs "CUSTOM") to tell them apart. A "CUSTOM" entry carries no muscle-group, equipment or capability-axis rating, so it never contributes to a weighted evaluation (muscle balance, capability balance) \u2014 treat that absence as "not rated", never as a real zero.',
1441
1466
  inputSchema: GetExerciseCatalogSchema,
1442
1467
  annotations: { readOnlyHint: true }
1443
1468
  },
@@ -2534,7 +2559,7 @@ function findCatalogMatch(proposedName, catalog) {
2534
2559
  const key = normalizeExerciseName(proposedName);
2535
2560
  if (!key) return void 0;
2536
2561
  return catalog.find(
2537
- (ex) => normalizeExerciseName(ex.nameEn) === key || ex.translations.some((t) => normalizeExerciseName(t.name) === key)
2562
+ (ex) => ex.origin === "CATALOG" && (normalizeExerciseName(ex.nameEn) === key || ex.translations.some((t) => normalizeExerciseName(t.name) === key))
2538
2563
  );
2539
2564
  }
2540
2565
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gehlich/calicomp-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.1.1",
4
4
  "description": "Local stdio MCP for CalisthenicsCompanion",
5
5
  "type": "module",
6
6
  "license": "MIT",