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.
- package/README.md +9 -2
- package/package.json +25 -4
- package/src/anonymize.js +12 -0
- package/src/coach-bakeoff.js +300 -0
- package/src/coach-facts.js +100 -0
- package/src/coach-prompt-variants.js +106 -0
- package/src/contract.js +56 -1
- package/src/exercise-aliases.js +163 -0
- package/src/format.js +64 -1
- package/src/increment-score-replay-data.js +486 -0
- package/src/increment-score-replay.js +822 -0
- package/src/lib.js +14 -2
- package/src/local.js +3 -3
- package/src/openrouter.js +1033 -179
- package/src/program-phase-resolver.js +206 -0
- package/src/prompt-security.js +13 -0
- package/src/promptfoo-domain-assert.cjs +4 -0
- package/src/promptfoo-evals.js +166 -0
- package/src/promptfoo-langfuse-scores.js +354 -0
- package/src/promptfoo-provider.cjs +14 -0
- package/src/promptfoo-tests.cjs +4 -0
- package/src/queries.js +2307 -164
- package/src/remote.js +144 -1
- package/src/state.js +9 -2
- package/src/stored-summary-eval-report.js +171 -0
- package/src/summary-evals.js +1445 -0
- package/src/sync-service.js +1557 -158
- package/src/workout-prompt-variants.js +52 -0
package/src/sync-service.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
1859
|
-
appleStartPath
|
|
1860
|
-
|
|
1861
|
-
|
|
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
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
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
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
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
|
-
|
|
1907
|
-
|
|
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 === '
|
|
2728
|
+
if (route.command === 'anonymous-start') {
|
|
2009
2729
|
if (request.method !== 'POST') {
|
|
2010
|
-
methodNotAllowed(response, 'Use POST for /auth/
|
|
2730
|
+
methodNotAllowed(response, 'Use POST for /auth/anonymous/start.');
|
|
2011
2731
|
return;
|
|
2012
2732
|
}
|
|
2013
2733
|
|
|
2014
|
-
if (!
|
|
2015
|
-
|
|
2734
|
+
if (!issueAnonymousWriteAccess) {
|
|
2735
|
+
methodNotAllowed(response, 'Anonymous hosted persistence is not enabled for this service mode.');
|
|
2016
2736
|
return;
|
|
2017
2737
|
}
|
|
2018
2738
|
|
|
2019
|
-
|
|
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 =
|
|
2082
|
-
? await
|
|
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 =
|
|
2150
|
-
? await
|
|
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 =
|
|
2285
|
-
? await
|
|
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 =
|
|
2363
|
-
? await
|
|
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 =
|
|
2424
|
-
? await
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
|
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
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
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, {
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
|
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, {
|
|
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, {
|
|
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
|
|
2811
|
-
|
|
2812
|
-
// Inject coach memory into ask context
|
|
2813
|
-
if (readCoachMemoryForAccount) {
|
|
4057
|
+
let coachFacts = [];
|
|
4058
|
+
if (listCoachFactsForAccount) {
|
|
2814
4059
|
try {
|
|
2815
|
-
const
|
|
2816
|
-
|
|
2817
|
-
|
|
2818
|
-
}
|
|
2819
|
-
} catch (
|
|
2820
|
-
console.error('Coach
|
|
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
|
|
2830
|
-
|
|
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
|
-
|
|
2834
|
-
|
|
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(
|
|
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:
|
|
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, {
|
|
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.');
|