forge-openclaw-plugin 0.2.69 → 0.2.71

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 (34) hide show
  1. package/dist/assets/{board-BfqxFNiQ.js → board-B0TuXl4u.js} +1 -1
  2. package/dist/assets/index-DtT3Y-Bj.css +1 -0
  3. package/dist/assets/index-clNilMKr.js +91 -0
  4. package/dist/assets/{motion-C0ALlgho.js → motion-Dmjq6HPm.js} +1 -1
  5. package/dist/assets/{table-WcMjnJll.js → table-CKKimYN1.js} +1 -1
  6. package/dist/assets/{ui-B5I-3U91.js → ui-JBdCP1Qb.js} +1 -1
  7. package/dist/assets/{vendor-C56o26_3.js → vendor-fiXu5f59.js} +233 -228
  8. package/dist/index.html +7 -7
  9. package/dist/server/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
  10. package/dist/server/server/migrations/063_psyche_flashcards.sql +31 -0
  11. package/dist/server/server/src/app.js +314 -20
  12. package/dist/server/server/src/health.js +525 -1
  13. package/dist/server/server/src/openapi.js +1 -0
  14. package/dist/server/server/src/psyche-types.js +54 -0
  15. package/dist/server/server/src/repositories/psyche.js +146 -1
  16. package/dist/server/server/src/services/entity-crud.js +27 -5
  17. package/dist/server/server/src/services/gamification.js +2 -0
  18. package/dist/server/server/src/services/knowledge-graph.js +87 -1
  19. package/dist/server/server/src/services/psyche-observation-calendar.js +1 -0
  20. package/dist/server/server/src/services/psyche.js +4 -1
  21. package/dist/server/server/src/types.js +3 -0
  22. package/dist/server/src/lib/api.js +43 -0
  23. package/dist/server/src/lib/entity-visuals.js +9 -0
  24. package/dist/server/src/lib/knowledge-graph-types.js +19 -0
  25. package/dist/server/src/lib/psyche-schemas.js +177 -0
  26. package/openclaw.plugin.json +1 -1
  27. package/package.json +2 -1
  28. package/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
  29. package/server/migrations/063_psyche_flashcards.sql +31 -0
  30. package/skills/forge-openclaw/SKILL.md +30 -9
  31. package/skills/forge-openclaw/entity_conversation_playbooks.md +139 -7
  32. package/skills/forge-openclaw/psyche_entity_playbooks.md +64 -3
  33. package/dist/assets/index-BfLQnCNZ.js +0 -91
  34. package/dist/assets/index-DIapFz9v.css +0 -1
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, 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";
@@ -259,6 +259,92 @@ export const mobileHealthSyncSchema = z.object({
259
259
  movement: movementSyncPayloadSchema.default({}),
260
260
  screenTime: screenTimeSyncPayloadSchema.default({})
261
261
  });
262
+ const HEALTH_MOBILE_SYNC_SCHEMA_VERSION = "healthkit-sync-v2";
263
+ const HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES = 512_000;
264
+ const HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES = 1_000_000;
265
+ const HEALTH_MOBILE_SYNC_SESSION_TTL_MS = 1000 * 60 * 60 * 24;
266
+ const mobileHealthSyncFamilySchema = z.enum([
267
+ "sleep_nights",
268
+ "sleep_segments",
269
+ "sleep_raw_records",
270
+ "workout_summaries",
271
+ "workout_time_series",
272
+ "workout_routes",
273
+ "workout_tombstones",
274
+ "vitals",
275
+ "movement",
276
+ "screen_time"
277
+ ]);
278
+ const defaultMobileHealthSyncFamilies = mobileHealthSyncFamilySchema.options;
279
+ export const mobileHealthSyncSessionStartSchema = mobileHealthSyncSchema
280
+ .pick({
281
+ sessionId: true,
282
+ pairingToken: true,
283
+ device: true,
284
+ permissions: true,
285
+ sourceStates: true
286
+ })
287
+ .extend({
288
+ schemaVersion: z
289
+ .string()
290
+ .trim()
291
+ .default(HEALTH_MOBILE_SYNC_SCHEMA_VERSION),
292
+ requestedFamilies: z
293
+ .array(mobileHealthSyncFamilySchema)
294
+ .default(defaultMobileHealthSyncFamilies),
295
+ expectedCounts: z.record(z.string(), z.number().int().nonnegative()).default({}),
296
+ metadata: z.record(z.string(), z.unknown()).default({})
297
+ });
298
+ const workoutTimeSeriesChunkPayloadSchema = z.object({
299
+ workouts: z
300
+ .array(z.object({
301
+ externalUid: z.string().trim().min(1),
302
+ samples: z.array(workoutTimeSeriesSampleSchema).default([])
303
+ }))
304
+ .default([])
305
+ });
306
+ const workoutRouteChunkPayloadSchema = z.object({
307
+ workouts: z
308
+ .array(z.object({
309
+ externalUid: z.string().trim().min(1),
310
+ routePoints: z.array(workoutRoutePointSchema).default([])
311
+ }))
312
+ .default([])
313
+ });
314
+ const workoutTombstoneChunkPayloadSchema = z.object({
315
+ workouts: z
316
+ .array(z.object({
317
+ externalUid: z.string().trim().min(1),
318
+ deletedAt: z.string().datetime().nullable().optional(),
319
+ reason: z.string().trim().default("healthkit_deleted")
320
+ }))
321
+ .default([])
322
+ });
323
+ const mobileHealthSyncChunkPayloadSchema = z.object({
324
+ sleepNights: mobileHealthSyncSchema.shape.sleepNights.optional(),
325
+ sleepSegments: mobileHealthSyncSchema.shape.sleepSegments.optional(),
326
+ sleepRawRecords: mobileHealthSyncSchema.shape.sleepRawRecords.optional(),
327
+ workouts: mobileHealthSyncSchema.shape.workouts.optional(),
328
+ workoutTimeSeries: workoutTimeSeriesChunkPayloadSchema.shape.workouts.optional(),
329
+ workoutRoutes: workoutRouteChunkPayloadSchema.shape.workouts.optional(),
330
+ workoutTombstones: workoutTombstoneChunkPayloadSchema.shape.workouts.optional(),
331
+ vitals: vitalsSyncPayloadSchema.optional(),
332
+ movement: movementSyncPayloadSchema.optional(),
333
+ screenTime: screenTimeSyncPayloadSchema.optional()
334
+ });
335
+ export const mobileHealthSyncChunkSchema = z.object({
336
+ chunkId: z.string().trim().min(1),
337
+ sequence: z.number().int().nonnegative(),
338
+ family: mobileHealthSyncFamilySchema,
339
+ recordCount: z.number().int().nonnegative().default(0),
340
+ byteCount: z.number().int().nonnegative().default(0),
341
+ checksumSha256: z.string().trim().min(1),
342
+ payload: mobileHealthSyncChunkPayloadSchema.default({})
343
+ });
344
+ export const mobileHealthSyncSessionCompleteSchema = z.object({
345
+ finalCursor: z.record(z.string(), z.unknown()).default({}),
346
+ expectedCounts: z.record(z.string(), z.number().int().nonnegative()).default({})
347
+ });
262
348
  export const verifyCompanionPairingSchema = z.object({
263
349
  sessionId: z.string().trim().min(1),
264
350
  pairingToken: z.string().trim().min(1),
@@ -2562,6 +2648,444 @@ function replaceHistoricalSleepSessionsForDate(userId, localDateKey, providerBac
2562
2648
  .run(historical.id);
2563
2649
  }
2564
2650
  }
2651
+ function mobileSyncSessionId() {
2652
+ return `hms_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
2653
+ }
2654
+ function mobileSyncChunkRecordId() {
2655
+ return `hmsc_${randomUUID().replaceAll("-", "").slice(0, 12)}`;
2656
+ }
2657
+ function expireStaleMobileSyncSessions() {
2658
+ const cutoff = new Date(Date.now() - HEALTH_MOBILE_SYNC_SESSION_TTL_MS).toISOString();
2659
+ const now = nowIso();
2660
+ getDatabase()
2661
+ .prepare(`UPDATE health_mobile_sync_sessions
2662
+ SET status = 'expired', expired_at = ?, updated_at = ?
2663
+ WHERE status = 'running' AND started_at < ?`)
2664
+ .run(now, now, cutoff);
2665
+ }
2666
+ function readMobileSyncSession(syncSessionId) {
2667
+ return getDatabase()
2668
+ .prepare(`SELECT * FROM health_mobile_sync_sessions WHERE id = ?`)
2669
+ .get(syncSessionId);
2670
+ }
2671
+ function ensureRunningMobileSyncSession(syncSessionId) {
2672
+ expireStaleMobileSyncSessions();
2673
+ const session = readMobileSyncSession(syncSessionId);
2674
+ if (!session) {
2675
+ throw new HttpError(404, "sync_session_not_found", "The HealthKit sync session does not exist.");
2676
+ }
2677
+ if (session.status !== "running") {
2678
+ throw new HttpError(409, "sync_session_closed", "The HealthKit sync session is no longer accepting chunks.", { status: session.status });
2679
+ }
2680
+ return session;
2681
+ }
2682
+ function chunkPayloadJson(payload) {
2683
+ return JSON.stringify(payload);
2684
+ }
2685
+ function chunkPayloadChecksum(payloadJson) {
2686
+ return createHash("sha256").update(payloadJson).digest("hex");
2687
+ }
2688
+ function summarizeChunkPayload(family, payload) {
2689
+ switch (family) {
2690
+ case "sleep_nights":
2691
+ return { sleepNights: payload.sleepNights?.length ?? 0 };
2692
+ case "sleep_segments":
2693
+ return { sleepSegments: payload.sleepSegments?.length ?? 0 };
2694
+ case "sleep_raw_records":
2695
+ return { sleepRawRecords: payload.sleepRawRecords?.length ?? 0 };
2696
+ case "workout_summaries":
2697
+ return { workouts: payload.workouts?.length ?? 0 };
2698
+ case "workout_time_series":
2699
+ return {
2700
+ workouts: payload.workoutTimeSeries?.length ?? 0,
2701
+ samples: payload.workoutTimeSeries?.reduce((sum, entry) => sum + entry.samples.length, 0) ?? 0
2702
+ };
2703
+ case "workout_routes":
2704
+ return {
2705
+ workouts: payload.workoutRoutes?.length ?? 0,
2706
+ routePoints: payload.workoutRoutes?.reduce((sum, entry) => sum + entry.routePoints.length, 0) ?? 0
2707
+ };
2708
+ case "workout_tombstones":
2709
+ return { workouts: payload.workoutTombstones?.length ?? 0 };
2710
+ case "vitals":
2711
+ return { daySummaries: payload.vitals?.daySummaries.length ?? 0 };
2712
+ case "movement":
2713
+ return {
2714
+ stays: payload.movement?.stays.length ?? 0,
2715
+ trips: payload.movement?.trips.length ?? 0
2716
+ };
2717
+ case "screen_time":
2718
+ return {
2719
+ daySummaries: payload.screenTime?.daySummaries.length ?? 0,
2720
+ hourlySegments: payload.screenTime?.hourlySegments.length ?? 0
2721
+ };
2722
+ }
2723
+ }
2724
+ function updateMobileSyncSessionProgress(syncSessionId) {
2725
+ const chunks = getDatabase()
2726
+ .prepare(`SELECT family, record_count, byte_count
2727
+ FROM health_mobile_sync_chunks
2728
+ WHERE sync_session_id = ?`)
2729
+ .all(syncSessionId);
2730
+ const receivedCounts = {};
2731
+ const byteTotals = {};
2732
+ for (const chunk of chunks) {
2733
+ receivedCounts[chunk.family] =
2734
+ (receivedCounts[chunk.family] ?? 0) + chunk.record_count;
2735
+ byteTotals[chunk.family] =
2736
+ (byteTotals[chunk.family] ?? 0) + chunk.byte_count;
2737
+ }
2738
+ getDatabase()
2739
+ .prepare(`UPDATE health_mobile_sync_sessions
2740
+ SET received_counts_json = ?, byte_totals_json = ?, updated_at = ?
2741
+ WHERE id = ?`)
2742
+ .run(JSON.stringify(receivedCounts), JSON.stringify(byteTotals), nowIso(), syncSessionId);
2743
+ return {
2744
+ receivedCounts,
2745
+ byteTotals,
2746
+ chunkCount: chunks.length,
2747
+ receivedBytes: chunks.reduce((sum, chunk) => sum + chunk.byte_count, 0)
2748
+ };
2749
+ }
2750
+ export function startMobileHealthSyncSession(payload) {
2751
+ const parsed = mobileHealthSyncSessionStartSchema.parse(payload);
2752
+ if (parsed.schemaVersion !== HEALTH_MOBILE_SYNC_SCHEMA_VERSION) {
2753
+ throw new HttpError(409, "schema_version_unsupported", "The companion HealthKit sync protocol is not supported by this Forge runtime.", {
2754
+ schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
2755
+ requestedSchemaVersion: parsed.schemaVersion
2756
+ });
2757
+ }
2758
+ expireStaleMobileSyncSessions();
2759
+ const pairing = requireValidPairing(parsed.sessionId, parsed.pairingToken);
2760
+ const resumeSyncSessionId = typeof parsed.metadata.resumeSyncSessionId === "string"
2761
+ ? parsed.metadata.resumeSyncSessionId.trim()
2762
+ : "";
2763
+ if (resumeSyncSessionId.length > 0) {
2764
+ const existing = readMobileSyncSession(resumeSyncSessionId);
2765
+ if (existing &&
2766
+ existing.pairing_session_id === pairing.id &&
2767
+ existing.status === "running") {
2768
+ const receivedChunkIds = getDatabase()
2769
+ .prepare(`SELECT chunk_id
2770
+ FROM health_mobile_sync_chunks
2771
+ WHERE sync_session_id = ?
2772
+ ORDER BY sequence ASC`)
2773
+ .all(resumeSyncSessionId)
2774
+ .map((row) => row.chunk_id);
2775
+ return {
2776
+ syncSessionId: resumeSyncSessionId,
2777
+ schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
2778
+ chunkTargetBytes: HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES,
2779
+ chunkMaxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
2780
+ supportsCompression: false,
2781
+ acceptedFamilies: safeJsonParse(existing.requested_families_json, parsed.requestedFamilies),
2782
+ receivedChunkIds
2783
+ };
2784
+ }
2785
+ }
2786
+ const now = nowIso();
2787
+ const syncSessionId = mobileSyncSessionId();
2788
+ getDatabase()
2789
+ .prepare(`INSERT INTO health_mobile_sync_sessions (
2790
+ id, pairing_session_id, user_id, status, schema_version,
2791
+ requested_families_json, source_metadata_json, expected_counts_json,
2792
+ received_counts_json, byte_totals_json, started_at, created_at, updated_at
2793
+ )
2794
+ VALUES (?, ?, ?, 'running', ?, ?, ?, ?, '{}', '{}', ?, ?, ?)`)
2795
+ .run(syncSessionId, pairing.id, pairing.user_id, HEALTH_MOBILE_SYNC_SCHEMA_VERSION, JSON.stringify(parsed.requestedFamilies), JSON.stringify({
2796
+ sessionId: parsed.sessionId,
2797
+ device: parsed.device,
2798
+ permissions: parsed.permissions,
2799
+ sourceStates: parsed.sourceStates,
2800
+ metadata: parsed.metadata
2801
+ }), JSON.stringify(parsed.expectedCounts), now, now, now);
2802
+ return {
2803
+ syncSessionId,
2804
+ schemaVersion: HEALTH_MOBILE_SYNC_SCHEMA_VERSION,
2805
+ chunkTargetBytes: HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES,
2806
+ chunkMaxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
2807
+ supportsCompression: false,
2808
+ acceptedFamilies: parsed.requestedFamilies,
2809
+ receivedChunkIds: []
2810
+ };
2811
+ }
2812
+ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJson) {
2813
+ const parsed = mobileHealthSyncChunkSchema.parse(payload);
2814
+ const session = ensureRunningMobileSyncSession(syncSessionId);
2815
+ const requestedFamilies = safeJsonParse(session.requested_families_json, []);
2816
+ if (requestedFamilies.length > 0 &&
2817
+ !requestedFamilies.includes(parsed.family)) {
2818
+ throw new HttpError(400, "unsupported_family", "This sync session did not request that HealthKit family.", { family: parsed.family });
2819
+ }
2820
+ const existing = getDatabase()
2821
+ .prepare(`SELECT * FROM health_mobile_sync_chunks
2822
+ WHERE sync_session_id = ? AND chunk_id = ?`)
2823
+ .get(syncSessionId, parsed.chunkId);
2824
+ if (existing) {
2825
+ if (existing.checksum_sha256 !== parsed.checksumSha256) {
2826
+ throw new HttpError(409, "chunk_checksum_mismatch", "A chunk with the same id was already accepted with different content.");
2827
+ }
2828
+ const progress = updateMobileSyncSessionProgress(syncSessionId);
2829
+ return {
2830
+ accepted: true,
2831
+ duplicate: true,
2832
+ receivedCount: progress.chunkCount,
2833
+ receivedBytes: progress.receivedBytes,
2834
+ progress
2835
+ };
2836
+ }
2837
+ const payloadJson = chunkPayloadJson(parsed.payload);
2838
+ const checksumPayloadJson = rawPayloadJson ?? payloadJson;
2839
+ const actualByteCount = Buffer.byteLength(checksumPayloadJson, "utf8");
2840
+ if (actualByteCount > HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES) {
2841
+ throw new HttpError(413, "chunk_too_large", "The HealthKit sync chunk is too large.", {
2842
+ maxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
2843
+ actualBytes: actualByteCount
2844
+ });
2845
+ }
2846
+ const serverChecksum = chunkPayloadChecksum(checksumPayloadJson);
2847
+ if (parsed.checksumSha256 !== serverChecksum) {
2848
+ throw new HttpError(409, "chunk_checksum_mismatch", "The HealthKit sync chunk checksum does not match its payload.", { actualBytes: actualByteCount });
2849
+ }
2850
+ const now = nowIso();
2851
+ getDatabase()
2852
+ .prepare(`INSERT INTO health_mobile_sync_chunks (
2853
+ id, sync_session_id, chunk_id, sequence, family, checksum_sha256,
2854
+ record_count, byte_count, payload_json, payload_summary_json,
2855
+ received_at, applied_at, created_at, updated_at
2856
+ )
2857
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2858
+ .run(mobileSyncChunkRecordId(), syncSessionId, parsed.chunkId, parsed.sequence, parsed.family, serverChecksum, parsed.recordCount, parsed.byteCount || actualByteCount, payloadJson, JSON.stringify({
2859
+ ...summarizeChunkPayload(parsed.family, parsed.payload),
2860
+ clientByteCount: parsed.byteCount,
2861
+ actualByteCount,
2862
+ serverChecksum
2863
+ }), now, now, now, now);
2864
+ const progress = updateMobileSyncSessionProgress(syncSessionId);
2865
+ return {
2866
+ accepted: true,
2867
+ duplicate: false,
2868
+ receivedCount: progress.chunkCount,
2869
+ receivedBytes: progress.receivedBytes,
2870
+ progress
2871
+ };
2872
+ }
2873
+ function mergeMobileHealthSyncChunks(session, chunks) {
2874
+ const pairing = getDatabase()
2875
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
2876
+ .get(session.pairing_session_id);
2877
+ if (!pairing) {
2878
+ throw new HttpError(404, "pairing_invalid", "The sync pairing no longer exists.");
2879
+ }
2880
+ const metadata = safeJsonParse(session.source_metadata_json, {});
2881
+ const assembled = mobileHealthSyncSchema.parse({
2882
+ sessionId: metadata.sessionId ?? pairing.id,
2883
+ pairingToken: pairing.pairing_token,
2884
+ device: metadata.device ??
2885
+ {
2886
+ name: pairing.device_name ?? "iPhone",
2887
+ platform: pairing.platform ?? "ios",
2888
+ appVersion: pairing.app_version ?? "",
2889
+ sourceDevice: pairing.device_name ?? "iPhone"
2890
+ },
2891
+ permissions: metadata.permissions ??
2892
+ {
2893
+ healthKitAuthorized: false,
2894
+ backgroundRefreshEnabled: false,
2895
+ motionReady: false,
2896
+ locationReady: false,
2897
+ screenTimeReady: false
2898
+ },
2899
+ sourceStates: metadata.sourceStates,
2900
+ sleepSessions: [],
2901
+ sleepNights: [],
2902
+ sleepSegments: [],
2903
+ sleepRawRecords: [],
2904
+ workouts: [],
2905
+ vitals: { daySummaries: [] },
2906
+ movement: {},
2907
+ screenTime: {}
2908
+ });
2909
+ const workoutsByExternalUid = new Map();
2910
+ const tombstones = [];
2911
+ for (const chunk of chunks.sort((left, right) => left.sequence - right.sequence)) {
2912
+ const payload = mobileHealthSyncChunkPayloadSchema.parse(safeJsonParse(chunk.payload_json, {}));
2913
+ if (payload.sleepNights) {
2914
+ assembled.sleepNights.push(...payload.sleepNights);
2915
+ }
2916
+ if (payload.sleepSegments) {
2917
+ assembled.sleepSegments.push(...payload.sleepSegments);
2918
+ }
2919
+ if (payload.sleepRawRecords) {
2920
+ assembled.sleepRawRecords.push(...payload.sleepRawRecords);
2921
+ }
2922
+ if (payload.workouts) {
2923
+ for (const workout of payload.workouts) {
2924
+ const previous = workoutsByExternalUid.get(workout.externalUid);
2925
+ workoutsByExternalUid.set(workout.externalUid, {
2926
+ ...(previous ?? workout),
2927
+ ...workout,
2928
+ timeSeriesSamples: [
2929
+ ...(previous?.timeSeriesSamples ?? []),
2930
+ ...workout.timeSeriesSamples
2931
+ ],
2932
+ routePoints: [
2933
+ ...(previous?.routePoints ?? []),
2934
+ ...workout.routePoints
2935
+ ]
2936
+ });
2937
+ }
2938
+ }
2939
+ if (payload.workoutTimeSeries) {
2940
+ for (const entry of payload.workoutTimeSeries) {
2941
+ const workout = workoutsByExternalUid.get(entry.externalUid);
2942
+ if (!workout) {
2943
+ throw new HttpError(409, "missing_required_chunks", "A workout time-series chunk arrived without a matching workout summary.", { workoutExternalUid: entry.externalUid });
2944
+ }
2945
+ workout.timeSeriesSamples.push(...entry.samples);
2946
+ }
2947
+ }
2948
+ if (payload.workoutRoutes) {
2949
+ for (const entry of payload.workoutRoutes) {
2950
+ const workout = workoutsByExternalUid.get(entry.externalUid);
2951
+ if (!workout) {
2952
+ throw new HttpError(409, "missing_required_chunks", "A workout route chunk arrived without a matching workout summary.", { workoutExternalUid: entry.externalUid });
2953
+ }
2954
+ workout.routePoints.push(...entry.routePoints);
2955
+ }
2956
+ }
2957
+ if (payload.workoutTombstones) {
2958
+ tombstones.push(...payload.workoutTombstones);
2959
+ }
2960
+ if (payload.vitals) {
2961
+ assembled.vitals.daySummaries.push(...payload.vitals.daySummaries);
2962
+ }
2963
+ if (payload.movement) {
2964
+ assembled.movement.settings = payload.movement.settings;
2965
+ assembled.movement.knownPlaces.push(...payload.movement.knownPlaces);
2966
+ assembled.movement.stays.push(...payload.movement.stays);
2967
+ assembled.movement.trips.push(...payload.movement.trips);
2968
+ }
2969
+ if (payload.screenTime) {
2970
+ assembled.screenTime.settings = payload.screenTime.settings;
2971
+ assembled.screenTime.daySummaries.push(...payload.screenTime.daySummaries);
2972
+ assembled.screenTime.hourlySegments.push(...payload.screenTime.hourlySegments);
2973
+ }
2974
+ }
2975
+ assembled.workouts = [...workoutsByExternalUid.values()].sort((left, right) => right.startedAt.localeCompare(left.startedAt));
2976
+ return { assembled, tombstones };
2977
+ }
2978
+ function applyWorkoutTombstones(pairing, tombstones) {
2979
+ if (tombstones.length === 0) {
2980
+ return 0;
2981
+ }
2982
+ const deleteStmt = getDatabase().prepare(`DELETE FROM health_workout_sessions
2983
+ WHERE user_id = ? AND source = 'apple_health' AND external_uid = ?`);
2984
+ let deleted = 0;
2985
+ for (const tombstone of tombstones) {
2986
+ const result = deleteStmt.run(pairing.user_id, tombstone.externalUid);
2987
+ deleted += Number(result.changes ?? 0);
2988
+ }
2989
+ return deleted;
2990
+ }
2991
+ function upsertMobileSyncFamilyCursors(pairing, finalCursor) {
2992
+ const now = nowIso();
2993
+ const stmt = getDatabase().prepare(`INSERT INTO health_mobile_sync_family_cursors (
2994
+ id, pairing_session_id, user_id, family, cursor_json, updated_at
2995
+ )
2996
+ VALUES (?, ?, ?, ?, ?, ?)
2997
+ ON CONFLICT(pairing_session_id, family)
2998
+ DO UPDATE SET cursor_json = excluded.cursor_json, updated_at = excluded.updated_at`);
2999
+ for (const [family, cursor] of Object.entries(finalCursor)) {
3000
+ stmt.run(`hmscur_${randomUUID().replaceAll("-", "").slice(0, 10)}`, pairing.id, pairing.user_id, family, JSON.stringify(cursor), now);
3001
+ }
3002
+ }
3003
+ function validateMobileSyncExpectedCounts(syncSessionId, expectedCounts) {
3004
+ const expectedEntries = Object.entries(expectedCounts).filter(([, expected]) => Number.isFinite(expected) && expected > 0);
3005
+ if (expectedEntries.length === 0) {
3006
+ return;
3007
+ }
3008
+ const progress = updateMobileSyncSessionProgress(syncSessionId);
3009
+ const missingFamilies = expectedEntries
3010
+ .map(([family, expected]) => ({
3011
+ family,
3012
+ expected,
3013
+ received: progress.receivedCounts[family] ?? 0
3014
+ }))
3015
+ .filter((entry) => entry.received < entry.expected);
3016
+ if (missingFamilies.length > 0) {
3017
+ throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session is missing required chunks.", { families: missingFamilies });
3018
+ }
3019
+ }
3020
+ function markMobileSyncSessionFailed(session, error) {
3021
+ const now = nowIso();
3022
+ const errorMessage = error instanceof Error ? error.message : String(error);
3023
+ getDatabase()
3024
+ .prepare(`UPDATE health_mobile_sync_sessions
3025
+ SET status = 'failed', failed_at = ?, error_json = ?, updated_at = ?
3026
+ WHERE id = ?`)
3027
+ .run(now, JSON.stringify({
3028
+ message: errorMessage
3029
+ }), now, session.id);
3030
+ getDatabase()
3031
+ .prepare(`UPDATE companion_pairing_sessions
3032
+ SET last_sync_error = ?, updated_at = ?
3033
+ WHERE id = ?`)
3034
+ .run(errorMessage, now, session.pairing_session_id);
3035
+ }
3036
+ export function completeMobileHealthSyncSession(syncSessionId, payload) {
3037
+ const parsed = mobileHealthSyncSessionCompleteSchema.parse(payload);
3038
+ const session = ensureRunningMobileSyncSession(syncSessionId);
3039
+ const chunks = getDatabase()
3040
+ .prepare(`SELECT * FROM health_mobile_sync_chunks
3041
+ WHERE sync_session_id = ?
3042
+ ORDER BY sequence ASC`)
3043
+ .all(syncSessionId);
3044
+ if (chunks.length === 0) {
3045
+ throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session has no accepted chunks.");
3046
+ }
3047
+ const pairing = getDatabase()
3048
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
3049
+ .get(session.pairing_session_id);
3050
+ try {
3051
+ return runInTransaction(() => {
3052
+ validateMobileSyncExpectedCounts(syncSessionId, parsed.expectedCounts);
3053
+ const { assembled, tombstones } = mergeMobileHealthSyncChunks(session, chunks);
3054
+ const sync = ingestMobileHealthSync(assembled);
3055
+ const deletedWorkoutCount = applyWorkoutTombstones(pairing, tombstones);
3056
+ upsertMobileSyncFamilyCursors(pairing, parsed.finalCursor);
3057
+ const now = nowIso();
3058
+ getDatabase()
3059
+ .prepare(`UPDATE health_mobile_sync_sessions
3060
+ SET status = 'completed', expected_counts_json = ?, completed_at = ?,
3061
+ updated_at = ?
3062
+ WHERE id = ?`)
3063
+ .run(JSON.stringify(parsed.expectedCounts), now, now, syncSessionId);
3064
+ return {
3065
+ ...sync,
3066
+ upload: {
3067
+ syncSessionId,
3068
+ chunks: chunks.length,
3069
+ deletedWorkoutCount
3070
+ }
3071
+ };
3072
+ });
3073
+ }
3074
+ catch (error) {
3075
+ markMobileSyncSessionFailed(session, error);
3076
+ throw error;
3077
+ }
3078
+ }
3079
+ export function abortMobileHealthSyncSession(syncSessionId) {
3080
+ const session = ensureRunningMobileSyncSession(syncSessionId);
3081
+ const now = nowIso();
3082
+ getDatabase()
3083
+ .prepare(`UPDATE health_mobile_sync_sessions
3084
+ SET status = 'aborted', aborted_at = ?, updated_at = ?
3085
+ WHERE id = ?`)
3086
+ .run(now, now, session.id);
3087
+ return { syncSessionId, status: "aborted" };
3088
+ }
2565
3089
  export function ingestMobileHealthSync(payload) {
2566
3090
  const parsed = mobileHealthSyncSchema.parse(payload);
2567
3091
  ensureLegacyAppleSleepHistoryRepaired();
@@ -1314,6 +1314,7 @@ export function buildOpenApiDocument() {
1314
1314
  "belief_entry",
1315
1315
  "mode_profile",
1316
1316
  "mode_guide_session",
1317
+ "flashcard",
1317
1318
  "trigger_report",
1318
1319
  "note",
1319
1320
  "tag",
@@ -13,12 +13,16 @@ export const behaviorKindSchema = z.enum(["away", "committed", "recovery"]);
13
13
  export const beliefTypeSchema = z.enum(["absolute", "conditional"]);
14
14
  export const modeFamilySchema = z.enum(["coping", "child", "critic_parent", "healthy_adult", "happy_child"]);
15
15
  export const schemaTypeSchema = z.enum(["maladaptive", "adaptive"]);
16
+ export const flashcardTypographySchema = z.enum(["serif", "sans", "mono", "display"]);
17
+ export const flashcardLayoutSchema = z.enum(["centered", "top_left", "image_split", "poster"]);
18
+ export const flashcardVisualStyleSchema = z.enum(["calm", "urgent", "warm", "clinical", "playful"]);
16
19
  export const PSYCHE_ENTITY_TYPES = [
17
20
  "psyche_value",
18
21
  "behavior_pattern",
19
22
  "behavior",
20
23
  "belief_entry",
21
24
  "mode_profile",
25
+ "flashcard",
22
26
  "trigger_report"
23
27
  ];
24
28
  export const domainSchema = z.object({
@@ -184,6 +188,32 @@ export const modeGuideSessionSchema = z.object({
184
188
  updatedAt: z.string(),
185
189
  ...ownedEntityFieldsSchema
186
190
  });
191
+ export const flashcardSchema = z.object({
192
+ id: z.string(),
193
+ domainId: z.string(),
194
+ title: trimmedString,
195
+ message: nonEmptyTrimmedString,
196
+ triggerSentence: trimmedString,
197
+ triggerSituation: trimmedString,
198
+ tags: z.array(trimmedString).default([]),
199
+ backgroundColor: trimmedString,
200
+ textColor: trimmedString,
201
+ accentColor: trimmedString,
202
+ typography: flashcardTypographySchema,
203
+ imageUrl: trimmedString,
204
+ imageAlt: trimmedString,
205
+ layout: flashcardLayoutSchema,
206
+ visualStyle: flashcardVisualStyleSchema,
207
+ linkedValueIds: uniqueStringArraySchema.default([]),
208
+ linkedBehaviorIds: uniqueStringArraySchema.default([]),
209
+ linkedPatternIds: uniqueStringArraySchema.default([]),
210
+ linkedBeliefIds: uniqueStringArraySchema.default([]),
211
+ linkedModeIds: uniqueStringArraySchema.default([]),
212
+ linkedReportIds: uniqueStringArraySchema.default([]),
213
+ createdAt: z.string(),
214
+ updatedAt: z.string(),
215
+ ...ownedEntityFieldsSchema
216
+ });
187
217
  export const triggerEmotionSchema = z.object({
188
218
  id: z.string(),
189
219
  emotionDefinitionId: z.string().nullable().default(null),
@@ -341,6 +371,7 @@ export const psycheOverviewPayloadSchema = z.object({
341
371
  behaviors: z.array(behaviorSchema),
342
372
  beliefs: z.array(beliefEntrySchema),
343
373
  modes: z.array(modeProfileSchema),
374
+ flashcards: z.array(flashcardSchema),
344
375
  reports: z.array(triggerReportSchema),
345
376
  schemaPressure: z.array(schemaPressureEntrySchema),
346
377
  devrageMetric: devrageMetricPayloadSchema,
@@ -456,6 +487,29 @@ export const createModeGuideSessionSchema = z.object({
456
487
  userId: optionalOwnedUserIdSchema
457
488
  });
458
489
  export const updateModeGuideSessionSchema = createModeGuideSessionSchema.partial();
490
+ export const createFlashcardSchema = z.object({
491
+ title: trimmedString.default(""),
492
+ message: nonEmptyTrimmedString,
493
+ triggerSentence: trimmedString.default(""),
494
+ triggerSituation: trimmedString.default(""),
495
+ tags: z.array(trimmedString).default([]),
496
+ backgroundColor: trimmedString.default("#f8fafc"),
497
+ textColor: trimmedString.default("#111827"),
498
+ accentColor: trimmedString.default("#6ee7b7"),
499
+ typography: flashcardTypographySchema.default("serif"),
500
+ imageUrl: trimmedString.default(""),
501
+ imageAlt: trimmedString.default(""),
502
+ layout: flashcardLayoutSchema.default("centered"),
503
+ visualStyle: flashcardVisualStyleSchema.default("calm"),
504
+ linkedValueIds: uniqueStringArraySchema.default([]),
505
+ linkedBehaviorIds: uniqueStringArraySchema.default([]),
506
+ linkedPatternIds: uniqueStringArraySchema.default([]),
507
+ linkedBeliefIds: uniqueStringArraySchema.default([]),
508
+ linkedModeIds: uniqueStringArraySchema.default([]),
509
+ linkedReportIds: uniqueStringArraySchema.default([]),
510
+ userId: optionalOwnedUserIdSchema
511
+ });
512
+ export const updateFlashcardSchema = createFlashcardSchema.partial();
459
513
  export const createEventTypeSchema = z.object({
460
514
  label: nonEmptyTrimmedString,
461
515
  description: trimmedString.default(""),