forge-openclaw-plugin 0.2.26 → 0.2.28

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 (109) hide show
  1. package/README.md +60 -3
  2. package/dist/assets/{board-ta0rUHOf.js → board-DPFvZf-D.js} +2 -2
  3. package/dist/assets/{board-ta0rUHOf.js.map → board-DPFvZf-D.js.map} +1 -1
  4. package/dist/assets/index-Auw3JrdE.css +1 -0
  5. package/dist/assets/index-D1H7myQH.js +85 -0
  6. package/dist/assets/index-D1H7myQH.js.map +1 -0
  7. package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js +2 -0
  8. package/dist/assets/knowledge-graph-layout.worker-DRvzPxhP.js.map +1 -0
  9. package/dist/assets/{motion-fBKPB6yw.js → motion-Bvwc85ch.js} +2 -2
  10. package/dist/assets/{motion-fBKPB6yw.js.map → motion-Bvwc85ch.js.map} +1 -1
  11. package/dist/assets/{table-C-IGTQni.js → table-FJQTJvUR.js} +2 -2
  12. package/dist/assets/{table-C-IGTQni.js.map → table-FJQTJvUR.js.map} +1 -1
  13. package/dist/assets/{ui-DInOpaYF.js → ui-GXFcgvSw.js} +2 -2
  14. package/dist/assets/{ui-DInOpaYF.js.map → ui-GXFcgvSw.js.map} +1 -1
  15. package/dist/assets/vendor-Cwf49UMz.js +1247 -0
  16. package/dist/assets/vendor-Cwf49UMz.js.map +1 -0
  17. package/dist/index.html +7 -7
  18. package/dist/openclaw/local-runtime.js +16 -0
  19. package/dist/openclaw/routes.d.ts +27 -0
  20. package/dist/openclaw/routes.js +16 -12
  21. package/dist/server/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
  22. package/dist/server/server/migrations/038_data_management_settings.sql +11 -0
  23. package/dist/server/server/migrations/039_life_force_and_action_points.sql +114 -0
  24. package/dist/server/server/migrations/040_screen_time_domain.sql +89 -0
  25. package/dist/server/server/migrations/041_companion_source_states.sql +21 -0
  26. package/dist/server/server/migrations/042_movement_boxes.sql +47 -0
  27. package/dist/server/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
  28. package/dist/server/server/src/app.js +1900 -91
  29. package/dist/server/server/src/connectors/box-registry.js +44 -9
  30. package/dist/server/server/src/data-management-types.js +107 -0
  31. package/dist/server/server/src/db.js +68 -4
  32. package/dist/server/server/src/demo-data.js +2 -2
  33. package/dist/server/server/src/health.js +702 -18
  34. package/dist/server/server/src/managers/platform/llm-manager.js +7 -4
  35. package/dist/server/server/src/managers/platform/mock-workbench-provider.js +149 -0
  36. package/dist/server/server/src/managers/platform/secrets-manager.js +18 -1
  37. package/dist/server/server/src/managers/runtime.js +9 -0
  38. package/dist/server/server/src/movement.js +1971 -112
  39. package/dist/server/server/src/openapi.js +1390 -105
  40. package/dist/server/server/src/psyche-types.js +9 -1
  41. package/dist/server/server/src/repositories/activity-events.js +8 -0
  42. package/dist/server/server/src/repositories/ai-connectors.js +522 -74
  43. package/dist/server/server/src/repositories/calendar.js +151 -0
  44. package/dist/server/server/src/repositories/habits.js +37 -1
  45. package/dist/server/server/src/repositories/model-settings.js +13 -3
  46. package/dist/server/server/src/repositories/notes.js +3 -0
  47. package/dist/server/server/src/repositories/settings.js +380 -18
  48. package/dist/server/server/src/repositories/tasks.js +170 -10
  49. package/dist/server/server/src/runtime-data-root.js +82 -0
  50. package/dist/server/server/src/screen-time.js +802 -0
  51. package/dist/server/server/src/services/data-management.js +788 -0
  52. package/dist/server/server/src/services/entity-crud.js +205 -2
  53. package/dist/server/server/src/services/knowledge-graph.js +1455 -0
  54. package/dist/server/server/src/services/life-force-model.js +217 -0
  55. package/dist/server/server/src/services/life-force.js +2506 -0
  56. package/dist/server/server/src/services/psyche-observation-calendar.js +383 -16
  57. package/dist/server/server/src/types.js +307 -14
  58. package/dist/server/server/src/web.js +228 -13
  59. package/dist/server/src/components/customization/utility-widgets.js +136 -27
  60. package/dist/server/src/components/ui/info-tooltip.js +25 -0
  61. package/dist/server/src/components/workbench-boxes/calendar/calendar-boxes.js +78 -0
  62. package/dist/server/src/components/workbench-boxes/goals/goals-boxes.js +62 -0
  63. package/dist/server/src/components/workbench-boxes/habits/habits-boxes.js +62 -0
  64. package/dist/server/src/components/workbench-boxes/health/health-boxes.js +63 -8
  65. package/dist/server/src/components/workbench-boxes/insights/insights-boxes.js +50 -0
  66. package/dist/server/src/components/workbench-boxes/kanban/kanban-boxes.js +62 -54
  67. package/dist/server/src/components/workbench-boxes/movement/movement-boxes.js +18 -8
  68. package/dist/server/src/components/workbench-boxes/notes/notes-boxes.js +56 -38
  69. package/dist/server/src/components/workbench-boxes/overview/overview-boxes.js +65 -0
  70. package/dist/server/src/components/workbench-boxes/preferences/preferences-boxes.js +78 -0
  71. package/dist/server/src/components/workbench-boxes/projects/projects-boxes.js +35 -30
  72. package/dist/server/src/components/workbench-boxes/psyche/psyche-boxes.js +88 -0
  73. package/dist/server/src/components/workbench-boxes/questionnaires/questionnaires-boxes.js +61 -0
  74. package/dist/server/src/components/workbench-boxes/review/review-boxes.js +53 -0
  75. package/dist/server/src/components/workbench-boxes/shared/define-workbench-box.js +3 -1
  76. package/dist/server/src/components/workbench-boxes/shared/generic-node-view.js +39 -3
  77. package/dist/server/src/components/workbench-boxes/strategies/strategies-boxes.js +62 -0
  78. package/dist/server/src/components/workbench-boxes/tasks/tasks-boxes.js +76 -0
  79. package/dist/server/src/components/workbench-boxes/today/today-boxes.js +47 -32
  80. package/dist/server/src/components/workbench-boxes/wiki/wiki-boxes.js +60 -0
  81. package/dist/server/src/lib/api.js +280 -21
  82. package/dist/server/src/lib/data-management-types.js +1 -0
  83. package/dist/server/src/lib/entity-visuals.js +279 -0
  84. package/dist/server/src/lib/knowledge-graph-types.js +276 -0
  85. package/dist/server/src/lib/knowledge-graph.js +470 -0
  86. package/dist/server/src/lib/schemas.js +4 -0
  87. package/dist/server/src/lib/snapshot-normalizer.js +45 -1
  88. package/dist/server/src/lib/workbench/contracts.js +229 -0
  89. package/dist/server/src/lib/workbench/nodes.js +200 -0
  90. package/dist/server/src/lib/workbench/registry.js +52 -5
  91. package/dist/server/src/lib/workbench/runtime.js +254 -38
  92. package/dist/server/src/lib/workbench/tool-catalog.js +68 -0
  93. package/openclaw.plugin.json +1 -1
  94. package/package.json +1 -1
  95. package/server/migrations/037_workbench_public_inputs_and_run_inputs.sql +5 -0
  96. package/server/migrations/038_data_management_settings.sql +11 -0
  97. package/server/migrations/039_life_force_and_action_points.sql +114 -0
  98. package/server/migrations/040_screen_time_domain.sql +89 -0
  99. package/server/migrations/041_companion_source_states.sql +21 -0
  100. package/server/migrations/042_movement_boxes.sql +47 -0
  101. package/server/migrations/043_movement_box_overlap_overrides.sql +26 -0
  102. package/skills/forge-openclaw/SKILL.md +41 -11
  103. package/skills/forge-openclaw/entity_conversation_playbooks.md +448 -34
  104. package/skills/forge-openclaw/psyche_entity_playbooks.md +170 -17
  105. package/dist/assets/index-Ro0ZF_az.css +0 -1
  106. package/dist/assets/index-ytlpSj23.js +0 -79
  107. package/dist/assets/index-ytlpSj23.js.map +0 -1
  108. package/dist/assets/vendor-lE3tZJcC.js +0 -876
  109. package/dist/assets/vendor-lE3tZJcC.js.map +0 -1
@@ -3,8 +3,10 @@ import { z } from "zod";
3
3
  import { getDatabase, runInTransaction } from "./db.js";
4
4
  import { HttpError } from "./errors.js";
5
5
  import { getMovementMobileBootstrap, ingestMovementSync, movementSyncPayloadSchema } from "./movement.js";
6
+ import { ingestScreenTimeSync, screenTimeSyncPayloadSchema } from "./screen-time.js";
6
7
  import { recordActivityEvent } from "./repositories/activity-events.js";
7
8
  import { recordHabitGeneratedWorkoutReward } from "./repositories/rewards.js";
9
+ import { resolveUserForMutation } from "./repositories/users.js";
8
10
  const healthLinkSchema = z.object({
9
11
  entityType: z.string().trim().min(1),
10
12
  entityId: z.string().trim().min(1),
@@ -50,6 +52,34 @@ const pairingStatusSchema = z.enum([
50
52
  "error",
51
53
  "revoked"
52
54
  ]);
55
+ export const companionSourceKeySchema = z.enum([
56
+ "health",
57
+ "movement",
58
+ "screenTime"
59
+ ]);
60
+ const companionSourceAuthorizationStatusSchema = z.enum([
61
+ "not_determined",
62
+ "pending",
63
+ "approved",
64
+ "denied",
65
+ "restricted",
66
+ "unavailable",
67
+ "partial",
68
+ "disabled"
69
+ ]);
70
+ const companionSourceStateSchema = z.object({
71
+ desiredEnabled: z.boolean().default(true),
72
+ appliedEnabled: z.boolean().default(false),
73
+ authorizationStatus: companionSourceAuthorizationStatusSchema.default("not_determined"),
74
+ syncEligible: z.boolean().default(false),
75
+ lastObservedAt: z.string().datetime().nullable().default(null),
76
+ metadata: z.record(z.string(), z.unknown()).default({})
77
+ });
78
+ const companionSourceStatesSchema = z.object({
79
+ health: companionSourceStateSchema.default({}),
80
+ movement: companionSourceStateSchema.default({}),
81
+ screenTime: companionSourceStateSchema.default({})
82
+ });
53
83
  export const createCompanionPairingSessionSchema = z.object({
54
84
  label: z.string().trim().default("Forge Companion"),
55
85
  userId: z.string().trim().nullable().optional(),
@@ -74,6 +104,20 @@ export const revokeAllCompanionPairingSessionsSchema = z.object({
74
104
  userIds: z.array(z.string().trim().min(1)).default([]),
75
105
  includeRevoked: z.boolean().default(false)
76
106
  });
107
+ export const patchCompanionPairingSourceStateSchema = z.object({
108
+ desiredEnabled: z.boolean()
109
+ });
110
+ export const updateMobileCompanionSourceStateSchema = z.object({
111
+ sessionId: z.string().trim().min(1),
112
+ pairingToken: z.string().trim().min(1),
113
+ source: companionSourceKeySchema,
114
+ desiredEnabled: z.boolean(),
115
+ appliedEnabled: z.boolean(),
116
+ authorizationStatus: companionSourceAuthorizationStatusSchema,
117
+ syncEligible: z.boolean().default(false),
118
+ lastObservedAt: z.string().datetime().nullable().optional(),
119
+ metadata: z.record(z.string(), z.unknown()).default({})
120
+ });
77
121
  export const mobileHealthSyncSchema = z.object({
78
122
  sessionId: z.string().trim().min(1),
79
123
  pairingToken: z.string().trim().min(1),
@@ -87,7 +131,8 @@ export const mobileHealthSyncSchema = z.object({
87
131
  healthKitAuthorized: z.boolean().default(false),
88
132
  backgroundRefreshEnabled: z.boolean().default(false),
89
133
  motionReady: z.boolean().default(false),
90
- locationReady: z.boolean().default(false)
134
+ locationReady: z.boolean().default(false),
135
+ screenTimeReady: z.boolean().default(false)
91
136
  }),
92
137
  sleepSessions: z
93
138
  .array(z.object({
@@ -121,7 +166,9 @@ export const mobileHealthSyncSchema = z.object({
121
166
  annotations: workoutAnnotationSchema.partial().default({})
122
167
  }))
123
168
  .default([]),
124
- movement: movementSyncPayloadSchema.default({})
169
+ sourceStates: companionSourceStatesSchema.default({}),
170
+ movement: movementSyncPayloadSchema.default({}),
171
+ screenTime: screenTimeSyncPayloadSchema.default({})
125
172
  });
126
173
  export const verifyCompanionPairingSchema = z.object({
127
174
  sessionId: z.string().trim().min(1),
@@ -149,9 +196,67 @@ export const updateSleepMetadataSchema = z.object({
149
196
  tags: z.array(z.string().trim()).optional(),
150
197
  links: z.array(healthLinkSchema).optional()
151
198
  });
199
+ const manualHealthProvenanceSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]));
200
+ export const createSleepSessionSchema = z.object({
201
+ userId: z.string().trim().min(1).nullable().optional(),
202
+ externalUid: z.string().trim().min(1).optional(),
203
+ source: z.string().trim().min(1).default("manual"),
204
+ sourceType: z.string().trim().min(1).default("manual"),
205
+ sourceDevice: z.string().trim().min(1).default("Forge"),
206
+ startedAt: z.string().datetime(),
207
+ endedAt: z.string().datetime(),
208
+ timeInBedSeconds: z.number().int().nonnegative().optional(),
209
+ asleepSeconds: z.number().int().nonnegative().optional(),
210
+ awakeSeconds: z.number().int().nonnegative().optional(),
211
+ stageBreakdown: z.array(healthStageSchema).default([]),
212
+ recoveryMetrics: sleepRecoveryMetricSchema.default({}),
213
+ qualitySummary: z.string().trim().default(""),
214
+ notes: z.string().trim().default(""),
215
+ tags: z.array(z.string().trim()).default([]),
216
+ links: z.array(healthLinkSchema).default([]),
217
+ provenance: manualHealthProvenanceSchema.default({})
218
+ });
219
+ export const updateSleepSessionSchema = createSleepSessionSchema
220
+ .omit({ userId: true })
221
+ .partial();
222
+ export const createWorkoutSessionSchema = z.object({
223
+ userId: z.string().trim().min(1).nullable().optional(),
224
+ externalUid: z.string().trim().min(1).optional(),
225
+ source: z.string().trim().min(1).default("manual"),
226
+ sourceType: z.string().trim().min(1).default("manual"),
227
+ sourceDevice: z.string().trim().min(1).default("Forge"),
228
+ workoutType: z.string().trim().min(1),
229
+ startedAt: z.string().datetime(),
230
+ endedAt: z.string().datetime(),
231
+ activeEnergyKcal: z.number().nonnegative().nullable().optional(),
232
+ totalEnergyKcal: z.number().nonnegative().nullable().optional(),
233
+ distanceMeters: z.number().nonnegative().nullable().optional(),
234
+ stepCount: z.number().int().nonnegative().nullable().optional(),
235
+ exerciseMinutes: z.number().nonnegative().nullable().optional(),
236
+ averageHeartRate: z.number().nonnegative().nullable().optional(),
237
+ maxHeartRate: z.number().nonnegative().nullable().optional(),
238
+ subjectiveEffort: z.number().int().min(1).max(10).nullable().optional(),
239
+ moodBefore: z.string().trim().default(""),
240
+ moodAfter: z.string().trim().default(""),
241
+ meaningText: z.string().trim().default(""),
242
+ plannedContext: z.string().trim().default(""),
243
+ socialContext: z.string().trim().default(""),
244
+ tags: z.array(z.string().trim()).default([]),
245
+ links: z.array(healthLinkSchema).default([]),
246
+ provenance: manualHealthProvenanceSchema.default({})
247
+ });
248
+ export const updateWorkoutSessionSchema = createWorkoutSessionSchema
249
+ .omit({ userId: true })
250
+ .partial();
152
251
  function nowIso() {
153
252
  return new Date().toISOString();
154
253
  }
254
+ function durationSecondsBetween(startedAt, endedAt) {
255
+ return Math.max(0, Math.round((Date.parse(endedAt) - Date.parse(startedAt)) / 1000));
256
+ }
257
+ function resolveHealthUserId(userId) {
258
+ return resolveUserForMutation(userId ?? null).id;
259
+ }
155
260
  function safeJsonParse(value, fallback) {
156
261
  if (!value) {
157
262
  return fallback;
@@ -163,6 +268,86 @@ function safeJsonParse(value, fallback) {
163
268
  return fallback;
164
269
  }
165
270
  }
271
+ function listPairingSourceStateRows(pairingSessionId) {
272
+ return getDatabase()
273
+ .prepare(`SELECT *
274
+ FROM companion_pairing_source_states
275
+ WHERE pairing_session_id = ?
276
+ ORDER BY source_key ASC`)
277
+ .all(pairingSessionId);
278
+ }
279
+ function defaultCompanionSourceState(source, pairing) {
280
+ const defaultAuthorizationStatus = source === "screenTime" ? "not_determined" : "pending";
281
+ return {
282
+ id: `pairsrc_${randomUUID().replaceAll("-", "").slice(0, 12)}`,
283
+ pairing_session_id: pairing.id,
284
+ user_id: pairing.user_id,
285
+ source_key: source,
286
+ desired_enabled: 1,
287
+ applied_enabled: pairing.paired_at ? 1 : 0,
288
+ authorization_status: defaultAuthorizationStatus,
289
+ sync_eligible: 0,
290
+ last_observed_at: pairing.paired_at ?? null,
291
+ metadata_json: "{}",
292
+ created_at: pairing.updated_at,
293
+ updated_at: pairing.updated_at
294
+ };
295
+ }
296
+ function ensurePairingSourceStates(pairing) {
297
+ const existing = listPairingSourceStateRows(pairing.id);
298
+ const bySource = new Map(existing.map((row) => [row.source_key, row]));
299
+ const missing = companionSourceKeySchema.options.filter((source) => bySource.has(source) === false);
300
+ if (missing.length > 0) {
301
+ const insert = getDatabase().prepare(`INSERT INTO companion_pairing_source_states (
302
+ id, pairing_session_id, user_id, source_key, desired_enabled, applied_enabled,
303
+ authorization_status, sync_eligible, last_observed_at, metadata_json, created_at, updated_at
304
+ )
305
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
306
+ for (const source of missing) {
307
+ const row = defaultCompanionSourceState(source, pairing);
308
+ insert.run(row.id, row.pairing_session_id, row.user_id, row.source_key, row.desired_enabled, row.applied_enabled, row.authorization_status, row.sync_eligible, row.last_observed_at, row.metadata_json, row.created_at, row.updated_at);
309
+ }
310
+ }
311
+ const refreshed = listPairingSourceStateRows(pairing.id);
312
+ return companionSourceStatesSchema.parse(refreshed.reduce((accumulator, row) => {
313
+ accumulator[row.source_key] = {
314
+ desiredEnabled: row.desired_enabled === 1,
315
+ appliedEnabled: row.applied_enabled === 1,
316
+ authorizationStatus: row.authorization_status,
317
+ syncEligible: row.sync_eligible === 1,
318
+ lastObservedAt: row.last_observed_at,
319
+ metadata: safeJsonParse(row.metadata_json, {})
320
+ };
321
+ return accumulator;
322
+ }, {}));
323
+ }
324
+ function upsertPairingSourceState(pairing, source, patch) {
325
+ ensurePairingSourceStates(pairing);
326
+ const current = listPairingSourceStateRows(pairing.id).find((row) => row.source_key === source);
327
+ if (!current) {
328
+ throw new Error(`Missing companion pairing source state for ${source}`);
329
+ }
330
+ const nextMetadata = patch.metadata != null
331
+ ? {
332
+ ...safeJsonParse(current.metadata_json, {}),
333
+ ...patch.metadata
334
+ }
335
+ : safeJsonParse(current.metadata_json, {});
336
+ const nextDesiredEnabled = patch.desiredEnabled ?? (current.desired_enabled === 1);
337
+ const nextAppliedEnabled = patch.appliedEnabled ?? (current.applied_enabled === 1);
338
+ const nextSyncEligible = patch.syncEligible ?? (current.sync_eligible === 1);
339
+ const nextUpdatedAt = nowIso();
340
+ getDatabase()
341
+ .prepare(`UPDATE companion_pairing_source_states
342
+ SET desired_enabled = ?, applied_enabled = ?, authorization_status = ?,
343
+ sync_eligible = ?, last_observed_at = ?, metadata_json = ?, updated_at = ?
344
+ WHERE pairing_session_id = ? AND source_key = ?`)
345
+ .run(nextDesiredEnabled ? 1 : 0, nextAppliedEnabled ? 1 : 0, patch.authorizationStatus ?? current.authorization_status, nextSyncEligible ? 1 : 0, patch.lastObservedAt ?? current.last_observed_at, JSON.stringify(nextMetadata), nextUpdatedAt, pairing.id, source);
346
+ const refreshedPairing = getDatabase()
347
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
348
+ .get(pairing.id);
349
+ return mapPairingSession(refreshedPairing);
350
+ }
166
351
  function dayKey(value) {
167
352
  return value.slice(0, 10);
168
353
  }
@@ -302,6 +487,7 @@ function mapPairingSession(row) {
302
487
  const stale = row.last_sync_at &&
303
488
  Date.now() - Date.parse(row.last_sync_at) > 1000 * 60 * 60 * 24;
304
489
  const effectiveStatus = row.status === "healthy" && stale ? "stale" : row.status;
490
+ const sourceStates = ensurePairingSourceStates(row);
305
491
  return {
306
492
  id: row.id,
307
493
  userId: row.user_id,
@@ -316,6 +502,7 @@ function mapPairingSession(row) {
316
502
  lastSyncAt: row.last_sync_at,
317
503
  lastSyncError: row.last_sync_error,
318
504
  pairedAt: row.paired_at,
505
+ sourceStates,
319
506
  expiresAt: row.expires_at,
320
507
  createdAt: row.created_at,
321
508
  updatedAt: row.updated_at
@@ -436,6 +623,24 @@ function listWorkoutRows(userIds) {
436
623
  ORDER BY started_at DESC`)
437
624
  .all(...params);
438
625
  }
626
+ export function listSleepSessions(userIds) {
627
+ return listSleepRows(userIds).map(mapSleepSession);
628
+ }
629
+ export function listWorkoutSessions(userIds) {
630
+ return listWorkoutRows(userIds).map(mapWorkoutSession);
631
+ }
632
+ export function getSleepSessionById(sleepId) {
633
+ const row = getDatabase()
634
+ .prepare(`SELECT * FROM health_sleep_sessions WHERE id = ?`)
635
+ .get(sleepId);
636
+ return row ? mapSleepSession(row) : undefined;
637
+ }
638
+ export function getWorkoutSessionById(workoutId) {
639
+ const row = getDatabase()
640
+ .prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
641
+ .get(workoutId);
642
+ return row ? mapWorkoutSession(row) : undefined;
643
+ }
439
644
  function listPairingRows(userIds) {
440
645
  const params = [];
441
646
  const where = userIds && userIds.length > 0
@@ -500,6 +705,12 @@ function listHealthImportRunRows(userIds, limit = 12) {
500
705
  export function listPairingSessions(userIds) {
501
706
  return listPairingRows(userIds).map(mapPairingSession);
502
707
  }
708
+ export function getCompanionPairingSessionById(pairingSessionId) {
709
+ const row = getDatabase()
710
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
711
+ .get(pairingSessionId);
712
+ return row ? mapPairingSession(row) : undefined;
713
+ }
503
714
  export function revokeCompanionPairingSession(pairingSessionId, activity) {
504
715
  const current = getDatabase()
505
716
  .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
@@ -523,6 +734,36 @@ export function revokeAllCompanionPairingSessions(input, activity) {
523
734
  sessions
524
735
  };
525
736
  }
737
+ export function patchCompanionPairingSourceState(pairingSessionId, source, patch) {
738
+ const pairing = getDatabase()
739
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
740
+ .get(pairingSessionId);
741
+ if (!pairing) {
742
+ return undefined;
743
+ }
744
+ return upsertPairingSourceState(pairing, source, {
745
+ desiredEnabled: patch.desiredEnabled,
746
+ metadata: {
747
+ desiredEnabledUpdatedAt: nowIso(),
748
+ desiredEnabledUpdatedBy: "forge_web"
749
+ }
750
+ });
751
+ }
752
+ export function updateMobileCompanionSourceState(payload) {
753
+ const parsed = updateMobileCompanionSourceStateSchema.parse(payload);
754
+ const pairing = requireValidPairing(parsed.sessionId, parsed.pairingToken);
755
+ return upsertPairingSourceState(pairing, parsed.source, {
756
+ desiredEnabled: parsed.desiredEnabled,
757
+ appliedEnabled: parsed.appliedEnabled,
758
+ authorizationStatus: parsed.authorizationStatus,
759
+ syncEligible: parsed.syncEligible,
760
+ lastObservedAt: parsed.lastObservedAt ?? nowIso(),
761
+ metadata: {
762
+ ...parsed.metadata,
763
+ source: "companion"
764
+ }
765
+ });
766
+ }
526
767
  export function createCompanionPairingSession(baseApiUrl, input) {
527
768
  const parsed = createCompanionPairingSessionSchema.parse(input);
528
769
  const now = new Date();
@@ -547,13 +788,18 @@ export function createCompanionPairingSession(baseApiUrl, input) {
547
788
  reason: "Superseded by a newer pairing QR"
548
789
  });
549
790
  }
550
- getDatabase()
551
- .prepare(`INSERT INTO companion_pairing_sessions (
552
- id, user_id, label, pairing_token, status, capability_flags_json, api_base_url,
553
- expires_at, created_at, updated_at
554
- )
555
- VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`)
556
- .run(id, userId, parsed.label, pairingToken, serializedCapabilities, baseApiUrl, expiresAt, now.toISOString(), now.toISOString());
791
+ runInTransaction(() => {
792
+ getDatabase()
793
+ .prepare(`INSERT INTO companion_pairing_sessions (
794
+ id, user_id, label, pairing_token, status, capability_flags_json, api_base_url,
795
+ expires_at, created_at, updated_at
796
+ )
797
+ VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`)
798
+ .run(id, userId, parsed.label, pairingToken, serializedCapabilities, baseApiUrl, expiresAt, now.toISOString(), now.toISOString());
799
+ ensurePairingSourceStates(getDatabase()
800
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
801
+ .get(id));
802
+ });
557
803
  const qrPayload = {
558
804
  kind: "forge-companion-pairing",
559
805
  apiBaseUrl: baseApiUrl,
@@ -602,6 +848,9 @@ export function verifyCompanionPairing(payload) {
602
848
  });
603
849
  }
604
850
  }
851
+ ensurePairingSourceStates(getDatabase()
852
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
853
+ .get(pairing.id));
605
854
  return {
606
855
  pairingSession: mapPairingSession(getDatabase()
607
856
  .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
@@ -623,6 +872,61 @@ export function requireValidPairing(sessionId, pairingToken) {
623
872
  }
624
873
  return row;
625
874
  }
875
+ function normalizeHealthAuthorizationStatus(healthAccessAuthorized, sleepCount, workoutCount) {
876
+ if (healthAccessAuthorized) {
877
+ return "approved";
878
+ }
879
+ if (sleepCount > 0 || workoutCount > 0) {
880
+ return "partial";
881
+ }
882
+ return "not_determined";
883
+ }
884
+ function syncPairingSourceStatesFromPayload(pairing, payload) {
885
+ upsertPairingSourceState(pairing, "health", {
886
+ desiredEnabled: payload.sourceStates.health.desiredEnabled,
887
+ appliedEnabled: payload.sourceStates.health.appliedEnabled,
888
+ authorizationStatus: normalizeHealthAuthorizationStatus(payload.permissions.healthKitAuthorized, payload.sleepSessions.length, payload.workouts.length),
889
+ syncEligible: payload.sourceStates.health.syncEligible,
890
+ lastObservedAt: payload.sourceStates.health.lastObservedAt ?? nowIso(),
891
+ metadata: payload.sourceStates.health.metadata
892
+ });
893
+ upsertPairingSourceState(pairing, "movement", {
894
+ desiredEnabled: payload.sourceStates.movement.desiredEnabled,
895
+ appliedEnabled: payload.sourceStates.movement.appliedEnabled,
896
+ authorizationStatus: payload.movement.settings.locationPermissionStatus === "always" ||
897
+ payload.movement.settings.locationPermissionStatus === "when_in_use"
898
+ ? "approved"
899
+ : payload.movement.settings.locationPermissionStatus === "restricted"
900
+ ? "restricted"
901
+ : payload.movement.settings.locationPermissionStatus === "denied"
902
+ ? "denied"
903
+ : "not_determined",
904
+ syncEligible: payload.sourceStates.movement.syncEligible,
905
+ lastObservedAt: payload.sourceStates.movement.lastObservedAt ?? nowIso(),
906
+ metadata: {
907
+ ...payload.sourceStates.movement.metadata,
908
+ motionPermissionStatus: payload.movement.settings.motionPermissionStatus,
909
+ backgroundTrackingReady: payload.movement.settings.backgroundTrackingReady
910
+ }
911
+ });
912
+ upsertPairingSourceState(pairing, "screenTime", {
913
+ desiredEnabled: payload.sourceStates.screenTime.desiredEnabled,
914
+ appliedEnabled: payload.sourceStates.screenTime.appliedEnabled,
915
+ authorizationStatus: payload.screenTime.settings.authorizationStatus === "approved"
916
+ ? "approved"
917
+ : payload.screenTime.settings.authorizationStatus === "denied"
918
+ ? "denied"
919
+ : payload.screenTime.settings.authorizationStatus === "unavailable"
920
+ ? "unavailable"
921
+ : "not_determined",
922
+ syncEligible: payload.sourceStates.screenTime.syncEligible,
923
+ lastObservedAt: payload.sourceStates.screenTime.lastObservedAt ?? nowIso(),
924
+ metadata: {
925
+ ...payload.sourceStates.screenTime.metadata,
926
+ captureState: payload.screenTime.settings.captureState
927
+ }
928
+ });
929
+ }
626
930
  function findMatchingGeneratedWorkout(input) {
627
931
  const rows = getDatabase()
628
932
  .prepare(`SELECT *
@@ -840,7 +1144,9 @@ export function ingestMobileHealthSync(payload) {
840
1144
  let createdCount = 0;
841
1145
  let updatedCount = 0;
842
1146
  let mergedCount = 0;
1147
+ syncPairingSourceStatesFromPayload(pairing, parsed);
843
1148
  const movementSync = ingestMovementSync(pairing, parsed.movement);
1149
+ const screenTimeSync = ingestScreenTimeSync(pairing, parsed.screenTime, parsed.device.sourceDevice);
844
1150
  for (const sleep of parsed.sleepSessions) {
845
1151
  const result = insertOrUpdateSleepSession(pairing, sleep);
846
1152
  if (result.mode === "created") {
@@ -887,11 +1193,19 @@ export function ingestMobileHealthSync(payload) {
887
1193
  knownPlaces: parsed.movement.knownPlaces.length,
888
1194
  stays: parsed.movement.stays.length,
889
1195
  trips: parsed.movement.trips.length
1196
+ },
1197
+ screenTime: {
1198
+ daySummaries: parsed.screenTime.daySummaries.length,
1199
+ hourlySegments: parsed.screenTime.hourlySegments.length,
1200
+ authorizationStatus: parsed.screenTime.settings.authorizationStatus,
1201
+ captureState: parsed.screenTime.settings.captureState
890
1202
  }
891
1203
  }), parsed.sleepSessions.length +
892
1204
  parsed.workouts.length +
893
1205
  parsed.movement.stays.length +
894
- parsed.movement.trips.length, createdCount + movementSync.createdCount, updatedCount + movementSync.updatedCount, mergedCount, now, now, now);
1206
+ parsed.movement.trips.length +
1207
+ parsed.screenTime.daySummaries.length +
1208
+ parsed.screenTime.hourlySegments.length, createdCount + movementSync.createdCount + screenTimeSync.createdCount, updatedCount + movementSync.updatedCount + screenTimeSync.updatedCount, mergedCount, now, now, now);
895
1209
  recordActivityEvent({
896
1210
  entityType: "system",
897
1211
  entityId: pairing.id,
@@ -905,8 +1219,10 @@ export function ingestMobileHealthSync(payload) {
905
1219
  workouts: parsed.workouts.length,
906
1220
  movementStays: parsed.movement.stays.length,
907
1221
  movementTrips: parsed.movement.trips.length,
1222
+ screenTimeDaySummaries: parsed.screenTime.daySummaries.length,
1223
+ screenTimeHourlySegments: parsed.screenTime.hourlySegments.length,
908
1224
  createdCount: createdCount + movementSync.createdCount,
909
- updatedCount: updatedCount + movementSync.updatedCount,
1225
+ updatedCount: updatedCount + movementSync.updatedCount + screenTimeSync.updatedCount,
910
1226
  mergedCount
911
1227
  }
912
1228
  });
@@ -917,12 +1233,14 @@ export function ingestMobileHealthSync(payload) {
917
1233
  imported: {
918
1234
  sleepSessions: parsed.sleepSessions.length,
919
1235
  workouts: parsed.workouts.length,
920
- createdCount: createdCount + movementSync.createdCount,
921
- updatedCount: updatedCount + movementSync.updatedCount,
1236
+ createdCount: createdCount + movementSync.createdCount + screenTimeSync.createdCount,
1237
+ updatedCount: updatedCount + movementSync.updatedCount + screenTimeSync.updatedCount,
922
1238
  mergedCount,
923
1239
  movementStays: parsed.movement.stays.length,
924
1240
  movementTrips: parsed.movement.trips.length,
925
- movementKnownPlaces: parsed.movement.knownPlaces.length
1241
+ movementKnownPlaces: parsed.movement.knownPlaces.length,
1242
+ screenTimeDaySummaries: parsed.screenTime.daySummaries.length,
1243
+ screenTimeHourlySegments: parsed.screenTime.hourlySegments.length
926
1244
  },
927
1245
  movement: getMovementMobileBootstrap(pairing)
928
1246
  };
@@ -938,9 +1256,19 @@ export function getCompanionOverview(userIds) {
938
1256
  return {
939
1257
  knownPlaces: totals.knownPlaces + (movement.knownPlaces ?? 0),
940
1258
  stays: totals.stays + (movement.stays ?? 0),
941
- trips: totals.trips + (movement.trips ?? 0)
1259
+ trips: totals.trips + (movement.trips ?? 0),
1260
+ screenTimeDays: totals.screenTimeDays +
1261
+ (run.payloadSummary.screenTime?.daySummaries ?? 0),
1262
+ screenTimeHourlySegments: totals.screenTimeHourlySegments +
1263
+ (run.payloadSummary.screenTime?.hourlySegments ?? 0)
942
1264
  };
943
- }, { knownPlaces: 0, stays: 0, trips: 0 });
1265
+ }, {
1266
+ knownPlaces: 0,
1267
+ stays: 0,
1268
+ trips: 0,
1269
+ screenTimeDays: 0,
1270
+ screenTimeHourlySegments: 0
1271
+ });
944
1272
  const activePairings = pairings.filter((pairing) => pairing.status !== "revoked");
945
1273
  const recentPermissionStates = importRuns
946
1274
  .map((run) => safeJsonParse(JSON.stringify(run.payloadSummary), {}))
@@ -979,13 +1307,16 @@ export function getCompanionOverview(userIds) {
979
1307
  reconciledWorkouts: workouts.filter((session) => session.reconciliationStatus === "merged").length,
980
1308
  movementKnownPlaces: movementSummary.knownPlaces,
981
1309
  movementStays: movementSummary.stays,
982
- movementTrips: movementSummary.trips
1310
+ movementTrips: movementSummary.trips,
1311
+ screenTimeDaySummaries: movementSummary.screenTimeDays,
1312
+ screenTimeHourlySegments: movementSummary.screenTimeHourlySegments
983
1313
  },
984
1314
  permissions: {
985
1315
  healthKitAuthorized: recentPermissionStates.some((state) => state.healthKitAuthorized === true),
986
1316
  backgroundRefreshEnabled: recentPermissionStates.some((state) => state.backgroundRefreshEnabled === true),
987
1317
  locationReady: recentPermissionStates.some((state) => state.locationReady === true),
988
- motionReady: recentPermissionStates.some((state) => state.motionReady === true)
1318
+ motionReady: recentPermissionStates.some((state) => state.motionReady === true),
1319
+ screenTimeReady: recentPermissionStates.some((state) => state.screenTimeReady === true)
989
1320
  }
990
1321
  };
991
1322
  }
@@ -1136,6 +1467,359 @@ export function getFitnessViewData(userIds) {
1136
1467
  sessions: recent
1137
1468
  };
1138
1469
  }
1470
+ export function createSleepSession(input, activity) {
1471
+ const parsed = createSleepSessionSchema.parse(input);
1472
+ const userId = resolveHealthUserId(parsed.userId);
1473
+ const now = nowIso();
1474
+ const id = `sleep_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
1475
+ const externalUid = parsed.externalUid ??
1476
+ `manual_sleep_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
1477
+ const timeInBedSeconds = parsed.timeInBedSeconds ??
1478
+ durationSecondsBetween(parsed.startedAt, parsed.endedAt);
1479
+ const asleepSeconds = parsed.asleepSeconds ?? timeInBedSeconds;
1480
+ const awakeSeconds = parsed.awakeSeconds ?? Math.max(0, timeInBedSeconds - asleepSeconds);
1481
+ const sleepScore = computeSleepScore({
1482
+ asleepSeconds,
1483
+ timeInBedSeconds,
1484
+ awakeSeconds,
1485
+ stageBreakdown: parsed.stageBreakdown
1486
+ });
1487
+ const timingMetrics = computeSleepTimingMetrics({
1488
+ userId,
1489
+ startedAt: parsed.startedAt,
1490
+ endedAt: parsed.endedAt
1491
+ });
1492
+ const annotations = {
1493
+ qualitySummary: parsed.qualitySummary,
1494
+ notes: parsed.notes,
1495
+ tags: parsed.tags,
1496
+ links: parsed.links
1497
+ };
1498
+ const derived = computeSleepDerivedMetrics({
1499
+ asleepSeconds,
1500
+ timeInBedSeconds,
1501
+ awakeSeconds,
1502
+ sleepScore,
1503
+ stageBreakdown: parsed.stageBreakdown
1504
+ });
1505
+ getDatabase()
1506
+ .prepare(`INSERT INTO health_sleep_sessions (
1507
+ id, external_uid, pairing_session_id, user_id, source, source_type, source_device,
1508
+ started_at, ended_at, time_in_bed_seconds, asleep_seconds, awake_seconds,
1509
+ sleep_score, regularity_score, bedtime_consistency_minutes, wake_consistency_minutes,
1510
+ stage_breakdown_json, recovery_metrics_json, links_json, annotations_json,
1511
+ provenance_json, derived_json, created_at, updated_at
1512
+ )
1513
+ VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1514
+ .run(id, externalUid, userId, parsed.source, parsed.sourceType, parsed.sourceDevice, parsed.startedAt, parsed.endedAt, timeInBedSeconds, asleepSeconds, awakeSeconds, sleepScore, timingMetrics.regularityScore, timingMetrics.bedtimeConsistencyMinutes, timingMetrics.wakeConsistencyMinutes, JSON.stringify(parsed.stageBreakdown), JSON.stringify(parsed.recoveryMetrics), JSON.stringify(parsed.links), JSON.stringify(annotations), JSON.stringify({
1515
+ manualEntry: true,
1516
+ entryMode: "local",
1517
+ source: parsed.source,
1518
+ sourceType: parsed.sourceType,
1519
+ sourceDevice: parsed.sourceDevice,
1520
+ actor: activity?.actor ?? null,
1521
+ createdAt: now,
1522
+ ...parsed.provenance
1523
+ }), JSON.stringify(derived), now, now);
1524
+ summarizeUserHealthDay(userId, dayKey(parsed.endedAt));
1525
+ recordActivityEvent({
1526
+ entityType: "sleep_session",
1527
+ entityId: id,
1528
+ eventType: "sleep_session_created",
1529
+ title: "Sleep session created",
1530
+ description: "A manual sleep session was created in Forge.",
1531
+ actor: activity?.actor ?? null,
1532
+ source: activity?.source ?? "ui",
1533
+ metadata: {
1534
+ userId,
1535
+ startedAt: parsed.startedAt,
1536
+ endedAt: parsed.endedAt
1537
+ }
1538
+ });
1539
+ return getSleepSessionById(id);
1540
+ }
1541
+ export function updateSleepSession(sleepId, patch, activity) {
1542
+ const parsed = updateSleepSessionSchema.parse(patch);
1543
+ const current = getDatabase()
1544
+ .prepare(`SELECT * FROM health_sleep_sessions WHERE id = ?`)
1545
+ .get(sleepId);
1546
+ if (!current) {
1547
+ return undefined;
1548
+ }
1549
+ const now = nowIso();
1550
+ const startedAt = parsed.startedAt ?? current.started_at;
1551
+ const endedAt = parsed.endedAt ?? current.ended_at;
1552
+ const timeInBedSeconds = parsed.timeInBedSeconds ??
1553
+ (parsed.startedAt !== undefined || parsed.endedAt !== undefined
1554
+ ? durationSecondsBetween(startedAt, endedAt)
1555
+ : current.time_in_bed_seconds);
1556
+ const asleepSeconds = parsed.asleepSeconds ?? current.asleep_seconds;
1557
+ const awakeSeconds = parsed.awakeSeconds ?? Math.max(0, timeInBedSeconds - asleepSeconds);
1558
+ const stageBreakdown = parsed.stageBreakdown ?? safeJsonParse(current.stage_breakdown_json, []);
1559
+ const recoveryMetrics = parsed.recoveryMetrics ?? safeJsonParse(current.recovery_metrics_json, {});
1560
+ const currentAnnotations = safeJsonParse(current.annotations_json, {});
1561
+ const links = parsed.links ?? safeJsonParse(current.links_json, []);
1562
+ const annotations = {
1563
+ qualitySummary: parsed.qualitySummary ??
1564
+ (typeof currentAnnotations.qualitySummary === "string"
1565
+ ? currentAnnotations.qualitySummary
1566
+ : ""),
1567
+ notes: parsed.notes ??
1568
+ (typeof currentAnnotations.notes === "string"
1569
+ ? currentAnnotations.notes
1570
+ : ""),
1571
+ tags: parsed.tags ??
1572
+ (Array.isArray(currentAnnotations.tags)
1573
+ ? currentAnnotations.tags
1574
+ : []),
1575
+ links
1576
+ };
1577
+ const sleepScore = computeSleepScore({
1578
+ asleepSeconds,
1579
+ timeInBedSeconds,
1580
+ awakeSeconds,
1581
+ stageBreakdown
1582
+ });
1583
+ const timingMetrics = computeSleepTimingMetrics({
1584
+ userId: current.user_id,
1585
+ startedAt,
1586
+ endedAt,
1587
+ excludeSleepId: current.id
1588
+ });
1589
+ const derived = computeSleepDerivedMetrics({
1590
+ asleepSeconds,
1591
+ timeInBedSeconds,
1592
+ awakeSeconds,
1593
+ sleepScore,
1594
+ stageBreakdown
1595
+ });
1596
+ const currentProvenance = safeJsonParse(current.provenance_json, {});
1597
+ getDatabase()
1598
+ .prepare(`UPDATE health_sleep_sessions
1599
+ SET external_uid = ?, source = ?, source_type = ?, source_device = ?,
1600
+ started_at = ?, ended_at = ?, time_in_bed_seconds = ?, asleep_seconds = ?, awake_seconds = ?,
1601
+ sleep_score = ?, regularity_score = ?, bedtime_consistency_minutes = ?, wake_consistency_minutes = ?,
1602
+ stage_breakdown_json = ?, recovery_metrics_json = ?, links_json = ?, annotations_json = ?,
1603
+ provenance_json = ?, derived_json = ?, updated_at = ?
1604
+ WHERE id = ?`)
1605
+ .run(parsed.externalUid ?? current.external_uid, parsed.source ?? current.source, parsed.sourceType ?? current.source_type, parsed.sourceDevice ?? current.source_device, startedAt, endedAt, timeInBedSeconds, asleepSeconds, awakeSeconds, sleepScore, timingMetrics.regularityScore, timingMetrics.bedtimeConsistencyMinutes, timingMetrics.wakeConsistencyMinutes, JSON.stringify(stageBreakdown), JSON.stringify(recoveryMetrics), JSON.stringify(links), JSON.stringify(annotations), JSON.stringify({
1606
+ ...currentProvenance,
1607
+ ...(parsed.provenance ?? {}),
1608
+ updatedAt: now,
1609
+ updatedByActor: activity?.actor ?? null
1610
+ }), JSON.stringify(derived), now, sleepId);
1611
+ summarizeUserHealthDay(current.user_id, dayKey(current.ended_at));
1612
+ summarizeUserHealthDay(current.user_id, dayKey(endedAt));
1613
+ recordActivityEvent({
1614
+ entityType: "sleep_session",
1615
+ entityId: sleepId,
1616
+ eventType: "sleep_session_updated",
1617
+ title: "Sleep session updated",
1618
+ description: "A sleep session was updated through Forge CRUD.",
1619
+ actor: activity?.actor ?? null,
1620
+ source: activity?.source ?? "ui",
1621
+ metadata: {
1622
+ startedAt,
1623
+ endedAt
1624
+ }
1625
+ });
1626
+ return getSleepSessionById(sleepId);
1627
+ }
1628
+ export function deleteSleepSession(sleepId, activity) {
1629
+ const current = getDatabase()
1630
+ .prepare(`SELECT * FROM health_sleep_sessions WHERE id = ?`)
1631
+ .get(sleepId);
1632
+ if (!current) {
1633
+ return undefined;
1634
+ }
1635
+ getDatabase()
1636
+ .prepare(`DELETE FROM health_sleep_sessions WHERE id = ?`)
1637
+ .run(sleepId);
1638
+ summarizeUserHealthDay(current.user_id, dayKey(current.ended_at));
1639
+ recordActivityEvent({
1640
+ entityType: "sleep_session",
1641
+ entityId: sleepId,
1642
+ eventType: "sleep_session_deleted",
1643
+ title: "Sleep session deleted",
1644
+ description: "A sleep session was permanently removed from Forge.",
1645
+ actor: activity?.actor ?? null,
1646
+ source: activity?.source ?? "ui",
1647
+ metadata: {
1648
+ startedAt: current.started_at,
1649
+ endedAt: current.ended_at
1650
+ }
1651
+ });
1652
+ return mapSleepSession(current);
1653
+ }
1654
+ export function createWorkoutSession(input, activity) {
1655
+ const parsed = createWorkoutSessionSchema.parse(input);
1656
+ const userId = resolveHealthUserId(parsed.userId);
1657
+ const now = nowIso();
1658
+ const id = `workout_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
1659
+ const externalUid = parsed.externalUid ??
1660
+ `manual_workout_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
1661
+ const durationSeconds = durationSecondsBetween(parsed.startedAt, parsed.endedAt);
1662
+ const annotations = {
1663
+ subjectiveEffort: parsed.subjectiveEffort ?? null,
1664
+ moodBefore: parsed.moodBefore,
1665
+ moodAfter: parsed.moodAfter,
1666
+ meaningText: parsed.meaningText,
1667
+ plannedContext: parsed.plannedContext,
1668
+ socialContext: parsed.socialContext,
1669
+ tags: parsed.tags,
1670
+ links: parsed.links
1671
+ };
1672
+ getDatabase()
1673
+ .prepare(`INSERT INTO health_workout_sessions (
1674
+ id, external_uid, pairing_session_id, user_id, source, source_type, workout_type, source_device,
1675
+ started_at, ended_at, duration_seconds, active_energy_kcal, total_energy_kcal, distance_meters,
1676
+ step_count, exercise_minutes, average_heart_rate, max_heart_rate, subjective_effort, mood_before,
1677
+ mood_after, meaning_text, planned_context, social_context, links_json, tags_json, annotations_json,
1678
+ provenance_json, derived_json, reconciliation_status, created_at, updated_at
1679
+ )
1680
+ VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'standalone', ?, ?)`)
1681
+ .run(id, externalUid, userId, parsed.source, parsed.sourceType, parsed.workoutType, parsed.sourceDevice, parsed.startedAt, parsed.endedAt, durationSeconds, parsed.activeEnergyKcal ?? null, parsed.totalEnergyKcal ?? null, parsed.distanceMeters ?? null, parsed.stepCount ?? null, parsed.exerciseMinutes ?? null, parsed.averageHeartRate ?? null, parsed.maxHeartRate ?? null, parsed.subjectiveEffort ?? null, parsed.moodBefore, parsed.moodAfter, parsed.meaningText, parsed.plannedContext, parsed.socialContext, JSON.stringify(parsed.links), JSON.stringify(parsed.tags), JSON.stringify(annotations), JSON.stringify({
1682
+ manualEntry: true,
1683
+ entryMode: "local",
1684
+ source: parsed.source,
1685
+ sourceType: parsed.sourceType,
1686
+ sourceDevice: parsed.sourceDevice,
1687
+ actor: activity?.actor ?? null,
1688
+ createdAt: now,
1689
+ ...parsed.provenance
1690
+ }), JSON.stringify({
1691
+ paceMetersPerMinute: parsed.distanceMeters && parsed.exerciseMinutes
1692
+ ? Number((parsed.distanceMeters / parsed.exerciseMinutes).toFixed(2))
1693
+ : null
1694
+ }), now, now);
1695
+ summarizeUserHealthDay(userId, dayKey(parsed.startedAt));
1696
+ recordActivityEvent({
1697
+ entityType: "workout_session",
1698
+ entityId: id,
1699
+ eventType: "workout_session_created",
1700
+ title: "Workout session created",
1701
+ description: "A manual workout session was created in Forge.",
1702
+ actor: activity?.actor ?? null,
1703
+ source: activity?.source ?? "ui",
1704
+ metadata: {
1705
+ userId,
1706
+ workoutType: parsed.workoutType
1707
+ }
1708
+ });
1709
+ return getWorkoutSessionById(id);
1710
+ }
1711
+ export function updateWorkoutSession(workoutId, patch, activity) {
1712
+ const parsed = updateWorkoutSessionSchema.parse(patch);
1713
+ const current = getDatabase()
1714
+ .prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
1715
+ .get(workoutId);
1716
+ if (!current) {
1717
+ return undefined;
1718
+ }
1719
+ const now = nowIso();
1720
+ const startedAt = parsed.startedAt ?? current.started_at;
1721
+ const endedAt = parsed.endedAt ?? current.ended_at;
1722
+ const durationSeconds = parsed.startedAt !== undefined || parsed.endedAt !== undefined
1723
+ ? durationSecondsBetween(startedAt, endedAt)
1724
+ : current.duration_seconds;
1725
+ const currentAnnotations = safeJsonParse(current.annotations_json, {});
1726
+ const tags = parsed.tags ??
1727
+ safeJsonParse(current.tags_json, []) ??
1728
+ [];
1729
+ const links = parsed.links ??
1730
+ safeJsonParse(current.links_json, []);
1731
+ const annotations = {
1732
+ subjectiveEffort: parsed.subjectiveEffort ??
1733
+ (typeof currentAnnotations.subjectiveEffort === "number"
1734
+ ? currentAnnotations.subjectiveEffort
1735
+ : current.subjective_effort),
1736
+ moodBefore: parsed.moodBefore ??
1737
+ (typeof currentAnnotations.moodBefore === "string"
1738
+ ? currentAnnotations.moodBefore
1739
+ : current.mood_before),
1740
+ moodAfter: parsed.moodAfter ??
1741
+ (typeof currentAnnotations.moodAfter === "string"
1742
+ ? currentAnnotations.moodAfter
1743
+ : current.mood_after),
1744
+ meaningText: parsed.meaningText ??
1745
+ (typeof currentAnnotations.meaningText === "string"
1746
+ ? currentAnnotations.meaningText
1747
+ : current.meaning_text),
1748
+ plannedContext: parsed.plannedContext ??
1749
+ (typeof currentAnnotations.plannedContext === "string"
1750
+ ? currentAnnotations.plannedContext
1751
+ : current.planned_context),
1752
+ socialContext: parsed.socialContext ??
1753
+ (typeof currentAnnotations.socialContext === "string"
1754
+ ? currentAnnotations.socialContext
1755
+ : current.social_context),
1756
+ tags,
1757
+ links
1758
+ };
1759
+ const currentProvenance = safeJsonParse(current.provenance_json, {});
1760
+ const nextExerciseMinutes = parsed.exerciseMinutes ?? current.exercise_minutes;
1761
+ const nextDistanceMeters = parsed.distanceMeters ?? current.distance_meters;
1762
+ getDatabase()
1763
+ .prepare(`UPDATE health_workout_sessions
1764
+ SET external_uid = ?, source = ?, source_type = ?, workout_type = ?, source_device = ?,
1765
+ started_at = ?, ended_at = ?, duration_seconds = ?, active_energy_kcal = ?, total_energy_kcal = ?,
1766
+ distance_meters = ?, step_count = ?, exercise_minutes = ?, average_heart_rate = ?, max_heart_rate = ?,
1767
+ subjective_effort = ?, mood_before = ?, mood_after = ?, meaning_text = ?, planned_context = ?,
1768
+ social_context = ?, links_json = ?, tags_json = ?, annotations_json = ?, provenance_json = ?,
1769
+ derived_json = ?, updated_at = ?
1770
+ WHERE id = ?`)
1771
+ .run(parsed.externalUid ?? current.external_uid, parsed.source ?? current.source, parsed.sourceType ?? current.source_type, parsed.workoutType ?? current.workout_type, parsed.sourceDevice ?? current.source_device, startedAt, endedAt, durationSeconds, parsed.activeEnergyKcal ?? current.active_energy_kcal, parsed.totalEnergyKcal ?? current.total_energy_kcal, nextDistanceMeters, parsed.stepCount ?? current.step_count, nextExerciseMinutes, parsed.averageHeartRate ?? current.average_heart_rate, parsed.maxHeartRate ?? current.max_heart_rate, annotations.subjectiveEffort, annotations.moodBefore, annotations.moodAfter, annotations.meaningText, annotations.plannedContext, annotations.socialContext, JSON.stringify(links), JSON.stringify(tags), JSON.stringify(annotations), JSON.stringify({
1772
+ ...currentProvenance,
1773
+ ...(parsed.provenance ?? {}),
1774
+ updatedAt: now,
1775
+ updatedByActor: activity?.actor ?? null
1776
+ }), JSON.stringify({
1777
+ paceMetersPerMinute: nextDistanceMeters && nextExerciseMinutes
1778
+ ? Number((nextDistanceMeters / nextExerciseMinutes).toFixed(2))
1779
+ : null
1780
+ }), now, workoutId);
1781
+ summarizeUserHealthDay(current.user_id, dayKey(current.started_at));
1782
+ summarizeUserHealthDay(current.user_id, dayKey(startedAt));
1783
+ recordActivityEvent({
1784
+ entityType: "workout_session",
1785
+ entityId: workoutId,
1786
+ eventType: "workout_session_updated",
1787
+ title: "Workout session updated",
1788
+ description: "A workout session was updated through Forge CRUD.",
1789
+ actor: activity?.actor ?? null,
1790
+ source: activity?.source ?? "ui",
1791
+ metadata: {
1792
+ workoutType: parsed.workoutType ?? current.workout_type
1793
+ }
1794
+ });
1795
+ return getWorkoutSessionById(workoutId);
1796
+ }
1797
+ export function deleteWorkoutSession(workoutId, activity) {
1798
+ const current = getDatabase()
1799
+ .prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
1800
+ .get(workoutId);
1801
+ if (!current) {
1802
+ return undefined;
1803
+ }
1804
+ getDatabase()
1805
+ .prepare(`DELETE FROM health_workout_sessions WHERE id = ?`)
1806
+ .run(workoutId);
1807
+ summarizeUserHealthDay(current.user_id, dayKey(current.started_at));
1808
+ recordActivityEvent({
1809
+ entityType: "workout_session",
1810
+ entityId: workoutId,
1811
+ eventType: "workout_session_deleted",
1812
+ title: "Workout session deleted",
1813
+ description: "A workout session was permanently removed from Forge.",
1814
+ actor: activity?.actor ?? null,
1815
+ source: activity?.source ?? "ui",
1816
+ metadata: {
1817
+ workoutType: current.workout_type,
1818
+ startedAt: current.started_at
1819
+ }
1820
+ });
1821
+ return mapWorkoutSession(current);
1822
+ }
1139
1823
  export function updateWorkoutMetadata(workoutId, patch, activity) {
1140
1824
  const parsed = updateWorkoutMetadataSchema.parse(patch);
1141
1825
  const current = getDatabase()