forge-openclaw-plugin 0.2.19 → 0.2.21

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 (82) hide show
  1. package/README.md +133 -2
  2. package/dist/assets/board-_C6oMy5w.js +6 -0
  3. package/dist/assets/{board-8L3uX7_O.js.map → board-_C6oMy5w.js.map} +1 -1
  4. package/dist/assets/index-B4A6TooJ.js +63 -0
  5. package/dist/assets/index-B4A6TooJ.js.map +1 -0
  6. package/dist/assets/index-D6Xs_2mo.css +1 -0
  7. package/dist/assets/{motion-1GAqqi8M.js → motion-D4sZgCHd.js} +2 -2
  8. package/dist/assets/{motion-1GAqqi8M.js.map → motion-D4sZgCHd.js.map} +1 -1
  9. package/dist/assets/{table-DBGlgRjk.js → table-BWzTaky1.js} +2 -2
  10. package/dist/assets/{table-DBGlgRjk.js.map → table-BWzTaky1.js.map} +1 -1
  11. package/dist/assets/{ui-iTluWjC4.js → ui-BzK4azQb.js} +7 -7
  12. package/dist/assets/{ui-iTluWjC4.js.map → ui-BzK4azQb.js.map} +1 -1
  13. package/dist/assets/vendor-DT3pnAKJ.css +1 -0
  14. package/dist/assets/vendor-De38P6YR.js +729 -0
  15. package/dist/assets/vendor-De38P6YR.js.map +1 -0
  16. package/dist/assets/viz-C6hfyqzu.js +34 -0
  17. package/dist/assets/viz-C6hfyqzu.js.map +1 -0
  18. package/dist/index.html +9 -9
  19. package/dist/openclaw/parity.d.ts +1 -1
  20. package/dist/openclaw/parity.js +29 -2
  21. package/dist/openclaw/routes.js +207 -24
  22. package/dist/openclaw/tools.js +324 -35
  23. package/dist/server/app.js +2080 -92
  24. package/dist/server/db.js +3 -0
  25. package/dist/server/health.js +1284 -0
  26. package/dist/server/managers/platform/background-job-manager.js +138 -2
  27. package/dist/server/managers/platform/llm-manager.js +126 -0
  28. package/dist/server/managers/platform/openai-responses-provider.js +773 -0
  29. package/dist/server/managers/runtime.js +6 -1
  30. package/dist/server/openapi.js +718 -0
  31. package/dist/server/preferences-seeds.js +409 -0
  32. package/dist/server/preferences-types.js +368 -0
  33. package/dist/server/psyche-types.js +42 -18
  34. package/dist/server/repositories/activity-events.js +53 -4
  35. package/dist/server/repositories/calendar.js +89 -15
  36. package/dist/server/repositories/collaboration.js +8 -3
  37. package/dist/server/repositories/diagnostic-logs.js +243 -0
  38. package/dist/server/repositories/entity-ownership.js +92 -0
  39. package/dist/server/repositories/goals.js +7 -2
  40. package/dist/server/repositories/habits.js +122 -16
  41. package/dist/server/repositories/notes.js +119 -41
  42. package/dist/server/repositories/preferences.js +1765 -0
  43. package/dist/server/repositories/projects.js +18 -7
  44. package/dist/server/repositories/psyche.js +84 -27
  45. package/dist/server/repositories/rewards.js +112 -4
  46. package/dist/server/repositories/strategies.js +450 -0
  47. package/dist/server/repositories/tags.js +11 -6
  48. package/dist/server/repositories/task-runs.js +10 -2
  49. package/dist/server/repositories/tasks.js +99 -17
  50. package/dist/server/repositories/users.js +417 -0
  51. package/dist/server/repositories/wiki-memory.js +3366 -0
  52. package/dist/server/services/context.js +20 -18
  53. package/dist/server/services/dashboard.js +29 -6
  54. package/dist/server/services/entity-crud.js +21 -3
  55. package/dist/server/services/insights.js +9 -7
  56. package/dist/server/services/projects.js +2 -1
  57. package/dist/server/services/psyche.js +10 -9
  58. package/dist/server/types.js +594 -30
  59. package/openclaw.plugin.json +1 -1
  60. package/package.json +1 -1
  61. package/server/migrations/015_multi_user_and_strategies.sql +244 -0
  62. package/server/migrations/016_health_companion.sql +158 -0
  63. package/server/migrations/016_strategy_contracts_and_user_graph.sql +22 -0
  64. package/server/migrations/017_preferences.sql +131 -0
  65. package/server/migrations/018_preference_catalogs.sql +31 -0
  66. package/server/migrations/019_wiki_memory.sql +255 -0
  67. package/server/migrations/020_wiki_page_hierarchy.sql +11 -0
  68. package/server/migrations/021_hide_evidence_from_wiki_index.sql +3 -0
  69. package/server/migrations/022_wiki_ingest_background.sql +85 -0
  70. package/server/migrations/023_diagnostic_logs.sql +28 -0
  71. package/skills/forge-openclaw/SKILL.md +126 -34
  72. package/skills/forge-openclaw/entity_conversation_playbooks.md +337 -0
  73. package/skills/forge-openclaw/psyche_entity_playbooks.md +404 -0
  74. package/dist/assets/board-8L3uX7_O.js +0 -6
  75. package/dist/assets/index-Cj1IBH_w.js +0 -36
  76. package/dist/assets/index-Cj1IBH_w.js.map +0 -1
  77. package/dist/assets/index-DQT6EbuS.css +0 -1
  78. package/dist/assets/vendor-BvM2F9Dp.js +0 -503
  79. package/dist/assets/vendor-BvM2F9Dp.js.map +0 -1
  80. package/dist/assets/vendor-CRS-psbw.css +0 -1
  81. package/dist/assets/viz-CNeunkfu.js +0 -34
  82. package/dist/assets/viz-CNeunkfu.js.map +0 -1
@@ -0,0 +1,1284 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { z } from "zod";
3
+ import { getDatabase, runInTransaction } from "./db.js";
4
+ import { HttpError } from "./errors.js";
5
+ import { recordActivityEvent } from "./repositories/activity-events.js";
6
+ import { recordHabitGeneratedWorkoutReward } from "./repositories/rewards.js";
7
+ const healthLinkSchema = z.object({
8
+ entityType: z.string().trim().min(1),
9
+ entityId: z.string().trim().min(1),
10
+ relationshipType: z.string().trim().min(1).default("context")
11
+ });
12
+ const healthStageSchema = z.object({
13
+ stage: z.string().trim().min(1),
14
+ seconds: z.number().int().nonnegative()
15
+ });
16
+ const sleepRecoveryMetricSchema = z.record(z.string(), z.union([z.string(), z.number(), z.boolean(), z.null()]));
17
+ const sleepAnnotationSchema = z.object({
18
+ qualitySummary: z.string().trim().default(""),
19
+ notes: z.string().trim().default(""),
20
+ tags: z.array(z.string().trim()).default([]),
21
+ links: z.array(healthLinkSchema).default([])
22
+ });
23
+ const workoutAnnotationSchema = z.object({
24
+ subjectiveEffort: z.number().int().min(1).max(10).nullable().default(null),
25
+ moodBefore: z.string().trim().default(""),
26
+ moodAfter: z.string().trim().default(""),
27
+ meaningText: z.string().trim().default(""),
28
+ plannedContext: z.string().trim().default(""),
29
+ socialContext: z.string().trim().default(""),
30
+ tags: z.array(z.string().trim()).default([]),
31
+ links: z.array(healthLinkSchema).default([])
32
+ });
33
+ const generatedHealthEventTemplateSchema = z.object({
34
+ enabled: z.boolean().default(false),
35
+ workoutType: z.string().trim().min(1).default("workout"),
36
+ title: z.string().trim().default(""),
37
+ durationMinutes: z.number().int().positive().max(24 * 60).default(45),
38
+ xpReward: z.number().int().min(0).max(500).default(0),
39
+ tags: z.array(z.string().trim()).default([]),
40
+ links: z.array(healthLinkSchema).default([]),
41
+ notesTemplate: z.string().trim().default("")
42
+ });
43
+ const pairingStatusSchema = z.enum([
44
+ "pending",
45
+ "paired",
46
+ "healthy",
47
+ "stale",
48
+ "permission_denied",
49
+ "error",
50
+ "revoked"
51
+ ]);
52
+ export const createCompanionPairingSessionSchema = z.object({
53
+ label: z.string().trim().default("Forge Companion"),
54
+ userId: z.string().trim().nullable().optional(),
55
+ expiresInMinutes: z.coerce.number().int().min(5).max(24 * 60).default(30),
56
+ capabilities: z
57
+ .array(z.enum([
58
+ "healthkit.sleep",
59
+ "healthkit.fitness",
60
+ "background-sync",
61
+ "location-ready",
62
+ "watch-ready"
63
+ ]))
64
+ .default([
65
+ "healthkit.sleep",
66
+ "healthkit.fitness",
67
+ "background-sync",
68
+ "location-ready",
69
+ "watch-ready"
70
+ ])
71
+ });
72
+ export const revokeAllCompanionPairingSessionsSchema = z.object({
73
+ userIds: z.array(z.string().trim().min(1)).default([]),
74
+ includeRevoked: z.boolean().default(false)
75
+ });
76
+ export const mobileHealthSyncSchema = z.object({
77
+ sessionId: z.string().trim().min(1),
78
+ pairingToken: z.string().trim().min(1),
79
+ device: z.object({
80
+ name: z.string().trim().default("iPhone"),
81
+ platform: z.string().trim().default("ios"),
82
+ appVersion: z.string().trim().default(""),
83
+ sourceDevice: z.string().trim().default("iPhone")
84
+ }),
85
+ permissions: z.object({
86
+ healthKitAuthorized: z.boolean().default(false),
87
+ backgroundRefreshEnabled: z.boolean().default(false),
88
+ motionReady: z.boolean().default(false),
89
+ locationReady: z.boolean().default(false)
90
+ }),
91
+ sleepSessions: z
92
+ .array(z.object({
93
+ externalUid: z.string().trim().min(1),
94
+ startedAt: z.string().datetime(),
95
+ endedAt: z.string().datetime(),
96
+ timeInBedSeconds: z.number().int().nonnegative().default(0),
97
+ asleepSeconds: z.number().int().nonnegative().default(0),
98
+ awakeSeconds: z.number().int().nonnegative().default(0),
99
+ stageBreakdown: z.array(healthStageSchema).default([]),
100
+ recoveryMetrics: sleepRecoveryMetricSchema.default({}),
101
+ links: z.array(healthLinkSchema).default([]),
102
+ annotations: sleepAnnotationSchema.partial().default({})
103
+ }))
104
+ .default([]),
105
+ workouts: z
106
+ .array(z.object({
107
+ externalUid: z.string().trim().min(1),
108
+ workoutType: z.string().trim().min(1),
109
+ startedAt: z.string().datetime(),
110
+ endedAt: z.string().datetime(),
111
+ activeEnergyKcal: z.number().nonnegative().nullable().optional(),
112
+ totalEnergyKcal: z.number().nonnegative().nullable().optional(),
113
+ distanceMeters: z.number().nonnegative().nullable().optional(),
114
+ stepCount: z.number().int().nonnegative().nullable().optional(),
115
+ exerciseMinutes: z.number().nonnegative().nullable().optional(),
116
+ averageHeartRate: z.number().nonnegative().nullable().optional(),
117
+ maxHeartRate: z.number().nonnegative().nullable().optional(),
118
+ sourceDevice: z.string().trim().default("Apple Health"),
119
+ links: z.array(healthLinkSchema).default([]),
120
+ annotations: workoutAnnotationSchema.partial().default({})
121
+ }))
122
+ .default([])
123
+ });
124
+ export const verifyCompanionPairingSchema = z.object({
125
+ sessionId: z.string().trim().min(1),
126
+ pairingToken: z.string().trim().min(1),
127
+ device: z.object({
128
+ name: z.string().trim().default("iPhone"),
129
+ platform: z.string().trim().default("ios"),
130
+ appVersion: z.string().trim().default(""),
131
+ sourceDevice: z.string().trim().default("iPhone")
132
+ })
133
+ });
134
+ export const updateWorkoutMetadataSchema = z.object({
135
+ subjectiveEffort: z.number().int().min(1).max(10).nullable().optional(),
136
+ moodBefore: z.string().trim().optional(),
137
+ moodAfter: z.string().trim().optional(),
138
+ meaningText: z.string().trim().optional(),
139
+ plannedContext: z.string().trim().optional(),
140
+ socialContext: z.string().trim().optional(),
141
+ tags: z.array(z.string().trim()).optional(),
142
+ links: z.array(healthLinkSchema).optional()
143
+ });
144
+ export const updateSleepMetadataSchema = z.object({
145
+ qualitySummary: z.string().trim().optional(),
146
+ notes: z.string().trim().optional(),
147
+ tags: z.array(z.string().trim()).optional(),
148
+ links: z.array(healthLinkSchema).optional()
149
+ });
150
+ function nowIso() {
151
+ return new Date().toISOString();
152
+ }
153
+ function safeJsonParse(value, fallback) {
154
+ if (!value) {
155
+ return fallback;
156
+ }
157
+ try {
158
+ return JSON.parse(value);
159
+ }
160
+ catch {
161
+ return fallback;
162
+ }
163
+ }
164
+ function dayKey(value) {
165
+ return value.slice(0, 10);
166
+ }
167
+ function diffMinutes(left, right) {
168
+ return Math.round(Math.abs(Date.parse(left) - Date.parse(right)) / 60_000);
169
+ }
170
+ function average(values) {
171
+ return values.length > 0
172
+ ? values.reduce((sum, value) => sum + value, 0) / values.length
173
+ : 0;
174
+ }
175
+ function round(value, digits = 0) {
176
+ return Number(value.toFixed(digits));
177
+ }
178
+ function mergeStringLists(...groups) {
179
+ return [
180
+ ...new Set(groups
181
+ .flatMap((group) => group ?? [])
182
+ .map((value) => value.trim())
183
+ .filter(Boolean))
184
+ ];
185
+ }
186
+ function mergeHealthLinks(...groups) {
187
+ const deduped = new Map();
188
+ for (const group of groups) {
189
+ for (const link of group ?? []) {
190
+ const parsed = healthLinkSchema.safeParse(link);
191
+ if (!parsed.success) {
192
+ continue;
193
+ }
194
+ const value = parsed.data;
195
+ deduped.set(`${value.entityType}:${value.entityId}:${value.relationshipType}`, value);
196
+ }
197
+ }
198
+ return [...deduped.values()];
199
+ }
200
+ function sleepMinutesOfDay(value, mode) {
201
+ const date = new Date(value);
202
+ const minutes = date.getUTCHours() * 60 + date.getUTCMinutes();
203
+ if (mode === "bedtime" && minutes < 12 * 60) {
204
+ return minutes + 24 * 60;
205
+ }
206
+ return minutes;
207
+ }
208
+ function computeSleepDerivedMetrics(input) {
209
+ const efficiency = input.timeInBedSeconds > 0
210
+ ? input.asleepSeconds / input.timeInBedSeconds
211
+ : 0;
212
+ const restorativeSeconds = input.stageBreakdown
213
+ .filter((stage) => {
214
+ const label = stage.stage.toLowerCase();
215
+ return label.includes("deep") || label.includes("rem");
216
+ })
217
+ .reduce((total, stage) => total + stage.seconds, 0);
218
+ const restorativeShare = input.asleepSeconds > 0 ? restorativeSeconds / input.asleepSeconds : 0;
219
+ const sleepDebtHours = input.asleepSeconds > 0
220
+ ? Math.max(0, 8 - input.asleepSeconds / 3600)
221
+ : 8;
222
+ return {
223
+ durationHours: round(input.asleepSeconds / 3600, 2),
224
+ efficiency: round(efficiency, 3),
225
+ restorativeShare: round(restorativeShare, 3),
226
+ awakeRatio: input.asleepSeconds > 0
227
+ ? round(input.awakeSeconds / input.asleepSeconds, 3)
228
+ : 0,
229
+ sleepDebtHours: round(sleepDebtHours, 2),
230
+ recoveryState: input.sleepScore >= 82
231
+ ? "recovered"
232
+ : input.sleepScore >= 68
233
+ ? "stable"
234
+ : input.sleepScore >= 52
235
+ ? "strained"
236
+ : "depleted"
237
+ };
238
+ }
239
+ function computeSleepTimingMetrics(input) {
240
+ const params = [input.userId];
241
+ const excludeSql = input.excludeSleepId ? "AND id != ?" : "";
242
+ if (input.excludeSleepId) {
243
+ params.push(input.excludeSleepId);
244
+ }
245
+ const rows = getDatabase()
246
+ .prepare(`SELECT id, started_at, ended_at
247
+ FROM health_sleep_sessions
248
+ WHERE user_id = ?
249
+ ${excludeSql}
250
+ ORDER BY started_at DESC
251
+ LIMIT 14`)
252
+ .all(...params);
253
+ if (rows.length === 0) {
254
+ return {
255
+ bedtimeConsistencyMinutes: null,
256
+ wakeConsistencyMinutes: null,
257
+ regularityScore: computeRegularityScore(input.startedAt)
258
+ };
259
+ }
260
+ const bedtimeReference = average(rows.map((row) => sleepMinutesOfDay(row.started_at, "bedtime")));
261
+ const wakeReference = average(rows.map((row) => sleepMinutesOfDay(row.ended_at, "wake")));
262
+ const bedtimeConsistencyMinutes = Math.round(Math.abs(sleepMinutesOfDay(input.startedAt, "bedtime") - bedtimeReference));
263
+ const wakeConsistencyMinutes = Math.round(Math.abs(sleepMinutesOfDay(input.endedAt, "wake") - wakeReference));
264
+ const regularityScore = Math.max(0, Math.min(100, Math.round(100 - average([bedtimeConsistencyMinutes, wakeConsistencyMinutes]) / 1.8)));
265
+ return {
266
+ bedtimeConsistencyMinutes,
267
+ wakeConsistencyMinutes,
268
+ regularityScore
269
+ };
270
+ }
271
+ function computeSleepScore(input) {
272
+ if (input.asleepSeconds <= 0) {
273
+ return 0;
274
+ }
275
+ const efficiency = input.timeInBedSeconds > 0
276
+ ? input.asleepSeconds / input.timeInBedSeconds
277
+ : 1;
278
+ const deepSeconds = input.stageBreakdown
279
+ .filter((stage) => stage.stage.toLowerCase().includes("deep"))
280
+ .reduce((total, stage) => total + stage.seconds, 0);
281
+ const remSeconds = input.stageBreakdown
282
+ .filter((stage) => stage.stage.toLowerCase().includes("rem"))
283
+ .reduce((total, stage) => total + stage.seconds, 0);
284
+ const restorativeRatio = (deepSeconds + remSeconds) / input.asleepSeconds;
285
+ const wakePenalty = input.asleepSeconds > 0 ? input.awakeSeconds / input.asleepSeconds : 0;
286
+ const durationHours = input.asleepSeconds / 3600;
287
+ const durationScore = Math.max(0, 1 - Math.abs(durationHours - 8) / 4);
288
+ return Math.round(Math.max(0, Math.min(100, 45 * efficiency +
289
+ 25 * durationScore +
290
+ 20 * Math.min(1, restorativeRatio * 1.5) +
291
+ 10 * Math.max(0, 1 - wakePenalty))));
292
+ }
293
+ function computeRegularityScore(startedAt) {
294
+ const date = new Date(startedAt);
295
+ const bedtimeMinutes = date.getUTCHours() * 60 + date.getUTCMinutes();
296
+ const distanceFromTarget = Math.min(Math.abs(bedtimeMinutes - 22 * 60 - 30), Math.abs(bedtimeMinutes - (24 * 60 + 22 * 60 + 30)));
297
+ return Math.round(Math.max(0, 100 - distanceFromTarget / 3));
298
+ }
299
+ function mapPairingSession(row) {
300
+ const stale = row.last_sync_at &&
301
+ Date.now() - Date.parse(row.last_sync_at) > 1000 * 60 * 60 * 24;
302
+ const effectiveStatus = row.status === "healthy" && stale ? "stale" : row.status;
303
+ return {
304
+ id: row.id,
305
+ userId: row.user_id,
306
+ label: row.label,
307
+ status: pairingStatusSchema.parse(effectiveStatus),
308
+ capabilities: safeJsonParse(row.capability_flags_json, []),
309
+ deviceName: row.device_name,
310
+ platform: row.platform,
311
+ appVersion: row.app_version,
312
+ apiBaseUrl: row.api_base_url,
313
+ lastSeenAt: row.last_seen_at,
314
+ lastSyncAt: row.last_sync_at,
315
+ lastSyncError: row.last_sync_error,
316
+ pairedAt: row.paired_at,
317
+ expiresAt: row.expires_at,
318
+ createdAt: row.created_at,
319
+ updatedAt: row.updated_at
320
+ };
321
+ }
322
+ function mapSleepSession(row) {
323
+ return {
324
+ id: row.id,
325
+ externalUid: row.external_uid,
326
+ pairingSessionId: row.pairing_session_id,
327
+ userId: row.user_id,
328
+ source: row.source,
329
+ sourceType: row.source_type,
330
+ sourceDevice: row.source_device,
331
+ startedAt: row.started_at,
332
+ endedAt: row.ended_at,
333
+ timeInBedSeconds: row.time_in_bed_seconds,
334
+ asleepSeconds: row.asleep_seconds,
335
+ awakeSeconds: row.awake_seconds,
336
+ sleepScore: row.sleep_score,
337
+ regularityScore: row.regularity_score,
338
+ bedtimeConsistencyMinutes: row.bedtime_consistency_minutes,
339
+ wakeConsistencyMinutes: row.wake_consistency_minutes,
340
+ stageBreakdown: safeJsonParse(row.stage_breakdown_json, []),
341
+ recoveryMetrics: safeJsonParse(row.recovery_metrics_json, {}),
342
+ links: safeJsonParse(row.links_json, []),
343
+ annotations: safeJsonParse(row.annotations_json, {}),
344
+ provenance: safeJsonParse(row.provenance_json, {}),
345
+ derived: safeJsonParse(row.derived_json, {}),
346
+ createdAt: row.created_at,
347
+ updatedAt: row.updated_at
348
+ };
349
+ }
350
+ function mapWorkoutSession(row) {
351
+ return {
352
+ id: row.id,
353
+ externalUid: row.external_uid,
354
+ pairingSessionId: row.pairing_session_id,
355
+ userId: row.user_id,
356
+ source: row.source,
357
+ sourceType: row.source_type,
358
+ workoutType: row.workout_type,
359
+ sourceDevice: row.source_device,
360
+ startedAt: row.started_at,
361
+ endedAt: row.ended_at,
362
+ durationSeconds: row.duration_seconds,
363
+ activeEnergyKcal: row.active_energy_kcal,
364
+ totalEnergyKcal: row.total_energy_kcal,
365
+ distanceMeters: row.distance_meters,
366
+ stepCount: row.step_count,
367
+ exerciseMinutes: row.exercise_minutes,
368
+ averageHeartRate: row.average_heart_rate,
369
+ maxHeartRate: row.max_heart_rate,
370
+ subjectiveEffort: row.subjective_effort,
371
+ moodBefore: row.mood_before,
372
+ moodAfter: row.mood_after,
373
+ meaningText: row.meaning_text,
374
+ plannedContext: row.planned_context,
375
+ socialContext: row.social_context,
376
+ links: safeJsonParse(row.links_json, []),
377
+ tags: safeJsonParse(row.tags_json, []),
378
+ annotations: safeJsonParse(row.annotations_json, {}),
379
+ provenance: safeJsonParse(row.provenance_json, {}),
380
+ derived: safeJsonParse(row.derived_json, {}),
381
+ generatedFromHabitId: row.generated_from_habit_id,
382
+ generatedFromCheckInId: row.generated_from_check_in_id,
383
+ reconciliationStatus: row.reconciliation_status,
384
+ createdAt: row.created_at,
385
+ updatedAt: row.updated_at
386
+ };
387
+ }
388
+ function mapHealthImportRun(row) {
389
+ return {
390
+ id: row.id,
391
+ pairingSessionId: row.pairing_session_id,
392
+ userId: row.user_id,
393
+ source: row.source,
394
+ sourceDevice: row.source_device,
395
+ status: row.status,
396
+ payloadSummary: safeJsonParse(row.payload_summary_json, {}),
397
+ importedCount: row.imported_count,
398
+ createdCount: row.created_count,
399
+ updatedCount: row.updated_count,
400
+ mergedCount: row.merged_count,
401
+ errorMessage: row.error_message,
402
+ importedAt: row.imported_at,
403
+ createdAt: row.created_at,
404
+ updatedAt: row.updated_at
405
+ };
406
+ }
407
+ function listSleepRows(userIds) {
408
+ const params = [];
409
+ const where = userIds && userIds.length > 0
410
+ ? `WHERE user_id IN (${userIds.map(() => "?").join(",")})`
411
+ : "";
412
+ if (userIds) {
413
+ params.push(...userIds);
414
+ }
415
+ return getDatabase()
416
+ .prepare(`SELECT *
417
+ FROM health_sleep_sessions
418
+ ${where}
419
+ ORDER BY started_at DESC`)
420
+ .all(...params);
421
+ }
422
+ function listWorkoutRows(userIds) {
423
+ const params = [];
424
+ const where = userIds && userIds.length > 0
425
+ ? `WHERE user_id IN (${userIds.map(() => "?").join(",")})`
426
+ : "";
427
+ if (userIds) {
428
+ params.push(...userIds);
429
+ }
430
+ return getDatabase()
431
+ .prepare(`SELECT *
432
+ FROM health_workout_sessions
433
+ ${where}
434
+ ORDER BY started_at DESC`)
435
+ .all(...params);
436
+ }
437
+ function listPairingRows(userIds) {
438
+ const params = [];
439
+ const where = userIds && userIds.length > 0
440
+ ? `WHERE user_id IN (${userIds.map(() => "?").join(",")})`
441
+ : "";
442
+ if (userIds) {
443
+ params.push(...userIds);
444
+ }
445
+ return getDatabase()
446
+ .prepare(`SELECT *
447
+ FROM companion_pairing_sessions
448
+ ${where}
449
+ ORDER BY updated_at DESC, created_at DESC`)
450
+ .all(...params);
451
+ }
452
+ function revokePairingRows(rows, activity) {
453
+ if (rows.length === 0) {
454
+ return [];
455
+ }
456
+ const now = nowIso();
457
+ const reason = activity?.reason ?? "Revoked by operator";
458
+ const revokeStatement = getDatabase().prepare(`UPDATE companion_pairing_sessions
459
+ SET status = 'revoked', last_sync_error = ?, updated_at = ?
460
+ WHERE id = ?`);
461
+ const refetchStatement = getDatabase().prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`);
462
+ for (const row of rows) {
463
+ revokeStatement.run(reason, now, row.id);
464
+ recordActivityEvent({
465
+ entityType: "system",
466
+ entityId: row.id,
467
+ eventType: "companion_pairing_revoked",
468
+ title: "Companion pairing revoked",
469
+ description: "An operator revoked a Forge Companion pairing session and blocked further syncs for that device.",
470
+ actor: activity?.actor ?? null,
471
+ source: activity?.source ?? "ui",
472
+ metadata: {
473
+ label: row.label,
474
+ deviceName: row.device_name,
475
+ platform: row.platform
476
+ }
477
+ });
478
+ }
479
+ return rows.map((row) => mapPairingSession(refetchStatement.get(row.id)));
480
+ }
481
+ function listHealthImportRunRows(userIds, limit = 12) {
482
+ const params = [];
483
+ const where = userIds && userIds.length > 0
484
+ ? `WHERE user_id IN (${userIds.map(() => "?").join(",")})`
485
+ : "";
486
+ if (userIds) {
487
+ params.push(...userIds);
488
+ }
489
+ params.push(limit);
490
+ return getDatabase()
491
+ .prepare(`SELECT *
492
+ FROM health_import_runs
493
+ ${where}
494
+ ORDER BY imported_at DESC, created_at DESC
495
+ LIMIT ?`)
496
+ .all(...params);
497
+ }
498
+ export function listPairingSessions(userIds) {
499
+ return listPairingRows(userIds).map(mapPairingSession);
500
+ }
501
+ export function revokeCompanionPairingSession(pairingSessionId, activity) {
502
+ const current = getDatabase()
503
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
504
+ .get(pairingSessionId);
505
+ if (!current) {
506
+ return undefined;
507
+ }
508
+ return revokePairingRows([current], activity)[0];
509
+ }
510
+ export function revokeAllCompanionPairingSessions(input, activity) {
511
+ const parsed = revokeAllCompanionPairingSessionsSchema.parse(input ?? {});
512
+ const rows = listPairingRows(parsed.userIds.length > 0 ? parsed.userIds : undefined)
513
+ .filter((row) => parsed.includeRevoked || row.status !== "revoked");
514
+ const sessions = revokePairingRows(rows, {
515
+ actor: activity?.actor ?? null,
516
+ source: activity?.source ?? "ui",
517
+ reason: "Revoked by operator (bulk)"
518
+ });
519
+ return {
520
+ revokedCount: sessions.length,
521
+ sessions
522
+ };
523
+ }
524
+ export function createCompanionPairingSession(baseApiUrl, input) {
525
+ const parsed = createCompanionPairingSessionSchema.parse(input);
526
+ const now = new Date();
527
+ const userId = parsed.userId ?? "user_operator";
528
+ const serializedCapabilities = JSON.stringify(parsed.capabilities);
529
+ const expiresAt = new Date(now.getTime() + parsed.expiresInMinutes * 60_000).toISOString();
530
+ const id = `pair_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
531
+ const pairingToken = randomUUID().replaceAll("-", "");
532
+ const stalePendingRows = getDatabase()
533
+ .prepare(`SELECT *
534
+ FROM companion_pairing_sessions
535
+ WHERE user_id = ?
536
+ AND label = ?
537
+ AND api_base_url = ?
538
+ AND capability_flags_json = ?
539
+ AND status = 'pending'`)
540
+ .all(userId, parsed.label, baseApiUrl, serializedCapabilities);
541
+ if (stalePendingRows.length > 0) {
542
+ revokePairingRows(stalePendingRows, {
543
+ actor: null,
544
+ source: "system",
545
+ reason: "Superseded by a newer pairing QR"
546
+ });
547
+ }
548
+ getDatabase()
549
+ .prepare(`INSERT INTO companion_pairing_sessions (
550
+ id, user_id, label, pairing_token, status, capability_flags_json, api_base_url,
551
+ expires_at, created_at, updated_at
552
+ )
553
+ VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?)`)
554
+ .run(id, userId, parsed.label, pairingToken, serializedCapabilities, baseApiUrl, expiresAt, now.toISOString(), now.toISOString());
555
+ const qrPayload = {
556
+ kind: "forge-companion-pairing",
557
+ apiBaseUrl: baseApiUrl,
558
+ sessionId: id,
559
+ pairingToken,
560
+ expiresAt,
561
+ capabilities: parsed.capabilities
562
+ };
563
+ return {
564
+ session: mapPairingSession(getDatabase()
565
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
566
+ .get(id)),
567
+ qrPayload
568
+ };
569
+ }
570
+ export function verifyCompanionPairing(payload) {
571
+ const parsed = verifyCompanionPairingSchema.parse(payload);
572
+ const pairing = requireValidPairing(parsed.sessionId, parsed.pairingToken);
573
+ const now = nowIso();
574
+ const nextStatus = pairing.status === "healthy" ||
575
+ pairing.status === "stale" ||
576
+ pairing.status === "permission_denied"
577
+ ? pairing.status
578
+ : "paired";
579
+ getDatabase()
580
+ .prepare(`UPDATE companion_pairing_sessions
581
+ SET status = ?, device_name = ?, platform = ?, app_version = ?,
582
+ last_seen_at = ?, paired_at = COALESCE(paired_at, ?), updated_at = ?
583
+ WHERE id = ?`)
584
+ .run(nextStatus, parsed.device.name, parsed.device.platform, parsed.device.appVersion, now, now, now, pairing.id);
585
+ if (parsed.device.name.trim().length > 0) {
586
+ const duplicateRows = getDatabase()
587
+ .prepare(`SELECT *
588
+ FROM companion_pairing_sessions
589
+ WHERE user_id = ?
590
+ AND id != ?
591
+ AND status != 'revoked'
592
+ AND COALESCE(device_name, '') = ?
593
+ AND COALESCE(platform, '') = ?`)
594
+ .all(pairing.user_id, pairing.id, parsed.device.name, parsed.device.platform);
595
+ if (duplicateRows.length > 0) {
596
+ revokePairingRows(duplicateRows, {
597
+ actor: null,
598
+ source: "system",
599
+ reason: "Superseded by a newer verified device pairing"
600
+ });
601
+ }
602
+ }
603
+ return {
604
+ pairingSession: mapPairingSession(getDatabase()
605
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
606
+ .get(pairing.id))
607
+ };
608
+ }
609
+ function requireValidPairing(sessionId, pairingToken) {
610
+ const row = getDatabase()
611
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
612
+ .get(sessionId);
613
+ if (!row || row.pairing_token !== pairingToken) {
614
+ throw new HttpError(401, "invalid_pairing_token", "The pairing session or pairing token is invalid.");
615
+ }
616
+ if (Date.parse(row.expires_at) < Date.now()) {
617
+ throw new HttpError(410, "pairing_expired", "The pairing session expired. Generate a new QR code in Forge.");
618
+ }
619
+ if (row.status === "revoked") {
620
+ throw new HttpError(403, "pairing_revoked", "This companion pairing was revoked.");
621
+ }
622
+ return row;
623
+ }
624
+ function findMatchingGeneratedWorkout(input) {
625
+ const rows = getDatabase()
626
+ .prepare(`SELECT *
627
+ FROM health_workout_sessions
628
+ WHERE user_id = ?
629
+ AND generated_from_habit_id IS NOT NULL
630
+ AND workout_type = ?
631
+ AND ABS(strftime('%s', started_at) - strftime('%s', ?)) <= 5400
632
+ AND ABS(strftime('%s', ended_at) - strftime('%s', ?)) <= 5400
633
+ ORDER BY (
634
+ ABS(strftime('%s', started_at) - strftime('%s', ?)) +
635
+ ABS(strftime('%s', ended_at) - strftime('%s', ?))
636
+ ) ASC
637
+ LIMIT 1`)
638
+ .all(input.userId, input.workoutType, input.startedAt, input.endedAt, input.startedAt, input.endedAt);
639
+ return rows[0] ?? null;
640
+ }
641
+ function upsertDailySummary(userId, dateKey, summaryType, metrics, derived = {}) {
642
+ const existing = getDatabase()
643
+ .prepare(`SELECT id, user_id, date_key, summary_type, metrics_json, derived_json, source, created_at, updated_at
644
+ FROM health_daily_summaries
645
+ WHERE user_id = ? AND date_key = ? AND summary_type = ?`)
646
+ .get(userId, dateKey, summaryType);
647
+ const now = nowIso();
648
+ if (existing) {
649
+ getDatabase()
650
+ .prepare(`UPDATE health_daily_summaries
651
+ SET metrics_json = ?, derived_json = ?, updated_at = ?
652
+ WHERE id = ?`)
653
+ .run(JSON.stringify(metrics), JSON.stringify(derived), now, existing.id);
654
+ return;
655
+ }
656
+ getDatabase()
657
+ .prepare(`INSERT INTO health_daily_summaries (
658
+ id, user_id, date_key, summary_type, metrics_json, derived_json, source, created_at, updated_at
659
+ )
660
+ VALUES (?, ?, ?, ?, ?, ?, 'derived', ?, ?)`)
661
+ .run(`hds_${randomUUID().replaceAll("-", "").slice(0, 10)}`, userId, dateKey, summaryType, JSON.stringify(metrics), JSON.stringify(derived), now, now);
662
+ }
663
+ function summarizeUserHealthDay(userId, dateKeyValue) {
664
+ const sleeps = listSleepRows([userId]).filter((row) => dayKey(row.ended_at) === dateKeyValue || dayKey(row.started_at) === dateKeyValue);
665
+ const workouts = listWorkoutRows([userId]).filter((row) => dayKey(row.started_at) === dateKeyValue);
666
+ const totalSleepSeconds = sleeps.reduce((sum, row) => sum + row.asleep_seconds, 0);
667
+ const totalWorkoutSeconds = workouts.reduce((sum, row) => sum + row.duration_seconds, 0);
668
+ const totalExerciseMinutes = workouts.reduce((sum, row) => sum + (row.exercise_minutes ?? row.duration_seconds / 60), 0);
669
+ const totalEnergyKcal = workouts.reduce((sum, row) => sum + (row.total_energy_kcal ?? row.active_energy_kcal ?? 0), 0);
670
+ upsertDailySummary(userId, dateKeyValue, "health", {
671
+ totalSleepSeconds,
672
+ totalWorkoutSeconds,
673
+ totalExerciseMinutes,
674
+ totalEnergyKcal,
675
+ workoutCount: workouts.length,
676
+ sleepSessionCount: sleeps.length
677
+ }, {
678
+ recoveryState: totalSleepSeconds >= 7 * 3600
679
+ ? "recovered"
680
+ : totalSleepSeconds >= 6 * 3600
681
+ ? "borderline"
682
+ : "strained"
683
+ });
684
+ }
685
+ function insertOrUpdateSleepSession(pairing, input) {
686
+ const existing = getDatabase()
687
+ .prepare(`SELECT *
688
+ FROM health_sleep_sessions
689
+ WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`)
690
+ .get(pairing.user_id, input.externalUid);
691
+ const stageBreakdown = input.stageBreakdown;
692
+ const sleepScore = computeSleepScore({
693
+ asleepSeconds: input.asleepSeconds,
694
+ timeInBedSeconds: input.timeInBedSeconds,
695
+ awakeSeconds: input.awakeSeconds,
696
+ stageBreakdown
697
+ });
698
+ const timingMetrics = computeSleepTimingMetrics({
699
+ userId: pairing.user_id,
700
+ startedAt: input.startedAt,
701
+ endedAt: input.endedAt,
702
+ excludeSleepId: existing?.id
703
+ });
704
+ const annotations = sleepAnnotationSchema.parse(input.annotations ?? {});
705
+ const derivedMetrics = computeSleepDerivedMetrics({
706
+ asleepSeconds: input.asleepSeconds,
707
+ timeInBedSeconds: input.timeInBedSeconds,
708
+ awakeSeconds: input.awakeSeconds,
709
+ sleepScore,
710
+ stageBreakdown
711
+ });
712
+ const now = nowIso();
713
+ if (existing) {
714
+ getDatabase()
715
+ .prepare(`UPDATE health_sleep_sessions
716
+ SET pairing_session_id = ?, source_device = ?, started_at = ?, ended_at = ?, time_in_bed_seconds = ?,
717
+ asleep_seconds = ?, awake_seconds = ?, sleep_score = ?, regularity_score = ?,
718
+ bedtime_consistency_minutes = ?, wake_consistency_minutes = ?, stage_breakdown_json = ?, recovery_metrics_json = ?, links_json = ?, annotations_json = ?,
719
+ provenance_json = ?, derived_json = ?, updated_at = ?
720
+ WHERE id = ?`)
721
+ .run(pairing.id, pairing.device_name ?? "", input.startedAt, input.endedAt, input.timeInBedSeconds, input.asleepSeconds, input.awakeSeconds, sleepScore, timingMetrics.regularityScore, timingMetrics.bedtimeConsistencyMinutes, timingMetrics.wakeConsistencyMinutes, JSON.stringify(stageBreakdown), JSON.stringify(input.recoveryMetrics), JSON.stringify(mergeHealthLinks(safeJsonParse(existing.links_json, []), input.links, annotations.links)), JSON.stringify(annotations), JSON.stringify({
722
+ importedVia: "ios_companion",
723
+ pairingSessionId: pairing.id,
724
+ updatedAt: now
725
+ }), JSON.stringify(derivedMetrics), now, existing.id);
726
+ return { mode: "updated", id: existing.id };
727
+ }
728
+ const id = `sleep_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
729
+ getDatabase()
730
+ .prepare(`INSERT INTO health_sleep_sessions (
731
+ id, external_uid, pairing_session_id, user_id, source, source_type, source_device,
732
+ started_at, ended_at, time_in_bed_seconds, asleep_seconds, awake_seconds,
733
+ sleep_score, regularity_score, bedtime_consistency_minutes, wake_consistency_minutes,
734
+ stage_breakdown_json, recovery_metrics_json, links_json, annotations_json,
735
+ provenance_json, derived_json, created_at, updated_at
736
+ )
737
+ VALUES (?, ?, ?, ?, 'apple_health', 'healthkit', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
738
+ .run(id, input.externalUid, pairing.id, pairing.user_id, pairing.device_name ?? "", input.startedAt, input.endedAt, input.timeInBedSeconds, input.asleepSeconds, input.awakeSeconds, sleepScore, timingMetrics.regularityScore, timingMetrics.bedtimeConsistencyMinutes, timingMetrics.wakeConsistencyMinutes, JSON.stringify(stageBreakdown), JSON.stringify(input.recoveryMetrics), JSON.stringify(mergeHealthLinks(input.links, annotations.links)), JSON.stringify(annotations), JSON.stringify({
739
+ importedVia: "ios_companion",
740
+ pairingSessionId: pairing.id,
741
+ createdAt: now
742
+ }), JSON.stringify(derivedMetrics), now, now);
743
+ return { mode: "created", id };
744
+ }
745
+ function insertOrUpdateWorkoutSession(pairing, input) {
746
+ const existing = getDatabase()
747
+ .prepare(`SELECT *
748
+ FROM health_workout_sessions
749
+ WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`)
750
+ .get(pairing.user_id, input.externalUid);
751
+ const annotations = workoutAnnotationSchema.parse(input.annotations ?? {});
752
+ const now = nowIso();
753
+ const matchedGenerated = existing ??
754
+ findMatchingGeneratedWorkout({
755
+ userId: pairing.user_id,
756
+ workoutType: input.workoutType,
757
+ startedAt: input.startedAt,
758
+ endedAt: input.endedAt
759
+ });
760
+ if (matchedGenerated) {
761
+ const existingLinks = safeJsonParse(matchedGenerated.links_json, []);
762
+ const existingTags = safeJsonParse(matchedGenerated.tags_json, []);
763
+ const existingAnnotations = safeJsonParse(matchedGenerated.annotations_json, {});
764
+ const mergedLinks = mergeHealthLinks(existingLinks, input.links, annotations.links);
765
+ const mergedTags = mergeStringLists(existingTags, annotations.tags);
766
+ const nextSubjectiveEffort = matchedGenerated.subjective_effort ?? annotations.subjectiveEffort ?? null;
767
+ const nextMoodBefore = matchedGenerated.mood_before || annotations.moodBefore;
768
+ const nextMoodAfter = matchedGenerated.mood_after || annotations.moodAfter;
769
+ const nextMeaningText = matchedGenerated.meaning_text || annotations.meaningText;
770
+ const nextPlannedContext = matchedGenerated.planned_context || annotations.plannedContext;
771
+ const nextSocialContext = matchedGenerated.social_context || annotations.socialContext;
772
+ const nextAnnotations = {
773
+ ...existingAnnotations,
774
+ subjectiveEffort: nextSubjectiveEffort,
775
+ moodBefore: nextMoodBefore,
776
+ moodAfter: nextMoodAfter,
777
+ meaningText: nextMeaningText,
778
+ plannedContext: nextPlannedContext,
779
+ socialContext: nextSocialContext,
780
+ tags: mergedTags,
781
+ links: mergedLinks
782
+ };
783
+ getDatabase()
784
+ .prepare(`UPDATE health_workout_sessions
785
+ SET external_uid = ?, pairing_session_id = ?, source = 'apple_health', source_type = ?,
786
+ source_device = ?, started_at = ?, ended_at = ?, duration_seconds = ?, active_energy_kcal = ?,
787
+ total_energy_kcal = ?, distance_meters = ?, step_count = ?, exercise_minutes = ?, average_heart_rate = ?,
788
+ max_heart_rate = ?, subjective_effort = ?, mood_before = ?, mood_after = ?, meaning_text = ?,
789
+ planned_context = ?, social_context = ?,
790
+ links_json = ?, tags_json = ?, annotations_json = ?, provenance_json = ?, derived_json = ?,
791
+ reconciliation_status = ?, updated_at = ?
792
+ WHERE id = ?`)
793
+ .run(input.externalUid, pairing.id, matchedGenerated.generated_from_habit_id ? "reconciled" : "healthkit", input.sourceDevice, input.startedAt, input.endedAt, Math.max(0, Math.round((Date.parse(input.endedAt) - Date.parse(input.startedAt)) / 1000)), input.activeEnergyKcal ?? null, input.totalEnergyKcal ?? null, input.distanceMeters ?? null, input.stepCount ?? null, input.exerciseMinutes ?? null, input.averageHeartRate ?? null, input.maxHeartRate ?? null, nextSubjectiveEffort, nextMoodBefore, nextMoodAfter, nextMeaningText, nextPlannedContext, nextSocialContext, JSON.stringify(mergedLinks), JSON.stringify(mergedTags), JSON.stringify(nextAnnotations), JSON.stringify({
794
+ importedVia: "ios_companion",
795
+ pairingSessionId: pairing.id,
796
+ mergedWithGenerated: matchedGenerated.generated_from_habit_id !== null,
797
+ priorSource: matchedGenerated.source,
798
+ updatedAt: now
799
+ }), JSON.stringify({
800
+ paceMetersPerMinute: input.distanceMeters && input.exerciseMinutes
801
+ ? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
802
+ : null
803
+ }), matchedGenerated.generated_from_habit_id ? "merged" : "standalone", now, matchedGenerated.id);
804
+ return {
805
+ mode: matchedGenerated.generated_from_habit_id || matchedGenerated.source !== "apple_health"
806
+ ? "merged"
807
+ : "updated",
808
+ id: matchedGenerated.id
809
+ };
810
+ }
811
+ const id = `workout_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
812
+ getDatabase()
813
+ .prepare(`INSERT INTO health_workout_sessions (
814
+ id, external_uid, pairing_session_id, user_id, source, source_type, workout_type, source_device,
815
+ started_at, ended_at, duration_seconds, active_energy_kcal, total_energy_kcal, distance_meters,
816
+ step_count, exercise_minutes, average_heart_rate, max_heart_rate, subjective_effort, mood_before,
817
+ mood_after, meaning_text, planned_context, social_context, links_json, tags_json, annotations_json,
818
+ provenance_json, derived_json, reconciliation_status, created_at, updated_at
819
+ )
820
+ VALUES (?, ?, ?, ?, 'apple_health', 'healthkit', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'standalone', ?, ?)`)
821
+ .run(id, input.externalUid, pairing.id, pairing.user_id, input.workoutType, input.sourceDevice, input.startedAt, input.endedAt, Math.max(0, Math.round((Date.parse(input.endedAt) - Date.parse(input.startedAt)) / 1000)), input.activeEnergyKcal ?? null, input.totalEnergyKcal ?? null, input.distanceMeters ?? null, input.stepCount ?? null, input.exerciseMinutes ?? null, input.averageHeartRate ?? null, input.maxHeartRate ?? null, annotations.subjectiveEffort, annotations.moodBefore, annotations.moodAfter, annotations.meaningText, annotations.plannedContext, annotations.socialContext, JSON.stringify(input.links), JSON.stringify(annotations.tags), JSON.stringify(annotations), JSON.stringify({
822
+ importedVia: "ios_companion",
823
+ pairingSessionId: pairing.id,
824
+ createdAt: now
825
+ }), JSON.stringify({
826
+ paceMetersPerMinute: input.distanceMeters && input.exerciseMinutes
827
+ ? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
828
+ : null
829
+ }), now, now);
830
+ return { mode: "created", id };
831
+ }
832
+ export function ingestMobileHealthSync(payload) {
833
+ const parsed = mobileHealthSyncSchema.parse(payload);
834
+ const pairing = requireValidPairing(parsed.sessionId, parsed.pairingToken);
835
+ return runInTransaction(() => {
836
+ const now = nowIso();
837
+ const runId = `hir_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
838
+ let createdCount = 0;
839
+ let updatedCount = 0;
840
+ let mergedCount = 0;
841
+ for (const sleep of parsed.sleepSessions) {
842
+ const result = insertOrUpdateSleepSession(pairing, sleep);
843
+ if (result.mode === "created") {
844
+ createdCount += 1;
845
+ }
846
+ else {
847
+ updatedCount += 1;
848
+ }
849
+ summarizeUserHealthDay(pairing.user_id, dayKey(sleep.endedAt));
850
+ }
851
+ for (const workout of parsed.workouts) {
852
+ const result = insertOrUpdateWorkoutSession(pairing, workout);
853
+ if (result.mode === "created") {
854
+ createdCount += 1;
855
+ }
856
+ else if (result.mode === "merged") {
857
+ mergedCount += 1;
858
+ }
859
+ else {
860
+ updatedCount += 1;
861
+ }
862
+ summarizeUserHealthDay(pairing.user_id, dayKey(workout.startedAt));
863
+ }
864
+ const permissionStatus = parsed.permissions.healthKitAuthorized === false
865
+ ? "permission_denied"
866
+ : "healthy";
867
+ getDatabase()
868
+ .prepare(`UPDATE companion_pairing_sessions
869
+ SET status = ?, device_name = ?, platform = ?, app_version = ?, last_seen_at = ?,
870
+ last_sync_at = ?, last_sync_error = NULL, paired_at = COALESCE(paired_at, ?), updated_at = ?
871
+ WHERE id = ?`)
872
+ .run(permissionStatus, parsed.device.name, parsed.device.platform, parsed.device.appVersion, now, now, now, now, pairing.id);
873
+ getDatabase()
874
+ .prepare(`INSERT INTO health_import_runs (
875
+ id, pairing_session_id, user_id, source, source_device, status, payload_summary_json,
876
+ imported_count, created_count, updated_count, merged_count, imported_at, created_at, updated_at
877
+ )
878
+ VALUES (?, ?, ?, 'ios_companion', ?, 'completed', ?, ?, ?, ?, ?, ?, ?, ?)`)
879
+ .run(runId, pairing.id, pairing.user_id, parsed.device.sourceDevice, JSON.stringify({
880
+ permissions: parsed.permissions,
881
+ sleepSessions: parsed.sleepSessions.length,
882
+ workouts: parsed.workouts.length
883
+ }), parsed.sleepSessions.length + parsed.workouts.length, createdCount, updatedCount, mergedCount, now, now, now);
884
+ recordActivityEvent({
885
+ entityType: "system",
886
+ entityId: pairing.id,
887
+ eventType: "companion_sync_completed",
888
+ title: `Forge Companion sync: ${parsed.device.name}`,
889
+ description: "The iOS companion imported Apple Health sleep and workout records into Forge.",
890
+ actor: "Forge Companion",
891
+ source: "system",
892
+ metadata: {
893
+ sleepSessions: parsed.sleepSessions.length,
894
+ workouts: parsed.workouts.length,
895
+ createdCount,
896
+ updatedCount,
897
+ mergedCount
898
+ }
899
+ });
900
+ return {
901
+ pairingSession: mapPairingSession(getDatabase()
902
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
903
+ .get(pairing.id)),
904
+ imported: {
905
+ sleepSessions: parsed.sleepSessions.length,
906
+ workouts: parsed.workouts.length,
907
+ createdCount,
908
+ updatedCount,
909
+ mergedCount
910
+ }
911
+ };
912
+ });
913
+ }
914
+ export function getCompanionOverview(userIds) {
915
+ const pairings = listPairingSessions(userIds);
916
+ const importRuns = listHealthImportRunRows(userIds).map(mapHealthImportRun);
917
+ const sleepSessions = listSleepRows(userIds).map(mapSleepSession);
918
+ const workouts = listWorkoutRows(userIds).map(mapWorkoutSession);
919
+ const activePairings = pairings.filter((pairing) => pairing.status !== "revoked");
920
+ const recentPermissionStates = importRuns
921
+ .map((run) => safeJsonParse(JSON.stringify(run.payloadSummary), {}))
922
+ .map((payloadSummary) => safeJsonParse(JSON.stringify(payloadSummary.permissions ?? {}), {}));
923
+ const lastSyncAt = pairings
924
+ .map((pairing) => pairing.lastSyncAt)
925
+ .filter((value) => Boolean(value))
926
+ .sort((left, right) => Date.parse(right) - Date.parse(left))[0] ?? null;
927
+ return {
928
+ pairings,
929
+ importRuns,
930
+ healthState: activePairings.length === 0
931
+ ? "disconnected"
932
+ : activePairings.some((pairing) => pairing.status === "permission_denied")
933
+ ? "partially_connected"
934
+ : activePairings.some((pairing) => pairing.status === "stale")
935
+ ? "stale_sync"
936
+ : activePairings.some((pairing) => pairing.status === "healthy")
937
+ ? "healthy_sync"
938
+ : "connected",
939
+ lastSyncAt,
940
+ counts: {
941
+ sleepSessions: sleepSessions.length,
942
+ workouts: workouts.length,
943
+ reflectiveSleepSessions: sleepSessions.filter((session) => {
944
+ const annotations = session.annotations;
945
+ const tags = Array.isArray(annotations.tags) ? annotations.tags : [];
946
+ return (session.links.length > 0 ||
947
+ (typeof annotations.qualitySummary === "string" &&
948
+ annotations.qualitySummary.length > 0) ||
949
+ (typeof annotations.notes === "string" && annotations.notes.length > 0) ||
950
+ tags.length > 0);
951
+ }).length,
952
+ linkedWorkouts: workouts.filter((session) => session.links.length > 0).length,
953
+ habitGeneratedWorkouts: workouts.filter((session) => session.sourceType === "habit_generated").length,
954
+ reconciledWorkouts: workouts.filter((session) => session.reconciliationStatus === "merged").length
955
+ },
956
+ permissions: {
957
+ healthKitAuthorized: recentPermissionStates.some((state) => state.healthKitAuthorized === true),
958
+ backgroundRefreshEnabled: recentPermissionStates.some((state) => state.backgroundRefreshEnabled === true),
959
+ locationReady: recentPermissionStates.some((state) => state.locationReady === true),
960
+ motionReady: recentPermissionStates.some((state) => state.motionReady === true)
961
+ }
962
+ };
963
+ }
964
+ export function getSleepViewData(userIds) {
965
+ const sessions = listSleepRows(userIds).map(mapSleepSession);
966
+ const recent = sessions.slice(0, 30);
967
+ const weekly = recent.slice(0, 7);
968
+ const monthly = recent.slice(0, 30);
969
+ const stageTotals = new Map();
970
+ const linkTotals = new Map();
971
+ for (const session of monthly) {
972
+ for (const stage of session.stageBreakdown) {
973
+ stageTotals.set(stage.stage, (stageTotals.get(stage.stage) ?? 0) + stage.seconds);
974
+ }
975
+ for (const link of session.links) {
976
+ linkTotals.set(link.entityType, (linkTotals.get(link.entityType) ?? 0) + 1);
977
+ }
978
+ }
979
+ return {
980
+ summary: {
981
+ totalSleepSeconds: weekly.reduce((sum, session) => sum + session.asleepSeconds, 0),
982
+ averageSleepSeconds: Math.round(average(weekly.map((session) => session.asleepSeconds))),
983
+ averageTimeInBedSeconds: Math.round(average(weekly.map((session) => session.timeInBedSeconds))),
984
+ averageSleepScore: Math.round(average(weekly
985
+ .map((session) => session.sleepScore)
986
+ .filter((value) => value !== null))),
987
+ averageRegularityScore: Math.round(average(weekly
988
+ .map((session) => session.regularityScore)
989
+ .filter((value) => value !== null))),
990
+ averageEfficiency: round(average(weekly.map((session) => typeof session.derived.efficiency ===
991
+ "number"
992
+ ? session.derived
993
+ .efficiency
994
+ : session.timeInBedSeconds > 0
995
+ ? session.asleepSeconds / session.timeInBedSeconds
996
+ : 0)), 2),
997
+ averageRestorativeShare: round(average(weekly.map((session) => typeof session.derived
998
+ .restorativeShare === "number"
999
+ ? session.derived
1000
+ .restorativeShare
1001
+ : 0)), 2),
1002
+ reflectiveNightCount: weekly.filter((session) => {
1003
+ const annotations = session.annotations;
1004
+ const tags = Array.isArray(annotations.tags) ? annotations.tags : [];
1005
+ return (session.links.length > 0 ||
1006
+ (typeof annotations.qualitySummary === "string" &&
1007
+ annotations.qualitySummary.length > 0) ||
1008
+ (typeof annotations.notes === "string" && annotations.notes.length > 0) ||
1009
+ tags.length > 0);
1010
+ }).length,
1011
+ linkedNightCount: weekly.filter((session) => session.links.length > 0).length,
1012
+ averageBedtimeConsistencyMinutes: Math.round(average(weekly
1013
+ .map((session) => session.bedtimeConsistencyMinutes)
1014
+ .filter((value) => value !== null))),
1015
+ averageWakeConsistencyMinutes: Math.round(average(weekly
1016
+ .map((session) => session.wakeConsistencyMinutes)
1017
+ .filter((value) => value !== null))),
1018
+ latestBedtime: recent[0]?.startedAt ?? null,
1019
+ latestWakeTime: recent[0]?.endedAt ?? null
1020
+ },
1021
+ weeklyTrend: weekly
1022
+ .map((session) => ({
1023
+ id: session.id,
1024
+ dateKey: dayKey(session.endedAt),
1025
+ sleepHours: Number((session.asleepSeconds / 3600).toFixed(2)),
1026
+ score: session.sleepScore ?? 0,
1027
+ regularity: session.regularityScore ?? 0
1028
+ }))
1029
+ .reverse(),
1030
+ monthlyPattern: monthly
1031
+ .map((session) => ({
1032
+ id: session.id,
1033
+ dateKey: dayKey(session.endedAt),
1034
+ onsetHour: new Date(session.startedAt).getHours(),
1035
+ wakeHour: new Date(session.endedAt).getHours(),
1036
+ sleepHours: Number((session.asleepSeconds / 3600).toFixed(2))
1037
+ }))
1038
+ .reverse(),
1039
+ stageAverages: [...stageTotals.entries()]
1040
+ .map(([stage, totalSeconds]) => ({
1041
+ stage,
1042
+ averageSeconds: Math.round(totalSeconds / Math.max(1, monthly.length))
1043
+ }))
1044
+ .sort((left, right) => right.averageSeconds - left.averageSeconds),
1045
+ linkBreakdown: [...linkTotals.entries()]
1046
+ .map(([entityType, count]) => ({ entityType, count }))
1047
+ .sort((left, right) => right.count - left.count),
1048
+ sessions: recent
1049
+ };
1050
+ }
1051
+ export function getFitnessViewData(userIds) {
1052
+ const workouts = listWorkoutRows(userIds).map(mapWorkoutSession);
1053
+ const recent = workouts.slice(0, 40);
1054
+ const weekly = recent.filter((session) => Date.now() - Date.parse(session.startedAt) <= 7 * 24 * 60 * 60 * 1000);
1055
+ const weeklyVolumeSeconds = weekly.reduce((sum, session) => sum + session.durationSeconds, 0);
1056
+ const exerciseMinutes = weekly.reduce((sum, session) => sum + (session.exerciseMinutes ?? session.durationSeconds / 60), 0);
1057
+ const energyBurned = weekly.reduce((sum, session) => sum + (session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0), 0);
1058
+ const distanceMeters = weekly.reduce((sum, session) => sum + (session.distanceMeters ?? 0), 0);
1059
+ const workoutTypes = Array.from(new Set(recent.map((session) => session.workoutType)));
1060
+ const workoutTypeBreakdown = new Map();
1061
+ for (const session of recent) {
1062
+ const current = workoutTypeBreakdown.get(session.workoutType) ?? {
1063
+ sessionCount: 0,
1064
+ totalMinutes: 0,
1065
+ energyKcal: 0
1066
+ };
1067
+ current.sessionCount += 1;
1068
+ current.totalMinutes += Math.round(session.durationSeconds / 60);
1069
+ current.energyKcal += Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0);
1070
+ workoutTypeBreakdown.set(session.workoutType, current);
1071
+ }
1072
+ const orderedWorkoutTypes = [...workoutTypeBreakdown.entries()].sort((left, right) => right[1].totalMinutes - left[1].totalMinutes);
1073
+ return {
1074
+ summary: {
1075
+ workoutCount: weekly.length,
1076
+ weeklyVolumeSeconds,
1077
+ exerciseMinutes: Math.round(exerciseMinutes),
1078
+ energyBurnedKcal: Math.round(energyBurned),
1079
+ distanceMeters: Math.round(distanceMeters),
1080
+ workoutTypes,
1081
+ averageSessionMinutes: Math.round(average(recent.map((session) => session.durationSeconds / 60))),
1082
+ averageEffort: round(average(recent
1083
+ .map((session) => session.subjectiveEffort)
1084
+ .filter((value) => value !== null)), 1),
1085
+ linkedSessionCount: recent.filter((session) => session.links.length > 0).length,
1086
+ plannedSessionCount: recent.filter((session) => session.plannedContext.trim().length > 0).length,
1087
+ importedSessionCount: recent.filter((session) => session.source === "apple_health").length,
1088
+ habitGeneratedSessionCount: recent.filter((session) => session.sourceType === "habit_generated").length,
1089
+ reconciledSessionCount: recent.filter((session) => session.reconciliationStatus === "merged").length,
1090
+ topWorkoutType: orderedWorkoutTypes[0]?.[0] ?? null,
1091
+ streakDays: Array.from(new Set(weekly.map((session) => dayKey(session.startedAt)))).length
1092
+ },
1093
+ weeklyTrend: weekly
1094
+ .map((session) => ({
1095
+ id: session.id,
1096
+ dateKey: dayKey(session.startedAt),
1097
+ workoutType: session.workoutType,
1098
+ durationMinutes: Math.round(session.durationSeconds / 60),
1099
+ energyKcal: Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0)
1100
+ }))
1101
+ .reverse(),
1102
+ typeBreakdown: orderedWorkoutTypes.map(([workoutType, metrics]) => ({
1103
+ workoutType,
1104
+ sessionCount: metrics.sessionCount,
1105
+ totalMinutes: metrics.totalMinutes,
1106
+ energyKcal: metrics.energyKcal
1107
+ })),
1108
+ sessions: recent
1109
+ };
1110
+ }
1111
+ export function updateWorkoutMetadata(workoutId, patch, activity) {
1112
+ const parsed = updateWorkoutMetadataSchema.parse(patch);
1113
+ const current = getDatabase()
1114
+ .prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
1115
+ .get(workoutId);
1116
+ if (!current) {
1117
+ return undefined;
1118
+ }
1119
+ const nextLinks = parsed.links ?? safeJsonParse(current.links_json, []);
1120
+ const nextTags = parsed.tags ?? safeJsonParse(current.tags_json, []);
1121
+ const nextAnnotations = {
1122
+ ...safeJsonParse(current.annotations_json, {}),
1123
+ ...(parsed.subjectiveEffort !== undefined
1124
+ ? { subjectiveEffort: parsed.subjectiveEffort }
1125
+ : {}),
1126
+ ...(parsed.moodBefore !== undefined ? { moodBefore: parsed.moodBefore } : {}),
1127
+ ...(parsed.moodAfter !== undefined ? { moodAfter: parsed.moodAfter } : {}),
1128
+ ...(parsed.meaningText !== undefined
1129
+ ? { meaningText: parsed.meaningText }
1130
+ : {}),
1131
+ ...(parsed.plannedContext !== undefined
1132
+ ? { plannedContext: parsed.plannedContext }
1133
+ : {}),
1134
+ ...(parsed.socialContext !== undefined
1135
+ ? { socialContext: parsed.socialContext }
1136
+ : {}),
1137
+ ...(parsed.tags !== undefined ? { tags: parsed.tags } : {}),
1138
+ ...(parsed.links !== undefined ? { links: parsed.links } : {})
1139
+ };
1140
+ const now = nowIso();
1141
+ getDatabase()
1142
+ .prepare(`UPDATE health_workout_sessions
1143
+ SET subjective_effort = ?, mood_before = ?, mood_after = ?, meaning_text = ?, planned_context = ?,
1144
+ social_context = ?, links_json = ?, tags_json = ?, annotations_json = ?, updated_at = ?
1145
+ WHERE id = ?`)
1146
+ .run(parsed.subjectiveEffort ?? current.subjective_effort, parsed.moodBefore ?? current.mood_before, parsed.moodAfter ?? current.mood_after, parsed.meaningText ?? current.meaning_text, parsed.plannedContext ?? current.planned_context, parsed.socialContext ?? current.social_context, JSON.stringify(nextLinks), JSON.stringify(nextTags), JSON.stringify(nextAnnotations), now, workoutId);
1147
+ recordActivityEvent({
1148
+ entityType: "system",
1149
+ entityId: workoutId,
1150
+ eventType: "workout_metadata_updated",
1151
+ title: "Workout metadata updated",
1152
+ description: "Forge metadata, tags, or linked context was updated for a workout session.",
1153
+ actor: activity?.actor ?? null,
1154
+ source: activity?.source ?? "ui",
1155
+ metadata: {
1156
+ hasLinks: nextLinks.length > 0,
1157
+ tagCount: nextTags.length
1158
+ }
1159
+ });
1160
+ return mapWorkoutSession(getDatabase()
1161
+ .prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
1162
+ .get(workoutId));
1163
+ }
1164
+ export function updateSleepMetadata(sleepId, patch, activity) {
1165
+ const parsed = updateSleepMetadataSchema.parse(patch);
1166
+ const current = getDatabase()
1167
+ .prepare(`SELECT * FROM health_sleep_sessions WHERE id = ?`)
1168
+ .get(sleepId);
1169
+ if (!current) {
1170
+ return undefined;
1171
+ }
1172
+ const nextAnnotations = {
1173
+ ...safeJsonParse(current.annotations_json, {}),
1174
+ ...(parsed.qualitySummary !== undefined
1175
+ ? { qualitySummary: parsed.qualitySummary }
1176
+ : {}),
1177
+ ...(parsed.notes !== undefined ? { notes: parsed.notes } : {}),
1178
+ ...(parsed.tags !== undefined ? { tags: parsed.tags } : {}),
1179
+ ...(parsed.links !== undefined ? { links: parsed.links } : {})
1180
+ };
1181
+ const nextLinks = parsed.links ?? safeJsonParse(current.links_json, []);
1182
+ const now = nowIso();
1183
+ getDatabase()
1184
+ .prepare(`UPDATE health_sleep_sessions
1185
+ SET links_json = ?, annotations_json = ?, updated_at = ?
1186
+ WHERE id = ?`)
1187
+ .run(JSON.stringify(nextLinks), JSON.stringify(nextAnnotations), now, sleepId);
1188
+ recordActivityEvent({
1189
+ entityType: "system",
1190
+ entityId: sleepId,
1191
+ eventType: "sleep_metadata_updated",
1192
+ title: "Sleep reflection updated",
1193
+ description: "Forge links or reflective notes were updated for a sleep session.",
1194
+ actor: activity?.actor ?? null,
1195
+ source: activity?.source ?? "ui",
1196
+ metadata: {
1197
+ hasLinks: nextLinks.length > 0
1198
+ }
1199
+ });
1200
+ return mapSleepSession(getDatabase()
1201
+ .prepare(`SELECT * FROM health_sleep_sessions WHERE id = ?`)
1202
+ .get(sleepId));
1203
+ }
1204
+ export function createGeneratedWorkoutFromHabit(args) {
1205
+ const template = generatedHealthEventTemplateSchema.parse(args.template);
1206
+ if (!template.enabled) {
1207
+ return null;
1208
+ }
1209
+ const startedAt = new Date(`${args.dateKey}T07:00:00.000Z`).toISOString();
1210
+ const endedAt = new Date(Date.parse(startedAt) + template.durationMinutes * 60_000).toISOString();
1211
+ const existing = getDatabase()
1212
+ .prepare(`SELECT *
1213
+ FROM health_workout_sessions
1214
+ WHERE generated_from_check_in_id = ?`)
1215
+ .get(args.checkInId);
1216
+ if (existing) {
1217
+ return mapWorkoutSession(existing);
1218
+ }
1219
+ const now = nowIso();
1220
+ const id = `workout_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
1221
+ getDatabase()
1222
+ .prepare(`INSERT INTO health_workout_sessions (
1223
+ id, external_uid, pairing_session_id, user_id, source, source_type, workout_type, source_device,
1224
+ started_at, ended_at, duration_seconds, links_json, tags_json, annotations_json, provenance_json,
1225
+ derived_json, generated_from_habit_id, generated_from_check_in_id, reconciliation_status, created_at, updated_at
1226
+ )
1227
+ VALUES (?, ?, NULL, ?, 'forge_habit', 'habit_generated', ?, 'Habit automation', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'awaiting_import_match', ?, ?)`)
1228
+ .run(id, `habit_${args.checkInId}`, args.userId, template.workoutType, startedAt, endedAt, template.durationMinutes * 60, JSON.stringify([
1229
+ ...(template.links ?? []),
1230
+ ...(args.linkedEntities ?? [])
1231
+ ]), JSON.stringify(template.tags), JSON.stringify({
1232
+ meaningText: template.notesTemplate,
1233
+ plannedContext: args.habitTitle
1234
+ }), JSON.stringify({
1235
+ generatedFrom: "habit_completion",
1236
+ habitId: args.habitId,
1237
+ checkInId: args.checkInId
1238
+ }), JSON.stringify({
1239
+ xpReward: template.xpReward
1240
+ }), args.habitId, args.checkInId, now, now);
1241
+ recordActivityEvent({
1242
+ entityType: "habit",
1243
+ entityId: args.habitId,
1244
+ eventType: "habit_generated_workout",
1245
+ title: `Habit generated workout: ${args.habitTitle}`,
1246
+ description: "Completing this habit generated a structured workout record inside Forge.",
1247
+ actor: "Forge",
1248
+ source: "system",
1249
+ metadata: {
1250
+ workoutId: id,
1251
+ workoutType: template.workoutType,
1252
+ xpReward: template.xpReward
1253
+ }
1254
+ });
1255
+ recordHabitGeneratedWorkoutReward({
1256
+ habitId: args.habitId,
1257
+ habitTitle: args.habitTitle,
1258
+ checkInId: args.checkInId,
1259
+ workoutId: id,
1260
+ workoutType: template.workoutType,
1261
+ xpReward: template.xpReward
1262
+ }, {
1263
+ actor: "Forge",
1264
+ source: "system"
1265
+ });
1266
+ return mapWorkoutSession(getDatabase()
1267
+ .prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
1268
+ .get(id));
1269
+ }
1270
+ export function parseGeneratedHealthEventTemplate(value) {
1271
+ if (typeof value === "string") {
1272
+ const trimmed = value.trim();
1273
+ if (!trimmed) {
1274
+ return generatedHealthEventTemplateSchema.parse({});
1275
+ }
1276
+ try {
1277
+ return generatedHealthEventTemplateSchema.parse(JSON.parse(trimmed));
1278
+ }
1279
+ catch {
1280
+ return generatedHealthEventTemplateSchema.parse({});
1281
+ }
1282
+ }
1283
+ return generatedHealthEventTemplateSchema.parse(value ?? {});
1284
+ }