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