forge-openclaw-plugin 0.2.73 → 0.2.80

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/index.html CHANGED
@@ -13,14 +13,14 @@
13
13
  />
14
14
  <link rel="icon" type="image/png" href="/forge/assets/favicon-BCHm9dUV.ico" />
15
15
  <link rel="alternate icon" href="/forge/assets/favicon-BCHm9dUV.ico" />
16
- <script type="module" crossorigin src="/forge/assets/index-clNilMKr.js"></script>
17
- <link rel="modulepreload" crossorigin href="/forge/assets/vendor-fiXu5f59.js">
18
- <link rel="modulepreload" crossorigin href="/forge/assets/board-B0TuXl4u.js">
19
- <link rel="modulepreload" crossorigin href="/forge/assets/ui-JBdCP1Qb.js">
20
- <link rel="modulepreload" crossorigin href="/forge/assets/motion-Dmjq6HPm.js">
21
- <link rel="modulepreload" crossorigin href="/forge/assets/table-CKKimYN1.js">
16
+ <script type="module" crossorigin src="/forge/assets/index-DEoJdpz5.js"></script>
17
+ <link rel="modulepreload" crossorigin href="/forge/assets/vendor-BVU0cZC9.js">
18
+ <link rel="modulepreload" crossorigin href="/forge/assets/board-DKxKOwax.js">
19
+ <link rel="modulepreload" crossorigin href="/forge/assets/ui-3Wd4pVaA.js">
20
+ <link rel="modulepreload" crossorigin href="/forge/assets/motion-CM4AfIqo.js">
21
+ <link rel="modulepreload" crossorigin href="/forge/assets/table-BUeQ9wzR.js">
22
22
  <link rel="stylesheet" crossorigin href="/forge/assets/vendor-B-Lq_OG3.css">
23
- <link rel="stylesheet" crossorigin href="/forge/assets/index-DtT3Y-Bj.css">
23
+ <link rel="stylesheet" crossorigin href="/forge/assets/index-Chc9CWm5.css">
24
24
  </head>
25
25
  <body class="bg-canvas text-ink antialiased">
26
26
  <div id="root"></div>
@@ -2895,7 +2895,7 @@ const AGENT_ONBOARDING_ENTITY_CATALOG = [
2895
2895
  searchHints: [
2896
2896
  "Clarify whether the user wants flow discovery, editing, execution, published output, run inspection, or node-level output before choosing the route.",
2897
2897
  "Distinguish flow contract, published output, run history, latest-node-output, and chat follow-up questions before reaching for a route.",
2898
- "If the user is still deciding how to run or edit a flow, read flow detail or the box catalog before asking them for payload structure."
2898
+ "If the user is still deciding how to run or edit a flow, read flow detail or the box catalog before asking them for structured input details."
2899
2899
  ],
2900
2900
  fieldGuide: []
2901
2901
  }),
@@ -2969,7 +2969,7 @@ const AGENT_ONBOARDING_CONVERSATION_RULES = [
2969
2969
  "Do not minimize functional analysis, trigger chains, behavior patterns, modes, beliefs, or schema themes. After at least one concrete example is clear, offer one careful interpretive hypothesis when it would help the user understand what the reaction may be protecting, predicting, relieving, or costing.",
2970
2970
  "Phrase Psyche interpretive hypotheses as collaborative and testable, not as verdicts. Ask whether the hypothesis lands or needs correction before turning it into a saved belief, pattern, mode, trigger report, or note.",
2971
2971
  "Once the Movement, Life Force, or Workbench job is clear, speak in product nouns such as timeline, overlay, weekday template, published output, run detail, or node result instead of generic record language.",
2972
- "If the next answer would not change the route, wording, timing, or write payload in a meaningful way, stop asking and act.",
2972
+ "If the next answer would not change the route, wording, timing, or write shape in a meaningful way, stop asking and act.",
2973
2973
  "Before saving, briefly summarize the working formulation in the user's own language when that would reduce ambiguity.",
2974
2974
  "Once the record is clear enough to name, stop exploring broadly and ask only for the last structural detail that still matters.",
2975
2975
  "If the record is already clear enough to save, save it instead of performing a ceremonial extra question.",
@@ -2980,7 +2980,7 @@ const AGENT_ONBOARDING_CONVERSATION_RULES = [
2980
2980
  "When the user already named a precise correction or review target, do not widen back out into a meta lane question. Confirm only the missing route-selecting detail and then act.",
2981
2981
  "Once the route family is clear, say it plainly enough that another agent could follow the same path without guessing.",
2982
2982
  "For Movement specifically, treat missing-data corrections as user-defined overlay boxes unless the user is editing an already-recorded stay or trip. When the user already gave a clear instruction like 'that missing block was home', act after only the last ambiguity is resolved.",
2983
- "For action workflows such as task_run, work_adjustment, questionnaire_run, preference_judgment, preference_signal, and self_observation, keep the question focused on the missing action payload and do not downgrade the request into generic batch CRUD.",
2983
+ "For action workflows such as task_run, work_adjustment, questionnaire_run, preference_judgment, preference_signal, and self_observation, keep the question focused on the missing action detail and do not downgrade the request into generic batch CRUD.",
2984
2984
  "For read-model-only health surfaces such as sleep_overview and sports_overview, use the dedicated overview reads first when the user wants review, pattern interpretation, recovery context, or training-load context. Move to sleep_session or workout_session writes only after one specific stored session needs enrichment.",
2985
2985
  "For normal stored Preferences and questionnaire records, use batch CRUD by default; switch to dedicated action routes only for judgments, signals, run answers, clone/draft/publish lifecycle, or visual comparison gameplay.",
2986
2986
  "When the user wants to remember a book, article, paper, source, concept, person, conversation, project reference, recurring explanation, or personal manual, consider wiki_page before note or self_observation.",
@@ -3344,26 +3344,28 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
3344
3344
  },
3345
3345
  {
3346
3346
  focus: "questionnaire_instrument",
3347
- openingQuestion: "What would this questionnaire help someone notice or track?",
3348
- coachingGoal: "Clarify whether the user is authoring a reusable questionnaire and what the instrument is for.",
3347
+ openingQuestion: "What honest moment or decision should this questionnaire help someone notice or track?",
3348
+ coachingGoal: "Clarify whether the user is authoring a reusable questionnaire and what honest moment, pattern, or decision the instrument should help someone notice.",
3349
3349
  askSequence: [
3350
- "Ask what the questionnaire is meant to measure or surface.",
3350
+ "Ask what honest moment, pattern, or decision the questionnaire should help someone notice.",
3351
3351
  "Ask who it is for and when it should be used.",
3352
- "Ask what kind of honest moment or decision it should help someone answer before getting into item wording.",
3353
- "Reflect the practical use case back in plain language.",
3352
+ "Ask what the respondent should understand after answering that they might otherwise miss.",
3353
+ "Reflect the practical use case back in plain language before asking for item wording.",
3354
3354
  "Ask what would make the instrument distinct instead of redundant if a near-duplicate risk is visible.",
3355
+ "Ask about item shape, response scale, scoring, or provenance only after the purpose and use context are steady.",
3355
3356
  "Use batch CRUD for ordinary questionnaire instrument create or update work; use clone, draft, and publish actions only for version lifecycle.",
3356
3357
  "Move to draft creation once the purpose is clear."
3357
3358
  ]
3358
3359
  },
3359
3360
  {
3360
3361
  focus: "questionnaire_run",
3361
- openingQuestion: "Do you want to start, continue, review, or finish a questionnaire run?",
3362
- coachingGoal: "Clarify whether the user wants to start, continue, or complete one answer session.",
3362
+ openingQuestion: "What do you want from this questionnaire run right now: start, continue, review, or finish it?",
3363
+ coachingGoal: "Clarify whether the user wants to start, continue, review, or complete one answer session without turning the run into a mechanical form fill.",
3363
3364
  askSequence: [
3364
3365
  "Ask what the user wants from the run right now: start, continue, review, or finish.",
3365
3366
  "Ask which questionnaire or existing run this is about.",
3366
3367
  "If the user wants to continue or finish, ask what feels most stuck, unfinished, or important before asking for more content.",
3368
+ "If the user is reviewing answers, ask what the run should help them understand before proposing edits or completion.",
3367
3369
  "Use the dedicated questionnaire run start, read, update, and complete routes instead of generic entity CRUD.",
3368
3370
  "If answering is still in progress, ask only for the next answer or note that matters."
3369
3371
  ]
@@ -3427,7 +3429,7 @@ const AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS = [
3427
3429
  "If the user wants to send a follow-up into a saved flow chat, confirm the saved flow and what the message should accomplish instead of treating it as a new run or note.",
3428
3430
  "If the user already named the flow and action clearly, skip the meta lane question and ask only for the missing run, node, or output scope.",
3429
3431
  "If the user wants a stable public input contract or published output, prefer those dedicated reads instead of detouring through run history first.",
3430
- "If the user is still shaping a payload or edit, prefer flow detail or box catalog reads before asking for structured inputs.",
3432
+ "If the user is still shaping one-off inputs or an edit, prefer flow detail or box catalog reads before asking for structured inputs.",
3431
3433
  "If the user is debugging one failed run, ask whether the useful artifact is the run summary, one node result, the latest node output, or the published output before you start asking for edits.",
3432
3434
  "Prefer flow detail or published-output reads for stable contracts, and use run or node-result routes only when the user is asking about execution history or debugging.",
3433
3435
  "Route to the dedicated workbench route family once the execution lane is clear."
@@ -4474,7 +4476,7 @@ function buildAgentOnboardingPayload(request) {
4474
4476
  taskTimebox: "A planned or live calendar slot tied to a task. Timeboxes can be suggested in advance or created automatically from active task runs.",
4475
4477
  workAdjustment: "A work adjustment is a truthful signed minute correction on an existing task or project when real work happened but no live run was active.",
4476
4478
  movement: "Forge Movement is the first-class mobility surface. It is a timeline of stays and trips: stays capture time spent in the same place, and trips capture travel between places. Use it for time-in-place questions, travel-history review, specific stay or trip edits, selected-span aggregates, known places, and links to other Forge records rather than pretending stays and trips are normal batch CRUD entities.",
4477
- lifeForce: "Life Force is Forge's energy-budget and fatigue model. Read it through the dedicated life-force payload and update it through focused profile, weekday-template, and fatigue-signal routes rather than generic entity CRUD.",
4479
+ lifeForce: "Life Force is Forge's energy-budget and fatigue model. Read it through the dedicated life-force state and update it through focused profile, weekday-template, and fatigue-signal routes rather than generic entity CRUD.",
4478
4480
  workbench: "Workbench is Forge's graph-flow execution system. Treat flows, runs, published outputs, node results, and latest-node-output reads as a dedicated API family instead of a normal entity-batch surface.",
4479
4481
  psyche: "Forge Psyche is the reflective domain for values, patterns, behaviors, beliefs, modes, flashcards, and trigger reports. It is sensitive and should be handled deliberately."
4480
4482
  },
@@ -4491,7 +4493,10 @@ function buildAgentOnboardingPayload(request) {
4491
4493
  emotionDefinition: "An emotion definition is reusable emotion vocabulary for trigger reports. Reports can either reference one or fall back to raw labels.",
4492
4494
  triggerReport: "A trigger report is the one-episode incident chain: situation, emotions, thoughts, behaviors, consequences, extra mode labels, schema themes, and next moves."
4493
4495
  },
4494
- psycheCoachingPlaybooks: AGENT_ONBOARDING_PSYCHE_PLAYBOOKS.map(enrichConversationPlaybookWithRouteInfo),
4496
+ psycheCoachingPlaybooks: AGENT_ONBOARDING_PSYCHE_PLAYBOOKS.map((playbook) => enrichConversationPlaybookWithRouteInfo({
4497
+ ...playbook,
4498
+ openingQuestion: playbook.exampleQuestions[0] ?? playbook.askSequence[0] ?? ""
4499
+ })),
4495
4500
  conversationRules: AGENT_ONBOARDING_CONVERSATION_RULES,
4496
4501
  entityConversationPlaybooks: AGENT_ONBOARDING_ENTITY_CONVERSATION_PLAYBOOKS.map(enrichConversationPlaybookWithRouteInfo),
4497
4502
  relationshipModel: [
@@ -4637,6 +4642,31 @@ function buildAgentOnboardingPayload(request) {
4637
4642
  classification: "specialized_domain_surface",
4638
4643
  aliases: ["movement", "Movement"],
4639
4644
  summary: "Dedicated movement workspace API. Use these routes for stays, trips, time-in-place questions, visited places, trip detail, selection aggregates, user-defined overlays, and repair actions on already-recorded movement data.",
4645
+ routeKeys: [
4646
+ "day",
4647
+ "month",
4648
+ "allTime",
4649
+ "timeline",
4650
+ "places",
4651
+ "boxDetail",
4652
+ "tripDetail",
4653
+ "selection",
4654
+ "settings",
4655
+ "settingsUpdate",
4656
+ "placeCreate",
4657
+ "placeUpdate",
4658
+ "userBoxCreate",
4659
+ "userBoxPreflight",
4660
+ "userBoxUpdate",
4661
+ "userBoxDelete",
4662
+ "automaticBoxInvalidate",
4663
+ "stayUpdate",
4664
+ "stayDelete",
4665
+ "tripUpdate",
4666
+ "tripDelete",
4667
+ "tripPointUpdate",
4668
+ "tripPointDelete"
4669
+ ],
4640
4670
  routeSelectionQuestions: [
4641
4671
  "Is the user asking for a day, month, all-time, timeline, place, trip detail, selected-span, or settings answer?",
4642
4672
  "Is this a missing-gap overlay, a saved-overlay repair, or an edit to one already-recorded stay, trip, or trip point?",
@@ -4709,6 +4739,7 @@ function buildAgentOnboardingPayload(request) {
4709
4739
  classification: "specialized_domain_surface",
4710
4740
  aliases: ["life_force", "life-force", "Life Force"],
4711
4741
  summary: "Dedicated life-force API. Use it to read the current energy budget, drains, recommendations, and warnings, then patch only the parts that are meant to be user-controlled.",
4742
+ routeKeys: ["overview", "profile", "weekdayTemplate", "fatigueSignal"],
4712
4743
  routeSelectionQuestions: [
4713
4744
  "Is the user trying to understand the overview, change durable profile assumptions, change a weekday curve, or log a right-now fatigue signal?",
4714
4745
  "Are they describing a repeatable weekly shape or a one-off current state?",
@@ -4742,6 +4773,7 @@ function buildAgentOnboardingPayload(request) {
4742
4773
  classification: "specialized_domain_surface",
4743
4774
  aliases: ["lifeForce", "life-force", "Life Force"],
4744
4775
  summary: "Alias for the dedicated Life Force API keyed to the entity-style name `life_force`. Use the same overview, profile, weekday-template, and fatigue-signal routes as `lifeForce`.",
4776
+ routeKeys: ["overview", "profile", "weekdayTemplate", "fatigueSignal"],
4745
4777
  routeSelectionQuestions: [
4746
4778
  "Is the user trying to understand the overview, change durable profile assumptions, change a weekday curve, or log a right-now fatigue signal?",
4747
4779
  "Are they describing a repeatable weekly shape or a one-off current state?",
@@ -4776,6 +4808,24 @@ function buildAgentOnboardingPayload(request) {
4776
4808
  classification: "specialized_domain_surface",
4777
4809
  aliases: ["workbench", "Workbench"],
4778
4810
  summary: "Dedicated graph-flow API. Use it for flow catalog reads, flow CRUD, execution, run history, published outputs, node results, and latest successful node outputs.",
4811
+ routeKeys: [
4812
+ "listFlows",
4813
+ "flowById",
4814
+ "flowBySlug",
4815
+ "publishedOutput",
4816
+ "runs",
4817
+ "runDetail",
4818
+ "runNodes",
4819
+ "nodeResult",
4820
+ "latestNodeOutput",
4821
+ "boxCatalog",
4822
+ "createFlow",
4823
+ "updateFlow",
4824
+ "deleteFlow",
4825
+ "runFlow",
4826
+ "runByPayload",
4827
+ "chatFlow"
4828
+ ],
4779
4829
  routeSelectionQuestions: [
4780
4830
  "Is the job flow discovery, flow creation, flow editing, flow deletion, execution, run history, published output, run detail, node result, latest node output, or flow chat follow-up?",
4781
4831
  "Does the user need a stable public contract or one execution artifact?",
@@ -4825,8 +4875,8 @@ function buildAgentOnboardingPayload(request) {
4825
4875
  "Workbench is a dedicated execution surface, not a batch CRUD entity family.",
4826
4876
  "Route-selection questions are internal. User-facing questions should ask whether the user needs the saved flow, its input contract, one run, one node, or the public result instead of reciting Workbench route keys.",
4827
4877
  "Use the flow routes when the agent needs stable public input contracts, published outputs, node-level results, or reusable execution history.",
4828
- "If the user is still figuring out inputs or editable structure, read flow detail or box catalog before asking them to author a payload from memory.",
4829
- "For flow creation, clarify what the flow should reliably produce, which input contract it should accept, and which first node or box anchors the flow before asking for structured payload details.",
4878
+ "If the user is still figuring out inputs or editable structure, read flow detail or box catalog before asking them to reconstruct structured inputs from memory.",
4879
+ "For flow creation, clarify what the flow should reliably produce, which input contract it should accept, and which first node or box anchors the flow before asking for structured input details.",
4830
4880
  "For flow edits, ask what behavior should change while preserving the public contract unless the user explicitly wants the contract changed.",
4831
4881
  "For flow deletion, confirm the saved flow and whether published outputs or run history need preservation elsewhere before using the delete route.",
4832
4882
  "For saved flow chat follow-ups, use POST /api/v1/workbench/flows/:id/chat only when the user wants to continue a flow-specific conversation. Do not turn that into a new run, note, or generic entity update unless the user asks.",
@@ -4891,8 +4941,8 @@ function buildAgentOnboardingPayload(request) {
4891
4941
  ],
4892
4942
  verifyCommands: [
4893
4943
  `curl -s ${origin}/api/v1/health`,
4894
- "openclaw plugins install ./projects/forge/openclaw-plugin",
4895
- "openclaw plugins info forge-openclaw-plugin",
4944
+ "openclaw plugins install --link --dangerously-force-unsafe-install ./projects/forge/openclaw-plugin",
4945
+ "openclaw plugins inspect forge-openclaw-plugin --runtime",
4896
4946
  "openclaw gateway restart",
4897
4947
  "openclaw forge onboarding",
4898
4948
  "openclaw forge health"
@@ -4900,7 +4950,7 @@ function buildAgentOnboardingPayload(request) {
4900
4950
  configNotes: [
4901
4951
  "Localhost and Tailscale targets can usually use the operator-session path without a long-lived token.",
4902
4952
  "The operator-session route is /api/v1/auth/operator-session, so trusted local OpenClaw onboarding does not need a browser confirmation step.",
4903
- "If your current OpenClaw build blocks the repo-local install because of the package scanner, keep the repo folder on plugins.load.paths and verify that plugins info still points at the local Forge source path before continuing.",
4953
+ "If your current OpenClaw build blocks the repo-local install because of the package scanner, keep the repo folder on plugins.load.paths and verify that plugins inspect --runtime still points at the local Forge source path before continuing.",
4904
4954
  "Use a distinct actor label such as Albert (claw) so OpenClaw-originated work stays obvious in Forge provenance.",
4905
4955
  "Create each agent as a Forge bot user, then use userId or userIds in tool inputs whenever the agent should focus on one human, one bot, or a specific collaboration slice.",
4906
4956
  "If you genuinely need a durable managed token, create it through /api/v1/settings/tokens instead of sending the operator into the Settings UI."
@@ -7620,7 +7670,7 @@ export async function buildServer(options = {}) {
7620
7670
  app.post("/api/v1/mobile/healthkit/sync-sessions", async (request) => ({
7621
7671
  upload: startMobileHealthSyncSession(mobileHealthSyncSessionStartSchema.parse(request.body ?? {}))
7622
7672
  }));
7623
- app.post("/api/v1/mobile/healthkit/sync-sessions/:id/chunks", { bodyLimit: 1_600_000 }, async (request) => {
7673
+ app.post("/api/v1/mobile/healthkit/sync-sessions/:id/chunks", { bodyLimit: 40_000_000 }, async (request) => {
7624
7674
  const { id } = request.params;
7625
7675
  const rawPayloadJson = JSON.stringify((request.body ?? {}).payload ?? {});
7626
7676
  return {
@@ -1,4 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
+ import { inflateSync } from "node:zlib";
2
3
  import { z } from "zod";
3
4
  import { getDatabase, runInTransaction } from "./db.js";
4
5
  import { HttpError } from "./errors.js";
@@ -260,11 +261,13 @@ export const mobileHealthSyncSchema = z.object({
260
261
  screenTime: screenTimeSyncPayloadSchema.default({})
261
262
  });
262
263
  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;
264
+ const HEALTH_MOBILE_SYNC_CHUNK_TARGET_BYTES = 12_000_000;
265
+ const HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES = 24_000_000;
265
266
  const HEALTH_MOBILE_SYNC_CHUNK_PAYLOAD_ENCODING = "payload_json_base64";
267
+ const HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING = "payload_json_deflate_base64";
266
268
  const HEALTH_MOBILE_SYNC_ACCEPTED_CHUNK_PAYLOAD_ENCODINGS = [
267
269
  HEALTH_MOBILE_SYNC_CHUNK_PAYLOAD_ENCODING,
270
+ HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING,
268
271
  "legacy_payload_object"
269
272
  ];
270
273
  const HEALTH_MOBILE_SYNC_SESSION_TTL_MS = 1000 * 60 * 60 * 24;
@@ -343,7 +346,9 @@ export const mobileHealthSyncChunkSchema = z.object({
343
346
  family: mobileHealthSyncFamilySchema,
344
347
  recordCount: z.number().int().nonnegative().default(0),
345
348
  byteCount: z.number().int().nonnegative().default(0),
349
+ compressedByteCount: z.number().int().nonnegative().optional(),
346
350
  checksumSha256: z.string().trim().min(1),
351
+ payloadJsonDeflateBase64: z.string().trim().min(1).optional(),
347
352
  payloadJsonBase64: z.string().trim().min(1).optional(),
348
353
  payload: mobileHealthSyncChunkPayloadSchema.default({})
349
354
  });
@@ -2691,6 +2696,13 @@ function chunkPayloadJson(payload) {
2691
2696
  function chunkPayloadChecksum(payloadJson) {
2692
2697
  return createHash("sha256").update(payloadJson).digest("hex");
2693
2698
  }
2699
+ function normalizedChunkChecksum(checksum) {
2700
+ const normalized = checksum.trim().toLowerCase();
2701
+ if (!/^[a-f0-9]{64}$/.test(normalized)) {
2702
+ throw new HttpError(400, "invalid_chunk_checksum", "The HealthKit sync chunk checksum is invalid.");
2703
+ }
2704
+ return normalized;
2705
+ }
2694
2706
  function parseBase64ChunkPayload(payloadJsonBase64, context) {
2695
2707
  const compactBase64 = payloadJsonBase64.replace(/\s/g, "");
2696
2708
  if (compactBase64.length === 0 ||
@@ -2724,10 +2736,71 @@ function parseBase64ChunkPayload(payloadJsonBase64, context) {
2724
2736
  payload: parsedPayload,
2725
2737
  payloadJson,
2726
2738
  byteCount: decoded.length,
2739
+ compressedByteCount: undefined,
2727
2740
  mode: "payload_json_base64"
2728
2741
  };
2729
2742
  }
2743
+ function parseDeflateBase64ChunkPayload(payloadJsonDeflateBase64, context) {
2744
+ const compactBase64 = payloadJsonDeflateBase64.replace(/\s/g, "");
2745
+ if (compactBase64.length === 0 ||
2746
+ compactBase64.length % 4 === 1 ||
2747
+ !/^[A-Za-z0-9+/]*={0,2}$/.test(compactBase64)) {
2748
+ throw new HttpError(400, "invalid_chunk_payload", "The HealthKit sync compressed payload encoding is invalid.", { mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING });
2749
+ }
2750
+ const compressed = Buffer.from(compactBase64, "base64");
2751
+ const recoded = compressed.toString("base64").replace(/=+$/g, "");
2752
+ if (recoded !== compactBase64.replace(/=+$/g, "")) {
2753
+ throw new HttpError(400, "invalid_chunk_payload", "The HealthKit sync compressed payload encoding is invalid.", { mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING });
2754
+ }
2755
+ let decoded;
2756
+ try {
2757
+ decoded = inflateSync(compressed);
2758
+ }
2759
+ catch (error) {
2760
+ console.warn("[healthkit-sync] invalid compressed chunk payload", {
2761
+ syncSessionId: context.syncSessionId,
2762
+ chunkId: context.chunkId,
2763
+ family: context.family,
2764
+ mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING,
2765
+ compressedBytes: compressed.length,
2766
+ error: error instanceof Error ? error.message : String(error)
2767
+ });
2768
+ throw new HttpError(400, "invalid_chunk_payload", "The HealthKit sync compressed payload cannot be decompressed.", { mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING });
2769
+ }
2770
+ const payloadJson = decoded.toString("utf8");
2771
+ let rawPayload;
2772
+ try {
2773
+ rawPayload = JSON.parse(payloadJson);
2774
+ }
2775
+ catch (error) {
2776
+ console.warn("[healthkit-sync] invalid compressed chunk JSON payload", {
2777
+ syncSessionId: context.syncSessionId,
2778
+ chunkId: context.chunkId,
2779
+ family: context.family,
2780
+ mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING,
2781
+ bytes: decoded.length,
2782
+ compressedBytes: compressed.length,
2783
+ error: error instanceof Error ? error.message : String(error)
2784
+ });
2785
+ throw new HttpError(400, "invalid_chunk_payload", "The HealthKit sync compressed payload is not valid JSON.", { mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING });
2786
+ }
2787
+ const parsedPayload = mobileHealthSyncChunkPayloadSchema.parse(rawPayload);
2788
+ return {
2789
+ payload: parsedPayload,
2790
+ payloadJson,
2791
+ byteCount: decoded.length,
2792
+ compressedByteCount: compressed.length,
2793
+ mode: HEALTH_MOBILE_SYNC_COMPRESSED_CHUNK_PAYLOAD_ENCODING
2794
+ };
2795
+ }
2730
2796
  function resolveChunkWirePayload(parsed, syncSessionId, rawPayloadJson) {
2797
+ if (parsed.payloadJsonDeflateBase64) {
2798
+ return parseDeflateBase64ChunkPayload(parsed.payloadJsonDeflateBase64, {
2799
+ syncSessionId,
2800
+ chunkId: parsed.chunkId,
2801
+ family: parsed.family
2802
+ });
2803
+ }
2731
2804
  if (parsed.payloadJsonBase64) {
2732
2805
  return parseBase64ChunkPayload(parsed.payloadJsonBase64, {
2733
2806
  syncSessionId,
@@ -2740,6 +2813,7 @@ function resolveChunkWirePayload(parsed, syncSessionId, rawPayloadJson) {
2740
2813
  payload: parsed.payload,
2741
2814
  payloadJson,
2742
2815
  byteCount: Buffer.byteLength(payloadJson, "utf8"),
2816
+ compressedByteCount: undefined,
2743
2817
  mode: "legacy_payload_object"
2744
2818
  };
2745
2819
  }
@@ -2779,6 +2853,76 @@ function summarizeChunkPayload(family, payload) {
2779
2853
  };
2780
2854
  }
2781
2855
  }
2856
+ function mobileSyncSessionPairing(session) {
2857
+ const pairing = getDatabase()
2858
+ .prepare(`SELECT * FROM companion_pairing_sessions WHERE id = ?`)
2859
+ .get(session.pairing_session_id);
2860
+ if (!pairing) {
2861
+ throw new HttpError(404, "pairing_invalid", "The sync pairing no longer exists.");
2862
+ }
2863
+ return pairing;
2864
+ }
2865
+ function mobileSyncSessionMetadata(session) {
2866
+ return safeJsonParse(session.source_metadata_json, {});
2867
+ }
2868
+ function applyWorkoutSummaryChunkImmediately(session, workouts) {
2869
+ if (workouts.length === 0) {
2870
+ return;
2871
+ }
2872
+ const pairing = mobileSyncSessionPairing(session);
2873
+ const metadata = mobileSyncSessionMetadata(session);
2874
+ const device = metadata.device ?? {
2875
+ name: pairing.device_name ?? "iPhone",
2876
+ platform: pairing.platform ?? "ios",
2877
+ appVersion: pairing.app_version ?? "",
2878
+ sourceDevice: pairing.device_name ?? "iPhone"
2879
+ };
2880
+ runInTransaction(() => {
2881
+ const now = nowIso();
2882
+ const runId = `hir_${randomUUID().replaceAll("-", "").slice(0, 10)}`;
2883
+ let createdCount = 0;
2884
+ let updatedCount = 0;
2885
+ let mergedCount = 0;
2886
+ getDatabase()
2887
+ .prepare(`INSERT INTO health_import_runs (
2888
+ id, pairing_session_id, user_id, source, source_device, status, payload_summary_json,
2889
+ imported_count, created_count, updated_count, merged_count, imported_at, created_at, updated_at
2890
+ )
2891
+ VALUES (?, ?, ?, 'ios_companion', ?, 'running', '{}', 0, 0, 0, 0, ?, ?, ?)`)
2892
+ .run(runId, pairing.id, pairing.user_id, device.sourceDevice, now, now, now);
2893
+ for (const workout of workouts) {
2894
+ const result = insertOrUpdateWorkoutSession(pairing, workout);
2895
+ if (result.mode === "created") {
2896
+ createdCount += 1;
2897
+ }
2898
+ else if (result.mode === "merged") {
2899
+ mergedCount += 1;
2900
+ }
2901
+ else {
2902
+ updatedCount += 1;
2903
+ }
2904
+ summarizeUserHealthDay(pairing.user_id, dayKey(workout.startedAt));
2905
+ }
2906
+ getDatabase()
2907
+ .prepare(`UPDATE companion_pairing_sessions
2908
+ SET status = 'healthy', device_name = ?, platform = ?, app_version = ?,
2909
+ last_seen_at = ?, last_sync_at = ?, last_sync_error = NULL,
2910
+ paired_at = COALESCE(paired_at, ?), updated_at = ?
2911
+ WHERE id = ?`)
2912
+ .run(device.name, device.platform, device.appVersion, now, now, now, now, pairing.id);
2913
+ getDatabase()
2914
+ .prepare(`UPDATE health_import_runs
2915
+ SET source_device = ?, status = 'completed', payload_summary_json = ?,
2916
+ imported_count = ?, created_count = ?, updated_count = ?, merged_count = ?,
2917
+ imported_at = ?, updated_at = ?
2918
+ WHERE id = ?`)
2919
+ .run(device.sourceDevice, JSON.stringify({
2920
+ progressiveChunk: true,
2921
+ syncSessionId: session.id,
2922
+ workouts: workouts.length
2923
+ }), workouts.length, createdCount, updatedCount, mergedCount, now, now, runId);
2924
+ });
2925
+ }
2782
2926
  function updateMobileSyncSessionProgress(syncSessionId) {
2783
2927
  const chunks = getDatabase()
2784
2928
  .prepare(`SELECT family, record_count, byte_count
@@ -2837,7 +2981,7 @@ export function startMobileHealthSyncSession(payload) {
2837
2981
  chunkMaxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
2838
2982
  chunkPayloadEncoding: HEALTH_MOBILE_SYNC_CHUNK_PAYLOAD_ENCODING,
2839
2983
  acceptedPayloadEncodings: HEALTH_MOBILE_SYNC_ACCEPTED_CHUNK_PAYLOAD_ENCODINGS,
2840
- supportsCompression: false,
2984
+ supportsCompression: true,
2841
2985
  acceptedFamilies: safeJsonParse(existing.requested_families_json, parsed.requestedFamilies),
2842
2986
  receivedChunkIds
2843
2987
  };
@@ -2866,13 +3010,14 @@ export function startMobileHealthSyncSession(payload) {
2866
3010
  chunkMaxBytes: HEALTH_MOBILE_SYNC_CHUNK_MAX_BYTES,
2867
3011
  chunkPayloadEncoding: HEALTH_MOBILE_SYNC_CHUNK_PAYLOAD_ENCODING,
2868
3012
  acceptedPayloadEncodings: HEALTH_MOBILE_SYNC_ACCEPTED_CHUNK_PAYLOAD_ENCODINGS,
2869
- supportsCompression: false,
3013
+ supportsCompression: true,
2870
3014
  acceptedFamilies: parsed.requestedFamilies,
2871
3015
  receivedChunkIds: []
2872
3016
  };
2873
3017
  }
2874
3018
  export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJson) {
2875
3019
  const parsed = mobileHealthSyncChunkSchema.parse(payload);
3020
+ const clientChecksum = normalizedChunkChecksum(parsed.checksumSha256);
2876
3021
  const session = ensureRunningMobileSyncSession(syncSessionId);
2877
3022
  const requestedFamilies = safeJsonParse(session.requested_families_json, []);
2878
3023
  if (requestedFamilies.length > 0 &&
@@ -2884,7 +3029,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
2884
3029
  WHERE sync_session_id = ? AND chunk_id = ?`)
2885
3030
  .get(syncSessionId, parsed.chunkId);
2886
3031
  if (existing) {
2887
- if (existing.checksum_sha256 !== parsed.checksumSha256) {
3032
+ if (existing.checksum_sha256 !== clientChecksum) {
2888
3033
  throw new HttpError(409, "chunk_checksum_mismatch", "A chunk with the same id was already accepted with different content.");
2889
3034
  }
2890
3035
  const progress = updateMobileSyncSessionProgress(syncSessionId);
@@ -2905,14 +3050,52 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
2905
3050
  actualBytes: actualByteCount
2906
3051
  });
2907
3052
  }
3053
+ if (wirePayload.mode !== "legacy_payload_object" && parsed.byteCount !== actualByteCount) {
3054
+ console.warn("[healthkit-sync] chunk byte count mismatch", {
3055
+ syncSessionId,
3056
+ chunkId: parsed.chunkId,
3057
+ family: parsed.family,
3058
+ mode: wirePayload.mode,
3059
+ clientByteCount: parsed.byteCount,
3060
+ actualByteCount
3061
+ });
3062
+ throw new HttpError(409, "chunk_byte_count_mismatch", "The HealthKit sync chunk byte count does not match its decoded payload.", {
3063
+ syncSessionId,
3064
+ chunkId: parsed.chunkId,
3065
+ family: parsed.family,
3066
+ actualBytes: actualByteCount,
3067
+ clientBytes: parsed.byteCount,
3068
+ mode: wirePayload.mode
3069
+ });
3070
+ }
3071
+ if (wirePayload.compressedByteCount !== undefined &&
3072
+ parsed.compressedByteCount !== undefined &&
3073
+ parsed.compressedByteCount !== wirePayload.compressedByteCount) {
3074
+ console.warn("[healthkit-sync] chunk compressed byte count mismatch", {
3075
+ syncSessionId,
3076
+ chunkId: parsed.chunkId,
3077
+ family: parsed.family,
3078
+ mode: wirePayload.mode,
3079
+ clientCompressedByteCount: parsed.compressedByteCount,
3080
+ actualCompressedByteCount: wirePayload.compressedByteCount
3081
+ });
3082
+ throw new HttpError(409, "chunk_compressed_byte_count_mismatch", "The HealthKit sync chunk compressed byte count does not match its decoded payload.", {
3083
+ syncSessionId,
3084
+ chunkId: parsed.chunkId,
3085
+ family: parsed.family,
3086
+ actualCompressedBytes: wirePayload.compressedByteCount,
3087
+ clientCompressedBytes: parsed.compressedByteCount,
3088
+ mode: wirePayload.mode
3089
+ });
3090
+ }
2908
3091
  const serverChecksum = chunkPayloadChecksum(payloadJson);
2909
- if (parsed.checksumSha256 !== serverChecksum) {
3092
+ if (clientChecksum !== serverChecksum) {
2910
3093
  console.warn("[healthkit-sync] chunk checksum mismatch", {
2911
3094
  syncSessionId,
2912
3095
  chunkId: parsed.chunkId,
2913
3096
  family: parsed.family,
2914
3097
  mode: wirePayload.mode,
2915
- clientChecksum: parsed.checksumSha256.slice(0, 12),
3098
+ clientChecksum: clientChecksum.slice(0, 12),
2916
3099
  serverChecksum: serverChecksum.slice(0, 12),
2917
3100
  clientByteCount: parsed.byteCount,
2918
3101
  actualByteCount
@@ -2923,7 +3106,7 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
2923
3106
  family: parsed.family,
2924
3107
  actualBytes: actualByteCount,
2925
3108
  clientBytes: parsed.byteCount,
2926
- clientChecksumPrefix: parsed.checksumSha256.slice(0, 12),
3109
+ clientChecksumPrefix: clientChecksum.slice(0, 12),
2927
3110
  serverChecksumPrefix: serverChecksum.slice(0, 12),
2928
3111
  mode: wirePayload.mode
2929
3112
  });
@@ -2936,13 +3119,17 @@ export function ingestMobileHealthSyncChunk(syncSessionId, payload, rawPayloadJs
2936
3119
  received_at, applied_at, created_at, updated_at
2937
3120
  )
2938
3121
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
2939
- .run(mobileSyncChunkRecordId(), syncSessionId, parsed.chunkId, parsed.sequence, parsed.family, serverChecksum, parsed.recordCount, parsed.byteCount || actualByteCount, payloadJson, JSON.stringify({
3122
+ .run(mobileSyncChunkRecordId(), syncSessionId, parsed.chunkId, parsed.sequence, parsed.family, serverChecksum, parsed.recordCount, actualByteCount, payloadJson, JSON.stringify({
2940
3123
  ...summarizeChunkPayload(parsed.family, wirePayload.payload),
2941
3124
  clientByteCount: parsed.byteCount,
2942
3125
  actualByteCount,
3126
+ compressedByteCount: wirePayload.compressedByteCount ?? null,
2943
3127
  serverChecksum,
2944
3128
  mode: wirePayload.mode
2945
3129
  }), now, now, now, now);
3130
+ if (parsed.family === "workout_summaries") {
3131
+ applyWorkoutSummaryChunkImmediately(session, wirePayload.payload.workouts ?? []);
3132
+ }
2946
3133
  const progress = updateMobileSyncSessionProgress(syncSessionId);
2947
3134
  return {
2948
3135
  accepted: true,
@@ -3569,8 +3756,64 @@ export function getSleepViewData(userIds) {
3569
3756
  sessions: recentDisplay
3570
3757
  };
3571
3758
  }
3759
+ function pickVitalMetricValue(metrics, candidates) {
3760
+ for (const key of candidates) {
3761
+ const metric = metrics[key];
3762
+ if (!metric) {
3763
+ continue;
3764
+ }
3765
+ const value = vitalMetricPrimaryValue({
3766
+ aggregation: metric.aggregation,
3767
+ latest: metric.latest ?? null,
3768
+ average: metric.average ?? null,
3769
+ total: metric.total ?? null,
3770
+ maximum: metric.maximum ?? null
3771
+ });
3772
+ if (typeof value === "number" && Number.isFinite(value)) {
3773
+ return value;
3774
+ }
3775
+ }
3776
+ return null;
3777
+ }
3778
+ function buildFitnessVitalsTrend(rows) {
3779
+ const byDate = new Map();
3780
+ for (const row of rows) {
3781
+ const metrics = safeJsonParse(row.metrics_json, {});
3782
+ const bucket = byDate.get(row.date_key) ?? {
3783
+ restingHeartRate: [],
3784
+ vo2Max: []
3785
+ };
3786
+ const restingHeartRate = pickVitalMetricValue(metrics, [
3787
+ "restingHeartRate",
3788
+ "resting_heart_rate"
3789
+ ]);
3790
+ const vo2Max = pickVitalMetricValue(metrics, [
3791
+ "vo2Max",
3792
+ "vo2max",
3793
+ "vo2_max"
3794
+ ]);
3795
+ if (restingHeartRate != null) {
3796
+ bucket.restingHeartRate.push(restingHeartRate);
3797
+ }
3798
+ if (vo2Max != null) {
3799
+ bucket.vo2Max.push(vo2Max);
3800
+ }
3801
+ byDate.set(row.date_key, bucket);
3802
+ }
3803
+ return [...byDate.entries()]
3804
+ .sort((left, right) => left[0].localeCompare(right[0]))
3805
+ .slice(-90)
3806
+ .map(([dateKey, values]) => ({
3807
+ dateKey,
3808
+ restingHeartRate: values.restingHeartRate.length > 0
3809
+ ? round(average(values.restingHeartRate), 1)
3810
+ : null,
3811
+ vo2Max: values.vo2Max.length > 0 ? round(average(values.vo2Max), 2) : null
3812
+ }));
3813
+ }
3572
3814
  export function getFitnessViewData(userIds) {
3573
3815
  const workouts = listWorkoutRows(userIds).map(mapWorkoutSession);
3816
+ const vitalsTrend = buildFitnessVitalsTrend(listDailySummaryRows("vitals", userIds));
3574
3817
  const recent = workouts.slice(0, 40);
3575
3818
  const weekly = recent.filter((session) => Date.now() - Date.parse(session.startedAt) <= 7 * 24 * 60 * 60 * 1000);
3576
3819
  const weeklyVolumeSeconds = weekly.reduce((sum, session) => sum + session.durationSeconds, 0);
@@ -3684,6 +3927,8 @@ export function getFitnessViewData(userIds) {
3684
3927
  totalMinutes: metrics.totalMinutes,
3685
3928
  energyKcal: metrics.energyKcal
3686
3929
  })),
3930
+ vitalsTrend,
3931
+ analysisSessions: workouts,
3687
3932
  sessions: recent
3688
3933
  };
3689
3934
  }