incremnt 0.4.0 → 0.5.0

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.
@@ -1,11 +1,13 @@
1
1
  import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
2
+ import { anonymizeAccountId } from './anonymize.js';
2
3
  import { capabilities as cliCapabilities, contractVersion, officialCommands } from './contract.js';
3
4
  import { executeReadCommand } from './queries.js';
4
- import { fenceContent, sanitizeHistory, detectSystemPromptLeak, stripXMLTagBlocks } from './prompt-security.js';
5
+ import { sanitizeHistory, detectSystemPromptLeak, stripXMLTagBlocks } from './prompt-security.js';
5
6
 
6
7
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
7
8
  const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
8
- const MAX_ASK_USER_TURNS = 3;
9
+ const WEEKLY_CHECKIN_COMPLETION_USER_TURNS = 3;
10
+ const WEEKLY_CHECKIN_RECAP_LOCAL_HOUR = 16;
9
11
  const DEFAULT_RATE_LIMIT_RULES = {
10
12
  'workout-summary-ai': 3,
11
13
  'cycle-summary-ai': 3,
@@ -14,6 +16,10 @@ const DEFAULT_RATE_LIMIT_RULES = {
14
16
  'ask-ai': 5,
15
17
  'ai-feedback': 60,
16
18
  'coach-memory': 30,
19
+ 'weekly-checkin-enroll': 10,
20
+ 'weekly-checkin-current': 30,
21
+ 'weekly-checkin-ack': 30,
22
+ 'weekly-checkin-start': 10,
17
23
  'dev-login': 10,
18
24
  'device-start': 20,
19
25
  'device-poll': 300,
@@ -25,6 +31,7 @@ const DEFAULT_RATE_LIMIT_RULES = {
25
31
  'web-auth-start': 20,
26
32
  'web-auth-callback': 20,
27
33
  'session-login': 60,
34
+ 'anonymous-start': 60,
28
35
  'session-refresh': 30,
29
36
  'delete-account': 1,
30
37
  'sync-account-preferences': 30,
@@ -34,6 +41,10 @@ const DEFAULT_RATE_LIMIT_RULES = {
34
41
  'program-share-list': 60,
35
42
  'program-share-public': 120,
36
43
  'program-share-revoke': 30,
44
+ 'mobile-sync-bootstrap': 60,
45
+ 'mobile-sync-pull': 120,
46
+ 'mobile-sync-push': 60,
47
+ 'score-snapshots': 60,
37
48
  'social-invite': 20,
38
49
  'social-groups': 60,
39
50
  'social-group-create': 20,
@@ -96,6 +107,292 @@ export function isNoInsightResponse(text) {
96
107
  return normalized === 'NO_INSIGHT' || normalized.startsWith('NO_INSIGHT\n');
97
108
  }
98
109
 
110
+ function isValidTimeZone(timeZoneId) {
111
+ if (!timeZoneId || typeof timeZoneId !== 'string') return false;
112
+ try {
113
+ new Intl.DateTimeFormat('en-US', { timeZone: timeZoneId }).format(new Date());
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
120
+ function zonedParts(date, timeZoneId) {
121
+ const parts = new Intl.DateTimeFormat('en-US', {
122
+ timeZone: timeZoneId,
123
+ year: 'numeric',
124
+ month: '2-digit',
125
+ day: '2-digit',
126
+ hour: '2-digit',
127
+ minute: '2-digit',
128
+ second: '2-digit',
129
+ hour12: false,
130
+ hourCycle: 'h23',
131
+ weekday: 'short'
132
+ }).formatToParts(date);
133
+ const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]));
134
+ const weekdayIndex = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(byType.weekday);
135
+ return {
136
+ year: Number(byType.year),
137
+ month: Number(byType.month),
138
+ day: Number(byType.day),
139
+ hour: Number(byType.hour),
140
+ minute: Number(byType.minute),
141
+ second: Number(byType.second),
142
+ weekday: weekdayIndex >= 0 ? weekdayIndex : 0
143
+ };
144
+ }
145
+
146
+ function zonedDateTimeToUtc(timeZoneId, { year, month, day, hour, minute = 0, second = 0 }) {
147
+ let utc = Date.UTC(year, month - 1, day, hour, minute, second);
148
+ for (let index = 0; index < 4; index += 1) {
149
+ const parts = zonedParts(new Date(utc), timeZoneId);
150
+ const asIfUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
151
+ const wanted = Date.UTC(year, month - 1, day, hour, minute, second);
152
+ const delta = wanted - asIfUtc;
153
+ if (delta === 0) break;
154
+ utc += delta;
155
+ }
156
+ return new Date(utc);
157
+ }
158
+
159
+ function addCalendarDays({ year, month, day }, days) {
160
+ const date = new Date(Date.UTC(year, month - 1, day + days, 12, 0, 0));
161
+ return {
162
+ year: date.getUTCFullYear(),
163
+ month: date.getUTCMonth() + 1,
164
+ day: date.getUTCDate()
165
+ };
166
+ }
167
+
168
+ function isoDateFromParts({ year, month, day }) {
169
+ return [
170
+ String(year).padStart(4, '0'),
171
+ String(month).padStart(2, '0'),
172
+ String(day).padStart(2, '0')
173
+ ].join('-');
174
+ }
175
+
176
+ export function nextWeeklyCheckinSchedule(timeZoneId, now = new Date()) {
177
+ if (!isValidTimeZone(timeZoneId)) {
178
+ const err = new Error('Invalid timezoneId');
179
+ err.code = 'invalid_timezone';
180
+ throw err;
181
+ }
182
+ const local = zonedParts(now, timeZoneId);
183
+ const daysUntilSunday = (7 - local.weekday) % 7;
184
+ let target = addCalendarDays(local, daysUntilSunday);
185
+ let dueAt = zonedDateTimeToUtc(timeZoneId, {
186
+ ...target,
187
+ hour: WEEKLY_CHECKIN_RECAP_LOCAL_HOUR,
188
+ minute: 0,
189
+ second: 0
190
+ });
191
+ if (dueAt <= now) {
192
+ target = addCalendarDays(target, 7);
193
+ dueAt = zonedDateTimeToUtc(timeZoneId, {
194
+ ...target,
195
+ hour: WEEKLY_CHECKIN_RECAP_LOCAL_HOUR,
196
+ minute: 0,
197
+ second: 0
198
+ });
199
+ }
200
+ return {
201
+ weekStartDate: isoDateFromParts(target),
202
+ nextRecapDueAt: dueAt.toISOString()
203
+ };
204
+ }
205
+
206
+ const PROGRAM_DRAFT_VERSION = 1;
207
+ const VALID_PROGRAM_DRAFT_EQUIPMENT_TIERS = new Set(['fullGym', 'benchDumbbells', 'dumbbellsOnly', 'bodyweightOnly']);
208
+ const VALID_PROGRAM_DRAFT_VOLUME_LEVELS = new Set(['minimum', 'moderate', 'high']);
209
+
210
+ const PROGRAM_DRAFT_LIMITS = {
211
+ nameMaxLen: 120,
212
+ muscleGroupMaxLen: 60,
213
+ dayLabelMaxLen: 60,
214
+ dayTitleMaxLen: 120,
215
+ daySubtitleMaxLen: 120,
216
+ noteMaxLen: 1000,
217
+ minWeight: 0,
218
+ maxWeight: 600,
219
+ minReps: 1,
220
+ maxReps: 30,
221
+ minRir: 0,
222
+ maxRir: 5,
223
+ minSetsPerExercise: 1,
224
+ maxSetsPerExercise: 12,
225
+ minExercisesPerDay: 1,
226
+ maxExercisesPerDay: 24,
227
+ minDaysPerWeek: 1,
228
+ maxDaysPerWeek: 7,
229
+ minDays: 1,
230
+ maxDays: 14
231
+ };
232
+
233
+ function collapseBlankLines(text) {
234
+ return String(text ?? '')
235
+ .replace(/\n{3,}/g, '\n\n')
236
+ .trim();
237
+ }
238
+
239
+ function titleCaseExerciseName(name) {
240
+ return String(name ?? '')
241
+ .split(' ')
242
+ .filter(Boolean)
243
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
244
+ .join(' ');
245
+ }
246
+
247
+ function normalizedExerciseDisplayName(name, canonicalizeExerciseName) {
248
+ const trimmed = String(name ?? '').trim();
249
+ if (!trimmed) return '';
250
+ const canonical = canonicalizeExerciseName ? canonicalizeExerciseName(trimmed) : trimmed.toLowerCase();
251
+ return titleCaseExerciseName(canonical);
252
+ }
253
+
254
+ function normalizeProgramDraftSet(set) {
255
+ const weight = Number(set?.weight);
256
+ const reps = Number(set?.reps);
257
+ if (!Number.isFinite(weight) || !Number.isInteger(reps)) return null;
258
+ if (
259
+ weight < PROGRAM_DRAFT_LIMITS.minWeight ||
260
+ weight > PROGRAM_DRAFT_LIMITS.maxWeight ||
261
+ reps < PROGRAM_DRAFT_LIMITS.minReps ||
262
+ reps > PROGRAM_DRAFT_LIMITS.maxReps
263
+ ) return null;
264
+ return {
265
+ weight,
266
+ reps,
267
+ isComplete: false,
268
+ isWarmup: set?.isWarmup === true
269
+ };
270
+ }
271
+
272
+ function normalizeProgramDraftExercise(exercise, canonicalizeExerciseName) {
273
+ const name = normalizedExerciseDisplayName(exercise?.name, canonicalizeExerciseName);
274
+ const muscleGroup = String(exercise?.muscleGroup ?? '').trim();
275
+ const sets = Array.isArray(exercise?.sets)
276
+ ? exercise.sets.map(normalizeProgramDraftSet).filter(Boolean)
277
+ : [];
278
+
279
+ if (!name || name.length > PROGRAM_DRAFT_LIMITS.nameMaxLen) return null;
280
+ if (!muscleGroup || muscleGroup.length > PROGRAM_DRAFT_LIMITS.muscleGroupMaxLen) return null;
281
+ if (
282
+ sets.length < PROGRAM_DRAFT_LIMITS.minSetsPerExercise ||
283
+ sets.length > PROGRAM_DRAFT_LIMITS.maxSetsPerExercise
284
+ ) return null;
285
+
286
+ const rir = exercise?.rir == null ? null : Number(exercise.rir);
287
+ if (rir != null && (
288
+ !Number.isInteger(rir) ||
289
+ rir < PROGRAM_DRAFT_LIMITS.minRir ||
290
+ rir > PROGRAM_DRAFT_LIMITS.maxRir
291
+ )) return null;
292
+
293
+ const note = exercise?.note == null ? null : String(exercise.note);
294
+ if (note && note.length > PROGRAM_DRAFT_LIMITS.noteMaxLen) return null;
295
+
296
+ return {
297
+ name,
298
+ muscleGroup,
299
+ lastSuggestion: '',
300
+ nextSuggestion: '',
301
+ sets,
302
+ ...(note ? { note } : {}),
303
+ ...(rir != null ? { rir } : {})
304
+ };
305
+ }
306
+
307
+ function normalizeProgramDraftDay(day, canonicalizeExerciseName) {
308
+ const dayLabel = String(day?.dayLabel ?? '').trim();
309
+ const title = String(day?.title ?? '').trim();
310
+ const subtitle = String(day?.subtitle ?? '').trim();
311
+ const exercises = Array.isArray(day?.exercises)
312
+ ? day.exercises.map((exercise) => normalizeProgramDraftExercise(exercise, canonicalizeExerciseName)).filter(Boolean)
313
+ : [];
314
+
315
+ if (!dayLabel || dayLabel.length > PROGRAM_DRAFT_LIMITS.dayLabelMaxLen) return null;
316
+ if (!title || title.length > PROGRAM_DRAFT_LIMITS.dayTitleMaxLen) return null;
317
+ if (subtitle.length > PROGRAM_DRAFT_LIMITS.daySubtitleMaxLen) return null;
318
+ if (
319
+ exercises.length < PROGRAM_DRAFT_LIMITS.minExercisesPerDay ||
320
+ exercises.length > PROGRAM_DRAFT_LIMITS.maxExercisesPerDay
321
+ ) return null;
322
+
323
+ return { dayLabel, title, subtitle, exercises };
324
+ }
325
+
326
+ function normalizeProgramDraft(rawProgram, { canonicalizeExerciseName } = {}) {
327
+ if (!rawProgram || typeof rawProgram !== 'object' || Array.isArray(rawProgram)) return null;
328
+
329
+ const name = String(rawProgram.name ?? '').trim();
330
+ const days = Array.isArray(rawProgram.days)
331
+ ? rawProgram.days.map((day) => normalizeProgramDraftDay(day, canonicalizeExerciseName)).filter(Boolean)
332
+ : [];
333
+ const daysPerWeek = Number(rawProgram.daysPerWeek);
334
+ const currentDayIndex = rawProgram.currentDayIndex == null ? 0 : Number(rawProgram.currentDayIndex);
335
+ const equipmentTier = String(rawProgram.equipmentTier ?? 'fullGym').trim();
336
+ const volumeLevel = String(rawProgram.volumeLevel ?? 'moderate').trim();
337
+
338
+ if (!name || name.length > PROGRAM_DRAFT_LIMITS.nameMaxLen) return null;
339
+ if (days.length < PROGRAM_DRAFT_LIMITS.minDays || days.length > PROGRAM_DRAFT_LIMITS.maxDays) return null;
340
+ if (
341
+ !Number.isInteger(daysPerWeek) ||
342
+ daysPerWeek < PROGRAM_DRAFT_LIMITS.minDaysPerWeek ||
343
+ daysPerWeek > PROGRAM_DRAFT_LIMITS.maxDaysPerWeek
344
+ ) return null;
345
+ if (!Number.isInteger(currentDayIndex) || currentDayIndex < 0 || currentDayIndex >= days.length) return null;
346
+ if (!VALID_PROGRAM_DRAFT_EQUIPMENT_TIERS.has(equipmentTier) || !VALID_PROGRAM_DRAFT_VOLUME_LEVELS.has(volumeLevel)) return null;
347
+
348
+ return {
349
+ name,
350
+ daysPerWeek,
351
+ equipmentTier,
352
+ volumeLevel,
353
+ source: 'guided',
354
+ days,
355
+ currentDayIndex
356
+ };
357
+ }
358
+
359
+ function extractAskProgramDraft(rawText, { canonicalizeExerciseName } = {}) {
360
+ const text = String(rawText ?? '');
361
+ const match = text.match(/<program_draft>\s*([\s\S]*?)\s*<\/program_draft>/i);
362
+ if (!match) {
363
+ return { answerText: text.trim(), programDraft: null };
364
+ }
365
+
366
+ const answerText = collapseBlankLines(text.replace(match[0], ''));
367
+ let parsed;
368
+ try {
369
+ parsed = JSON.parse(match[1]);
370
+ } catch (err) {
371
+ console.warn('askCoach: <program_draft> JSON parse failed — dropping draft:', err.message);
372
+ return { answerText, programDraft: null };
373
+ }
374
+
375
+ const program = normalizeProgramDraft(parsed, { canonicalizeExerciseName });
376
+ if (!program) {
377
+ console.warn('askCoach: <program_draft> payload failed validation — dropping draft');
378
+ return { answerText, programDraft: null };
379
+ }
380
+
381
+ return {
382
+ answerText,
383
+ programDraft: {
384
+ program,
385
+ provenance: {
386
+ source: 'ai-coach',
387
+ type: 'program',
388
+ version: PROGRAM_DRAFT_VERSION,
389
+ createdAt: new Date().toISOString(),
390
+ tokenHint: null
391
+ }
392
+ }
393
+ };
394
+ }
395
+
99
396
  function json(response, statusCode, payload) {
100
397
  response.writeHead(statusCode, { 'content-type': 'application/json' });
101
398
  response.end(JSON.stringify(payload));
@@ -117,17 +414,6 @@ function logRequest(request, statusCode, extra = '') {
117
414
  console.log(`${method} ${path} ${statusCode}${suffix}`);
118
415
  }
119
416
 
120
- function anonymizeAccountId(accountId) {
121
- if (typeof accountId !== 'string' || !accountId.trim()) {
122
- return 'anon:unknown';
123
- }
124
- const digest = createHash('sha256')
125
- .update(`account:${accountId}`)
126
- .digest('hex')
127
- .slice(0, 12);
128
- return `anon:${digest}`;
129
- }
130
-
131
417
  function anonymizeSessionToken(sessionToken) {
132
418
  if (typeof sessionToken !== 'string' || !sessionToken.trim()) {
133
419
  return 'sess:unknown';
@@ -215,6 +501,71 @@ function anonymizeRelationIds(items, { max = 5 } = {}) {
215
501
  .join(',');
216
502
  }
217
503
 
504
+ function currentAIGitSha() {
505
+ return process.env.RENDER_GIT_COMMIT
506
+ ?? process.env.GIT_SHA
507
+ ?? process.env.COMMIT_SHA
508
+ ?? process.env.VERCEL_GIT_COMMIT_SHA
509
+ ?? null;
510
+ }
511
+
512
+ function buildAIGenerationMetadata(surface, model, promptVersion, generation = {}) {
513
+ return {
514
+ surface,
515
+ generatedAt: new Date().toISOString(),
516
+ model: model ?? null,
517
+ promptVersion: promptVersion ?? null,
518
+ gitSha: currentAIGitSha(),
519
+ langfuseTraceId: generation.langfuseTraceId ?? null,
520
+ langfuseObservationId: generation.langfuseObservationId ?? null
521
+ };
522
+ }
523
+
524
+ function transcriptForCoachFactExtraction(messages, { maxChars = 4000 } = {}) {
525
+ const text = (Array.isArray(messages) ? messages : [])
526
+ .filter((message) => message?.role === 'user')
527
+ .slice(-8)
528
+ .map((message) => `user: ${String(message.content ?? '').trim()}`)
529
+ .join('\n')
530
+ .slice(-maxChars);
531
+ return text.trim();
532
+ }
533
+
534
+ async function extractAndSaveCoachFacts({
535
+ account,
536
+ sourceSurface,
537
+ sourceSessionId,
538
+ transcript,
539
+ openrouterKey,
540
+ aiUser,
541
+ saveCoachFactsForAccount,
542
+ generateCoachFactCandidatesImpl,
543
+ onError
544
+ }) {
545
+ if (!saveCoachFactsForAccount || !transcript || !openrouterKey) return [];
546
+ try {
547
+ const { generateCoachFactCandidates } = await import('./openrouter.js');
548
+ const generateFacts = generateCoachFactCandidatesImpl ?? generateCoachFactCandidates;
549
+ const factResult = await generateFacts(transcript, {
550
+ apiKey: openrouterKey,
551
+ user: aiUser,
552
+ sessionId: `coach-facts:${sourceSessionId ?? sourceSurface}`,
553
+ contextMetadata: {
554
+ sourceSurface,
555
+ sourceSessionId
556
+ }
557
+ });
558
+ return saveCoachFactsForAccount(account, factResult.facts ?? [], {
559
+ sourceSurface,
560
+ sourceSessionId
561
+ });
562
+ } catch (factErr) {
563
+ console.error('Coach fact extraction failed:', factErr.message);
564
+ onError?.(factErr, { feature: 'coach-fact-extraction', sourceSurface });
565
+ return [];
566
+ }
567
+ }
568
+
218
569
  function unauthorized(response, request) {
219
570
  if (request) logRequest(request, 401);
220
571
  json(response, 401, { error: 'Unauthorized' });
@@ -348,6 +699,10 @@ function routeRequest(url, method) {
348
699
  return { command: 'session-login', options: {} };
349
700
  }
350
701
 
702
+ if (pathname === '/auth/anonymous/start') {
703
+ return { command: 'anonymous-start', options: {} };
704
+ }
705
+
351
706
  if (pathname === '/auth/refresh') {
352
707
  return { command: 'session-refresh', options: {} };
353
708
  }
@@ -372,6 +727,10 @@ function routeRequest(url, method) {
372
727
  return { command: 'google-callback', options: {} };
373
728
  }
374
729
 
730
+ if (pathname === '/auth/google/mobile') {
731
+ return { command: 'google-mobile', options: {} };
732
+ }
733
+
375
734
  if (pathname === '/auth/apple/start') {
376
735
  return { command: 'apple-start', options: {} };
377
736
  }
@@ -396,6 +755,34 @@ function routeRequest(url, method) {
396
755
  return { command: 'sync-upload', options: {} };
397
756
  }
398
757
 
758
+ if (pathname === '/mobile/sync/bootstrap') {
759
+ return { command: 'mobile-sync-bootstrap', options: {} };
760
+ }
761
+
762
+ if (pathname === '/mobile/sync/pull') {
763
+ return {
764
+ command: 'mobile-sync-pull',
765
+ options: {
766
+ since: url.searchParams.get('since') ?? undefined
767
+ }
768
+ };
769
+ }
770
+
771
+ if (pathname === '/mobile/sync/push') {
772
+ return { command: 'mobile-sync-push', options: {} };
773
+ }
774
+
775
+ if (pathname === '/mobile/score-snapshots') {
776
+ return {
777
+ command: 'score-snapshots',
778
+ options: {
779
+ from: url.searchParams.get('from') ?? undefined,
780
+ to: url.searchParams.get('to') ?? undefined,
781
+ limit: url.searchParams.get('limit') ?? undefined
782
+ }
783
+ };
784
+ }
785
+
399
786
  if (pathname === '/cli/account') {
400
787
  return { command: 'delete-account', options: {} };
401
788
  }
@@ -472,7 +859,7 @@ function routeRequest(url, method) {
472
859
  if (programShareRevokeMatch) {
473
860
  return {
474
861
  command: 'program-share-revoke',
475
- options: { token: decodeURIComponent(programShareRevokeMatch[1]) }
862
+ options: { shareId: decodeURIComponent(programShareRevokeMatch[1]) }
476
863
  };
477
864
  }
478
865
  }
@@ -625,6 +1012,22 @@ function routeRequest(url, method) {
625
1012
  return { command: 'ai-feedback', options: {} };
626
1013
  }
627
1014
 
1015
+ if (pathname === '/cli/weekly-checkin/current') {
1016
+ return { command: 'weekly-checkin-current', options: {} };
1017
+ }
1018
+
1019
+ if (pathname === '/cli/weekly-checkin/enroll') {
1020
+ return { command: 'weekly-checkin-enroll', options: {} };
1021
+ }
1022
+
1023
+ if (pathname === '/cli/weekly-checkin/ack') {
1024
+ return { command: 'weekly-checkin-ack', options: {} };
1025
+ }
1026
+
1027
+ if (pathname === '/cli/weekly-checkin/start') {
1028
+ return { command: 'weekly-checkin-start', options: {} };
1029
+ }
1030
+
628
1031
  if (pathname === '/cli/health/ai') {
629
1032
  return {
630
1033
  command: 'health-ai',
@@ -1009,6 +1412,91 @@ function routeRequest(url, method) {
1009
1412
  return null;
1010
1413
  }
1011
1414
 
1415
+ /// Formats a `ProgramPhaseWindowContext` (sent by iOS in the request body) as
1416
+ /// a short text prelude prepended to the AI context. Without this the model
1417
+ /// would have to infer "is this a deload week / was last week deload?" from
1418
+ /// session prose; with it the structured phase facts are explicit.
1419
+ function formatProgramPhasePrelude(programPhase) {
1420
+ if (!programPhase || typeof programPhase !== 'object') return null;
1421
+ const current = programPhase.current;
1422
+ const previous = programPhase.previousWeek;
1423
+ const next = programPhase.nextWeek;
1424
+ if (!current?.phase || typeof current.displayWeek !== 'number') return null;
1425
+ const describe = (phase) => {
1426
+ if (!phase?.phase) return null;
1427
+ const week = typeof phase.displayWeek === 'number' ? `week ${phase.displayWeek}` : 'week ?';
1428
+ return `${week} (${phase.phase})${phase.isDeload ? ' · deload week' : ''}`;
1429
+ };
1430
+ const describeList = (phases) => {
1431
+ if (!Array.isArray(phases) || phases.length === 0) return null;
1432
+ return phases.map(describe).filter(Boolean).join(', ');
1433
+ };
1434
+ const lines = [
1435
+ '[Program phase]',
1436
+ `- Current: ${describe(current)}`
1437
+ ];
1438
+ if (previous?.phase) lines.push(`- Previous: ${describe(previous)}`);
1439
+ if (next?.phase) lines.push(`- Next: ${describe(next)}`);
1440
+ if (programPhase.isPostDeloadReturn === true) {
1441
+ lines.push('- Post-deload return: yes (last week was deload, this week is build)');
1442
+ }
1443
+ const range = describeList(programPhase.phasesInRange);
1444
+ if (range) lines.push(`- Range phases: ${range}`);
1445
+ const previousRange = describeList(programPhase.previousRangePhases);
1446
+ if (previousRange) lines.push(`- Previous range phases: ${previousRange}`);
1447
+ return lines.join('\n');
1448
+ }
1449
+
1450
+ export function formatIncrementScorePrelude(snapshots) {
1451
+ if (!Array.isArray(snapshots) || snapshots.length === 0) return null;
1452
+ const latest = snapshots[0];
1453
+ if (latest == null || typeof latest.score !== 'number') return null;
1454
+
1455
+ const lines = ['[Increment Score]'];
1456
+ const tier = latest.dataTier ? ` · ${latest.dataTier}` : '';
1457
+ lines.push(`- Current: ${latest.score}/100${tier}`);
1458
+
1459
+ if (latest.components && typeof latest.components === 'object') {
1460
+ const parts = [];
1461
+ for (const [name, value] of Object.entries(latest.components)) {
1462
+ const num = typeof value === 'number' ? value : value?.score;
1463
+ if (typeof num === 'number') parts.push(`${name} ${num}`);
1464
+ }
1465
+ if (parts.length > 0) lines.push(`- Components: ${parts.join(', ')}`);
1466
+ }
1467
+
1468
+ const driverLabels = (list) => {
1469
+ if (!Array.isArray(list) || list.length === 0) return null;
1470
+ return list
1471
+ .slice(0, 3)
1472
+ .map((d) => d?.label ?? d?.id ?? d?.driver)
1473
+ .filter(Boolean)
1474
+ .join('; ');
1475
+ };
1476
+ const positives = driverLabels(latest.topPositiveDrivers);
1477
+ if (positives) lines.push(`- Top positive drivers: ${positives}`);
1478
+ const negatives = driverLabels(latest.topNegativeDrivers);
1479
+ if (negatives) lines.push(`- Top negative drivers: ${negatives}`);
1480
+
1481
+ if (snapshots.length > 1) {
1482
+ const prior = snapshots[1];
1483
+ if (typeof prior?.score === 'number') {
1484
+ const delta = latest.score - prior.score;
1485
+ const sign = delta > 0 ? '+' : '';
1486
+ lines.push(`- Day-over-day delta: ${sign}${delta}`);
1487
+ }
1488
+ const recent = snapshots
1489
+ .slice(0, 7)
1490
+ .map((s) => (typeof s?.score === 'number' ? s.score : null))
1491
+ .filter((s) => s != null);
1492
+ if (recent.length >= 3) {
1493
+ lines.push(`- Last ${recent.length} days: ${recent.join(', ')}`);
1494
+ }
1495
+ }
1496
+
1497
+ return lines.join('\n');
1498
+ }
1499
+
1012
1500
  async function readJsonBody(request) {
1013
1501
  const chunks = [];
1014
1502
  let totalSize = 0;
@@ -1450,6 +1938,7 @@ export function createSyncServiceRequestHandler({
1450
1938
  writeSnapshotForAccount,
1451
1939
  issueDevLogin,
1452
1940
  issueSession,
1941
+ issueAnonymousWriteAccess,
1453
1942
  issueDeviceChallenge,
1454
1943
  consumeDeviceChallenge,
1455
1944
  readDeviceChallengeByUserCode,
@@ -1472,7 +1961,9 @@ export function createSyncServiceRequestHandler({
1472
1961
  buildGoogleWebAuthUrl = null,
1473
1962
  completeAppleWebAuth = null,
1474
1963
  completeGoogleWebAuth = null,
1964
+ completeGoogleMobileAuth = null,
1475
1965
  refreshSession,
1966
+ authenticateConnectedWriteToken,
1476
1967
  allowManualDeviceApproval = false,
1477
1968
  rateLimitConfig = null,
1478
1969
  publicOrigin = null,
@@ -1490,9 +1981,24 @@ export function createSyncServiceRequestHandler({
1490
1981
  listAskConversationsForAccount = null,
1491
1982
  getAskConversationForAccount = null,
1492
1983
  readCoachMemoryForAccount = null,
1493
- writeCoachMemoryForAccount = null,
1984
+ saveCoachFactsForAccount = null,
1985
+ listCoachFactsForAccount = null,
1986
+ saveCoachCommitmentsForAccount = null,
1987
+ listActiveCoachCommitmentsForAccount = null,
1988
+ getCurrentWeeklyCheckinForAccount = null,
1989
+ upsertScheduledWeeklyCheckinForAccount = null,
1990
+ transitionWeeklyCheckinForAccount = null,
1991
+ generateWeeklyCheckinRecapImpl = null,
1992
+ generateCheckinQuestionsImpl = null,
1494
1993
  saveAIFeedbackForAccount = null,
1994
+ generateAskAnswerImpl = null,
1995
+ generateCoachFactCandidatesImpl = null,
1495
1996
  deleteAccountForUser = null,
1997
+ loadMobileSyncStateForAccount = null,
1998
+ pullMobileSyncStateForAccount = null,
1999
+ pushMobileSyncChangesForAccount = null,
2000
+ insertScoreSnapshotsForAccount = null,
2001
+ listScoreSnapshotsForAccount = null,
1496
2002
  // Social
1497
2003
  social = null,
1498
2004
  onError = null
@@ -1837,10 +2343,12 @@ export function createSyncServiceRequestHandler({
1837
2343
 
1838
2344
  let code = url.searchParams.get('code') ?? '';
1839
2345
  let state = url.searchParams.get('state') ?? '';
2346
+ let user = null;
1840
2347
  if (request.method === 'POST') {
1841
2348
  const body = await readUrlEncodedBody(request);
1842
2349
  code = body.code ?? code;
1843
2350
  state = body.state ?? state;
2351
+ user = body.user ?? null;
1844
2352
  }
1845
2353
 
1846
2354
  if (!code || !state) {
@@ -1863,7 +2371,7 @@ export function createSyncServiceRequestHandler({
1863
2371
  }
1864
2372
 
1865
2373
  try {
1866
- const result = await completeAppleWebAuth({ code, state });
2374
+ const result = await completeAppleWebAuth({ code, state, user });
1867
2375
  const returnUrl = new URL(result.returnUrl);
1868
2376
  returnUrl.hash = `token=${result.session.accessToken}&expires=${result.session.expiresAt}`;
1869
2377
  response.writeHead(302, { location: returnUrl.toString() });
@@ -1891,7 +2399,7 @@ export function createSyncServiceRequestHandler({
1891
2399
  }
1892
2400
 
1893
2401
  try {
1894
- const result = await completeAppleDeviceApproval({ code, state });
2402
+ const result = await completeAppleDeviceApproval({ code, state, user });
1895
2403
  html(response, 200, deviceApprovalSuccessPage({
1896
2404
  email: result.account.email ?? '',
1897
2405
  userId: result.account.id
@@ -2119,7 +2627,7 @@ export function createSyncServiceRequestHandler({
2119
2627
  }
2120
2628
  json(response, 200, {
2121
2629
  ok: true,
2122
- token: shared.share.token,
2630
+ token: route.options.token,
2123
2631
  version: shared.share.version,
2124
2632
  programId: shared.share.programId,
2125
2633
  programName: shared.share.programPayload?.name ?? null,
@@ -2138,6 +2646,47 @@ export function createSyncServiceRequestHandler({
2138
2646
  }
2139
2647
  }
2140
2648
 
2649
+ if (route.command === 'google-mobile') {
2650
+ if (request.method !== 'POST') {
2651
+ methodNotAllowed(response, 'Use POST for /auth/google/mobile.');
2652
+ return;
2653
+ }
2654
+
2655
+ if (!googleAuth?.configured || !completeGoogleMobileAuth) {
2656
+ methodNotAllowed(response, 'Google mobile auth is not enabled for this service mode.');
2657
+ return;
2658
+ }
2659
+
2660
+ try {
2661
+ const body = await readJsonBody(request);
2662
+ const idToken = typeof body?.idToken === 'string' ? body.idToken.trim() : '';
2663
+ if (!idToken) {
2664
+ badRequest(response, 'idToken is required.');
2665
+ return;
2666
+ }
2667
+
2668
+ const result = await completeGoogleMobileAuth({ idToken });
2669
+ json(response, 200, {
2670
+ ok: true,
2671
+ session: {
2672
+ accessToken: result.session.accessToken,
2673
+ expiresAt: result.session.expiresAt
2674
+ },
2675
+ account: result.account
2676
+ });
2677
+ return;
2678
+ } catch (error) {
2679
+ reportAuthFailure(onError, error, {
2680
+ route: 'google-mobile',
2681
+ provider: 'google',
2682
+ authFlow: 'mobile',
2683
+ statusCode: 400
2684
+ });
2685
+ badRequest(response, error.message);
2686
+ return;
2687
+ }
2688
+ }
2689
+
2141
2690
  const requestToken = bearerToken(request);
2142
2691
  if (route.command === 'session-login') {
2143
2692
  if (request.method !== 'POST') {
@@ -2176,6 +2725,43 @@ export function createSyncServiceRequestHandler({
2176
2725
  return;
2177
2726
  }
2178
2727
 
2728
+ if (route.command === 'anonymous-start') {
2729
+ if (request.method !== 'POST') {
2730
+ methodNotAllowed(response, 'Use POST for /auth/anonymous/start.');
2731
+ return;
2732
+ }
2733
+
2734
+ if (!issueAnonymousWriteAccess) {
2735
+ methodNotAllowed(response, 'Anonymous hosted persistence is not enabled for this service mode.');
2736
+ return;
2737
+ }
2738
+
2739
+ try {
2740
+ const body = await readJsonBody(request);
2741
+ const installId = typeof body?.installId === 'string' ? body.installId.trim() : '';
2742
+ if (!installId) {
2743
+ badRequest(response, 'installId is required.');
2744
+ return;
2745
+ }
2746
+
2747
+ const issued = await issueAnonymousWriteAccess({ installId });
2748
+ // Return only the minimal account shape. `issued.account` also carries
2749
+ // internal fields (identities, consent timestamps, capabilities) that
2750
+ // the client doesn't need and shouldn't receive on an anon endpoint.
2751
+ json(response, 200, {
2752
+ ok: true,
2753
+ account: { id: issued.account.id, email: issued.account.email ?? null },
2754
+ anonymous: {
2755
+ accessToken: issued.accessToken
2756
+ }
2757
+ });
2758
+ return;
2759
+ } catch (error) {
2760
+ badRequest(response, error.message);
2761
+ return;
2762
+ }
2763
+ }
2764
+
2179
2765
  if (route.command === 'session-refresh') {
2180
2766
  if (request.method !== 'POST') {
2181
2767
  methodNotAllowed(response, 'Use POST for /auth/refresh.');
@@ -2211,6 +2797,132 @@ export function createSyncServiceRequestHandler({
2211
2797
 
2212
2798
  const readAuthenticator = authenticateReadToken ?? authenticateToken;
2213
2799
  const writeAuthenticator = authenticateWriteToken ?? authenticateToken;
2800
+ const connectedWriteAuthenticator = authenticateConnectedWriteToken ?? readAuthenticator;
2801
+ const mobileSyncAuthenticator = writeAuthenticator;
2802
+
2803
+ if (route.command === 'mobile-sync-bootstrap') {
2804
+ if (request.method !== 'GET') {
2805
+ methodNotAllowed(response, 'Use GET for /mobile/sync/bootstrap.');
2806
+ return;
2807
+ }
2808
+ if (!loadMobileSyncStateForAccount) {
2809
+ methodNotAllowed(response, 'Mobile sync bootstrap is not enabled for this service mode.');
2810
+ return;
2811
+ }
2812
+
2813
+ const account = mobileSyncAuthenticator
2814
+ ? await mobileSyncAuthenticator(requestToken)
2815
+ : null;
2816
+ if (!account) {
2817
+ unauthorized(response, request);
2818
+ return;
2819
+ }
2820
+
2821
+ json(response, 200, await loadMobileSyncStateForAccount(account));
2822
+ return;
2823
+ }
2824
+
2825
+ if (route.command === 'mobile-sync-pull') {
2826
+ if (request.method !== 'GET') {
2827
+ methodNotAllowed(response, 'Use GET for /mobile/sync/pull.');
2828
+ return;
2829
+ }
2830
+ if (!pullMobileSyncStateForAccount) {
2831
+ methodNotAllowed(response, 'Mobile sync pull is not enabled for this service mode.');
2832
+ return;
2833
+ }
2834
+
2835
+ const account = mobileSyncAuthenticator
2836
+ ? await mobileSyncAuthenticator(requestToken)
2837
+ : null;
2838
+ if (!account) {
2839
+ unauthorized(response, request);
2840
+ return;
2841
+ }
2842
+
2843
+ json(response, 200, await pullMobileSyncStateForAccount(account, route.options.since));
2844
+ return;
2845
+ }
2846
+
2847
+ if (route.command === 'score-snapshots') {
2848
+ const account = connectedWriteAuthenticator
2849
+ ? await connectedWriteAuthenticator(requestToken)
2850
+ : null;
2851
+ if (!account) {
2852
+ unauthorized(response, request);
2853
+ return;
2854
+ }
2855
+
2856
+ if (request.method === 'POST') {
2857
+ if (!insertScoreSnapshotsForAccount) {
2858
+ methodNotAllowed(response, 'Score snapshot upload is not enabled for this service mode.');
2859
+ return;
2860
+ }
2861
+ try {
2862
+ const body = await readJsonBody(request);
2863
+ if (!body || typeof body !== 'object' || !Array.isArray(body.snapshots)) {
2864
+ badRequest(response, 'Invalid score snapshots body: expected an object with a snapshots array.');
2865
+ return;
2866
+ }
2867
+ const result = await insertScoreSnapshotsForAccount(account, body.snapshots);
2868
+ json(response, 200, result);
2869
+ return;
2870
+ } catch (error) {
2871
+ badRequest(response, error.message);
2872
+ return;
2873
+ }
2874
+ }
2875
+
2876
+ if (request.method === 'GET') {
2877
+ if (!listScoreSnapshotsForAccount) {
2878
+ methodNotAllowed(response, 'Score snapshot history is not enabled for this service mode.');
2879
+ return;
2880
+ }
2881
+ try {
2882
+ const snapshots = await listScoreSnapshotsForAccount(account, route.options ?? {});
2883
+ json(response, 200, { snapshots });
2884
+ return;
2885
+ } catch (error) {
2886
+ badRequest(response, error.message);
2887
+ return;
2888
+ }
2889
+ }
2890
+
2891
+ methodNotAllowed(response, 'Use POST to upload or GET to list score snapshots.');
2892
+ return;
2893
+ }
2894
+
2895
+ if (route.command === 'mobile-sync-push') {
2896
+ if (request.method !== 'POST') {
2897
+ methodNotAllowed(response, 'Use POST for /mobile/sync/push.');
2898
+ return;
2899
+ }
2900
+ if (!pushMobileSyncChangesForAccount) {
2901
+ methodNotAllowed(response, 'Mobile sync push is not enabled for this service mode.');
2902
+ return;
2903
+ }
2904
+
2905
+ const account = mobileSyncAuthenticator
2906
+ ? await mobileSyncAuthenticator(requestToken)
2907
+ : null;
2908
+ if (!account) {
2909
+ unauthorized(response, request);
2910
+ return;
2911
+ }
2912
+
2913
+ try {
2914
+ const body = await readJsonBody(request);
2915
+ if (!body || typeof body !== 'object' || !Array.isArray(body.changes)) {
2916
+ badRequest(response, 'Invalid mobile sync push body: expected an object with a changes array.');
2917
+ return;
2918
+ }
2919
+ json(response, 200, await pushMobileSyncChangesForAccount(account, body));
2920
+ return;
2921
+ } catch (error) {
2922
+ badRequest(response, error.message);
2923
+ return;
2924
+ }
2925
+ }
2214
2926
 
2215
2927
  if (route.command === 'delete-account') {
2216
2928
  if (request.method !== 'DELETE') {
@@ -2249,8 +2961,8 @@ export function createSyncServiceRequestHandler({
2249
2961
  return;
2250
2962
  }
2251
2963
 
2252
- const proposalAccount = writeAuthenticator
2253
- ? await writeAuthenticator(requestToken)
2964
+ const proposalAccount = connectedWriteAuthenticator
2965
+ ? await connectedWriteAuthenticator(requestToken)
2254
2966
  : requestToken === token
2255
2967
  ? { id: 'remote-user', email: null }
2256
2968
  : null;
@@ -2317,8 +3029,8 @@ export function createSyncServiceRequestHandler({
2317
3029
  return;
2318
3030
  }
2319
3031
 
2320
- const proposalAccount = writeAuthenticator
2321
- ? await writeAuthenticator(requestToken)
3032
+ const proposalAccount = connectedWriteAuthenticator
3033
+ ? await connectedWriteAuthenticator(requestToken)
2322
3034
  : requestToken === token
2323
3035
  ? { id: 'remote-user', email: null }
2324
3036
  : null;
@@ -2357,8 +3069,8 @@ export function createSyncServiceRequestHandler({
2357
3069
  methodNotAllowed(response, 'Program sharing is not enabled for this service mode.');
2358
3070
  return;
2359
3071
  }
2360
- const account = writeAuthenticator
2361
- ? await writeAuthenticator(requestToken)
3072
+ const account = connectedWriteAuthenticator
3073
+ ? await connectedWriteAuthenticator(requestToken)
2362
3074
  : null;
2363
3075
  if (!account) {
2364
3076
  unauthorized(response, request);
@@ -2368,6 +3080,8 @@ export function createSyncServiceRequestHandler({
2368
3080
  const share = await createProgramShareForAccount(account, route.options.programId);
2369
3081
  json(response, 201, {
2370
3082
  ok: true,
3083
+ shareId: share.id,
3084
+ tokenHint: share.tokenHint,
2371
3085
  token: share.token,
2372
3086
  programId: share.programId,
2373
3087
  createdAt: share.createdAt,
@@ -2375,7 +3089,7 @@ export function createSyncServiceRequestHandler({
2375
3089
  revokedAt: share.revokedAt,
2376
3090
  version: share.version,
2377
3091
  link: `${resolvedPublicOrigin}/program-share/${share.token}`,
2378
- deepLink: `incremnt://plan-share/${share.token}`
3092
+ deepLink: `incremnt://plan-share/${share.token}?base=${encodeURIComponent(resolvedPublicOrigin)}`
2379
3093
  });
2380
3094
  return;
2381
3095
  } catch (error) {
@@ -2410,7 +3124,8 @@ export function createSyncServiceRequestHandler({
2410
3124
  json(response, 200, {
2411
3125
  ok: true,
2412
3126
  shares: rows.map((share) => ({
2413
- token: share.token,
3127
+ shareId: share.id,
3128
+ tokenHint: share.tokenHint,
2414
3129
  programId: share.programId,
2415
3130
  createdAt: share.createdAt,
2416
3131
  expiresAt: share.expiresAt,
@@ -2438,27 +3153,28 @@ export function createSyncServiceRequestHandler({
2438
3153
  methodNotAllowed(response, 'Program sharing is not enabled for this service mode.');
2439
3154
  return;
2440
3155
  }
2441
- const account = writeAuthenticator
2442
- ? await writeAuthenticator(requestToken)
3156
+ const account = connectedWriteAuthenticator
3157
+ ? await connectedWriteAuthenticator(requestToken)
2443
3158
  : null;
2444
3159
  if (!account) {
2445
3160
  unauthorized(response, request);
2446
3161
  return;
2447
3162
  }
2448
3163
  try {
2449
- const share = await revokeProgramShareForAccount(account, route.options.token);
3164
+ const share = await revokeProgramShareForAccount(account, route.options.shareId);
2450
3165
  if (!share) {
2451
3166
  notFound(response, 'Program share not found.');
2452
3167
  return;
2453
3168
  }
2454
3169
  json(response, 200, {
2455
3170
  ok: true,
2456
- token: share.token,
3171
+ shareId: share.id,
3172
+ tokenHint: share.tokenHint,
2457
3173
  revokedAt: share.revokedAt
2458
3174
  });
2459
3175
  return;
2460
3176
  } catch (error) {
2461
- if (error?.message === 'Invalid program share token.') {
3177
+ if (error?.message === 'Invalid program share id.') {
2462
3178
  badRequest(response, error.message);
2463
3179
  return;
2464
3180
  }
@@ -2571,8 +3287,8 @@ export function createSyncServiceRequestHandler({
2571
3287
  return;
2572
3288
  }
2573
3289
 
2574
- const account = writeAuthenticator
2575
- ? await writeAuthenticator(requestToken)
3290
+ const account = connectedWriteAuthenticator
3291
+ ? await connectedWriteAuthenticator(requestToken)
2576
3292
  : requestToken === token
2577
3293
  ? { id: 'remote-user', email: null }
2578
3294
  : null;
@@ -2649,8 +3365,8 @@ export function createSyncServiceRequestHandler({
2649
3365
  return;
2650
3366
  }
2651
3367
 
2652
- const writeAccount = writeAuthenticator
2653
- ? await writeAuthenticator(requestToken)
3368
+ const writeAccount = connectedWriteAuthenticator
3369
+ ? await connectedWriteAuthenticator(requestToken)
2654
3370
  : requestToken === token
2655
3371
  ? { id: 'remote-user', email: null }
2656
3372
  : null;
@@ -2710,8 +3426,8 @@ export function createSyncServiceRequestHandler({
2710
3426
  return;
2711
3427
  }
2712
3428
 
2713
- const writeAccount = writeAuthenticator
2714
- ? await writeAuthenticator(requestToken)
3429
+ const writeAccount = connectedWriteAuthenticator
3430
+ ? await connectedWriteAuthenticator(requestToken)
2715
3431
  : requestToken === token
2716
3432
  ? { id: 'remote-user', email: null }
2717
3433
  : null;
@@ -2759,6 +3475,7 @@ export function createSyncServiceRequestHandler({
2759
3475
  }
2760
3476
  // Parse comma-separated exclude param into a Set for AI context builders
2761
3477
  const parseExclude = (raw) => new Set((raw ?? '').split(',').map((s) => s.trim()).filter(Boolean));
3478
+ const aiUser = anonymizeAccountId(account.id);
2762
3479
 
2763
3480
  if (route.command === 'workout-summary-ai') {
2764
3481
  const sessionId = route.options['session-id'];
@@ -2768,7 +3485,8 @@ export function createSyncServiceRequestHandler({
2768
3485
  }
2769
3486
 
2770
3487
  const { workoutSummaryContext } = await import('./queries.js');
2771
- const ctx = workoutSummaryContext(snapshot, sessionId, { exclude: parseExclude(route.options['exclude']) });
3488
+ const exclude = parseExclude(route.options['exclude']);
3489
+ const ctx = workoutSummaryContext(snapshot, sessionId, { exclude });
2772
3490
  if (!ctx) {
2773
3491
  notFound(response, `Session not found: ${sessionId}`);
2774
3492
  return;
@@ -2781,8 +3499,17 @@ export function createSyncServiceRequestHandler({
2781
3499
  }
2782
3500
 
2783
3501
  try {
2784
- const { generateWorkoutCoachingSummary } = await import('./openrouter.js');
2785
- const result = await generateWorkoutCoachingSummary(ctx, { apiKey: openrouterKey, tone: route.options['tone'] });
3502
+ const { AI_PROMPT_VERSIONS, generateWorkoutCoachingSummary } = await import('./openrouter.js');
3503
+ const result = await generateWorkoutCoachingSummary(ctx, {
3504
+ apiKey: openrouterKey,
3505
+ tone: route.options['tone'],
3506
+ user: aiUser,
3507
+ sessionId: `workout:${sessionId}`,
3508
+ contextMetadata: {
3509
+ excludedSections: [...exclude],
3510
+ sessionId
3511
+ }
3512
+ });
2786
3513
  if (isNoInsightResponse(result.text)) {
2787
3514
  response.writeHead(204).end();
2788
3515
  return;
@@ -2804,7 +3531,11 @@ export function createSyncServiceRequestHandler({
2804
3531
  json(response, 200, { summary: null, model: result.model, filtered: true });
2805
3532
  return;
2806
3533
  }
2807
- json(response, 200, { summary: stripXMLTagBlocks(result.text), model: result.model });
3534
+ json(response, 200, {
3535
+ summary: stripXMLTagBlocks(result.text),
3536
+ model: result.model,
3537
+ metadata: buildAIGenerationMetadata('workout', result.model, AI_PROMPT_VERSIONS.workout, result)
3538
+ });
2808
3539
  } catch (err) {
2809
3540
  console.error('AI workout summary error:', err.message);
2810
3541
  onError?.(err, {
@@ -2841,6 +3572,245 @@ export function createSyncServiceRequestHandler({
2841
3572
  return;
2842
3573
  }
2843
3574
 
3575
+ if (route.command === 'weekly-checkin-enroll') {
3576
+ if (request.method !== 'POST') {
3577
+ methodNotAllowed(response, 'Use POST for /cli/weekly-checkin/enroll.');
3578
+ return;
3579
+ }
3580
+ if (!upsertScheduledWeeklyCheckinForAccount) {
3581
+ json(response, 503, { error: 'Weekly check-in not available' });
3582
+ return;
3583
+ }
3584
+ let body;
3585
+ try { body = await readJsonBody(request); } catch { badRequest(response, 'Invalid JSON body.'); return; }
3586
+ const timezoneId = body?.timezoneId;
3587
+ if (!isValidTimeZone(timezoneId)) {
3588
+ badRequest(response, 'timezoneId must be a valid IANA timezone identifier.');
3589
+ return;
3590
+ }
3591
+ const schedule = nextWeeklyCheckinSchedule(timezoneId);
3592
+ try {
3593
+ const row = await upsertScheduledWeeklyCheckinForAccount(account, {
3594
+ id: `weekly-checkin:${account.id}:${schedule.weekStartDate}:${randomUUID()}`,
3595
+ weekStartDate: schedule.weekStartDate,
3596
+ nextRecapDueAt: schedule.nextRecapDueAt,
3597
+ timezoneId
3598
+ });
3599
+ json(response, 200, {
3600
+ id: row.id,
3601
+ weekStartDate: row.weekStartDate,
3602
+ status: row.status,
3603
+ nextRecapDueAt: row.nextRecapDueAt,
3604
+ timezoneId: row.timezoneId
3605
+ });
3606
+ } catch (err) {
3607
+ console.error('Weekly check-in enroll error:', err.message);
3608
+ json(response, 500, { error: 'Failed to enroll weekly check-in' });
3609
+ }
3610
+ return;
3611
+ }
3612
+
3613
+ if (route.command === 'weekly-checkin-current') {
3614
+ if (request.method !== 'GET') {
3615
+ methodNotAllowed(response, 'Use GET for /cli/weekly-checkin/current.');
3616
+ return;
3617
+ }
3618
+ if (!getCurrentWeeklyCheckinForAccount) {
3619
+ json(response, 503, { error: 'Weekly check-in not available' });
3620
+ return;
3621
+ }
3622
+ try {
3623
+ const row = await getCurrentWeeklyCheckinForAccount(account);
3624
+ if (!row) {
3625
+ notFound(response, 'No weekly check-in scheduled yet.');
3626
+ return;
3627
+ }
3628
+ // Lazy-gen path: if scheduled and overdue, attempt to generate now.
3629
+ if (row.status === 'scheduled' && row.nextRecapDueAt && new Date(row.nextRecapDueAt) <= new Date()) {
3630
+ const openrouterKey = process.env.OPENROUTER_API_KEY;
3631
+ let recap = null;
3632
+ if (openrouterKey && generateWeeklyCheckinRecapImpl && generateCheckinQuestionsImpl) {
3633
+ try {
3634
+ const { weeklyCheckinContext } = await import('./queries.js');
3635
+ const ctx = weeklyCheckinContext(snapshot, account.id, {});
3636
+ if (ctx) {
3637
+ let priorCommitmentRow = null;
3638
+ if (listActiveCoachCommitmentsForAccount) {
3639
+ try {
3640
+ const activeCommitments = await listActiveCoachCommitmentsForAccount(account, {
3641
+ limit: 1,
3642
+ weekStartDate: ctx.weekRangeIso?.start
3643
+ });
3644
+ priorCommitmentRow = activeCommitments[0] ?? null;
3645
+ } catch (commitmentErr) {
3646
+ console.error('Lazy weekly-checkin commitment read failed:', commitmentErr.message);
3647
+ }
3648
+ }
3649
+ const recapResult = await generateWeeklyCheckinRecapImpl(ctx, {
3650
+ apiKey: openrouterKey,
3651
+ user: aiUser,
3652
+ sessionId: `weekly-checkin:${row.id}:recap`,
3653
+ priorCommitment: priorCommitmentRow?.commitment ?? null,
3654
+ contextMetadata: {
3655
+ coachCommitmentIds: priorCommitmentRow?.id ? [priorCommitmentRow.id] : []
3656
+ }
3657
+ });
3658
+ const questionsResult = await generateCheckinQuestionsImpl(ctx, recapResult.text, {
3659
+ apiKey: openrouterKey,
3660
+ user: aiUser,
3661
+ sessionId: `weekly-checkin:${row.id}:questions`
3662
+ });
3663
+ recap = {
3664
+ recapText: recapResult.text,
3665
+ questions: questionsResult.questions,
3666
+ model: recapResult.model,
3667
+ generatedAt: new Date().toISOString()
3668
+ };
3669
+ }
3670
+ } catch (genErr) {
3671
+ console.error('Lazy weekly-checkin gen failed:', genErr.message);
3672
+ }
3673
+ }
3674
+ if (!recap) {
3675
+ recap = { recapText: 'Your recap is being prepared.', questions: [], placeholder: true, generatedAt: new Date().toISOString() };
3676
+ }
3677
+ if (transitionWeeklyCheckinForAccount) {
3678
+ try {
3679
+ const updated = await transitionWeeklyCheckinForAccount(account, row.id, { toStatus: 'generated', recap });
3680
+ if (updated) Object.assign(row, updated);
3681
+ } catch (tErr) {
3682
+ console.error('Lazy weekly-checkin transition failed:', tErr.message);
3683
+ const latest = await getCurrentWeeklyCheckinForAccount(account);
3684
+ if (latest?.id === row.id) {
3685
+ Object.assign(row, latest);
3686
+ }
3687
+ }
3688
+ }
3689
+ }
3690
+ json(response, 200, {
3691
+ id: row.id,
3692
+ weekStartDate: row.weekStartDate,
3693
+ status: row.status,
3694
+ recap: row.recap,
3695
+ conversationId: row.conversationId
3696
+ });
3697
+ } catch (err) {
3698
+ console.error('Weekly check-in current error:', err.message);
3699
+ json(response, 500, { error: 'Failed to load weekly check-in' });
3700
+ }
3701
+ return;
3702
+ }
3703
+
3704
+ if (route.command === 'weekly-checkin-ack') {
3705
+ if (request.method !== 'POST') {
3706
+ methodNotAllowed(response, 'Use POST for /cli/weekly-checkin/ack.');
3707
+ return;
3708
+ }
3709
+ if (!getCurrentWeeklyCheckinForAccount || !transitionWeeklyCheckinForAccount) {
3710
+ json(response, 503, { error: 'Weekly check-in not available' });
3711
+ return;
3712
+ }
3713
+ let body;
3714
+ try { body = await readJsonBody(request); } catch { badRequest(response, 'Invalid JSON body.'); return; }
3715
+ const action = body?.action;
3716
+ if (action !== 'opened' && action !== 'dismissed') {
3717
+ badRequest(response, 'action must be "opened" or "dismissed".');
3718
+ return;
3719
+ }
3720
+ const row = await getCurrentWeeklyCheckinForAccount(account);
3721
+ if (!row) {
3722
+ notFound(response, 'No weekly check-in.');
3723
+ return;
3724
+ }
3725
+ try {
3726
+ const updated = await transitionWeeklyCheckinForAccount(account, row.id, { toStatus: action });
3727
+ if (!updated) {
3728
+ badRequest(response, `Cannot transition from ${row.status} to ${action}.`);
3729
+ return;
3730
+ }
3731
+ json(response, 200, { id: updated.id, status: updated.status });
3732
+ } catch (err) {
3733
+ console.error('Weekly check-in ack error:', err.message);
3734
+ if (err.code === 'invalid_transition') {
3735
+ badRequest(response, err.message);
3736
+ return;
3737
+ }
3738
+ json(response, 500, { error: 'Failed to ack weekly check-in' });
3739
+ }
3740
+ return;
3741
+ }
3742
+
3743
+ if (route.command === 'weekly-checkin-start') {
3744
+ if (request.method !== 'POST') {
3745
+ methodNotAllowed(response, 'Use POST for /cli/weekly-checkin/start.');
3746
+ return;
3747
+ }
3748
+ if (!getCurrentWeeklyCheckinForAccount || !transitionWeeklyCheckinForAccount || !saveAskConversationForAccount) {
3749
+ json(response, 503, { error: 'Weekly check-in not available' });
3750
+ return;
3751
+ }
3752
+ const row = await getCurrentWeeklyCheckinForAccount(account);
3753
+ if (!row) {
3754
+ notFound(response, 'No weekly check-in scheduled.');
3755
+ return;
3756
+ }
3757
+ if (!row.recap || row.recap.placeholder) {
3758
+ json(response, 409, { error: 'Weekly check-in recap not ready yet.', code: 'recap_not_ready' });
3759
+ return;
3760
+ }
3761
+ const conversationId = `weekly-checkin:${row.id}`;
3762
+ const recapText = String(row.recap.recapText ?? '').trim();
3763
+ const questions = Array.isArray(row.recap.questions) ? row.recap.questions : [];
3764
+ const firstAssistantMessage = [recapText, ...(questions.length ? [questions.map((q, i) => `${i + 1}. ${q}`).join('\n')] : [])].filter(Boolean).join('\n\n');
3765
+ try {
3766
+ const { AI_PROMPT_VERSIONS } = await import('./openrouter.js');
3767
+ const existingConversation = getAskConversationForAccount
3768
+ ? await getAskConversationForAccount(account, conversationId)
3769
+ : null;
3770
+ if (Array.isArray(existingConversation?.messages) && existingConversation.messages.length > 0) {
3771
+ const existingFirstAssistant = existingConversation.messages.find((m) => m.role === 'assistant')?.content;
3772
+ json(response, 200, {
3773
+ conversationId,
3774
+ firstAssistantMessage: existingFirstAssistant || firstAssistantMessage,
3775
+ questions,
3776
+ resumed: true
3777
+ });
3778
+ return;
3779
+ }
3780
+ if (row.status !== 'in_progress') {
3781
+ let openedRow = row;
3782
+ if (openedRow.status === 'generated' || openedRow.status === 'delivered') {
3783
+ openedRow = await transitionWeeklyCheckinForAccount(account, row.id, { toStatus: 'opened' });
3784
+ }
3785
+ if (!openedRow || openedRow.status !== 'opened') {
3786
+ json(response, 409, { error: `Cannot start weekly check-in from ${row.status}.`, code: 'invalid_state' });
3787
+ return;
3788
+ }
3789
+ const inProgress = await transitionWeeklyCheckinForAccount(account, row.id, { toStatus: 'in_progress', conversationId });
3790
+ if (!inProgress || inProgress.status !== 'in_progress') {
3791
+ json(response, 409, { error: 'Weekly check-in state was not updated.', code: 'state_update_failed' });
3792
+ return;
3793
+ }
3794
+ }
3795
+ await saveAskConversationForAccount(account, {
3796
+ id: conversationId,
3797
+ messages: [{ role: 'assistant', content: firstAssistantMessage }],
3798
+ model: row.recap.model ?? null,
3799
+ metadata: buildAIGenerationMetadata('weekly-checkin', row.recap.model ?? null, AI_PROMPT_VERSIONS.weeklyCheckin, row.recap),
3800
+ kind: 'weekly-checkin'
3801
+ });
3802
+ json(response, 200, { conversationId, firstAssistantMessage, questions });
3803
+ } catch (err) {
3804
+ console.error('Weekly check-in start error:', err.message);
3805
+ if (err.code === 'invalid_transition') {
3806
+ json(response, 409, { error: err.message, code: 'invalid_transition' });
3807
+ return;
3808
+ }
3809
+ json(response, 500, { error: 'Failed to start weekly check-in' });
3810
+ }
3811
+ return;
3812
+ }
3813
+
2844
3814
  if (route.command === 'cycle-summary-ai') {
2845
3815
  const programId = route.options['program-id'];
2846
3816
  if (!programId) {
@@ -2849,22 +3819,13 @@ export function createSyncServiceRequestHandler({
2849
3819
  }
2850
3820
 
2851
3821
  const { cycleSummaryContext } = await import('./queries.js');
2852
- const ctx = cycleSummaryContext(snapshot, programId, { exclude: parseExclude(route.options['exclude']) });
3822
+ const exclude = parseExclude(route.options['exclude']);
3823
+ const ctx = cycleSummaryContext(snapshot, programId, { exclude });
2853
3824
  if (!ctx) {
2854
3825
  notFound(response, `No completed cycle found for program: ${programId}`);
2855
3826
  return;
2856
3827
  }
2857
3828
 
2858
- // Inject coach memory into cycle summary context if available
2859
- let coachMemory = null;
2860
- if (readCoachMemoryForAccount) {
2861
- try {
2862
- coachMemory = await readCoachMemoryForAccount(account);
2863
- } catch (memErr) {
2864
- console.error('Coach memory read error (cycle-summary):', memErr.message);
2865
- }
2866
- }
2867
-
2868
3829
  const openrouterKey = process.env.OPENROUTER_API_KEY;
2869
3830
  if (!openrouterKey) {
2870
3831
  json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
@@ -2872,11 +3833,17 @@ export function createSyncServiceRequestHandler({
2872
3833
  }
2873
3834
 
2874
3835
  try {
2875
- const { generateCoachingSummary } = await import('./openrouter.js');
2876
- if (coachMemory?.content) {
2877
- ctx.coachMemory = coachMemory.content;
2878
- }
2879
- const result = await generateCoachingSummary(ctx, { apiKey: openrouterKey, tone: route.options['tone'] });
3836
+ const { AI_PROMPT_VERSIONS, generateCoachingSummary } = await import('./openrouter.js');
3837
+ const result = await generateCoachingSummary(ctx, {
3838
+ apiKey: openrouterKey,
3839
+ tone: route.options['tone'],
3840
+ user: aiUser,
3841
+ sessionId: `cycle:${programId}:${ctx.cycleNumber}`,
3842
+ contextMetadata: {
3843
+ excludedSections: [...exclude],
3844
+ programId
3845
+ }
3846
+ });
2880
3847
  if (result.fallback && onError) {
2881
3848
  const warning = new Error(`AI cycle-summary used fallback model ${result.model}`);
2882
3849
  warning.level = 'warning';
@@ -2894,32 +3861,11 @@ export function createSyncServiceRequestHandler({
2894
3861
  json(response, 200, { summary: null, model: result.model, filtered: true });
2895
3862
  return;
2896
3863
  }
2897
- json(response, 200, { summary: stripXMLTagBlocks(result.text), model: result.model });
2898
-
2899
- // Background: update coach memory after responding
2900
- if (writeCoachMemoryForAccount && readCoachMemoryForAccount) {
2901
- setImmediate(async () => {
2902
- try {
2903
- const { generateMemoryUpdate } = await import('./openrouter.js');
2904
- // Build recent context from previous cycle summaries
2905
- const recentLines = (ctx.previousCycles || [])
2906
- .filter((pc) => pc.previousAISummary)
2907
- .map((pc) => `Week ${pc.weekNumber}: ${pc.previousAISummary.split('\n')[0].slice(0, 200)}`)
2908
- .join('\n');
2909
- const memResult = await generateMemoryUpdate(
2910
- coachMemory?.content || '',
2911
- result.text,
2912
- recentLines || null,
2913
- { apiKey: openrouterKey }
2914
- );
2915
- await writeCoachMemoryForAccount(account, memResult.text);
2916
- console.log(`Coach memory updated for account (v${(coachMemory?.version ?? 0) + 1})`);
2917
- } catch (memErr) {
2918
- console.error('Background coach memory update failed:', memErr.message);
2919
- onError?.(memErr, { feature: 'coach-memory-update' });
2920
- }
2921
- });
2922
- }
3864
+ json(response, 200, {
3865
+ summary: stripXMLTagBlocks(result.text),
3866
+ model: result.model,
3867
+ metadata: buildAIGenerationMetadata('cycle', result.model, AI_PROMPT_VERSIONS.cycle, result)
3868
+ });
2923
3869
  } catch (err) {
2924
3870
  console.error('AI cycle summary error:', err.message);
2925
3871
  onError?.(err, {
@@ -2940,11 +3886,21 @@ export function createSyncServiceRequestHandler({
2940
3886
  }
2941
3887
 
2942
3888
  const { vitalsSummaryContext } = await import('./queries.js');
2943
- const ctx = vitalsSummaryContext(snapshot, { exclude: parseExclude(route.options['exclude']) });
3889
+ const exclude = parseExclude(route.options['exclude']);
3890
+ const ctx = vitalsSummaryContext(snapshot, { exclude });
2944
3891
 
2945
3892
  try {
2946
- const { generateVitalsSummary } = await import('./openrouter.js');
2947
- const result = await generateVitalsSummary(ctx, { apiKey: openrouterKey, tone: route.options['tone'] });
3893
+ const { AI_PROMPT_VERSIONS, generateVitalsSummary } = await import('./openrouter.js');
3894
+ const result = await generateVitalsSummary(ctx, {
3895
+ apiKey: openrouterKey,
3896
+ tone: route.options['tone'],
3897
+ user: aiUser,
3898
+ sessionId: `vitals:${new Date().toISOString().slice(0, 10)}`,
3899
+ contextMetadata: {
3900
+ excludedSections: [...exclude],
3901
+ recentDays: 14
3902
+ }
3903
+ });
2948
3904
  if (result.fallback && onError) {
2949
3905
  const warning = new Error(`AI vitals-summary used fallback model ${result.model}`);
2950
3906
  warning.level = 'warning';
@@ -2962,7 +3918,11 @@ export function createSyncServiceRequestHandler({
2962
3918
  json(response, 200, { summary: null, model: result.model, filtered: true });
2963
3919
  return;
2964
3920
  }
2965
- json(response, 200, { summary: stripXMLTagBlocks(result.text), model: result.model });
3921
+ json(response, 200, {
3922
+ summary: stripXMLTagBlocks(result.text),
3923
+ model: result.model,
3924
+ metadata: buildAIGenerationMetadata('vitals', result.model, AI_PROMPT_VERSIONS.vitals, result)
3925
+ });
2966
3926
  } catch (err) {
2967
3927
  console.error('AI vitals summary error:', err.message);
2968
3928
  onError?.(err, {
@@ -2988,24 +3948,13 @@ export function createSyncServiceRequestHandler({
2988
3948
  }
2989
3949
 
2990
3950
  const { checkpointContext } = await import('./queries.js');
2991
- const ctx = checkpointContext(snapshot, programId, checkpointWeek, { exclude: parseExclude(route.options['exclude']) });
3951
+ const exclude = parseExclude(route.options['exclude']);
3952
+ const ctx = checkpointContext(snapshot, programId, checkpointWeek, { exclude });
2992
3953
  if (!ctx) {
2993
3954
  notFound(response, 'No strength plan found for program');
2994
3955
  return;
2995
3956
  }
2996
3957
 
2997
- // Inject coach memory into checkpoint context
2998
- if (readCoachMemoryForAccount) {
2999
- try {
3000
- const mem = await readCoachMemoryForAccount(account);
3001
- if (mem?.content) {
3002
- ctx.coachMemory = mem.content;
3003
- }
3004
- } catch (memErr) {
3005
- console.error('Coach memory read error (checkpoint):', memErr.message);
3006
- }
3007
- }
3008
-
3009
3958
  const openrouterKey = process.env.OPENROUTER_API_KEY;
3010
3959
  if (!openrouterKey) {
3011
3960
  json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
@@ -3013,8 +3962,18 @@ export function createSyncServiceRequestHandler({
3013
3962
  }
3014
3963
 
3015
3964
  try {
3016
- const { generateCheckpointSummary } = await import('./openrouter.js');
3017
- const result = await generateCheckpointSummary(ctx, { apiKey: openrouterKey, tone: route.options['tone'] });
3965
+ const { AI_PROMPT_VERSIONS, generateCheckpointSummary } = await import('./openrouter.js');
3966
+ const result = await generateCheckpointSummary(ctx, {
3967
+ apiKey: openrouterKey,
3968
+ tone: route.options['tone'],
3969
+ user: aiUser,
3970
+ sessionId: `checkpoint:${programId}:${checkpointWeek}`,
3971
+ contextMetadata: {
3972
+ excludedSections: [...exclude],
3973
+ programId,
3974
+ checkpointWeek
3975
+ }
3976
+ });
3018
3977
  if (result.fallback && onError) {
3019
3978
  const warning = new Error(`AI checkpoint-summary used fallback model ${result.model}`);
3020
3979
  warning.level = 'warning';
@@ -3032,7 +3991,11 @@ export function createSyncServiceRequestHandler({
3032
3991
  json(response, 200, { summary: null, model: result.model, filtered: true });
3033
3992
  return;
3034
3993
  }
3035
- json(response, 200, { summary: stripXMLTagBlocks(result.text), model: result.model });
3994
+ json(response, 200, {
3995
+ summary: stripXMLTagBlocks(result.text),
3996
+ model: result.model,
3997
+ metadata: buildAIGenerationMetadata('checkpoint', result.model, AI_PROMPT_VERSIONS.checkpoint, result)
3998
+ });
3036
3999
  } catch (err) {
3037
4000
  console.error('AI checkpoint summary error:', err.message);
3038
4001
  onError?.(err, {
@@ -3083,12 +4046,6 @@ export function createSyncServiceRequestHandler({
3083
4046
  ? sanitizeHistory(persistedConversation.messages)
3084
4047
  : null;
3085
4048
  const canonicalHistory = (persistedMessages?.length ? persistedMessages : null) ?? history;
3086
- const priorUserTurns = canonicalHistory.filter((m) => m.role === 'user').length;
3087
- if (priorUserTurns >= MAX_ASK_USER_TURNS) {
3088
- json(response, 400, { error: `Ask Coach supports up to ${MAX_ASK_USER_TURNS} questions per conversation. Start a new conversation.`, code: 'conversation_limit' });
3089
- return;
3090
- }
3091
-
3092
4049
  const openrouterKey = process.env.OPENROUTER_API_KEY;
3093
4050
  if (!openrouterKey) {
3094
4051
  json(response, 503, { error: 'AI not configured', code: 'not_configured' });
@@ -3097,55 +4054,158 @@ export function createSyncServiceRequestHandler({
3097
4054
 
3098
4055
  const queries = await import('./queries.js');
3099
4056
  const exclude = parseExclude(body?.exclude);
3100
- let ctx = queries.askContext(snapshot, { exclude });
3101
-
3102
- // Inject coach memory into ask context
3103
- if (readCoachMemoryForAccount) {
4057
+ let coachFacts = [];
4058
+ if (listCoachFactsForAccount) {
3104
4059
  try {
3105
- const mem = await readCoachMemoryForAccount(account);
3106
- if (mem?.content) {
3107
- ctx = ctx + '\n\n' + fenceContent('coach_memory', mem.content);
3108
- }
3109
- } catch (memErr) {
3110
- console.error('Coach memory read error (ask):', memErr.message);
4060
+ const kinds = queries.coachFactKindsForAskQuestion
4061
+ ? queries.coachFactKindsForAskQuestion(snapshot, question)
4062
+ : [];
4063
+ coachFacts = await listCoachFactsForAccount(account, { kinds, limit: 30 });
4064
+ } catch (factErr) {
4065
+ console.error('Coach facts read error (ask):', factErr.message);
3111
4066
  }
3112
4067
  }
4068
+ let scoreSnapshots = [];
4069
+ if (listScoreSnapshotsForAccount) {
4070
+ try {
4071
+ scoreSnapshots = await listScoreSnapshotsForAccount(account, { limit: 14 }) ?? [];
4072
+ } catch (scoreErr) {
4073
+ console.error('Increment Score read error (ask):', scoreErr.message);
4074
+ }
4075
+ }
4076
+ if (scoreSnapshots.length > 0) {
4077
+ snapshot.incrementScore = {
4078
+ latest: scoreSnapshots[0],
4079
+ history: scoreSnapshots
4080
+ };
4081
+ }
4082
+
4083
+ const routedContext = queries.askRoutedContext
4084
+ ? queries.askRoutedContext(snapshot, question, { exclude, coachFacts })
4085
+ : { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
4086
+ const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
4087
+ const serverProgramPhase = persistedKind === 'weekly-checkin'
4088
+ ? queries.weeklyCheckinContext?.(snapshot, account.id)?.programPhase
4089
+ : null;
4090
+ const programPhasePrelude = formatProgramPhasePrelude(body?.programPhase ?? serverProgramPhase);
4091
+ const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
4092
+
4093
+ const preludes = [programPhasePrelude, incrementScorePrelude].filter(Boolean);
4094
+ const ctx = preludes.length > 0
4095
+ ? `${preludes.join('\n\n')}\n\n${routedContext.context}`
4096
+ : routedContext.context;
3113
4097
 
3114
4098
  const askTone = ['default', 'hype', 'numbers-only'].includes(body?.tone) ? body.tone : undefined;
3115
4099
 
3116
4100
  try {
3117
- const { generateAskAnswer } = await import('./openrouter.js');
3118
-
3119
- const askResult = await generateAskAnswer(ctx, question, {
3120
- apiKey: openrouterKey, history: canonicalHistory, tone: askTone
4101
+ const { AI_PROMPT_VERSIONS, generateAskAnswer, WEEKLY_CHECKIN_PROMPT } = await import('./openrouter.js');
4102
+ const generateAsk = generateAskAnswerImpl ?? generateAskAnswer;
4103
+ const systemPromptOverride = persistedKind === 'weekly-checkin' ? WEEKLY_CHECKIN_PROMPT : undefined;
4104
+
4105
+ const askResult = await generateAsk(ctx, question, {
4106
+ apiKey: openrouterKey,
4107
+ history: canonicalHistory,
4108
+ tone: askTone,
4109
+ user: aiUser,
4110
+ sessionId: `ask:${conversationId}`,
4111
+ systemPrompt: systemPromptOverride,
4112
+ routingMetadata: {
4113
+ ...routedContext.metadata,
4114
+ contextCharCount: ctx.length,
4115
+ historyTurnCount: canonicalHistory.filter((m) => m.role === 'user').length,
4116
+ coachFactsIncluded: (routedContext.metadata?.includedCoachFactIds ?? []).length > 0,
4117
+ coachFactIds: routedContext.metadata?.includedCoachFactIds ?? [],
4118
+ coachFactKinds: routedContext.metadata?.coachFactKinds ?? []
4119
+ }
3121
4120
  });
3122
4121
 
3123
- // Check for system prompt leakage BEFORE persisting — a leaked
3124
- // response must never be saved to the conversation history.
4122
+ const parsedAsk = extractAskProgramDraft(askResult.text, {
4123
+ canonicalizeExerciseName: queries.canonicalExerciseName
4124
+ });
4125
+ const assistantAnswer = stripXMLTagBlocks(parsedAsk.answerText);
4126
+ // Check for system prompt leakage before persisting. We inspect only
4127
+ // the user-visible prose, not the structured draft payload, so valid
4128
+ // <program_draft> output does not false-positive as a prompt leak.
3125
4129
  const { SYSTEM_PROMPTS_FOR_LEAK_CHECK } = await import('./openrouter.js');
3126
- if (detectSystemPromptLeak(askResult.text, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
4130
+ if (detectSystemPromptLeak(assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
3127
4131
  console.error('SECURITY: System prompt leak detected in ask-ai response, blocking');
3128
4132
  onError?.(new Error('System prompt leak detected in AI response'), { feature: 'ask-coach', security: true });
3129
4133
  json(response, 200, { answer: 'I can only answer questions about your training. Could you rephrase?', model: askResult.model, filtered: true });
3130
4134
  return;
3131
4135
  }
3132
4136
 
4137
+ const promptSurface = persistedKind === 'weekly-checkin' ? 'weekly-checkin' : 'ask';
4138
+ const promptVersion = persistedKind === 'weekly-checkin' ? AI_PROMPT_VERSIONS.weeklyCheckin : AI_PROMPT_VERSIONS.ask;
4139
+ const metadata = buildAIGenerationMetadata(promptSurface, askResult.model, promptVersion, askResult);
3133
4140
  const updatedMessages = [
3134
4141
  ...canonicalHistory,
3135
4142
  { role: 'user', content: question },
3136
- { role: 'assistant', content: askResult.text }
4143
+ { role: 'assistant', content: assistantAnswer }
3137
4144
  ];
3138
4145
  if (saveAskConversationForAccount) {
3139
4146
  try {
3140
4147
  await saveAskConversationForAccount(account, {
3141
4148
  id: conversationId,
3142
4149
  messages: updatedMessages,
3143
- model: askResult.model
4150
+ model: askResult.model,
4151
+ metadata,
4152
+ kind: persistedKind
3144
4153
  });
3145
4154
  } catch (saveErr) {
3146
4155
  console.error('Failed to save ask conversation:', saveErr.message);
3147
4156
  }
3148
4157
  }
4158
+ if (saveCoachFactsForAccount) {
4159
+ const transcript = transcriptForCoachFactExtraction(updatedMessages);
4160
+ const sourceSessionId = conversationId;
4161
+ setImmediate(() => {
4162
+ extractAndSaveCoachFacts({
4163
+ account,
4164
+ sourceSurface: promptSurface,
4165
+ sourceSessionId,
4166
+ transcript,
4167
+ openrouterKey,
4168
+ aiUser,
4169
+ saveCoachFactsForAccount,
4170
+ generateCoachFactCandidatesImpl,
4171
+ onError
4172
+ });
4173
+ });
4174
+ }
4175
+ const updatedUserTurns = updatedMessages.filter((m) => m.role === 'user').length;
4176
+ if (
4177
+ persistedKind === 'weekly-checkin' &&
4178
+ updatedUserTurns >= WEEKLY_CHECKIN_COMPLETION_USER_TURNS &&
4179
+ conversationId.startsWith('weekly-checkin:') &&
4180
+ transitionWeeklyCheckinForAccount
4181
+ ) {
4182
+ const weeklyCheckinId = conversationId.slice('weekly-checkin:'.length);
4183
+ let completedCheckin = null;
4184
+ try {
4185
+ completedCheckin = await transitionWeeklyCheckinForAccount(account, weeklyCheckinId, { toStatus: 'completed' });
4186
+ } catch (completeErr) {
4187
+ if (completeErr.code !== 'invalid_transition') {
4188
+ console.error('Weekly check-in completion transition failed:', completeErr.message);
4189
+ }
4190
+ }
4191
+ if (saveCoachCommitmentsForAccount) {
4192
+ setImmediate(async () => {
4193
+ try {
4194
+ const { extractCoachCommitmentsFromUserTurns } = await import('./openrouter.js');
4195
+ const commitments = extractCoachCommitmentsFromUserTurns(updatedMessages);
4196
+ if (commitments.length === 0) return;
4197
+ await saveCoachCommitmentsForAccount(account, commitments, {
4198
+ weekStartDate: completedCheckin?.weekStartDate ?? new Date().toISOString().slice(0, 10),
4199
+ sourceSurface: 'weekly-checkin',
4200
+ sourceConversationId: conversationId
4201
+ });
4202
+ } catch (commitmentErr) {
4203
+ console.error('Background weekly check-in commitment save failed:', commitmentErr.message);
4204
+ onError?.(commitmentErr, { feature: 'weekly-checkin-commitment-save' });
4205
+ }
4206
+ });
4207
+ }
4208
+ }
3149
4209
  if (askResult.fallback && onError) {
3150
4210
  const warning = new Error(`AI ask-coach used fallback model ${askResult.model}`);
3151
4211
  warning.level = 'warning';
@@ -3156,7 +4216,12 @@ export function createSyncServiceRequestHandler({
3156
4216
  fallbackModel: askResult.model
3157
4217
  });
3158
4218
  }
3159
- json(response, 200, { answer: stripXMLTagBlocks(askResult.text), model: askResult.model });
4219
+ json(response, 200, {
4220
+ answer: assistantAnswer,
4221
+ model: askResult.model,
4222
+ metadata,
4223
+ programDraft: parsedAsk.programDraft
4224
+ });
3160
4225
  } catch (err) {
3161
4226
  console.error('AI ask error:', err.message);
3162
4227
  onError?.(err, {
@@ -3235,7 +4300,10 @@ export function createSyncServiceRequestHandler({
3235
4300
  id: c.id,
3236
4301
  preview: (firstUserMsg?.content ?? '').slice(0, 120),
3237
4302
  messageCount: c.messages?.length ?? 0,
3238
- createdAt: c.createdAt
4303
+ createdAt: c.createdAt,
4304
+ model: c.model ?? null,
4305
+ kind: c.kind ?? 'ask',
4306
+ metadata: c.metadata ?? null
3239
4307
  };
3240
4308
  });
3241
4309
  json(response, 200, { conversations: summaries });
@@ -3266,7 +4334,7 @@ export function createSyncServiceRequestHandler({
3266
4334
  const conversations = await listAskConversationsForAccount(account);
3267
4335
  conversation = conversations.find((c) => c.id === route.options.id);
3268
4336
  }
3269
- if (!conversation) {
4337
+ if (!conversation || (conversation.kind ?? 'ask') !== 'ask') {
3270
4338
  notFound(response, `Conversation not found: ${route.options.id}`);
3271
4339
  return;
3272
4340
  }