incremnt 0.7.1 → 0.8.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 +57 -1
- package/package.json +2 -1
- package/src/ask-answer-verifier.js +857 -0
- package/src/ask-coach.js +2634 -0
- package/src/ask-replay.js +358 -0
- package/src/auth.js +169 -15
- package/src/coach-facts.js +14 -1
- package/src/contract.js +160 -3
- package/src/format.js +68 -2
- package/src/lib.js +205 -17
- package/src/mcp.js +88 -24
- package/src/openrouter.js +261 -33
- package/src/plan-changeset.js +132 -0
- package/src/plan-comparison.js +245 -0
- package/src/program-draft.js +230 -0
- package/src/prompt-changelog.js +184 -0
- package/src/promptfoo-evals.js +10 -4
- package/src/promptfoo-langfuse-scores.js +55 -0
- package/src/queries.js +1442 -786
- package/src/remote.js +465 -12
- package/src/score-context.js +14 -7
- package/src/score-prelude.js +113 -0
- package/src/service-url.js +9 -0
- package/src/summary-evals.js +1192 -44
- package/src/sync-service.js +1383 -367
- package/src/transport.js +119 -3
package/src/sync-service.js
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { createHash, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
2
2
|
import { anonymizeAccountId } from './anonymize.js';
|
|
3
|
+
import { formatIncrementScorePrelude } from './score-prelude.js';
|
|
4
|
+
import {
|
|
5
|
+
askVerificationMetadata,
|
|
6
|
+
buildAskAnswerRepairContext,
|
|
7
|
+
safeAskVerificationFallback,
|
|
8
|
+
shouldRepairAskAnswer,
|
|
9
|
+
verifyAskAnswer
|
|
10
|
+
} from './ask-answer-verifier.js';
|
|
11
|
+
import {
|
|
12
|
+
askMissingObservationFollowUpContext,
|
|
13
|
+
askObservationFollowUpContext,
|
|
14
|
+
askRoutedContext,
|
|
15
|
+
buildAskStructuredResponse,
|
|
16
|
+
coachFactKindsForAskQuestion,
|
|
17
|
+
normalizeObservationFollowUpIntent,
|
|
18
|
+
planAskEvidence,
|
|
19
|
+
sanitizeAskAnswerVerificationReceipt
|
|
20
|
+
} from './ask-coach.js';
|
|
3
21
|
import { capabilities as cliCapabilities, contractVersion, officialCommands } from './contract.js';
|
|
4
|
-
import { executeReadCommand } from './queries.js';
|
|
22
|
+
import { canonicalExerciseName, executeReadCommand } from './queries.js';
|
|
5
23
|
import { sanitizeHistory, detectSystemPromptLeak, stripXMLTagBlocks } from './prompt-security.js';
|
|
6
24
|
import { enrichScoreSnapshots } from './score-context.js';
|
|
25
|
+
import { extractAskProgramDraft } from './program-draft.js';
|
|
26
|
+
import { extractPlanChangeset } from './plan-changeset.js';
|
|
7
27
|
|
|
8
28
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
9
29
|
const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
@@ -15,6 +35,7 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
15
35
|
'vitals-summary-ai': 3,
|
|
16
36
|
'checkpoint-summary-ai': 3,
|
|
17
37
|
'ask-ai': 5,
|
|
38
|
+
'ask-plan': 30,
|
|
18
39
|
'ai-feedback': 60,
|
|
19
40
|
'coach-memory': 30,
|
|
20
41
|
'weekly-checkin-enroll': 10,
|
|
@@ -25,6 +46,8 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
25
46
|
'coach-observations-current': 30,
|
|
26
47
|
'coach-observations-seen': 30,
|
|
27
48
|
'coach-observations-dismiss': 30,
|
|
49
|
+
'coach-observations-outcome': 30,
|
|
50
|
+
'coach-observations-feedback': 30,
|
|
28
51
|
'dev-login': 10,
|
|
29
52
|
'device-start': 20,
|
|
30
53
|
'device-poll': 300,
|
|
@@ -102,6 +125,173 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
102
125
|
'sync-push-device-register': 30,
|
|
103
126
|
'sync-push-device-revoke': 30
|
|
104
127
|
};
|
|
128
|
+
const ASK_HISTORY_MAX_MESSAGES = 20;
|
|
129
|
+
const ASK_HISTORY_MAX_MESSAGE_LENGTH = 2000;
|
|
130
|
+
const ASK_STRUCTURED_MAX_ITEMS = 10;
|
|
131
|
+
const ASK_STRUCTURED_MAX_STRING_LENGTH = 1000;
|
|
132
|
+
const ASK_STRUCTURED_MAX_JSON_LENGTH = 30000;
|
|
133
|
+
const COACH_OBSERVATION_OUTCOME_STATUSES = new Set(['improved', 'unchanged', 'regressed', 'inconclusive']);
|
|
134
|
+
const COACH_OBSERVATION_FEEDBACK_STATUSES = new Set(['accepted', 'rejected']);
|
|
135
|
+
|
|
136
|
+
function askStorageString(value, { maxLength = ASK_STRUCTURED_MAX_STRING_LENGTH } = {}) {
|
|
137
|
+
if (typeof value !== 'string') return null;
|
|
138
|
+
const trimmed = value.trim();
|
|
139
|
+
if (!trimmed) return null;
|
|
140
|
+
return trimmed.length > maxLength ? trimmed.slice(0, maxLength) : trimmed;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function askStorageStringArray(value, { maxItems = ASK_STRUCTURED_MAX_ITEMS, maxLength = 240 } = {}) {
|
|
144
|
+
if (!Array.isArray(value)) return [];
|
|
145
|
+
return value
|
|
146
|
+
.map((item) => askStorageString(item, { maxLength }))
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.slice(0, maxItems);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function evidenceLabel(section, toolName) {
|
|
152
|
+
const cleaned = String(section || toolName || 'evidence')
|
|
153
|
+
.replace(/^observation_/, '')
|
|
154
|
+
.replace(/_/g, ' ')
|
|
155
|
+
.trim();
|
|
156
|
+
return cleaned ? cleaned.replace(/\b\w/g, (char) => char.toUpperCase()) : 'Evidence';
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Fold tools the agentic loop fetched at generation time into the routing
|
|
160
|
+
// metadata so toolsUsed, provenance, and the structured evidence list reflect
|
|
161
|
+
// what was actually read (keeps ask_tool_provenance honest). Mutates in place.
|
|
162
|
+
function mergeAgenticToolProvenance(routingMetadata, toolInvocations = []) {
|
|
163
|
+
if (!routingMetadata || !Array.isArray(toolInvocations) || toolInvocations.length === 0) return;
|
|
164
|
+
const names = toolInvocations.map((invocation) => invocation.name).filter(Boolean);
|
|
165
|
+
routingMetadata.toolsUsed = [...new Set([...(routingMetadata.toolsUsed ?? []), ...names])];
|
|
166
|
+
routingMetadata.toolParams = {
|
|
167
|
+
...(routingMetadata.toolParams ?? {}),
|
|
168
|
+
...Object.fromEntries(toolInvocations
|
|
169
|
+
.filter((invocation) => invocation?.name)
|
|
170
|
+
.map((invocation) => [invocation.name, invocation.params ?? {}]))
|
|
171
|
+
};
|
|
172
|
+
if (routingMetadata.evidencePlan && typeof routingMetadata.evidencePlan === 'object') {
|
|
173
|
+
routingMetadata.evidencePlan = {
|
|
174
|
+
...routingMetadata.evidencePlan,
|
|
175
|
+
executedTools: [...new Set([...(routingMetadata.evidencePlan.executedTools ?? []), ...names])]
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
routingMetadata.agenticToolInvocations = toolInvocations.map((invocation) => ({
|
|
179
|
+
name: invocation.name,
|
|
180
|
+
params: invocation.params ?? {},
|
|
181
|
+
sourceIds: invocation.sourceIds ?? []
|
|
182
|
+
}));
|
|
183
|
+
|
|
184
|
+
const provenanceEntries = toolInvocations.map((invocation) => ({
|
|
185
|
+
section: invocation.name,
|
|
186
|
+
toolName: invocation.name,
|
|
187
|
+
sourceIds: invocation.sourceIds ?? []
|
|
188
|
+
}));
|
|
189
|
+
routingMetadata.provenance = [...(routingMetadata.provenance ?? []), ...provenanceEntries];
|
|
190
|
+
|
|
191
|
+
const contextBundle = routingMetadata.contextBundle;
|
|
192
|
+
if (contextBundle && typeof contextBundle === 'object') {
|
|
193
|
+
const evidenceEntries = provenanceEntries.map((entry) => ({
|
|
194
|
+
label: evidenceLabel(entry.section, entry.toolName),
|
|
195
|
+
section: entry.section,
|
|
196
|
+
toolName: entry.toolName,
|
|
197
|
+
sourceTimestamp: null,
|
|
198
|
+
sourceIds: entry.sourceIds,
|
|
199
|
+
noteSourceIds: [],
|
|
200
|
+
missingDataFlags: []
|
|
201
|
+
}));
|
|
202
|
+
routingMetadata.contextBundle = {
|
|
203
|
+
...contextBundle,
|
|
204
|
+
executedTools: [...new Set([...(contextBundle.executedTools ?? []), ...names])],
|
|
205
|
+
evidenceUsed: [...(contextBundle.evidenceUsed ?? []), ...evidenceEntries]
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function sanitizeAskEvidenceForStorage(item) {
|
|
211
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
212
|
+
const sanitized = {};
|
|
213
|
+
for (const key of ['label', 'section', 'toolName', 'sourceTimestamp']) {
|
|
214
|
+
const value = askStorageString(item[key], { maxLength: 240 });
|
|
215
|
+
if (value) sanitized[key] = value;
|
|
216
|
+
}
|
|
217
|
+
for (const key of ['sourceIds', 'noteSourceIds', 'missingDataFlags']) {
|
|
218
|
+
const values = askStorageStringArray(item[key], { maxItems: ASK_STRUCTURED_MAX_ITEMS, maxLength: 160 });
|
|
219
|
+
if (values.length > 0) sanitized[key] = values;
|
|
220
|
+
}
|
|
221
|
+
return Object.keys(sanitized).length > 0 ? sanitized : null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function sanitizeAskActionForStorage(item) {
|
|
225
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
|
|
226
|
+
const id = askStorageString(item.id, { maxLength: 160 });
|
|
227
|
+
const label = askStorageString(item.label, { maxLength: 240 });
|
|
228
|
+
const kind = askStorageString(item.kind, { maxLength: 120 });
|
|
229
|
+
if (!id && !label) return null;
|
|
230
|
+
return {
|
|
231
|
+
...(id ? { id } : {}),
|
|
232
|
+
label: label ?? id,
|
|
233
|
+
...(kind ? { kind } : {})
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function sanitizeAskProgramDraftForStorage(value) {
|
|
238
|
+
if (value == null) return null;
|
|
239
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
|
|
240
|
+
const serialized = JSON.stringify(value);
|
|
241
|
+
if (serialized.length > ASK_STRUCTURED_MAX_JSON_LENGTH) return null;
|
|
242
|
+
return JSON.parse(serialized);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function sanitizeAskStructuredResponseForStorage(structured) {
|
|
246
|
+
if (!structured || typeof structured !== 'object' || Array.isArray(structured)) return null;
|
|
247
|
+
const confidence = askStorageString(structured.confidence, { maxLength: 40 });
|
|
248
|
+
const answer = askStorageString(structured.answer);
|
|
249
|
+
const evidenceUsed = Array.isArray(structured.evidenceUsed)
|
|
250
|
+
? structured.evidenceUsed.map(sanitizeAskEvidenceForStorage).filter(Boolean).slice(0, ASK_STRUCTURED_MAX_ITEMS)
|
|
251
|
+
: [];
|
|
252
|
+
const recommendedActions = Array.isArray(structured.recommendedActions)
|
|
253
|
+
? structured.recommendedActions.map(sanitizeAskActionForStorage).filter(Boolean).slice(0, ASK_STRUCTURED_MAX_ITEMS)
|
|
254
|
+
: [];
|
|
255
|
+
const followUpSuggestions = askStorageStringArray(structured.followUpSuggestions, { maxItems: 5, maxLength: 240 });
|
|
256
|
+
const limitations = askStorageStringArray(structured.limitations, { maxItems: ASK_STRUCTURED_MAX_ITEMS, maxLength: 240 });
|
|
257
|
+
const answerVerification = sanitizeAskAnswerVerificationReceipt(structured.answerVerification);
|
|
258
|
+
const programDraft = sanitizeAskProgramDraftForStorage(structured.programDraft);
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
...(answer ? { answer } : {}),
|
|
262
|
+
...(confidence ? { confidence } : {}),
|
|
263
|
+
evidenceUsed,
|
|
264
|
+
recommendedActions,
|
|
265
|
+
followUpSuggestions,
|
|
266
|
+
limitations,
|
|
267
|
+
...(answerVerification ? { answerVerification } : {}),
|
|
268
|
+
programDraft
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function sanitizeAskMessagesForStorage(messages, { allowStructured = false } = {}) {
|
|
273
|
+
if (!Array.isArray(messages)) return [];
|
|
274
|
+
|
|
275
|
+
const cleaned = messages
|
|
276
|
+
.filter((message) => message && ['user', 'assistant'].includes(message.role) && typeof message.content === 'string')
|
|
277
|
+
.map((message) => {
|
|
278
|
+
const item = {
|
|
279
|
+
role: message.role,
|
|
280
|
+
content: message.content.length > ASK_HISTORY_MAX_MESSAGE_LENGTH
|
|
281
|
+
? message.content.slice(0, ASK_HISTORY_MAX_MESSAGE_LENGTH)
|
|
282
|
+
: message.content
|
|
283
|
+
};
|
|
284
|
+
if (allowStructured && message.role === 'assistant') {
|
|
285
|
+
const structured = sanitizeAskStructuredResponseForStorage(message.structured);
|
|
286
|
+
if (structured) item.structured = structured;
|
|
287
|
+
}
|
|
288
|
+
return item;
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return cleaned.length > ASK_HISTORY_MAX_MESSAGES
|
|
292
|
+
? cleaned.slice(cleaned.length - ASK_HISTORY_MAX_MESSAGES)
|
|
293
|
+
: cleaned;
|
|
294
|
+
}
|
|
105
295
|
|
|
106
296
|
export function isNoInsightResponse(text) {
|
|
107
297
|
const normalized = String(text ?? '')
|
|
@@ -178,6 +368,42 @@ function isoDateFromParts({ year, month, day }) {
|
|
|
178
368
|
].join('-');
|
|
179
369
|
}
|
|
180
370
|
|
|
371
|
+
function isoDateParts(isoDate) {
|
|
372
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(isoDate ?? ''));
|
|
373
|
+
if (!match) return null;
|
|
374
|
+
return {
|
|
375
|
+
year: Number(match[1]),
|
|
376
|
+
month: Number(match[2]),
|
|
377
|
+
day: Number(match[3])
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function weeklyCheckinContextOptions(row) {
|
|
382
|
+
const parts = isoDateParts(row?.weekStartDate);
|
|
383
|
+
if (!parts) return { now: new Date(`${row?.weekStartDate ?? ''}T23:59:59.999Z`) };
|
|
384
|
+
const timeZoneId = isValidTimeZone(row?.timezoneId) ? row.timezoneId : 'UTC';
|
|
385
|
+
const weekStartParts = addCalendarDays(parts, -7);
|
|
386
|
+
const nextDay = addCalendarDays(parts, 1);
|
|
387
|
+
const now = new Date(zonedDateTimeToUtc(timeZoneId, {
|
|
388
|
+
...nextDay,
|
|
389
|
+
hour: 0,
|
|
390
|
+
minute: 0,
|
|
391
|
+
second: 0
|
|
392
|
+
}).getTime() - 1);
|
|
393
|
+
const cutoff = zonedDateTimeToUtc(timeZoneId, {
|
|
394
|
+
...weekStartParts,
|
|
395
|
+
hour: 0,
|
|
396
|
+
minute: 0,
|
|
397
|
+
second: 0
|
|
398
|
+
});
|
|
399
|
+
return {
|
|
400
|
+
now,
|
|
401
|
+
todayIso: isoDateFromParts(parts),
|
|
402
|
+
weekStartIso: isoDateFromParts(weekStartParts),
|
|
403
|
+
cutoff
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
181
407
|
export function nextWeeklyCheckinSchedule(timeZoneId, now = new Date()) {
|
|
182
408
|
if (!isValidTimeZone(timeZoneId)) {
|
|
183
409
|
const err = new Error('Invalid timezoneId');
|
|
@@ -208,196 +434,6 @@ export function nextWeeklyCheckinSchedule(timeZoneId, now = new Date()) {
|
|
|
208
434
|
};
|
|
209
435
|
}
|
|
210
436
|
|
|
211
|
-
const PROGRAM_DRAFT_VERSION = 1;
|
|
212
|
-
const VALID_PROGRAM_DRAFT_EQUIPMENT_TIERS = new Set(['fullGym', 'benchDumbbells', 'dumbbellsOnly', 'bodyweightOnly']);
|
|
213
|
-
const VALID_PROGRAM_DRAFT_VOLUME_LEVELS = new Set(['minimum', 'moderate', 'high']);
|
|
214
|
-
|
|
215
|
-
const PROGRAM_DRAFT_LIMITS = {
|
|
216
|
-
nameMaxLen: 120,
|
|
217
|
-
muscleGroupMaxLen: 60,
|
|
218
|
-
dayLabelMaxLen: 60,
|
|
219
|
-
dayTitleMaxLen: 120,
|
|
220
|
-
daySubtitleMaxLen: 120,
|
|
221
|
-
noteMaxLen: 1000,
|
|
222
|
-
minWeight: 0,
|
|
223
|
-
maxWeight: 600,
|
|
224
|
-
minReps: 1,
|
|
225
|
-
maxReps: 30,
|
|
226
|
-
minRir: 0,
|
|
227
|
-
maxRir: 5,
|
|
228
|
-
minSetsPerExercise: 1,
|
|
229
|
-
maxSetsPerExercise: 12,
|
|
230
|
-
minExercisesPerDay: 1,
|
|
231
|
-
maxExercisesPerDay: 24,
|
|
232
|
-
minDaysPerWeek: 1,
|
|
233
|
-
maxDaysPerWeek: 7,
|
|
234
|
-
minDays: 1,
|
|
235
|
-
maxDays: 14
|
|
236
|
-
};
|
|
237
|
-
|
|
238
|
-
function collapseBlankLines(text) {
|
|
239
|
-
return String(text ?? '')
|
|
240
|
-
.replace(/\n{3,}/g, '\n\n')
|
|
241
|
-
.trim();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
function titleCaseExerciseName(name) {
|
|
245
|
-
return String(name ?? '')
|
|
246
|
-
.split(' ')
|
|
247
|
-
.filter(Boolean)
|
|
248
|
-
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
249
|
-
.join(' ');
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
function normalizedExerciseDisplayName(name, canonicalizeExerciseName) {
|
|
253
|
-
const trimmed = String(name ?? '').trim();
|
|
254
|
-
if (!trimmed) return '';
|
|
255
|
-
const canonical = canonicalizeExerciseName ? canonicalizeExerciseName(trimmed) : trimmed.toLowerCase();
|
|
256
|
-
return titleCaseExerciseName(canonical);
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
function normalizeProgramDraftSet(set) {
|
|
260
|
-
const weight = Number(set?.weight);
|
|
261
|
-
const reps = Number(set?.reps);
|
|
262
|
-
if (!Number.isFinite(weight) || !Number.isInteger(reps)) return null;
|
|
263
|
-
if (
|
|
264
|
-
weight < PROGRAM_DRAFT_LIMITS.minWeight ||
|
|
265
|
-
weight > PROGRAM_DRAFT_LIMITS.maxWeight ||
|
|
266
|
-
reps < PROGRAM_DRAFT_LIMITS.minReps ||
|
|
267
|
-
reps > PROGRAM_DRAFT_LIMITS.maxReps
|
|
268
|
-
) return null;
|
|
269
|
-
return {
|
|
270
|
-
weight,
|
|
271
|
-
reps,
|
|
272
|
-
isComplete: false,
|
|
273
|
-
isWarmup: set?.isWarmup === true
|
|
274
|
-
};
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
function normalizeProgramDraftExercise(exercise, canonicalizeExerciseName) {
|
|
278
|
-
const name = normalizedExerciseDisplayName(exercise?.name, canonicalizeExerciseName);
|
|
279
|
-
const muscleGroup = String(exercise?.muscleGroup ?? '').trim();
|
|
280
|
-
const sets = Array.isArray(exercise?.sets)
|
|
281
|
-
? exercise.sets.map(normalizeProgramDraftSet).filter(Boolean)
|
|
282
|
-
: [];
|
|
283
|
-
|
|
284
|
-
if (!name || name.length > PROGRAM_DRAFT_LIMITS.nameMaxLen) return null;
|
|
285
|
-
if (!muscleGroup || muscleGroup.length > PROGRAM_DRAFT_LIMITS.muscleGroupMaxLen) return null;
|
|
286
|
-
if (
|
|
287
|
-
sets.length < PROGRAM_DRAFT_LIMITS.minSetsPerExercise ||
|
|
288
|
-
sets.length > PROGRAM_DRAFT_LIMITS.maxSetsPerExercise
|
|
289
|
-
) return null;
|
|
290
|
-
|
|
291
|
-
const rir = exercise?.rir == null ? null : Number(exercise.rir);
|
|
292
|
-
if (rir != null && (
|
|
293
|
-
!Number.isInteger(rir) ||
|
|
294
|
-
rir < PROGRAM_DRAFT_LIMITS.minRir ||
|
|
295
|
-
rir > PROGRAM_DRAFT_LIMITS.maxRir
|
|
296
|
-
)) return null;
|
|
297
|
-
|
|
298
|
-
const note = exercise?.note == null ? null : String(exercise.note);
|
|
299
|
-
if (note && note.length > PROGRAM_DRAFT_LIMITS.noteMaxLen) return null;
|
|
300
|
-
|
|
301
|
-
return {
|
|
302
|
-
name,
|
|
303
|
-
muscleGroup,
|
|
304
|
-
lastSuggestion: '',
|
|
305
|
-
nextSuggestion: '',
|
|
306
|
-
sets,
|
|
307
|
-
...(note ? { note } : {}),
|
|
308
|
-
...(rir != null ? { rir } : {})
|
|
309
|
-
};
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function normalizeProgramDraftDay(day, canonicalizeExerciseName) {
|
|
313
|
-
const dayLabel = String(day?.dayLabel ?? '').trim();
|
|
314
|
-
const title = String(day?.title ?? '').trim();
|
|
315
|
-
const subtitle = String(day?.subtitle ?? '').trim();
|
|
316
|
-
const exercises = Array.isArray(day?.exercises)
|
|
317
|
-
? day.exercises.map((exercise) => normalizeProgramDraftExercise(exercise, canonicalizeExerciseName)).filter(Boolean)
|
|
318
|
-
: [];
|
|
319
|
-
|
|
320
|
-
if (!dayLabel || dayLabel.length > PROGRAM_DRAFT_LIMITS.dayLabelMaxLen) return null;
|
|
321
|
-
if (!title || title.length > PROGRAM_DRAFT_LIMITS.dayTitleMaxLen) return null;
|
|
322
|
-
if (subtitle.length > PROGRAM_DRAFT_LIMITS.daySubtitleMaxLen) return null;
|
|
323
|
-
if (
|
|
324
|
-
exercises.length < PROGRAM_DRAFT_LIMITS.minExercisesPerDay ||
|
|
325
|
-
exercises.length > PROGRAM_DRAFT_LIMITS.maxExercisesPerDay
|
|
326
|
-
) return null;
|
|
327
|
-
|
|
328
|
-
return { dayLabel, title, subtitle, exercises };
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
function normalizeProgramDraft(rawProgram, { canonicalizeExerciseName } = {}) {
|
|
332
|
-
if (!rawProgram || typeof rawProgram !== 'object' || Array.isArray(rawProgram)) return null;
|
|
333
|
-
|
|
334
|
-
const name = String(rawProgram.name ?? '').trim();
|
|
335
|
-
const days = Array.isArray(rawProgram.days)
|
|
336
|
-
? rawProgram.days.map((day) => normalizeProgramDraftDay(day, canonicalizeExerciseName)).filter(Boolean)
|
|
337
|
-
: [];
|
|
338
|
-
const daysPerWeek = Number(rawProgram.daysPerWeek);
|
|
339
|
-
const currentDayIndex = rawProgram.currentDayIndex == null ? 0 : Number(rawProgram.currentDayIndex);
|
|
340
|
-
const equipmentTier = String(rawProgram.equipmentTier ?? 'fullGym').trim();
|
|
341
|
-
const volumeLevel = String(rawProgram.volumeLevel ?? 'moderate').trim();
|
|
342
|
-
|
|
343
|
-
if (!name || name.length > PROGRAM_DRAFT_LIMITS.nameMaxLen) return null;
|
|
344
|
-
if (days.length < PROGRAM_DRAFT_LIMITS.minDays || days.length > PROGRAM_DRAFT_LIMITS.maxDays) return null;
|
|
345
|
-
if (
|
|
346
|
-
!Number.isInteger(daysPerWeek) ||
|
|
347
|
-
daysPerWeek < PROGRAM_DRAFT_LIMITS.minDaysPerWeek ||
|
|
348
|
-
daysPerWeek > PROGRAM_DRAFT_LIMITS.maxDaysPerWeek
|
|
349
|
-
) return null;
|
|
350
|
-
if (!Number.isInteger(currentDayIndex) || currentDayIndex < 0 || currentDayIndex >= days.length) return null;
|
|
351
|
-
if (!VALID_PROGRAM_DRAFT_EQUIPMENT_TIERS.has(equipmentTier) || !VALID_PROGRAM_DRAFT_VOLUME_LEVELS.has(volumeLevel)) return null;
|
|
352
|
-
|
|
353
|
-
return {
|
|
354
|
-
name,
|
|
355
|
-
daysPerWeek,
|
|
356
|
-
equipmentTier,
|
|
357
|
-
volumeLevel,
|
|
358
|
-
source: 'guided',
|
|
359
|
-
days,
|
|
360
|
-
currentDayIndex
|
|
361
|
-
};
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
function extractAskProgramDraft(rawText, { canonicalizeExerciseName } = {}) {
|
|
365
|
-
const text = String(rawText ?? '');
|
|
366
|
-
const match = text.match(/<program_draft>\s*([\s\S]*?)\s*<\/program_draft>/i);
|
|
367
|
-
if (!match) {
|
|
368
|
-
return { answerText: text.trim(), programDraft: null };
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const answerText = collapseBlankLines(text.replace(match[0], ''));
|
|
372
|
-
let parsed;
|
|
373
|
-
try {
|
|
374
|
-
parsed = JSON.parse(match[1]);
|
|
375
|
-
} catch (err) {
|
|
376
|
-
console.warn('askCoach: <program_draft> JSON parse failed — dropping draft:', err.message);
|
|
377
|
-
return { answerText, programDraft: null };
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
const program = normalizeProgramDraft(parsed, { canonicalizeExerciseName });
|
|
381
|
-
if (!program) {
|
|
382
|
-
console.warn('askCoach: <program_draft> payload failed validation — dropping draft');
|
|
383
|
-
return { answerText, programDraft: null };
|
|
384
|
-
}
|
|
385
|
-
|
|
386
|
-
return {
|
|
387
|
-
answerText,
|
|
388
|
-
programDraft: {
|
|
389
|
-
program,
|
|
390
|
-
provenance: {
|
|
391
|
-
source: 'ai-coach',
|
|
392
|
-
type: 'program',
|
|
393
|
-
version: PROGRAM_DRAFT_VERSION,
|
|
394
|
-
createdAt: new Date().toISOString(),
|
|
395
|
-
tokenHint: null
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
};
|
|
399
|
-
}
|
|
400
|
-
|
|
401
437
|
function json(response, statusCode, payload) {
|
|
402
438
|
response.writeHead(statusCode, { 'content-type': 'application/json' });
|
|
403
439
|
response.end(JSON.stringify(payload));
|
|
@@ -514,7 +550,132 @@ function currentAIGitSha() {
|
|
|
514
550
|
?? null;
|
|
515
551
|
}
|
|
516
552
|
|
|
553
|
+
function compactLogObject(obj) {
|
|
554
|
+
return Object.fromEntries(
|
|
555
|
+
Object.entries(obj).filter(([, value]) => {
|
|
556
|
+
if (value === undefined || value === null) return false;
|
|
557
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
558
|
+
return true;
|
|
559
|
+
})
|
|
560
|
+
);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function logStringArray(values, { max = 12 } = {}) {
|
|
564
|
+
const unique = [];
|
|
565
|
+
const seen = new Set();
|
|
566
|
+
for (const value of Array.isArray(values) ? values : []) {
|
|
567
|
+
const text = typeof value === 'string' ? value.trim() : '';
|
|
568
|
+
if (!text || seen.has(text)) continue;
|
|
569
|
+
seen.add(text);
|
|
570
|
+
unique.push(text);
|
|
571
|
+
if (unique.length >= max) break;
|
|
572
|
+
}
|
|
573
|
+
return unique;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
export function buildAskInteractionLogPayload({
|
|
577
|
+
accountId = null,
|
|
578
|
+
status = 'ok',
|
|
579
|
+
promptSurface = 'ask',
|
|
580
|
+
routingMetadata = {},
|
|
581
|
+
askResult = {},
|
|
582
|
+
structured = {}
|
|
583
|
+
} = {}) {
|
|
584
|
+
const evidencePlan = routingMetadata?.evidencePlan ?? {};
|
|
585
|
+
const contextBundle = routingMetadata?.contextBundle ?? {};
|
|
586
|
+
const missingDataFlags = logStringArray([
|
|
587
|
+
...(routingMetadata?.missingDataFlags ?? []),
|
|
588
|
+
...(contextBundle?.missingDataFlags ?? []),
|
|
589
|
+
...(evidencePlan?.evidenceGaps ?? [])
|
|
590
|
+
]);
|
|
591
|
+
const evidenceUsed = Array.isArray(structured?.evidenceUsed) ? structured.evidenceUsed : [];
|
|
592
|
+
const recommendedActions = Array.isArray(structured?.recommendedActions) ? structured.recommendedActions : [];
|
|
593
|
+
const answerVerification = routingMetadata?.answerVerification ?? {};
|
|
594
|
+
|
|
595
|
+
return compactLogObject({
|
|
596
|
+
event: 'ask_coach_interaction',
|
|
597
|
+
surface: promptSurface,
|
|
598
|
+
status,
|
|
599
|
+
aid: accountId ? anonymizeAccountId(accountId) : undefined,
|
|
600
|
+
model: askResult?.model ?? undefined,
|
|
601
|
+
durationMs: typeof askResult?.durationMs === 'number' ? askResult.durationMs : undefined,
|
|
602
|
+
fallback: askResult?.fallback === true ? true : undefined,
|
|
603
|
+
route: routingMetadata?.route ?? evidencePlan?.route,
|
|
604
|
+
effectiveRoute: routingMetadata?.effectiveRoute ?? evidencePlan?.effectiveRoute,
|
|
605
|
+
requestedAction: routingMetadata?.intent?.requestedAction,
|
|
606
|
+
intentConfidence: typeof routingMetadata?.intent?.confidence === 'number' ? routingMetadata.intent.confidence : undefined,
|
|
607
|
+
structuredConfidence: typeof structured?.confidence === 'string' ? structured.confidence : undefined,
|
|
608
|
+
contextCharCount: typeof routingMetadata?.contextCharCount === 'number' ? routingMetadata.contextCharCount : undefined,
|
|
609
|
+
historyTurnCount: typeof routingMetadata?.historyTurnCount === 'number' ? routingMetadata.historyTurnCount : undefined,
|
|
610
|
+
requiredTools: logStringArray(evidencePlan?.requiredTools),
|
|
611
|
+
executedTools: logStringArray(evidencePlan?.executedTools ?? routingMetadata?.toolsUsed ?? contextBundle?.executedTools),
|
|
612
|
+
evidenceGaps: logStringArray(evidencePlan?.evidenceGaps),
|
|
613
|
+
missingDataFlags,
|
|
614
|
+
evidenceUsedLabels: logStringArray(evidenceUsed.map((item) => item?.label)),
|
|
615
|
+
evidenceUsedTools: logStringArray(evidenceUsed.map((item) => item?.toolName)),
|
|
616
|
+
recommendedActionIds: logStringArray(recommendedActions.map((item) => item?.id)),
|
|
617
|
+
recommendedActionLabels: logStringArray(recommendedActions.map((item) => item?.label)),
|
|
618
|
+
followUpSuggestionCount: Array.isArray(structured?.followUpSuggestions) ? structured.followUpSuggestions.length : undefined,
|
|
619
|
+
limitationCount: Array.isArray(structured?.limitations) ? structured.limitations.length : undefined,
|
|
620
|
+
hasProgramDraft: structured?.programDraft != null ? true : undefined,
|
|
621
|
+
askVerificationStatus: answerVerification.status,
|
|
622
|
+
askVerificationRetryCount: typeof answerVerification.retryCount === 'number' ? answerVerification.retryCount : undefined,
|
|
623
|
+
askVerificationBlockingFailureCount: typeof answerVerification.blockingFailureCount === 'number' ? answerVerification.blockingFailureCount : undefined,
|
|
624
|
+
askVerificationAdvisoryFailureCount: typeof answerVerification.advisoryFailureCount === 'number' ? answerVerification.advisoryFailureCount : undefined,
|
|
625
|
+
askVerificationFailureKeys: logStringArray(answerVerification.failureKeys),
|
|
626
|
+
coachFactsIncluded: routingMetadata?.coachFactsIncluded === true ? true : undefined,
|
|
627
|
+
coachFactCount: Array.isArray(routingMetadata?.includedCoachFactIds)
|
|
628
|
+
? routingMetadata.includedCoachFactIds.length
|
|
629
|
+
: Array.isArray(routingMetadata?.coachFactIds)
|
|
630
|
+
? routingMetadata.coachFactIds.length
|
|
631
|
+
: undefined,
|
|
632
|
+
coachObservationCount: Array.isArray(routingMetadata?.includedCoachObservationIds)
|
|
633
|
+
? routingMetadata.includedCoachObservationIds.length
|
|
634
|
+
: Array.isArray(routingMetadata?.coachObservationIds)
|
|
635
|
+
? routingMetadata.coachObservationIds.length
|
|
636
|
+
: undefined,
|
|
637
|
+
langfuseTraceId: askResult?.langfuseTraceId,
|
|
638
|
+
langfuseObservationId: askResult?.langfuseObservationId
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function shouldRequireProgramDraftForAsk({ persistedKind, requestedCoachObservation, missingRequestedCoachObservation }) {
|
|
643
|
+
return persistedKind === 'ask' &&
|
|
644
|
+
requestedCoachObservation?.intent === 'successor_plan' &&
|
|
645
|
+
!missingRequestedCoachObservation;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function shouldRequirePlanChangesetForAsk({ persistedKind, requestedCoachObservation, missingRequestedCoachObservation }) {
|
|
649
|
+
return persistedKind === 'ask' &&
|
|
650
|
+
requestedCoachObservation?.intent === 'plan_adjustment' &&
|
|
651
|
+
!missingRequestedCoachObservation;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function buildMissingProgramDraftRepairContext(context) {
|
|
655
|
+
return `${context}
|
|
656
|
+
|
|
657
|
+
Program draft repair:
|
|
658
|
+
A program draft was required for this matched successor plan request, but the previous answer only gave prose.
|
|
659
|
+
Re-answer with 1-2 short prose sentences and exactly one trailing <program_draft>{JSON}</program_draft> block.
|
|
660
|
+
The JSON must be a complete Program object using the schema already specified above.
|
|
661
|
+
Do not ask a follow-up question unless the evidence is weak, stale, or contradicted by the tools above.`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function buildMissingPlanChangesetRepairContext(context) {
|
|
665
|
+
return `${context}
|
|
666
|
+
|
|
667
|
+
Plan changeset repair:
|
|
668
|
+
A plan changeset was required for this matched plan adjustment request, but the previous answer only gave prose.
|
|
669
|
+
Re-answer with 1-2 short prose sentences and exactly one trailing <plan_changeset>{JSON}</plan_changeset> block.
|
|
670
|
+
The JSON must use the Plan adjustment request schema already specified above.
|
|
671
|
+
Do not include concrete weights, reps, set counts, or deltas.
|
|
672
|
+
Do not ask a follow-up question unless the evidence is weak, stale, or contradicted by the tools above.`;
|
|
673
|
+
}
|
|
674
|
+
|
|
517
675
|
function buildAIGenerationMetadata(surface, model, promptVersion, generation = {}) {
|
|
676
|
+
const routing = generation.routingMetadata && typeof generation.routingMetadata === 'object'
|
|
677
|
+
? generation.routingMetadata
|
|
678
|
+
: {};
|
|
518
679
|
return {
|
|
519
680
|
surface,
|
|
520
681
|
generatedAt: new Date().toISOString(),
|
|
@@ -522,7 +683,15 @@ function buildAIGenerationMetadata(surface, model, promptVersion, generation = {
|
|
|
522
683
|
promptVersion: promptVersion ?? null,
|
|
523
684
|
gitSha: currentAIGitSha(),
|
|
524
685
|
langfuseTraceId: generation.langfuseTraceId ?? null,
|
|
525
|
-
langfuseObservationId: generation.langfuseObservationId ?? null
|
|
686
|
+
langfuseObservationId: generation.langfuseObservationId ?? null,
|
|
687
|
+
...(typeof routing.route === 'string' ? { route: routing.route } : {}),
|
|
688
|
+
...(typeof routing.effectiveRoute === 'string' ? { effectiveRoute: routing.effectiveRoute } : {}),
|
|
689
|
+
...(typeof routing.observationFollowUpIntent === 'string' ? { observationFollowUpIntent: routing.observationFollowUpIntent } : {}),
|
|
690
|
+
...(typeof routing.requestedCoachObservationIntent === 'string' ? { requestedCoachObservationIntent: routing.requestedCoachObservationIntent } : {}),
|
|
691
|
+
...(typeof routing.requestedCoachObservationId === 'string' ? { requestedCoachObservationId: routing.requestedCoachObservationId } : {}),
|
|
692
|
+
...(Array.isArray(routing.coachObservationIds) && routing.coachObservationIds.length > 0
|
|
693
|
+
? { coachObservationIds: routing.coachObservationIds }
|
|
694
|
+
: {})
|
|
526
695
|
};
|
|
527
696
|
}
|
|
528
697
|
|
|
@@ -588,6 +757,23 @@ function forbidden(response, message = 'Forbidden') {
|
|
|
588
757
|
json(response, 403, { error: message });
|
|
589
758
|
}
|
|
590
759
|
|
|
760
|
+
function insufficientScope(response, request, requiredAccess = 'write') {
|
|
761
|
+
if (request) logRequest(request, 403);
|
|
762
|
+
json(response, 403, {
|
|
763
|
+
error: `Insufficient token scope. This endpoint requires ${requiredAccess} access.`,
|
|
764
|
+
code: 'INSUFFICIENT_SCOPE',
|
|
765
|
+
requiredAccess
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
async function rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator, requiredAccess = 'write') {
|
|
770
|
+
if (!requestToken || !readAuthenticator) return false;
|
|
771
|
+
const readAccount = await readAuthenticator(requestToken);
|
|
772
|
+
if (!readAccount) return false;
|
|
773
|
+
insufficientScope(response, request, requiredAccess);
|
|
774
|
+
return true;
|
|
775
|
+
}
|
|
776
|
+
|
|
591
777
|
function methodNotAllowed(response, message = 'Method not allowed') {
|
|
592
778
|
json(response, 405, { error: message });
|
|
593
779
|
}
|
|
@@ -690,6 +876,67 @@ function parseLimit(value, { defaultValue, max }) {
|
|
|
690
876
|
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, max) : defaultValue;
|
|
691
877
|
}
|
|
692
878
|
|
|
879
|
+
function coachToolObservationOptions(toolName, body = {}) {
|
|
880
|
+
if (toolName === 'get_current_coach_observations') {
|
|
881
|
+
return {
|
|
882
|
+
limit: parseLimit(body?.limit, { defaultValue: 5, max: 20 }),
|
|
883
|
+
includeDismissed: Boolean(body?.includeDismissed),
|
|
884
|
+
includeOutcomeHistory: Boolean(body?.includeOutcomeHistory)
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
if (toolName === 'compare_session_to_observations') {
|
|
888
|
+
return {
|
|
889
|
+
limit: parseLimit(body?.observationLimit, { defaultValue: 5, max: 20 }),
|
|
890
|
+
includeDismissed: false,
|
|
891
|
+
includeOutcomeHistory: Boolean(body?.includeOutcomeHistory)
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
return null;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function coachObservationSourceTriggerForScoreSnapshots(snapshots) {
|
|
898
|
+
if (!Array.isArray(snapshots)) return undefined;
|
|
899
|
+
const reasons = snapshots.map((snapshot) => snapshot?.triggerReason);
|
|
900
|
+
if (reasons.length === 0 || reasons.some((reason) => !reason)) return undefined;
|
|
901
|
+
if (reasons.includes('sessionSave')) return 'session_completed';
|
|
902
|
+
if (reasons.every((reason) => reason === 'historicalBackfill')) {
|
|
903
|
+
return 'manual_backfill';
|
|
904
|
+
}
|
|
905
|
+
if (reasons.every((reason) => reason === 'dailyRefresh')) {
|
|
906
|
+
return 'daily_refresh';
|
|
907
|
+
}
|
|
908
|
+
return undefined;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
function coachObservationGenerationMetadata(result, sourceTrigger) {
|
|
912
|
+
return {
|
|
913
|
+
attempted: true,
|
|
914
|
+
generated: Number(result?.generated ?? 0),
|
|
915
|
+
persisted: Number(result?.persisted ?? 0),
|
|
916
|
+
skippedReason: result?.skippedReason ?? null,
|
|
917
|
+
sourceTrigger: sourceTrigger ?? null
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function normalizeAskCoachObservationFollowUp(value) {
|
|
922
|
+
if (!value || typeof value !== 'object') return null;
|
|
923
|
+
const id = String(value.id ?? '').trim();
|
|
924
|
+
if (!id) return null;
|
|
925
|
+
const intent = normalizeObservationFollowUpIntent(value.intent);
|
|
926
|
+
return {
|
|
927
|
+
id: id.slice(0, 128),
|
|
928
|
+
...(intent ? { intent } : {})
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function selectAskCoachObservationFollowUp(requested, observations) {
|
|
933
|
+
if (!requested) return null;
|
|
934
|
+
return (Array.isArray(observations) ? observations : [])
|
|
935
|
+
.find((observation) => String(observation?.id ?? '') === requested.id);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
const ASK_REQUESTED_COACH_OBSERVATION_LIMIT = 20;
|
|
939
|
+
|
|
693
940
|
function routeRequest(url, method) {
|
|
694
941
|
const pathname = url.pathname;
|
|
695
942
|
|
|
@@ -761,6 +1008,22 @@ function routeRequest(url, method) {
|
|
|
761
1008
|
return { command: 'contract', options: {} };
|
|
762
1009
|
}
|
|
763
1010
|
|
|
1011
|
+
if (pathname === '/cli/agent-tokens') {
|
|
1012
|
+
return { command: 'agent-tokens', options: {} };
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
{
|
|
1016
|
+
const agentTokenMatch = pathname.match(/^\/cli\/agent-tokens\/([^/]+)$/);
|
|
1017
|
+
if (agentTokenMatch) {
|
|
1018
|
+
return {
|
|
1019
|
+
command: 'agent-token',
|
|
1020
|
+
options: {
|
|
1021
|
+
id: decodeURIComponent(agentTokenMatch[1])
|
|
1022
|
+
}
|
|
1023
|
+
};
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
|
|
764
1027
|
if (pathname === '/sync/snapshot') {
|
|
765
1028
|
return { command: 'sync-upload', options: {} };
|
|
766
1029
|
}
|
|
@@ -908,11 +1171,34 @@ function routeRequest(url, method) {
|
|
|
908
1171
|
return { command: 'program-detail', options: {} };
|
|
909
1172
|
}
|
|
910
1173
|
|
|
1174
|
+
if (pathname === '/cli/programs/progress') {
|
|
1175
|
+
return {
|
|
1176
|
+
command: 'program-progress',
|
|
1177
|
+
options: {
|
|
1178
|
+
'program-id': url.searchParams.get('program-id') ?? undefined,
|
|
1179
|
+
since: url.searchParams.get('since') ?? undefined,
|
|
1180
|
+
limitExercises: url.searchParams.get('limitExercises') ?? undefined
|
|
1181
|
+
}
|
|
1182
|
+
};
|
|
1183
|
+
}
|
|
1184
|
+
|
|
911
1185
|
const programShowMatch = pathname.match(/^\/cli\/programs\/([^/]+)$/);
|
|
912
1186
|
if (programShowMatch) {
|
|
913
1187
|
return { command: 'program-detail', options: { id: programShowMatch[1] } };
|
|
914
1188
|
}
|
|
915
1189
|
|
|
1190
|
+
if (pathname === '/cli/exercises/progress') {
|
|
1191
|
+
return {
|
|
1192
|
+
command: 'exercise-progress-summary',
|
|
1193
|
+
options: {
|
|
1194
|
+
name: url.searchParams.get('name') ?? undefined,
|
|
1195
|
+
since: url.searchParams.get('since') ?? undefined,
|
|
1196
|
+
'program-id': url.searchParams.get('program-id') ?? undefined,
|
|
1197
|
+
limit: url.searchParams.get('limit') ?? undefined
|
|
1198
|
+
}
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
|
|
916
1202
|
if (pathname === '/cli/exercises/history') {
|
|
917
1203
|
return {
|
|
918
1204
|
command: 'exercise-history',
|
|
@@ -944,6 +1230,16 @@ function routeRequest(url, method) {
|
|
|
944
1230
|
};
|
|
945
1231
|
}
|
|
946
1232
|
|
|
1233
|
+
if (pathname === '/cli/cycles/progress') {
|
|
1234
|
+
return {
|
|
1235
|
+
command: 'cycle-progression-summary',
|
|
1236
|
+
options: {
|
|
1237
|
+
'program-id': url.searchParams.get('program-id') ?? undefined,
|
|
1238
|
+
limit: url.searchParams.get('limit') ?? undefined
|
|
1239
|
+
}
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
|
|
947
1243
|
const cyclesShowMatch = pathname.match(/^\/cli\/cycles\/([^/]+)$/);
|
|
948
1244
|
if (cyclesShowMatch) {
|
|
949
1245
|
return { command: 'cycle-summary-show', options: { id: decodeURIComponent(cyclesShowMatch[1]) } };
|
|
@@ -1028,6 +1324,10 @@ function routeRequest(url, method) {
|
|
|
1028
1324
|
return { command: 'ask-ai', options: {} };
|
|
1029
1325
|
}
|
|
1030
1326
|
|
|
1327
|
+
if (pathname === '/cli/ask/plan') {
|
|
1328
|
+
return { command: 'ask-plan', options: {} };
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1031
1331
|
if (pathname === '/cli/ask/history') {
|
|
1032
1332
|
return { command: 'ask-history', options: { limit: url.searchParams.get('limit') ?? undefined } };
|
|
1033
1333
|
}
|
|
@@ -1064,11 +1364,17 @@ function routeRequest(url, method) {
|
|
|
1064
1364
|
}
|
|
1065
1365
|
|
|
1066
1366
|
if (pathname === '/cli/coach-observations/current') {
|
|
1067
|
-
return {
|
|
1367
|
+
return {
|
|
1368
|
+
command: 'coach-observations-current',
|
|
1369
|
+
options: {
|
|
1370
|
+
limit: url.searchParams.get('limit') ?? undefined,
|
|
1371
|
+
refresh: url.searchParams.get('refresh') ?? undefined
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1068
1374
|
}
|
|
1069
1375
|
|
|
1070
1376
|
{
|
|
1071
|
-
const coachObservationActionMatch = pathname.match(/^\/cli\/coach-observations\/([^/]+)\/(seen|dismiss)$/);
|
|
1377
|
+
const coachObservationActionMatch = pathname.match(/^\/cli\/coach-observations\/([^/]+)\/(seen|dismiss|outcome|feedback)$/);
|
|
1072
1378
|
if (coachObservationActionMatch) {
|
|
1073
1379
|
return {
|
|
1074
1380
|
command: `coach-observations-${coachObservationActionMatch[2]}`,
|
|
@@ -1099,6 +1405,15 @@ function routeRequest(url, method) {
|
|
|
1099
1405
|
return { command: 'training-load', options: {} };
|
|
1100
1406
|
}
|
|
1101
1407
|
|
|
1408
|
+
if (pathname === '/cli/training-profile') {
|
|
1409
|
+
return {
|
|
1410
|
+
command: 'training-profile',
|
|
1411
|
+
options: {
|
|
1412
|
+
since: url.searchParams.get('since') ?? undefined
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1102
1417
|
// --- Social endpoints ---
|
|
1103
1418
|
if (pathname === '/cli/social/me/profile') {
|
|
1104
1419
|
return { command: 'social-me-profile', options: {} };
|
|
@@ -1463,55 +1778,10 @@ function routeRequest(url, method) {
|
|
|
1463
1778
|
return null;
|
|
1464
1779
|
}
|
|
1465
1780
|
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
const lines = ['[Increment Score]'];
|
|
1472
|
-
const tier = latest.dataTier ? ` · ${latest.dataTier}` : '';
|
|
1473
|
-
lines.push(`- Current: ${latest.score}/100${tier}`);
|
|
1474
|
-
|
|
1475
|
-
if (latest.components && typeof latest.components === 'object') {
|
|
1476
|
-
const parts = [];
|
|
1477
|
-
for (const [name, value] of Object.entries(latest.components)) {
|
|
1478
|
-
const num = typeof value === 'number' ? value : value?.score;
|
|
1479
|
-
if (typeof num === 'number') parts.push(`${name} ${num}`);
|
|
1480
|
-
}
|
|
1481
|
-
if (parts.length > 0) lines.push(`- Components: ${parts.join(', ')}`);
|
|
1482
|
-
}
|
|
1483
|
-
|
|
1484
|
-
const driverLabels = (list) => {
|
|
1485
|
-
if (!Array.isArray(list) || list.length === 0) return null;
|
|
1486
|
-
return list
|
|
1487
|
-
.slice(0, 3)
|
|
1488
|
-
.map((d) => d?.label ?? d?.id ?? d?.driver)
|
|
1489
|
-
.filter(Boolean)
|
|
1490
|
-
.join('; ');
|
|
1491
|
-
};
|
|
1492
|
-
const positives = driverLabels(latest.topPositiveDrivers);
|
|
1493
|
-
if (positives) lines.push(`- Top positive drivers: ${positives}`);
|
|
1494
|
-
const negatives = driverLabels(latest.topNegativeDrivers);
|
|
1495
|
-
if (negatives) lines.push(`- Top negative drivers: ${negatives}`);
|
|
1496
|
-
|
|
1497
|
-
if (snapshots.length > 1) {
|
|
1498
|
-
const prior = snapshots[1];
|
|
1499
|
-
if (typeof prior?.score === 'number') {
|
|
1500
|
-
const delta = latest.score - prior.score;
|
|
1501
|
-
const sign = delta > 0 ? '+' : '';
|
|
1502
|
-
lines.push(`- Day-over-day delta: ${sign}${delta}`);
|
|
1503
|
-
}
|
|
1504
|
-
const recent = snapshots
|
|
1505
|
-
.slice(0, 7)
|
|
1506
|
-
.map((s) => (typeof s?.score === 'number' ? s.score : null))
|
|
1507
|
-
.filter((s) => s != null);
|
|
1508
|
-
if (recent.length >= 3) {
|
|
1509
|
-
lines.push(`- Last ${recent.length} days: ${recent.join(', ')}`);
|
|
1510
|
-
}
|
|
1511
|
-
}
|
|
1512
|
-
|
|
1513
|
-
return lines.join('\n');
|
|
1514
|
-
}
|
|
1781
|
+
// formatIncrementScorePrelude lives in score-prelude.js (no server deps) so the
|
|
1782
|
+
// eval harness can build the same context. Imported at the top; re-exported here
|
|
1783
|
+
// for existing importers (e.g. cli.test.js, the ask handler below).
|
|
1784
|
+
export { formatIncrementScorePrelude };
|
|
1515
1785
|
|
|
1516
1786
|
async function readJsonBody(request) {
|
|
1517
1787
|
const chunks = [];
|
|
@@ -1937,7 +2207,8 @@ export function syncServiceContractPayload({
|
|
|
1937
2207
|
tokenBootstrap: true,
|
|
1938
2208
|
deviceFlow: false,
|
|
1939
2209
|
browserApproval: false,
|
|
1940
|
-
devEmail: false
|
|
2210
|
+
devEmail: false,
|
|
2211
|
+
agentTokens: false
|
|
1941
2212
|
},
|
|
1942
2213
|
providers = {
|
|
1943
2214
|
apple: {
|
|
@@ -2000,6 +2271,10 @@ export function createSyncServiceRequestHandler({
|
|
|
2000
2271
|
completeGoogleWebAuth = null,
|
|
2001
2272
|
completeGoogleMobileAuth = null,
|
|
2002
2273
|
refreshSession,
|
|
2274
|
+
issueAgentToken,
|
|
2275
|
+
listAgentTokens,
|
|
2276
|
+
revokeAgentToken,
|
|
2277
|
+
authenticateAgentTokenManagement,
|
|
2003
2278
|
authenticateConnectedWriteToken,
|
|
2004
2279
|
allowManualDeviceApproval = false,
|
|
2005
2280
|
rateLimitConfig = null,
|
|
@@ -2025,6 +2300,7 @@ export function createSyncServiceRequestHandler({
|
|
|
2025
2300
|
getCurrentWeeklyCheckinForAccount = null,
|
|
2026
2301
|
upsertScheduledWeeklyCheckinForAccount = null,
|
|
2027
2302
|
transitionWeeklyCheckinForAccount = null,
|
|
2303
|
+
updateWeeklyCheckinRecapForAccount = null,
|
|
2028
2304
|
generateWeeklyCheckinRecapImpl = null,
|
|
2029
2305
|
generateCheckinQuestionsImpl = null,
|
|
2030
2306
|
saveAIFeedbackForAccount = null,
|
|
@@ -2037,13 +2313,25 @@ export function createSyncServiceRequestHandler({
|
|
|
2037
2313
|
insertScoreSnapshotsForAccount = null,
|
|
2038
2314
|
listScoreSnapshotsForAccount = null,
|
|
2039
2315
|
getCurrentWeeklyScoreDigestForAccount = null,
|
|
2316
|
+
generateCoachObservationsForAccount = null,
|
|
2040
2317
|
listCurrentCoachObservationsForAccount = null,
|
|
2041
2318
|
markCoachObservationSeenForAccount = null,
|
|
2042
2319
|
dismissCoachObservationForAccount = null,
|
|
2320
|
+
recordCoachObservationOutcomeForAccount = null,
|
|
2321
|
+
recordCoachObservationFeedbackForAccount = null,
|
|
2043
2322
|
// Social
|
|
2044
2323
|
social = null,
|
|
2045
2324
|
onError = null
|
|
2046
2325
|
}) {
|
|
2326
|
+
// Fail-closed invariant: agent-token management is a human-only, full/session
|
|
2327
|
+
// scoped operation. If management is enabled, it MUST be gated by a dedicated
|
|
2328
|
+
// authenticator. Without this guard, the per-request `?? readAuthenticator`
|
|
2329
|
+
// fallback below would let the read authenticator (which accepts agent tokens)
|
|
2330
|
+
// manage tokens — a read-token → write-token escalation. Boot loudly instead.
|
|
2331
|
+
if ((issueAgentToken || listAgentTokens || revokeAgentToken) && typeof authenticateAgentTokenManagement !== 'function') {
|
|
2332
|
+
throw new Error('authenticateAgentTokenManagement must be provided when agent-token management is enabled; it must not fall back to the read authenticator.');
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2047
2335
|
const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
|
|
2048
2336
|
|
|
2049
2337
|
return async function handle(request, response) {
|
|
@@ -2840,65 +3128,162 @@ export function createSyncServiceRequestHandler({
|
|
|
2840
3128
|
const writeAuthenticator = authenticateWriteToken ?? authenticateToken;
|
|
2841
3129
|
const connectedWriteAuthenticator = authenticateConnectedWriteToken ?? readAuthenticator;
|
|
2842
3130
|
const mobileSyncAuthenticator = writeAuthenticator;
|
|
3131
|
+
// Safe fallback: the construction-time invariant guarantees a dedicated
|
|
3132
|
+
// authenticator whenever management handlers are wired, so this only ever
|
|
3133
|
+
// resolves to readAuthenticator when management is disabled (issueAgentToken
|
|
3134
|
+
// absent), where the route returns 405 before any token is honoured.
|
|
3135
|
+
const agentTokenManagementAuthenticator = authenticateAgentTokenManagement ?? readAuthenticator;
|
|
2843
3136
|
|
|
2844
|
-
if (route.command === '
|
|
2845
|
-
if (request.method !== 'GET') {
|
|
2846
|
-
methodNotAllowed(response, 'Use GET for /
|
|
3137
|
+
if (route.command === 'agent-tokens') {
|
|
3138
|
+
if (request.method !== 'POST' && request.method !== 'GET') {
|
|
3139
|
+
methodNotAllowed(response, 'Use GET or POST for /cli/agent-tokens.');
|
|
2847
3140
|
return;
|
|
2848
3141
|
}
|
|
2849
|
-
|
|
2850
|
-
|
|
3142
|
+
|
|
3143
|
+
if (!requestToken) {
|
|
3144
|
+
unauthorized(response, request);
|
|
2851
3145
|
return;
|
|
2852
3146
|
}
|
|
2853
3147
|
|
|
2854
|
-
const
|
|
2855
|
-
? await
|
|
3148
|
+
const managementAccount = agentTokenManagementAuthenticator
|
|
3149
|
+
? await agentTokenManagementAuthenticator(requestToken)
|
|
2856
3150
|
: null;
|
|
2857
|
-
if (!
|
|
3151
|
+
if (!managementAccount) {
|
|
2858
3152
|
unauthorized(response, request);
|
|
2859
3153
|
return;
|
|
2860
3154
|
}
|
|
2861
3155
|
|
|
2862
|
-
|
|
3156
|
+
if (request.method === 'POST') {
|
|
3157
|
+
if (!issueAgentToken) {
|
|
3158
|
+
methodNotAllowed(response, 'Agent token creation is not enabled for this service mode.');
|
|
3159
|
+
return;
|
|
3160
|
+
}
|
|
3161
|
+
try {
|
|
3162
|
+
const body = await readJsonBody(request);
|
|
3163
|
+
const agentToken = await issueAgentToken(managementAccount, {
|
|
3164
|
+
name: body?.name,
|
|
3165
|
+
access: body?.access ?? 'read',
|
|
3166
|
+
expiresDays: body?.expiresDays ?? 90
|
|
3167
|
+
});
|
|
3168
|
+
json(response, 201, {
|
|
3169
|
+
ok: true,
|
|
3170
|
+
agentToken
|
|
3171
|
+
});
|
|
3172
|
+
return;
|
|
3173
|
+
} catch (error) {
|
|
3174
|
+
badRequest(response, error.message);
|
|
3175
|
+
return;
|
|
3176
|
+
}
|
|
3177
|
+
}
|
|
3178
|
+
|
|
3179
|
+
if (!listAgentTokens) {
|
|
3180
|
+
methodNotAllowed(response, 'Agent token listing is not enabled for this service mode.');
|
|
3181
|
+
return;
|
|
3182
|
+
}
|
|
3183
|
+
const agentTokens = await listAgentTokens(managementAccount);
|
|
3184
|
+
json(response, 200, {
|
|
3185
|
+
agentTokens
|
|
3186
|
+
});
|
|
2863
3187
|
return;
|
|
2864
3188
|
}
|
|
2865
3189
|
|
|
2866
|
-
if (route.command === '
|
|
2867
|
-
if (request.method !== '
|
|
2868
|
-
methodNotAllowed(response, 'Use
|
|
3190
|
+
if (route.command === 'agent-token') {
|
|
3191
|
+
if (request.method !== 'DELETE') {
|
|
3192
|
+
methodNotAllowed(response, 'Use DELETE for /cli/agent-tokens/:id.');
|
|
2869
3193
|
return;
|
|
2870
3194
|
}
|
|
2871
|
-
|
|
2872
|
-
|
|
3195
|
+
|
|
3196
|
+
if (!requestToken) {
|
|
3197
|
+
unauthorized(response, request);
|
|
2873
3198
|
return;
|
|
2874
3199
|
}
|
|
2875
3200
|
|
|
2876
|
-
|
|
2877
|
-
|
|
3201
|
+
if (!revokeAgentToken) {
|
|
3202
|
+
methodNotAllowed(response, 'Agent token revocation is not enabled for this service mode.');
|
|
3203
|
+
return;
|
|
3204
|
+
}
|
|
3205
|
+
|
|
3206
|
+
const managementAccount = agentTokenManagementAuthenticator
|
|
3207
|
+
? await agentTokenManagementAuthenticator(requestToken)
|
|
2878
3208
|
: null;
|
|
2879
|
-
if (!
|
|
3209
|
+
if (!managementAccount) {
|
|
2880
3210
|
unauthorized(response, request);
|
|
2881
3211
|
return;
|
|
2882
3212
|
}
|
|
2883
3213
|
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
2890
|
-
|
|
3214
|
+
const agentToken = await revokeAgentToken(managementAccount, route.options.id);
|
|
3215
|
+
if (!agentToken) {
|
|
3216
|
+
notFound(response, 'Agent token not found.');
|
|
3217
|
+
return;
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
json(response, 200, {
|
|
3221
|
+
ok: true,
|
|
3222
|
+
agentToken
|
|
3223
|
+
});
|
|
3224
|
+
return;
|
|
3225
|
+
}
|
|
3226
|
+
|
|
3227
|
+
if (route.command === 'mobile-sync-bootstrap') {
|
|
3228
|
+
if (request.method !== 'GET') {
|
|
3229
|
+
methodNotAllowed(response, 'Use GET for /mobile/sync/bootstrap.');
|
|
3230
|
+
return;
|
|
3231
|
+
}
|
|
3232
|
+
if (!loadMobileSyncStateForAccount) {
|
|
3233
|
+
methodNotAllowed(response, 'Mobile sync bootstrap is not enabled for this service mode.');
|
|
3234
|
+
return;
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
const account = mobileSyncAuthenticator
|
|
3238
|
+
? await mobileSyncAuthenticator(requestToken)
|
|
2891
3239
|
: null;
|
|
2892
3240
|
if (!account) {
|
|
2893
3241
|
unauthorized(response, request);
|
|
2894
3242
|
return;
|
|
2895
3243
|
}
|
|
2896
3244
|
|
|
3245
|
+
json(response, 200, await loadMobileSyncStateForAccount(account));
|
|
3246
|
+
return;
|
|
3247
|
+
}
|
|
3248
|
+
|
|
3249
|
+
if (route.command === 'mobile-sync-pull') {
|
|
3250
|
+
if (request.method !== 'GET') {
|
|
3251
|
+
methodNotAllowed(response, 'Use GET for /mobile/sync/pull.');
|
|
3252
|
+
return;
|
|
3253
|
+
}
|
|
3254
|
+
if (!pullMobileSyncStateForAccount) {
|
|
3255
|
+
methodNotAllowed(response, 'Mobile sync pull is not enabled for this service mode.');
|
|
3256
|
+
return;
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
const account = mobileSyncAuthenticator
|
|
3260
|
+
? await mobileSyncAuthenticator(requestToken)
|
|
3261
|
+
: null;
|
|
3262
|
+
if (!account) {
|
|
3263
|
+
unauthorized(response, request);
|
|
3264
|
+
return;
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
json(response, 200, await pullMobileSyncStateForAccount(account, route.options.since));
|
|
3268
|
+
return;
|
|
3269
|
+
}
|
|
3270
|
+
|
|
3271
|
+
if (route.command === 'score-snapshots') {
|
|
2897
3272
|
if (request.method === 'POST') {
|
|
2898
3273
|
if (!insertScoreSnapshotsForAccount) {
|
|
2899
3274
|
methodNotAllowed(response, 'Score snapshot upload is not enabled for this service mode.');
|
|
2900
3275
|
return;
|
|
2901
3276
|
}
|
|
3277
|
+
const account = connectedWriteAuthenticator
|
|
3278
|
+
? await connectedWriteAuthenticator(requestToken)
|
|
3279
|
+
: null;
|
|
3280
|
+
if (!account) {
|
|
3281
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
3282
|
+
return;
|
|
3283
|
+
}
|
|
3284
|
+
unauthorized(response, request);
|
|
3285
|
+
return;
|
|
3286
|
+
}
|
|
2902
3287
|
try {
|
|
2903
3288
|
const body = await readJsonBody(request);
|
|
2904
3289
|
if (!body || typeof body !== 'object' || !Array.isArray(body.snapshots)) {
|
|
@@ -2906,6 +3291,22 @@ export function createSyncServiceRequestHandler({
|
|
|
2906
3291
|
return;
|
|
2907
3292
|
}
|
|
2908
3293
|
const result = await insertScoreSnapshotsForAccount(account, body.snapshots);
|
|
3294
|
+
if (generateCoachObservationsForAccount) {
|
|
3295
|
+
const sourceTrigger = coachObservationSourceTriggerForScoreSnapshots(body.snapshots);
|
|
3296
|
+
try {
|
|
3297
|
+
const generationResult = await generateCoachObservationsForAccount(account, { sourceTrigger });
|
|
3298
|
+
result.coachObservations = coachObservationGenerationMetadata(generationResult, sourceTrigger);
|
|
3299
|
+
} catch (error) {
|
|
3300
|
+
console.error('Coach observation generation after score upload failed:', error.message);
|
|
3301
|
+
result.coachObservations = {
|
|
3302
|
+
attempted: true,
|
|
3303
|
+
generated: 0,
|
|
3304
|
+
persisted: 0,
|
|
3305
|
+
skippedReason: 'generation_failed',
|
|
3306
|
+
sourceTrigger
|
|
3307
|
+
};
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
2909
3310
|
json(response, 200, result);
|
|
2910
3311
|
return;
|
|
2911
3312
|
} catch (error) {
|
|
@@ -2915,6 +3316,13 @@ export function createSyncServiceRequestHandler({
|
|
|
2915
3316
|
}
|
|
2916
3317
|
|
|
2917
3318
|
if (request.method === 'GET') {
|
|
3319
|
+
const account = readAuthenticator
|
|
3320
|
+
? await readAuthenticator(requestToken)
|
|
3321
|
+
: null;
|
|
3322
|
+
if (!account) {
|
|
3323
|
+
unauthorized(response, request);
|
|
3324
|
+
return;
|
|
3325
|
+
}
|
|
2918
3326
|
if (!listScoreSnapshotsForAccount) {
|
|
2919
3327
|
methodNotAllowed(response, 'Score snapshot history is not enabled for this service mode.');
|
|
2920
3328
|
return;
|
|
@@ -2985,6 +3393,19 @@ export function createSyncServiceRequestHandler({
|
|
|
2985
3393
|
return;
|
|
2986
3394
|
}
|
|
2987
3395
|
|
|
3396
|
+
// Account deletion is an irreversible human action. Agent tokens — even
|
|
3397
|
+
// write-scoped ones — must never be able to delete the account; a leaked
|
|
3398
|
+
// CI/automation token would otherwise nuke everything. Require a human
|
|
3399
|
+
// (full/session) credential.
|
|
3400
|
+
if (typeof deleteAccount.tokenScope === 'string' && deleteAccount.tokenScope.startsWith('agent')) {
|
|
3401
|
+
logRequest(request, 403);
|
|
3402
|
+
json(response, 403, {
|
|
3403
|
+
error: 'Account deletion requires a human login and cannot be performed with an agent token.',
|
|
3404
|
+
code: 'AGENT_TOKEN_FORBIDDEN'
|
|
3405
|
+
});
|
|
3406
|
+
return;
|
|
3407
|
+
}
|
|
3408
|
+
|
|
2988
3409
|
try {
|
|
2989
3410
|
await deleteAccountForUser(deleteAccount.id);
|
|
2990
3411
|
logRequest(request, 200);
|
|
@@ -3009,6 +3430,9 @@ export function createSyncServiceRequestHandler({
|
|
|
3009
3430
|
? { id: 'remote-user', email: null }
|
|
3010
3431
|
: null;
|
|
3011
3432
|
if (!proposalAccount) {
|
|
3433
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3012
3436
|
unauthorized(response, request);
|
|
3013
3437
|
return;
|
|
3014
3438
|
}
|
|
@@ -3077,6 +3501,9 @@ export function createSyncServiceRequestHandler({
|
|
|
3077
3501
|
? { id: 'remote-user', email: null }
|
|
3078
3502
|
: null;
|
|
3079
3503
|
if (!proposalAccount) {
|
|
3504
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
3505
|
+
return;
|
|
3506
|
+
}
|
|
3080
3507
|
unauthorized(response, request);
|
|
3081
3508
|
return;
|
|
3082
3509
|
}
|
|
@@ -3115,6 +3542,9 @@ export function createSyncServiceRequestHandler({
|
|
|
3115
3542
|
? await connectedWriteAuthenticator(requestToken)
|
|
3116
3543
|
: null;
|
|
3117
3544
|
if (!account) {
|
|
3545
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
3546
|
+
return;
|
|
3547
|
+
}
|
|
3118
3548
|
unauthorized(response, request);
|
|
3119
3549
|
return;
|
|
3120
3550
|
}
|
|
@@ -3199,6 +3629,9 @@ export function createSyncServiceRequestHandler({
|
|
|
3199
3629
|
? await connectedWriteAuthenticator(requestToken)
|
|
3200
3630
|
: null;
|
|
3201
3631
|
if (!account) {
|
|
3632
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
3202
3635
|
unauthorized(response, request);
|
|
3203
3636
|
return;
|
|
3204
3637
|
}
|
|
@@ -3240,7 +3673,8 @@ export function createSyncServiceRequestHandler({
|
|
|
3240
3673
|
tokenBootstrap: Boolean(issueSession || token),
|
|
3241
3674
|
deviceFlow: Boolean(issueDeviceChallenge && consumeDeviceChallenge),
|
|
3242
3675
|
browserApproval: Boolean(approveDeviceChallenge),
|
|
3243
|
-
devEmail: Boolean(issueDevLogin)
|
|
3676
|
+
devEmail: Boolean(issueDevLogin),
|
|
3677
|
+
agentTokens: Boolean(issueAgentToken && listAgentTokens && revokeAgentToken)
|
|
3244
3678
|
}
|
|
3245
3679
|
}));
|
|
3246
3680
|
return;
|
|
@@ -3549,16 +3983,65 @@ export function createSyncServiceRequestHandler({
|
|
|
3549
3983
|
methodNotAllowed(response, 'Use GET for /cli/coach-observations/current.');
|
|
3550
3984
|
return;
|
|
3551
3985
|
}
|
|
3986
|
+
if (route.options.refresh && route.options.refresh !== 'morning_open') {
|
|
3987
|
+
badRequest(response, 'Unsupported coach observations refresh value.');
|
|
3988
|
+
return;
|
|
3989
|
+
}
|
|
3552
3990
|
if (!listCurrentCoachObservationsForAccount) {
|
|
3553
3991
|
json(response, 503, { error: 'Coach observations not available' });
|
|
3554
3992
|
return;
|
|
3555
3993
|
}
|
|
3556
3994
|
try {
|
|
3557
3995
|
const limit = parseLimit(route.options.limit, { defaultValue: 5, max: 20 });
|
|
3996
|
+
let refresh = null;
|
|
3997
|
+
if (route.options.refresh === 'morning_open') {
|
|
3998
|
+
const writeAccount = writeAuthenticator
|
|
3999
|
+
? await writeAuthenticator(requestToken)
|
|
4000
|
+
: requestToken === token
|
|
4001
|
+
? account
|
|
4002
|
+
: null;
|
|
4003
|
+
if (!writeAccount) {
|
|
4004
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
4005
|
+
return;
|
|
4006
|
+
}
|
|
4007
|
+
unauthorized(response, request);
|
|
4008
|
+
return;
|
|
4009
|
+
}
|
|
4010
|
+
const sourceTrigger = 'daily_refresh';
|
|
4011
|
+
if (generateCoachObservationsForAccount) {
|
|
4012
|
+
try {
|
|
4013
|
+
const generationResult = await generateCoachObservationsForAccount(writeAccount, {
|
|
4014
|
+
sourceTrigger,
|
|
4015
|
+
refresh: route.options.refresh
|
|
4016
|
+
});
|
|
4017
|
+
refresh = coachObservationGenerationMetadata(generationResult, sourceTrigger);
|
|
4018
|
+
} catch (error) {
|
|
4019
|
+
console.error('Coach observation generation on morning open failed:', error.message);
|
|
4020
|
+
refresh = {
|
|
4021
|
+
attempted: true,
|
|
4022
|
+
generated: 0,
|
|
4023
|
+
persisted: 0,
|
|
4024
|
+
skippedReason: 'generation_failed',
|
|
4025
|
+
sourceTrigger
|
|
4026
|
+
};
|
|
4027
|
+
}
|
|
4028
|
+
} else {
|
|
4029
|
+
refresh = {
|
|
4030
|
+
attempted: false,
|
|
4031
|
+
generated: 0,
|
|
4032
|
+
persisted: 0,
|
|
4033
|
+
skippedReason: 'generation_unavailable',
|
|
4034
|
+
sourceTrigger
|
|
4035
|
+
};
|
|
4036
|
+
}
|
|
4037
|
+
}
|
|
3558
4038
|
const observations = await listCurrentCoachObservationsForAccount(account, {
|
|
3559
4039
|
limit
|
|
3560
4040
|
});
|
|
3561
|
-
json(response, 200, {
|
|
4041
|
+
json(response, 200, {
|
|
4042
|
+
observations: observations ?? [],
|
|
4043
|
+
...(refresh ? { refresh } : {})
|
|
4044
|
+
});
|
|
3562
4045
|
} catch (err) {
|
|
3563
4046
|
console.error('Coach observations read error:', err.message);
|
|
3564
4047
|
json(response, 500, { error: 'Failed to read coach observations' });
|
|
@@ -3575,6 +4058,18 @@ export function createSyncServiceRequestHandler({
|
|
|
3575
4058
|
json(response, 503, { error: 'Coach observations not available' });
|
|
3576
4059
|
return;
|
|
3577
4060
|
}
|
|
4061
|
+
const writeAccount = connectedWriteAuthenticator
|
|
4062
|
+
? await connectedWriteAuthenticator(requestToken)
|
|
4063
|
+
: requestToken === token
|
|
4064
|
+
? account
|
|
4065
|
+
: null;
|
|
4066
|
+
if (!writeAccount) {
|
|
4067
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
4068
|
+
return;
|
|
4069
|
+
}
|
|
4070
|
+
unauthorized(response, request);
|
|
4071
|
+
return;
|
|
4072
|
+
}
|
|
3578
4073
|
let body = {};
|
|
3579
4074
|
try {
|
|
3580
4075
|
body = await readOptionalJsonBody(request);
|
|
@@ -3588,7 +4083,7 @@ export function createSyncServiceRequestHandler({
|
|
|
3588
4083
|
return;
|
|
3589
4084
|
}
|
|
3590
4085
|
try {
|
|
3591
|
-
const observation = await markCoachObservationSeenForAccount(
|
|
4086
|
+
const observation = await markCoachObservationSeenForAccount(writeAccount, route.options.id, { seenAt });
|
|
3592
4087
|
if (!observation) {
|
|
3593
4088
|
notFound(response, 'Observation not found');
|
|
3594
4089
|
return;
|
|
@@ -3610,6 +4105,18 @@ export function createSyncServiceRequestHandler({
|
|
|
3610
4105
|
json(response, 503, { error: 'Coach observations not available' });
|
|
3611
4106
|
return;
|
|
3612
4107
|
}
|
|
4108
|
+
const writeAccount = connectedWriteAuthenticator
|
|
4109
|
+
? await connectedWriteAuthenticator(requestToken)
|
|
4110
|
+
: requestToken === token
|
|
4111
|
+
? account
|
|
4112
|
+
: null;
|
|
4113
|
+
if (!writeAccount) {
|
|
4114
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
4115
|
+
return;
|
|
4116
|
+
}
|
|
4117
|
+
unauthorized(response, request);
|
|
4118
|
+
return;
|
|
4119
|
+
}
|
|
3613
4120
|
try {
|
|
3614
4121
|
await readOptionalJsonBody(request);
|
|
3615
4122
|
} catch {
|
|
@@ -3617,7 +4124,7 @@ export function createSyncServiceRequestHandler({
|
|
|
3617
4124
|
return;
|
|
3618
4125
|
}
|
|
3619
4126
|
try {
|
|
3620
|
-
const observation = await dismissCoachObservationForAccount(
|
|
4127
|
+
const observation = await dismissCoachObservationForAccount(writeAccount, route.options.id, {});
|
|
3621
4128
|
if (!observation) {
|
|
3622
4129
|
notFound(response, 'Observation not found');
|
|
3623
4130
|
return;
|
|
@@ -3630,6 +4137,126 @@ export function createSyncServiceRequestHandler({
|
|
|
3630
4137
|
return;
|
|
3631
4138
|
}
|
|
3632
4139
|
|
|
4140
|
+
if (route.command === 'coach-observations-outcome') {
|
|
4141
|
+
if (request.method !== 'POST') {
|
|
4142
|
+
methodNotAllowed(response, 'Use POST for /cli/coach-observations/:id/outcome.');
|
|
4143
|
+
return;
|
|
4144
|
+
}
|
|
4145
|
+
if (!recordCoachObservationOutcomeForAccount) {
|
|
4146
|
+
json(response, 503, { error: 'Coach observations not available' });
|
|
4147
|
+
return;
|
|
4148
|
+
}
|
|
4149
|
+
const writeAccount = connectedWriteAuthenticator
|
|
4150
|
+
? await connectedWriteAuthenticator(requestToken)
|
|
4151
|
+
: requestToken === token
|
|
4152
|
+
? account
|
|
4153
|
+
: null;
|
|
4154
|
+
if (!writeAccount) {
|
|
4155
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
4156
|
+
return;
|
|
4157
|
+
}
|
|
4158
|
+
unauthorized(response, request);
|
|
4159
|
+
return;
|
|
4160
|
+
}
|
|
4161
|
+
let body = {};
|
|
4162
|
+
try {
|
|
4163
|
+
body = await readOptionalJsonBody(request);
|
|
4164
|
+
} catch {
|
|
4165
|
+
badRequest(response, 'Invalid request body.');
|
|
4166
|
+
return;
|
|
4167
|
+
}
|
|
4168
|
+
const outcomeStatus = String(body?.outcomeStatus ?? '').trim();
|
|
4169
|
+
if (!COACH_OBSERVATION_OUTCOME_STATUSES.has(outcomeStatus)) {
|
|
4170
|
+
badRequest(response, 'outcomeStatus must be improved, unchanged, regressed, or inconclusive.');
|
|
4171
|
+
return;
|
|
4172
|
+
}
|
|
4173
|
+
const outcomeObservedAt = body?.outcomeObservedAt ? new Date(body.outcomeObservedAt) : new Date();
|
|
4174
|
+
if (Number.isNaN(outcomeObservedAt.getTime())) {
|
|
4175
|
+
badRequest(response, 'Invalid outcomeObservedAt.');
|
|
4176
|
+
return;
|
|
4177
|
+
}
|
|
4178
|
+
try {
|
|
4179
|
+
const observation = await recordCoachObservationOutcomeForAccount(writeAccount, route.options.id, {
|
|
4180
|
+
outcomeStatus,
|
|
4181
|
+
outcomeObservedAt,
|
|
4182
|
+
outcomeNotes: typeof body?.outcomeNotes === 'string' && body.outcomeNotes.trim()
|
|
4183
|
+
? body.outcomeNotes.trim()
|
|
4184
|
+
: null,
|
|
4185
|
+
linkedFollowupObservationId: typeof body?.linkedFollowupObservationId === 'string' && body.linkedFollowupObservationId.trim()
|
|
4186
|
+
? body.linkedFollowupObservationId.trim()
|
|
4187
|
+
: null
|
|
4188
|
+
});
|
|
4189
|
+
if (!observation) {
|
|
4190
|
+
notFound(response, 'Observation not found');
|
|
4191
|
+
return;
|
|
4192
|
+
}
|
|
4193
|
+
json(response, 200, { observation });
|
|
4194
|
+
} catch (err) {
|
|
4195
|
+
console.error('Coach observation outcome error:', err.message);
|
|
4196
|
+
if (err.code === 'INVALID_LINKED_FOLLOWUP_OBSERVATION') {
|
|
4197
|
+
badRequest(response, err.message);
|
|
4198
|
+
return;
|
|
4199
|
+
}
|
|
4200
|
+
json(response, 500, { error: 'Failed to record observation outcome' });
|
|
4201
|
+
}
|
|
4202
|
+
return;
|
|
4203
|
+
}
|
|
4204
|
+
|
|
4205
|
+
if (route.command === 'coach-observations-feedback') {
|
|
4206
|
+
if (request.method !== 'POST') {
|
|
4207
|
+
methodNotAllowed(response, 'Use POST for /cli/coach-observations/:id/feedback.');
|
|
4208
|
+
return;
|
|
4209
|
+
}
|
|
4210
|
+
if (!recordCoachObservationFeedbackForAccount) {
|
|
4211
|
+
json(response, 503, { error: 'Coach observations not available' });
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
4214
|
+
const writeAccount = connectedWriteAuthenticator
|
|
4215
|
+
? await connectedWriteAuthenticator(requestToken)
|
|
4216
|
+
: requestToken === token
|
|
4217
|
+
? account
|
|
4218
|
+
: null;
|
|
4219
|
+
if (!writeAccount) {
|
|
4220
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
4221
|
+
return;
|
|
4222
|
+
}
|
|
4223
|
+
unauthorized(response, request);
|
|
4224
|
+
return;
|
|
4225
|
+
}
|
|
4226
|
+
let body = {};
|
|
4227
|
+
try {
|
|
4228
|
+
body = await readOptionalJsonBody(request);
|
|
4229
|
+
} catch {
|
|
4230
|
+
badRequest(response, 'Invalid request body.');
|
|
4231
|
+
return;
|
|
4232
|
+
}
|
|
4233
|
+
const feedbackStatus = String(body?.feedbackStatus ?? '').trim();
|
|
4234
|
+
if (!COACH_OBSERVATION_FEEDBACK_STATUSES.has(feedbackStatus)) {
|
|
4235
|
+
badRequest(response, 'feedbackStatus must be accepted or rejected.');
|
|
4236
|
+
return;
|
|
4237
|
+
}
|
|
4238
|
+
const feedbackAt = body?.feedbackAt ? new Date(body.feedbackAt) : new Date();
|
|
4239
|
+
if (Number.isNaN(feedbackAt.getTime())) {
|
|
4240
|
+
badRequest(response, 'Invalid feedbackAt.');
|
|
4241
|
+
return;
|
|
4242
|
+
}
|
|
4243
|
+
try {
|
|
4244
|
+
const observation = await recordCoachObservationFeedbackForAccount(writeAccount, route.options.id, {
|
|
4245
|
+
feedbackStatus,
|
|
4246
|
+
feedbackAt
|
|
4247
|
+
});
|
|
4248
|
+
if (!observation) {
|
|
4249
|
+
notFound(response, 'Observation not found');
|
|
4250
|
+
return;
|
|
4251
|
+
}
|
|
4252
|
+
json(response, 200, { observation });
|
|
4253
|
+
} catch (err) {
|
|
4254
|
+
console.error('Coach observation feedback error:', err.message);
|
|
4255
|
+
json(response, 500, { error: 'Failed to record observation feedback' });
|
|
4256
|
+
}
|
|
4257
|
+
return;
|
|
4258
|
+
}
|
|
4259
|
+
|
|
3633
4260
|
let snapshot;
|
|
3634
4261
|
try {
|
|
3635
4262
|
snapshot = loadSnapshotForAccount
|
|
@@ -3652,6 +4279,85 @@ export function createSyncServiceRequestHandler({
|
|
|
3652
4279
|
};
|
|
3653
4280
|
}
|
|
3654
4281
|
};
|
|
4282
|
+
const weeklyCheckinContextForRow = async (row) => {
|
|
4283
|
+
const { weeklyCheckinContext } = await import('./queries.js');
|
|
4284
|
+
return weeklyCheckinContext(snapshot, account.id, weeklyCheckinContextOptions(row));
|
|
4285
|
+
};
|
|
4286
|
+
const priorWeeklyCheckinCommitment = async (ctx) => {
|
|
4287
|
+
if (!listActiveCoachCommitmentsForAccount || !ctx) return null;
|
|
4288
|
+
try {
|
|
4289
|
+
const activeCommitments = await listActiveCoachCommitmentsForAccount(account, {
|
|
4290
|
+
limit: 1,
|
|
4291
|
+
weekStartDate: ctx.weekRangeIso?.start
|
|
4292
|
+
});
|
|
4293
|
+
return activeCommitments[0] ?? null;
|
|
4294
|
+
} catch (commitmentErr) {
|
|
4295
|
+
console.error('Weekly-checkin commitment read failed:', commitmentErr.message);
|
|
4296
|
+
return null;
|
|
4297
|
+
}
|
|
4298
|
+
};
|
|
4299
|
+
const weeklyCheckinFreshnessForRow = async (row) => {
|
|
4300
|
+
const checkedAt = new Date().toISOString();
|
|
4301
|
+
if (!row?.recap || row.recap.placeholder) return null;
|
|
4302
|
+
const storedDigest = typeof row.recap.contextDigest === 'string' ? row.recap.contextDigest : null;
|
|
4303
|
+
if (!storedDigest) return { state: 'unknown', checkedAt };
|
|
4304
|
+
try {
|
|
4305
|
+
const ctx = await weeklyCheckinContextForRow(row);
|
|
4306
|
+
const priorCommitmentRow = await priorWeeklyCheckinCommitment(ctx);
|
|
4307
|
+
const { weeklyCheckinContextDigest } = await import('./queries.js');
|
|
4308
|
+
const currentDigest = weeklyCheckinContextDigest(ctx, {
|
|
4309
|
+
priorCommitment: priorCommitmentRow?.commitment ?? null,
|
|
4310
|
+
coachCommitmentIds: priorCommitmentRow?.id ? [priorCommitmentRow.id] : []
|
|
4311
|
+
});
|
|
4312
|
+
if (!currentDigest) return { state: 'unknown', checkedAt };
|
|
4313
|
+
return { state: currentDigest === storedDigest ? 'fresh' : 'stale', checkedAt };
|
|
4314
|
+
} catch {
|
|
4315
|
+
return { state: 'unknown', checkedAt };
|
|
4316
|
+
}
|
|
4317
|
+
};
|
|
4318
|
+
const generateWeeklyCheckinRecapPayload = async (row) => {
|
|
4319
|
+
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
|
4320
|
+
if (!openrouterKey || !generateWeeklyCheckinRecapImpl || !generateCheckinQuestionsImpl) {
|
|
4321
|
+
const err = new Error('Weekly check-in refresh is unavailable.');
|
|
4322
|
+
err.code = 'weekly_checkin_refresh_unavailable';
|
|
4323
|
+
throw err;
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4326
|
+
const ctx = await weeklyCheckinContextForRow(row);
|
|
4327
|
+
if (!ctx) {
|
|
4328
|
+
const err = new Error('Weekly check-in context is unavailable.');
|
|
4329
|
+
err.code = 'weekly_checkin_context_unavailable';
|
|
4330
|
+
throw err;
|
|
4331
|
+
}
|
|
4332
|
+
|
|
4333
|
+
const priorCommitmentRow = await priorWeeklyCheckinCommitment(ctx);
|
|
4334
|
+
const { weeklyCheckinContextDigest } = await import('./queries.js');
|
|
4335
|
+
const coachCommitmentIds = priorCommitmentRow?.id ? [priorCommitmentRow.id] : [];
|
|
4336
|
+
const recapResult = await generateWeeklyCheckinRecapImpl(ctx, {
|
|
4337
|
+
apiKey: openrouterKey,
|
|
4338
|
+
user: aiUser,
|
|
4339
|
+
sessionId: `weekly-checkin:${row.id}:recap`,
|
|
4340
|
+
priorCommitment: priorCommitmentRow?.commitment ?? null,
|
|
4341
|
+
contextMetadata: {
|
|
4342
|
+
coachCommitmentIds: priorCommitmentRow?.id ? [priorCommitmentRow.id] : []
|
|
4343
|
+
}
|
|
4344
|
+
});
|
|
4345
|
+
const questionsResult = await generateCheckinQuestionsImpl(ctx, recapResult.text, {
|
|
4346
|
+
apiKey: openrouterKey,
|
|
4347
|
+
user: aiUser,
|
|
4348
|
+
sessionId: `weekly-checkin:${row.id}:questions`
|
|
4349
|
+
});
|
|
4350
|
+
return {
|
|
4351
|
+
recapText: recapResult.text,
|
|
4352
|
+
questions: questionsResult.questions,
|
|
4353
|
+
model: recapResult.model,
|
|
4354
|
+
generatedAt: new Date().toISOString(),
|
|
4355
|
+
contextDigest: weeklyCheckinContextDigest(ctx, {
|
|
4356
|
+
priorCommitment: priorCommitmentRow?.commitment ?? null,
|
|
4357
|
+
coachCommitmentIds
|
|
4358
|
+
})
|
|
4359
|
+
};
|
|
4360
|
+
};
|
|
3655
4361
|
|
|
3656
4362
|
if (route.command === 'increment-score-current') {
|
|
3657
4363
|
if (request.method !== 'GET') {
|
|
@@ -3686,6 +4392,12 @@ export function createSyncServiceRequestHandler({
|
|
|
3686
4392
|
if (route.options.toolName === 'get_increment_score') {
|
|
3687
4393
|
await hydrateIncrementScore(body?.historyDays ?? 14);
|
|
3688
4394
|
}
|
|
4395
|
+
const observationOptions = coachToolObservationOptions(route.options.toolName, body ?? {});
|
|
4396
|
+
if (observationOptions) {
|
|
4397
|
+
snapshot.coachObservations = listCurrentCoachObservationsForAccount
|
|
4398
|
+
? await listCurrentCoachObservationsForAccount(account, observationOptions) ?? []
|
|
4399
|
+
: [];
|
|
4400
|
+
}
|
|
3689
4401
|
json(response, 200, queries.executeCoachReadTool(snapshot, route.options.toolName, body ?? {}));
|
|
3690
4402
|
return;
|
|
3691
4403
|
} catch (error) {
|
|
@@ -3844,52 +4556,11 @@ export function createSyncServiceRequestHandler({
|
|
|
3844
4556
|
}
|
|
3845
4557
|
// Lazy-gen path: if scheduled and overdue, attempt to generate now.
|
|
3846
4558
|
if (row.status === 'scheduled' && row.nextRecapDueAt && new Date(row.nextRecapDueAt) <= new Date()) {
|
|
3847
|
-
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
|
3848
4559
|
let recap = null;
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
|
|
3852
|
-
|
|
3853
|
-
// canonical week, matching cron behaviour (onemore-8oc5).
|
|
3854
|
-
const referenceNow = new Date(`${row.weekStartDate}T23:59:59.999Z`);
|
|
3855
|
-
const ctx = weeklyCheckinContext(snapshot, account.id, { now: referenceNow });
|
|
3856
|
-
if (ctx) {
|
|
3857
|
-
let priorCommitmentRow = null;
|
|
3858
|
-
if (listActiveCoachCommitmentsForAccount) {
|
|
3859
|
-
try {
|
|
3860
|
-
const activeCommitments = await listActiveCoachCommitmentsForAccount(account, {
|
|
3861
|
-
limit: 1,
|
|
3862
|
-
weekStartDate: ctx.weekRangeIso?.start
|
|
3863
|
-
});
|
|
3864
|
-
priorCommitmentRow = activeCommitments[0] ?? null;
|
|
3865
|
-
} catch (commitmentErr) {
|
|
3866
|
-
console.error('Lazy weekly-checkin commitment read failed:', commitmentErr.message);
|
|
3867
|
-
}
|
|
3868
|
-
}
|
|
3869
|
-
const recapResult = await generateWeeklyCheckinRecapImpl(ctx, {
|
|
3870
|
-
apiKey: openrouterKey,
|
|
3871
|
-
user: aiUser,
|
|
3872
|
-
sessionId: `weekly-checkin:${row.id}:recap`,
|
|
3873
|
-
priorCommitment: priorCommitmentRow?.commitment ?? null,
|
|
3874
|
-
contextMetadata: {
|
|
3875
|
-
coachCommitmentIds: priorCommitmentRow?.id ? [priorCommitmentRow.id] : []
|
|
3876
|
-
}
|
|
3877
|
-
});
|
|
3878
|
-
const questionsResult = await generateCheckinQuestionsImpl(ctx, recapResult.text, {
|
|
3879
|
-
apiKey: openrouterKey,
|
|
3880
|
-
user: aiUser,
|
|
3881
|
-
sessionId: `weekly-checkin:${row.id}:questions`
|
|
3882
|
-
});
|
|
3883
|
-
recap = {
|
|
3884
|
-
recapText: recapResult.text,
|
|
3885
|
-
questions: questionsResult.questions,
|
|
3886
|
-
model: recapResult.model,
|
|
3887
|
-
generatedAt: new Date().toISOString()
|
|
3888
|
-
};
|
|
3889
|
-
}
|
|
3890
|
-
} catch (genErr) {
|
|
3891
|
-
console.error('Lazy weekly-checkin gen failed:', genErr.message);
|
|
3892
|
-
}
|
|
4560
|
+
try {
|
|
4561
|
+
recap = await generateWeeklyCheckinRecapPayload(row);
|
|
4562
|
+
} catch (genErr) {
|
|
4563
|
+
console.error('Lazy weekly-checkin gen failed:', genErr.message);
|
|
3893
4564
|
}
|
|
3894
4565
|
if (!recap) {
|
|
3895
4566
|
recap = { recapText: 'Your recap is being prepared.', questions: [], placeholder: true, generatedAt: new Date().toISOString() };
|
|
@@ -3907,12 +4578,14 @@ export function createSyncServiceRequestHandler({
|
|
|
3907
4578
|
}
|
|
3908
4579
|
}
|
|
3909
4580
|
}
|
|
4581
|
+
const freshness = await weeklyCheckinFreshnessForRow(row);
|
|
3910
4582
|
json(response, 200, {
|
|
3911
4583
|
id: row.id,
|
|
3912
4584
|
weekStartDate: row.weekStartDate,
|
|
3913
4585
|
status: row.status,
|
|
3914
4586
|
recap: row.recap,
|
|
3915
|
-
conversationId: row.conversationId
|
|
4587
|
+
conversationId: row.conversationId,
|
|
4588
|
+
freshness
|
|
3916
4589
|
});
|
|
3917
4590
|
} catch (err) {
|
|
3918
4591
|
console.error('Weekly check-in current error:', err.message);
|
|
@@ -3969,7 +4642,10 @@ export function createSyncServiceRequestHandler({
|
|
|
3969
4642
|
json(response, 503, { error: 'Weekly check-in not available' });
|
|
3970
4643
|
return;
|
|
3971
4644
|
}
|
|
3972
|
-
|
|
4645
|
+
let body;
|
|
4646
|
+
try { body = await readOptionalJsonBody(request); } catch { badRequest(response, 'Invalid JSON body.'); return; }
|
|
4647
|
+
const refreshIfStale = body?.refreshIfStale === true;
|
|
4648
|
+
let row = await getCurrentWeeklyCheckinForAccount(account);
|
|
3973
4649
|
if (!row) {
|
|
3974
4650
|
notFound(response, 'No weekly check-in scheduled.');
|
|
3975
4651
|
return;
|
|
@@ -3979,9 +4655,9 @@ export function createSyncServiceRequestHandler({
|
|
|
3979
4655
|
return;
|
|
3980
4656
|
}
|
|
3981
4657
|
const conversationId = `weekly-checkin:${row.id}`;
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
4658
|
+
let recapText = String(row.recap.recapText ?? '').trim();
|
|
4659
|
+
let questions = Array.isArray(row.recap.questions) ? row.recap.questions : [];
|
|
4660
|
+
let firstAssistantMessage = [recapText, ...(questions.length ? [questions.map((q, i) => `${i + 1}. ${q}`).join('\n')] : [])].filter(Boolean).join('\n\n');
|
|
3985
4661
|
try {
|
|
3986
4662
|
const { AI_PROMPT_VERSIONS } = await import('./openrouter.js');
|
|
3987
4663
|
const existingConversation = getAskConversationForAccount
|
|
@@ -3997,6 +4673,61 @@ export function createSyncServiceRequestHandler({
|
|
|
3997
4673
|
});
|
|
3998
4674
|
return;
|
|
3999
4675
|
}
|
|
4676
|
+
if (refreshIfStale) {
|
|
4677
|
+
const freshness = await weeklyCheckinFreshnessForRow(row);
|
|
4678
|
+
if (freshness?.state === 'stale' || freshness?.state === 'unknown') {
|
|
4679
|
+
if (!updateWeeklyCheckinRecapForAccount) {
|
|
4680
|
+
json(response, 503, {
|
|
4681
|
+
error: 'Weekly check-in refresh is not available.',
|
|
4682
|
+
code: 'refresh_unavailable'
|
|
4683
|
+
});
|
|
4684
|
+
return;
|
|
4685
|
+
}
|
|
4686
|
+
let recap;
|
|
4687
|
+
try {
|
|
4688
|
+
recap = await generateWeeklyCheckinRecapPayload(row);
|
|
4689
|
+
} catch (refreshErr) {
|
|
4690
|
+
console.error('Weekly check-in refresh failed:', refreshErr.message);
|
|
4691
|
+
if (refreshErr?.code === 'weekly_checkin_refresh_unavailable' ||
|
|
4692
|
+
refreshErr?.code === 'weekly_checkin_context_unavailable') {
|
|
4693
|
+
json(response, 503, {
|
|
4694
|
+
error: 'Weekly check-in refresh is not available.',
|
|
4695
|
+
code: 'refresh_unavailable'
|
|
4696
|
+
});
|
|
4697
|
+
} else {
|
|
4698
|
+
json(response, 502, {
|
|
4699
|
+
error: 'Failed to refresh weekly check-in.',
|
|
4700
|
+
code: 'refresh_failed'
|
|
4701
|
+
});
|
|
4702
|
+
}
|
|
4703
|
+
return;
|
|
4704
|
+
}
|
|
4705
|
+
let updated;
|
|
4706
|
+
try {
|
|
4707
|
+
updated = await updateWeeklyCheckinRecapForAccount(account, row.id, {
|
|
4708
|
+
recap
|
|
4709
|
+
});
|
|
4710
|
+
} catch (refreshUpdateErr) {
|
|
4711
|
+
console.error('Weekly check-in refresh update failed:', refreshUpdateErr.message);
|
|
4712
|
+
json(response, 502, {
|
|
4713
|
+
error: 'Failed to refresh weekly check-in.',
|
|
4714
|
+
code: 'refresh_failed'
|
|
4715
|
+
});
|
|
4716
|
+
return;
|
|
4717
|
+
}
|
|
4718
|
+
if (!updated) {
|
|
4719
|
+
json(response, 409, {
|
|
4720
|
+
error: 'Weekly check-in was not refreshed.',
|
|
4721
|
+
code: 'refresh_conflict'
|
|
4722
|
+
});
|
|
4723
|
+
return;
|
|
4724
|
+
}
|
|
4725
|
+
row = updated;
|
|
4726
|
+
}
|
|
4727
|
+
}
|
|
4728
|
+
recapText = String(row.recap.recapText ?? '').trim();
|
|
4729
|
+
questions = Array.isArray(row.recap.questions) ? row.recap.questions : [];
|
|
4730
|
+
firstAssistantMessage = [recapText, ...(questions.length ? [questions.map((q, i) => `${i + 1}. ${q}`).join('\n')] : [])].filter(Boolean).join('\n\n');
|
|
4000
4731
|
if (row.status !== 'in_progress') {
|
|
4001
4732
|
let openedRow = row;
|
|
4002
4733
|
if (openedRow.status === 'generated' || openedRow.status === 'delivered') {
|
|
@@ -4228,12 +4959,128 @@ export function createSyncServiceRequestHandler({
|
|
|
4228
4959
|
return;
|
|
4229
4960
|
}
|
|
4230
4961
|
|
|
4962
|
+
if (route.command === 'ask-plan') {
|
|
4963
|
+
if (request.method !== 'POST') {
|
|
4964
|
+
methodNotAllowed(response, 'Use POST for /cli/ask/plan.');
|
|
4965
|
+
return;
|
|
4966
|
+
}
|
|
4967
|
+
|
|
4968
|
+
let body;
|
|
4969
|
+
try {
|
|
4970
|
+
body = await readJsonBody(request);
|
|
4971
|
+
} catch {
|
|
4972
|
+
badRequest(response, 'Invalid JSON body.');
|
|
4973
|
+
return;
|
|
4974
|
+
}
|
|
4975
|
+
|
|
4976
|
+
const question = body?.question;
|
|
4977
|
+
if (!question || typeof question !== 'string' || question.trim().length === 0) {
|
|
4978
|
+
badRequest(response, 'question is required');
|
|
4979
|
+
return;
|
|
4980
|
+
}
|
|
4981
|
+
if (question.length > 500) {
|
|
4982
|
+
badRequest(response, 'question must be 500 characters or fewer');
|
|
4983
|
+
return;
|
|
4984
|
+
}
|
|
4985
|
+
|
|
4986
|
+
const exclude = parseExclude(body?.exclude);
|
|
4987
|
+
const conversationId = typeof body?.conversationId === 'string' ? body.conversationId : null;
|
|
4988
|
+
const history = sanitizeHistory(body?.history);
|
|
4989
|
+
const persistedConversation = conversationId && getAskConversationForAccount
|
|
4990
|
+
? await getAskConversationForAccount(account, conversationId)
|
|
4991
|
+
: null;
|
|
4992
|
+
const persistedMessages = Array.isArray(persistedConversation?.messages)
|
|
4993
|
+
? sanitizeHistory(persistedConversation.messages)
|
|
4994
|
+
: null;
|
|
4995
|
+
const canonicalHistory = (persistedMessages?.length ? persistedMessages : null) ?? history;
|
|
4996
|
+
const requestedCoachObservation = normalizeAskCoachObservationFollowUp(body?.coachObservation);
|
|
4997
|
+
const plannedEvidence = planAskEvidence(snapshot, question, {
|
|
4998
|
+
exclude,
|
|
4999
|
+
history: canonicalHistory
|
|
5000
|
+
});
|
|
5001
|
+
let coachFacts = [];
|
|
5002
|
+
if (listCoachFactsForAccount) {
|
|
5003
|
+
try {
|
|
5004
|
+
const kinds = coachFactKindsForAskQuestion(snapshot, question, { history: canonicalHistory });
|
|
5005
|
+
coachFacts = await listCoachFactsForAccount(account, { kinds, limit: 30 });
|
|
5006
|
+
} catch (factErr) {
|
|
5007
|
+
console.error('Coach facts read error (ask plan):', factErr.message);
|
|
5008
|
+
}
|
|
5009
|
+
}
|
|
5010
|
+
if (
|
|
5011
|
+
listScoreSnapshotsForAccount &&
|
|
5012
|
+
(requestedCoachObservation || plannedEvidence.requiredTools.includes('get_increment_score'))
|
|
5013
|
+
) {
|
|
5014
|
+
try {
|
|
5015
|
+
const scoreSnapshots = await listScoreSnapshotsForAccount(account, { limit: 14 }) ?? [];
|
|
5016
|
+
if (scoreSnapshots.length > 0) {
|
|
5017
|
+
const enrichedSnapshots = enrichScoreSnapshots(scoreSnapshots);
|
|
5018
|
+
snapshot.incrementScore = {
|
|
5019
|
+
latest: enrichedSnapshots[0],
|
|
5020
|
+
history: enrichedSnapshots
|
|
5021
|
+
};
|
|
5022
|
+
}
|
|
5023
|
+
} catch (scoreErr) {
|
|
5024
|
+
console.error('Increment Score read error (ask plan):', scoreErr.message);
|
|
5025
|
+
}
|
|
5026
|
+
}
|
|
5027
|
+
let coachObservations = [];
|
|
5028
|
+
if (listCurrentCoachObservationsForAccount && !exclude.has('coach_observations')) {
|
|
5029
|
+
try {
|
|
5030
|
+
coachObservations = await listCurrentCoachObservationsForAccount(account, {
|
|
5031
|
+
limit: requestedCoachObservation ? ASK_REQUESTED_COACH_OBSERVATION_LIMIT : 3,
|
|
5032
|
+
includeOutcomeHistory: true
|
|
5033
|
+
}) ?? [];
|
|
5034
|
+
} catch (observationErr) {
|
|
5035
|
+
console.error('Coach observations read error (ask plan):', observationErr.message);
|
|
5036
|
+
}
|
|
5037
|
+
}
|
|
5038
|
+
snapshot.coachObservations = coachObservations;
|
|
5039
|
+
const coachObservationFollowUp = selectAskCoachObservationFollowUp(requestedCoachObservation, coachObservations);
|
|
5040
|
+
const routedContext = coachObservationFollowUp
|
|
5041
|
+
? askObservationFollowUpContext(snapshot, question, coachObservationFollowUp, {
|
|
5042
|
+
exclude,
|
|
5043
|
+
coachFacts,
|
|
5044
|
+
intent: requestedCoachObservation?.intent
|
|
5045
|
+
})
|
|
5046
|
+
: requestedCoachObservation?.intent === 'successor_plan'
|
|
5047
|
+
? askMissingObservationFollowUpContext(snapshot, question, requestedCoachObservation, {
|
|
5048
|
+
exclude,
|
|
5049
|
+
intent: requestedCoachObservation.intent,
|
|
5050
|
+
today: new Date()
|
|
5051
|
+
})
|
|
5052
|
+
: askRoutedContext(snapshot, question, {
|
|
5053
|
+
exclude,
|
|
5054
|
+
coachFacts,
|
|
5055
|
+
coachObservations,
|
|
5056
|
+
history: canonicalHistory
|
|
5057
|
+
});
|
|
5058
|
+
json(response, 200, {
|
|
5059
|
+
contextBundle: routedContext.contextBundle,
|
|
5060
|
+
metadata: routedContext.metadata
|
|
5061
|
+
});
|
|
5062
|
+
return;
|
|
5063
|
+
}
|
|
5064
|
+
|
|
4231
5065
|
if (route.command === 'ask-ai') {
|
|
4232
5066
|
if (request.method !== 'POST') {
|
|
4233
5067
|
methodNotAllowed(response, 'Use POST for /cli/ask.');
|
|
4234
5068
|
return;
|
|
4235
5069
|
}
|
|
4236
5070
|
|
|
5071
|
+
const writeAccount = connectedWriteAuthenticator
|
|
5072
|
+
? await connectedWriteAuthenticator(requestToken)
|
|
5073
|
+
: requestToken === token
|
|
5074
|
+
? account
|
|
5075
|
+
: null;
|
|
5076
|
+
if (!writeAccount) {
|
|
5077
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
5078
|
+
return;
|
|
5079
|
+
}
|
|
5080
|
+
unauthorized(response, request);
|
|
5081
|
+
return;
|
|
5082
|
+
}
|
|
5083
|
+
|
|
4237
5084
|
let body;
|
|
4238
5085
|
try {
|
|
4239
5086
|
body = await readJsonBody(request);
|
|
@@ -4266,20 +5113,23 @@ export function createSyncServiceRequestHandler({
|
|
|
4266
5113
|
? sanitizeHistory(persistedConversation.messages)
|
|
4267
5114
|
: null;
|
|
4268
5115
|
const canonicalHistory = (persistedMessages?.length ? persistedMessages : null) ?? history;
|
|
5116
|
+
const persistedStorageMessages = Array.isArray(persistedConversation?.messages)
|
|
5117
|
+
? sanitizeAskMessagesForStorage(persistedConversation.messages, { allowStructured: true })
|
|
5118
|
+
: null;
|
|
5119
|
+
const canonicalStorageHistory = (persistedStorageMessages?.length ? persistedStorageMessages : null)
|
|
5120
|
+
?? sanitizeAskMessagesForStorage(body?.history);
|
|
4269
5121
|
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
|
4270
5122
|
if (!openrouterKey) {
|
|
4271
5123
|
json(response, 503, { error: 'AI not configured', code: 'not_configured' });
|
|
4272
5124
|
return;
|
|
4273
5125
|
}
|
|
4274
5126
|
|
|
4275
|
-
const queries = await import('./queries.js');
|
|
4276
5127
|
const exclude = parseExclude(body?.exclude);
|
|
5128
|
+
const requestedCoachObservation = normalizeAskCoachObservationFollowUp(body?.coachObservation);
|
|
4277
5129
|
let coachFacts = [];
|
|
4278
5130
|
if (listCoachFactsForAccount) {
|
|
4279
5131
|
try {
|
|
4280
|
-
const kinds =
|
|
4281
|
-
? queries.coachFactKindsForAskQuestion(snapshot, question)
|
|
4282
|
-
: [];
|
|
5132
|
+
const kinds = coachFactKindsForAskQuestion(snapshot, question, { history: canonicalHistory });
|
|
4283
5133
|
coachFacts = await listCoachFactsForAccount(account, { kinds, limit: 30 });
|
|
4284
5134
|
} catch (factErr) {
|
|
4285
5135
|
console.error('Coach facts read error (ask):', factErr.message);
|
|
@@ -4301,19 +5151,35 @@ export function createSyncServiceRequestHandler({
|
|
|
4301
5151
|
};
|
|
4302
5152
|
}
|
|
4303
5153
|
let coachObservations = [];
|
|
4304
|
-
if (listCurrentCoachObservationsForAccount) {
|
|
5154
|
+
if (listCurrentCoachObservationsForAccount && !exclude.has('coach_observations')) {
|
|
4305
5155
|
try {
|
|
4306
|
-
coachObservations = await listCurrentCoachObservationsForAccount(account, {
|
|
5156
|
+
coachObservations = await listCurrentCoachObservationsForAccount(account, {
|
|
5157
|
+
limit: requestedCoachObservation ? ASK_REQUESTED_COACH_OBSERVATION_LIMIT : 3,
|
|
5158
|
+
includeOutcomeHistory: true
|
|
5159
|
+
}) ?? [];
|
|
4307
5160
|
} catch (observationErr) {
|
|
4308
5161
|
console.error('Coach observations read error (ask):', observationErr.message);
|
|
4309
5162
|
}
|
|
4310
5163
|
}
|
|
4311
|
-
|
|
4312
|
-
const
|
|
4313
|
-
|
|
4314
|
-
|
|
5164
|
+
snapshot.coachObservations = coachObservations;
|
|
5165
|
+
const coachObservationFollowUp = selectAskCoachObservationFollowUp(requestedCoachObservation, coachObservations);
|
|
5166
|
+
const missingRequestedCoachObservation = Boolean(requestedCoachObservation && !coachObservationFollowUp);
|
|
5167
|
+
|
|
5168
|
+
const routedContext = coachObservationFollowUp
|
|
5169
|
+
? askObservationFollowUpContext(snapshot, question, coachObservationFollowUp, {
|
|
5170
|
+
exclude,
|
|
5171
|
+
coachFacts,
|
|
5172
|
+
intent: requestedCoachObservation?.intent
|
|
5173
|
+
})
|
|
5174
|
+
: requestedCoachObservation?.intent === 'successor_plan'
|
|
5175
|
+
? askMissingObservationFollowUpContext(snapshot, question, requestedCoachObservation, {
|
|
5176
|
+
exclude,
|
|
5177
|
+
intent: requestedCoachObservation.intent,
|
|
5178
|
+
today: new Date()
|
|
5179
|
+
})
|
|
5180
|
+
: askRoutedContext(snapshot, question, { exclude, coachFacts, coachObservations, history: canonicalHistory });
|
|
4315
5181
|
const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
|
|
4316
|
-
const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
|
|
5182
|
+
const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots, { question });
|
|
4317
5183
|
|
|
4318
5184
|
const preludes = [incrementScorePrelude].filter(Boolean);
|
|
4319
5185
|
const ctx = preludes.length > 0
|
|
@@ -4323,51 +5189,195 @@ export function createSyncServiceRequestHandler({
|
|
|
4323
5189
|
const askTone = ['default', 'hype', 'numbers-only'].includes(body?.tone) ? body.tone : undefined;
|
|
4324
5190
|
|
|
4325
5191
|
try {
|
|
4326
|
-
const { AI_PROMPT_VERSIONS,
|
|
4327
|
-
const generateAsk = generateAskAnswerImpl ??
|
|
5192
|
+
const { AI_PROMPT_VERSIONS, generateAskAnswerAgentic, SYSTEM_PROMPTS_FOR_LEAK_CHECK, WEEKLY_CHECKIN_PROMPT } = await import('./openrouter.js');
|
|
5193
|
+
const generateAsk = generateAskAnswerImpl ?? generateAskAnswerAgentic;
|
|
4328
5194
|
const systemPromptOverride = persistedKind === 'weekly-checkin' ? WEEKLY_CHECKIN_PROMPT : undefined;
|
|
5195
|
+
const routingMetadata = {
|
|
5196
|
+
...routedContext.metadata,
|
|
5197
|
+
contextCharCount: ctx.length,
|
|
5198
|
+
historyTurnCount: canonicalHistory.filter((m) => m.role === 'user').length,
|
|
5199
|
+
coachFactsIncluded: (routedContext.metadata?.includedCoachFactIds ?? []).length > 0,
|
|
5200
|
+
coachFactIds: routedContext.metadata?.includedCoachFactIds ?? [],
|
|
5201
|
+
coachFactKinds: routedContext.metadata?.coachFactKinds ?? [],
|
|
5202
|
+
coachObservationIds: routedContext.metadata?.includedCoachObservationIds ?? [],
|
|
5203
|
+
requestedCoachObservationId: requestedCoachObservation?.id ?? undefined,
|
|
5204
|
+
requestedCoachObservationIntent: requestedCoachObservation?.intent ?? undefined,
|
|
5205
|
+
coachObservationFollowUpMissing: missingRequestedCoachObservation || undefined
|
|
5206
|
+
};
|
|
4329
5207
|
|
|
4330
|
-
const
|
|
4331
|
-
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
|
|
4335
|
-
|
|
4336
|
-
|
|
4337
|
-
|
|
4338
|
-
|
|
4339
|
-
|
|
4340
|
-
|
|
4341
|
-
|
|
4342
|
-
|
|
4343
|
-
coachFactKinds: routedContext.metadata?.coachFactKinds ?? [],
|
|
4344
|
-
coachObservationIds: routedContext.metadata?.includedCoachObservationIds ?? []
|
|
4345
|
-
}
|
|
4346
|
-
});
|
|
5208
|
+
const generateAttempt = async (contextForGeneration, { maxSteps } = {}) => {
|
|
5209
|
+
const result = await generateAsk(contextForGeneration, question, {
|
|
5210
|
+
apiKey: openrouterKey,
|
|
5211
|
+
history: canonicalHistory,
|
|
5212
|
+
tone: askTone,
|
|
5213
|
+
user: aiUser,
|
|
5214
|
+
sessionId: `ask:${conversationId}`,
|
|
5215
|
+
systemPrompt: systemPromptOverride,
|
|
5216
|
+
routingMetadata,
|
|
5217
|
+
snapshot,
|
|
5218
|
+
exclude: [...exclude],
|
|
5219
|
+
...(Number.isInteger(maxSteps) ? { maxSteps } : {})
|
|
5220
|
+
});
|
|
4347
5221
|
|
|
4348
|
-
|
|
4349
|
-
|
|
4350
|
-
|
|
4351
|
-
|
|
5222
|
+
// Fold any tools the agent fetched dynamically into provenance so
|
|
5223
|
+
// structured evidence and verifier replay reflect what was actually
|
|
5224
|
+
// read, not just the warm-start route plan.
|
|
5225
|
+
mergeAgenticToolProvenance(routingMetadata, result.toolInvocations);
|
|
5226
|
+
|
|
5227
|
+
const parsed = extractAskProgramDraft(result.text, {
|
|
5228
|
+
canonicalizeExerciseName: canonicalExerciseName
|
|
5229
|
+
});
|
|
5230
|
+
const changesetParsed = extractPlanChangeset(parsed.answerText);
|
|
5231
|
+
const requestedPlanAdjustment = requestedCoachObservation?.intent === 'plan_adjustment';
|
|
5232
|
+
const draft = missingRequestedCoachObservation && requestedCoachObservation?.intent === 'successor_plan'
|
|
5233
|
+
? undefined
|
|
5234
|
+
: parsed.programDraft;
|
|
5235
|
+
// Drop a changeset if the requested observation is missing/stale — same
|
|
5236
|
+
// guard as successor_plan: no live observation, no plan edits.
|
|
5237
|
+
const changeset = !requestedPlanAdjustment || missingRequestedCoachObservation
|
|
5238
|
+
? undefined
|
|
5239
|
+
: changesetParsed.planChangeset;
|
|
5240
|
+
const answer = stripXMLTagBlocks(changesetParsed.answerText);
|
|
5241
|
+
return { askResult: result, programDraft: draft, planChangeset: changeset, assistantAnswer: answer };
|
|
5242
|
+
};
|
|
5243
|
+
|
|
5244
|
+
let attempt = await generateAttempt(ctx);
|
|
5245
|
+
let programDraftRetryCount = 0;
|
|
5246
|
+
let planChangesetRetryCount = 0;
|
|
5247
|
+
if (
|
|
5248
|
+
shouldRequireProgramDraftForAsk({
|
|
5249
|
+
persistedKind,
|
|
5250
|
+
requestedCoachObservation,
|
|
5251
|
+
missingRequestedCoachObservation
|
|
5252
|
+
}) &&
|
|
5253
|
+
!attempt.programDraft
|
|
5254
|
+
) {
|
|
5255
|
+
programDraftRetryCount = 1;
|
|
5256
|
+
const draftRepairContext = buildMissingProgramDraftRepairContext(ctx);
|
|
5257
|
+
const draftRepairAttempt = await generateAttempt(draftRepairContext, { maxSteps: 2 });
|
|
5258
|
+
if (draftRepairAttempt.programDraft || !attempt.assistantAnswer) {
|
|
5259
|
+
attempt = draftRepairAttempt;
|
|
5260
|
+
}
|
|
5261
|
+
}
|
|
5262
|
+
if (
|
|
5263
|
+
shouldRequirePlanChangesetForAsk({
|
|
5264
|
+
persistedKind,
|
|
5265
|
+
requestedCoachObservation,
|
|
5266
|
+
missingRequestedCoachObservation
|
|
5267
|
+
}) &&
|
|
5268
|
+
!attempt.planChangeset
|
|
5269
|
+
) {
|
|
5270
|
+
planChangesetRetryCount = 1;
|
|
5271
|
+
const changesetRepairContext = buildMissingPlanChangesetRepairContext(ctx);
|
|
5272
|
+
const changesetRepairAttempt = await generateAttempt(changesetRepairContext, { maxSteps: 2 });
|
|
5273
|
+
if (changesetRepairAttempt.planChangeset || !attempt.assistantAnswer) {
|
|
5274
|
+
attempt = changesetRepairAttempt;
|
|
5275
|
+
}
|
|
5276
|
+
}
|
|
4352
5277
|
// Check for system prompt leakage before persisting. We inspect only
|
|
4353
5278
|
// the user-visible prose, not the structured draft payload, so valid
|
|
4354
5279
|
// <program_draft> output does not false-positive as a prompt leak.
|
|
4355
|
-
|
|
4356
|
-
if (detectSystemPromptLeak(assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
|
|
5280
|
+
if (detectSystemPromptLeak(attempt.assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
|
|
4357
5281
|
console.error('SECURITY: System prompt leak detected in ask-ai response, blocking');
|
|
4358
5282
|
onError?.(new Error('System prompt leak detected in AI response'), { feature: 'ask-coach', security: true });
|
|
4359
|
-
json(response, 200, { answer: 'I can only answer questions about your training. Could you rephrase?', model: askResult.model, filtered: true });
|
|
5283
|
+
json(response, 200, { answer: 'I can only answer questions about your training. Could you rephrase?', model: attempt.askResult.model, filtered: true });
|
|
4360
5284
|
return;
|
|
4361
5285
|
}
|
|
4362
5286
|
|
|
4363
|
-
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
|
|
4367
|
-
|
|
5287
|
+
let verification = persistedKind === 'ask'
|
|
5288
|
+
? verifyAskAnswer({
|
|
5289
|
+
answer: attempt.assistantAnswer,
|
|
5290
|
+
snapshot,
|
|
5291
|
+
routingMetadata,
|
|
5292
|
+
today: new Date(),
|
|
5293
|
+
exclude: [...exclude]
|
|
5294
|
+
})
|
|
5295
|
+
: null;
|
|
5296
|
+
let verificationRetryCount = 0;
|
|
5297
|
+
let verificationRepaired = false;
|
|
5298
|
+
let verificationFallback = false;
|
|
5299
|
+
|
|
5300
|
+
if (persistedKind === 'ask' && shouldRepairAskAnswer(verification)) {
|
|
5301
|
+
verificationRetryCount = 1;
|
|
5302
|
+
const repairContext = buildAskAnswerRepairContext(ctx, attempt.assistantAnswer, verification);
|
|
5303
|
+
const repairAttempt = await generateAttempt(repairContext, { maxSteps: 2 });
|
|
5304
|
+
if (detectSystemPromptLeak(repairAttempt.assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
|
|
5305
|
+
console.error('SECURITY: System prompt leak detected in ask-ai repair response, blocking');
|
|
5306
|
+
onError?.(new Error('System prompt leak detected in AI repair response'), { feature: 'ask-coach', security: true });
|
|
5307
|
+
json(response, 200, { answer: 'I can only answer questions about your training. Could you rephrase?', model: repairAttempt.askResult.model, filtered: true });
|
|
5308
|
+
return;
|
|
5309
|
+
}
|
|
5310
|
+
const repairVerification = verifyAskAnswer({
|
|
5311
|
+
answer: repairAttempt.assistantAnswer,
|
|
5312
|
+
snapshot,
|
|
5313
|
+
routingMetadata,
|
|
5314
|
+
today: new Date(),
|
|
5315
|
+
exclude: [...exclude]
|
|
5316
|
+
});
|
|
5317
|
+
if (repairVerification.blockingFailureCount <= verification.blockingFailureCount) {
|
|
5318
|
+
attempt = repairAttempt;
|
|
5319
|
+
verification = repairVerification;
|
|
5320
|
+
verificationRepaired = repairVerification.passed;
|
|
5321
|
+
}
|
|
5322
|
+
}
|
|
5323
|
+
|
|
5324
|
+
if (persistedKind === 'ask' && shouldRepairAskAnswer(verification)) {
|
|
5325
|
+
verificationFallback = true;
|
|
5326
|
+
attempt = {
|
|
5327
|
+
...attempt,
|
|
5328
|
+
assistantAnswer: safeAskVerificationFallback(),
|
|
5329
|
+
programDraft: undefined,
|
|
5330
|
+
planChangeset: undefined
|
|
5331
|
+
};
|
|
5332
|
+
}
|
|
5333
|
+
|
|
5334
|
+
const answerVerification = persistedKind === 'ask'
|
|
5335
|
+
? askVerificationMetadata(verification, {
|
|
5336
|
+
retryCount: verificationRetryCount,
|
|
5337
|
+
repaired: verificationRepaired,
|
|
5338
|
+
fallback: verificationFallback
|
|
5339
|
+
})
|
|
5340
|
+
: undefined;
|
|
5341
|
+
if (answerVerification) {
|
|
5342
|
+
routingMetadata.answerVerification = answerVerification;
|
|
5343
|
+
}
|
|
5344
|
+
if (verificationFallback && onError) {
|
|
5345
|
+
const warning = new Error('Ask Coach answer verification fallback');
|
|
5346
|
+
warning.level = 'warning';
|
|
5347
|
+
onError(warning, {
|
|
5348
|
+
feature: 'ask-coach',
|
|
5349
|
+
verification: answerVerification
|
|
5350
|
+
});
|
|
5351
|
+
}
|
|
5352
|
+
|
|
5353
|
+
const { askResult, assistantAnswer, programDraft, planChangeset } = attempt;
|
|
5354
|
+
const promptSurface = askResult.promptSurface
|
|
5355
|
+
?? (persistedKind === 'weekly-checkin' ? 'weekly-checkin' : 'ask');
|
|
5356
|
+
const promptVersion = askResult.promptVersion
|
|
5357
|
+
?? (persistedKind === 'weekly-checkin' ? AI_PROMPT_VERSIONS.weeklyCheckin : AI_PROMPT_VERSIONS.askAgentic);
|
|
5358
|
+
const metadata = {
|
|
5359
|
+
...buildAIGenerationMetadata(promptSurface, askResult.model, promptVersion, {
|
|
5360
|
+
...askResult,
|
|
5361
|
+
routingMetadata
|
|
5362
|
+
}),
|
|
5363
|
+
routing: routingMetadata,
|
|
5364
|
+
...(programDraftRetryCount > 0 ? { programDraftRetryCount } : {}),
|
|
5365
|
+
...(planChangesetRetryCount > 0 ? { planChangesetRetryCount } : {})
|
|
5366
|
+
};
|
|
5367
|
+
const structured = buildAskStructuredResponse(assistantAnswer, routingMetadata, { programDraft, planChangeset, question });
|
|
5368
|
+
console.log(`ask-coach-meta ${JSON.stringify(buildAskInteractionLogPayload({
|
|
5369
|
+
accountId: account.id,
|
|
5370
|
+
status: 'ok',
|
|
5371
|
+
promptSurface,
|
|
5372
|
+
routingMetadata,
|
|
5373
|
+
askResult,
|
|
5374
|
+
structured
|
|
5375
|
+
}))}`);
|
|
5376
|
+
const updatedMessages = sanitizeAskMessagesForStorage([
|
|
5377
|
+
...canonicalStorageHistory,
|
|
4368
5378
|
{ role: 'user', content: question },
|
|
4369
|
-
{ role: 'assistant', content: assistantAnswer }
|
|
4370
|
-
];
|
|
5379
|
+
{ role: 'assistant', content: assistantAnswer, structured }
|
|
5380
|
+
], { allowStructured: true });
|
|
4371
5381
|
if (saveAskConversationForAccount) {
|
|
4372
5382
|
try {
|
|
4373
5383
|
await saveAskConversationForAccount(account, {
|
|
@@ -4455,7 +5465,9 @@ export function createSyncServiceRequestHandler({
|
|
|
4455
5465
|
answer: assistantAnswer,
|
|
4456
5466
|
model: askResult.model,
|
|
4457
5467
|
metadata,
|
|
4458
|
-
|
|
5468
|
+
structured,
|
|
5469
|
+
programDraft,
|
|
5470
|
+
planChangeset
|
|
4459
5471
|
});
|
|
4460
5472
|
} catch (err) {
|
|
4461
5473
|
console.error('AI ask error:', err.message);
|
|
@@ -4703,6 +5715,10 @@ export function createSyncServiceRequestHandler({
|
|
|
4703
5715
|
return;
|
|
4704
5716
|
}
|
|
4705
5717
|
const report = await social.getUserReport(account.id, route.options.accountId);
|
|
5718
|
+
if (report?.error === 'forbidden') {
|
|
5719
|
+
json(response, 403, { ok: false, error: 'Access denied' });
|
|
5720
|
+
return;
|
|
5721
|
+
}
|
|
4706
5722
|
if (!report) {
|
|
4707
5723
|
notFound(response, 'User report not found.');
|
|
4708
5724
|
return;
|