forge-openclaw-plugin 0.2.47 → 0.2.48

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/dist/index.html CHANGED
@@ -13,7 +13,7 @@
13
13
  />
14
14
  <link rel="icon" type="image/png" href="/forge/assets/favicon-BCHm9dUV.ico" />
15
15
  <link rel="alternate icon" href="/forge/assets/favicon-BCHm9dUV.ico" />
16
- <script type="module" crossorigin src="/forge/assets/index-BejDHw1R.js"></script>
16
+ <script type="module" crossorigin src="/forge/assets/index-Bv9FWWsZ.js"></script>
17
17
  <link rel="modulepreload" crossorigin href="/forge/assets/vendor-D_NZFJze.js">
18
18
  <link rel="modulepreload" crossorigin href="/forge/assets/board-CAszQU7Y.js">
19
19
  <link rel="modulepreload" crossorigin href="/forge/assets/ui-B5MjRjKe.js">
@@ -46,6 +46,20 @@ export function canBootstrapOperatorSession(baseUrl) {
46
46
  function isRecord(value) {
47
47
  return typeof value === "object" && value !== null;
48
48
  }
49
+ function extractUpstreamErrorCode(body) {
50
+ return isRecord(body) &&
51
+ isRecord(body.error) &&
52
+ typeof body.error.code === "string"
53
+ ? body.error.code
54
+ : null;
55
+ }
56
+ function buildGuidedUpstreamMessage(result, fallback) {
57
+ const upstreamCode = extractUpstreamErrorCode(result.body);
58
+ if (result.status === 401 && upstreamCode === "auth_required") {
59
+ return `${fallback} In OpenClaw, do not fall back to raw Forge routes. Call forge_get_agent_onboarding, then use the shared plugin tools. For official habit outcomes, use forge_update_entities on entityType "habit" with patch.checkIn instead of a direct check-in route call.`;
60
+ }
61
+ return fallback;
62
+ }
49
63
  function buildErrorBody(code, message) {
50
64
  return {
51
65
  ok: false,
@@ -342,7 +356,7 @@ export function expectForgeSuccess(result) {
342
356
  typeof result.body.error.message === "string"
343
357
  ? result.body.error.message
344
358
  : `Forge API returned ${result.status}`;
345
- throw new ForgePluginError(result.status, "forge_plugin_upstream_error", message);
359
+ throw new ForgePluginError(result.status, "forge_plugin_upstream_error", buildGuidedUpstreamMessage(result, message));
346
360
  }
347
361
  return result.body;
348
362
  }
@@ -848,7 +848,7 @@ export function registerForgePluginTools(api, config) {
848
848
  registerWriteTool(api, config, {
849
849
  name: "forge_update_entities",
850
850
  label: "Update Forge Entities",
851
- description: "Update one or more Forge entities through the ordered batch workflow. Pass `operations` as an array. Each operation must include `entityType`, `id`, and `patch`. This is the preferred update path for calendar_event, work_block_template, task_timebox, preferences basic CRUD entities, and questionnaire_instrument too.",
851
+ description: "Update one or more Forge entities through the ordered batch workflow. Pass `operations` as an array. Each operation must include `entityType`, `id`, and `patch`. This is the preferred update path for calendar_event, work_block_template, task_timebox, preferences basic CRUD entities, questionnaire_instrument, and official habit outcome logging through `habit.patch.checkIn`.",
852
852
  parameters: Type.Object({
853
853
  atomic: Type.Optional(Type.Boolean()),
854
854
  operations: Type.Array(Type.Object({
@@ -830,7 +830,8 @@ const AGENT_ONBOARDING_ENTITY_CATALOG_BASE = [
830
830
  ],
831
831
  searchHints: [
832
832
  "Search by title before creating a duplicate habit.",
833
- "Use linkedTo when the habit should already be attached to a goal, project, task, or Psyche entity."
833
+ "Use linkedTo when the habit should already be attached to a goal, project, task, or Psyche entity.",
834
+ "To log an official habit outcome from the shared agent tool surface, patch the habit through forge_update_entities with checkIn: { status, dateKey?, note?, description? }."
834
835
  ],
835
836
  examples: [
836
837
  '{"title":"Morning training","frequency":"daily","polarity":"positive","linkedGoalIds":["goal_train_body"],"linkedValueIds":["value_steadiness"],"linkedBehaviorIds":["behavior_regulating_walk"]}'
@@ -3096,6 +3097,7 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
3096
3097
  "Ask for the time window, place, or movement item that makes the question concrete.",
3097
3098
  "Ask what they are trying to notice, preserve, or answer through that movement context.",
3098
3099
  "Choose the dedicated day, month, all-time, timeline, places, trip-detail, or selection route once the question shape is clear.",
3100
+ "If the truth of one uncertain span is still unclear, read the timeline or saved-box detail before you mutate it.",
3099
3101
  "Skip the meta lane question when the user already named the exact correction or review target and only one ambiguity remains.",
3100
3102
  "If the request is filling a missing-data gap, use a user-defined movement box rather than a raw stay or trip patch.",
3101
3103
  "If the request is repairing already-saved movement data, use the repair route that matches the saved object instead of treating it like a missing span.",
@@ -3113,6 +3115,7 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
3113
3115
  "Ask what part of the current energy picture feels most important or inaccurate.",
3114
3116
  "Ask what should stay true if they are changing profile or template assumptions.",
3115
3117
  "Ask whether the user is describing a stable weekly shape or just how today feels when the lane is still blurred.",
3118
+ "If the user describes a repeatable day-shape such as 'Mondays crash after lunch', treat that as a weekday-template question before you reach for profile or fatigue-signal routes.",
3116
3119
  "If the user already named the life-force lane clearly, skip the meta lane question and ask only for the specific weekday, profile field, or signal that still matters.",
3117
3120
  "If the request is about a durable baseline, use profile or weekday-template mutation rather than a fatigue signal; if it is about right now, prefer the fatigue-signal route.",
3118
3121
  "If the user wants to see what changed after a write, read the overview back instead of leaving the result implicit.",
@@ -3126,11 +3129,12 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
3126
3129
  askSequence: [
3127
3130
  "Ask whether the job is flow discovery, one flow edit, execution, run history, published output, node-level inspection, or latest-node-output lookup.",
3128
3131
  "Ask which flow, slug, run, or node the request is about.",
3129
- "Ask whether they need the flow contract, a run result, a published output, or a node result.",
3132
+ "Ask whether they need the stable flow contract, one run result, one published output, one node result, or the latest node output.",
3130
3133
  "Ask what the user is trying to learn, repair, or publish through that flow.",
3131
3134
  "If the user already named the flow and action clearly, skip the meta lane question and ask only for the missing run, node, or output scope.",
3132
3135
  "If the user wants a stable public input contract or published output, prefer those dedicated reads instead of detouring through run history first.",
3133
3136
  "If the user is still shaping a payload or edit, prefer flow detail or box catalog reads before asking for structured inputs.",
3137
+ "If the user is debugging one failed run, ask whether the useful artifact is the run summary, one node result, the latest node output, or the published output before you start asking for edits.",
3134
3138
  "Prefer flow detail or published-output reads for stable contracts, and use run or node-result routes only when the user is asking about execution history or debugging.",
3135
3139
  "Route to the dedicated workbench route family once the execution lane is clear."
3136
3140
  ]
@@ -3985,8 +3989,8 @@ function buildAgentOnboardingPayload(request) {
3985
3989
  tokenRecovery: {
3986
3990
  rawTokenStoredByForge: false,
3987
3991
  recoveryAction: "rotate_or_issue_new_token",
3988
- rotationSummary: "Forge reveals raw tokens once. If you lose one, rotate it or issue a new token from Settings and update the plugin config.",
3989
- settingsSummary: "Token creation, rotation, and revocation all live under Forge Settings so recovery is explicit and operator-controlled."
3992
+ rotationSummary: "Forge reveals raw tokens once. If you lose one, rotate it or issue a new token through /api/v1/settings/tokens, then update the plugin config.",
3993
+ settingsSummary: "Token creation, rotation, and revocation all live on explicit settings routes so recovery stays operator-controlled without requiring a browser click."
3990
3994
  },
3991
3995
  requiredHeaders: {
3992
3996
  authorization: "Authorization: Bearer <forge-api-token>",
@@ -4281,19 +4285,24 @@ function buildAgentOnboardingPayload(request) {
4281
4285
  installSteps: [
4282
4286
  "Install the Forge plugin from the repo or published package.",
4283
4287
  "Restart the OpenClaw gateway so the tool surface and UI proxy routes refresh.",
4284
- "Open Forge Settings -> Agents to issue or rotate a managed token when remote scoped auth is needed."
4288
+ "For localhost or Tailscale, finish onboarding through operator-session bootstrap and CLI verification without opening the Forge UI.",
4289
+ "If remote scoped auth is needed, issue or rotate a managed token through /api/v1/settings/tokens and update the plugin config without a Settings click."
4285
4290
  ],
4286
4291
  verifyCommands: [
4287
4292
  `curl -s ${origin}/api/v1/health`,
4288
4293
  "openclaw plugins install ./projects/forge/openclaw-plugin",
4289
4294
  "openclaw plugins info forge-openclaw-plugin",
4290
- "openclaw gateway restart"
4295
+ "openclaw gateway restart",
4296
+ "openclaw forge onboarding",
4297
+ "openclaw forge health"
4291
4298
  ],
4292
4299
  configNotes: [
4293
4300
  "Localhost and Tailscale targets can usually use the operator-session path without a long-lived token.",
4301
+ "The operator-session route is /api/v1/auth/operator-session, so trusted local OpenClaw onboarding does not need a browser confirmation step.",
4294
4302
  "If your current OpenClaw build blocks the repo-local install because of the package scanner, keep the repo folder on plugins.load.paths and verify that plugins info still points at the local Forge source path before continuing.",
4295
4303
  "Use a distinct actor label such as Albert (claw) so OpenClaw-originated work stays obvious in Forge provenance.",
4296
- "Create each agent as a Forge bot user, then use userId or userIds in tool inputs whenever the agent should focus on one human, one bot, or a specific collaboration slice."
4304
+ "Create each agent as a Forge bot user, then use userId or userIds in tool inputs whenever the agent should focus on one human, one bot, or a specific collaboration slice.",
4305
+ "If you genuinely need a durable managed token, create it through /api/v1/settings/tokens instead of sending the operator into the Settings UI."
4297
4306
  ]
4298
4307
  },
4299
4308
  hermes: {
@@ -4301,7 +4310,7 @@ function buildAgentOnboardingPayload(request) {
4301
4310
  installSteps: [
4302
4311
  "Install forge-hermes-plugin into the Python environment Hermes actually runs.",
4303
4312
  "Let Hermes load the Forge plugin and bundled skill pack on startup.",
4304
- "Use Forge Settings -> Agents if Hermes needs a managed token for remote or durable access."
4313
+ "If Hermes needs remote or durable scoped auth, issue a managed token through /api/v1/settings/tokens and update the Hermes config without a Settings click."
4305
4314
  ],
4306
4315
  verifyCommands: [
4307
4316
  "python -m pip show forge-hermes-plugin",
@@ -4436,7 +4445,7 @@ function buildAgentOnboardingPayload(request) {
4436
4445
  saveSuggestionTone: "gentle_optional",
4437
4446
  maxQuestionsPerTurn: 1,
4438
4447
  psycheExplorationRule: "When a Psyche entity needs understanding first, begin with one exploratory question before any working formulation, replacement belief, suggested title, or save pitch. Keep the opening reflection to one or two short sentences, stay in plain prose instead of bullets or numbered lists, keep that first reply short, do not mention Forge search or save structure yet, avoid colons or list-shaped phrasing, prefer what/when/how over why until the experience is grounded, wait for the user's answer before offering a fuller formulation, ask permission before moving from charged exploration into naming or challenge when needed, do not widen into adjacent entities until the current one has a working sentence the user recognizes, and once the lived experience is coherent stop deepening and help the user name it cleanly. When the user is updating a Psyche record because of one fresh episode, anchor in that episode before renaming the durable formulation. If the user accepts the wording, move toward the save instead of reopening deeper exploration.",
4439
- specializedSurfaceRule: "For Movement, Life Force, and Workbench, clarify the lane first, then name the dedicated route family in plain language and do not guess at a generic CRUD path. When the user already named a precise correction or review target, confirm only the route-selecting detail that is still missing. After a concrete specialized-surface correction, read the relevant specialized view back when the user is trying to understand the result rather than just store it. The canonical runtime routes stay under /api/v1/*, and the OpenClaw HTTP mirror exposes the same families under /forge/v1/movement, /forge/v1/life-force, and /forge/v1/workbench.",
4448
+ specializedSurfaceRule: "For Movement, Life Force, and Workbench, clarify the lane first, then name the dedicated route family in plain language and do not guess at a generic CRUD path. If the truth of the current state is still uncertain, read the relevant specialized view before you mutate it. When the user already named a precise correction or review target, confirm only the route-selecting detail that is still missing. After a concrete specialized-surface correction, read the relevant specialized view back when the user is trying to understand the result rather than just store it. The canonical runtime routes stay under /api/v1/*, and the OpenClaw HTTP mirror exposes the same families under /forge/v1/movement, /forge/v1/life-force, and /forge/v1/workbench.",
4440
4449
  reviewShortcutRule: "When the user is reviewing or correcting an existing record, narrow the saved object, timeframe, or route family first. Do not reopen the whole intake unless the user is actually redefining the record.",
4441
4450
  readModelWriteRule: "Self-observation is note-backed and should be written through observed notes with frontmatter.observedAt. Sleep and workout sessions stay on batch CRUD by default; use the reflective review helpers only when enriching one already-known record after review.",
4442
4451
  psycheOpeningQuestionRule: "Prefer a concrete opening question tied to the entity: ask when the value mattered, what happened the last time the pattern appeared, what cue or body signal came first before the behavior, what the belief starts saying about self or outcome, what feels most at risk inside the mode, what the part is trying to get the user to do or stop doing, or where the shift began in the incident. Reflect briefly before the question, choose one follow-up lane at a time, say what is becoming clearer before the next deeper question, and if several Psyche entities are visible hold the adjacent ones lightly until the main container is clear.",
@@ -4460,9 +4469,9 @@ function buildAgentOnboardingPayload(request) {
4460
4469
  batchingRule: "forge_create_entities, forge_update_entities, forge_delete_entities, and forge_restore_entities all accept operations as arrays. Batch CRUD is the default for simple entities, so batch multiple related mutations together instead of reaching for a long list of entity-specific routes.",
4461
4470
  searchRule: "forge_search_entities accepts searches as an array. Search before create or update when duplicate risk exists.",
4462
4471
  createRule: "Each create operation must include entityType and full data. entityType alone is not enough. This includes calendar_event, work_block_template, task_timebox, sleep_session, workout_session, preference CRUD entities, and questionnaire_instrument alongside the usual planning and Psyche entities.",
4463
- updateRule: "Each update operation must include entityType, id, and patch. For projects, lifecycle changes are status patches: active to restart, paused to suspend, completed to finish. Keep task and project scheduling rules on those same patch payloads. Calendar-event updates still run downstream provider projection sync, and manual health-session field edits belong on the batch route by default rather than on the reflective review helpers.",
4472
+ updateRule: "Each update operation must include entityType, id, and patch. For projects, lifecycle changes are status patches: active to restart, paused to suspend, completed to finish. Keep task and project scheduling rules on those same patch payloads. Official habit outcomes can also be logged through forge_update_entities by patching the habit with checkIn: { status, dateKey?, note?, description? } instead of route-hunting. Calendar-event updates still run downstream provider projection sync, and manual health-session field edits belong on the batch route by default rather than on the reflective review helpers.",
4464
4473
  createExample: '{"operations":[{"entityType":"goal","data":{"title":"Create meaningfully"},"clientRef":"goal-create-1"},{"entityType":"goal","data":{"title":"Build a beautiful family"},"clientRef":"goal-create-2"}]}',
4465
- updateExample: '{"operations":[{"entityType":"project","id":"project_123","patch":{"status":"paused","schedulingRules":{"blockWorkBlockKinds":["main_activity"],"allowWorkBlockKinds":["secondary_activity"]}},"clientRef":"project-suspend-1"},{"entityType":"task","id":"task_456","patch":{"plannedDurationSeconds":5400,"schedulingRules":{"allowEventKeywords":["creative"],"blockEventKeywords":["clinic"]}},"clientRef":"task-scheduling-1"}]}'
4474
+ updateExample: '{"operations":[{"entityType":"project","id":"project_123","patch":{"status":"paused","schedulingRules":{"blockWorkBlockKinds":["main_activity"],"allowWorkBlockKinds":["secondary_activity"]}},"clientRef":"project-suspend-1"},{"entityType":"habit","id":"habit_456","patch":{"checkIn":{"status":"missed","note":"Resisted the urge after dinner.","description":"85 sec reset"}},"clientRef":"habit-check-in-1"}]}'
4466
4475
  }
4467
4476
  };
4468
4477
  }
@@ -0,0 +1,465 @@
1
+ import { z } from "zod";
2
+ const scalarSchema = z.union([z.string(), z.number(), z.boolean(), z.null()]);
3
+ export const workoutActivityDescriptorSchema = z.object({
4
+ sourceSystem: z.string().trim().min(1),
5
+ providerActivityType: z.string().trim().min(1),
6
+ providerRawValue: z.number().int().nullable().optional(),
7
+ canonicalKey: z.string().trim().min(1),
8
+ canonicalLabel: z.string().trim().min(1),
9
+ familyKey: z.string().trim().min(1),
10
+ familyLabel: z.string().trim().min(1),
11
+ isFallback: z.boolean().default(false)
12
+ });
13
+ export const workoutMetricSchema = z.object({
14
+ key: z.string().trim().min(1),
15
+ label: z.string().trim().min(1),
16
+ category: z.string().trim().min(1),
17
+ unit: z.string().trim().min(1).default("count"),
18
+ statistic: z.string().trim().min(1).default("value"),
19
+ value: scalarSchema,
20
+ startedAt: z.string().datetime().nullable().optional(),
21
+ endedAt: z.string().datetime().nullable().optional()
22
+ });
23
+ export const workoutEventSchema = z.object({
24
+ type: z.string().trim().min(1),
25
+ label: z.string().trim().min(1),
26
+ startedAt: z.string().datetime(),
27
+ endedAt: z.string().datetime().nullable().optional(),
28
+ durationSeconds: z.number().int().nonnegative().default(0),
29
+ metadata: z.record(z.string(), scalarSchema).default({})
30
+ });
31
+ export const workoutComponentSchema = z.object({
32
+ externalUid: z.string().trim().min(1),
33
+ startedAt: z.string().datetime(),
34
+ endedAt: z.string().datetime().nullable().optional(),
35
+ durationSeconds: z.number().int().nonnegative().default(0),
36
+ activity: workoutActivityDescriptorSchema,
37
+ metrics: z.array(workoutMetricSchema).default([]),
38
+ metadata: z.record(z.string(), scalarSchema).default({})
39
+ });
40
+ export const workoutDetailsSchema = z.object({
41
+ sourceSystem: z.string().trim().min(1),
42
+ metrics: z.array(workoutMetricSchema).default([]),
43
+ events: z.array(workoutEventSchema).default([]),
44
+ components: z.array(workoutComponentSchema).default([]),
45
+ metadata: z.record(z.string(), scalarSchema).default({})
46
+ });
47
+ const APPLE_HEALTH_ACTIVITY_TYPES = new Map([
48
+ [1, { key: "american_football", label: "American football" }],
49
+ [2, { key: "archery", label: "Archery" }],
50
+ [3, { key: "australian_football", label: "Australian football" }],
51
+ [4, { key: "badminton", label: "Badminton" }],
52
+ [5, { key: "baseball", label: "Baseball" }],
53
+ [6, { key: "basketball", label: "Basketball" }],
54
+ [7, { key: "bowling", label: "Bowling" }],
55
+ [8, { key: "boxing", label: "Boxing" }],
56
+ [9, { key: "climbing", label: "Climbing" }],
57
+ [10, { key: "cricket", label: "Cricket" }],
58
+ [11, { key: "cross_training", label: "Cross training" }],
59
+ [12, { key: "curling", label: "Curling" }],
60
+ [13, { key: "cycling", label: "Cycling" }],
61
+ [14, { key: "dance", label: "Dance" }],
62
+ [15, { key: "dance_inspired_training", label: "Dance-inspired training" }],
63
+ [16, { key: "elliptical", label: "Elliptical" }],
64
+ [17, { key: "equestrian_sports", label: "Equestrian sports" }],
65
+ [18, { key: "fencing", label: "Fencing" }],
66
+ [19, { key: "fishing", label: "Fishing" }],
67
+ [20, { key: "functional_strength_training", label: "Functional strength training" }],
68
+ [21, { key: "golf", label: "Golf" }],
69
+ [22, { key: "gymnastics", label: "Gymnastics" }],
70
+ [23, { key: "handball", label: "Handball" }],
71
+ [24, { key: "hiking", label: "Hiking" }],
72
+ [25, { key: "hockey", label: "Hockey" }],
73
+ [26, { key: "hunting", label: "Hunting" }],
74
+ [27, { key: "lacrosse", label: "Lacrosse" }],
75
+ [28, { key: "martial_arts", label: "Martial arts" }],
76
+ [29, { key: "mind_and_body", label: "Mind and body" }],
77
+ [30, { key: "mixed_metabolic_cardio_training", label: "Mixed metabolic cardio training" }],
78
+ [31, { key: "paddle_sports", label: "Paddle sports" }],
79
+ [32, { key: "play", label: "Play" }],
80
+ [33, { key: "preparation_and_recovery", label: "Preparation and recovery" }],
81
+ [34, { key: "racquetball", label: "Racquetball" }],
82
+ [35, { key: "rowing", label: "Rowing" }],
83
+ [36, { key: "rugby", label: "Rugby" }],
84
+ [37, { key: "running", label: "Running" }],
85
+ [38, { key: "sailing", label: "Sailing" }],
86
+ [39, { key: "skating_sports", label: "Skating sports" }],
87
+ [40, { key: "snow_sports", label: "Snow sports" }],
88
+ [41, { key: "soccer", label: "Soccer" }],
89
+ [42, { key: "softball", label: "Softball" }],
90
+ [43, { key: "squash", label: "Squash" }],
91
+ [44, { key: "stair_climbing", label: "Stair climbing" }],
92
+ [45, { key: "surfing_sports", label: "Surfing sports" }],
93
+ [46, { key: "swimming", label: "Swimming" }],
94
+ [47, { key: "table_tennis", label: "Table tennis" }],
95
+ [48, { key: "tennis", label: "Tennis" }],
96
+ [49, { key: "track_and_field", label: "Track and field" }],
97
+ [50, { key: "traditional_strength_training", label: "Traditional strength training" }],
98
+ [51, { key: "volleyball", label: "Volleyball" }],
99
+ [52, { key: "walking", label: "Walking" }],
100
+ [53, { key: "water_fitness", label: "Water fitness" }],
101
+ [54, { key: "water_polo", label: "Water polo" }],
102
+ [55, { key: "water_sports", label: "Water sports" }],
103
+ [56, { key: "wrestling", label: "Wrestling" }],
104
+ [57, { key: "yoga", label: "Yoga" }],
105
+ [58, { key: "barre", label: "Barre" }],
106
+ [59, { key: "core_training", label: "Core training" }],
107
+ [60, { key: "cross_country_skiing", label: "Cross-country skiing" }],
108
+ [61, { key: "downhill_skiing", label: "Downhill skiing" }],
109
+ [62, { key: "flexibility", label: "Flexibility" }],
110
+ [63, { key: "high_intensity_interval_training", label: "High-intensity interval training" }],
111
+ [64, { key: "jump_rope", label: "Jump rope" }],
112
+ [65, { key: "kickboxing", label: "Kickboxing" }],
113
+ [66, { key: "pilates", label: "Pilates" }],
114
+ [67, { key: "snowboarding", label: "Snowboarding" }],
115
+ [68, { key: "stairs", label: "Stairs" }],
116
+ [69, { key: "step_training", label: "Step training" }],
117
+ [70, { key: "wheelchair_walk_pace", label: "Wheelchair walk pace" }],
118
+ [71, { key: "wheelchair_run_pace", label: "Wheelchair run pace" }],
119
+ [72, { key: "tai_chi", label: "Tai chi" }],
120
+ [73, { key: "mixed_cardio", label: "Mixed cardio" }],
121
+ [74, { key: "hand_cycling", label: "Hand cycling" }],
122
+ [75, { key: "disc_sports", label: "Disc sports" }],
123
+ [76, { key: "fitness_gaming", label: "Fitness gaming" }],
124
+ [77, { key: "cardio_dance", label: "Cardio dance" }],
125
+ [78, { key: "social_dance", label: "Social dance" }],
126
+ [79, { key: "pickleball", label: "Pickleball" }],
127
+ [80, { key: "cooldown", label: "Cooldown" }],
128
+ [82, { key: "swim_bike_run", label: "Swim-bike-run" }],
129
+ [83, { key: "transition", label: "Transition" }],
130
+ [84, { key: "underwater_diving", label: "Underwater diving" }],
131
+ [3000, { key: "other", label: "Other" }]
132
+ ]);
133
+ const CARDIO_KEYS = new Set([
134
+ "walking",
135
+ "running",
136
+ "cycling",
137
+ "rowing",
138
+ "elliptical",
139
+ "hiking",
140
+ "mixed_cardio",
141
+ "mixed_metabolic_cardio_training",
142
+ "high_intensity_interval_training",
143
+ "jump_rope",
144
+ "stair_climbing",
145
+ "stairs",
146
+ "step_training",
147
+ "cross_country_skiing",
148
+ "downhill_skiing",
149
+ "snowboarding",
150
+ "hand_cycling",
151
+ "wheelchair_walk_pace",
152
+ "wheelchair_run_pace",
153
+ "track_and_field",
154
+ "cross_training",
155
+ "cardio_dance",
156
+ "fitness_gaming",
157
+ "swim_bike_run",
158
+ "transition"
159
+ ]);
160
+ const STRENGTH_KEYS = new Set([
161
+ "traditional_strength_training",
162
+ "functional_strength_training",
163
+ "core_training",
164
+ "cross_training",
165
+ "climbing"
166
+ ]);
167
+ const MOBILITY_KEYS = new Set([
168
+ "barre",
169
+ "pilates",
170
+ "flexibility",
171
+ "preparation_and_recovery",
172
+ "cooldown"
173
+ ]);
174
+ const MINDFUL_KEYS = new Set([
175
+ "mind_and_body",
176
+ "yoga",
177
+ "tai_chi"
178
+ ]);
179
+ const WATER_KEYS = new Set([
180
+ "swimming",
181
+ "water_fitness",
182
+ "water_polo",
183
+ "water_sports",
184
+ "paddle_sports",
185
+ "surfing_sports",
186
+ "sailing",
187
+ "underwater_diving"
188
+ ]);
189
+ const TEAM_SPORT_KEYS = new Set([
190
+ "american_football",
191
+ "australian_football",
192
+ "baseball",
193
+ "basketball",
194
+ "cricket",
195
+ "handball",
196
+ "hockey",
197
+ "lacrosse",
198
+ "rugby",
199
+ "soccer",
200
+ "softball",
201
+ "volleyball",
202
+ "water_polo"
203
+ ]);
204
+ const RACKET_KEYS = new Set([
205
+ "badminton",
206
+ "pickleball",
207
+ "racquetball",
208
+ "squash",
209
+ "table_tennis",
210
+ "tennis"
211
+ ]);
212
+ const COMBAT_KEYS = new Set([
213
+ "boxing",
214
+ "kickboxing",
215
+ "martial_arts",
216
+ "wrestling",
217
+ "fencing"
218
+ ]);
219
+ const WINTER_KEYS = new Set([
220
+ "cross_country_skiing",
221
+ "downhill_skiing",
222
+ "snow_sports",
223
+ "snowboarding",
224
+ "curling"
225
+ ]);
226
+ function cleanString(value) {
227
+ return typeof value === "string" && value.trim().length > 0
228
+ ? value.trim()
229
+ : null;
230
+ }
231
+ function humanizeKey(value) {
232
+ return value
233
+ .trim()
234
+ .replace(/^activity_/i, "")
235
+ .replaceAll("_", " ")
236
+ .replace(/\s+/g, " ")
237
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
238
+ }
239
+ function normalizeCanonicalKey(value) {
240
+ return value
241
+ .trim()
242
+ .toLowerCase()
243
+ .replaceAll("-", "_")
244
+ .replace(/\s+/g, "_");
245
+ }
246
+ function resolveActivityFamily(key) {
247
+ const normalized = normalizeCanonicalKey(key);
248
+ if (CARDIO_KEYS.has(normalized)) {
249
+ return { familyKey: "cardio", familyLabel: "Cardio" };
250
+ }
251
+ if (STRENGTH_KEYS.has(normalized)) {
252
+ return { familyKey: "strength", familyLabel: "Strength" };
253
+ }
254
+ if (MOBILITY_KEYS.has(normalized)) {
255
+ return { familyKey: "mobility", familyLabel: "Mobility" };
256
+ }
257
+ if (MINDFUL_KEYS.has(normalized)) {
258
+ return { familyKey: "mindful", familyLabel: "Mindful" };
259
+ }
260
+ if (WATER_KEYS.has(normalized)) {
261
+ return { familyKey: "water", familyLabel: "Water" };
262
+ }
263
+ if (TEAM_SPORT_KEYS.has(normalized)) {
264
+ return { familyKey: "team_sport", familyLabel: "Team sport" };
265
+ }
266
+ if (RACKET_KEYS.has(normalized)) {
267
+ return { familyKey: "racket", familyLabel: "Racket" };
268
+ }
269
+ if (COMBAT_KEYS.has(normalized)) {
270
+ return { familyKey: "combat", familyLabel: "Combat" };
271
+ }
272
+ if (WINTER_KEYS.has(normalized)) {
273
+ return { familyKey: "winter", familyLabel: "Winter" };
274
+ }
275
+ if (normalized.includes("dance") ||
276
+ normalized === "play" ||
277
+ normalized === "disc_sports" ||
278
+ normalized === "golf" ||
279
+ normalized === "gymnastics" ||
280
+ normalized === "bowling") {
281
+ return { familyKey: "recreation", familyLabel: "Recreation" };
282
+ }
283
+ return { familyKey: "other", familyLabel: "Other" };
284
+ }
285
+ function inferSourceSystem(source, sourceType, provenance) {
286
+ const fromProvenance = cleanString(provenance?.sourceSystem);
287
+ if (fromProvenance) {
288
+ return fromProvenance;
289
+ }
290
+ if (source === "apple_health" || sourceType.includes("healthkit")) {
291
+ return "apple_health";
292
+ }
293
+ if (source === "forge_habit" || source === "manual" || sourceType === "manual") {
294
+ return "forge";
295
+ }
296
+ return cleanString(sourceType) ?? cleanString(source) ?? "unknown";
297
+ }
298
+ function buildFallbackActivity(sourceSystem, workoutType, providerActivityType = "generic_workout_type", providerRawValue = null, isFallback = false) {
299
+ const canonicalKey = normalizeCanonicalKey(workoutType.length > 0 ? workoutType : "workout");
300
+ const canonicalLabel = humanizeKey(canonicalKey);
301
+ const family = resolveActivityFamily(canonicalKey);
302
+ return {
303
+ sourceSystem,
304
+ providerActivityType,
305
+ providerRawValue,
306
+ canonicalKey,
307
+ canonicalLabel,
308
+ familyKey: family.familyKey,
309
+ familyLabel: family.familyLabel,
310
+ isFallback
311
+ };
312
+ }
313
+ function normalizeAppleHealthActivity(workoutType, existingActivity) {
314
+ if (existingActivity?.sourceSystem === "apple_health") {
315
+ const family = resolveActivityFamily(existingActivity.canonicalKey);
316
+ return {
317
+ ...existingActivity,
318
+ familyKey: family.familyKey,
319
+ familyLabel: family.familyLabel
320
+ };
321
+ }
322
+ const rawMatch = /^activity_(\d+)$/i.exec(workoutType.trim());
323
+ const rawValue = rawMatch ? Number(rawMatch[1]) : null;
324
+ if (rawValue != null) {
325
+ const catalog = APPLE_HEALTH_ACTIVITY_TYPES.get(rawValue);
326
+ if (catalog) {
327
+ const family = resolveActivityFamily(catalog.key);
328
+ return {
329
+ sourceSystem: "apple_health",
330
+ providerActivityType: "hk_workout_activity_type",
331
+ providerRawValue: rawValue,
332
+ canonicalKey: catalog.key,
333
+ canonicalLabel: catalog.label,
334
+ familyKey: family.familyKey,
335
+ familyLabel: family.familyLabel,
336
+ isFallback: false
337
+ };
338
+ }
339
+ }
340
+ const normalizedKey = normalizeCanonicalKey(workoutType);
341
+ for (const [providerRawValue, catalog] of APPLE_HEALTH_ACTIVITY_TYPES.entries()) {
342
+ if (catalog.key === normalizedKey) {
343
+ const family = resolveActivityFamily(catalog.key);
344
+ return {
345
+ sourceSystem: "apple_health",
346
+ providerActivityType: "hk_workout_activity_type",
347
+ providerRawValue,
348
+ canonicalKey: catalog.key,
349
+ canonicalLabel: catalog.label,
350
+ familyKey: family.familyKey,
351
+ familyLabel: family.familyLabel,
352
+ isFallback: false
353
+ };
354
+ }
355
+ }
356
+ return buildFallbackActivity("apple_health", normalizedKey, "hk_workout_activity_type", rawValue, true);
357
+ }
358
+ const workoutSourceAdapters = new Map([
359
+ [
360
+ "apple_health",
361
+ {
362
+ sourceSystem: "apple_health",
363
+ normalizeActivity: ({ workoutType, existingActivity }) => normalizeAppleHealthActivity(workoutType, existingActivity)
364
+ }
365
+ ],
366
+ [
367
+ "forge",
368
+ {
369
+ sourceSystem: "forge",
370
+ normalizeActivity: ({ workoutType, existingActivity }) => {
371
+ if (existingActivity) {
372
+ return {
373
+ ...existingActivity,
374
+ ...resolveActivityFamily(existingActivity.canonicalKey)
375
+ };
376
+ }
377
+ return buildFallbackActivity("forge", workoutType, "forge_workout_type");
378
+ }
379
+ }
380
+ ]
381
+ ]);
382
+ function getWorkoutSourceAdapter(sourceSystem) {
383
+ return (workoutSourceAdapters.get(sourceSystem) ??
384
+ {
385
+ sourceSystem,
386
+ normalizeActivity: ({ workoutType, existingActivity }) => {
387
+ if (existingActivity) {
388
+ return {
389
+ ...existingActivity,
390
+ ...resolveActivityFamily(existingActivity.canonicalKey)
391
+ };
392
+ }
393
+ return buildFallbackActivity(sourceSystem, workoutType, "generic_workout_type");
394
+ }
395
+ });
396
+ }
397
+ function normalizeWorkoutDetails(sourceSystem, value) {
398
+ const parsed = workoutDetailsSchema.safeParse(value);
399
+ if (!parsed.success) {
400
+ return {
401
+ sourceSystem,
402
+ metrics: [],
403
+ events: [],
404
+ components: [],
405
+ metadata: {}
406
+ };
407
+ }
408
+ return {
409
+ ...parsed.data,
410
+ sourceSystem,
411
+ metrics: [...parsed.data.metrics].sort((left, right) => {
412
+ if (left.category === right.category) {
413
+ return left.label.localeCompare(right.label);
414
+ }
415
+ return left.category.localeCompare(right.category);
416
+ }),
417
+ events: [...parsed.data.events].sort((left, right) => left.startedAt.localeCompare(right.startedAt)),
418
+ components: [...parsed.data.components].sort((left, right) => left.startedAt.localeCompare(right.startedAt))
419
+ };
420
+ }
421
+ export function buildWorkoutSessionPresentation(input) {
422
+ const provenance = input.provenance ?? {};
423
+ const derived = input.derived ?? {};
424
+ const sourceSystem = inferSourceSystem(input.source, input.sourceType, provenance);
425
+ const storedActivity = workoutActivityDescriptorSchema.safeParse(derived.activity).success
426
+ ? workoutActivityDescriptorSchema.parse(derived.activity)
427
+ : workoutActivityDescriptorSchema.safeParse(provenance.activity).success
428
+ ? workoutActivityDescriptorSchema.parse(provenance.activity)
429
+ : null;
430
+ const adapter = getWorkoutSourceAdapter(sourceSystem);
431
+ const activity = adapter.normalizeActivity({
432
+ workoutType: input.workoutType,
433
+ existingActivity: storedActivity
434
+ });
435
+ const details = normalizeWorkoutDetails(sourceSystem, derived.details ?? provenance.details);
436
+ return {
437
+ sourceSystem,
438
+ sourceBundleIdentifier: cleanString(provenance.sourceBundleIdentifier),
439
+ sourceProductType: cleanString(provenance.sourceProductType),
440
+ workoutType: activity.canonicalKey,
441
+ workoutTypeLabel: activity.canonicalLabel,
442
+ activityFamily: activity.familyKey,
443
+ activityFamilyLabel: activity.familyLabel,
444
+ activity,
445
+ details
446
+ };
447
+ }
448
+ export function buildWorkoutSessionPersistenceSeed(input) {
449
+ const sourceSystem = cleanString(input.sourceSystem) ??
450
+ inferSourceSystem(input.source, input.sourceType, undefined);
451
+ const parsedActivity = workoutActivityDescriptorSchema.safeParse(input.activity);
452
+ const adapter = getWorkoutSourceAdapter(sourceSystem);
453
+ const activity = adapter.normalizeActivity({
454
+ workoutType: input.workoutType,
455
+ existingActivity: parsedActivity.success ? parsedActivity.data : null
456
+ });
457
+ const details = normalizeWorkoutDetails(sourceSystem, input.details);
458
+ return {
459
+ sourceSystem,
460
+ sourceBundleIdentifier: cleanString(input.sourceBundleIdentifier),
461
+ sourceProductType: cleanString(input.sourceProductType),
462
+ activity,
463
+ details
464
+ };
465
+ }