incremnt 0.3.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 } 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,11 +31,20 @@ 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,
31
38
  'proposals': 30,
32
39
  'proposal-update': 30,
40
+ 'program-share-create': 30,
41
+ 'program-share-list': 60,
42
+ 'program-share-public': 120,
43
+ 'program-share-revoke': 30,
44
+ 'mobile-sync-bootstrap': 60,
45
+ 'mobile-sync-pull': 120,
46
+ 'mobile-sync-push': 60,
47
+ 'score-snapshots': 60,
33
48
  'social-invite': 20,
34
49
  'social-groups': 60,
35
50
  'social-group-create': 20,
@@ -52,6 +67,8 @@ const DEFAULT_RATE_LIMIT_RULES = {
52
67
  'social-user-suggestions': 60,
53
68
  'social-user-report': 60,
54
69
  'social-user-exercise-history': 60,
70
+ 'social-user-activities': 60,
71
+ 'social-user-best-efforts': 60,
55
72
  'social-user-mute': 20,
56
73
  'social-user-block': 20,
57
74
  'social-report': 20,
@@ -90,6 +107,292 @@ export function isNoInsightResponse(text) {
90
107
  return normalized === 'NO_INSIGHT' || normalized.startsWith('NO_INSIGHT\n');
91
108
  }
92
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
+
93
396
  function json(response, statusCode, payload) {
94
397
  response.writeHead(statusCode, { 'content-type': 'application/json' });
95
398
  response.end(JSON.stringify(payload));
@@ -111,17 +414,6 @@ function logRequest(request, statusCode, extra = '') {
111
414
  console.log(`${method} ${path} ${statusCode}${suffix}`);
112
415
  }
113
416
 
114
- function anonymizeAccountId(accountId) {
115
- if (typeof accountId !== 'string' || !accountId.trim()) {
116
- return 'anon:unknown';
117
- }
118
- const digest = createHash('sha256')
119
- .update(`account:${accountId}`)
120
- .digest('hex')
121
- .slice(0, 12);
122
- return `anon:${digest}`;
123
- }
124
-
125
417
  function anonymizeSessionToken(sessionToken) {
126
418
  if (typeof sessionToken !== 'string' || !sessionToken.trim()) {
127
419
  return 'sess:unknown';
@@ -133,6 +425,30 @@ function anonymizeSessionToken(sessionToken) {
133
425
  return `sess:${digest}`;
134
426
  }
135
427
 
428
+ function resolveConfiguredPublicOrigin(candidate) {
429
+ if (typeof candidate !== 'string' || !candidate.trim()) {
430
+ return null;
431
+ }
432
+
433
+ let parsed;
434
+ try {
435
+ parsed = new URL(candidate);
436
+ } catch {
437
+ return null;
438
+ }
439
+
440
+ if ((parsed.protocol !== 'https:' && parsed.protocol !== 'http:') ||
441
+ parsed.username ||
442
+ parsed.password ||
443
+ parsed.pathname !== '/' ||
444
+ parsed.search ||
445
+ parsed.hash) {
446
+ return null;
447
+ }
448
+
449
+ return parsed.origin;
450
+ }
451
+
136
452
  function sanitizeSocialLogValue(value) {
137
453
  if (typeof value === 'string') return value;
138
454
  if (typeof value === 'number' || typeof value === 'boolean') return value;
@@ -185,6 +501,71 @@ function anonymizeRelationIds(items, { max = 5 } = {}) {
185
501
  .join(',');
186
502
  }
187
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
+
188
569
  function unauthorized(response, request) {
189
570
  if (request) logRequest(request, 401);
190
571
  json(response, 401, { error: 'Unauthorized' });
@@ -212,6 +593,13 @@ function internalError(response, error, onError) {
212
593
  json(response, 500, { error: 'Internal server error' });
213
594
  }
214
595
 
596
+ function reportAuthFailure(onError, error, context = {}) {
597
+ onError?.(error, {
598
+ feature: 'auth-callback',
599
+ ...context
600
+ });
601
+ }
602
+
215
603
  function constantTimeEqual(a, b) {
216
604
  if (!a || !b) return false;
217
605
  // Hash both values to fixed length to avoid leaking length information
@@ -311,6 +699,10 @@ function routeRequest(url, method) {
311
699
  return { command: 'session-login', options: {} };
312
700
  }
313
701
 
702
+ if (pathname === '/auth/anonymous/start') {
703
+ return { command: 'anonymous-start', options: {} };
704
+ }
705
+
314
706
  if (pathname === '/auth/refresh') {
315
707
  return { command: 'session-refresh', options: {} };
316
708
  }
@@ -335,6 +727,10 @@ function routeRequest(url, method) {
335
727
  return { command: 'google-callback', options: {} };
336
728
  }
337
729
 
730
+ if (pathname === '/auth/google/mobile') {
731
+ return { command: 'google-mobile', options: {} };
732
+ }
733
+
338
734
  if (pathname === '/auth/apple/start') {
339
735
  return { command: 'apple-start', options: {} };
340
736
  }
@@ -359,6 +755,34 @@ function routeRequest(url, method) {
359
755
  return { command: 'sync-upload', options: {} };
360
756
  }
361
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
+
362
786
  if (pathname === '/cli/account') {
363
787
  return { command: 'delete-account', options: {} };
364
788
  }
@@ -400,6 +824,46 @@ function routeRequest(url, method) {
400
824
  return { command: 'proposals', options: { status: url.searchParams.get('status') ?? undefined } };
401
825
  }
402
826
 
827
+ {
828
+ const programShareCreateMatch = pathname.match(/^\/cli\/programs\/([^/]+)\/share$/);
829
+ if (programShareCreateMatch) {
830
+ return {
831
+ command: 'program-share-create',
832
+ options: { programId: decodeURIComponent(programShareCreateMatch[1]) }
833
+ };
834
+ }
835
+ }
836
+
837
+ {
838
+ const programShareListMatch = pathname.match(/^\/cli\/programs\/([^/]+)\/shares$/);
839
+ if (programShareListMatch) {
840
+ return {
841
+ command: 'program-share-list',
842
+ options: { programId: decodeURIComponent(programShareListMatch[1]) }
843
+ };
844
+ }
845
+ }
846
+
847
+ {
848
+ const programSharePublicMatch = pathname.match(/^\/program-share\/([^/]+)$/);
849
+ if (programSharePublicMatch) {
850
+ return {
851
+ command: 'program-share-public',
852
+ options: { token: decodeURIComponent(programSharePublicMatch[1]) }
853
+ };
854
+ }
855
+ }
856
+
857
+ {
858
+ const programShareRevokeMatch = pathname.match(/^\/cli\/program-share\/([^/]+)\/revoke$/);
859
+ if (programShareRevokeMatch) {
860
+ return {
861
+ command: 'program-share-revoke',
862
+ options: { shareId: decodeURIComponent(programShareRevokeMatch[1]) }
863
+ };
864
+ }
865
+ }
866
+
403
867
  const proposalUpdateMatch = pathname.match(/^\/cli\/programs\/proposals\/([^/]+)$/);
404
868
  if (proposalUpdateMatch) {
405
869
  return { command: 'proposal-update', options: { id: proposalUpdateMatch[1] } };
@@ -548,6 +1012,22 @@ function routeRequest(url, method) {
548
1012
  return { command: 'ai-feedback', options: {} };
549
1013
  }
550
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
+
551
1031
  if (pathname === '/cli/health/ai') {
552
1032
  return {
553
1033
  command: 'health-ai',
@@ -605,6 +1085,30 @@ function routeRequest(url, method) {
605
1085
  }
606
1086
  }
607
1087
 
1088
+ {
1089
+ const userActivitiesMatch = pathname.match(/^\/cli\/social\/users\/([^/]+)\/activities$/);
1090
+ if (userActivitiesMatch) {
1091
+ return {
1092
+ command: 'social-user-activities',
1093
+ options: {
1094
+ accountId: decodeURIComponent(userActivitiesMatch[1]),
1095
+ limit: url.searchParams.get('limit') ?? undefined,
1096
+ before: url.searchParams.get('before') ?? undefined
1097
+ }
1098
+ };
1099
+ }
1100
+ }
1101
+
1102
+ {
1103
+ const userBestEffortsMatch = pathname.match(/^\/cli\/social\/users\/([^/]+)\/best-efforts$/);
1104
+ if (userBestEffortsMatch) {
1105
+ return {
1106
+ command: 'social-user-best-efforts',
1107
+ options: { accountId: decodeURIComponent(userBestEffortsMatch[1]) }
1108
+ };
1109
+ }
1110
+ }
1111
+
608
1112
  {
609
1113
  const userReportMatch = pathname.match(/^\/cli\/social\/users\/([^/]+)\/report$/);
610
1114
  if (userReportMatch) {
@@ -908,6 +1412,91 @@ function routeRequest(url, method) {
908
1412
  return null;
909
1413
  }
910
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
+
911
1500
  async function readJsonBody(request) {
912
1501
  const chunks = [];
913
1502
  let totalSize = 0;
@@ -961,7 +1550,7 @@ function deviceApprovalPage({
961
1550
  userCode = '',
962
1551
  email = '',
963
1552
  userId = '',
964
- includeManualForm = true,
1553
+ includeManualForm = false,
965
1554
  appleStartPath = null,
966
1555
  googleStartPath = null,
967
1556
  isError = false
@@ -973,6 +1562,7 @@ function deviceApprovalPage({
973
1562
  const escapedUserId = escapeHtml(userId);
974
1563
  const badgeBg = isError ? 'rgba(255,69,58,0.15)' : 'rgba(0,255,163,0.1)';
975
1564
  const badgeColor = isError ? '#FF453A' : '#00ffa3';
1565
+ const hasProviderActions = Boolean(appleStartPath || googleStartPath);
976
1566
 
977
1567
  return `<!doctype html>
978
1568
  <html lang="en">
@@ -1139,7 +1729,9 @@ function deviceApprovalPage({
1139
1729
  </form>
1140
1730
  <small>Enter the code shown by <code>incremnt login</code>. Provide either the email or user ID for the account that should own this session.</small>
1141
1731
  ` : `
1142
- <small>Continue with a configured identity provider to approve the code shown by <code>incremnt login</code>.</small>
1732
+ <small>${hasProviderActions
1733
+ ? 'Continue with a configured identity provider to approve the code shown by <code>incremnt login</code>.'
1734
+ : 'No hosted identity provider is available for this login flow.'}</small>
1143
1735
  `}
1144
1736
  </main>
1145
1737
  </body>
@@ -1346,6 +1938,7 @@ export function createSyncServiceRequestHandler({
1346
1938
  writeSnapshotForAccount,
1347
1939
  issueDevLogin,
1348
1940
  issueSession,
1941
+ issueAnonymousWriteAccess,
1349
1942
  issueDeviceChallenge,
1350
1943
  consumeDeviceChallenge,
1351
1944
  readDeviceChallengeByUserCode,
@@ -1368,22 +1961,44 @@ export function createSyncServiceRequestHandler({
1368
1961
  buildGoogleWebAuthUrl = null,
1369
1962
  completeAppleWebAuth = null,
1370
1963
  completeGoogleWebAuth = null,
1964
+ completeGoogleMobileAuth = null,
1371
1965
  refreshSession,
1966
+ authenticateConnectedWriteToken,
1372
1967
  allowManualDeviceApproval = false,
1373
1968
  rateLimitConfig = null,
1969
+ publicOrigin = null,
1374
1970
  corsOrigins = [],
1375
1971
  createProposalForAccount = null,
1376
1972
  listProposalsForAccount = null,
1377
1973
  updateProposalForAccount = null,
1974
+ createProgramShareForAccount = null,
1975
+ listProgramSharesForAccount = null,
1976
+ readPublicProgramShare = null,
1977
+ revokeProgramShareForAccount = null,
1378
1978
  updateAnalysisConsentForAccount = null,
1379
1979
  updateDisplayNameForAccount = null,
1380
1980
  saveAskConversationForAccount = null,
1381
1981
  listAskConversationsForAccount = null,
1382
1982
  getAskConversationForAccount = null,
1383
1983
  readCoachMemoryForAccount = null,
1384
- 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,
1385
1993
  saveAIFeedbackForAccount = null,
1994
+ generateAskAnswerImpl = null,
1995
+ generateCoachFactCandidatesImpl = null,
1386
1996
  deleteAccountForUser = null,
1997
+ loadMobileSyncStateForAccount = null,
1998
+ pullMobileSyncStateForAccount = null,
1999
+ pushMobileSyncChangesForAccount = null,
2000
+ insertScoreSnapshotsForAccount = null,
2001
+ listScoreSnapshotsForAccount = null,
1387
2002
  // Social
1388
2003
  social = null,
1389
2004
  onError = null
@@ -1412,6 +2027,8 @@ export function createSyncServiceRequestHandler({
1412
2027
  }
1413
2028
 
1414
2029
  const url = new URL(request.url ?? '/', `http://${request.headers.host ?? '127.0.0.1'}`);
2030
+ const resolvedPublicOrigin = resolveConfiguredPublicOrigin(publicOrigin)
2031
+ ?? `${url.protocol}//${url.host}`;
1415
2032
  const route = routeRequest(url, request.method);
1416
2033
  if (!route) {
1417
2034
  notFound(response);
@@ -1449,8 +2066,7 @@ export function createSyncServiceRequestHandler({
1449
2066
 
1450
2067
  logRequest(request, '-', rateLimitCommand);
1451
2068
 
1452
- const providerApprovalAvailable = Boolean(appleAuth?.configured || googleAuth?.configured);
1453
- const manualDeviceApprovalEnabled = allowManualDeviceApproval || !providerApprovalAvailable;
2069
+ const manualDeviceApprovalEnabled = allowManualDeviceApproval;
1454
2070
 
1455
2071
  if (route.command === 'auth-config') {
1456
2072
  json(response, 200, {
@@ -1648,6 +2264,12 @@ export function createSyncServiceRequestHandler({
1648
2264
  response.end();
1649
2265
  return;
1650
2266
  } catch (error) {
2267
+ reportAuthFailure(onError, error, {
2268
+ route: 'google-callback',
2269
+ provider: 'google',
2270
+ authFlow: 'web',
2271
+ statusCode: 400
2272
+ });
1651
2273
  html(response, 400, deviceApprovalPage({
1652
2274
  title: 'Login failed',
1653
2275
  message: error.message,
@@ -1670,6 +2292,12 @@ export function createSyncServiceRequestHandler({
1670
2292
  }));
1671
2293
  return;
1672
2294
  } catch (error) {
2295
+ reportAuthFailure(onError, error, {
2296
+ route: 'google-callback',
2297
+ provider: 'google',
2298
+ authFlow: 'device',
2299
+ statusCode: 400
2300
+ });
1673
2301
  html(response, 400, deviceApprovalPage({
1674
2302
  title: 'Approval failed',
1675
2303
  message: error.message,
@@ -1715,10 +2343,12 @@ export function createSyncServiceRequestHandler({
1715
2343
 
1716
2344
  let code = url.searchParams.get('code') ?? '';
1717
2345
  let state = url.searchParams.get('state') ?? '';
2346
+ let user = null;
1718
2347
  if (request.method === 'POST') {
1719
2348
  const body = await readUrlEncodedBody(request);
1720
2349
  code = body.code ?? code;
1721
2350
  state = body.state ?? state;
2351
+ user = body.user ?? null;
1722
2352
  }
1723
2353
 
1724
2354
  if (!code || !state) {
@@ -1741,13 +2371,19 @@ export function createSyncServiceRequestHandler({
1741
2371
  }
1742
2372
 
1743
2373
  try {
1744
- const result = await completeAppleWebAuth({ code, state });
2374
+ const result = await completeAppleWebAuth({ code, state, user });
1745
2375
  const returnUrl = new URL(result.returnUrl);
1746
2376
  returnUrl.hash = `token=${result.session.accessToken}&expires=${result.session.expiresAt}`;
1747
2377
  response.writeHead(302, { location: returnUrl.toString() });
1748
2378
  response.end();
1749
2379
  return;
1750
2380
  } catch (error) {
2381
+ reportAuthFailure(onError, error, {
2382
+ route: 'apple-callback',
2383
+ provider: 'apple',
2384
+ authFlow: 'web',
2385
+ statusCode: 400
2386
+ });
1751
2387
  html(response, 400, deviceApprovalPage({
1752
2388
  title: 'Login failed',
1753
2389
  message: error.message,
@@ -1763,7 +2399,7 @@ export function createSyncServiceRequestHandler({
1763
2399
  }
1764
2400
 
1765
2401
  try {
1766
- const result = await completeAppleDeviceApproval({ code, state });
2402
+ const result = await completeAppleDeviceApproval({ code, state, user });
1767
2403
  html(response, 200, deviceApprovalSuccessPage({
1768
2404
  email: result.account.email ?? '',
1769
2405
  userId: result.account.id
@@ -1776,6 +2412,12 @@ export function createSyncServiceRequestHandler({
1776
2412
  hasCode: Boolean(code),
1777
2413
  hasState: Boolean(state)
1778
2414
  });
2415
+ reportAuthFailure(onError, error, {
2416
+ route: 'apple-callback',
2417
+ provider: 'apple',
2418
+ authFlow: 'device',
2419
+ statusCode: 400
2420
+ });
1779
2421
  html(response, 400, deviceApprovalPage({
1780
2422
  title: 'Approval failed',
1781
2423
  message: error.message,
@@ -1849,19 +2491,25 @@ export function createSyncServiceRequestHandler({
1849
2491
  }
1850
2492
 
1851
2493
  if (request.method === 'GET') {
2494
+ const appleStartPath = appleAuth?.configured
2495
+ ? `/auth/apple/start?userCode=${encodeURIComponent(url.searchParams.get('userCode') ?? '')}`
2496
+ : null;
2497
+ const googleStartPath = googleAuth?.configured
2498
+ ? `/auth/google/start?userCode=${encodeURIComponent(url.searchParams.get('userCode') ?? '')}`
2499
+ : null;
2500
+ const hasHostedProvider = Boolean(appleStartPath || googleStartPath);
1852
2501
  html(response, 200, deviceApprovalPage({
1853
- title: 'Approve incremnt login',
1854
- message: 'Enter the approval code shown by the CLI and the account identity that should own the session.',
2502
+ title: hasHostedProvider ? 'Approve incremnt login' : 'Cloud Sync unavailable',
2503
+ message: hasHostedProvider
2504
+ ? 'Continue with a configured identity provider to approve the code shown by incremnt login.'
2505
+ : 'Cloud Sync sign-in is temporarily unavailable. Try again later.',
1855
2506
  userCode: url.searchParams.get('userCode') ?? '',
1856
2507
  email: url.searchParams.get('email') ?? '',
1857
2508
  userId: url.searchParams.get('userId') ?? '',
1858
- includeManualForm: manualDeviceApprovalEnabled,
1859
- appleStartPath: appleAuth?.configured
1860
- ? `/auth/apple/start?userCode=${encodeURIComponent(url.searchParams.get('userCode') ?? '')}`
1861
- : null,
1862
- googleStartPath: googleAuth?.configured
1863
- ? `/auth/google/start?userCode=${encodeURIComponent(url.searchParams.get('userCode') ?? '')}`
1864
- : null
2509
+ includeManualForm: false,
2510
+ appleStartPath,
2511
+ googleStartPath,
2512
+ isError: !hasHostedProvider
1865
2513
  }));
1866
2514
  return;
1867
2515
  }
@@ -1878,9 +2526,11 @@ export function createSyncServiceRequestHandler({
1878
2526
 
1879
2527
  try {
1880
2528
  const contentType = request.headers['content-type'] ?? '';
1881
- const body = contentType.includes('application/json')
1882
- ? await readJsonBody(request)
1883
- : await readUrlEncodedBody(request);
2529
+ if (!contentType.includes('application/json')) {
2530
+ methodNotAllowed(response, 'Manual device approval only accepts application/json.');
2531
+ return;
2532
+ }
2533
+ const body = await readJsonBody(request);
1884
2534
  const result = await approveDeviceChallenge({
1885
2535
  deviceCode: body.deviceCode ?? null,
1886
2536
  userCode: body.userCode ?? body.user_code ?? null,
@@ -1888,24 +2538,13 @@ export function createSyncServiceRequestHandler({
1888
2538
  email: body.email ?? null
1889
2539
  });
1890
2540
 
1891
- if (contentType.includes('application/json')) {
1892
- json(response, 200, {
1893
- ok: true,
1894
- deviceCode: result.deviceCode,
1895
- userCode: result.userCode,
1896
- account: result.account,
1897
- expiresAt: result.expiresAt
1898
- });
1899
- return;
1900
- }
1901
-
1902
- html(response, 200, deviceApprovalPage({
1903
- title: 'Login approved',
1904
- message: `The session for ${result.account.email ?? result.account.id} is ready. Return to the CLI to finish login.`,
2541
+ json(response, 200, {
2542
+ ok: true,
2543
+ deviceCode: result.deviceCode,
1905
2544
  userCode: result.userCode,
1906
- email: result.account.email ?? '',
1907
- userId: result.account.id
1908
- }));
2545
+ account: result.account,
2546
+ expiresAt: result.expiresAt
2547
+ });
1909
2548
  return;
1910
2549
  } catch (error) {
1911
2550
  html(response, 400, deviceApprovalPage({
@@ -1967,6 +2606,87 @@ export function createSyncServiceRequestHandler({
1967
2606
  }
1968
2607
  }
1969
2608
 
2609
+ if (route.command === 'program-share-public') {
2610
+ if (request.method !== 'GET') {
2611
+ methodNotAllowed(response, 'Use GET for /program-share/:token.');
2612
+ return;
2613
+ }
2614
+ if (!readPublicProgramShare) {
2615
+ methodNotAllowed(response, 'Program sharing is not enabled for this service mode.');
2616
+ return;
2617
+ }
2618
+ try {
2619
+ const shared = await readPublicProgramShare(route.options.token);
2620
+ if (shared.status === 'not_found') {
2621
+ notFound(response, 'Program share not found.');
2622
+ return;
2623
+ }
2624
+ if (shared.status === 'revoked' || shared.status === 'expired') {
2625
+ json(response, 410, { error: 'Program share is no longer available.' });
2626
+ return;
2627
+ }
2628
+ json(response, 200, {
2629
+ ok: true,
2630
+ token: route.options.token,
2631
+ version: shared.share.version,
2632
+ programId: shared.share.programId,
2633
+ programName: shared.share.programPayload?.name ?? null,
2634
+ programPayload: shared.share.programPayload,
2635
+ createdAt: shared.share.createdAt,
2636
+ expiresAt: shared.share.expiresAt
2637
+ });
2638
+ return;
2639
+ } catch (error) {
2640
+ if (error?.message === 'Invalid program share token.') {
2641
+ badRequest(response, error.message);
2642
+ return;
2643
+ }
2644
+ internalError(response, error, onError);
2645
+ return;
2646
+ }
2647
+ }
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
+
1970
2690
  const requestToken = bearerToken(request);
1971
2691
  if (route.command === 'session-login') {
1972
2692
  if (request.method !== 'POST') {
@@ -2005,18 +2725,55 @@ export function createSyncServiceRequestHandler({
2005
2725
  return;
2006
2726
  }
2007
2727
 
2008
- if (route.command === 'session-refresh') {
2728
+ if (route.command === 'anonymous-start') {
2009
2729
  if (request.method !== 'POST') {
2010
- methodNotAllowed(response, 'Use POST for /auth/refresh.');
2730
+ methodNotAllowed(response, 'Use POST for /auth/anonymous/start.');
2011
2731
  return;
2012
2732
  }
2013
2733
 
2014
- if (!requestToken) {
2015
- unauthorized(response, request);
2734
+ if (!issueAnonymousWriteAccess) {
2735
+ methodNotAllowed(response, 'Anonymous hosted persistence is not enabled for this service mode.');
2016
2736
  return;
2017
2737
  }
2018
2738
 
2019
- if (!refreshSession) {
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
+
2765
+ if (route.command === 'session-refresh') {
2766
+ if (request.method !== 'POST') {
2767
+ methodNotAllowed(response, 'Use POST for /auth/refresh.');
2768
+ return;
2769
+ }
2770
+
2771
+ if (!requestToken) {
2772
+ unauthorized(response, request);
2773
+ return;
2774
+ }
2775
+
2776
+ if (!refreshSession) {
2020
2777
  methodNotAllowed(response, 'Session refresh is not enabled for this service mode.');
2021
2778
  return;
2022
2779
  }
@@ -2040,6 +2797,132 @@ export function createSyncServiceRequestHandler({
2040
2797
 
2041
2798
  const readAuthenticator = authenticateReadToken ?? authenticateToken;
2042
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
+ }
2043
2926
 
2044
2927
  if (route.command === 'delete-account') {
2045
2928
  if (request.method !== 'DELETE') {
@@ -2078,8 +2961,8 @@ export function createSyncServiceRequestHandler({
2078
2961
  return;
2079
2962
  }
2080
2963
 
2081
- const proposalAccount = writeAuthenticator
2082
- ? await writeAuthenticator(requestToken)
2964
+ const proposalAccount = connectedWriteAuthenticator
2965
+ ? await connectedWriteAuthenticator(requestToken)
2083
2966
  : requestToken === token
2084
2967
  ? { id: 'remote-user', email: null }
2085
2968
  : null;
@@ -2146,8 +3029,8 @@ export function createSyncServiceRequestHandler({
2146
3029
  return;
2147
3030
  }
2148
3031
 
2149
- const proposalAccount = writeAuthenticator
2150
- ? await writeAuthenticator(requestToken)
3032
+ const proposalAccount = connectedWriteAuthenticator
3033
+ ? await connectedWriteAuthenticator(requestToken)
2151
3034
  : requestToken === token
2152
3035
  ? { id: 'remote-user', email: null }
2153
3036
  : null;
@@ -2177,6 +3060,129 @@ export function createSyncServiceRequestHandler({
2177
3060
  }
2178
3061
  }
2179
3062
 
3063
+ if (route.command === 'program-share-create') {
3064
+ if (request.method !== 'POST') {
3065
+ methodNotAllowed(response, 'Use POST for /cli/programs/:programId/share.');
3066
+ return;
3067
+ }
3068
+ if (!createProgramShareForAccount) {
3069
+ methodNotAllowed(response, 'Program sharing is not enabled for this service mode.');
3070
+ return;
3071
+ }
3072
+ const account = connectedWriteAuthenticator
3073
+ ? await connectedWriteAuthenticator(requestToken)
3074
+ : null;
3075
+ if (!account) {
3076
+ unauthorized(response, request);
3077
+ return;
3078
+ }
3079
+ try {
3080
+ const share = await createProgramShareForAccount(account, route.options.programId);
3081
+ json(response, 201, {
3082
+ ok: true,
3083
+ shareId: share.id,
3084
+ tokenHint: share.tokenHint,
3085
+ token: share.token,
3086
+ programId: share.programId,
3087
+ createdAt: share.createdAt,
3088
+ expiresAt: share.expiresAt,
3089
+ revokedAt: share.revokedAt,
3090
+ version: share.version,
3091
+ link: `${resolvedPublicOrigin}/program-share/${share.token}`,
3092
+ deepLink: `incremnt://plan-share/${share.token}?base=${encodeURIComponent(resolvedPublicOrigin)}`
3093
+ });
3094
+ return;
3095
+ } catch (error) {
3096
+ if (error?.message === 'programId is required.' || error?.message === 'Program not found.') {
3097
+ const code = error.message === 'Program not found.' ? 404 : 400;
3098
+ json(response, code, { error: error.message });
3099
+ return;
3100
+ }
3101
+ internalError(response, error, onError);
3102
+ return;
3103
+ }
3104
+ }
3105
+
3106
+ if (route.command === 'program-share-list') {
3107
+ if (request.method !== 'GET') {
3108
+ methodNotAllowed(response, 'Use GET for /cli/programs/:programId/shares.');
3109
+ return;
3110
+ }
3111
+ if (!listProgramSharesForAccount) {
3112
+ methodNotAllowed(response, 'Program sharing is not enabled for this service mode.');
3113
+ return;
3114
+ }
3115
+ const account = readAuthenticator
3116
+ ? await readAuthenticator(requestToken)
3117
+ : null;
3118
+ if (!account) {
3119
+ unauthorized(response, request);
3120
+ return;
3121
+ }
3122
+ try {
3123
+ const rows = await listProgramSharesForAccount(account, route.options.programId);
3124
+ json(response, 200, {
3125
+ ok: true,
3126
+ shares: rows.map((share) => ({
3127
+ shareId: share.id,
3128
+ tokenHint: share.tokenHint,
3129
+ programId: share.programId,
3130
+ createdAt: share.createdAt,
3131
+ expiresAt: share.expiresAt,
3132
+ revokedAt: share.revokedAt,
3133
+ version: share.version
3134
+ }))
3135
+ });
3136
+ return;
3137
+ } catch (error) {
3138
+ if (error?.message === 'programId is required.') {
3139
+ badRequest(response, error.message);
3140
+ return;
3141
+ }
3142
+ internalError(response, error, onError);
3143
+ return;
3144
+ }
3145
+ }
3146
+
3147
+ if (route.command === 'program-share-revoke') {
3148
+ if (request.method !== 'POST') {
3149
+ methodNotAllowed(response, 'Use POST for /cli/program-share/:token/revoke.');
3150
+ return;
3151
+ }
3152
+ if (!revokeProgramShareForAccount) {
3153
+ methodNotAllowed(response, 'Program sharing is not enabled for this service mode.');
3154
+ return;
3155
+ }
3156
+ const account = connectedWriteAuthenticator
3157
+ ? await connectedWriteAuthenticator(requestToken)
3158
+ : null;
3159
+ if (!account) {
3160
+ unauthorized(response, request);
3161
+ return;
3162
+ }
3163
+ try {
3164
+ const share = await revokeProgramShareForAccount(account, route.options.shareId);
3165
+ if (!share) {
3166
+ notFound(response, 'Program share not found.');
3167
+ return;
3168
+ }
3169
+ json(response, 200, {
3170
+ ok: true,
3171
+ shareId: share.id,
3172
+ tokenHint: share.tokenHint,
3173
+ revokedAt: share.revokedAt
3174
+ });
3175
+ return;
3176
+ } catch (error) {
3177
+ if (error?.message === 'Invalid program share id.') {
3178
+ badRequest(response, error.message);
3179
+ return;
3180
+ }
3181
+ internalError(response, error, onError);
3182
+ return;
3183
+ }
3184
+ }
3185
+
2180
3186
  if (route.command === 'contract') {
2181
3187
  const account = readAuthenticator
2182
3188
  ? await readAuthenticator(requestToken)
@@ -2281,8 +3287,8 @@ export function createSyncServiceRequestHandler({
2281
3287
  return;
2282
3288
  }
2283
3289
 
2284
- const account = writeAuthenticator
2285
- ? await writeAuthenticator(requestToken)
3290
+ const account = connectedWriteAuthenticator
3291
+ ? await connectedWriteAuthenticator(requestToken)
2286
3292
  : requestToken === token
2287
3293
  ? { id: 'remote-user', email: null }
2288
3294
  : null;
@@ -2359,8 +3365,8 @@ export function createSyncServiceRequestHandler({
2359
3365
  return;
2360
3366
  }
2361
3367
 
2362
- const writeAccount = writeAuthenticator
2363
- ? await writeAuthenticator(requestToken)
3368
+ const writeAccount = connectedWriteAuthenticator
3369
+ ? await connectedWriteAuthenticator(requestToken)
2364
3370
  : requestToken === token
2365
3371
  ? { id: 'remote-user', email: null }
2366
3372
  : null;
@@ -2420,8 +3426,8 @@ export function createSyncServiceRequestHandler({
2420
3426
  return;
2421
3427
  }
2422
3428
 
2423
- const writeAccount = writeAuthenticator
2424
- ? await writeAuthenticator(requestToken)
3429
+ const writeAccount = connectedWriteAuthenticator
3430
+ ? await connectedWriteAuthenticator(requestToken)
2425
3431
  : requestToken === token
2426
3432
  ? { id: 'remote-user', email: null }
2427
3433
  : null;
@@ -2469,6 +3475,7 @@ export function createSyncServiceRequestHandler({
2469
3475
  }
2470
3476
  // Parse comma-separated exclude param into a Set for AI context builders
2471
3477
  const parseExclude = (raw) => new Set((raw ?? '').split(',').map((s) => s.trim()).filter(Boolean));
3478
+ const aiUser = anonymizeAccountId(account.id);
2472
3479
 
2473
3480
  if (route.command === 'workout-summary-ai') {
2474
3481
  const sessionId = route.options['session-id'];
@@ -2478,7 +3485,8 @@ export function createSyncServiceRequestHandler({
2478
3485
  }
2479
3486
 
2480
3487
  const { workoutSummaryContext } = await import('./queries.js');
2481
- const ctx = workoutSummaryContext(snapshot, sessionId, { exclude: parseExclude(route.options['exclude']) });
3488
+ const exclude = parseExclude(route.options['exclude']);
3489
+ const ctx = workoutSummaryContext(snapshot, sessionId, { exclude });
2482
3490
  if (!ctx) {
2483
3491
  notFound(response, `Session not found: ${sessionId}`);
2484
3492
  return;
@@ -2491,8 +3499,17 @@ export function createSyncServiceRequestHandler({
2491
3499
  }
2492
3500
 
2493
3501
  try {
2494
- const { generateWorkoutCoachingSummary } = await import('./openrouter.js');
2495
- 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
+ });
2496
3513
  if (isNoInsightResponse(result.text)) {
2497
3514
  response.writeHead(204).end();
2498
3515
  return;
@@ -2514,7 +3531,11 @@ export function createSyncServiceRequestHandler({
2514
3531
  json(response, 200, { summary: null, model: result.model, filtered: true });
2515
3532
  return;
2516
3533
  }
2517
- json(response, 200, { summary: 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
+ });
2518
3539
  } catch (err) {
2519
3540
  console.error('AI workout summary error:', err.message);
2520
3541
  onError?.(err, {
@@ -2551,6 +3572,245 @@ export function createSyncServiceRequestHandler({
2551
3572
  return;
2552
3573
  }
2553
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
+
2554
3814
  if (route.command === 'cycle-summary-ai') {
2555
3815
  const programId = route.options['program-id'];
2556
3816
  if (!programId) {
@@ -2559,22 +3819,13 @@ export function createSyncServiceRequestHandler({
2559
3819
  }
2560
3820
 
2561
3821
  const { cycleSummaryContext } = await import('./queries.js');
2562
- const ctx = cycleSummaryContext(snapshot, programId, { exclude: parseExclude(route.options['exclude']) });
3822
+ const exclude = parseExclude(route.options['exclude']);
3823
+ const ctx = cycleSummaryContext(snapshot, programId, { exclude });
2563
3824
  if (!ctx) {
2564
3825
  notFound(response, `No completed cycle found for program: ${programId}`);
2565
3826
  return;
2566
3827
  }
2567
3828
 
2568
- // Inject coach memory into cycle summary context if available
2569
- let coachMemory = null;
2570
- if (readCoachMemoryForAccount) {
2571
- try {
2572
- coachMemory = await readCoachMemoryForAccount(account);
2573
- } catch (memErr) {
2574
- console.error('Coach memory read error (cycle-summary):', memErr.message);
2575
- }
2576
- }
2577
-
2578
3829
  const openrouterKey = process.env.OPENROUTER_API_KEY;
2579
3830
  if (!openrouterKey) {
2580
3831
  json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
@@ -2582,11 +3833,17 @@ export function createSyncServiceRequestHandler({
2582
3833
  }
2583
3834
 
2584
3835
  try {
2585
- const { generateCoachingSummary } = await import('./openrouter.js');
2586
- if (coachMemory?.content) {
2587
- ctx.coachMemory = coachMemory.content;
2588
- }
2589
- 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
+ });
2590
3847
  if (result.fallback && onError) {
2591
3848
  const warning = new Error(`AI cycle-summary used fallback model ${result.model}`);
2592
3849
  warning.level = 'warning';
@@ -2604,32 +3861,11 @@ export function createSyncServiceRequestHandler({
2604
3861
  json(response, 200, { summary: null, model: result.model, filtered: true });
2605
3862
  return;
2606
3863
  }
2607
- json(response, 200, { summary: result.text, model: result.model });
2608
-
2609
- // Background: update coach memory after responding
2610
- if (writeCoachMemoryForAccount && readCoachMemoryForAccount) {
2611
- setImmediate(async () => {
2612
- try {
2613
- const { generateMemoryUpdate } = await import('./openrouter.js');
2614
- // Build recent context from previous cycle summaries
2615
- const recentLines = (ctx.previousCycles || [])
2616
- .filter((pc) => pc.previousAISummary)
2617
- .map((pc) => `Week ${pc.weekNumber}: ${pc.previousAISummary.split('\n')[0].slice(0, 200)}`)
2618
- .join('\n');
2619
- const memResult = await generateMemoryUpdate(
2620
- coachMemory?.content || '',
2621
- result.text,
2622
- recentLines || null,
2623
- { apiKey: openrouterKey }
2624
- );
2625
- await writeCoachMemoryForAccount(account, memResult.text);
2626
- console.log(`Coach memory updated for account (v${(coachMemory?.version ?? 0) + 1})`);
2627
- } catch (memErr) {
2628
- console.error('Background coach memory update failed:', memErr.message);
2629
- onError?.(memErr, { feature: 'coach-memory-update' });
2630
- }
2631
- });
2632
- }
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
+ });
2633
3869
  } catch (err) {
2634
3870
  console.error('AI cycle summary error:', err.message);
2635
3871
  onError?.(err, {
@@ -2650,11 +3886,21 @@ export function createSyncServiceRequestHandler({
2650
3886
  }
2651
3887
 
2652
3888
  const { vitalsSummaryContext } = await import('./queries.js');
2653
- const ctx = vitalsSummaryContext(snapshot, { exclude: parseExclude(route.options['exclude']) });
3889
+ const exclude = parseExclude(route.options['exclude']);
3890
+ const ctx = vitalsSummaryContext(snapshot, { exclude });
2654
3891
 
2655
3892
  try {
2656
- const { generateVitalsSummary } = await import('./openrouter.js');
2657
- 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
+ });
2658
3904
  if (result.fallback && onError) {
2659
3905
  const warning = new Error(`AI vitals-summary used fallback model ${result.model}`);
2660
3906
  warning.level = 'warning';
@@ -2672,7 +3918,11 @@ export function createSyncServiceRequestHandler({
2672
3918
  json(response, 200, { summary: null, model: result.model, filtered: true });
2673
3919
  return;
2674
3920
  }
2675
- json(response, 200, { summary: 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
+ });
2676
3926
  } catch (err) {
2677
3927
  console.error('AI vitals summary error:', err.message);
2678
3928
  onError?.(err, {
@@ -2698,24 +3948,13 @@ export function createSyncServiceRequestHandler({
2698
3948
  }
2699
3949
 
2700
3950
  const { checkpointContext } = await import('./queries.js');
2701
- 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 });
2702
3953
  if (!ctx) {
2703
3954
  notFound(response, 'No strength plan found for program');
2704
3955
  return;
2705
3956
  }
2706
3957
 
2707
- // Inject coach memory into checkpoint context
2708
- if (readCoachMemoryForAccount) {
2709
- try {
2710
- const mem = await readCoachMemoryForAccount(account);
2711
- if (mem?.content) {
2712
- ctx.coachMemory = mem.content;
2713
- }
2714
- } catch (memErr) {
2715
- console.error('Coach memory read error (checkpoint):', memErr.message);
2716
- }
2717
- }
2718
-
2719
3958
  const openrouterKey = process.env.OPENROUTER_API_KEY;
2720
3959
  if (!openrouterKey) {
2721
3960
  json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
@@ -2723,8 +3962,18 @@ export function createSyncServiceRequestHandler({
2723
3962
  }
2724
3963
 
2725
3964
  try {
2726
- const { generateCheckpointSummary } = await import('./openrouter.js');
2727
- 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
+ });
2728
3977
  if (result.fallback && onError) {
2729
3978
  const warning = new Error(`AI checkpoint-summary used fallback model ${result.model}`);
2730
3979
  warning.level = 'warning';
@@ -2742,7 +3991,11 @@ export function createSyncServiceRequestHandler({
2742
3991
  json(response, 200, { summary: null, model: result.model, filtered: true });
2743
3992
  return;
2744
3993
  }
2745
- json(response, 200, { summary: 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
+ });
2746
3999
  } catch (err) {
2747
4000
  console.error('AI checkpoint summary error:', err.message);
2748
4001
  onError?.(err, {
@@ -2793,12 +4046,6 @@ export function createSyncServiceRequestHandler({
2793
4046
  ? sanitizeHistory(persistedConversation.messages)
2794
4047
  : null;
2795
4048
  const canonicalHistory = (persistedMessages?.length ? persistedMessages : null) ?? history;
2796
- const priorUserTurns = canonicalHistory.filter((m) => m.role === 'user').length;
2797
- if (priorUserTurns >= MAX_ASK_USER_TURNS) {
2798
- json(response, 400, { error: `Ask Coach supports up to ${MAX_ASK_USER_TURNS} questions per conversation. Start a new conversation.`, code: 'conversation_limit' });
2799
- return;
2800
- }
2801
-
2802
4049
  const openrouterKey = process.env.OPENROUTER_API_KEY;
2803
4050
  if (!openrouterKey) {
2804
4051
  json(response, 503, { error: 'AI not configured', code: 'not_configured' });
@@ -2807,55 +4054,158 @@ export function createSyncServiceRequestHandler({
2807
4054
 
2808
4055
  const queries = await import('./queries.js');
2809
4056
  const exclude = parseExclude(body?.exclude);
2810
- let ctx = queries.askContext(snapshot, { exclude });
2811
-
2812
- // Inject coach memory into ask context
2813
- if (readCoachMemoryForAccount) {
4057
+ let coachFacts = [];
4058
+ if (listCoachFactsForAccount) {
2814
4059
  try {
2815
- const mem = await readCoachMemoryForAccount(account);
2816
- if (mem?.content) {
2817
- ctx = ctx + '\n\n' + fenceContent('coach_memory', mem.content);
2818
- }
2819
- } catch (memErr) {
2820
- 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);
4066
+ }
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);
2821
4074
  }
2822
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;
2823
4097
 
2824
4098
  const askTone = ['default', 'hype', 'numbers-only'].includes(body?.tone) ? body.tone : undefined;
2825
4099
 
2826
4100
  try {
2827
- const { generateAskAnswer } = await import('./openrouter.js');
2828
-
2829
- const askResult = await generateAskAnswer(ctx, question, {
2830
- 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
+ }
2831
4120
  });
2832
4121
 
2833
- // Check for system prompt leakage BEFORE persisting — a leaked
2834
- // 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.
2835
4129
  const { SYSTEM_PROMPTS_FOR_LEAK_CHECK } = await import('./openrouter.js');
2836
- if (detectSystemPromptLeak(askResult.text, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
4130
+ if (detectSystemPromptLeak(assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
2837
4131
  console.error('SECURITY: System prompt leak detected in ask-ai response, blocking');
2838
4132
  onError?.(new Error('System prompt leak detected in AI response'), { feature: 'ask-coach', security: true });
2839
4133
  json(response, 200, { answer: 'I can only answer questions about your training. Could you rephrase?', model: askResult.model, filtered: true });
2840
4134
  return;
2841
4135
  }
2842
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);
2843
4140
  const updatedMessages = [
2844
4141
  ...canonicalHistory,
2845
4142
  { role: 'user', content: question },
2846
- { role: 'assistant', content: askResult.text }
4143
+ { role: 'assistant', content: assistantAnswer }
2847
4144
  ];
2848
4145
  if (saveAskConversationForAccount) {
2849
4146
  try {
2850
4147
  await saveAskConversationForAccount(account, {
2851
4148
  id: conversationId,
2852
4149
  messages: updatedMessages,
2853
- model: askResult.model
4150
+ model: askResult.model,
4151
+ metadata,
4152
+ kind: persistedKind
2854
4153
  });
2855
4154
  } catch (saveErr) {
2856
4155
  console.error('Failed to save ask conversation:', saveErr.message);
2857
4156
  }
2858
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
+ }
2859
4209
  if (askResult.fallback && onError) {
2860
4210
  const warning = new Error(`AI ask-coach used fallback model ${askResult.model}`);
2861
4211
  warning.level = 'warning';
@@ -2866,7 +4216,12 @@ export function createSyncServiceRequestHandler({
2866
4216
  fallbackModel: askResult.model
2867
4217
  });
2868
4218
  }
2869
- json(response, 200, { answer: askResult.text, model: askResult.model });
4219
+ json(response, 200, {
4220
+ answer: assistantAnswer,
4221
+ model: askResult.model,
4222
+ metadata,
4223
+ programDraft: parsedAsk.programDraft
4224
+ });
2870
4225
  } catch (err) {
2871
4226
  console.error('AI ask error:', err.message);
2872
4227
  onError?.(err, {
@@ -2945,7 +4300,10 @@ export function createSyncServiceRequestHandler({
2945
4300
  id: c.id,
2946
4301
  preview: (firstUserMsg?.content ?? '').slice(0, 120),
2947
4302
  messageCount: c.messages?.length ?? 0,
2948
- createdAt: c.createdAt
4303
+ createdAt: c.createdAt,
4304
+ model: c.model ?? null,
4305
+ kind: c.kind ?? 'ask',
4306
+ metadata: c.metadata ?? null
2949
4307
  };
2950
4308
  });
2951
4309
  json(response, 200, { conversations: summaries });
@@ -2976,7 +4334,7 @@ export function createSyncServiceRequestHandler({
2976
4334
  const conversations = await listAskConversationsForAccount(account);
2977
4335
  conversation = conversations.find((c) => c.id === route.options.id);
2978
4336
  }
2979
- if (!conversation) {
4337
+ if (!conversation || (conversation.kind ?? 'ask') !== 'ask') {
2980
4338
  notFound(response, `Conversation not found: ${route.options.id}`);
2981
4339
  return;
2982
4340
  }
@@ -3142,6 +4500,47 @@ export function createSyncServiceRequestHandler({
3142
4500
  return;
3143
4501
  }
3144
4502
 
4503
+ if (social && route.command === 'social-user-activities') {
4504
+ if (request.method !== 'GET') {
4505
+ methodNotAllowed(response, 'Use GET for /cli/social/users/:accountId/activities.');
4506
+ return;
4507
+ }
4508
+ const parsedLimit = route.options.limit ? parseInt(route.options.limit, 10) : 20;
4509
+ const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 100) : 20;
4510
+ const before = route.options.before ?? null;
4511
+ const result = await social.getUserActivities(account.id, route.options.accountId, { limit, before });
4512
+ if (result.error === 'forbidden') {
4513
+ json(response, 403, { ok: false, error: 'Access denied' });
4514
+ return;
4515
+ }
4516
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
4517
+ cmd: route.command,
4518
+ count: result.items?.length ?? 0,
4519
+ limit,
4520
+ hasBefore: Boolean(before)
4521
+ }));
4522
+ json(response, 200, result);
4523
+ return;
4524
+ }
4525
+
4526
+ if (social && route.command === 'social-user-best-efforts') {
4527
+ if (request.method !== 'GET') {
4528
+ methodNotAllowed(response, 'Use GET for /cli/social/users/:accountId/best-efforts.');
4529
+ return;
4530
+ }
4531
+ const result = await social.getUserBestEfforts(account.id, route.options.accountId);
4532
+ if (result.error === 'forbidden') {
4533
+ json(response, 403, { ok: false, error: 'Access denied' });
4534
+ return;
4535
+ }
4536
+ logRequest(request, 200, socialLogSuffix(request, account.id, {
4537
+ cmd: route.command,
4538
+ count: result.efforts?.length ?? 0
4539
+ }));
4540
+ json(response, 200, result);
4541
+ return;
4542
+ }
4543
+
3145
4544
  if (social && route.command === 'social-invite') {
3146
4545
  if (request.method !== 'POST') {
3147
4546
  methodNotAllowed(response, 'Use POST for /cli/social/invite.');