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