forge-openclaw-plugin 0.2.47 → 0.2.49

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 (32) hide show
  1. package/README.md +6 -5
  2. package/dist/assets/index-2_tuemtU.css +1 -0
  3. package/dist/assets/index-BAmEvOXb.js +91 -0
  4. package/dist/assets/index-BAmEvOXb.js.map +1 -0
  5. package/dist/index.html +2 -2
  6. package/dist/openclaw/api-client.js +15 -1
  7. package/dist/openclaw/session-registry.js +17 -0
  8. package/dist/openclaw/tools.js +1 -1
  9. package/dist/server/server/migrations/052_agent_identity_tightening.sql +307 -0
  10. package/dist/server/server/migrations/053_agent_runtime_session_canonical_labels.sql +9 -0
  11. package/dist/server/server/src/app.js +42 -12
  12. package/dist/server/server/src/health-workout-adapters.js +465 -0
  13. package/dist/server/server/src/health.js +134 -9
  14. package/dist/server/server/src/openapi.js +33 -0
  15. package/dist/server/server/src/repositories/agent-runtime-sessions.js +122 -16
  16. package/dist/server/server/src/repositories/habits.js +62 -25
  17. package/dist/server/server/src/repositories/model-settings.js +5 -0
  18. package/dist/server/server/src/repositories/settings.js +101 -13
  19. package/dist/server/server/src/repositories/users.js +23 -0
  20. package/dist/server/server/src/types.js +22 -6
  21. package/dist/server/server/src/watch-mobile.js +33 -21
  22. package/dist/server/src/lib/date-keys.js +21 -0
  23. package/openclaw.plugin.json +1 -1
  24. package/package.json +5 -2
  25. package/server/migrations/052_agent_identity_tightening.sql +307 -0
  26. package/server/migrations/053_agent_runtime_session_canonical_labels.sql +9 -0
  27. package/skills/forge-openclaw/SKILL.md +3 -1
  28. package/skills/forge-openclaw/entity_conversation_playbooks.md +45 -8
  29. package/skills/forge-openclaw/psyche_entity_playbooks.md +14 -0
  30. package/dist/assets/index-BejDHw1R.js +0 -91
  31. package/dist/assets/index-BejDHw1R.js.map +0 -1
  32. package/dist/assets/index-DtEvFzXp.css +0 -1
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { z } from "zod";
3
3
  import { getDatabase, runInTransaction } from "./db.js";
4
4
  import { HttpError } from "./errors.js";
5
+ import { buildWorkoutSessionPersistenceSeed, buildWorkoutSessionPresentation, workoutActivityDescriptorSchema, workoutDetailsSchema } from "./health-workout-adapters.js";
5
6
  import { getMovementMobileBootstrap, ingestMovementSync, movementSyncPayloadSchema } from "./movement.js";
6
7
  import { ingestScreenTimeSync, screenTimeSyncPayloadSchema } from "./screen-time.js";
7
8
  import { recordActivityEvent } from "./repositories/activity-events.js";
@@ -235,6 +236,11 @@ export const mobileHealthSyncSchema = z.object({
235
236
  averageHeartRate: z.number().nonnegative().nullable().optional(),
236
237
  maxHeartRate: z.number().nonnegative().nullable().optional(),
237
238
  sourceDevice: z.string().trim().default("Apple Health"),
239
+ sourceSystem: z.string().trim().min(1).default("apple_health"),
240
+ sourceBundleIdentifier: z.string().trim().optional(),
241
+ sourceProductType: z.string().trim().optional(),
242
+ activity: workoutActivityDescriptorSchema.optional(),
243
+ details: workoutDetailsSchema.optional(),
238
244
  links: z.array(healthLinkSchema).default([]),
239
245
  annotations: workoutAnnotationSchema.partial().default({})
240
246
  }))
@@ -766,6 +772,15 @@ function mapSleepRawLog(row) {
766
772
  };
767
773
  }
768
774
  function mapWorkoutSession(row) {
775
+ const provenance = safeJsonParse(row.provenance_json, {});
776
+ const derived = safeJsonParse(row.derived_json, {});
777
+ const presentation = buildWorkoutSessionPresentation({
778
+ source: row.source,
779
+ sourceType: row.source_type,
780
+ workoutType: row.workout_type,
781
+ provenance,
782
+ derived
783
+ });
769
784
  return {
770
785
  id: row.id,
771
786
  externalUid: row.external_uid,
@@ -773,7 +788,15 @@ function mapWorkoutSession(row) {
773
788
  userId: row.user_id,
774
789
  source: row.source,
775
790
  sourceType: row.source_type,
776
- workoutType: row.workout_type,
791
+ sourceSystem: presentation.sourceSystem,
792
+ sourceBundleIdentifier: presentation.sourceBundleIdentifier,
793
+ sourceProductType: presentation.sourceProductType,
794
+ workoutType: presentation.workoutType,
795
+ workoutTypeLabel: presentation.workoutTypeLabel,
796
+ activityFamily: presentation.activityFamily,
797
+ activityFamilyLabel: presentation.activityFamilyLabel,
798
+ activity: presentation.activity,
799
+ details: presentation.details,
777
800
  sourceDevice: row.source_device,
778
801
  startedAt: row.started_at,
779
802
  endedAt: row.ended_at,
@@ -794,8 +817,8 @@ function mapWorkoutSession(row) {
794
817
  links: safeJsonParse(row.links_json, []),
795
818
  tags: safeJsonParse(row.tags_json, []),
796
819
  annotations: safeJsonParse(row.annotations_json, {}),
797
- provenance: safeJsonParse(row.provenance_json, {}),
798
- derived: safeJsonParse(row.derived_json, {}),
820
+ provenance,
821
+ derived,
799
822
  generatedFromHabitId: row.generated_from_habit_id,
800
823
  generatedFromCheckInId: row.generated_from_check_in_id,
801
824
  reconciliationStatus: row.reconciliation_status,
@@ -1904,10 +1927,20 @@ function insertOrUpdateWorkoutSession(pairing, input) {
1904
1927
  .get(pairing.user_id, input.externalUid);
1905
1928
  const annotations = workoutAnnotationSchema.parse(input.annotations ?? {});
1906
1929
  const now = nowIso();
1930
+ const basePersistenceSeed = buildWorkoutSessionPersistenceSeed({
1931
+ source: "apple_health",
1932
+ sourceType: "healthkit",
1933
+ workoutType: input.workoutType,
1934
+ sourceSystem: input.sourceSystem,
1935
+ sourceBundleIdentifier: input.sourceBundleIdentifier,
1936
+ sourceProductType: input.sourceProductType,
1937
+ activity: input.activity,
1938
+ details: input.details
1939
+ });
1907
1940
  const matchedGenerated = existing ??
1908
1941
  findMatchingGeneratedWorkout({
1909
1942
  userId: pairing.user_id,
1910
- workoutType: input.workoutType,
1943
+ workoutType: basePersistenceSeed.activity.canonicalKey,
1911
1944
  startedAt: input.startedAt,
1912
1945
  endedAt: input.endedAt
1913
1946
  });
@@ -1915,6 +1948,26 @@ function insertOrUpdateWorkoutSession(pairing, input) {
1915
1948
  const existingLinks = safeJsonParse(matchedGenerated.links_json, []);
1916
1949
  const existingTags = safeJsonParse(matchedGenerated.tags_json, []);
1917
1950
  const existingAnnotations = safeJsonParse(matchedGenerated.annotations_json, {});
1951
+ const existingProvenance = safeJsonParse(matchedGenerated.provenance_json, {});
1952
+ const existingDerived = safeJsonParse(matchedGenerated.derived_json, {});
1953
+ const persistenceSeed = buildWorkoutSessionPersistenceSeed({
1954
+ source: "apple_health",
1955
+ sourceType: matchedGenerated.generated_from_habit_id
1956
+ ? "reconciled"
1957
+ : "healthkit",
1958
+ workoutType: input.workoutType,
1959
+ sourceSystem: input.sourceSystem,
1960
+ sourceBundleIdentifier: input.sourceBundleIdentifier ??
1961
+ (typeof existingProvenance.sourceBundleIdentifier === "string"
1962
+ ? existingProvenance.sourceBundleIdentifier
1963
+ : null),
1964
+ sourceProductType: input.sourceProductType ??
1965
+ (typeof existingProvenance.sourceProductType === "string"
1966
+ ? existingProvenance.sourceProductType
1967
+ : null),
1968
+ activity: input.activity ?? existingDerived.activity ?? existingProvenance.activity,
1969
+ details: input.details ?? existingDerived.details ?? existingProvenance.details
1970
+ });
1918
1971
  const mergedLinks = mergeHealthLinks(existingLinks, input.links, annotations.links);
1919
1972
  const mergedTags = mergeStringLists(existingTags, annotations.tags);
1920
1973
  const nextSubjectiveEffort = matchedGenerated.subjective_effort ?? annotations.subjectiveEffort ?? null;
@@ -1945,12 +1998,21 @@ function insertOrUpdateWorkoutSession(pairing, input) {
1945
1998
  reconciliation_status = ?, updated_at = ?
1946
1999
  WHERE id = ?`)
1947
2000
  .run(input.externalUid, pairing.id, matchedGenerated.generated_from_habit_id ? "reconciled" : "healthkit", input.sourceDevice, input.startedAt, input.endedAt, Math.max(0, Math.round((Date.parse(input.endedAt) - Date.parse(input.startedAt)) / 1000)), input.activeEnergyKcal ?? null, input.totalEnergyKcal ?? null, input.distanceMeters ?? null, input.stepCount ?? null, input.exerciseMinutes ?? null, input.averageHeartRate ?? null, input.maxHeartRate ?? null, nextSubjectiveEffort, nextMoodBefore, nextMoodAfter, nextMeaningText, nextPlannedContext, nextSocialContext, JSON.stringify(mergedLinks), JSON.stringify(mergedTags), JSON.stringify(nextAnnotations), JSON.stringify({
2001
+ ...existingProvenance,
1948
2002
  importedVia: "ios_companion",
1949
2003
  pairingSessionId: pairing.id,
2004
+ sourceSystem: persistenceSeed.sourceSystem,
2005
+ sourceBundleIdentifier: persistenceSeed.sourceBundleIdentifier,
2006
+ sourceProductType: persistenceSeed.sourceProductType,
2007
+ activity: persistenceSeed.activity,
2008
+ details: persistenceSeed.details,
1950
2009
  mergedWithGenerated: matchedGenerated.generated_from_habit_id !== null,
1951
2010
  priorSource: matchedGenerated.source,
1952
2011
  updatedAt: now
1953
2012
  }), JSON.stringify({
2013
+ ...existingDerived,
2014
+ activity: persistenceSeed.activity,
2015
+ details: persistenceSeed.details,
1954
2016
  paceMetersPerMinute: input.distanceMeters && input.exerciseMinutes
1955
2017
  ? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
1956
2018
  : null
@@ -1972,11 +2034,18 @@ function insertOrUpdateWorkoutSession(pairing, input) {
1972
2034
  provenance_json, derived_json, reconciliation_status, created_at, updated_at
1973
2035
  )
1974
2036
  VALUES (?, ?, ?, ?, 'apple_health', 'healthkit', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'standalone', ?, ?)`)
1975
- .run(id, input.externalUid, pairing.id, pairing.user_id, input.workoutType, input.sourceDevice, input.startedAt, input.endedAt, Math.max(0, Math.round((Date.parse(input.endedAt) - Date.parse(input.startedAt)) / 1000)), input.activeEnergyKcal ?? null, input.totalEnergyKcal ?? null, input.distanceMeters ?? null, input.stepCount ?? null, input.exerciseMinutes ?? null, input.averageHeartRate ?? null, input.maxHeartRate ?? null, annotations.subjectiveEffort, annotations.moodBefore, annotations.moodAfter, annotations.meaningText, annotations.plannedContext, annotations.socialContext, JSON.stringify(input.links), JSON.stringify(annotations.tags), JSON.stringify(annotations), JSON.stringify({
2037
+ .run(id, input.externalUid, pairing.id, pairing.user_id, basePersistenceSeed.activity.canonicalKey, input.sourceDevice, input.startedAt, input.endedAt, Math.max(0, Math.round((Date.parse(input.endedAt) - Date.parse(input.startedAt)) / 1000)), input.activeEnergyKcal ?? null, input.totalEnergyKcal ?? null, input.distanceMeters ?? null, input.stepCount ?? null, input.exerciseMinutes ?? null, input.averageHeartRate ?? null, input.maxHeartRate ?? null, annotations.subjectiveEffort, annotations.moodBefore, annotations.moodAfter, annotations.meaningText, annotations.plannedContext, annotations.socialContext, JSON.stringify(input.links), JSON.stringify(annotations.tags), JSON.stringify(annotations), JSON.stringify({
1976
2038
  importedVia: "ios_companion",
1977
2039
  pairingSessionId: pairing.id,
2040
+ sourceSystem: basePersistenceSeed.sourceSystem,
2041
+ sourceBundleIdentifier: basePersistenceSeed.sourceBundleIdentifier,
2042
+ sourceProductType: basePersistenceSeed.sourceProductType,
2043
+ activity: basePersistenceSeed.activity,
2044
+ details: basePersistenceSeed.details,
1978
2045
  createdAt: now
1979
2046
  }), JSON.stringify({
2047
+ activity: basePersistenceSeed.activity,
2048
+ details: basePersistenceSeed.details,
1980
2049
  paceMetersPerMinute: input.distanceMeters && input.exerciseMinutes
1981
2050
  ? Number((input.distanceMeters / input.exerciseMinutes).toFixed(2))
1982
2051
  : null
@@ -2814,6 +2883,8 @@ export function getFitnessViewData(userIds) {
2814
2883
  habitGeneratedSessionCount: recent.filter((session) => session.sourceType === "habit_generated").length,
2815
2884
  reconciledSessionCount: recent.filter((session) => session.reconciliationStatus === "merged").length,
2816
2885
  topWorkoutType: orderedWorkoutTypes[0]?.[0] ?? null,
2886
+ topWorkoutTypeLabel: recent.find((session) => session.workoutType === orderedWorkoutTypes[0]?.[0])
2887
+ ?.workoutTypeLabel ?? null,
2817
2888
  streakDays: Array.from(new Set(weekly.map((session) => dayKey(session.startedAt)))).length
2818
2889
  },
2819
2890
  weeklyTrend: weekly
@@ -2821,12 +2892,21 @@ export function getFitnessViewData(userIds) {
2821
2892
  id: session.id,
2822
2893
  dateKey: dayKey(session.startedAt),
2823
2894
  workoutType: session.workoutType,
2895
+ workoutTypeLabel: session.workoutTypeLabel,
2896
+ activityFamily: session.activityFamily,
2897
+ activityFamilyLabel: session.activityFamilyLabel,
2824
2898
  durationMinutes: Math.round(session.durationSeconds / 60),
2825
2899
  energyKcal: Math.round(session.totalEnergyKcal ?? session.activeEnergyKcal ?? 0)
2826
2900
  }))
2827
2901
  .reverse(),
2828
2902
  typeBreakdown: orderedWorkoutTypes.map(([workoutType, metrics]) => ({
2829
2903
  workoutType,
2904
+ workoutTypeLabel: recent.find((session) => session.workoutType === workoutType)?.workoutTypeLabel ??
2905
+ workoutType,
2906
+ activityFamily: recent.find((session) => session.workoutType === workoutType)?.activityFamily ??
2907
+ "other",
2908
+ activityFamilyLabel: recent.find((session) => session.workoutType === workoutType)
2909
+ ?.activityFamilyLabel ?? "Other",
2830
2910
  sessionCount: metrics.sessionCount,
2831
2911
  totalMinutes: metrics.totalMinutes,
2832
2912
  energyKcal: metrics.energyKcal
@@ -3180,6 +3260,11 @@ export function createWorkoutSession(input, activity) {
3180
3260
  tags: parsed.tags,
3181
3261
  links: parsed.links
3182
3262
  };
3263
+ const persistenceSeed = buildWorkoutSessionPersistenceSeed({
3264
+ source: parsed.source,
3265
+ sourceType: parsed.sourceType,
3266
+ workoutType: parsed.workoutType
3267
+ });
3183
3268
  getDatabase()
3184
3269
  .prepare(`INSERT INTO health_workout_sessions (
3185
3270
  id, external_uid, pairing_session_id, user_id, source, source_type, workout_type, source_device,
@@ -3189,16 +3274,21 @@ export function createWorkoutSession(input, activity) {
3189
3274
  provenance_json, derived_json, reconciliation_status, created_at, updated_at
3190
3275
  )
3191
3276
  VALUES (?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'standalone', ?, ?)`)
3192
- .run(id, externalUid, userId, parsed.source, parsed.sourceType, parsed.workoutType, parsed.sourceDevice, parsed.startedAt, parsed.endedAt, durationSeconds, parsed.activeEnergyKcal ?? null, parsed.totalEnergyKcal ?? null, parsed.distanceMeters ?? null, parsed.stepCount ?? null, parsed.exerciseMinutes ?? null, parsed.averageHeartRate ?? null, parsed.maxHeartRate ?? null, parsed.subjectiveEffort ?? null, parsed.moodBefore, parsed.moodAfter, parsed.meaningText, parsed.plannedContext, parsed.socialContext, JSON.stringify(parsed.links), JSON.stringify(parsed.tags), JSON.stringify(annotations), JSON.stringify({
3277
+ .run(id, externalUid, userId, parsed.source, parsed.sourceType, persistenceSeed.activity.canonicalKey, parsed.sourceDevice, parsed.startedAt, parsed.endedAt, durationSeconds, parsed.activeEnergyKcal ?? null, parsed.totalEnergyKcal ?? null, parsed.distanceMeters ?? null, parsed.stepCount ?? null, parsed.exerciseMinutes ?? null, parsed.averageHeartRate ?? null, parsed.maxHeartRate ?? null, parsed.subjectiveEffort ?? null, parsed.moodBefore, parsed.moodAfter, parsed.meaningText, parsed.plannedContext, parsed.socialContext, JSON.stringify(parsed.links), JSON.stringify(parsed.tags), JSON.stringify(annotations), JSON.stringify({
3193
3278
  manualEntry: true,
3194
3279
  entryMode: "local",
3195
3280
  source: parsed.source,
3196
3281
  sourceType: parsed.sourceType,
3282
+ sourceSystem: persistenceSeed.sourceSystem,
3283
+ activity: persistenceSeed.activity,
3284
+ details: persistenceSeed.details,
3197
3285
  sourceDevice: parsed.sourceDevice,
3198
3286
  actor: activity?.actor ?? null,
3199
3287
  createdAt: now,
3200
3288
  ...parsed.provenance
3201
3289
  }), JSON.stringify({
3290
+ activity: persistenceSeed.activity,
3291
+ details: persistenceSeed.details,
3202
3292
  paceMetersPerMinute: parsed.distanceMeters && parsed.exerciseMinutes
3203
3293
  ? Number((parsed.distanceMeters / parsed.exerciseMinutes).toFixed(2))
3204
3294
  : null
@@ -3268,8 +3358,25 @@ export function updateWorkoutSession(workoutId, patch, activity) {
3268
3358
  links
3269
3359
  };
3270
3360
  const currentProvenance = safeJsonParse(current.provenance_json, {});
3361
+ const currentDerived = safeJsonParse(current.derived_json, {});
3271
3362
  const nextExerciseMinutes = parsed.exerciseMinutes ?? current.exercise_minutes;
3272
3363
  const nextDistanceMeters = parsed.distanceMeters ?? current.distance_meters;
3364
+ const persistenceSeed = buildWorkoutSessionPersistenceSeed({
3365
+ source: parsed.source ?? current.source,
3366
+ sourceType: parsed.sourceType ?? current.source_type,
3367
+ workoutType: parsed.workoutType ?? current.workout_type,
3368
+ sourceSystem: typeof currentProvenance.sourceSystem === "string"
3369
+ ? currentProvenance.sourceSystem
3370
+ : null,
3371
+ sourceBundleIdentifier: typeof currentProvenance.sourceBundleIdentifier === "string"
3372
+ ? currentProvenance.sourceBundleIdentifier
3373
+ : null,
3374
+ sourceProductType: typeof currentProvenance.sourceProductType === "string"
3375
+ ? currentProvenance.sourceProductType
3376
+ : null,
3377
+ activity: currentDerived.activity ?? currentProvenance.activity,
3378
+ details: currentDerived.details ?? currentProvenance.details
3379
+ });
3273
3380
  getDatabase()
3274
3381
  .prepare(`UPDATE health_workout_sessions
3275
3382
  SET external_uid = ?, source = ?, source_type = ?, workout_type = ?, source_device = ?,
@@ -3279,12 +3386,20 @@ export function updateWorkoutSession(workoutId, patch, activity) {
3279
3386
  social_context = ?, links_json = ?, tags_json = ?, annotations_json = ?, provenance_json = ?,
3280
3387
  derived_json = ?, updated_at = ?
3281
3388
  WHERE id = ?`)
3282
- .run(parsed.externalUid ?? current.external_uid, parsed.source ?? current.source, parsed.sourceType ?? current.source_type, parsed.workoutType ?? current.workout_type, parsed.sourceDevice ?? current.source_device, startedAt, endedAt, durationSeconds, parsed.activeEnergyKcal ?? current.active_energy_kcal, parsed.totalEnergyKcal ?? current.total_energy_kcal, nextDistanceMeters, parsed.stepCount ?? current.step_count, nextExerciseMinutes, parsed.averageHeartRate ?? current.average_heart_rate, parsed.maxHeartRate ?? current.max_heart_rate, annotations.subjectiveEffort, annotations.moodBefore, annotations.moodAfter, annotations.meaningText, annotations.plannedContext, annotations.socialContext, JSON.stringify(links), JSON.stringify(tags), JSON.stringify(annotations), JSON.stringify({
3389
+ .run(parsed.externalUid ?? current.external_uid, parsed.source ?? current.source, parsed.sourceType ?? current.source_type, persistenceSeed.activity.canonicalKey, parsed.sourceDevice ?? current.source_device, startedAt, endedAt, durationSeconds, parsed.activeEnergyKcal ?? current.active_energy_kcal, parsed.totalEnergyKcal ?? current.total_energy_kcal, nextDistanceMeters, parsed.stepCount ?? current.step_count, nextExerciseMinutes, parsed.averageHeartRate ?? current.average_heart_rate, parsed.maxHeartRate ?? current.max_heart_rate, annotations.subjectiveEffort, annotations.moodBefore, annotations.moodAfter, annotations.meaningText, annotations.plannedContext, annotations.socialContext, JSON.stringify(links), JSON.stringify(tags), JSON.stringify(annotations), JSON.stringify({
3283
3390
  ...currentProvenance,
3284
3391
  ...(parsed.provenance ?? {}),
3392
+ sourceSystem: persistenceSeed.sourceSystem,
3393
+ sourceBundleIdentifier: persistenceSeed.sourceBundleIdentifier,
3394
+ sourceProductType: persistenceSeed.sourceProductType,
3395
+ activity: persistenceSeed.activity,
3396
+ details: persistenceSeed.details,
3285
3397
  updatedAt: now,
3286
3398
  updatedByActor: activity?.actor ?? null
3287
3399
  }), JSON.stringify({
3400
+ ...currentDerived,
3401
+ activity: persistenceSeed.activity,
3402
+ details: persistenceSeed.details,
3288
3403
  paceMetersPerMinute: nextDistanceMeters && nextExerciseMinutes
3289
3404
  ? Number((nextDistanceMeters / nextExerciseMinutes).toFixed(2))
3290
3405
  : null
@@ -3441,6 +3556,11 @@ export function createGeneratedWorkoutFromHabit(args) {
3441
3556
  }
3442
3557
  const now = nowIso();
3443
3558
  const id = `workout_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
3559
+ const persistenceSeed = buildWorkoutSessionPersistenceSeed({
3560
+ source: "forge_habit",
3561
+ sourceType: "habit_generated",
3562
+ workoutType: template.workoutType
3563
+ });
3444
3564
  getDatabase()
3445
3565
  .prepare(`INSERT INTO health_workout_sessions (
3446
3566
  id, external_uid, pairing_session_id, user_id, source, source_type, workout_type, source_device,
@@ -3448,7 +3568,7 @@ export function createGeneratedWorkoutFromHabit(args) {
3448
3568
  derived_json, generated_from_habit_id, generated_from_check_in_id, reconciliation_status, created_at, updated_at
3449
3569
  )
3450
3570
  VALUES (?, ?, NULL, ?, 'forge_habit', 'habit_generated', ?, 'Habit automation', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'awaiting_import_match', ?, ?)`)
3451
- .run(id, `habit_${args.checkInId}`, args.userId, template.workoutType, startedAt, endedAt, template.durationMinutes * 60, JSON.stringify([
3571
+ .run(id, `habit_${args.checkInId}`, args.userId, persistenceSeed.activity.canonicalKey, startedAt, endedAt, template.durationMinutes * 60, JSON.stringify([
3452
3572
  ...(template.links ?? []),
3453
3573
  ...(args.linkedEntities ?? [])
3454
3574
  ]), JSON.stringify(template.tags), JSON.stringify({
@@ -3457,8 +3577,13 @@ export function createGeneratedWorkoutFromHabit(args) {
3457
3577
  }), JSON.stringify({
3458
3578
  generatedFrom: "habit_completion",
3459
3579
  habitId: args.habitId,
3460
- checkInId: args.checkInId
3580
+ checkInId: args.checkInId,
3581
+ sourceSystem: persistenceSeed.sourceSystem,
3582
+ activity: persistenceSeed.activity,
3583
+ details: persistenceSeed.details
3461
3584
  }), JSON.stringify({
3585
+ activity: persistenceSeed.activity,
3586
+ details: persistenceSeed.details,
3462
3587
  xpReward: template.xpReward
3463
3588
  }), args.habitId, args.checkInId, now, now);
3464
3589
  recordActivityEvent({
@@ -2137,6 +2137,11 @@ export function buildOpenApiDocument() {
2137
2137
  "id",
2138
2138
  "label",
2139
2139
  "agentType",
2140
+ "identityKey",
2141
+ "provider",
2142
+ "machineKey",
2143
+ "personaKey",
2144
+ "linkedUsers",
2140
2145
  "trustLevel",
2141
2146
  "autonomyMode",
2142
2147
  "approvalMode",
@@ -2150,6 +2155,20 @@ export function buildOpenApiDocument() {
2150
2155
  id: { type: "string" },
2151
2156
  label: { type: "string" },
2152
2157
  agentType: { type: "string" },
2158
+ identityKey: nullable({ type: "string" }),
2159
+ provider: nullable({ type: "string", enum: ["openclaw", "hermes", "codex"] }),
2160
+ machineKey: nullable({ type: "string" }),
2161
+ personaKey: nullable({ type: "string" }),
2162
+ linkedUsers: arrayOf({
2163
+ type: "object",
2164
+ additionalProperties: false,
2165
+ required: ["userId", "role", "user"],
2166
+ properties: {
2167
+ userId: { type: "string" },
2168
+ role: { type: "string" },
2169
+ user: nullable({ $ref: "#/components/schemas/UserSummary" })
2170
+ }
2171
+ }),
2153
2172
  trustLevel: {
2154
2173
  type: "string",
2155
2174
  enum: ["standard", "trusted", "autonomous"]
@@ -4055,6 +4074,12 @@ export function buildOpenApiDocument() {
4055
4074
  "userId",
4056
4075
  "source",
4057
4076
  "sourceType",
4077
+ "sourceSystem",
4078
+ "workoutTypeLabel",
4079
+ "activityFamily",
4080
+ "activityFamilyLabel",
4081
+ "activity",
4082
+ "details",
4058
4083
  "workoutType",
4059
4084
  "sourceDevice",
4060
4085
  "startedAt",
@@ -4091,7 +4116,15 @@ export function buildOpenApiDocument() {
4091
4116
  userId: { type: "string" },
4092
4117
  source: { type: "string" },
4093
4118
  sourceType: { type: "string" },
4119
+ sourceSystem: { type: "string" },
4120
+ sourceBundleIdentifier: nullable({ type: "string" }),
4121
+ sourceProductType: nullable({ type: "string" }),
4094
4122
  workoutType: { type: "string" },
4123
+ workoutTypeLabel: { type: "string" },
4124
+ activityFamily: { type: "string" },
4125
+ activityFamilyLabel: { type: "string" },
4126
+ activity: { type: "object", additionalProperties: true },
4127
+ details: { type: "object", additionalProperties: true },
4095
4128
  sourceDevice: { type: "string" },
4096
4129
  startedAt: { type: "string", format: "date-time" },
4097
4130
  endedAt: { type: "string", format: "date-time" },
@@ -1,7 +1,8 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { getDatabase, runInTransaction } from "../db.js";
3
3
  import { recordActivityEvent } from "./activity-events.js";
4
4
  import { listAgentActions } from "./collaboration.js";
5
+ import { ensureBotUser, getUserById } from "./users.js";
5
6
  import { agentActionSchema, agentRuntimeEventLevelSchema, agentRuntimeReconnectPlanSchema, agentRuntimeSessionEventSchema, agentRuntimeSessionSchema, createAgentRuntimeSessionEventSchema, createAgentRuntimeSessionSchema, disconnectAgentRuntimeSessionSchema, heartbeatAgentRuntimeSessionSchema, reconnectAgentRuntimeSessionSchema } from "../types.js";
6
7
  function parseMetadata(raw) {
7
8
  try {
@@ -211,7 +212,95 @@ function ensureCurrentSessionInstance(row, externalSessionId) {
211
212
  }
212
213
  return true;
213
214
  }
214
- function disconnectSupersededSingletonSessions(parsed, sessionId, now) {
215
+ function normalizeIdentityPart(value) {
216
+ return (value
217
+ ?.trim()
218
+ .toLowerCase()
219
+ .replace(/[^a-z0-9._:]+/g, "_")
220
+ .replace(/^_+|_+$/g, "") ?? "");
221
+ }
222
+ function shortHash(value) {
223
+ return createHash("sha1").update(value).digest("hex").slice(0, 12);
224
+ }
225
+ function canonicalRuntimeAgentLabel(provider) {
226
+ if (provider === "openclaw") {
227
+ return "Forge OpenClaw";
228
+ }
229
+ if (provider === "hermes") {
230
+ return "Forge Hermes";
231
+ }
232
+ return "Forge Codex";
233
+ }
234
+ function canonicalRuntimeDescription(provider) {
235
+ return `${canonicalRuntimeAgentLabel(provider)} runtime agent with stable Forge identity and linked Kanban user.`;
236
+ }
237
+ function canonicalAgentUserSpec(provider) {
238
+ if (provider === "openclaw") {
239
+ return {
240
+ id: "user_agent_openclaw",
241
+ handle: "openclaw",
242
+ displayName: "OpenClaw",
243
+ description: "OpenClaw runtime actor linked to Forge agent identity and Kanban ownership.",
244
+ accentColor: "#38bdf8"
245
+ };
246
+ }
247
+ if (provider === "hermes") {
248
+ return {
249
+ id: "user_agent_hermes",
250
+ handle: "hermes",
251
+ displayName: "Hermes",
252
+ description: "Hermes runtime actor linked to Forge agent identity and Kanban ownership.",
253
+ accentColor: "#a78bfa"
254
+ };
255
+ }
256
+ return {
257
+ id: "user_agent_codex",
258
+ handle: "codex",
259
+ displayName: "Codex",
260
+ description: "Codex runtime actor linked to Forge agent identity and Kanban ownership.",
261
+ accentColor: "#22c55e"
262
+ };
263
+ }
264
+ function deriveMachineKey(input) {
265
+ const explicit = normalizeIdentityPart(input.machineKey);
266
+ if (explicit) {
267
+ return explicit;
268
+ }
269
+ const source = [
270
+ normalizeText(input.dataRoot) ?? "",
271
+ normalizeText(input.baseUrl) ?? "local"
272
+ ].join("|");
273
+ return `machine_${shortHash(source)}`;
274
+ }
275
+ function derivePersonaKey(input) {
276
+ return (normalizeIdentityPart(input.personaKey) ||
277
+ normalizeIdentityPart(input.agentType) ||
278
+ "default");
279
+ }
280
+ function deriveAgentIdentityKey(input) {
281
+ const explicit = normalizeIdentityPart(input.agentIdentityKey);
282
+ if (explicit) {
283
+ return explicit;
284
+ }
285
+ return `runtime:${input.provider}:${deriveMachineKey(input)}:${derivePersonaKey(input)}`;
286
+ }
287
+ function linkAgentIdentityUsers(agentId, provider, linkedUserIds, now) {
288
+ const primaryUser = ensureBotUser(canonicalAgentUserSpec(provider));
289
+ const normalizedUserIds = Array.from(new Set([primaryUser.id, ...linkedUserIds.map((id) => id.trim()).filter(Boolean)]));
290
+ for (const userId of normalizedUserIds) {
291
+ if (!getUserById(userId)) {
292
+ continue;
293
+ }
294
+ getDatabase()
295
+ .prepare(`INSERT INTO agent_identity_users (agent_id, user_id, role, created_at, updated_at)
296
+ VALUES (?, ?, ?, ?, ?)
297
+ ON CONFLICT(agent_id, user_id) DO UPDATE SET
298
+ role = excluded.role,
299
+ updated_at = excluded.updated_at`)
300
+ .run(agentId, userId, userId === primaryUser.id ? "primary" : "linked", now, now);
301
+ }
302
+ }
303
+ function disconnectSupersededSingletonSessions(parsed, sessionId, agentId, now) {
215
304
  if (!parsed.metadata?.singleton) {
216
305
  return;
217
306
  }
@@ -224,11 +313,11 @@ function disconnectSupersededSingletonSessions(parsed, sessionId, now) {
224
313
  created_at, updated_at
225
314
  FROM agent_runtime_sessions
226
315
  WHERE provider = ?
227
- AND agent_label = ?
316
+ AND agent_id = ?
228
317
  AND coalesce(base_url, '') = coalesce(?, '')
229
318
  AND coalesce(data_root, '') = coalesce(?, '')
230
319
  AND id <> ?`)
231
- .all(parsed.provider, parsed.agentLabel, normalizeText(parsed.baseUrl), normalizeText(parsed.dataRoot), sessionId);
320
+ .all(parsed.provider, agentId, normalizeText(parsed.baseUrl), normalizeText(parsed.dataRoot), sessionId);
232
321
  for (const row of rows) {
233
322
  if (row.status === "disconnected" && row.ended_at) {
234
323
  continue;
@@ -251,29 +340,45 @@ function disconnectSupersededSingletonSessions(parsed, sessionId, now) {
251
340
  }
252
341
  }
253
342
  function upsertRuntimeAgentIdentity(input) {
343
+ const identityKey = deriveAgentIdentityKey(input);
344
+ const machineKey = deriveMachineKey(input);
345
+ const personaKey = derivePersonaKey(input);
346
+ const label = canonicalRuntimeAgentLabel(input.provider);
254
347
  const existing = getDatabase()
255
348
  .prepare(`SELECT id
256
349
  FROM agent_identities
257
- WHERE lower(label) = lower(?)
350
+ WHERE identity_key = ?
351
+ OR (
352
+ (identity_key IS NULL OR machine_key IS NULL OR machine_key = 'legacy' OR identity_key LIKE 'runtime:%:legacy:%')
353
+ AND (
354
+ provider = ?
355
+ OR lower(agent_type) = lower(?)
356
+ OR lower(label) = lower(?)
357
+ )
358
+ )
258
359
  LIMIT 1`)
259
- .get(input.agentLabel);
360
+ .get(identityKey, input.provider, input.provider, label);
260
361
  const now = new Date().toISOString();
261
- const description = `${input.provider[0].toUpperCase()}${input.provider.slice(1)} runtime session participant registered through Forge.`;
362
+ const description = canonicalRuntimeDescription(input.provider);
262
363
  if (existing) {
263
364
  getDatabase()
264
365
  .prepare(`UPDATE agent_identities
265
- SET agent_type = ?, updated_at = ?
366
+ SET label = ?, agent_type = ?, identity_key = ?, provider = ?,
367
+ machine_key = ?, persona_key = ?, description = ?, updated_at = ?
266
368
  WHERE id = ?`)
267
- .run(input.agentType || input.provider, now, existing.id);
369
+ .run(label, input.agentType || input.provider, identityKey, input.provider, machineKey, personaKey, description, now, existing.id);
370
+ linkAgentIdentityUsers(existing.id, input.provider, input.linkedUserIds, now);
268
371
  return existing.id;
269
372
  }
270
373
  const agentId = `agt_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
271
374
  getDatabase()
272
375
  .prepare(`INSERT INTO agent_identities (
273
- id, label, agent_type, trust_level, autonomy_mode, approval_mode,
376
+ id, label, agent_type, identity_key, provider, machine_key, persona_key,
377
+ trust_level, autonomy_mode, approval_mode,
274
378
  description, created_at, updated_at
275
- ) VALUES (?, ?, ?, 'trusted', 'approval_required', 'approval_by_default', ?, ?, ?)`)
276
- .run(agentId, input.agentLabel, input.agentType || input.provider, description, now, now);
379
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, 'trusted', 'approval_required', 'approval_by_default', ?, ?, ?)`)
380
+ .run(agentId, label, input.agentType || input.provider, identityKey, input.provider, machineKey, personaKey, description, now, now);
381
+ linkAgentIdentityUsers(agentId, input.provider, input.linkedUserIds, now);
277
382
  return agentId;
278
383
  }
279
384
  function insertSessionEvent(sessionId, input, now = new Date().toISOString()) {
@@ -321,6 +426,7 @@ export function registerAgentRuntimeSession(input) {
321
426
  return runInTransaction(() => {
322
427
  const now = new Date().toISOString();
323
428
  const agentId = upsertRuntimeAgentIdentity(parsed);
429
+ const agentLabel = canonicalRuntimeAgentLabel(parsed.provider);
324
430
  const existing = getSessionRowByCompositeKey(parsed.provider, parsed.sessionKey);
325
431
  const sessionId = existing?.id ?? `ags_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
326
432
  if (existing) {
@@ -332,7 +438,7 @@ export function registerAgentRuntimeSession(input) {
332
438
  last_error = ?, last_seen_at = ?, last_heartbeat_at = ?, started_at = ?,
333
439
  ended_at = NULL, metadata_json = ?, updated_at = ?
334
440
  WHERE id = ?`)
335
- .run(agentId, parsed.agentLabel, parsed.agentType || parsed.provider, parsed.sessionLabel || parsed.sessionKey, parsed.actorLabel, parsed.connectionMode, parsed.status === "error" ? "error" : "connected", normalizeText(parsed.baseUrl), normalizeText(parsed.webUrl), normalizeText(parsed.dataRoot), normalizeText(parsed.externalSessionId), parsed.staleAfterSeconds, normalizeText(parsed.lastError), now, now, now, JSON.stringify(parsed.metadata), now, sessionId);
441
+ .run(agentId, agentLabel, parsed.agentType || parsed.provider, parsed.sessionLabel || parsed.sessionKey, parsed.actorLabel, parsed.connectionMode, parsed.status === "error" ? "error" : "connected", normalizeText(parsed.baseUrl), normalizeText(parsed.webUrl), normalizeText(parsed.dataRoot), normalizeText(parsed.externalSessionId), parsed.staleAfterSeconds, normalizeText(parsed.lastError), now, now, now, JSON.stringify(parsed.metadata), now, sessionId);
336
442
  insertSessionEvent(sessionId, {
337
443
  eventType: "session_registered",
338
444
  title: "Session re-registered",
@@ -349,7 +455,7 @@ export function registerAgentRuntimeSession(input) {
349
455
  last_seen_at, last_heartbeat_at, started_at, ended_at, metadata_json,
350
456
  created_at, updated_at
351
457
  ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, NULL, ?, ?, ?, ?, NULL, ?, ?, ?)`)
352
- .run(sessionId, agentId, parsed.agentLabel, parsed.agentType || parsed.provider, parsed.provider, parsed.sessionKey, parsed.sessionLabel || parsed.sessionKey, parsed.actorLabel, parsed.connectionMode, parsed.status === "error" ? "error" : "connected", normalizeText(parsed.baseUrl), normalizeText(parsed.webUrl), normalizeText(parsed.dataRoot), normalizeText(parsed.externalSessionId), parsed.staleAfterSeconds, normalizeText(parsed.lastError), now, now, now, JSON.stringify(parsed.metadata), now, now);
458
+ .run(sessionId, agentId, agentLabel, parsed.agentType || parsed.provider, parsed.provider, parsed.sessionKey, parsed.sessionLabel || parsed.sessionKey, parsed.actorLabel, parsed.connectionMode, parsed.status === "error" ? "error" : "connected", normalizeText(parsed.baseUrl), normalizeText(parsed.webUrl), normalizeText(parsed.dataRoot), normalizeText(parsed.externalSessionId), parsed.staleAfterSeconds, normalizeText(parsed.lastError), now, now, now, JSON.stringify(parsed.metadata), now, now);
353
459
  insertSessionEvent(sessionId, {
354
460
  eventType: "session_registered",
355
461
  title: "Session registered",
@@ -357,12 +463,12 @@ export function registerAgentRuntimeSession(input) {
357
463
  metadata: parsed.metadata
358
464
  }, now);
359
465
  }
360
- disconnectSupersededSingletonSessions(parsed, sessionId, now);
466
+ disconnectSupersededSingletonSessions(parsed, sessionId, agentId, now);
361
467
  recordActivityEvent({
362
468
  entityType: "session",
363
469
  entityId: sessionId,
364
470
  eventType: "agent_session_registered",
365
- title: `Agent session registered: ${parsed.agentLabel}`,
471
+ title: `Agent session registered: ${agentLabel}`,
366
472
  description: `${parsed.provider} registered a live agent session.`,
367
473
  actor: parsed.actorLabel,
368
474
  source: "agent",