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.
@@ -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 mapWorkoutSession(current);
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}`;