forge-openclaw-plugin 0.2.68 → 0.2.70

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 (28) hide show
  1. package/dist/assets/{board-DFNV9VAZ.js → board-BfqxFNiQ.js} +1 -1
  2. package/dist/assets/index-BfLQnCNZ.js +91 -0
  3. package/dist/assets/index-DIapFz9v.css +1 -0
  4. package/dist/assets/{motion-CXdn34ih.js → motion-C0ALlgho.js} +1 -1
  5. package/dist/assets/{table-CEq3bTDv.js → table-WcMjnJll.js} +1 -1
  6. package/dist/assets/{ui-g7FaEglG.js → ui-B5I-3U91.js} +1 -1
  7. package/dist/assets/vendor-B-Lq_OG3.css +1 -0
  8. package/dist/assets/vendor-C56o26_3.js +2163 -0
  9. package/dist/index.html +8 -8
  10. package/dist/server/server/migrations/061_health_workout_raw_evidence.sql +95 -0
  11. package/dist/server/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
  12. package/dist/server/server/src/app.js +149 -21
  13. package/dist/server/server/src/health-workout-analytics.js +572 -0
  14. package/dist/server/server/src/health.js +612 -4
  15. package/dist/server/server/src/openapi.js +162 -0
  16. package/dist/server/server/src/psyche-types.js +59 -0
  17. package/dist/server/server/src/services/devrage.js +191 -0
  18. package/dist/server/src/lib/api.js +13 -0
  19. package/openclaw.plugin.json +1 -1
  20. package/package.json +2 -1
  21. package/server/migrations/061_health_workout_raw_evidence.sql +95 -0
  22. package/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
  23. package/skills/forge-openclaw/SKILL.md +35 -6
  24. package/skills/forge-openclaw/entity_conversation_playbooks.md +179 -18
  25. package/dist/assets/index-B0PIKEnz.css +0 -1
  26. package/dist/assets/index-BofyMuFh.js +0 -90
  27. package/dist/assets/vendor-BcOHGipZ.js +0 -1341
  28. package/dist/assets/vendor-DT3pnAKJ.css +0 -1
@@ -4650,6 +4650,7 @@ export function buildOpenApiDocument() {
4650
4650
  additionalProperties: false,
4651
4651
  required: [
4652
4652
  "generatedAt",
4653
+ "hasData",
4653
4654
  "latestDateKey",
4654
4655
  "rawSwearCount",
4655
4656
  "swearingMessagePercent",
@@ -4663,6 +4664,7 @@ export function buildOpenApiDocument() {
4663
4664
  ],
4664
4665
  properties: {
4665
4666
  generatedAt: { type: "string", format: "date-time" },
4667
+ hasData: { type: "boolean" },
4666
4668
  latestDateKey: nullable({ type: "string" }),
4667
4669
  rawSwearCount: { type: "number" },
4668
4670
  swearingMessagePercent: { type: "number" },
@@ -4719,6 +4721,148 @@ export function buildOpenApiDocument() {
4719
4721
  }
4720
4722
  }
4721
4723
  };
4724
+ const dailyMetricDayRecord = {
4725
+ type: "object",
4726
+ additionalProperties: false,
4727
+ required: [
4728
+ "dateKey",
4729
+ "average",
4730
+ "minimum",
4731
+ "maximum",
4732
+ "latest",
4733
+ "total",
4734
+ "sampleCount",
4735
+ "latestSampleAt"
4736
+ ],
4737
+ properties: {
4738
+ dateKey: { type: "string" },
4739
+ average: nullable({ type: "number" }),
4740
+ minimum: nullable({ type: "number" }),
4741
+ maximum: nullable({ type: "number" }),
4742
+ latest: nullable({ type: "number" }),
4743
+ total: nullable({ type: "number" }),
4744
+ sampleCount: { type: "integer" },
4745
+ latestSampleAt: nullable({ type: "string", format: "date-time" })
4746
+ }
4747
+ };
4748
+ const dailyMetricRecord = {
4749
+ type: "object",
4750
+ additionalProperties: false,
4751
+ required: [
4752
+ "metric",
4753
+ "label",
4754
+ "category",
4755
+ "unit",
4756
+ "aggregation",
4757
+ "latestValue",
4758
+ "latestDateKey",
4759
+ "baselineValue",
4760
+ "deltaValue",
4761
+ "coverageDays",
4762
+ "days"
4763
+ ],
4764
+ properties: {
4765
+ metric: { type: "string" },
4766
+ label: { type: "string" },
4767
+ category: { type: "string" },
4768
+ unit: { type: "string" },
4769
+ aggregation: { type: "string", enum: ["discrete", "cumulative"] },
4770
+ latestValue: nullable({ type: "number" }),
4771
+ latestDateKey: nullable({ type: "string" }),
4772
+ baselineValue: nullable({ type: "number" }),
4773
+ deltaValue: nullable({ type: "number" }),
4774
+ coverageDays: { type: "integer" },
4775
+ days: arrayOf(dailyMetricDayRecord)
4776
+ }
4777
+ };
4778
+ const psycheMetricsViewData = {
4779
+ type: "object",
4780
+ additionalProperties: false,
4781
+ required: ["summary", "context", "metrics"],
4782
+ properties: {
4783
+ summary: {
4784
+ type: "object",
4785
+ additionalProperties: false,
4786
+ required: [
4787
+ "hasData",
4788
+ "trackedDays",
4789
+ "metricCount",
4790
+ "latestDateKey",
4791
+ "latestMetricCount",
4792
+ "categoryBreakdown"
4793
+ ],
4794
+ properties: {
4795
+ hasData: { type: "boolean" },
4796
+ trackedDays: { type: "integer" },
4797
+ metricCount: { type: "integer" },
4798
+ latestDateKey: nullable({ type: "string" }),
4799
+ latestMetricCount: { type: "integer" },
4800
+ categoryBreakdown: arrayOf({
4801
+ type: "object",
4802
+ additionalProperties: false,
4803
+ required: ["category", "metricCount", "coverageDays"],
4804
+ properties: {
4805
+ category: { type: "string" },
4806
+ metricCount: { type: "integer" },
4807
+ coverageDays: { type: "integer" }
4808
+ }
4809
+ })
4810
+ }
4811
+ },
4812
+ context: {
4813
+ type: "object",
4814
+ additionalProperties: false,
4815
+ required: [
4816
+ "generatedAt",
4817
+ "conversationsScanned",
4818
+ "sourceCount",
4819
+ "messagesScanned",
4820
+ "messagesWithSwears",
4821
+ "totalSwears",
4822
+ "dailyAverage",
4823
+ "weeklyAverage",
4824
+ "sync"
4825
+ ],
4826
+ properties: {
4827
+ generatedAt: { type: "string", format: "date-time" },
4828
+ conversationsScanned: { type: "integer" },
4829
+ sourceCount: { type: "integer" },
4830
+ messagesScanned: { type: "integer" },
4831
+ messagesWithSwears: { type: "integer" },
4832
+ totalSwears: { type: "number" },
4833
+ dailyAverage: {
4834
+ type: "object",
4835
+ additionalProperties: false,
4836
+ required: ["rawSwearCount", "swearingMessagePercent"],
4837
+ properties: {
4838
+ rawSwearCount: { type: "number" },
4839
+ swearingMessagePercent: { type: "number" }
4840
+ }
4841
+ },
4842
+ weeklyAverage: {
4843
+ type: "object",
4844
+ additionalProperties: false,
4845
+ required: ["rawSwearCount", "swearingMessagePercent"],
4846
+ properties: {
4847
+ rawSwearCount: { type: "number" },
4848
+ swearingMessagePercent: { type: "number" }
4849
+ }
4850
+ },
4851
+ sync: {
4852
+ type: "object",
4853
+ additionalProperties: false,
4854
+ required: ["fullSyncCompletedAt", "lastDailySyncAt", "lastSyncedDateKey"],
4855
+ properties: {
4856
+ fullSyncCompletedAt: nullable({ type: "string", format: "date-time" }),
4857
+ lastDailySyncAt: nullable({ type: "string", format: "date-time" }),
4858
+ lastSyncedDateKey: nullable({ type: "string" })
4859
+ }
4860
+ }
4861
+ }
4862
+ },
4863
+ metrics: arrayOf(dailyMetricRecord)
4864
+ }
4865
+ };
4722
4866
  const psycheOverviewPayload = {
4723
4867
  type: "object",
4724
4868
  additionalProperties: false,
@@ -5068,6 +5212,7 @@ export function buildOpenApiDocument() {
5068
5212
  WorkoutSession: workoutSession,
5069
5213
  SleepViewData: sleepViewData,
5070
5214
  FitnessViewData: fitnessViewData,
5215
+ PsycheMetricsViewData: psycheMetricsViewData,
5071
5216
  PsycheOverviewPayload: psycheOverviewPayload,
5072
5217
  Insight: insight,
5073
5218
  InsightFeedback: insightFeedback,
@@ -7120,6 +7265,23 @@ export function buildOpenApiDocument() {
7120
7265
  }
7121
7266
  }
7122
7267
  },
7268
+ "/api/v1/psyche/metrics": {
7269
+ get: {
7270
+ summary: "Get daily Psyche metric history",
7271
+ responses: {
7272
+ "200": jsonResponse({
7273
+ type: "object",
7274
+ required: ["metrics"],
7275
+ properties: {
7276
+ metrics: {
7277
+ $ref: "#/components/schemas/PsycheMetricsViewData"
7278
+ }
7279
+ }
7280
+ }, "Psyche metrics view"),
7281
+ default: { $ref: "#/components/responses/Error" }
7282
+ }
7283
+ }
7284
+ },
7123
7285
  "/api/v1/psyche/values": {
7124
7286
  get: {
7125
7287
  summary: "List ACT-style values",
@@ -246,6 +246,7 @@ export const schemaPressureEntrySchema = z.object({
246
246
  });
247
247
  export const devrageMetricPayloadSchema = z.object({
248
248
  generatedAt: z.string(),
249
+ hasData: z.boolean(),
249
250
  latestDateKey: z.string().nullable(),
250
251
  rawSwearCount: z.number().nonnegative(),
251
252
  swearingMessagePercent: z.number().nonnegative(),
@@ -274,6 +275,64 @@ export const devrageMetricPayloadSchema = z.object({
274
275
  lastSyncedDateKey: z.string().nullable()
275
276
  })
276
277
  });
278
+ export const psycheMetricDayRecordSchema = z.object({
279
+ dateKey: z.string(),
280
+ average: z.number().nullable(),
281
+ minimum: z.number().nullable(),
282
+ maximum: z.number().nullable(),
283
+ latest: z.number().nullable(),
284
+ total: z.number().nullable(),
285
+ sampleCount: z.number().int().nonnegative(),
286
+ latestSampleAt: z.string().nullable()
287
+ });
288
+ export const psycheMetricsViewDataSchema = z.object({
289
+ summary: z.object({
290
+ hasData: z.boolean(),
291
+ trackedDays: z.number().int().nonnegative(),
292
+ metricCount: z.number().int().nonnegative(),
293
+ latestDateKey: z.string().nullable(),
294
+ latestMetricCount: z.number().int().nonnegative(),
295
+ categoryBreakdown: z.array(z.object({
296
+ category: z.string(),
297
+ metricCount: z.number().int().nonnegative(),
298
+ coverageDays: z.number().int().nonnegative()
299
+ }))
300
+ }),
301
+ context: z.object({
302
+ generatedAt: z.string(),
303
+ conversationsScanned: z.number().int().nonnegative(),
304
+ sourceCount: z.number().int().nonnegative(),
305
+ messagesScanned: z.number().int().nonnegative(),
306
+ messagesWithSwears: z.number().int().nonnegative(),
307
+ totalSwears: z.number().nonnegative(),
308
+ dailyAverage: z.object({
309
+ rawSwearCount: z.number().nonnegative(),
310
+ swearingMessagePercent: z.number().nonnegative()
311
+ }),
312
+ weeklyAverage: z.object({
313
+ rawSwearCount: z.number().nonnegative(),
314
+ swearingMessagePercent: z.number().nonnegative()
315
+ }),
316
+ sync: z.object({
317
+ fullSyncCompletedAt: z.string().nullable(),
318
+ lastDailySyncAt: z.string().nullable(),
319
+ lastSyncedDateKey: z.string().nullable()
320
+ })
321
+ }),
322
+ metrics: z.array(z.object({
323
+ metric: z.string(),
324
+ label: z.string(),
325
+ category: z.string(),
326
+ unit: z.string(),
327
+ aggregation: z.enum(["discrete", "cumulative"]),
328
+ latestValue: z.number().nullable(),
329
+ latestDateKey: z.string().nullable(),
330
+ baselineValue: z.number().nullable(),
331
+ deltaValue: z.number().nullable(),
332
+ coverageDays: z.number().int().nonnegative(),
333
+ days: z.array(psycheMetricDayRecordSchema)
334
+ }))
335
+ });
277
336
  export const psycheOverviewPayloadSchema = z.object({
278
337
  generatedAt: z.string(),
279
338
  domain: domainSchema,
@@ -1,9 +1,26 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { scanConversations } from "forge-devrage";
3
3
  import { getDatabase, runInTransaction } from "../db.js";
4
+ import { psycheMetricsViewDataSchema } from "../psyche-types.js";
4
5
  const SWEAR_COUNT_KEY = "swear_count";
5
6
  const SWEARING_MESSAGE_PERCENT_KEY = "swearing_message_percent";
6
7
  const DEFAULT_ROLE_FILTER = new Set(["user"]);
8
+ const PSYCHE_METRIC_DEFINITIONS = {
9
+ [SWEAR_COUNT_KEY]: {
10
+ metric: "devrageSwearCount",
11
+ label: "Devrage swears",
12
+ category: "conversationTone",
13
+ unit: "swears",
14
+ aggregation: "cumulative"
15
+ },
16
+ [SWEARING_MESSAGE_PERCENT_KEY]: {
17
+ metric: "swearingMessagePercent",
18
+ label: "Swearing messages",
19
+ category: "conversationTone",
20
+ unit: "%",
21
+ aggregation: "discrete"
22
+ }
23
+ };
7
24
  let syncInFlight = null;
8
25
  export async function syncDevrageMetricHistory(options = {}) {
9
26
  if (syncInFlight) {
@@ -34,6 +51,7 @@ export function getDevrageMetricPayload() {
34
51
  const weeklyAverages = getMetricAverages(7);
35
52
  return {
36
53
  generatedAt,
54
+ hasData: history.some((day) => day.conversationsScanned > 0),
37
55
  latestDateKey: latest?.dateKey ?? null,
38
56
  rawSwearCount: latest?.rawSwearCount ?? 0,
39
57
  swearingMessagePercent: latest?.swearingMessagePercent ?? 0,
@@ -56,6 +74,153 @@ export function getDevrageMetricPayload() {
56
74
  }
57
75
  };
58
76
  }
77
+ export function getPsycheMetricsViewData() {
78
+ const rows = getDatabase()
79
+ .prepare(`SELECT date_key, metric_key, value, unit, sample_count, computed_at
80
+ FROM psyche_devrage_metric_measures
81
+ ORDER BY date_key ASC, metric_key ASC`)
82
+ .all();
83
+ const metricBuckets = new Map();
84
+ for (const row of rows) {
85
+ const definition = PSYCHE_METRIC_DEFINITIONS[row.metric_key];
86
+ if (!definition) {
87
+ continue;
88
+ }
89
+ const bucket = metricBuckets.get(definition.metric) ?? {
90
+ label: definition.label,
91
+ category: definition.category,
92
+ unit: definition.unit,
93
+ aggregation: definition.aggregation,
94
+ days: new Map()
95
+ };
96
+ const dayRows = bucket.days.get(row.date_key) ?? [];
97
+ dayRows.push(row);
98
+ bucket.days.set(row.date_key, dayRows);
99
+ metricBuckets.set(definition.metric, bucket);
100
+ }
101
+ const metrics = [...metricBuckets.entries()]
102
+ .map(([metric, bucket]) => {
103
+ const days = [...bucket.days.entries()]
104
+ .sort((left, right) => left[0].localeCompare(right[0]))
105
+ .map(([dateKey, entries]) => {
106
+ const values = entries.map((entry) => Number(entry.value) || 0);
107
+ const value = average(values);
108
+ return {
109
+ dateKey,
110
+ average: round(value, bucket.aggregation === "cumulative" ? 0 : 1),
111
+ minimum: round(Math.min(...values), bucket.aggregation === "cumulative" ? 0 : 1),
112
+ maximum: round(Math.max(...values), bucket.aggregation === "cumulative" ? 0 : 1),
113
+ latest: round(values.at(-1) ?? value, bucket.aggregation === "cumulative" ? 0 : 1),
114
+ total: bucket.aggregation === "cumulative"
115
+ ? round(sumNullable(values), 0)
116
+ : null,
117
+ sampleCount: entries.reduce((sum, entry) => sum + Number(entry.sample_count || 0), 0),
118
+ latestSampleAt: entries
119
+ .map((entry) => entry.computed_at)
120
+ .filter(Boolean)
121
+ .sort()
122
+ .at(-1) ?? null
123
+ };
124
+ });
125
+ const latestDay = [...days].reverse().find((day) => psycheMetricPrimaryValue({
126
+ aggregation: bucket.aggregation,
127
+ latest: day.latest,
128
+ average: day.average,
129
+ total: day.total,
130
+ maximum: day.maximum
131
+ }) !== null) ?? null;
132
+ const recentValues = days
133
+ .map((day) => psycheMetricPrimaryValue({
134
+ aggregation: bucket.aggregation,
135
+ latest: day.latest,
136
+ average: day.average,
137
+ total: day.total,
138
+ maximum: day.maximum
139
+ }))
140
+ .filter((value) => value != null);
141
+ const baselineValues = recentValues.slice(Math.max(0, recentValues.length - 8), recentValues.length - 1);
142
+ const baselineValue = baselineValues.length > 0 ? average(baselineValues) : recentValues.at(-2) ?? null;
143
+ const latestValue = latestDay
144
+ ? psycheMetricPrimaryValue({
145
+ aggregation: bucket.aggregation,
146
+ latest: latestDay.latest,
147
+ average: latestDay.average,
148
+ total: latestDay.total,
149
+ maximum: latestDay.maximum
150
+ })
151
+ : null;
152
+ const digits = bucket.aggregation === "cumulative" ? 0 : 1;
153
+ return {
154
+ metric,
155
+ label: bucket.label,
156
+ category: bucket.category,
157
+ unit: bucket.unit,
158
+ aggregation: bucket.aggregation,
159
+ latestValue: latestValue == null ? null : round(latestValue, digits),
160
+ latestDateKey: latestDay?.dateKey ?? null,
161
+ baselineValue: baselineValue == null ? null : round(baselineValue, digits),
162
+ deltaValue: latestValue != null && baselineValue != null
163
+ ? round(latestValue - baselineValue, digits)
164
+ : null,
165
+ coverageDays: days.filter((day) => day.sampleCount > 0).length,
166
+ days
167
+ };
168
+ })
169
+ .sort((left, right) => {
170
+ if (left.category === right.category) {
171
+ return left.label.localeCompare(right.label);
172
+ }
173
+ return left.category.localeCompare(right.category);
174
+ });
175
+ const latestDateKey = rows.map((row) => row.date_key).sort().at(-1) ?? null;
176
+ const trackedDays = new Set(rows.map((row) => row.date_key)).size;
177
+ const categoryBreakdown = [...new Set(metrics.map((metric) => metric.category))]
178
+ .map((category) => {
179
+ const categoryMetrics = metrics.filter((metric) => metric.category === category);
180
+ return {
181
+ category,
182
+ metricCount: categoryMetrics.length,
183
+ coverageDays: Math.max(...categoryMetrics.map((metric) => metric.coverageDays), 0)
184
+ };
185
+ })
186
+ .sort((left, right) => right.metricCount - left.metricCount);
187
+ const context = getDevrageConversationTotals();
188
+ const dailyAverages = getMetricAverages();
189
+ const weeklyAverages = getMetricAverages(7);
190
+ const state = getDevrageSyncState();
191
+ return psycheMetricsViewDataSchema.parse({
192
+ summary: {
193
+ hasData: metrics.length > 0 && context.conversations > 0,
194
+ trackedDays,
195
+ metricCount: metrics.length,
196
+ latestDateKey,
197
+ latestMetricCount: metrics.filter((metric) => metric.latestDateKey === latestDateKey).length,
198
+ categoryBreakdown
199
+ },
200
+ context: {
201
+ generatedAt: nowIso(),
202
+ conversationsScanned: Number(context.conversations) || 0,
203
+ sourceCount: Number(context.sources) || 0,
204
+ messagesScanned: Number(context.messages) || 0,
205
+ messagesWithSwears: Number(context.messages_with_swears) || 0,
206
+ totalSwears: Number(context.swear_count) || 0,
207
+ dailyAverage: {
208
+ rawSwearCount: dailyAverages.rawSwearCount,
209
+ swearingMessagePercent: dailyAverages.swearingMessagePercent
210
+ },
211
+ weeklyAverage: {
212
+ rawSwearCount: weeklyAverages.rawSwearCount,
213
+ swearingMessagePercent: weeklyAverages.swearingMessagePercent
214
+ },
215
+ sync: {
216
+ fullSyncCompletedAt: state?.full_sync_completed_at ?? null,
217
+ lastDailySyncAt: state?.last_daily_sync_at ?? null,
218
+ lastSyncedDateKey: state?.last_synced_date_key ?? null
219
+ }
220
+ },
221
+ metrics
222
+ });
223
+ }
59
224
  async function syncDevrageMetricHistoryInternal(options) {
60
225
  const dateKey = options.forceFull ? undefined : options.dateKey ?? todayDateKey();
61
226
  const report = await scanConversations({
@@ -201,6 +366,32 @@ function getMetricAverages(days) {
201
366
  swearingMessagePercent: round(Number(percentAverage) || 0, 1)
202
367
  };
203
368
  }
369
+ function getDevrageConversationTotals() {
370
+ return getDatabase()
371
+ .prepare(`SELECT
372
+ COUNT(*) AS conversations,
373
+ COUNT(DISTINCT source) AS sources,
374
+ COALESCE(SUM(messages), 0) AS messages,
375
+ COALESCE(SUM(messages_with_swears), 0) AS messages_with_swears,
376
+ COALESCE(SUM(swear_count), 0) AS swear_count
377
+ FROM psyche_devrage_conversation_measures`)
378
+ .get();
379
+ }
380
+ function psycheMetricPrimaryValue(metric) {
381
+ if (metric.aggregation === "cumulative") {
382
+ return metric.total ?? metric.latest;
383
+ }
384
+ return metric.latest ?? metric.average ?? metric.maximum;
385
+ }
386
+ function average(values) {
387
+ if (values.length === 0) {
388
+ return 0;
389
+ }
390
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
391
+ }
392
+ function sumNullable(values) {
393
+ return values.reduce((sum, value) => sum + value, 0);
394
+ }
204
395
  function stableId(prefix, ...parts) {
205
396
  const digest = createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20);
206
397
  return `${prefix}_${digest}`;
@@ -500,6 +500,9 @@ export function getPsycheOverview(userIds) {
500
500
  const suffix = search.size > 0 ? `?${search.toString()}` : "";
501
501
  return request(`/api/v1/psyche/overview${suffix}`);
502
502
  }
503
+ export function getPsycheMetricsView() {
504
+ return request("/api/v1/psyche/metrics");
505
+ }
503
506
  export function listQuestionnaires(userIds) {
504
507
  const search = new URLSearchParams();
505
508
  appendUserIds(search, coerceUserIds(userIds));
@@ -1688,6 +1691,16 @@ export function getFitnessView(userIds) {
1688
1691
  const suffix = search.size > 0 ? `?${search.toString()}` : "";
1689
1692
  return request(`/api/v1/health/fitness${suffix}`);
1690
1693
  }
1694
+ export function getWorkoutDetail(workoutId, resolution = "adaptive") {
1695
+ const search = new URLSearchParams({ resolution });
1696
+ return request(`/api/v1/health/workouts/${workoutId}/detail?${search.toString()}`);
1697
+ }
1698
+ export function getHealthZoneProfile(userIds) {
1699
+ const search = new URLSearchParams();
1700
+ appendUserIds(search, coerceUserIds(userIds));
1701
+ const suffix = search.size > 0 ? `?${search.toString()}` : "";
1702
+ return request(`/api/v1/health/zone-profile${suffix}`);
1703
+ }
1691
1704
  export function getVitalsView(userIds) {
1692
1705
  const search = new URLSearchParams();
1693
1706
  appendUserIds(search, coerceUserIds(userIds));
@@ -2,7 +2,7 @@
2
2
  "id": "forge-openclaw-plugin",
3
3
  "name": "Forge",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
- "version": "0.2.68",
5
+ "version": "0.2.70",
6
6
  "activation": {
7
7
  "onStartup": true,
8
8
  "onCapabilities": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-openclaw-plugin",
3
- "version": "0.2.68",
3
+ "version": "0.2.70",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -105,6 +105,7 @@
105
105
  "follow-redirects": "^1.16.0",
106
106
  "hono": "^4.12.18",
107
107
  "ip-address": "^10.2.0",
108
+ "ws": "^8.20.1",
108
109
  "uuid": "^14.0.0"
109
110
  },
110
111
  "scripts": {
@@ -0,0 +1,95 @@
1
+ CREATE TABLE IF NOT EXISTS health_workout_time_series (
2
+ id TEXT PRIMARY KEY,
3
+ workout_id TEXT NOT NULL REFERENCES health_workout_sessions(id) ON DELETE CASCADE,
4
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5
+ source_sample_uid TEXT NOT NULL,
6
+ series_index INTEGER NOT NULL DEFAULT 0,
7
+ metric_key TEXT NOT NULL,
8
+ label TEXT NOT NULL DEFAULT '',
9
+ category TEXT NOT NULL DEFAULT '',
10
+ unit TEXT NOT NULL DEFAULT '',
11
+ value REAL NOT NULL,
12
+ started_at TEXT NOT NULL,
13
+ ended_at TEXT NOT NULL,
14
+ source_device TEXT NOT NULL DEFAULT '',
15
+ source_bundle_identifier TEXT,
16
+ source_product_type TEXT,
17
+ capture_method TEXT NOT NULL DEFAULT 'associated_workout',
18
+ quality_flags_json TEXT NOT NULL DEFAULT '[]',
19
+ metadata_json TEXT NOT NULL DEFAULT '{}',
20
+ provenance_json TEXT NOT NULL DEFAULT '{}',
21
+ created_at TEXT NOT NULL,
22
+ updated_at TEXT NOT NULL,
23
+ UNIQUE (workout_id, metric_key, source_sample_uid, series_index)
24
+ );
25
+
26
+ CREATE INDEX IF NOT EXISTS idx_health_workout_time_series_workout_metric
27
+ ON health_workout_time_series(workout_id, metric_key, started_at);
28
+
29
+ CREATE INDEX IF NOT EXISTS idx_health_workout_time_series_user_metric
30
+ ON health_workout_time_series(user_id, metric_key, started_at DESC);
31
+
32
+ CREATE TABLE IF NOT EXISTS health_workout_routes (
33
+ id TEXT PRIMARY KEY,
34
+ workout_id TEXT NOT NULL REFERENCES health_workout_sessions(id) ON DELETE CASCADE,
35
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
36
+ source_route_uid TEXT NOT NULL,
37
+ point_index INTEGER NOT NULL,
38
+ recorded_at TEXT NOT NULL,
39
+ latitude REAL NOT NULL,
40
+ longitude REAL NOT NULL,
41
+ altitude_meters REAL,
42
+ horizontal_accuracy_meters REAL,
43
+ vertical_accuracy_meters REAL,
44
+ speed_mps REAL,
45
+ course_degrees REAL,
46
+ metadata_json TEXT NOT NULL DEFAULT '{}',
47
+ provenance_json TEXT NOT NULL DEFAULT '{}',
48
+ created_at TEXT NOT NULL,
49
+ updated_at TEXT NOT NULL,
50
+ UNIQUE (workout_id, source_route_uid, point_index)
51
+ );
52
+
53
+ CREATE INDEX IF NOT EXISTS idx_health_workout_routes_workout
54
+ ON health_workout_routes(workout_id, point_index);
55
+
56
+ CREATE TABLE IF NOT EXISTS health_zone_profiles (
57
+ id TEXT PRIMARY KEY,
58
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
59
+ model_version TEXT NOT NULL DEFAULT 'forge-hrr-v1',
60
+ birth_year INTEGER,
61
+ sex_at_birth TEXT,
62
+ known_max_hr REAL,
63
+ threshold_hr REAL,
64
+ resting_hr_override REAL,
65
+ custom_zones_json TEXT NOT NULL DEFAULT '[]',
66
+ inferred_max_hr REAL,
67
+ inferred_resting_hr REAL,
68
+ confidence TEXT NOT NULL DEFAULT 'medium',
69
+ thresholds_json TEXT NOT NULL DEFAULT '[]',
70
+ metadata_json TEXT NOT NULL DEFAULT '{}',
71
+ created_at TEXT NOT NULL,
72
+ updated_at TEXT NOT NULL,
73
+ UNIQUE(user_id, model_version)
74
+ );
75
+
76
+ CREATE TABLE IF NOT EXISTS health_workout_analytics (
77
+ id TEXT PRIMARY KEY,
78
+ workout_id TEXT NOT NULL REFERENCES health_workout_sessions(id) ON DELETE CASCADE,
79
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
80
+ zone_profile_id TEXT REFERENCES health_zone_profiles(id) ON DELETE SET NULL,
81
+ model_version TEXT NOT NULL DEFAULT 'forge-hrr-v1',
82
+ confidence TEXT NOT NULL DEFAULT 'unavailable',
83
+ data_quality_json TEXT NOT NULL DEFAULT '{}',
84
+ zone_durations_json TEXT NOT NULL DEFAULT '[]',
85
+ hr_summary_json TEXT NOT NULL DEFAULT '{}',
86
+ load_json TEXT NOT NULL DEFAULT '{}',
87
+ route_summary_json TEXT NOT NULL DEFAULT '{}',
88
+ computed_at TEXT NOT NULL,
89
+ created_at TEXT NOT NULL,
90
+ updated_at TEXT NOT NULL,
91
+ UNIQUE(workout_id, model_version)
92
+ );
93
+
94
+ CREATE INDEX IF NOT EXISTS idx_health_workout_analytics_user
95
+ ON health_workout_analytics(user_id, computed_at DESC);