forge-openclaw-plugin 0.2.67 → 0.2.69

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 (29) 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/060_psyche_devrage_metrics.sql +40 -0
  11. package/dist/server/server/migrations/061_health_workout_raw_evidence.sql +95 -0
  12. package/dist/server/server/src/app.js +94 -12
  13. package/dist/server/server/src/health-workout-analytics.js +572 -0
  14. package/dist/server/server/src/health.js +116 -3
  15. package/dist/server/server/src/openapi.js +238 -0
  16. package/dist/server/server/src/psyche-types.js +90 -0
  17. package/dist/server/server/src/services/devrage.js +412 -0
  18. package/dist/server/server/src/services/psyche.js +3 -0
  19. package/dist/server/src/lib/api.js +13 -0
  20. package/openclaw.plugin.json +1 -1
  21. package/package.json +1 -1
  22. package/server/migrations/060_psyche_devrage_metrics.sql +40 -0
  23. package/server/migrations/061_health_workout_raw_evidence.sql +95 -0
  24. package/skills/forge-openclaw/SKILL.md +25 -1
  25. package/skills/forge-openclaw/entity_conversation_playbooks.md +46 -14
  26. package/dist/assets/index-B9IVt8VN.js +0 -90
  27. package/dist/assets/index-DJlo9Tsp.css +0 -1
  28. package/dist/assets/vendor-BcOHGipZ.js +0 -1341
  29. package/dist/assets/vendor-DT3pnAKJ.css +0 -1
@@ -0,0 +1,412 @@
1
+ import { createHash } from "node:crypto";
2
+ import { scanConversations } from "forge-devrage";
3
+ import { getDatabase, runInTransaction } from "../db.js";
4
+ import { psycheMetricsViewDataSchema } from "../psyche-types.js";
5
+ const SWEAR_COUNT_KEY = "swear_count";
6
+ const SWEARING_MESSAGE_PERCENT_KEY = "swearing_message_percent";
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
+ };
24
+ let syncInFlight = null;
25
+ export async function syncDevrageMetricHistory(options = {}) {
26
+ if (syncInFlight) {
27
+ return syncInFlight;
28
+ }
29
+ syncInFlight = syncDevrageMetricHistoryInternal(options).finally(() => {
30
+ syncInFlight = null;
31
+ });
32
+ return syncInFlight;
33
+ }
34
+ export async function syncDevrageMetricHistoryIfNeeded() {
35
+ const state = getDevrageSyncState();
36
+ if (!state?.full_sync_completed_at) {
37
+ await syncDevrageMetricHistory({ forceFull: true });
38
+ return;
39
+ }
40
+ const today = todayDateKey();
41
+ if (state.last_synced_date_key !== today) {
42
+ await syncDevrageMetricHistory({ dateKey: today });
43
+ }
44
+ }
45
+ export function getDevrageMetricPayload() {
46
+ const generatedAt = nowIso();
47
+ const state = getDevrageSyncState();
48
+ const history = getDevrageDailyHistory(90);
49
+ const latest = history[0] ?? null;
50
+ const dailyAverages = getMetricAverages();
51
+ const weeklyAverages = getMetricAverages(7);
52
+ return {
53
+ generatedAt,
54
+ hasData: history.some((day) => day.conversationsScanned > 0),
55
+ latestDateKey: latest?.dateKey ?? null,
56
+ rawSwearCount: latest?.rawSwearCount ?? 0,
57
+ swearingMessagePercent: latest?.swearingMessagePercent ?? 0,
58
+ conversationsScanned: latest?.conversationsScanned ?? 0,
59
+ messagesScanned: latest?.messagesScanned ?? 0,
60
+ messagesWithSwears: latest?.messagesWithSwears ?? 0,
61
+ dailyAverage: {
62
+ rawSwearCount: dailyAverages.rawSwearCount,
63
+ swearingMessagePercent: dailyAverages.swearingMessagePercent
64
+ },
65
+ weeklyAverage: {
66
+ rawSwearCount: weeklyAverages.rawSwearCount,
67
+ swearingMessagePercent: weeklyAverages.swearingMessagePercent
68
+ },
69
+ history,
70
+ sync: {
71
+ fullSyncCompletedAt: state?.full_sync_completed_at ?? null,
72
+ lastDailySyncAt: state?.last_daily_sync_at ?? null,
73
+ lastSyncedDateKey: state?.last_synced_date_key ?? null
74
+ }
75
+ };
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
+ }
224
+ async function syncDevrageMetricHistoryInternal(options) {
225
+ const dateKey = options.forceFull ? undefined : options.dateKey ?? todayDateKey();
226
+ const report = await scanConversations({
227
+ roles: DEFAULT_ROLE_FILTER,
228
+ date: dateKey,
229
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone
230
+ });
231
+ storeDevrageReport(report, {
232
+ fullSync: Boolean(options.forceFull),
233
+ syncedDateKey: dateKey ?? null
234
+ });
235
+ }
236
+ export function storeDevrageReport(report, options) {
237
+ const scannedAt = nowIso();
238
+ const affectedDateKeys = new Set(report.conversations.map((conversation) => conversation.dateKey));
239
+ runInTransaction(() => {
240
+ const database = getDatabase();
241
+ const deleteDate = database.prepare(`DELETE FROM psyche_devrage_conversation_measures WHERE date_key = ?`);
242
+ const insertConversation = database.prepare(`INSERT INTO psyche_devrage_conversation_measures (
243
+ id, source, conversation_id, date_key, updated_at, messages,
244
+ messages_with_swears, swear_count, scanned_at
245
+ )
246
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
247
+ ON CONFLICT(source, conversation_id, date_key) DO UPDATE SET
248
+ updated_at = excluded.updated_at,
249
+ messages = excluded.messages,
250
+ messages_with_swears = excluded.messages_with_swears,
251
+ swear_count = excluded.swear_count,
252
+ scanned_at = excluded.scanned_at`);
253
+ for (const dateKey of affectedDateKeys) {
254
+ deleteDate.run(dateKey);
255
+ }
256
+ for (const conversation of report.conversations) {
257
+ insertConversation.run(stableId("devrage_conversation", conversation.source, conversation.conversationId, conversation.dateKey), conversation.source, conversation.conversationId, conversation.dateKey, conversation.updatedAt, conversation.messages, conversation.messagesWithSwears, conversation.swears, scannedAt);
258
+ }
259
+ for (const dateKey of affectedDateKeys) {
260
+ recomputeMetricMeasuresForDate(dateKey, scannedAt);
261
+ }
262
+ upsertSyncState({
263
+ fullSyncCompletedAt: options.fullSync ? scannedAt : undefined,
264
+ lastDailySyncAt: options.fullSync ? undefined : scannedAt,
265
+ lastSyncedDateKey: options.syncedDateKey ?? [...affectedDateKeys].sort().at(-1) ?? null,
266
+ updatedAt: scannedAt
267
+ });
268
+ });
269
+ }
270
+ function recomputeMetricMeasuresForDate(dateKey, computedAt) {
271
+ const aggregate = getDatabase()
272
+ .prepare(`SELECT
273
+ COUNT(*) AS conversations,
274
+ COALESCE(SUM(messages), 0) AS messages,
275
+ COALESCE(SUM(messages_with_swears), 0) AS messages_with_swears,
276
+ COALESCE(SUM(swear_count), 0) AS swear_count
277
+ FROM psyche_devrage_conversation_measures
278
+ WHERE date_key = ?`)
279
+ .get(dateKey);
280
+ const messages = Number(aggregate.messages) || 0;
281
+ const messagesWithSwears = Number(aggregate.messages_with_swears) || 0;
282
+ const swearCount = Number(aggregate.swear_count) || 0;
283
+ const percent = messages > 0 ? (messagesWithSwears / messages) * 100 : 0;
284
+ upsertMetricMeasure(dateKey, SWEAR_COUNT_KEY, swearCount, "count", Number(aggregate.conversations) || 0, computedAt);
285
+ upsertMetricMeasure(dateKey, SWEARING_MESSAGE_PERCENT_KEY, percent, "percent", messages, computedAt);
286
+ }
287
+ function upsertMetricMeasure(dateKey, metricKey, value, unit, sampleCount, computedAt) {
288
+ getDatabase()
289
+ .prepare(`INSERT INTO psyche_devrage_metric_measures (
290
+ id, date_key, metric_key, value, unit, sample_count, computed_at
291
+ )
292
+ VALUES (?, ?, ?, ?, ?, ?, ?)
293
+ ON CONFLICT(date_key, metric_key) DO UPDATE SET
294
+ value = excluded.value,
295
+ unit = excluded.unit,
296
+ sample_count = excluded.sample_count,
297
+ computed_at = excluded.computed_at`)
298
+ .run(stableId("devrage_metric", dateKey, metricKey), dateKey, metricKey, value, unit, sampleCount, computedAt);
299
+ }
300
+ function upsertSyncState(input) {
301
+ const current = getDevrageSyncState();
302
+ getDatabase()
303
+ .prepare(`INSERT INTO psyche_devrage_sync_state (
304
+ id, full_sync_completed_at, last_daily_sync_at, last_synced_date_key, updated_at
305
+ )
306
+ VALUES ('default', ?, ?, ?, ?)
307
+ ON CONFLICT(id) DO UPDATE SET
308
+ full_sync_completed_at = excluded.full_sync_completed_at,
309
+ last_daily_sync_at = excluded.last_daily_sync_at,
310
+ last_synced_date_key = excluded.last_synced_date_key,
311
+ updated_at = excluded.updated_at`)
312
+ .run(input.fullSyncCompletedAt ?? current?.full_sync_completed_at ?? null, input.lastDailySyncAt ?? current?.last_daily_sync_at ?? null, input.lastSyncedDateKey ?? current?.last_synced_date_key ?? null, input.updatedAt);
313
+ }
314
+ function getDevrageSyncState() {
315
+ return (getDatabase()
316
+ .prepare(`SELECT full_sync_completed_at, last_daily_sync_at, last_synced_date_key, updated_at
317
+ FROM psyche_devrage_sync_state
318
+ WHERE id = 'default'`)
319
+ .get() ?? null);
320
+ }
321
+ function getDevrageDailyHistory(limit) {
322
+ const rows = getDatabase()
323
+ .prepare(`SELECT
324
+ date_key,
325
+ COUNT(*) AS conversations,
326
+ COALESCE(SUM(messages), 0) AS messages,
327
+ COALESCE(SUM(messages_with_swears), 0) AS messages_with_swears,
328
+ COALESCE(SUM(swear_count), 0) AS swear_count
329
+ FROM psyche_devrage_conversation_measures
330
+ GROUP BY date_key
331
+ ORDER BY date_key DESC
332
+ LIMIT ?`)
333
+ .all(limit);
334
+ return rows.map((row) => {
335
+ const messages = Number(row.messages) || 0;
336
+ const messagesWithSwears = Number(row.messages_with_swears) || 0;
337
+ return {
338
+ dateKey: row.date_key,
339
+ rawSwearCount: Number(row.swear_count) || 0,
340
+ swearingMessagePercent: messages > 0 ? (messagesWithSwears / messages) * 100 : 0,
341
+ conversationsScanned: Number(row.conversations) || 0,
342
+ messagesScanned: messages,
343
+ messagesWithSwears
344
+ };
345
+ });
346
+ }
347
+ function getMetricAverages(days) {
348
+ const where = days
349
+ ? `WHERE date_key IN (
350
+ SELECT date_key FROM psyche_devrage_metric_measures
351
+ GROUP BY date_key
352
+ ORDER BY date_key DESC
353
+ LIMIT ?
354
+ )`
355
+ : "";
356
+ const rows = getDatabase()
357
+ .prepare(`SELECT metric_key, AVG(value) AS value
358
+ FROM psyche_devrage_metric_measures
359
+ ${where}
360
+ GROUP BY metric_key`)
361
+ .all(...(days ? [days] : []));
362
+ const swearAverage = rows.find((row) => row.metric_key === SWEAR_COUNT_KEY)?.value ?? 0;
363
+ const percentAverage = rows.find((row) => row.metric_key === SWEARING_MESSAGE_PERCENT_KEY)?.value ?? 0;
364
+ return {
365
+ rawSwearCount: round(Number(swearAverage) || 0, 1),
366
+ swearingMessagePercent: round(Number(percentAverage) || 0, 1)
367
+ };
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
+ }
395
+ function stableId(prefix, ...parts) {
396
+ const digest = createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 20);
397
+ return `${prefix}_${digest}`;
398
+ }
399
+ function todayDateKey() {
400
+ return new Intl.DateTimeFormat("en-CA", {
401
+ year: "numeric",
402
+ month: "2-digit",
403
+ day: "2-digit"
404
+ }).format(new Date());
405
+ }
406
+ function nowIso() {
407
+ return new Date().toISOString();
408
+ }
409
+ function round(value, digits) {
410
+ const factor = 10 ** digits;
411
+ return Math.round(value * factor) / factor;
412
+ }
@@ -3,6 +3,7 @@ import { listInsights } from "../repositories/collaboration.js";
3
3
  import { listNotes } from "../repositories/notes.js";
4
4
  import { filterOwnedEntities } from "../repositories/entity-ownership.js";
5
5
  import { listBehaviorPatterns, listBehaviors, listBeliefEntries, listModeProfiles, listPsycheValues, listSchemaCatalog, listTriggerReports } from "../repositories/psyche.js";
6
+ import { getDevrageMetricPayload } from "./devrage.js";
6
7
  import { psycheOverviewPayloadSchema } from "../psyche-types.js";
7
8
  const PSYCHE_ENTITY_TYPE_SET = new Set([
8
9
  "psyche_value",
@@ -32,6 +33,7 @@ export function getPsycheOverview(userIds) {
32
33
  ...behaviors.filter((behavior) => behavior.kind === "committed").map((behavior) => behavior.title),
33
34
  ...reports.flatMap((report) => report.nextMoves)
34
35
  ];
36
+ const devrageMetric = getDevrageMetricPayload();
35
37
  const schemaPressure = schemaCatalog
36
38
  .filter((schema) => schema.schemaType === "maladaptive")
37
39
  .map((schema) => {
@@ -57,6 +59,7 @@ export function getPsycheOverview(userIds) {
57
59
  modes,
58
60
  reports,
59
61
  schemaPressure,
62
+ devrageMetric,
60
63
  openInsights,
61
64
  openNotes,
62
65
  committedActions
@@ -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.67",
5
+ "version": "0.2.69",
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.67",
3
+ "version": "0.2.69",
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",
@@ -0,0 +1,40 @@
1
+ CREATE TABLE IF NOT EXISTS psyche_devrage_conversation_measures (
2
+ id TEXT PRIMARY KEY,
3
+ source TEXT NOT NULL,
4
+ conversation_id TEXT NOT NULL,
5
+ date_key TEXT NOT NULL,
6
+ updated_at TEXT NOT NULL,
7
+ messages INTEGER NOT NULL DEFAULT 0,
8
+ messages_with_swears INTEGER NOT NULL DEFAULT 0,
9
+ swear_count INTEGER NOT NULL DEFAULT 0,
10
+ scanned_at TEXT NOT NULL,
11
+ UNIQUE (source, conversation_id, date_key)
12
+ );
13
+
14
+ CREATE INDEX IF NOT EXISTS psyche_devrage_conversation_measures_date_idx
15
+ ON psyche_devrage_conversation_measures (date_key DESC);
16
+
17
+ CREATE INDEX IF NOT EXISTS psyche_devrage_conversation_measures_updated_idx
18
+ ON psyche_devrage_conversation_measures (updated_at DESC);
19
+
20
+ CREATE TABLE IF NOT EXISTS psyche_devrage_metric_measures (
21
+ id TEXT PRIMARY KEY,
22
+ date_key TEXT NOT NULL,
23
+ metric_key TEXT NOT NULL,
24
+ value REAL NOT NULL,
25
+ unit TEXT NOT NULL DEFAULT '',
26
+ sample_count INTEGER NOT NULL DEFAULT 0,
27
+ computed_at TEXT NOT NULL,
28
+ UNIQUE (date_key, metric_key)
29
+ );
30
+
31
+ CREATE INDEX IF NOT EXISTS psyche_devrage_metric_measures_metric_date_idx
32
+ ON psyche_devrage_metric_measures (metric_key, date_key DESC);
33
+
34
+ CREATE TABLE IF NOT EXISTS psyche_devrage_sync_state (
35
+ id TEXT PRIMARY KEY CHECK (id = 'default'),
36
+ full_sync_completed_at TEXT,
37
+ last_daily_sync_at TEXT,
38
+ last_synced_date_key TEXT,
39
+ updated_at TEXT NOT NULL
40
+ );
@@ -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);