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.
- package/dist/assets/{board-BfqxFNiQ.js → board-B0TuXl4u.js} +1 -1
- package/dist/assets/index-DtT3Y-Bj.css +1 -0
- package/dist/assets/index-clNilMKr.js +91 -0
- package/dist/assets/{motion-C0ALlgho.js → motion-Dmjq6HPm.js} +1 -1
- package/dist/assets/{table-WcMjnJll.js → table-CKKimYN1.js} +1 -1
- package/dist/assets/{ui-B5I-3U91.js → ui-JBdCP1Qb.js} +1 -1
- package/dist/assets/{vendor-C56o26_3.js → vendor-fiXu5f59.js} +233 -228
- package/dist/index.html +7 -7
- package/dist/server/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
- package/dist/server/server/migrations/063_psyche_flashcards.sql +31 -0
- package/dist/server/server/src/app.js +314 -20
- package/dist/server/server/src/health.js +525 -1
- package/dist/server/server/src/openapi.js +1 -0
- package/dist/server/server/src/psyche-types.js +54 -0
- package/dist/server/server/src/repositories/psyche.js +146 -1
- package/dist/server/server/src/services/entity-crud.js +27 -5
- package/dist/server/server/src/services/gamification.js +2 -0
- package/dist/server/server/src/services/knowledge-graph.js +87 -1
- package/dist/server/server/src/services/psyche-observation-calendar.js +1 -0
- package/dist/server/server/src/services/psyche.js +4 -1
- package/dist/server/server/src/types.js +3 -0
- package/dist/server/src/lib/api.js +43 -0
- package/dist/server/src/lib/entity-visuals.js +9 -0
- package/dist/server/src/lib/knowledge-graph-types.js +19 -0
- package/dist/server/src/lib/psyche-schemas.js +177 -0
- package/openclaw.plugin.json +1 -1
- package/package.json +2 -1
- package/server/migrations/062_health_mobile_sync_sessions.sql +55 -0
- package/server/migrations/063_psyche_flashcards.sql +31 -0
- package/skills/forge-openclaw/SKILL.md +30 -9
- package/skills/forge-openclaw/entity_conversation_playbooks.md +139 -7
- package/skills/forge-openclaw/psyche_entity_playbooks.md +64 -3
- package/dist/assets/index-BfLQnCNZ.js +0 -91
- 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();
|
|
@@ -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(""),
|