forge-openclaw-plugin 0.2.69 → 0.2.70

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.
@@ -0,0 +1,55 @@
1
+ CREATE TABLE IF NOT EXISTS health_mobile_sync_sessions (
2
+ id TEXT PRIMARY KEY,
3
+ pairing_session_id TEXT NOT NULL REFERENCES companion_pairing_sessions(id) ON DELETE CASCADE,
4
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5
+ status TEXT NOT NULL DEFAULT 'running',
6
+ schema_version TEXT NOT NULL DEFAULT 'healthkit-sync-v2',
7
+ requested_families_json TEXT NOT NULL DEFAULT '[]',
8
+ source_metadata_json TEXT NOT NULL DEFAULT '{}',
9
+ expected_counts_json TEXT NOT NULL DEFAULT '{}',
10
+ received_counts_json TEXT NOT NULL DEFAULT '{}',
11
+ byte_totals_json TEXT NOT NULL DEFAULT '{}',
12
+ affected_workout_ids_json TEXT NOT NULL DEFAULT '[]',
13
+ error_json TEXT NOT NULL DEFAULT '{}',
14
+ started_at TEXT NOT NULL,
15
+ completed_at TEXT,
16
+ failed_at TEXT,
17
+ aborted_at TEXT,
18
+ expired_at TEXT,
19
+ created_at TEXT NOT NULL,
20
+ updated_at TEXT NOT NULL
21
+ );
22
+
23
+ CREATE INDEX IF NOT EXISTS idx_health_mobile_sync_sessions_pairing_status
24
+ ON health_mobile_sync_sessions(pairing_session_id, status, started_at DESC);
25
+
26
+ CREATE TABLE IF NOT EXISTS health_mobile_sync_chunks (
27
+ id TEXT PRIMARY KEY,
28
+ sync_session_id TEXT NOT NULL REFERENCES health_mobile_sync_sessions(id) ON DELETE CASCADE,
29
+ chunk_id TEXT NOT NULL,
30
+ sequence INTEGER NOT NULL,
31
+ family TEXT NOT NULL,
32
+ checksum_sha256 TEXT NOT NULL,
33
+ record_count INTEGER NOT NULL DEFAULT 0,
34
+ byte_count INTEGER NOT NULL DEFAULT 0,
35
+ payload_json TEXT NOT NULL DEFAULT '{}',
36
+ payload_summary_json TEXT NOT NULL DEFAULT '{}',
37
+ received_at TEXT NOT NULL,
38
+ applied_at TEXT,
39
+ created_at TEXT NOT NULL,
40
+ updated_at TEXT NOT NULL,
41
+ UNIQUE(sync_session_id, chunk_id)
42
+ );
43
+
44
+ CREATE INDEX IF NOT EXISTS idx_health_mobile_sync_chunks_session_sequence
45
+ ON health_mobile_sync_chunks(sync_session_id, sequence);
46
+
47
+ CREATE TABLE IF NOT EXISTS health_mobile_sync_family_cursors (
48
+ id TEXT PRIMARY KEY,
49
+ pairing_session_id TEXT NOT NULL REFERENCES companion_pairing_sessions(id) ON DELETE CASCADE,
50
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
51
+ family TEXT NOT NULL,
52
+ cursor_json TEXT NOT NULL DEFAULT '{}',
53
+ updated_at TEXT NOT NULL,
54
+ UNIQUE(pairing_session_id, family)
55
+ );
@@ -66,7 +66,7 @@ import { registerWebRoutes } from "./web.js";
66
66
  import { createManagerRuntime } from "./managers/runtime.js";
67
67
  import { isManagerError } from "./managers/type-guards.js";
68
68
  import { buildCompanionPairingTransport, getCompanionIrohStatus, stopCompanionIroh } from "./services/companion-iroh.js";
69
- import { createCompanionPairingSession, createCompanionPairingSessionSchema, createSleepSession, createSleepSessionSchema, createWorkoutSession, createWorkoutSessionSchema, deleteSleepSession, deleteWorkoutSession, getCompanionPairingSessionById, getCompanionOverview, getFitnessViewData, getSleepSessionById, getSleepSessionDetailById, getSleepTimelineOverlaysForRange, getSleepViewData, getVitalsViewData, getHealthZoneProfileForUser, getWorkoutSessionById, getWorkoutSessionDetailById, heartbeatCompanionPairing, heartbeatCompanionPairingSchema, healthZoneProfilePatchSchema, ingestMobileHealthSync, mobileHealthSyncSchema, patchHealthZoneProfileForUser, patchCompanionPairingSourceState, patchCompanionPairingSourceStateSchema, companionSourceKeySchema, requireValidPairing, revokeAllCompanionPairingSessions, revokeAllCompanionPairingSessionsSchema, revokeCompanionPairingSession, updateMobileCompanionSourceState, updateMobileCompanionSourceStateSchema, verifyCompanionPairing, verifyCompanionPairingSchema, updateSleepMetadata, updateSleepMetadataSchema, updateWorkoutMetadata, updateWorkoutMetadataSchema } from "./health.js";
69
+ import { createCompanionPairingSession, createCompanionPairingSessionSchema, createSleepSession, createSleepSessionSchema, createWorkoutSession, createWorkoutSessionSchema, deleteSleepSession, deleteWorkoutSession, getCompanionPairingSessionById, getCompanionOverview, getFitnessViewData, getSleepSessionById, getSleepSessionDetailById, getSleepTimelineOverlaysForRange, getSleepViewData, getVitalsViewData, getHealthZoneProfileForUser, getWorkoutSessionById, getWorkoutSessionDetailById, heartbeatCompanionPairing, heartbeatCompanionPairingSchema, healthZoneProfilePatchSchema, abortMobileHealthSyncSession, completeMobileHealthSyncSession, ingestMobileHealthSync, ingestMobileHealthSyncChunk, mobileHealthSyncChunkSchema, mobileHealthSyncSessionCompleteSchema, mobileHealthSyncSessionStartSchema, mobileHealthSyncSchema, patchHealthZoneProfileForUser, patchCompanionPairingSourceState, patchCompanionPairingSourceStateSchema, companionSourceKeySchema, requireValidPairing, startMobileHealthSyncSession, revokeAllCompanionPairingSessions, revokeAllCompanionPairingSessionsSchema, revokeCompanionPairingSession, updateMobileCompanionSourceState, updateMobileCompanionSourceStateSchema, verifyCompanionPairing, verifyCompanionPairingSchema, updateSleepMetadata, updateSleepMetadataSchema, updateWorkoutMetadata, updateWorkoutMetadataSchema } from "./health.js";
70
70
  import { analyzeMovementUserBoxPreflight, createMovementUserBox, createMovementPlace, deleteMovementUserBox, getMovementAllTimeSummary, getMovementBoxDetail, getMovementDayDetail, getMovementMobileBootstrap, getMovementTimeline, getMovementSelectionAggregate, getMovementSettings, getMovementTripDetail, getMovementMonthSummary, invalidateAutomaticMovementBox, listMovementPlaces, movementAutomaticBoxInvalidateSchema, movementMobileBootstrapSchema, movementMobilePlaceMutationSchema, movementMobileStayPatchSchema, movementMobileUserBoxCreateSchema, movementMobileUserBoxPreflightSchema, movementMobileUserBoxPatchSchema, movementMobileAutomaticBoxInvalidateSchema, movementMobileTimelineSchema, movementPlaceMutationSchema, movementPlacePatchSchema, movementSelectionAggregateSchema, movementStayPatchSchema, movementTripPatchSchema, movementUserBoxCreateSchema, movementUserBoxPreflightSchema, movementUserBoxPatchSchema, movementSettingsPatchSchema, movementTimelineQuerySchema, movementTripPointPatchSchema, deleteMovementStay, deleteMovementTrip, deleteMovementTripPoint, updateMovementPlace, updateMovementSettings, updateMovementStay, updateMovementTrip, updateMovementUserBox, updateMovementTripPoint, resolveMovementTimelineSegmentForBox } from "./movement.js";
71
71
  import { getScreenTimeAllTimeSummary, getScreenTimeDayDetail, getScreenTimeMonthSummary, getScreenTimeSettings, screenTimeSettingsPatchSchema, updateScreenTimeSettings } from "./screen-time.js";
72
72
  import { assertWatchReady, buildWatchBootstrap, ingestWatchCaptureBatch, mobileWatchBootstrapSchema, mobileWatchCaptureBatchSchema, mobileWatchHabitCheckInSchema } from "./watch-mobile.js";
@@ -2969,6 +2969,18 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
2969
2969
  "Ask about source or override reason only when that context matters."
2970
2970
  ]
2971
2971
  },
2972
+ {
2973
+ focus: "calendar_overview",
2974
+ openingQuestion: "What are you trying to understand or decide from your calendar picture?",
2975
+ coachingGoal: "Review commitments, work blocks, provider state, and existing timeboxes before creating or changing calendar records.",
2976
+ askSequence: [
2977
+ "Ask what practical calendar question the user wants the overview to answer.",
2978
+ "Ask which day, week, date range, or owner scope matters only if it changes the read.",
2979
+ "Use forge_get_calendar_overview before asking the user to reconstruct availability from memory.",
2980
+ "Reflect the scheduling or planning decision the user is trying to make.",
2981
+ "Move to calendar_event, work_block_template, task_timebox, or calendar_connection only when one concrete follow-up action is visible."
2982
+ ]
2983
+ },
2972
2984
  {
2973
2985
  focus: "calendar_connection",
2974
2986
  openingQuestion: "Which calendar provider are you trying to connect, and what do you want Forge to do with it?",
@@ -3004,6 +3016,29 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
3004
3016
  "Ask for a short audit note only if the reason would otherwise be unclear later."
3005
3017
  ]
3006
3018
  },
3019
+ {
3020
+ focus: "operator_overview",
3021
+ openingQuestion: "What are you trying to understand about Forge overall right now?",
3022
+ coachingGoal: "Read the broad Forge state before choosing a specific entity action.",
3023
+ askSequence: [
3024
+ "Ask what broad Forge question the user wants the overview to answer.",
3025
+ "Ask which owner or user scope matters only if several humans or bots are in play.",
3026
+ "Use forge_get_operator_overview before reopening a create or update intake.",
3027
+ "Reflect the attention cue, priority, or handoff decision the overview should support.",
3028
+ "Move into a specific entity flow only when the overview points to a concrete goal, project, task, habit, note, Psyche record, or action."
3029
+ ]
3030
+ },
3031
+ {
3032
+ focus: "operator_context",
3033
+ openingQuestion: "What current work, risk, or next move are you trying to check?",
3034
+ coachingGoal: "Inspect current work, active runs, risk, and next moves before changing records.",
3035
+ askSequence: [
3036
+ "Ask whether the user is checking current work, risk, blockers, active sessions, or the next move.",
3037
+ "Use forge_get_operator_context before mutating tasks, projects, runs, or notes when the current state is uncertain.",
3038
+ "Reflect whether the read is meant to decide continue, stop, reprioritize, update, or create.",
3039
+ "Move to task_run, work_adjustment, task, project, or note flow only when one concrete follow-up is visible."
3040
+ ]
3041
+ },
3007
3042
  {
3008
3043
  focus: "self_observation",
3009
3044
  openingQuestion: "What happened in the situation, and what did you feel, think, or do next?",
@@ -4592,7 +4627,8 @@ function buildAgentOnboardingPayload(request) {
4592
4627
  calendar_overview: "/api/v1/calendar/overview",
4593
4628
  operatorOverview: "/api/v1/operator/overview",
4594
4629
  operator_overview: "/api/v1/operator/overview",
4595
- operatorContext: "/api/v1/operator/context"
4630
+ operatorContext: "/api/v1/operator/context",
4631
+ operator_context: "/api/v1/operator/context"
4596
4632
  }
4597
4633
  },
4598
4634
  multiUserModel: {
@@ -6355,6 +6391,8 @@ export async function buildServer(options = {}) {
6355
6391
  app.setErrorHandler((error, request, reply) => {
6356
6392
  const validationIssues = error instanceof ZodError ? formatValidationIssues(error) : undefined;
6357
6393
  const routeUrl = request.routeOptions.url || request.url;
6394
+ const isBodyTooLarge = typeof error.code === "string" &&
6395
+ error.code === "FST_ERR_CTP_BODY_TOO_LARGE";
6358
6396
  const validationHelp = validationIssues
6359
6397
  ? buildValidationHelp(request.method, routeUrl, validationIssues)
6360
6398
  : undefined;
@@ -6364,20 +6402,24 @@ export async function buildServer(options = {}) {
6364
6402
  ? error.statusCode
6365
6403
  : error instanceof ZodError
6366
6404
  ? 400
6367
- : 500;
6405
+ : isBodyTooLarge
6406
+ ? 413
6407
+ : 500;
6368
6408
  if (!shouldSkipAutomaticDiagnosticRoute(routeUrl)) {
6369
6409
  try {
6370
6410
  recordDiagnosticLog({
6371
6411
  level: statusCode >= 500 ? "error" : "warning",
6372
6412
  source: normalizeDiagnosticSource(request.headers["x-forge-source"]),
6373
6413
  scope: "api_error",
6374
- eventKey: isHttpError(error)
6375
- ? error.code
6376
- : isManagerError(error)
6414
+ eventKey: isBodyTooLarge
6415
+ ? "payload_too_large"
6416
+ : isHttpError(error)
6377
6417
  ? error.code
6378
- : statusCode === 400
6379
- ? "invalid_request"
6380
- : "internal_error",
6418
+ : isManagerError(error)
6419
+ ? error.code
6420
+ : statusCode === 400
6421
+ ? "invalid_request"
6422
+ : "internal_error",
6381
6423
  message: getErrorMessage(error),
6382
6424
  route: routeUrl,
6383
6425
  functionName: "setErrorHandler",
@@ -6401,13 +6443,25 @@ export async function buildServer(options = {}) {
6401
6443
  ? error.code
6402
6444
  : isManagerError(error)
6403
6445
  ? error.code
6404
- : statusCode === 400
6405
- ? "invalid_request"
6406
- : "internal_error",
6446
+ : isBodyTooLarge
6447
+ ? "payload_too_large"
6448
+ : statusCode === 400
6449
+ ? "invalid_request"
6450
+ : "internal_error",
6407
6451
  error: validationIssues
6408
6452
  ? `Request validation failed for ${request.method.toUpperCase()} ${routeUrl}. ${validationHelp?.validationSummary ?? ""}`.trim()
6409
- : getErrorMessage(error),
6453
+ : isBodyTooLarge
6454
+ ? "The request body is too large. Use chunked HealthKit sync."
6455
+ : getErrorMessage(error),
6410
6456
  statusCode,
6457
+ ...(isBodyTooLarge
6458
+ ? {
6459
+ recommendedMode: "chunked",
6460
+ maxBytes: typeof request.routeOptions.bodyLimit === "number"
6461
+ ? request.routeOptions.bodyLimit
6462
+ : undefined
6463
+ }
6464
+ : {}),
6411
6465
  ...(validationIssues ? { details: validationIssues } : {}),
6412
6466
  ...(validationHelp ?? {}),
6413
6467
  ...(isHttpError(error) && error.details ? error.details : {}),
@@ -7343,7 +7397,27 @@ export async function buildServer(options = {}) {
7343
7397
  watch: buildWatchBootstrap(pairing)
7344
7398
  };
7345
7399
  });
7346
- app.post("/api/v1/mobile/healthkit/sync", async (request) => ({
7400
+ app.post("/api/v1/mobile/healthkit/sync-sessions", async (request) => ({
7401
+ upload: startMobileHealthSyncSession(mobileHealthSyncSessionStartSchema.parse(request.body ?? {}))
7402
+ }));
7403
+ app.post("/api/v1/mobile/healthkit/sync-sessions/:id/chunks", { bodyLimit: 1_250_000 }, async (request) => {
7404
+ const { id } = request.params;
7405
+ const rawPayloadJson = JSON.stringify((request.body ?? {}).payload ?? {});
7406
+ return {
7407
+ chunk: ingestMobileHealthSyncChunk(id, mobileHealthSyncChunkSchema.parse(request.body ?? {}), rawPayloadJson)
7408
+ };
7409
+ });
7410
+ app.post("/api/v1/mobile/healthkit/sync-sessions/:id/complete", async (request) => {
7411
+ const { id } = request.params;
7412
+ return {
7413
+ sync: completeMobileHealthSyncSession(id, mobileHealthSyncSessionCompleteSchema.parse(request.body ?? {}))
7414
+ };
7415
+ });
7416
+ app.delete("/api/v1/mobile/healthkit/sync-sessions/:id", async (request) => {
7417
+ const { id } = request.params;
7418
+ return { upload: abortMobileHealthSyncSession(id) };
7419
+ });
7420
+ app.post("/api/v1/mobile/healthkit/sync", { bodyLimit: 8_000_000 }, async (request) => ({
7347
7421
  sync: ingestMobileHealthSync(mobileHealthSyncSchema.parse(request.body ?? {}))
7348
7422
  }));
7349
7423
  app.patch("/api/v1/health/workouts/:id", async (request, reply) => {
@@ -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,415 @@ 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
+ export function completeMobileHealthSyncSession(syncSessionId, payload) {
3004
+ const parsed = mobileHealthSyncSessionCompleteSchema.parse(payload);
3005
+ const session = ensureRunningMobileSyncSession(syncSessionId);
3006
+ const chunks = getDatabase()
3007
+ .prepare(`SELECT * FROM health_mobile_sync_chunks
3008
+ WHERE sync_session_id = ?
3009
+ ORDER BY sequence ASC`)
3010
+ .all(syncSessionId);
3011
+ if (chunks.length === 0) {
3012
+ throw new HttpError(409, "missing_required_chunks", "The HealthKit sync session has no accepted chunks.");
3013
+ }
3014
+ const pairing = getDatabase()
3015
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
3016
+ .get(session.pairing_session_id);
3017
+ try {
3018
+ const { assembled, tombstones } = mergeMobileHealthSyncChunks(session, chunks);
3019
+ const sync = ingestMobileHealthSync(assembled);
3020
+ const deletedWorkoutCount = applyWorkoutTombstones(pairing, tombstones);
3021
+ upsertMobileSyncFamilyCursors(pairing, parsed.finalCursor);
3022
+ const now = nowIso();
3023
+ getDatabase()
3024
+ .prepare(`UPDATE health_mobile_sync_sessions
3025
+ SET status = 'completed', expected_counts_json = ?, completed_at = ?,
3026
+ updated_at = ?
3027
+ WHERE id = ?`)
3028
+ .run(JSON.stringify(parsed.expectedCounts), now, now, syncSessionId);
3029
+ return {
3030
+ ...sync,
3031
+ upload: {
3032
+ syncSessionId,
3033
+ chunks: chunks.length,
3034
+ deletedWorkoutCount
3035
+ }
3036
+ };
3037
+ }
3038
+ catch (error) {
3039
+ const now = nowIso();
3040
+ getDatabase()
3041
+ .prepare(`UPDATE health_mobile_sync_sessions
3042
+ SET status = 'failed', failed_at = ?, error_json = ?, updated_at = ?
3043
+ WHERE id = ?`)
3044
+ .run(now, JSON.stringify({
3045
+ message: error instanceof Error ? error.message : String(error)
3046
+ }), now, syncSessionId);
3047
+ throw error;
3048
+ }
3049
+ }
3050
+ export function abortMobileHealthSyncSession(syncSessionId) {
3051
+ const session = ensureRunningMobileSyncSession(syncSessionId);
3052
+ const now = nowIso();
3053
+ getDatabase()
3054
+ .prepare(`UPDATE health_mobile_sync_sessions
3055
+ SET status = 'aborted', aborted_at = ?, updated_at = ?
3056
+ WHERE id = ?`)
3057
+ .run(now, now, session.id);
3058
+ return { syncSessionId, status: "aborted" };
3059
+ }
2565
3060
  export function ingestMobileHealthSync(payload) {
2566
3061
  const parsed = mobileHealthSyncSchema.parse(payload);
2567
3062
  ensureLegacyAppleSleepHistoryRepaired();
@@ -2,7 +2,7 @@
2
2
  "id": "forge-openclaw-plugin",
3
3
  "name": "Forge",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
- "version": "0.2.69",
5
+ "version": "0.2.70",
6
6
  "activation": {
7
7
  "onStartup": true,
8
8
  "onCapabilities": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "forge-openclaw-plugin",
3
- "version": "0.2.69",
3
+ "version": "0.2.70",
4
4
  "description": "Curated OpenClaw adapter for the Forge collaboration API, UI entrypoint, and localhost auto-start runtime.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -105,6 +105,7 @@
105
105
  "follow-redirects": "^1.16.0",
106
106
  "hono": "^4.12.18",
107
107
  "ip-address": "^10.2.0",
108
+ "ws": "^8.20.1",
108
109
  "uuid": "^14.0.0"
109
110
  },
110
111
  "scripts": {
@@ -0,0 +1,55 @@
1
+ CREATE TABLE IF NOT EXISTS health_mobile_sync_sessions (
2
+ id TEXT PRIMARY KEY,
3
+ pairing_session_id TEXT NOT NULL REFERENCES companion_pairing_sessions(id) ON DELETE CASCADE,
4
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5
+ status TEXT NOT NULL DEFAULT 'running',
6
+ schema_version TEXT NOT NULL DEFAULT 'healthkit-sync-v2',
7
+ requested_families_json TEXT NOT NULL DEFAULT '[]',
8
+ source_metadata_json TEXT NOT NULL DEFAULT '{}',
9
+ expected_counts_json TEXT NOT NULL DEFAULT '{}',
10
+ received_counts_json TEXT NOT NULL DEFAULT '{}',
11
+ byte_totals_json TEXT NOT NULL DEFAULT '{}',
12
+ affected_workout_ids_json TEXT NOT NULL DEFAULT '[]',
13
+ error_json TEXT NOT NULL DEFAULT '{}',
14
+ started_at TEXT NOT NULL,
15
+ completed_at TEXT,
16
+ failed_at TEXT,
17
+ aborted_at TEXT,
18
+ expired_at TEXT,
19
+ created_at TEXT NOT NULL,
20
+ updated_at TEXT NOT NULL
21
+ );
22
+
23
+ CREATE INDEX IF NOT EXISTS idx_health_mobile_sync_sessions_pairing_status
24
+ ON health_mobile_sync_sessions(pairing_session_id, status, started_at DESC);
25
+
26
+ CREATE TABLE IF NOT EXISTS health_mobile_sync_chunks (
27
+ id TEXT PRIMARY KEY,
28
+ sync_session_id TEXT NOT NULL REFERENCES health_mobile_sync_sessions(id) ON DELETE CASCADE,
29
+ chunk_id TEXT NOT NULL,
30
+ sequence INTEGER NOT NULL,
31
+ family TEXT NOT NULL,
32
+ checksum_sha256 TEXT NOT NULL,
33
+ record_count INTEGER NOT NULL DEFAULT 0,
34
+ byte_count INTEGER NOT NULL DEFAULT 0,
35
+ payload_json TEXT NOT NULL DEFAULT '{}',
36
+ payload_summary_json TEXT NOT NULL DEFAULT '{}',
37
+ received_at TEXT NOT NULL,
38
+ applied_at TEXT,
39
+ created_at TEXT NOT NULL,
40
+ updated_at TEXT NOT NULL,
41
+ UNIQUE(sync_session_id, chunk_id)
42
+ );
43
+
44
+ CREATE INDEX IF NOT EXISTS idx_health_mobile_sync_chunks_session_sequence
45
+ ON health_mobile_sync_chunks(sync_session_id, sequence);
46
+
47
+ CREATE TABLE IF NOT EXISTS health_mobile_sync_family_cursors (
48
+ id TEXT PRIMARY KEY,
49
+ pairing_session_id TEXT NOT NULL REFERENCES companion_pairing_sessions(id) ON DELETE CASCADE,
50
+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
51
+ family TEXT NOT NULL,
52
+ cursor_json TEXT NOT NULL DEFAULT '{}',
53
+ updated_at TEXT NOT NULL,
54
+ UNIQUE(pairing_session_id, family)
55
+ );
@@ -185,11 +185,16 @@ Health rule:
185
185
 
186
186
  - Sleep and sports records are first-class health surfaces, not generic notes or tasks.
187
187
  - Use `forge_get_sleep_overview` and `forge_get_sports_overview` for review and trend reading.
188
- - In `forge_get_agent_onboarding.entityRouteModel.readModelOnlySurfaces`, the health
189
- overview routes are published under both the plain names `sleepOverview` and
190
- `sportsOverview` and the entity-style aliases `sleep_overview` and
191
- `sports_overview`. Treat those as read-only overview surfaces, not batch CRUD
192
- entities.
188
+ - In `forge_get_agent_onboarding.entityRouteModel.readModelOnlySurfaces`, operator,
189
+ calendar, self-observation, sleep, and sports read models are published with
190
+ both camelCase names and entity-style aliases where useful, including
191
+ `operatorOverview`, `operatorContext`, `calendarOverview`, `sleepOverview`,
192
+ `sportsOverview`, `operator_overview`, `operator_context`,
193
+ `calendar_overview`, `self_observation`, `sleep_overview`, and
194
+ `sports_overview`. Treat those as read-only surfaces, not batch CRUD entities.
195
+ - Use `forge_get_operator_overview` for a broad Forge status read, `forge_get_operator_context`
196
+ for current work and risk, and `forge_get_calendar_overview` before calendar-aware
197
+ planning or scheduling mutations.
193
198
  - Use the shared batch entity tools for ordinary `sleep_session` and `workout_session` create, update, delete, and search work. Do not force agents into a large one-route-per-entity mental model when the batch routes already cover the record cleanly.
194
199
  - Use `forge_update_sleep_session` and `forge_update_workout_session` only when the job is reflective enrichment on one existing health record after review, such as attaching notes, tags, mood, meaning, or Forge links.
195
200
  - Habit-generated workouts and imported HealthKit workouts belong to the same workout record model, so do not invent a separate storage path for sport sessions.
@@ -237,10 +237,11 @@ Use this quick split before the conversation gets too detailed.
237
237
  `preference_signal`, and `self_observation` are action workflows. Start from what
238
238
  the user is trying to do, then use the dedicated action tool or note-backed write
239
239
  model.
240
- - `sleep_overview` and `sports_overview` are read-model-only surfaces. Use them when
241
- the user wants to review nights, workouts, training load, recovery context, or
242
- health patterns before deciding whether one stored `sleep_session` or
243
- `workout_session` needs enrichment.
240
+ - `operator_overview`, `operator_context`, `calendar_overview`, `sleep_overview`,
241
+ and `sports_overview` are read-model-only surfaces. Use them when the user wants
242
+ to understand current Forge state, work risk, calendar commitments, nights,
243
+ workouts, training load, recovery context, or health patterns before deciding
244
+ whether a stored entity needs creation or enrichment.
244
245
  - Movement, Life Force, and Workbench are specialized domain areas. Use their
245
246
  dedicated route families for timelines and overlays, energy profile/templates and
246
247
  fatigue signals, and Workbench flow execution or result artifacts. When available,
@@ -282,6 +283,19 @@ still knowing the exact write/read family before it acts.
282
283
  - `calendar_connection`: specialized CRUD. Use provider discovery, connection CRUD,
283
284
  selected-calendar rediscovery, sync, and delete routes rather than batch entity
284
285
  tools.
286
+ - `operator_overview`: read-model-only operator surface. Use
287
+ `forge_get_operator_overview` or `/api/v1/operator/overview` when the user wants
288
+ the current Forge picture, attention cues, or broad status before choosing a
289
+ specific entity action.
290
+ - `operator_context`: read-model-only operator surface. Use
291
+ `forge_get_operator_context` or `/api/v1/operator/context` when the user wants
292
+ current work, active runs, risks, board context, or next moves before mutating
293
+ anything.
294
+ - `calendar_overview`: read-model-only calendar surface. Use
295
+ `forge_get_calendar_overview` or `/api/v1/calendar/overview` when the user wants
296
+ mirrored events, work blocks, timeboxes, provider state, or availability before
297
+ creating a `calendar_event`, `work_block_template`, `task_timebox`, or
298
+ `calendar_connection`.
285
299
  - `task_run`: action workflow. Use task-run start, heartbeat, focus, complete, and
286
300
  release routes; never treat status changes as proof of live work.
287
301
  - `work_adjustment`: action workflow. Use the signed work-adjustment route for real
@@ -1061,6 +1075,81 @@ Preferred opening question:
1061
1075
 
1062
1076
  - "Which task or project should this time correction belong to?"
1063
1077
 
1078
+ ## Operator Overview
1079
+
1080
+ Aim: read the broad Forge state before choosing a specific action, without turning a
1081
+ status check into generic intake.
1082
+
1083
+ Arc:
1084
+
1085
+ 1. Ask what the user is trying to understand about Forge overall.
1086
+ 2. Read the operator overview before asking the user to reconstruct active work,
1087
+ attention cues, or broad status from memory.
1088
+ 3. Reflect the practical decision the overview should support.
1089
+ 4. Move into a specific entity flow only when the overview points to a concrete goal,
1090
+ project, task, habit, note, Psyche record, or follow-up action.
1091
+
1092
+ Helpful follow-up lanes:
1093
+
1094
+ - whether the user wants a broad status read, a priority decision, or a handoff
1095
+ - which owner or user scope matters if several humans or bots are involved
1096
+ - what decision the overview should help them make next
1097
+
1098
+ Route note:
1099
+
1100
+ - `operator_overview` is a read-model-only operator surface. Use
1101
+ `forge_get_operator_overview` or `/api/v1/operator/overview`; do not create,
1102
+ update, or delete `operator_overview` through batch CRUD.
1103
+ - If the read reveals a specific record that needs work, switch to that record's
1104
+ normal route posture after the user chooses the follow-up.
1105
+
1106
+ Ready to review when:
1107
+
1108
+ - the broad question is clear enough
1109
+ - any owner or user scope that changes the read is clear enough
1110
+
1111
+ Preferred opening question:
1112
+
1113
+ - "What are you trying to understand about Forge overall right now?"
1114
+
1115
+ ## Operator Context
1116
+
1117
+ Aim: inspect current work, active runs, risk, and next moves before changing records.
1118
+
1119
+ Arc:
1120
+
1121
+ 1. Ask whether the user is checking current work, risk, blockers, active sessions, or
1122
+ the next move.
1123
+ 2. Read operator context before reopening a create or update intake.
1124
+ 3. Reflect what the read is meant to decide: continue, stop, reprioritize, update, or
1125
+ create.
1126
+ 4. Move to task-run, work-adjustment, task, project, or note flow only when one
1127
+ concrete follow-up is visible.
1128
+
1129
+ Helpful follow-up lanes:
1130
+
1131
+ - current task or active run
1132
+ - blocked or stale work
1133
+ - next move versus broad review
1134
+ - owner or user scope when bot and human work are both present
1135
+
1136
+ Route note:
1137
+
1138
+ - `operator_context` is a read-model-only operator surface. Use
1139
+ `forge_get_operator_context` or `/api/v1/operator/context`; do not mutate it
1140
+ through batch CRUD.
1141
+ - If the user decides to start, complete, release, or adjust work after the read,
1142
+ switch to the dedicated action workflow for that operation.
1143
+
1144
+ Ready to review when:
1145
+
1146
+ - the current-work question is clear
1147
+ - any user or owner scope is clear enough
1148
+
1149
+ Preferred opening question:
1150
+
1151
+ - "What current work, risk, or next move are you trying to check?"
1152
+
1064
1153
  ## Self Observation
1065
1154
 
1066
1155
  Aim: capture one observed episode in a structured chain without letting a loose note
@@ -1251,6 +1340,46 @@ Preferred opening question:
1251
1340
 
1252
1341
  - "What are you trying to understand from your workout picture right now?"
1253
1342
 
1343
+ ## Calendar Overview
1344
+
1345
+ Aim: review commitments, work blocks, provider state, and existing timeboxes before
1346
+ creating or changing calendar records.
1347
+
1348
+ Arc:
1349
+
1350
+ 1. Ask what the user is trying to understand or decide from the calendar picture.
1351
+ 2. Ask for the date range or owner scope only if it changes the read.
1352
+ 3. Read the calendar overview before asking the user to recreate availability from
1353
+ memory.
1354
+ 4. Reflect the practical decision the overview should support.
1355
+ 5. Move to `calendar_event`, `work_block_template`, `task_timebox`, or
1356
+ `calendar_connection` only when a specific follow-up action is visible.
1357
+
1358
+ Helpful follow-up lanes:
1359
+
1360
+ - which day, week, or date range matters
1361
+ - whether the question is availability, conflicts, provider health, work blocks, or
1362
+ existing timeboxes
1363
+ - what scheduling or planning decision the review should support
1364
+
1365
+ Route note:
1366
+
1367
+ - `calendar_overview` is a read-model-only calendar surface. Use
1368
+ `forge_get_calendar_overview` or `/api/v1/calendar/overview`; do not create,
1369
+ update, or delete `calendar_overview` through batch CRUD.
1370
+ - If the review reveals a concrete scheduling action, switch to the stored
1371
+ `calendar_event`, `work_block_template`, or `task_timebox` batch route, or to the
1372
+ specialized `calendar_connection` lifecycle route.
1373
+
1374
+ Ready to review when:
1375
+
1376
+ - the user's practical calendar question is clear
1377
+ - the relevant date range or owner scope is clear enough
1378
+
1379
+ Preferred opening question:
1380
+
1381
+ - "What are you trying to understand or decide from your calendar picture?"
1382
+
1254
1383
  ## Calendar Connection
1255
1384
 
1256
1385
  Aim: connect the right provider deliberately without turning setup into a credential