forge-openclaw-plugin 0.2.68 → 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.
- package/dist/assets/{board-DFNV9VAZ.js → board-BfqxFNiQ.js} +1 -1
- package/dist/assets/index-BfLQnCNZ.js +91 -0
- package/dist/assets/index-DIapFz9v.css +1 -0
- package/dist/assets/{motion-CXdn34ih.js → motion-C0ALlgho.js} +1 -1
- package/dist/assets/{table-CEq3bTDv.js → table-WcMjnJll.js} +1 -1
- package/dist/assets/{ui-g7FaEglG.js → ui-B5I-3U91.js} +1 -1
- package/dist/assets/vendor-B-Lq_OG3.css +1 -0
- package/dist/assets/vendor-C56o26_3.js +2163 -0
- package/dist/index.html +8 -8
- package/dist/server/server/migrations/061_health_workout_raw_evidence.sql +95 -0
- package/dist/server/server/src/app.js +62 -8
- package/dist/server/server/src/health-workout-analytics.js +572 -0
- package/dist/server/server/src/health.js +116 -3
- package/dist/server/server/src/openapi.js +162 -0
- package/dist/server/server/src/psyche-types.js +59 -0
- package/dist/server/server/src/services/devrage.js +191 -0
- package/dist/server/src/lib/api.js +13 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/server/migrations/061_health_workout_raw_evidence.sql +95 -0
- package/skills/forge-openclaw/SKILL.md +25 -1
- package/skills/forge-openclaw/entity_conversation_playbooks.md +46 -14
- package/dist/assets/index-B0PIKEnz.css +0 -1
- package/dist/assets/index-BofyMuFh.js +0 -90
- package/dist/assets/vendor-BcOHGipZ.js +0 -1341
- package/dist/assets/vendor-DT3pnAKJ.css +0 -1
|
@@ -3,6 +3,7 @@ import { z } from "zod";
|
|
|
3
3
|
import { getDatabase, runInTransaction } from "./db.js";
|
|
4
4
|
import { HttpError } from "./errors.js";
|
|
5
5
|
import { buildWorkoutSessionPersistenceSeed, buildWorkoutSessionPresentation, workoutActivityDescriptorSchema, workoutDetailsSchema } from "./health-workout-adapters.js";
|
|
6
|
+
import { getHealthZoneProfile, getStoredWorkoutAnalytics, getWorkoutRawEvidence, healthZoneProfilePatchSchema, recomputeAndStoreWorkoutAnalytics, upsertWorkoutRoutePoints, upsertWorkoutTimeSeries, workoutCaptureQualitySchema, workoutRoutePointSchema, workoutTimeSeriesSampleSchema, patchHealthZoneProfile } from "./health-workout-analytics.js";
|
|
6
7
|
import { getMovementMobileBootstrap, ingestMovementSync, movementSyncPayloadSchema } from "./movement.js";
|
|
7
8
|
import { ingestScreenTimeSync, screenTimeSyncPayloadSchema } from "./screen-time.js";
|
|
8
9
|
import { recordActivityEvent } from "./repositories/activity-events.js";
|
|
@@ -245,6 +246,10 @@ export const mobileHealthSyncSchema = z.object({
|
|
|
245
246
|
sourceProductType: z.string().trim().optional(),
|
|
246
247
|
activity: workoutActivityDescriptorSchema.optional(),
|
|
247
248
|
details: workoutDetailsSchema.optional(),
|
|
249
|
+
timeSeriesSamples: z.array(workoutTimeSeriesSampleSchema).default([]),
|
|
250
|
+
routePoints: z.array(workoutRoutePointSchema).default([]),
|
|
251
|
+
captureQuality: workoutCaptureQualitySchema.optional(),
|
|
252
|
+
syncCursor: z.record(z.string(), z.unknown()).default({}),
|
|
248
253
|
links: z.array(healthLinkSchema).default([]),
|
|
249
254
|
annotations: workoutAnnotationSchema.partial().default({})
|
|
250
255
|
}))
|
|
@@ -788,6 +793,7 @@ function mapSleepRawLog(row) {
|
|
|
788
793
|
function mapWorkoutSession(row) {
|
|
789
794
|
const provenance = safeJsonParse(row.provenance_json, {});
|
|
790
795
|
const derived = safeJsonParse(row.derived_json, {});
|
|
796
|
+
const analytics = getStoredWorkoutAnalytics(row);
|
|
791
797
|
const presentation = buildWorkoutSessionPresentation({
|
|
792
798
|
source: row.source,
|
|
793
799
|
sourceType: row.source_type,
|
|
@@ -833,6 +839,7 @@ function mapWorkoutSession(row) {
|
|
|
833
839
|
annotations: safeJsonParse(row.annotations_json, {}),
|
|
834
840
|
provenance,
|
|
835
841
|
derived,
|
|
842
|
+
analytics,
|
|
836
843
|
generatedFromHabitId: row.generated_from_habit_id,
|
|
837
844
|
generatedFromCheckInId: row.generated_from_check_in_id,
|
|
838
845
|
reconciliationStatus: row.reconciliation_status,
|
|
@@ -1352,6 +1359,28 @@ export function getWorkoutSessionById(workoutId) {
|
|
|
1352
1359
|
.get(workoutId);
|
|
1353
1360
|
return row ? mapWorkoutSession(row) : undefined;
|
|
1354
1361
|
}
|
|
1362
|
+
export function getWorkoutSessionDetailById(workoutId, resolution = "adaptive") {
|
|
1363
|
+
const row = getDatabase()
|
|
1364
|
+
.prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
|
|
1365
|
+
.get(workoutId);
|
|
1366
|
+
if (!row) {
|
|
1367
|
+
return undefined;
|
|
1368
|
+
}
|
|
1369
|
+
const workout = mapWorkoutSession(row);
|
|
1370
|
+
return {
|
|
1371
|
+
workout,
|
|
1372
|
+
analytics: getStoredWorkoutAnalytics(row),
|
|
1373
|
+
evidence: getWorkoutRawEvidence(row, resolution),
|
|
1374
|
+
zoneProfile: getHealthZoneProfile(row.user_id)
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
export { healthZoneProfilePatchSchema };
|
|
1378
|
+
export function getHealthZoneProfileForUser(userId) {
|
|
1379
|
+
return getHealthZoneProfile(userId);
|
|
1380
|
+
}
|
|
1381
|
+
export function patchHealthZoneProfileForUser(userId, patch) {
|
|
1382
|
+
return patchHealthZoneProfile(userId, patch);
|
|
1383
|
+
}
|
|
1355
1384
|
function listPairingRows(userIds) {
|
|
1356
1385
|
const params = [];
|
|
1357
1386
|
const where = userIds && userIds.length > 0
|
|
@@ -2072,6 +2101,7 @@ function insertOrUpdateWorkoutSession(pairing, input) {
|
|
|
2072
2101
|
? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
|
|
2073
2102
|
: null
|
|
2074
2103
|
}), matchedGenerated.generated_from_habit_id ? "merged" : "standalone", now, matchedGenerated.id);
|
|
2104
|
+
persistWorkoutEvidenceForInput(matchedGenerated.id, pairing.user_id, input);
|
|
2075
2105
|
return {
|
|
2076
2106
|
mode: matchedGenerated.generated_from_habit_id || matchedGenerated.source !== "apple_health"
|
|
2077
2107
|
? "merged"
|
|
@@ -2105,8 +2135,43 @@ function insertOrUpdateWorkoutSession(pairing, input) {
|
|
|
2105
2135
|
? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
|
|
2106
2136
|
: null
|
|
2107
2137
|
}), now, now);
|
|
2138
|
+
persistWorkoutEvidenceForInput(id, pairing.user_id, input);
|
|
2108
2139
|
return { mode: "created", id };
|
|
2109
2140
|
}
|
|
2141
|
+
function persistWorkoutEvidenceForInput(workoutId, userId, input) {
|
|
2142
|
+
if (input.timeSeriesSamples.length > 0) {
|
|
2143
|
+
upsertWorkoutTimeSeries({
|
|
2144
|
+
workoutId,
|
|
2145
|
+
userId,
|
|
2146
|
+
samples: input.timeSeriesSamples
|
|
2147
|
+
});
|
|
2148
|
+
}
|
|
2149
|
+
if (input.routePoints.length > 0) {
|
|
2150
|
+
upsertWorkoutRoutePoints({
|
|
2151
|
+
workoutId,
|
|
2152
|
+
userId,
|
|
2153
|
+
points: input.routePoints
|
|
2154
|
+
});
|
|
2155
|
+
}
|
|
2156
|
+
const row = getDatabase()
|
|
2157
|
+
.prepare(`SELECT * FROM health_workout_sessions WHERE id = ?`)
|
|
2158
|
+
.get(workoutId);
|
|
2159
|
+
if (row) {
|
|
2160
|
+
recomputeAndStoreWorkoutAnalytics(row);
|
|
2161
|
+
if (input.captureQuality) {
|
|
2162
|
+
const derived = safeJsonParse(row.derived_json, {});
|
|
2163
|
+
getDatabase()
|
|
2164
|
+
.prepare(`UPDATE health_workout_sessions
|
|
2165
|
+
SET derived_json = ?, updated_at = ?
|
|
2166
|
+
WHERE id = ?`)
|
|
2167
|
+
.run(JSON.stringify({
|
|
2168
|
+
...derived,
|
|
2169
|
+
captureQuality: input.captureQuality,
|
|
2170
|
+
syncCursor: input.syncCursor
|
|
2171
|
+
}), nowIso(), workoutId);
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
}
|
|
2110
2175
|
function clusterSleepRowsByGap(rows) {
|
|
2111
2176
|
const clusters = [];
|
|
2112
2177
|
let currentCluster = [];
|
|
@@ -2920,6 +2985,42 @@ export function getFitnessViewData(userIds) {
|
|
|
2920
2985
|
workoutTypeBreakdown.set(session.workoutType, current);
|
|
2921
2986
|
}
|
|
2922
2987
|
const orderedWorkoutTypes = [...workoutTypeBreakdown.entries()].sort((left, right) => right[1].totalMinutes - left[1].totalMinutes);
|
|
2988
|
+
const zoneTotals = new Map();
|
|
2989
|
+
let totalZoneSeconds = 0;
|
|
2990
|
+
let heartRateCoverageSum = 0;
|
|
2991
|
+
let heartRateCoverageCount = 0;
|
|
2992
|
+
let totalTrainingLoad = 0;
|
|
2993
|
+
let routeWorkoutCount = 0;
|
|
2994
|
+
for (const session of recent) {
|
|
2995
|
+
const analytics = session.analytics;
|
|
2996
|
+
for (const zone of analytics?.zoneDurations ?? []) {
|
|
2997
|
+
const current = zoneTotals.get(zone.key) ?? {
|
|
2998
|
+
label: zone.label,
|
|
2999
|
+
seconds: 0
|
|
3000
|
+
};
|
|
3001
|
+
current.seconds += zone.seconds;
|
|
3002
|
+
totalZoneSeconds += zone.seconds;
|
|
3003
|
+
zoneTotals.set(zone.key, current);
|
|
3004
|
+
}
|
|
3005
|
+
if (typeof analytics?.dataQuality?.sampleCoverage === "number") {
|
|
3006
|
+
heartRateCoverageSum += analytics.dataQuality.sampleCoverage;
|
|
3007
|
+
heartRateCoverageCount += 1;
|
|
3008
|
+
}
|
|
3009
|
+
if (typeof analytics?.load?.trimp === "number") {
|
|
3010
|
+
totalTrainingLoad += analytics.load.trimp;
|
|
3011
|
+
}
|
|
3012
|
+
if (analytics?.routeSummary?.hasRoute) {
|
|
3013
|
+
routeWorkoutCount += 1;
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
const zoneMix = [...zoneTotals.entries()].map(([key, value]) => ({
|
|
3017
|
+
key,
|
|
3018
|
+
label: value.label,
|
|
3019
|
+
seconds: Math.round(value.seconds),
|
|
3020
|
+
percentage: totalZoneSeconds > 0
|
|
3021
|
+
? Number((value.seconds / totalZoneSeconds).toFixed(4))
|
|
3022
|
+
: 0
|
|
3023
|
+
}));
|
|
2923
3024
|
return {
|
|
2924
3025
|
summary: {
|
|
2925
3026
|
workoutCount: weekly.length,
|
|
@@ -2940,7 +3041,13 @@ export function getFitnessViewData(userIds) {
|
|
|
2940
3041
|
topWorkoutType: orderedWorkoutTypes[0]?.[0] ?? null,
|
|
2941
3042
|
topWorkoutTypeLabel: recent.find((session) => session.workoutType === orderedWorkoutTypes[0]?.[0])
|
|
2942
3043
|
?.workoutTypeLabel ?? null,
|
|
2943
|
-
streakDays: Array.from(new Set(weekly.map((session) => dayKey(session.startedAt)))).length
|
|
3044
|
+
streakDays: Array.from(new Set(weekly.map((session) => dayKey(session.startedAt)))).length,
|
|
3045
|
+
averageHeartRateCoverage: heartRateCoverageCount > 0
|
|
3046
|
+
? Number((heartRateCoverageSum / heartRateCoverageCount).toFixed(3))
|
|
3047
|
+
: 0,
|
|
3048
|
+
totalTrainingLoad: Number(totalTrainingLoad.toFixed(1)),
|
|
3049
|
+
routeWorkoutCount,
|
|
3050
|
+
zoneMix
|
|
2944
3051
|
},
|
|
2945
3052
|
weeklyTrend: weekly
|
|
2946
3053
|
.map((session) => ({
|
|
@@ -2951,7 +3058,12 @@ export function getFitnessViewData(userIds) {
|
|
|
2951
3058
|
activityFamily: session.activityFamily,
|
|
2952
3059
|
activityFamilyLabel: session.activityFamilyLabel,
|
|
2953
3060
|
durationMinutes: Math.round(session.durationSeconds / 60),
|
|
2954
|
-
energyKcal: Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0)
|
|
3061
|
+
energyKcal: Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0),
|
|
3062
|
+
zoneDurations: session.analytics
|
|
3063
|
+
?.zoneDurations ?? [],
|
|
3064
|
+
trainingLoad: (session.analytics
|
|
3065
|
+
?.load?.trimp ?? null),
|
|
3066
|
+
heartRateCoverage: (session.analytics?.dataQuality?.sampleCoverage ?? 0)
|
|
2955
3067
|
}))
|
|
2956
3068
|
.reverse(),
|
|
2957
3069
|
typeBreakdown: orderedWorkoutTypes.map(([workoutType, metrics]) => ({
|
|
@@ -3482,6 +3594,7 @@ export function deleteWorkoutSession(workoutId, activity) {
|
|
|
3482
3594
|
if (!current) {
|
|
3483
3595
|
return undefined;
|
|
3484
3596
|
}
|
|
3597
|
+
const deletedWorkout = mapWorkoutSession(current);
|
|
3485
3598
|
getDatabase()
|
|
3486
3599
|
.prepare(`DELETE FROM health_workout_sessions WHERE id = ?`)
|
|
3487
3600
|
.run(workoutId);
|
|
@@ -3499,7 +3612,7 @@ export function deleteWorkoutSession(workoutId, activity) {
|
|
|
3499
3612
|
startedAt: current.started_at
|
|
3500
3613
|
}
|
|
3501
3614
|
});
|
|
3502
|
-
return
|
|
3615
|
+
return deletedWorkout;
|
|
3503
3616
|
}
|
|
3504
3617
|
export function updateWorkoutMetadata(workoutId, patch, activity) {
|
|
3505
3618
|
const parsed = updateWorkoutMetadataSchema.parse(patch);
|
|
@@ -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}`;
|