incremnt 0.4.0 → 0.6.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,14 @@
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';
6
+ import { enrichScoreSnapshots } from './score-context.js';
5
7
 
6
8
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
7
9
  const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
8
- const MAX_ASK_USER_TURNS = 3;
10
+ const WEEKLY_CHECKIN_COMPLETION_USER_TURNS = 3;
11
+ const WEEKLY_CHECKIN_RECAP_LOCAL_HOUR = 16;
9
12
  const DEFAULT_RATE_LIMIT_RULES = {
10
13
  'workout-summary-ai': 3,
11
14
  'cycle-summary-ai': 3,
@@ -14,6 +17,10 @@ const DEFAULT_RATE_LIMIT_RULES = {
14
17
  'ask-ai': 5,
15
18
  'ai-feedback': 60,
16
19
  'coach-memory': 30,
20
+ 'weekly-checkin-enroll': 10,
21
+ 'weekly-checkin-current': 30,
22
+ 'weekly-checkin-ack': 30,
23
+ 'weekly-checkin-start': 10,
17
24
  'dev-login': 10,
18
25
  'device-start': 20,
19
26
  'device-poll': 300,
@@ -25,6 +32,7 @@ const DEFAULT_RATE_LIMIT_RULES = {
25
32
  'web-auth-start': 20,
26
33
  'web-auth-callback': 20,
27
34
  'session-login': 60,
35
+ 'anonymous-start': 60,
28
36
  'session-refresh': 30,
29
37
  'delete-account': 1,
30
38
  'sync-account-preferences': 30,
@@ -34,6 +42,10 @@ const DEFAULT_RATE_LIMIT_RULES = {
34
42
  'program-share-list': 60,
35
43
  'program-share-public': 120,
36
44
  'program-share-revoke': 30,
45
+ 'mobile-sync-bootstrap': 60,
46
+ 'mobile-sync-pull': 120,
47
+ 'mobile-sync-push': 60,
48
+ 'score-snapshots': 60,
37
49
  'social-invite': 20,
38
50
  'social-groups': 60,
39
51
  'social-group-create': 20,
@@ -96,6 +108,292 @@ export function isNoInsightResponse(text) {
96
108
  return normalized === 'NO_INSIGHT' || normalized.startsWith('NO_INSIGHT\n');
97
109
  }
98
110
 
111
+ function isValidTimeZone(timeZoneId) {
112
+ if (!timeZoneId || typeof timeZoneId !== 'string') return false;
113
+ try {
114
+ new Intl.DateTimeFormat('en-US', { timeZone: timeZoneId }).format(new Date());
115
+ return true;
116
+ } catch {
117
+ return false;
118
+ }
119
+ }
120
+
121
+ function zonedParts(date, timeZoneId) {
122
+ const parts = new Intl.DateTimeFormat('en-US', {
123
+ timeZone: timeZoneId,
124
+ year: 'numeric',
125
+ month: '2-digit',
126
+ day: '2-digit',
127
+ hour: '2-digit',
128
+ minute: '2-digit',
129
+ second: '2-digit',
130
+ hour12: false,
131
+ hourCycle: 'h23',
132
+ weekday: 'short'
133
+ }).formatToParts(date);
134
+ const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]));
135
+ const weekdayIndex = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].indexOf(byType.weekday);
136
+ return {
137
+ year: Number(byType.year),
138
+ month: Number(byType.month),
139
+ day: Number(byType.day),
140
+ hour: Number(byType.hour),
141
+ minute: Number(byType.minute),
142
+ second: Number(byType.second),
143
+ weekday: weekdayIndex >= 0 ? weekdayIndex : 0
144
+ };
145
+ }
146
+
147
+ function zonedDateTimeToUtc(timeZoneId, { year, month, day, hour, minute = 0, second = 0 }) {
148
+ let utc = Date.UTC(year, month - 1, day, hour, minute, second);
149
+ for (let index = 0; index < 4; index += 1) {
150
+ const parts = zonedParts(new Date(utc), timeZoneId);
151
+ const asIfUtc = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
152
+ const wanted = Date.UTC(year, month - 1, day, hour, minute, second);
153
+ const delta = wanted - asIfUtc;
154
+ if (delta === 0) break;
155
+ utc += delta;
156
+ }
157
+ return new Date(utc);
158
+ }
159
+
160
+ function addCalendarDays({ year, month, day }, days) {
161
+ const date = new Date(Date.UTC(year, month - 1, day + days, 12, 0, 0));
162
+ return {
163
+ year: date.getUTCFullYear(),
164
+ month: date.getUTCMonth() + 1,
165
+ day: date.getUTCDate()
166
+ };
167
+ }
168
+
169
+ function isoDateFromParts({ year, month, day }) {
170
+ return [
171
+ String(year).padStart(4, '0'),
172
+ String(month).padStart(2, '0'),
173
+ String(day).padStart(2, '0')
174
+ ].join('-');
175
+ }
176
+
177
+ export function nextWeeklyCheckinSchedule(timeZoneId, now = new Date()) {
178
+ if (!isValidTimeZone(timeZoneId)) {
179
+ const err = new Error('Invalid timezoneId');
180
+ err.code = 'invalid_timezone';
181
+ throw err;
182
+ }
183
+ const local = zonedParts(now, timeZoneId);
184
+ const daysUntilSunday = (7 - local.weekday) % 7;
185
+ let target = addCalendarDays(local, daysUntilSunday);
186
+ let dueAt = zonedDateTimeToUtc(timeZoneId, {
187
+ ...target,
188
+ hour: WEEKLY_CHECKIN_RECAP_LOCAL_HOUR,
189
+ minute: 0,
190
+ second: 0
191
+ });
192
+ if (dueAt <= now) {
193
+ target = addCalendarDays(target, 7);
194
+ dueAt = zonedDateTimeToUtc(timeZoneId, {
195
+ ...target,
196
+ hour: WEEKLY_CHECKIN_RECAP_LOCAL_HOUR,
197
+ minute: 0,
198
+ second: 0
199
+ });
200
+ }
201
+ return {
202
+ weekStartDate: isoDateFromParts(target),
203
+ nextRecapDueAt: dueAt.toISOString()
204
+ };
205
+ }
206
+
207
+ const PROGRAM_DRAFT_VERSION = 1;
208
+ const VALID_PROGRAM_DRAFT_EQUIPMENT_TIERS = new Set(['fullGym', 'benchDumbbells', 'dumbbellsOnly', 'bodyweightOnly']);
209
+ const VALID_PROGRAM_DRAFT_VOLUME_LEVELS = new Set(['minimum', 'moderate', 'high']);
210
+
211
+ const PROGRAM_DRAFT_LIMITS = {
212
+ nameMaxLen: 120,
213
+ muscleGroupMaxLen: 60,
214
+ dayLabelMaxLen: 60,
215
+ dayTitleMaxLen: 120,
216
+ daySubtitleMaxLen: 120,
217
+ noteMaxLen: 1000,
218
+ minWeight: 0,
219
+ maxWeight: 600,
220
+ minReps: 1,
221
+ maxReps: 30,
222
+ minRir: 0,
223
+ maxRir: 5,
224
+ minSetsPerExercise: 1,
225
+ maxSetsPerExercise: 12,
226
+ minExercisesPerDay: 1,
227
+ maxExercisesPerDay: 24,
228
+ minDaysPerWeek: 1,
229
+ maxDaysPerWeek: 7,
230
+ minDays: 1,
231
+ maxDays: 14
232
+ };
233
+
234
+ function collapseBlankLines(text) {
235
+ return String(text ?? '')
236
+ .replace(/\n{3,}/g, '\n\n')
237
+ .trim();
238
+ }
239
+
240
+ function titleCaseExerciseName(name) {
241
+ return String(name ?? '')
242
+ .split(' ')
243
+ .filter(Boolean)
244
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
245
+ .join(' ');
246
+ }
247
+
248
+ function normalizedExerciseDisplayName(name, canonicalizeExerciseName) {
249
+ const trimmed = String(name ?? '').trim();
250
+ if (!trimmed) return '';
251
+ const canonical = canonicalizeExerciseName ? canonicalizeExerciseName(trimmed) : trimmed.toLowerCase();
252
+ return titleCaseExerciseName(canonical);
253
+ }
254
+
255
+ function normalizeProgramDraftSet(set) {
256
+ const weight = Number(set?.weight);
257
+ const reps = Number(set?.reps);
258
+ if (!Number.isFinite(weight) || !Number.isInteger(reps)) return null;
259
+ if (
260
+ weight < PROGRAM_DRAFT_LIMITS.minWeight ||
261
+ weight > PROGRAM_DRAFT_LIMITS.maxWeight ||
262
+ reps < PROGRAM_DRAFT_LIMITS.minReps ||
263
+ reps > PROGRAM_DRAFT_LIMITS.maxReps
264
+ ) return null;
265
+ return {
266
+ weight,
267
+ reps,
268
+ isComplete: false,
269
+ isWarmup: set?.isWarmup === true
270
+ };
271
+ }
272
+
273
+ function normalizeProgramDraftExercise(exercise, canonicalizeExerciseName) {
274
+ const name = normalizedExerciseDisplayName(exercise?.name, canonicalizeExerciseName);
275
+ const muscleGroup = String(exercise?.muscleGroup ?? '').trim();
276
+ const sets = Array.isArray(exercise?.sets)
277
+ ? exercise.sets.map(normalizeProgramDraftSet).filter(Boolean)
278
+ : [];
279
+
280
+ if (!name || name.length > PROGRAM_DRAFT_LIMITS.nameMaxLen) return null;
281
+ if (!muscleGroup || muscleGroup.length > PROGRAM_DRAFT_LIMITS.muscleGroupMaxLen) return null;
282
+ if (
283
+ sets.length < PROGRAM_DRAFT_LIMITS.minSetsPerExercise ||
284
+ sets.length > PROGRAM_DRAFT_LIMITS.maxSetsPerExercise
285
+ ) return null;
286
+
287
+ const rir = exercise?.rir == null ? null : Number(exercise.rir);
288
+ if (rir != null && (
289
+ !Number.isInteger(rir) ||
290
+ rir < PROGRAM_DRAFT_LIMITS.minRir ||
291
+ rir > PROGRAM_DRAFT_LIMITS.maxRir
292
+ )) return null;
293
+
294
+ const note = exercise?.note == null ? null : String(exercise.note);
295
+ if (note && note.length > PROGRAM_DRAFT_LIMITS.noteMaxLen) return null;
296
+
297
+ return {
298
+ name,
299
+ muscleGroup,
300
+ lastSuggestion: '',
301
+ nextSuggestion: '',
302
+ sets,
303
+ ...(note ? { note } : {}),
304
+ ...(rir != null ? { rir } : {})
305
+ };
306
+ }
307
+
308
+ function normalizeProgramDraftDay(day, canonicalizeExerciseName) {
309
+ const dayLabel = String(day?.dayLabel ?? '').trim();
310
+ const title = String(day?.title ?? '').trim();
311
+ const subtitle = String(day?.subtitle ?? '').trim();
312
+ const exercises = Array.isArray(day?.exercises)
313
+ ? day.exercises.map((exercise) => normalizeProgramDraftExercise(exercise, canonicalizeExerciseName)).filter(Boolean)
314
+ : [];
315
+
316
+ if (!dayLabel || dayLabel.length > PROGRAM_DRAFT_LIMITS.dayLabelMaxLen) return null;
317
+ if (!title || title.length > PROGRAM_DRAFT_LIMITS.dayTitleMaxLen) return null;
318
+ if (subtitle.length > PROGRAM_DRAFT_LIMITS.daySubtitleMaxLen) return null;
319
+ if (
320
+ exercises.length < PROGRAM_DRAFT_LIMITS.minExercisesPerDay ||
321
+ exercises.length > PROGRAM_DRAFT_LIMITS.maxExercisesPerDay
322
+ ) return null;
323
+
324
+ return { dayLabel, title, subtitle, exercises };
325
+ }
326
+
327
+ function normalizeProgramDraft(rawProgram, { canonicalizeExerciseName } = {}) {
328
+ if (!rawProgram || typeof rawProgram !== 'object' || Array.isArray(rawProgram)) return null;
329
+
330
+ const name = String(rawProgram.name ?? '').trim();
331
+ const days = Array.isArray(rawProgram.days)
332
+ ? rawProgram.days.map((day) => normalizeProgramDraftDay(day, canonicalizeExerciseName)).filter(Boolean)
333
+ : [];
334
+ const daysPerWeek = Number(rawProgram.daysPerWeek);
335
+ const currentDayIndex = rawProgram.currentDayIndex == null ? 0 : Number(rawProgram.currentDayIndex);
336
+ const equipmentTier = String(rawProgram.equipmentTier ?? 'fullGym').trim();
337
+ const volumeLevel = String(rawProgram.volumeLevel ?? 'moderate').trim();
338
+
339
+ if (!name || name.length > PROGRAM_DRAFT_LIMITS.nameMaxLen) return null;
340
+ if (days.length < PROGRAM_DRAFT_LIMITS.minDays || days.length > PROGRAM_DRAFT_LIMITS.maxDays) return null;
341
+ if (
342
+ !Number.isInteger(daysPerWeek) ||
343
+ daysPerWeek < PROGRAM_DRAFT_LIMITS.minDaysPerWeek ||
344
+ daysPerWeek > PROGRAM_DRAFT_LIMITS.maxDaysPerWeek
345
+ ) return null;
346
+ if (!Number.isInteger(currentDayIndex) || currentDayIndex < 0 || currentDayIndex >= days.length) return null;
347
+ if (!VALID_PROGRAM_DRAFT_EQUIPMENT_TIERS.has(equipmentTier) || !VALID_PROGRAM_DRAFT_VOLUME_LEVELS.has(volumeLevel)) return null;
348
+
349
+ return {
350
+ name,
351
+ daysPerWeek,
352
+ equipmentTier,
353
+ volumeLevel,
354
+ source: 'guided',
355
+ days,
356
+ currentDayIndex
357
+ };
358
+ }
359
+
360
+ function extractAskProgramDraft(rawText, { canonicalizeExerciseName } = {}) {
361
+ const text = String(rawText ?? '');
362
+ const match = text.match(/<program_draft>\s*([\s\S]*?)\s*<\/program_draft>/i);
363
+ if (!match) {
364
+ return { answerText: text.trim(), programDraft: null };
365
+ }
366
+
367
+ const answerText = collapseBlankLines(text.replace(match[0], ''));
368
+ let parsed;
369
+ try {
370
+ parsed = JSON.parse(match[1]);
371
+ } catch (err) {
372
+ console.warn('askCoach: <program_draft> JSON parse failed — dropping draft:', err.message);
373
+ return { answerText, programDraft: null };
374
+ }
375
+
376
+ const program = normalizeProgramDraft(parsed, { canonicalizeExerciseName });
377
+ if (!program) {
378
+ console.warn('askCoach: <program_draft> payload failed validation — dropping draft');
379
+ return { answerText, programDraft: null };
380
+ }
381
+
382
+ return {
383
+ answerText,
384
+ programDraft: {
385
+ program,
386
+ provenance: {
387
+ source: 'ai-coach',
388
+ type: 'program',
389
+ version: PROGRAM_DRAFT_VERSION,
390
+ createdAt: new Date().toISOString(),
391
+ tokenHint: null
392
+ }
393
+ }
394
+ };
395
+ }
396
+
99
397
  function json(response, statusCode, payload) {
100
398
  response.writeHead(statusCode, { 'content-type': 'application/json' });
101
399
  response.end(JSON.stringify(payload));
@@ -117,17 +415,6 @@ function logRequest(request, statusCode, extra = '') {
117
415
  console.log(`${method} ${path} ${statusCode}${suffix}`);
118
416
  }
119
417
 
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
418
  function anonymizeSessionToken(sessionToken) {
132
419
  if (typeof sessionToken !== 'string' || !sessionToken.trim()) {
133
420
  return 'sess:unknown';
@@ -215,6 +502,71 @@ function anonymizeRelationIds(items, { max = 5 } = {}) {
215
502
  .join(',');
216
503
  }
217
504
 
505
+ function currentAIGitSha() {
506
+ return process.env.RENDER_GIT_COMMIT
507
+ ?? process.env.GIT_SHA
508
+ ?? process.env.COMMIT_SHA
509
+ ?? process.env.VERCEL_GIT_COMMIT_SHA
510
+ ?? null;
511
+ }
512
+
513
+ function buildAIGenerationMetadata(surface, model, promptVersion, generation = {}) {
514
+ return {
515
+ surface,
516
+ generatedAt: new Date().toISOString(),
517
+ model: model ?? null,
518
+ promptVersion: promptVersion ?? null,
519
+ gitSha: currentAIGitSha(),
520
+ langfuseTraceId: generation.langfuseTraceId ?? null,
521
+ langfuseObservationId: generation.langfuseObservationId ?? null
522
+ };
523
+ }
524
+
525
+ function transcriptForCoachFactExtraction(messages, { maxChars = 4000 } = {}) {
526
+ const text = (Array.isArray(messages) ? messages : [])
527
+ .filter((message) => message?.role === 'user')
528
+ .slice(-8)
529
+ .map((message) => `user: ${String(message.content ?? '').trim()}`)
530
+ .join('\n')
531
+ .slice(-maxChars);
532
+ return text.trim();
533
+ }
534
+
535
+ async function extractAndSaveCoachFacts({
536
+ account,
537
+ sourceSurface,
538
+ sourceSessionId,
539
+ transcript,
540
+ openrouterKey,
541
+ aiUser,
542
+ saveCoachFactsForAccount,
543
+ generateCoachFactCandidatesImpl,
544
+ onError
545
+ }) {
546
+ if (!saveCoachFactsForAccount || !transcript || !openrouterKey) return [];
547
+ try {
548
+ const { generateCoachFactCandidates } = await import('./openrouter.js');
549
+ const generateFacts = generateCoachFactCandidatesImpl ?? generateCoachFactCandidates;
550
+ const factResult = await generateFacts(transcript, {
551
+ apiKey: openrouterKey,
552
+ user: aiUser,
553
+ sessionId: `coach-facts:${sourceSessionId ?? sourceSurface}`,
554
+ contextMetadata: {
555
+ sourceSurface,
556
+ sourceSessionId
557
+ }
558
+ });
559
+ return saveCoachFactsForAccount(account, factResult.facts ?? [], {
560
+ sourceSurface,
561
+ sourceSessionId
562
+ });
563
+ } catch (factErr) {
564
+ console.error('Coach fact extraction failed:', factErr.message);
565
+ onError?.(factErr, { feature: 'coach-fact-extraction', sourceSurface });
566
+ return [];
567
+ }
568
+ }
569
+
218
570
  function unauthorized(response, request) {
219
571
  if (request) logRequest(request, 401);
220
572
  json(response, 401, { error: 'Unauthorized' });
@@ -348,6 +700,10 @@ function routeRequest(url, method) {
348
700
  return { command: 'session-login', options: {} };
349
701
  }
350
702
 
703
+ if (pathname === '/auth/anonymous/start') {
704
+ return { command: 'anonymous-start', options: {} };
705
+ }
706
+
351
707
  if (pathname === '/auth/refresh') {
352
708
  return { command: 'session-refresh', options: {} };
353
709
  }
@@ -372,6 +728,10 @@ function routeRequest(url, method) {
372
728
  return { command: 'google-callback', options: {} };
373
729
  }
374
730
 
731
+ if (pathname === '/auth/google/mobile') {
732
+ return { command: 'google-mobile', options: {} };
733
+ }
734
+
375
735
  if (pathname === '/auth/apple/start') {
376
736
  return { command: 'apple-start', options: {} };
377
737
  }
@@ -396,6 +756,55 @@ function routeRequest(url, method) {
396
756
  return { command: 'sync-upload', options: {} };
397
757
  }
398
758
 
759
+ if (pathname === '/mobile/sync/bootstrap') {
760
+ return { command: 'mobile-sync-bootstrap', options: {} };
761
+ }
762
+
763
+ if (pathname === '/mobile/sync/pull') {
764
+ return {
765
+ command: 'mobile-sync-pull',
766
+ options: {
767
+ since: url.searchParams.get('since') ?? undefined
768
+ }
769
+ };
770
+ }
771
+
772
+ if (pathname === '/mobile/sync/push') {
773
+ return { command: 'mobile-sync-push', options: {} };
774
+ }
775
+
776
+ if (pathname === '/mobile/score-snapshots') {
777
+ return {
778
+ command: 'score-snapshots',
779
+ options: {
780
+ from: url.searchParams.get('from') ?? undefined,
781
+ to: url.searchParams.get('to') ?? undefined,
782
+ limit: url.searchParams.get('limit') ?? undefined
783
+ }
784
+ };
785
+ }
786
+
787
+ if (pathname === '/cli/increment-score/current') {
788
+ return {
789
+ command: 'increment-score-current',
790
+ options: {
791
+ historyDays: url.searchParams.get('historyDays') ?? undefined
792
+ }
793
+ };
794
+ }
795
+
796
+ {
797
+ const coachToolMatch = pathname.match(/^\/cli\/coach-tools\/([^/]+)$/);
798
+ if (coachToolMatch) {
799
+ return {
800
+ command: 'coach-tool',
801
+ options: {
802
+ toolName: decodeURIComponent(coachToolMatch[1])
803
+ }
804
+ };
805
+ }
806
+ }
807
+
399
808
  if (pathname === '/cli/account') {
400
809
  return { command: 'delete-account', options: {} };
401
810
  }
@@ -472,7 +881,7 @@ function routeRequest(url, method) {
472
881
  if (programShareRevokeMatch) {
473
882
  return {
474
883
  command: 'program-share-revoke',
475
- options: { token: decodeURIComponent(programShareRevokeMatch[1]) }
884
+ options: { shareId: decodeURIComponent(programShareRevokeMatch[1]) }
476
885
  };
477
886
  }
478
887
  }
@@ -625,6 +1034,22 @@ function routeRequest(url, method) {
625
1034
  return { command: 'ai-feedback', options: {} };
626
1035
  }
627
1036
 
1037
+ if (pathname === '/cli/weekly-checkin/current') {
1038
+ return { command: 'weekly-checkin-current', options: {} };
1039
+ }
1040
+
1041
+ if (pathname === '/cli/weekly-checkin/enroll') {
1042
+ return { command: 'weekly-checkin-enroll', options: {} };
1043
+ }
1044
+
1045
+ if (pathname === '/cli/weekly-checkin/ack') {
1046
+ return { command: 'weekly-checkin-ack', options: {} };
1047
+ }
1048
+
1049
+ if (pathname === '/cli/weekly-checkin/start') {
1050
+ return { command: 'weekly-checkin-start', options: {} };
1051
+ }
1052
+
628
1053
  if (pathname === '/cli/health/ai') {
629
1054
  return {
630
1055
  command: 'health-ai',
@@ -1009,6 +1434,91 @@ function routeRequest(url, method) {
1009
1434
  return null;
1010
1435
  }
1011
1436
 
1437
+ /// Formats a `ProgramPhaseWindowContext` (sent by iOS in the request body) as
1438
+ /// a short text prelude prepended to the AI context. Without this the model
1439
+ /// would have to infer "is this a deload week / was last week deload?" from
1440
+ /// session prose; with it the structured phase facts are explicit.
1441
+ function formatProgramPhasePrelude(programPhase) {
1442
+ if (!programPhase || typeof programPhase !== 'object') return null;
1443
+ const current = programPhase.current;
1444
+ const previous = programPhase.previousWeek;
1445
+ const next = programPhase.nextWeek;
1446
+ if (!current?.phase || typeof current.displayWeek !== 'number') return null;
1447
+ const describe = (phase) => {
1448
+ if (!phase?.phase) return null;
1449
+ const week = typeof phase.displayWeek === 'number' ? `week ${phase.displayWeek}` : 'week ?';
1450
+ return `${week} (${phase.phase})${phase.isDeload ? ' · deload week' : ''}`;
1451
+ };
1452
+ const describeList = (phases) => {
1453
+ if (!Array.isArray(phases) || phases.length === 0) return null;
1454
+ return phases.map(describe).filter(Boolean).join(', ');
1455
+ };
1456
+ const lines = [
1457
+ '[Program phase]',
1458
+ `- Current: ${describe(current)}`
1459
+ ];
1460
+ if (previous?.phase) lines.push(`- Previous: ${describe(previous)}`);
1461
+ if (next?.phase) lines.push(`- Next: ${describe(next)}`);
1462
+ if (programPhase.isPostDeloadReturn === true) {
1463
+ lines.push('- Post-deload return: yes (last week was deload, this week is build)');
1464
+ }
1465
+ const range = describeList(programPhase.phasesInRange);
1466
+ if (range) lines.push(`- Range phases: ${range}`);
1467
+ const previousRange = describeList(programPhase.previousRangePhases);
1468
+ if (previousRange) lines.push(`- Previous range phases: ${previousRange}`);
1469
+ return lines.join('\n');
1470
+ }
1471
+
1472
+ export function formatIncrementScorePrelude(snapshots) {
1473
+ if (!Array.isArray(snapshots) || snapshots.length === 0) return null;
1474
+ const latest = snapshots[0];
1475
+ if (latest == null || typeof latest.score !== 'number') return null;
1476
+
1477
+ const lines = ['[Increment Score]'];
1478
+ const tier = latest.dataTier ? ` · ${latest.dataTier}` : '';
1479
+ lines.push(`- Current: ${latest.score}/100${tier}`);
1480
+
1481
+ if (latest.components && typeof latest.components === 'object') {
1482
+ const parts = [];
1483
+ for (const [name, value] of Object.entries(latest.components)) {
1484
+ const num = typeof value === 'number' ? value : value?.score;
1485
+ if (typeof num === 'number') parts.push(`${name} ${num}`);
1486
+ }
1487
+ if (parts.length > 0) lines.push(`- Components: ${parts.join(', ')}`);
1488
+ }
1489
+
1490
+ const driverLabels = (list) => {
1491
+ if (!Array.isArray(list) || list.length === 0) return null;
1492
+ return list
1493
+ .slice(0, 3)
1494
+ .map((d) => d?.label ?? d?.id ?? d?.driver)
1495
+ .filter(Boolean)
1496
+ .join('; ');
1497
+ };
1498
+ const positives = driverLabels(latest.topPositiveDrivers);
1499
+ if (positives) lines.push(`- Top positive drivers: ${positives}`);
1500
+ const negatives = driverLabels(latest.topNegativeDrivers);
1501
+ if (negatives) lines.push(`- Top negative drivers: ${negatives}`);
1502
+
1503
+ if (snapshots.length > 1) {
1504
+ const prior = snapshots[1];
1505
+ if (typeof prior?.score === 'number') {
1506
+ const delta = latest.score - prior.score;
1507
+ const sign = delta > 0 ? '+' : '';
1508
+ lines.push(`- Day-over-day delta: ${sign}${delta}`);
1509
+ }
1510
+ const recent = snapshots
1511
+ .slice(0, 7)
1512
+ .map((s) => (typeof s?.score === 'number' ? s.score : null))
1513
+ .filter((s) => s != null);
1514
+ if (recent.length >= 3) {
1515
+ lines.push(`- Last ${recent.length} days: ${recent.join(', ')}`);
1516
+ }
1517
+ }
1518
+
1519
+ return lines.join('\n');
1520
+ }
1521
+
1012
1522
  async function readJsonBody(request) {
1013
1523
  const chunks = [];
1014
1524
  let totalSize = 0;
@@ -1450,6 +1960,7 @@ export function createSyncServiceRequestHandler({
1450
1960
  writeSnapshotForAccount,
1451
1961
  issueDevLogin,
1452
1962
  issueSession,
1963
+ issueAnonymousWriteAccess,
1453
1964
  issueDeviceChallenge,
1454
1965
  consumeDeviceChallenge,
1455
1966
  readDeviceChallengeByUserCode,
@@ -1472,7 +1983,9 @@ export function createSyncServiceRequestHandler({
1472
1983
  buildGoogleWebAuthUrl = null,
1473
1984
  completeAppleWebAuth = null,
1474
1985
  completeGoogleWebAuth = null,
1986
+ completeGoogleMobileAuth = null,
1475
1987
  refreshSession,
1988
+ authenticateConnectedWriteToken,
1476
1989
  allowManualDeviceApproval = false,
1477
1990
  rateLimitConfig = null,
1478
1991
  publicOrigin = null,
@@ -1490,9 +2003,24 @@ export function createSyncServiceRequestHandler({
1490
2003
  listAskConversationsForAccount = null,
1491
2004
  getAskConversationForAccount = null,
1492
2005
  readCoachMemoryForAccount = null,
1493
- writeCoachMemoryForAccount = null,
2006
+ saveCoachFactsForAccount = null,
2007
+ listCoachFactsForAccount = null,
2008
+ saveCoachCommitmentsForAccount = null,
2009
+ listActiveCoachCommitmentsForAccount = null,
2010
+ getCurrentWeeklyCheckinForAccount = null,
2011
+ upsertScheduledWeeklyCheckinForAccount = null,
2012
+ transitionWeeklyCheckinForAccount = null,
2013
+ generateWeeklyCheckinRecapImpl = null,
2014
+ generateCheckinQuestionsImpl = null,
1494
2015
  saveAIFeedbackForAccount = null,
2016
+ generateAskAnswerImpl = null,
2017
+ generateCoachFactCandidatesImpl = null,
1495
2018
  deleteAccountForUser = null,
2019
+ loadMobileSyncStateForAccount = null,
2020
+ pullMobileSyncStateForAccount = null,
2021
+ pushMobileSyncChangesForAccount = null,
2022
+ insertScoreSnapshotsForAccount = null,
2023
+ listScoreSnapshotsForAccount = null,
1496
2024
  // Social
1497
2025
  social = null,
1498
2026
  onError = null
@@ -1837,10 +2365,12 @@ export function createSyncServiceRequestHandler({
1837
2365
 
1838
2366
  let code = url.searchParams.get('code') ?? '';
1839
2367
  let state = url.searchParams.get('state') ?? '';
2368
+ let user = null;
1840
2369
  if (request.method === 'POST') {
1841
2370
  const body = await readUrlEncodedBody(request);
1842
2371
  code = body.code ?? code;
1843
2372
  state = body.state ?? state;
2373
+ user = body.user ?? null;
1844
2374
  }
1845
2375
 
1846
2376
  if (!code || !state) {
@@ -1863,7 +2393,7 @@ export function createSyncServiceRequestHandler({
1863
2393
  }
1864
2394
 
1865
2395
  try {
1866
- const result = await completeAppleWebAuth({ code, state });
2396
+ const result = await completeAppleWebAuth({ code, state, user });
1867
2397
  const returnUrl = new URL(result.returnUrl);
1868
2398
  returnUrl.hash = `token=${result.session.accessToken}&expires=${result.session.expiresAt}`;
1869
2399
  response.writeHead(302, { location: returnUrl.toString() });
@@ -1891,7 +2421,7 @@ export function createSyncServiceRequestHandler({
1891
2421
  }
1892
2422
 
1893
2423
  try {
1894
- const result = await completeAppleDeviceApproval({ code, state });
2424
+ const result = await completeAppleDeviceApproval({ code, state, user });
1895
2425
  html(response, 200, deviceApprovalSuccessPage({
1896
2426
  email: result.account.email ?? '',
1897
2427
  userId: result.account.id
@@ -2119,7 +2649,7 @@ export function createSyncServiceRequestHandler({
2119
2649
  }
2120
2650
  json(response, 200, {
2121
2651
  ok: true,
2122
- token: shared.share.token,
2652
+ token: route.options.token,
2123
2653
  version: shared.share.version,
2124
2654
  programId: shared.share.programId,
2125
2655
  programName: shared.share.programPayload?.name ?? null,
@@ -2138,6 +2668,47 @@ export function createSyncServiceRequestHandler({
2138
2668
  }
2139
2669
  }
2140
2670
 
2671
+ if (route.command === 'google-mobile') {
2672
+ if (request.method !== 'POST') {
2673
+ methodNotAllowed(response, 'Use POST for /auth/google/mobile.');
2674
+ return;
2675
+ }
2676
+
2677
+ if (!googleAuth?.configured || !completeGoogleMobileAuth) {
2678
+ methodNotAllowed(response, 'Google mobile auth is not enabled for this service mode.');
2679
+ return;
2680
+ }
2681
+
2682
+ try {
2683
+ const body = await readJsonBody(request);
2684
+ const idToken = typeof body?.idToken === 'string' ? body.idToken.trim() : '';
2685
+ if (!idToken) {
2686
+ badRequest(response, 'idToken is required.');
2687
+ return;
2688
+ }
2689
+
2690
+ const result = await completeGoogleMobileAuth({ idToken });
2691
+ json(response, 200, {
2692
+ ok: true,
2693
+ session: {
2694
+ accessToken: result.session.accessToken,
2695
+ expiresAt: result.session.expiresAt
2696
+ },
2697
+ account: result.account
2698
+ });
2699
+ return;
2700
+ } catch (error) {
2701
+ reportAuthFailure(onError, error, {
2702
+ route: 'google-mobile',
2703
+ provider: 'google',
2704
+ authFlow: 'mobile',
2705
+ statusCode: 400
2706
+ });
2707
+ badRequest(response, error.message);
2708
+ return;
2709
+ }
2710
+ }
2711
+
2141
2712
  const requestToken = bearerToken(request);
2142
2713
  if (route.command === 'session-login') {
2143
2714
  if (request.method !== 'POST') {
@@ -2176,6 +2747,43 @@ export function createSyncServiceRequestHandler({
2176
2747
  return;
2177
2748
  }
2178
2749
 
2750
+ if (route.command === 'anonymous-start') {
2751
+ if (request.method !== 'POST') {
2752
+ methodNotAllowed(response, 'Use POST for /auth/anonymous/start.');
2753
+ return;
2754
+ }
2755
+
2756
+ if (!issueAnonymousWriteAccess) {
2757
+ methodNotAllowed(response, 'Anonymous hosted persistence is not enabled for this service mode.');
2758
+ return;
2759
+ }
2760
+
2761
+ try {
2762
+ const body = await readJsonBody(request);
2763
+ const installId = typeof body?.installId === 'string' ? body.installId.trim() : '';
2764
+ if (!installId) {
2765
+ badRequest(response, 'installId is required.');
2766
+ return;
2767
+ }
2768
+
2769
+ const issued = await issueAnonymousWriteAccess({ installId });
2770
+ // Return only the minimal account shape. `issued.account` also carries
2771
+ // internal fields (identities, consent timestamps, capabilities) that
2772
+ // the client doesn't need and shouldn't receive on an anon endpoint.
2773
+ json(response, 200, {
2774
+ ok: true,
2775
+ account: { id: issued.account.id, email: issued.account.email ?? null },
2776
+ anonymous: {
2777
+ accessToken: issued.accessToken
2778
+ }
2779
+ });
2780
+ return;
2781
+ } catch (error) {
2782
+ badRequest(response, error.message);
2783
+ return;
2784
+ }
2785
+ }
2786
+
2179
2787
  if (route.command === 'session-refresh') {
2180
2788
  if (request.method !== 'POST') {
2181
2789
  methodNotAllowed(response, 'Use POST for /auth/refresh.');
@@ -2211,6 +2819,133 @@ export function createSyncServiceRequestHandler({
2211
2819
 
2212
2820
  const readAuthenticator = authenticateReadToken ?? authenticateToken;
2213
2821
  const writeAuthenticator = authenticateWriteToken ?? authenticateToken;
2822
+ const connectedWriteAuthenticator = authenticateConnectedWriteToken ?? readAuthenticator;
2823
+ const mobileSyncAuthenticator = writeAuthenticator;
2824
+
2825
+ if (route.command === 'mobile-sync-bootstrap') {
2826
+ if (request.method !== 'GET') {
2827
+ methodNotAllowed(response, 'Use GET for /mobile/sync/bootstrap.');
2828
+ return;
2829
+ }
2830
+ if (!loadMobileSyncStateForAccount) {
2831
+ methodNotAllowed(response, 'Mobile sync bootstrap 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 loadMobileSyncStateForAccount(account));
2844
+ return;
2845
+ }
2846
+
2847
+ if (route.command === 'mobile-sync-pull') {
2848
+ if (request.method !== 'GET') {
2849
+ methodNotAllowed(response, 'Use GET for /mobile/sync/pull.');
2850
+ return;
2851
+ }
2852
+ if (!pullMobileSyncStateForAccount) {
2853
+ methodNotAllowed(response, 'Mobile sync pull is not enabled for this service mode.');
2854
+ return;
2855
+ }
2856
+
2857
+ const account = mobileSyncAuthenticator
2858
+ ? await mobileSyncAuthenticator(requestToken)
2859
+ : null;
2860
+ if (!account) {
2861
+ unauthorized(response, request);
2862
+ return;
2863
+ }
2864
+
2865
+ json(response, 200, await pullMobileSyncStateForAccount(account, route.options.since));
2866
+ return;
2867
+ }
2868
+
2869
+ if (route.command === 'score-snapshots') {
2870
+ const account = connectedWriteAuthenticator
2871
+ ? await connectedWriteAuthenticator(requestToken)
2872
+ : null;
2873
+ if (!account) {
2874
+ unauthorized(response, request);
2875
+ return;
2876
+ }
2877
+
2878
+ if (request.method === 'POST') {
2879
+ if (!insertScoreSnapshotsForAccount) {
2880
+ methodNotAllowed(response, 'Score snapshot upload is not enabled for this service mode.');
2881
+ return;
2882
+ }
2883
+ try {
2884
+ const body = await readJsonBody(request);
2885
+ if (!body || typeof body !== 'object' || !Array.isArray(body.snapshots)) {
2886
+ badRequest(response, 'Invalid score snapshots body: expected an object with a snapshots array.');
2887
+ return;
2888
+ }
2889
+ const result = await insertScoreSnapshotsForAccount(account, body.snapshots);
2890
+ json(response, 200, result);
2891
+ return;
2892
+ } catch (error) {
2893
+ badRequest(response, error.message);
2894
+ return;
2895
+ }
2896
+ }
2897
+
2898
+ if (request.method === 'GET') {
2899
+ if (!listScoreSnapshotsForAccount) {
2900
+ methodNotAllowed(response, 'Score snapshot history is not enabled for this service mode.');
2901
+ return;
2902
+ }
2903
+ try {
2904
+ const rawSnapshots = await listScoreSnapshotsForAccount(account, route.options ?? {});
2905
+ const snapshots = enrichScoreSnapshots(rawSnapshots);
2906
+ json(response, 200, { snapshots });
2907
+ return;
2908
+ } catch (error) {
2909
+ badRequest(response, error.message);
2910
+ return;
2911
+ }
2912
+ }
2913
+
2914
+ methodNotAllowed(response, 'Use POST to upload or GET to list score snapshots.');
2915
+ return;
2916
+ }
2917
+
2918
+ if (route.command === 'mobile-sync-push') {
2919
+ if (request.method !== 'POST') {
2920
+ methodNotAllowed(response, 'Use POST for /mobile/sync/push.');
2921
+ return;
2922
+ }
2923
+ if (!pushMobileSyncChangesForAccount) {
2924
+ methodNotAllowed(response, 'Mobile sync push is not enabled for this service mode.');
2925
+ return;
2926
+ }
2927
+
2928
+ const account = mobileSyncAuthenticator
2929
+ ? await mobileSyncAuthenticator(requestToken)
2930
+ : null;
2931
+ if (!account) {
2932
+ unauthorized(response, request);
2933
+ return;
2934
+ }
2935
+
2936
+ try {
2937
+ const body = await readJsonBody(request);
2938
+ if (!body || typeof body !== 'object' || !Array.isArray(body.changes)) {
2939
+ badRequest(response, 'Invalid mobile sync push body: expected an object with a changes array.');
2940
+ return;
2941
+ }
2942
+ json(response, 200, await pushMobileSyncChangesForAccount(account, body));
2943
+ return;
2944
+ } catch (error) {
2945
+ badRequest(response, error.message);
2946
+ return;
2947
+ }
2948
+ }
2214
2949
 
2215
2950
  if (route.command === 'delete-account') {
2216
2951
  if (request.method !== 'DELETE') {
@@ -2249,8 +2984,8 @@ export function createSyncServiceRequestHandler({
2249
2984
  return;
2250
2985
  }
2251
2986
 
2252
- const proposalAccount = writeAuthenticator
2253
- ? await writeAuthenticator(requestToken)
2987
+ const proposalAccount = connectedWriteAuthenticator
2988
+ ? await connectedWriteAuthenticator(requestToken)
2254
2989
  : requestToken === token
2255
2990
  ? { id: 'remote-user', email: null }
2256
2991
  : null;
@@ -2317,8 +3052,8 @@ export function createSyncServiceRequestHandler({
2317
3052
  return;
2318
3053
  }
2319
3054
 
2320
- const proposalAccount = writeAuthenticator
2321
- ? await writeAuthenticator(requestToken)
3055
+ const proposalAccount = connectedWriteAuthenticator
3056
+ ? await connectedWriteAuthenticator(requestToken)
2322
3057
  : requestToken === token
2323
3058
  ? { id: 'remote-user', email: null }
2324
3059
  : null;
@@ -2357,8 +3092,8 @@ export function createSyncServiceRequestHandler({
2357
3092
  methodNotAllowed(response, 'Program sharing is not enabled for this service mode.');
2358
3093
  return;
2359
3094
  }
2360
- const account = writeAuthenticator
2361
- ? await writeAuthenticator(requestToken)
3095
+ const account = connectedWriteAuthenticator
3096
+ ? await connectedWriteAuthenticator(requestToken)
2362
3097
  : null;
2363
3098
  if (!account) {
2364
3099
  unauthorized(response, request);
@@ -2368,6 +3103,8 @@ export function createSyncServiceRequestHandler({
2368
3103
  const share = await createProgramShareForAccount(account, route.options.programId);
2369
3104
  json(response, 201, {
2370
3105
  ok: true,
3106
+ shareId: share.id,
3107
+ tokenHint: share.tokenHint,
2371
3108
  token: share.token,
2372
3109
  programId: share.programId,
2373
3110
  createdAt: share.createdAt,
@@ -2375,7 +3112,7 @@ export function createSyncServiceRequestHandler({
2375
3112
  revokedAt: share.revokedAt,
2376
3113
  version: share.version,
2377
3114
  link: `${resolvedPublicOrigin}/program-share/${share.token}`,
2378
- deepLink: `incremnt://plan-share/${share.token}`
3115
+ deepLink: `incremnt://plan-share/${share.token}?base=${encodeURIComponent(resolvedPublicOrigin)}`
2379
3116
  });
2380
3117
  return;
2381
3118
  } catch (error) {
@@ -2410,7 +3147,8 @@ export function createSyncServiceRequestHandler({
2410
3147
  json(response, 200, {
2411
3148
  ok: true,
2412
3149
  shares: rows.map((share) => ({
2413
- token: share.token,
3150
+ shareId: share.id,
3151
+ tokenHint: share.tokenHint,
2414
3152
  programId: share.programId,
2415
3153
  createdAt: share.createdAt,
2416
3154
  expiresAt: share.expiresAt,
@@ -2438,27 +3176,28 @@ export function createSyncServiceRequestHandler({
2438
3176
  methodNotAllowed(response, 'Program sharing is not enabled for this service mode.');
2439
3177
  return;
2440
3178
  }
2441
- const account = writeAuthenticator
2442
- ? await writeAuthenticator(requestToken)
3179
+ const account = connectedWriteAuthenticator
3180
+ ? await connectedWriteAuthenticator(requestToken)
2443
3181
  : null;
2444
3182
  if (!account) {
2445
3183
  unauthorized(response, request);
2446
3184
  return;
2447
3185
  }
2448
3186
  try {
2449
- const share = await revokeProgramShareForAccount(account, route.options.token);
3187
+ const share = await revokeProgramShareForAccount(account, route.options.shareId);
2450
3188
  if (!share) {
2451
3189
  notFound(response, 'Program share not found.');
2452
3190
  return;
2453
3191
  }
2454
3192
  json(response, 200, {
2455
3193
  ok: true,
2456
- token: share.token,
3194
+ shareId: share.id,
3195
+ tokenHint: share.tokenHint,
2457
3196
  revokedAt: share.revokedAt
2458
3197
  });
2459
3198
  return;
2460
3199
  } catch (error) {
2461
- if (error?.message === 'Invalid program share token.') {
3200
+ if (error?.message === 'Invalid program share id.') {
2462
3201
  badRequest(response, error.message);
2463
3202
  return;
2464
3203
  }
@@ -2571,8 +3310,8 @@ export function createSyncServiceRequestHandler({
2571
3310
  return;
2572
3311
  }
2573
3312
 
2574
- const account = writeAuthenticator
2575
- ? await writeAuthenticator(requestToken)
3313
+ const account = connectedWriteAuthenticator
3314
+ ? await connectedWriteAuthenticator(requestToken)
2576
3315
  : requestToken === token
2577
3316
  ? { id: 'remote-user', email: null }
2578
3317
  : null;
@@ -2649,8 +3388,8 @@ export function createSyncServiceRequestHandler({
2649
3388
  return;
2650
3389
  }
2651
3390
 
2652
- const writeAccount = writeAuthenticator
2653
- ? await writeAuthenticator(requestToken)
3391
+ const writeAccount = connectedWriteAuthenticator
3392
+ ? await connectedWriteAuthenticator(requestToken)
2654
3393
  : requestToken === token
2655
3394
  ? { id: 'remote-user', email: null }
2656
3395
  : null;
@@ -2710,8 +3449,8 @@ export function createSyncServiceRequestHandler({
2710
3449
  return;
2711
3450
  }
2712
3451
 
2713
- const writeAccount = writeAuthenticator
2714
- ? await writeAuthenticator(requestToken)
3452
+ const writeAccount = connectedWriteAuthenticator
3453
+ ? await connectedWriteAuthenticator(requestToken)
2715
3454
  : requestToken === token
2716
3455
  ? { id: 'remote-user', email: null }
2717
3456
  : null;
@@ -2759,6 +3498,59 @@ export function createSyncServiceRequestHandler({
2759
3498
  }
2760
3499
  // Parse comma-separated exclude param into a Set for AI context builders
2761
3500
  const parseExclude = (raw) => new Set((raw ?? '').split(',').map((s) => s.trim()).filter(Boolean));
3501
+ const aiUser = anonymizeAccountId(account.id);
3502
+ const hydrateIncrementScore = async (limit = 14) => {
3503
+ if (!listScoreSnapshotsForAccount) return;
3504
+ const scoreSnapshots = await listScoreSnapshotsForAccount(account, { limit }) ?? [];
3505
+ if (scoreSnapshots.length > 0) {
3506
+ const enrichedSnapshots = enrichScoreSnapshots(scoreSnapshots);
3507
+ snapshot.incrementScore = {
3508
+ latest: enrichedSnapshots[0],
3509
+ history: enrichedSnapshots
3510
+ };
3511
+ }
3512
+ };
3513
+
3514
+ if (route.command === 'increment-score-current') {
3515
+ if (request.method !== 'GET') {
3516
+ methodNotAllowed(response, 'Use GET for /cli/increment-score/current.');
3517
+ return;
3518
+ }
3519
+
3520
+ try {
3521
+ await hydrateIncrementScore(route.options.historyDays ?? 14);
3522
+ const queries = await import('./queries.js');
3523
+ json(response, 200, queries.incrementScoreCurrent(snapshot, route.options));
3524
+ return;
3525
+ } catch (error) {
3526
+ badRequest(response, error.message);
3527
+ return;
3528
+ }
3529
+ }
3530
+
3531
+ if (route.command === 'coach-tool') {
3532
+ if (request.method !== 'POST') {
3533
+ methodNotAllowed(response, 'Use POST for /cli/coach-tools/:toolName.');
3534
+ return;
3535
+ }
3536
+
3537
+ try {
3538
+ const body = await readJsonBody(request);
3539
+ const queries = await import('./queries.js');
3540
+ if (!queries.COACH_READ_TOOL_NAMES.includes(route.options.toolName)) {
3541
+ notFound(response, `Unknown coach read tool: ${route.options.toolName}`);
3542
+ return;
3543
+ }
3544
+ if (route.options.toolName === 'get_increment_score') {
3545
+ await hydrateIncrementScore(body?.historyDays ?? 14);
3546
+ }
3547
+ json(response, 200, queries.executeCoachReadTool(snapshot, route.options.toolName, body ?? {}));
3548
+ return;
3549
+ } catch (error) {
3550
+ badRequest(response, error.message);
3551
+ return;
3552
+ }
3553
+ }
2762
3554
 
2763
3555
  if (route.command === 'workout-summary-ai') {
2764
3556
  const sessionId = route.options['session-id'];
@@ -2768,7 +3560,8 @@ export function createSyncServiceRequestHandler({
2768
3560
  }
2769
3561
 
2770
3562
  const { workoutSummaryContext } = await import('./queries.js');
2771
- const ctx = workoutSummaryContext(snapshot, sessionId, { exclude: parseExclude(route.options['exclude']) });
3563
+ const exclude = parseExclude(route.options['exclude']);
3564
+ const ctx = workoutSummaryContext(snapshot, sessionId, { exclude });
2772
3565
  if (!ctx) {
2773
3566
  notFound(response, `Session not found: ${sessionId}`);
2774
3567
  return;
@@ -2781,8 +3574,17 @@ export function createSyncServiceRequestHandler({
2781
3574
  }
2782
3575
 
2783
3576
  try {
2784
- const { generateWorkoutCoachingSummary } = await import('./openrouter.js');
2785
- const result = await generateWorkoutCoachingSummary(ctx, { apiKey: openrouterKey, tone: route.options['tone'] });
3577
+ const { AI_PROMPT_VERSIONS, generateWorkoutCoachingSummary } = await import('./openrouter.js');
3578
+ const result = await generateWorkoutCoachingSummary(ctx, {
3579
+ apiKey: openrouterKey,
3580
+ tone: route.options['tone'],
3581
+ user: aiUser,
3582
+ sessionId: `workout:${sessionId}`,
3583
+ contextMetadata: {
3584
+ excludedSections: [...exclude],
3585
+ sessionId
3586
+ }
3587
+ });
2786
3588
  if (isNoInsightResponse(result.text)) {
2787
3589
  response.writeHead(204).end();
2788
3590
  return;
@@ -2804,7 +3606,11 @@ export function createSyncServiceRequestHandler({
2804
3606
  json(response, 200, { summary: null, model: result.model, filtered: true });
2805
3607
  return;
2806
3608
  }
2807
- json(response, 200, { summary: stripXMLTagBlocks(result.text), model: result.model });
3609
+ json(response, 200, {
3610
+ summary: stripXMLTagBlocks(result.text),
3611
+ model: result.model,
3612
+ metadata: buildAIGenerationMetadata('workout', result.model, AI_PROMPT_VERSIONS.workout, result)
3613
+ });
2808
3614
  } catch (err) {
2809
3615
  console.error('AI workout summary error:', err.message);
2810
3616
  onError?.(err, {
@@ -2841,6 +3647,245 @@ export function createSyncServiceRequestHandler({
2841
3647
  return;
2842
3648
  }
2843
3649
 
3650
+ if (route.command === 'weekly-checkin-enroll') {
3651
+ if (request.method !== 'POST') {
3652
+ methodNotAllowed(response, 'Use POST for /cli/weekly-checkin/enroll.');
3653
+ return;
3654
+ }
3655
+ if (!upsertScheduledWeeklyCheckinForAccount) {
3656
+ json(response, 503, { error: 'Weekly check-in not available' });
3657
+ return;
3658
+ }
3659
+ let body;
3660
+ try { body = await readJsonBody(request); } catch { badRequest(response, 'Invalid JSON body.'); return; }
3661
+ const timezoneId = body?.timezoneId;
3662
+ if (!isValidTimeZone(timezoneId)) {
3663
+ badRequest(response, 'timezoneId must be a valid IANA timezone identifier.');
3664
+ return;
3665
+ }
3666
+ const schedule = nextWeeklyCheckinSchedule(timezoneId);
3667
+ try {
3668
+ const row = await upsertScheduledWeeklyCheckinForAccount(account, {
3669
+ id: `weekly-checkin:${account.id}:${schedule.weekStartDate}:${randomUUID()}`,
3670
+ weekStartDate: schedule.weekStartDate,
3671
+ nextRecapDueAt: schedule.nextRecapDueAt,
3672
+ timezoneId
3673
+ });
3674
+ json(response, 200, {
3675
+ id: row.id,
3676
+ weekStartDate: row.weekStartDate,
3677
+ status: row.status,
3678
+ nextRecapDueAt: row.nextRecapDueAt,
3679
+ timezoneId: row.timezoneId
3680
+ });
3681
+ } catch (err) {
3682
+ console.error('Weekly check-in enroll error:', err.message);
3683
+ json(response, 500, { error: 'Failed to enroll weekly check-in' });
3684
+ }
3685
+ return;
3686
+ }
3687
+
3688
+ if (route.command === 'weekly-checkin-current') {
3689
+ if (request.method !== 'GET') {
3690
+ methodNotAllowed(response, 'Use GET for /cli/weekly-checkin/current.');
3691
+ return;
3692
+ }
3693
+ if (!getCurrentWeeklyCheckinForAccount) {
3694
+ json(response, 503, { error: 'Weekly check-in not available' });
3695
+ return;
3696
+ }
3697
+ try {
3698
+ const row = await getCurrentWeeklyCheckinForAccount(account);
3699
+ if (!row) {
3700
+ notFound(response, 'No weekly check-in scheduled yet.');
3701
+ return;
3702
+ }
3703
+ // Lazy-gen path: if scheduled and overdue, attempt to generate now.
3704
+ if (row.status === 'scheduled' && row.nextRecapDueAt && new Date(row.nextRecapDueAt) <= new Date()) {
3705
+ const openrouterKey = process.env.OPENROUTER_API_KEY;
3706
+ let recap = null;
3707
+ if (openrouterKey && generateWeeklyCheckinRecapImpl && generateCheckinQuestionsImpl) {
3708
+ try {
3709
+ const { weeklyCheckinContext } = await import('./queries.js');
3710
+ const ctx = weeklyCheckinContext(snapshot, account.id, {});
3711
+ if (ctx) {
3712
+ let priorCommitmentRow = null;
3713
+ if (listActiveCoachCommitmentsForAccount) {
3714
+ try {
3715
+ const activeCommitments = await listActiveCoachCommitmentsForAccount(account, {
3716
+ limit: 1,
3717
+ weekStartDate: ctx.weekRangeIso?.start
3718
+ });
3719
+ priorCommitmentRow = activeCommitments[0] ?? null;
3720
+ } catch (commitmentErr) {
3721
+ console.error('Lazy weekly-checkin commitment read failed:', commitmentErr.message);
3722
+ }
3723
+ }
3724
+ const recapResult = await generateWeeklyCheckinRecapImpl(ctx, {
3725
+ apiKey: openrouterKey,
3726
+ user: aiUser,
3727
+ sessionId: `weekly-checkin:${row.id}:recap`,
3728
+ priorCommitment: priorCommitmentRow?.commitment ?? null,
3729
+ contextMetadata: {
3730
+ coachCommitmentIds: priorCommitmentRow?.id ? [priorCommitmentRow.id] : []
3731
+ }
3732
+ });
3733
+ const questionsResult = await generateCheckinQuestionsImpl(ctx, recapResult.text, {
3734
+ apiKey: openrouterKey,
3735
+ user: aiUser,
3736
+ sessionId: `weekly-checkin:${row.id}:questions`
3737
+ });
3738
+ recap = {
3739
+ recapText: recapResult.text,
3740
+ questions: questionsResult.questions,
3741
+ model: recapResult.model,
3742
+ generatedAt: new Date().toISOString()
3743
+ };
3744
+ }
3745
+ } catch (genErr) {
3746
+ console.error('Lazy weekly-checkin gen failed:', genErr.message);
3747
+ }
3748
+ }
3749
+ if (!recap) {
3750
+ recap = { recapText: 'Your recap is being prepared.', questions: [], placeholder: true, generatedAt: new Date().toISOString() };
3751
+ }
3752
+ if (transitionWeeklyCheckinForAccount) {
3753
+ try {
3754
+ const updated = await transitionWeeklyCheckinForAccount(account, row.id, { toStatus: 'generated', recap });
3755
+ if (updated) Object.assign(row, updated);
3756
+ } catch (tErr) {
3757
+ console.error('Lazy weekly-checkin transition failed:', tErr.message);
3758
+ const latest = await getCurrentWeeklyCheckinForAccount(account);
3759
+ if (latest?.id === row.id) {
3760
+ Object.assign(row, latest);
3761
+ }
3762
+ }
3763
+ }
3764
+ }
3765
+ json(response, 200, {
3766
+ id: row.id,
3767
+ weekStartDate: row.weekStartDate,
3768
+ status: row.status,
3769
+ recap: row.recap,
3770
+ conversationId: row.conversationId
3771
+ });
3772
+ } catch (err) {
3773
+ console.error('Weekly check-in current error:', err.message);
3774
+ json(response, 500, { error: 'Failed to load weekly check-in' });
3775
+ }
3776
+ return;
3777
+ }
3778
+
3779
+ if (route.command === 'weekly-checkin-ack') {
3780
+ if (request.method !== 'POST') {
3781
+ methodNotAllowed(response, 'Use POST for /cli/weekly-checkin/ack.');
3782
+ return;
3783
+ }
3784
+ if (!getCurrentWeeklyCheckinForAccount || !transitionWeeklyCheckinForAccount) {
3785
+ json(response, 503, { error: 'Weekly check-in not available' });
3786
+ return;
3787
+ }
3788
+ let body;
3789
+ try { body = await readJsonBody(request); } catch { badRequest(response, 'Invalid JSON body.'); return; }
3790
+ const action = body?.action;
3791
+ if (action !== 'opened' && action !== 'dismissed') {
3792
+ badRequest(response, 'action must be "opened" or "dismissed".');
3793
+ return;
3794
+ }
3795
+ const row = await getCurrentWeeklyCheckinForAccount(account);
3796
+ if (!row) {
3797
+ notFound(response, 'No weekly check-in.');
3798
+ return;
3799
+ }
3800
+ try {
3801
+ const updated = await transitionWeeklyCheckinForAccount(account, row.id, { toStatus: action });
3802
+ if (!updated) {
3803
+ badRequest(response, `Cannot transition from ${row.status} to ${action}.`);
3804
+ return;
3805
+ }
3806
+ json(response, 200, { id: updated.id, status: updated.status });
3807
+ } catch (err) {
3808
+ console.error('Weekly check-in ack error:', err.message);
3809
+ if (err.code === 'invalid_transition') {
3810
+ badRequest(response, err.message);
3811
+ return;
3812
+ }
3813
+ json(response, 500, { error: 'Failed to ack weekly check-in' });
3814
+ }
3815
+ return;
3816
+ }
3817
+
3818
+ if (route.command === 'weekly-checkin-start') {
3819
+ if (request.method !== 'POST') {
3820
+ methodNotAllowed(response, 'Use POST for /cli/weekly-checkin/start.');
3821
+ return;
3822
+ }
3823
+ if (!getCurrentWeeklyCheckinForAccount || !transitionWeeklyCheckinForAccount || !saveAskConversationForAccount) {
3824
+ json(response, 503, { error: 'Weekly check-in not available' });
3825
+ return;
3826
+ }
3827
+ const row = await getCurrentWeeklyCheckinForAccount(account);
3828
+ if (!row) {
3829
+ notFound(response, 'No weekly check-in scheduled.');
3830
+ return;
3831
+ }
3832
+ if (!row.recap || row.recap.placeholder) {
3833
+ json(response, 409, { error: 'Weekly check-in recap not ready yet.', code: 'recap_not_ready' });
3834
+ return;
3835
+ }
3836
+ const conversationId = `weekly-checkin:${row.id}`;
3837
+ const recapText = String(row.recap.recapText ?? '').trim();
3838
+ const questions = Array.isArray(row.recap.questions) ? row.recap.questions : [];
3839
+ const firstAssistantMessage = [recapText, ...(questions.length ? [questions.map((q, i) => `${i + 1}. ${q}`).join('\n')] : [])].filter(Boolean).join('\n\n');
3840
+ try {
3841
+ const { AI_PROMPT_VERSIONS } = await import('./openrouter.js');
3842
+ const existingConversation = getAskConversationForAccount
3843
+ ? await getAskConversationForAccount(account, conversationId)
3844
+ : null;
3845
+ if (Array.isArray(existingConversation?.messages) && existingConversation.messages.length > 0) {
3846
+ const existingFirstAssistant = existingConversation.messages.find((m) => m.role === 'assistant')?.content;
3847
+ json(response, 200, {
3848
+ conversationId,
3849
+ firstAssistantMessage: existingFirstAssistant || firstAssistantMessage,
3850
+ questions,
3851
+ resumed: true
3852
+ });
3853
+ return;
3854
+ }
3855
+ if (row.status !== 'in_progress') {
3856
+ let openedRow = row;
3857
+ if (openedRow.status === 'generated' || openedRow.status === 'delivered') {
3858
+ openedRow = await transitionWeeklyCheckinForAccount(account, row.id, { toStatus: 'opened' });
3859
+ }
3860
+ if (!openedRow || openedRow.status !== 'opened') {
3861
+ json(response, 409, { error: `Cannot start weekly check-in from ${row.status}.`, code: 'invalid_state' });
3862
+ return;
3863
+ }
3864
+ const inProgress = await transitionWeeklyCheckinForAccount(account, row.id, { toStatus: 'in_progress', conversationId });
3865
+ if (!inProgress || inProgress.status !== 'in_progress') {
3866
+ json(response, 409, { error: 'Weekly check-in state was not updated.', code: 'state_update_failed' });
3867
+ return;
3868
+ }
3869
+ }
3870
+ await saveAskConversationForAccount(account, {
3871
+ id: conversationId,
3872
+ messages: [{ role: 'assistant', content: firstAssistantMessage }],
3873
+ model: row.recap.model ?? null,
3874
+ metadata: buildAIGenerationMetadata('weekly-checkin', row.recap.model ?? null, AI_PROMPT_VERSIONS.weeklyCheckin, row.recap),
3875
+ kind: 'weekly-checkin'
3876
+ });
3877
+ json(response, 200, { conversationId, firstAssistantMessage, questions });
3878
+ } catch (err) {
3879
+ console.error('Weekly check-in start error:', err.message);
3880
+ if (err.code === 'invalid_transition') {
3881
+ json(response, 409, { error: err.message, code: 'invalid_transition' });
3882
+ return;
3883
+ }
3884
+ json(response, 500, { error: 'Failed to start weekly check-in' });
3885
+ }
3886
+ return;
3887
+ }
3888
+
2844
3889
  if (route.command === 'cycle-summary-ai') {
2845
3890
  const programId = route.options['program-id'];
2846
3891
  if (!programId) {
@@ -2849,22 +3894,13 @@ export function createSyncServiceRequestHandler({
2849
3894
  }
2850
3895
 
2851
3896
  const { cycleSummaryContext } = await import('./queries.js');
2852
- const ctx = cycleSummaryContext(snapshot, programId, { exclude: parseExclude(route.options['exclude']) });
3897
+ const exclude = parseExclude(route.options['exclude']);
3898
+ const ctx = cycleSummaryContext(snapshot, programId, { exclude });
2853
3899
  if (!ctx) {
2854
3900
  notFound(response, `No completed cycle found for program: ${programId}`);
2855
3901
  return;
2856
3902
  }
2857
3903
 
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
3904
  const openrouterKey = process.env.OPENROUTER_API_KEY;
2869
3905
  if (!openrouterKey) {
2870
3906
  json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
@@ -2872,11 +3908,17 @@ export function createSyncServiceRequestHandler({
2872
3908
  }
2873
3909
 
2874
3910
  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'] });
3911
+ const { AI_PROMPT_VERSIONS, generateCoachingSummary } = await import('./openrouter.js');
3912
+ const result = await generateCoachingSummary(ctx, {
3913
+ apiKey: openrouterKey,
3914
+ tone: route.options['tone'],
3915
+ user: aiUser,
3916
+ sessionId: `cycle:${programId}:${ctx.cycleNumber}`,
3917
+ contextMetadata: {
3918
+ excludedSections: [...exclude],
3919
+ programId
3920
+ }
3921
+ });
2880
3922
  if (result.fallback && onError) {
2881
3923
  const warning = new Error(`AI cycle-summary used fallback model ${result.model}`);
2882
3924
  warning.level = 'warning';
@@ -2894,32 +3936,11 @@ export function createSyncServiceRequestHandler({
2894
3936
  json(response, 200, { summary: null, model: result.model, filtered: true });
2895
3937
  return;
2896
3938
  }
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
- }
3939
+ json(response, 200, {
3940
+ summary: stripXMLTagBlocks(result.text),
3941
+ model: result.model,
3942
+ metadata: buildAIGenerationMetadata('cycle', result.model, AI_PROMPT_VERSIONS.cycle, result)
3943
+ });
2923
3944
  } catch (err) {
2924
3945
  console.error('AI cycle summary error:', err.message);
2925
3946
  onError?.(err, {
@@ -2940,11 +3961,21 @@ export function createSyncServiceRequestHandler({
2940
3961
  }
2941
3962
 
2942
3963
  const { vitalsSummaryContext } = await import('./queries.js');
2943
- const ctx = vitalsSummaryContext(snapshot, { exclude: parseExclude(route.options['exclude']) });
3964
+ const exclude = parseExclude(route.options['exclude']);
3965
+ const ctx = vitalsSummaryContext(snapshot, { exclude });
2944
3966
 
2945
3967
  try {
2946
- const { generateVitalsSummary } = await import('./openrouter.js');
2947
- const result = await generateVitalsSummary(ctx, { apiKey: openrouterKey, tone: route.options['tone'] });
3968
+ const { AI_PROMPT_VERSIONS, generateVitalsSummary } = await import('./openrouter.js');
3969
+ const result = await generateVitalsSummary(ctx, {
3970
+ apiKey: openrouterKey,
3971
+ tone: route.options['tone'],
3972
+ user: aiUser,
3973
+ sessionId: `vitals:${new Date().toISOString().slice(0, 10)}`,
3974
+ contextMetadata: {
3975
+ excludedSections: [...exclude],
3976
+ recentDays: 14
3977
+ }
3978
+ });
2948
3979
  if (result.fallback && onError) {
2949
3980
  const warning = new Error(`AI vitals-summary used fallback model ${result.model}`);
2950
3981
  warning.level = 'warning';
@@ -2962,7 +3993,11 @@ export function createSyncServiceRequestHandler({
2962
3993
  json(response, 200, { summary: null, model: result.model, filtered: true });
2963
3994
  return;
2964
3995
  }
2965
- json(response, 200, { summary: stripXMLTagBlocks(result.text), model: result.model });
3996
+ json(response, 200, {
3997
+ summary: stripXMLTagBlocks(result.text),
3998
+ model: result.model,
3999
+ metadata: buildAIGenerationMetadata('vitals', result.model, AI_PROMPT_VERSIONS.vitals, result)
4000
+ });
2966
4001
  } catch (err) {
2967
4002
  console.error('AI vitals summary error:', err.message);
2968
4003
  onError?.(err, {
@@ -2988,24 +4023,13 @@ export function createSyncServiceRequestHandler({
2988
4023
  }
2989
4024
 
2990
4025
  const { checkpointContext } = await import('./queries.js');
2991
- const ctx = checkpointContext(snapshot, programId, checkpointWeek, { exclude: parseExclude(route.options['exclude']) });
4026
+ const exclude = parseExclude(route.options['exclude']);
4027
+ const ctx = checkpointContext(snapshot, programId, checkpointWeek, { exclude });
2992
4028
  if (!ctx) {
2993
4029
  notFound(response, 'No strength plan found for program');
2994
4030
  return;
2995
4031
  }
2996
4032
 
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
4033
  const openrouterKey = process.env.OPENROUTER_API_KEY;
3010
4034
  if (!openrouterKey) {
3011
4035
  json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
@@ -3013,8 +4037,18 @@ export function createSyncServiceRequestHandler({
3013
4037
  }
3014
4038
 
3015
4039
  try {
3016
- const { generateCheckpointSummary } = await import('./openrouter.js');
3017
- const result = await generateCheckpointSummary(ctx, { apiKey: openrouterKey, tone: route.options['tone'] });
4040
+ const { AI_PROMPT_VERSIONS, generateCheckpointSummary } = await import('./openrouter.js');
4041
+ const result = await generateCheckpointSummary(ctx, {
4042
+ apiKey: openrouterKey,
4043
+ tone: route.options['tone'],
4044
+ user: aiUser,
4045
+ sessionId: `checkpoint:${programId}:${checkpointWeek}`,
4046
+ contextMetadata: {
4047
+ excludedSections: [...exclude],
4048
+ programId,
4049
+ checkpointWeek
4050
+ }
4051
+ });
3018
4052
  if (result.fallback && onError) {
3019
4053
  const warning = new Error(`AI checkpoint-summary used fallback model ${result.model}`);
3020
4054
  warning.level = 'warning';
@@ -3032,7 +4066,11 @@ export function createSyncServiceRequestHandler({
3032
4066
  json(response, 200, { summary: null, model: result.model, filtered: true });
3033
4067
  return;
3034
4068
  }
3035
- json(response, 200, { summary: stripXMLTagBlocks(result.text), model: result.model });
4069
+ json(response, 200, {
4070
+ summary: stripXMLTagBlocks(result.text),
4071
+ model: result.model,
4072
+ metadata: buildAIGenerationMetadata('checkpoint', result.model, AI_PROMPT_VERSIONS.checkpoint, result)
4073
+ });
3036
4074
  } catch (err) {
3037
4075
  console.error('AI checkpoint summary error:', err.message);
3038
4076
  onError?.(err, {
@@ -3083,12 +4121,6 @@ export function createSyncServiceRequestHandler({
3083
4121
  ? sanitizeHistory(persistedConversation.messages)
3084
4122
  : null;
3085
4123
  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
4124
  const openrouterKey = process.env.OPENROUTER_API_KEY;
3093
4125
  if (!openrouterKey) {
3094
4126
  json(response, 503, { error: 'AI not configured', code: 'not_configured' });
@@ -3097,55 +4129,159 @@ export function createSyncServiceRequestHandler({
3097
4129
 
3098
4130
  const queries = await import('./queries.js');
3099
4131
  const exclude = parseExclude(body?.exclude);
3100
- let ctx = queries.askContext(snapshot, { exclude });
3101
-
3102
- // Inject coach memory into ask context
3103
- if (readCoachMemoryForAccount) {
4132
+ let coachFacts = [];
4133
+ if (listCoachFactsForAccount) {
3104
4134
  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);
4135
+ const kinds = queries.coachFactKindsForAskQuestion
4136
+ ? queries.coachFactKindsForAskQuestion(snapshot, question)
4137
+ : [];
4138
+ coachFacts = await listCoachFactsForAccount(account, { kinds, limit: 30 });
4139
+ } catch (factErr) {
4140
+ console.error('Coach facts read error (ask):', factErr.message);
4141
+ }
4142
+ }
4143
+ let scoreSnapshots = [];
4144
+ if (listScoreSnapshotsForAccount) {
4145
+ try {
4146
+ scoreSnapshots = await listScoreSnapshotsForAccount(account, { limit: 14 }) ?? [];
4147
+ } catch (scoreErr) {
4148
+ console.error('Increment Score read error (ask):', scoreErr.message);
3111
4149
  }
3112
4150
  }
4151
+ if (scoreSnapshots.length > 0) {
4152
+ const enrichedSnapshots = enrichScoreSnapshots(scoreSnapshots);
4153
+ snapshot.incrementScore = {
4154
+ latest: enrichedSnapshots[0],
4155
+ history: enrichedSnapshots
4156
+ };
4157
+ }
4158
+
4159
+ const routedContext = queries.askRoutedContext
4160
+ ? queries.askRoutedContext(snapshot, question, { exclude, coachFacts })
4161
+ : { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
4162
+ const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
4163
+ const serverProgramPhase = persistedKind === 'weekly-checkin'
4164
+ ? queries.weeklyCheckinContext?.(snapshot, account.id)?.programPhase
4165
+ : null;
4166
+ const programPhasePrelude = formatProgramPhasePrelude(body?.programPhase ?? serverProgramPhase);
4167
+ const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
4168
+
4169
+ const preludes = [programPhasePrelude, incrementScorePrelude].filter(Boolean);
4170
+ const ctx = preludes.length > 0
4171
+ ? `${preludes.join('\n\n')}\n\n${routedContext.context}`
4172
+ : routedContext.context;
3113
4173
 
3114
4174
  const askTone = ['default', 'hype', 'numbers-only'].includes(body?.tone) ? body.tone : undefined;
3115
4175
 
3116
4176
  try {
3117
- const { generateAskAnswer } = await import('./openrouter.js');
3118
-
3119
- const askResult = await generateAskAnswer(ctx, question, {
3120
- apiKey: openrouterKey, history: canonicalHistory, tone: askTone
4177
+ const { AI_PROMPT_VERSIONS, generateAskAnswer, WEEKLY_CHECKIN_PROMPT } = await import('./openrouter.js');
4178
+ const generateAsk = generateAskAnswerImpl ?? generateAskAnswer;
4179
+ const systemPromptOverride = persistedKind === 'weekly-checkin' ? WEEKLY_CHECKIN_PROMPT : undefined;
4180
+
4181
+ const askResult = await generateAsk(ctx, question, {
4182
+ apiKey: openrouterKey,
4183
+ history: canonicalHistory,
4184
+ tone: askTone,
4185
+ user: aiUser,
4186
+ sessionId: `ask:${conversationId}`,
4187
+ systemPrompt: systemPromptOverride,
4188
+ routingMetadata: {
4189
+ ...routedContext.metadata,
4190
+ contextCharCount: ctx.length,
4191
+ historyTurnCount: canonicalHistory.filter((m) => m.role === 'user').length,
4192
+ coachFactsIncluded: (routedContext.metadata?.includedCoachFactIds ?? []).length > 0,
4193
+ coachFactIds: routedContext.metadata?.includedCoachFactIds ?? [],
4194
+ coachFactKinds: routedContext.metadata?.coachFactKinds ?? []
4195
+ }
3121
4196
  });
3122
4197
 
3123
- // Check for system prompt leakage BEFORE persisting — a leaked
3124
- // response must never be saved to the conversation history.
4198
+ const parsedAsk = extractAskProgramDraft(askResult.text, {
4199
+ canonicalizeExerciseName: queries.canonicalExerciseName
4200
+ });
4201
+ const assistantAnswer = stripXMLTagBlocks(parsedAsk.answerText);
4202
+ // Check for system prompt leakage before persisting. We inspect only
4203
+ // the user-visible prose, not the structured draft payload, so valid
4204
+ // <program_draft> output does not false-positive as a prompt leak.
3125
4205
  const { SYSTEM_PROMPTS_FOR_LEAK_CHECK } = await import('./openrouter.js');
3126
- if (detectSystemPromptLeak(askResult.text, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
4206
+ if (detectSystemPromptLeak(assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
3127
4207
  console.error('SECURITY: System prompt leak detected in ask-ai response, blocking');
3128
4208
  onError?.(new Error('System prompt leak detected in AI response'), { feature: 'ask-coach', security: true });
3129
4209
  json(response, 200, { answer: 'I can only answer questions about your training. Could you rephrase?', model: askResult.model, filtered: true });
3130
4210
  return;
3131
4211
  }
3132
4212
 
4213
+ const promptSurface = persistedKind === 'weekly-checkin' ? 'weekly-checkin' : 'ask';
4214
+ const promptVersion = persistedKind === 'weekly-checkin' ? AI_PROMPT_VERSIONS.weeklyCheckin : AI_PROMPT_VERSIONS.ask;
4215
+ const metadata = buildAIGenerationMetadata(promptSurface, askResult.model, promptVersion, askResult);
3133
4216
  const updatedMessages = [
3134
4217
  ...canonicalHistory,
3135
4218
  { role: 'user', content: question },
3136
- { role: 'assistant', content: askResult.text }
4219
+ { role: 'assistant', content: assistantAnswer }
3137
4220
  ];
3138
4221
  if (saveAskConversationForAccount) {
3139
4222
  try {
3140
4223
  await saveAskConversationForAccount(account, {
3141
4224
  id: conversationId,
3142
4225
  messages: updatedMessages,
3143
- model: askResult.model
4226
+ model: askResult.model,
4227
+ metadata,
4228
+ kind: persistedKind
3144
4229
  });
3145
4230
  } catch (saveErr) {
3146
4231
  console.error('Failed to save ask conversation:', saveErr.message);
3147
4232
  }
3148
4233
  }
4234
+ if (saveCoachFactsForAccount) {
4235
+ const transcript = transcriptForCoachFactExtraction(updatedMessages);
4236
+ const sourceSessionId = conversationId;
4237
+ setImmediate(() => {
4238
+ extractAndSaveCoachFacts({
4239
+ account,
4240
+ sourceSurface: promptSurface,
4241
+ sourceSessionId,
4242
+ transcript,
4243
+ openrouterKey,
4244
+ aiUser,
4245
+ saveCoachFactsForAccount,
4246
+ generateCoachFactCandidatesImpl,
4247
+ onError
4248
+ });
4249
+ });
4250
+ }
4251
+ const updatedUserTurns = updatedMessages.filter((m) => m.role === 'user').length;
4252
+ if (
4253
+ persistedKind === 'weekly-checkin' &&
4254
+ updatedUserTurns >= WEEKLY_CHECKIN_COMPLETION_USER_TURNS &&
4255
+ conversationId.startsWith('weekly-checkin:') &&
4256
+ transitionWeeklyCheckinForAccount
4257
+ ) {
4258
+ const weeklyCheckinId = conversationId.slice('weekly-checkin:'.length);
4259
+ let completedCheckin = null;
4260
+ try {
4261
+ completedCheckin = await transitionWeeklyCheckinForAccount(account, weeklyCheckinId, { toStatus: 'completed' });
4262
+ } catch (completeErr) {
4263
+ if (completeErr.code !== 'invalid_transition') {
4264
+ console.error('Weekly check-in completion transition failed:', completeErr.message);
4265
+ }
4266
+ }
4267
+ if (saveCoachCommitmentsForAccount) {
4268
+ setImmediate(async () => {
4269
+ try {
4270
+ const { extractCoachCommitmentsFromUserTurns } = await import('./openrouter.js');
4271
+ const commitments = extractCoachCommitmentsFromUserTurns(updatedMessages);
4272
+ if (commitments.length === 0) return;
4273
+ await saveCoachCommitmentsForAccount(account, commitments, {
4274
+ weekStartDate: completedCheckin?.weekStartDate ?? new Date().toISOString().slice(0, 10),
4275
+ sourceSurface: 'weekly-checkin',
4276
+ sourceConversationId: conversationId
4277
+ });
4278
+ } catch (commitmentErr) {
4279
+ console.error('Background weekly check-in commitment save failed:', commitmentErr.message);
4280
+ onError?.(commitmentErr, { feature: 'weekly-checkin-commitment-save' });
4281
+ }
4282
+ });
4283
+ }
4284
+ }
3149
4285
  if (askResult.fallback && onError) {
3150
4286
  const warning = new Error(`AI ask-coach used fallback model ${askResult.model}`);
3151
4287
  warning.level = 'warning';
@@ -3156,7 +4292,12 @@ export function createSyncServiceRequestHandler({
3156
4292
  fallbackModel: askResult.model
3157
4293
  });
3158
4294
  }
3159
- json(response, 200, { answer: stripXMLTagBlocks(askResult.text), model: askResult.model });
4295
+ json(response, 200, {
4296
+ answer: assistantAnswer,
4297
+ model: askResult.model,
4298
+ metadata,
4299
+ programDraft: parsedAsk.programDraft
4300
+ });
3160
4301
  } catch (err) {
3161
4302
  console.error('AI ask error:', err.message);
3162
4303
  onError?.(err, {
@@ -3235,7 +4376,10 @@ export function createSyncServiceRequestHandler({
3235
4376
  id: c.id,
3236
4377
  preview: (firstUserMsg?.content ?? '').slice(0, 120),
3237
4378
  messageCount: c.messages?.length ?? 0,
3238
- createdAt: c.createdAt
4379
+ createdAt: c.createdAt,
4380
+ model: c.model ?? null,
4381
+ kind: c.kind ?? 'ask',
4382
+ metadata: c.metadata ?? null
3239
4383
  };
3240
4384
  });
3241
4385
  json(response, 200, { conversations: summaries });
@@ -3266,7 +4410,7 @@ export function createSyncServiceRequestHandler({
3266
4410
  const conversations = await listAskConversationsForAccount(account);
3267
4411
  conversation = conversations.find((c) => c.id === route.options.id);
3268
4412
  }
3269
- if (!conversation) {
4413
+ if (!conversation || (conversation.kind ?? 'ask') !== 'ask') {
3270
4414
  notFound(response, `Conversation not found: ${route.options.id}`);
3271
4415
  return;
3272
4416
  }