incremnt 0.8.2 → 0.8.4

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.
@@ -28,6 +28,7 @@ import { extractPlanChangeset } from './plan-changeset.js';
28
28
 
29
29
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
30
30
  const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
31
+ const COACH_OBSERVATION_DAILY_REFRESH_COOLDOWN_MS = 20 * 60 * 60 * 1000;
31
32
  const WEEKLY_CHECKIN_COMPLETION_USER_TURNS = 3;
32
33
  const WEEKLY_CHECKIN_RECAP_LOCAL_HOUR = 16;
33
34
  const DEFAULT_RATE_LIMIT_RULES = {
@@ -70,6 +71,8 @@ const DEFAULT_RATE_LIMIT_RULES = {
70
71
  'program-share-list': 60,
71
72
  'program-share-public': 120,
72
73
  'program-share-revoke': 30,
74
+ 'workout-share-create': 30,
75
+ 'workout-share-public': 120,
73
76
  'mobile-sync-bootstrap': 60,
74
77
  'mobile-sync-pull': 120,
75
78
  'mobile-sync-push': 60,
@@ -208,10 +211,28 @@ function mergeAgenticToolProvenance(routingMetadata, toolInvocations = []) {
208
211
  }
209
212
  }
210
213
 
214
+ function sanitizeBodyWeightEvidenceFactsForStorage(value) {
215
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
216
+ const facts = { ...value };
217
+ if (Array.isArray(facts.rows)) {
218
+ facts.rows = facts.rows
219
+ .filter((row) => row?.date && Number.isFinite(Number(row.weightKg)))
220
+ .slice(-90)
221
+ .map((row) => ({
222
+ date: askStorageString(String(row.date).slice(0, 10), { maxLength: 10 }),
223
+ weightKg: Math.round(Number(row.weightKg) * 10) / 10
224
+ }))
225
+ .filter((row) => row.date);
226
+ }
227
+ const serialized = JSON.stringify(facts);
228
+ if (serialized.length > ASK_STRUCTURED_MAX_JSON_LENGTH) return null;
229
+ return JSON.parse(serialized);
230
+ }
231
+
211
232
  function sanitizeAskEvidenceForStorage(item) {
212
233
  if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
213
234
  const sanitized = {};
214
- for (const key of ['label', 'section', 'toolName', 'sourceTimestamp']) {
235
+ for (const key of ['label', 'section', 'toolName', 'sourceTimestamp', 'kind', 'presentation']) {
215
236
  const value = askStorageString(item[key], { maxLength: 240 });
216
237
  if (value) sanitized[key] = value;
217
238
  }
@@ -219,6 +240,13 @@ function sanitizeAskEvidenceForStorage(item) {
219
240
  const values = askStorageStringArray(item[key], { maxItems: ASK_STRUCTURED_MAX_ITEMS, maxLength: 160 });
220
241
  if (values.length > 0) sanitized[key] = values;
221
242
  }
243
+ const isBodyWeightEvidence = sanitized.toolName === 'get_body_weight_snapshot'
244
+ || sanitized.kind === 'body_weight_trend'
245
+ || sanitized.presentation === 'body_weight_trend';
246
+ if (isBodyWeightEvidence) {
247
+ const facts = sanitizeBodyWeightEvidenceFactsForStorage(item.facts);
248
+ if (facts) sanitized.facts = facts;
249
+ }
222
250
  return Object.keys(sanitized).length > 0 ? sanitized : null;
223
251
  }
224
252
 
@@ -243,7 +271,7 @@ function sanitizeAskProgramDraftForStorage(value) {
243
271
  return JSON.parse(serialized);
244
272
  }
245
273
 
246
- function sanitizeAskStructuredResponseForStorage(structured) {
274
+ export function sanitizeAskStructuredResponseForStorage(structured) {
247
275
  if (!structured || typeof structured !== 'object' || Array.isArray(structured)) return null;
248
276
  const confidence = askStorageString(structured.confidence, { maxLength: 40 });
249
277
  const answer = askStorageString(structured.answer);
@@ -922,6 +950,38 @@ function coachObservationGenerationMetadata(result, sourceTrigger) {
922
950
  };
923
951
  }
924
952
 
953
+ function skippedCoachObservationGenerationMetadata(skippedReason, sourceTrigger) {
954
+ return {
955
+ attempted: false,
956
+ generated: 0,
957
+ persisted: 0,
958
+ skippedReason,
959
+ sourceTrigger: sourceTrigger ?? null
960
+ };
961
+ }
962
+
963
+ function recentDailyRefreshObservation(observations, now = new Date()) {
964
+ const threshold = now.getTime() - COACH_OBSERVATION_DAILY_REFRESH_COOLDOWN_MS;
965
+ return (Array.isArray(observations) ? observations : []).some((observation) => {
966
+ if (observation?.sourceTrigger !== 'daily_refresh') return false;
967
+ const generatedAt = new Date(observation.generatedAt ?? observation.windowEnd ?? 0).getTime();
968
+ return Number.isFinite(generatedAt) && generatedAt >= threshold;
969
+ });
970
+ }
971
+
972
+ async function coachObservationGenerationSkipReason({
973
+ account,
974
+ uploadResult,
975
+ sourceTrigger,
976
+ listCurrentCoachObservationsForAccount
977
+ }) {
978
+ if (sourceTrigger !== 'daily_refresh') return null;
979
+ if (Number(uploadResult?.inserted ?? 0) <= 0) return 'no_score_snapshot_change';
980
+ if (typeof listCurrentCoachObservationsForAccount !== 'function') return null;
981
+ const observations = await listCurrentCoachObservationsForAccount(account, { limit: 20 });
982
+ return recentDailyRefreshObservation(observations) ? 'recent_daily_refresh' : null;
983
+ }
984
+
925
985
  function normalizeAskCoachObservationFollowUp(value) {
926
986
  if (!value || typeof value !== 'object') return null;
927
987
  const id = String(value.id ?? '').trim();
@@ -1152,6 +1212,20 @@ function routeRequest(url, method) {
1152
1212
  }
1153
1213
  }
1154
1214
 
1215
+ if (pathname === '/cli/workout-share') {
1216
+ return { command: 'workout-share-create', options: {} };
1217
+ }
1218
+
1219
+ {
1220
+ const workoutSharePublicMatch = pathname.match(/^\/workout-share\/([^/]+)$/);
1221
+ if (workoutSharePublicMatch) {
1222
+ return {
1223
+ command: 'workout-share-public',
1224
+ options: { token: decodeURIComponent(workoutSharePublicMatch[1]) }
1225
+ };
1226
+ }
1227
+ }
1228
+
1155
1229
  {
1156
1230
  const programShareRevokeMatch = pathname.match(/^\/cli\/program-share\/([^/]+)\/revoke$/);
1157
1231
  if (programShareRevokeMatch) {
@@ -2291,6 +2365,8 @@ export function createSyncServiceRequestHandler({
2291
2365
  listProgramSharesForAccount = null,
2292
2366
  readPublicProgramShare = null,
2293
2367
  revokeProgramShareForAccount = null,
2368
+ createWorkoutShareForAccount = null,
2369
+ readPublicWorkoutShare = null,
2294
2370
  updateAnalysisConsentForAccount = null,
2295
2371
  updateDisplayNameForAccount = null,
2296
2372
  saveAskConversationForAccount = null,
@@ -2979,6 +3055,45 @@ export function createSyncServiceRequestHandler({
2979
3055
  }
2980
3056
  }
2981
3057
 
3058
+ if (route.command === 'workout-share-public') {
3059
+ if (request.method !== 'GET') {
3060
+ methodNotAllowed(response, 'Use GET for /workout-share/:token.');
3061
+ return;
3062
+ }
3063
+ if (!readPublicWorkoutShare) {
3064
+ methodNotAllowed(response, 'Workout sharing is not enabled for this service mode.');
3065
+ return;
3066
+ }
3067
+ try {
3068
+ const shared = await readPublicWorkoutShare(route.options.token);
3069
+ if (shared.status === 'not_found') {
3070
+ notFound(response, 'Workout share not found.');
3071
+ return;
3072
+ }
3073
+ if (shared.status === 'revoked' || shared.status === 'expired') {
3074
+ json(response, 410, { error: 'Workout share is no longer available.' });
3075
+ return;
3076
+ }
3077
+ json(response, 200, {
3078
+ ok: true,
3079
+ token: route.options.token,
3080
+ version: shared.share.version,
3081
+ workoutName: shared.share.workoutName,
3082
+ workoutPayload: shared.share.workoutPayload,
3083
+ createdAt: shared.share.createdAt,
3084
+ expiresAt: shared.share.expiresAt
3085
+ });
3086
+ return;
3087
+ } catch (error) {
3088
+ if (error?.message === 'Invalid workout share token.') {
3089
+ badRequest(response, error.message);
3090
+ return;
3091
+ }
3092
+ internalError(response, error, onError);
3093
+ return;
3094
+ }
3095
+ }
3096
+
2982
3097
  if (route.command === 'google-mobile') {
2983
3098
  if (request.method !== 'POST') {
2984
3099
  methodNotAllowed(response, 'Use POST for /auth/google/mobile.');
@@ -3297,18 +3412,33 @@ export function createSyncServiceRequestHandler({
3297
3412
  const result = await insertScoreSnapshotsForAccount(account, body.snapshots);
3298
3413
  if (generateCoachObservationsForAccount) {
3299
3414
  const sourceTrigger = coachObservationSourceTriggerForScoreSnapshots(body.snapshots);
3415
+ let skipReason = null;
3300
3416
  try {
3301
- const generationResult = await generateCoachObservationsForAccount(account, { sourceTrigger });
3302
- result.coachObservations = coachObservationGenerationMetadata(generationResult, sourceTrigger);
3417
+ skipReason = await coachObservationGenerationSkipReason({
3418
+ account,
3419
+ uploadResult: result,
3420
+ sourceTrigger,
3421
+ listCurrentCoachObservationsForAccount
3422
+ });
3303
3423
  } catch (error) {
3304
- console.error('Coach observation generation after score upload failed:', error.message);
3305
- result.coachObservations = {
3306
- attempted: true,
3307
- generated: 0,
3308
- persisted: 0,
3309
- skippedReason: 'generation_failed',
3310
- sourceTrigger
3311
- };
3424
+ console.error('Coach observation generation skip check failed:', error.message);
3425
+ }
3426
+ if (skipReason) {
3427
+ result.coachObservations = skippedCoachObservationGenerationMetadata(skipReason, sourceTrigger);
3428
+ } else {
3429
+ try {
3430
+ const generationResult = await generateCoachObservationsForAccount(account, { sourceTrigger });
3431
+ result.coachObservations = coachObservationGenerationMetadata(generationResult, sourceTrigger);
3432
+ } catch (error) {
3433
+ console.error('Coach observation generation after score upload failed:', error.message);
3434
+ result.coachObservations = {
3435
+ attempted: true,
3436
+ generated: 0,
3437
+ persisted: 0,
3438
+ skippedReason: 'generation_failed',
3439
+ sourceTrigger
3440
+ };
3441
+ }
3312
3442
  }
3313
3443
  }
3314
3444
  json(response, 200, result);
@@ -3579,6 +3709,53 @@ export function createSyncServiceRequestHandler({
3579
3709
  }
3580
3710
  }
3581
3711
 
3712
+ if (route.command === 'workout-share-create') {
3713
+ if (request.method !== 'POST') {
3714
+ methodNotAllowed(response, 'Use POST for /cli/workout-share.');
3715
+ return;
3716
+ }
3717
+ if (!createWorkoutShareForAccount) {
3718
+ methodNotAllowed(response, 'Workout sharing is not enabled for this service mode.');
3719
+ return;
3720
+ }
3721
+ const account = connectedWriteAuthenticator
3722
+ ? await connectedWriteAuthenticator(requestToken)
3723
+ : null;
3724
+ if (!account) {
3725
+ if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
3726
+ return;
3727
+ }
3728
+ unauthorized(response, request);
3729
+ return;
3730
+ }
3731
+ try {
3732
+ const body = await readJsonBody(request);
3733
+ const workoutPayload = body.workoutPayload ?? body;
3734
+ const share = await createWorkoutShareForAccount(account, workoutPayload);
3735
+ json(response, 201, {
3736
+ ok: true,
3737
+ shareId: share.id,
3738
+ tokenHint: share.tokenHint,
3739
+ token: share.token,
3740
+ workoutName: share.workoutName,
3741
+ createdAt: share.createdAt,
3742
+ expiresAt: share.expiresAt,
3743
+ revokedAt: share.revokedAt,
3744
+ version: share.version,
3745
+ link: `${resolvedPublicOrigin}/workout-share/${share.token}`,
3746
+ deepLink: `incremnt://workout-share/${share.token}?base=${encodeURIComponent(resolvedPublicOrigin)}`
3747
+ });
3748
+ return;
3749
+ } catch (error) {
3750
+ if (error?.message === 'Workout share payload is malformed.') {
3751
+ badRequest(response, error.message);
3752
+ return;
3753
+ }
3754
+ internalError(response, error, onError);
3755
+ return;
3756
+ }
3757
+ }
3758
+
3582
3759
  if (route.command === 'program-share-list') {
3583
3760
  if (request.method !== 'GET') {
3584
3761
  methodNotAllowed(response, 'Use GET for /cli/programs/:programId/shares.');
@@ -3999,45 +4176,8 @@ export function createSyncServiceRequestHandler({
3999
4176
  const limit = parseLimit(route.options.limit, { defaultValue: 5, max: 20 });
4000
4177
  let refresh = null;
4001
4178
  if (route.options.refresh === 'morning_open') {
4002
- const writeAccount = writeAuthenticator
4003
- ? await writeAuthenticator(requestToken)
4004
- : requestToken === token
4005
- ? account
4006
- : null;
4007
- if (!writeAccount) {
4008
- if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
4009
- return;
4010
- }
4011
- unauthorized(response, request);
4012
- return;
4013
- }
4014
4179
  const sourceTrigger = 'daily_refresh';
4015
- if (generateCoachObservationsForAccount) {
4016
- try {
4017
- const generationResult = await generateCoachObservationsForAccount(writeAccount, {
4018
- sourceTrigger,
4019
- refresh: route.options.refresh
4020
- });
4021
- refresh = coachObservationGenerationMetadata(generationResult, sourceTrigger);
4022
- } catch (error) {
4023
- console.error('Coach observation generation on morning open failed:', error.message);
4024
- refresh = {
4025
- attempted: true,
4026
- generated: 0,
4027
- persisted: 0,
4028
- skippedReason: 'generation_failed',
4029
- sourceTrigger
4030
- };
4031
- }
4032
- } else {
4033
- refresh = {
4034
- attempted: false,
4035
- generated: 0,
4036
- persisted: 0,
4037
- skippedReason: 'generation_unavailable',
4038
- sourceTrigger
4039
- };
4040
- }
4180
+ refresh = skippedCoachObservationGenerationMetadata('morning_open_read_only', sourceTrigger);
4041
4181
  }
4042
4182
  const observations = await listCurrentCoachObservationsForAccount(account, {
4043
4183
  limit