incremnt 0.7.2 → 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/contract.js +160 -3
- package/src/format.js +24 -1
- package/src/lib.js +205 -17
- package/src/mcp.js +88 -24
- package/src/openrouter.js +242 -19
- package/src/plan-changeset.js +132 -0
- package/src/program-draft.js +230 -0
- package/src/prompt-changelog.js +90 -0
- package/src/promptfoo-evals.js +10 -4
- package/src/promptfoo-langfuse-scores.js +55 -0
- package/src/queries.js +992 -987
- 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 +677 -42
- package/src/sync-service.js +1259 -352
- 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,24 @@ 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
|
+
|
|
693
897
|
function coachObservationSourceTriggerForScoreSnapshots(snapshots) {
|
|
694
898
|
if (!Array.isArray(snapshots)) return undefined;
|
|
695
899
|
const reasons = snapshots.map((snapshot) => snapshot?.triggerReason);
|
|
@@ -717,7 +921,12 @@ function coachObservationGenerationMetadata(result, sourceTrigger) {
|
|
|
717
921
|
function normalizeAskCoachObservationFollowUp(value) {
|
|
718
922
|
if (!value || typeof value !== 'object') return null;
|
|
719
923
|
const id = String(value.id ?? '').trim();
|
|
720
|
-
|
|
924
|
+
if (!id) return null;
|
|
925
|
+
const intent = normalizeObservationFollowUpIntent(value.intent);
|
|
926
|
+
return {
|
|
927
|
+
id: id.slice(0, 128),
|
|
928
|
+
...(intent ? { intent } : {})
|
|
929
|
+
};
|
|
721
930
|
}
|
|
722
931
|
|
|
723
932
|
function selectAskCoachObservationFollowUp(requested, observations) {
|
|
@@ -726,6 +935,8 @@ function selectAskCoachObservationFollowUp(requested, observations) {
|
|
|
726
935
|
.find((observation) => String(observation?.id ?? '') === requested.id);
|
|
727
936
|
}
|
|
728
937
|
|
|
938
|
+
const ASK_REQUESTED_COACH_OBSERVATION_LIMIT = 20;
|
|
939
|
+
|
|
729
940
|
function routeRequest(url, method) {
|
|
730
941
|
const pathname = url.pathname;
|
|
731
942
|
|
|
@@ -797,6 +1008,22 @@ function routeRequest(url, method) {
|
|
|
797
1008
|
return { command: 'contract', options: {} };
|
|
798
1009
|
}
|
|
799
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
|
+
|
|
800
1027
|
if (pathname === '/sync/snapshot') {
|
|
801
1028
|
return { command: 'sync-upload', options: {} };
|
|
802
1029
|
}
|
|
@@ -944,11 +1171,34 @@ function routeRequest(url, method) {
|
|
|
944
1171
|
return { command: 'program-detail', options: {} };
|
|
945
1172
|
}
|
|
946
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
|
+
|
|
947
1185
|
const programShowMatch = pathname.match(/^\/cli\/programs\/([^/]+)$/);
|
|
948
1186
|
if (programShowMatch) {
|
|
949
1187
|
return { command: 'program-detail', options: { id: programShowMatch[1] } };
|
|
950
1188
|
}
|
|
951
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
|
+
|
|
952
1202
|
if (pathname === '/cli/exercises/history') {
|
|
953
1203
|
return {
|
|
954
1204
|
command: 'exercise-history',
|
|
@@ -980,6 +1230,16 @@ function routeRequest(url, method) {
|
|
|
980
1230
|
};
|
|
981
1231
|
}
|
|
982
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
|
+
|
|
983
1243
|
const cyclesShowMatch = pathname.match(/^\/cli\/cycles\/([^/]+)$/);
|
|
984
1244
|
if (cyclesShowMatch) {
|
|
985
1245
|
return { command: 'cycle-summary-show', options: { id: decodeURIComponent(cyclesShowMatch[1]) } };
|
|
@@ -1064,6 +1324,10 @@ function routeRequest(url, method) {
|
|
|
1064
1324
|
return { command: 'ask-ai', options: {} };
|
|
1065
1325
|
}
|
|
1066
1326
|
|
|
1327
|
+
if (pathname === '/cli/ask/plan') {
|
|
1328
|
+
return { command: 'ask-plan', options: {} };
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1067
1331
|
if (pathname === '/cli/ask/history') {
|
|
1068
1332
|
return { command: 'ask-history', options: { limit: url.searchParams.get('limit') ?? undefined } };
|
|
1069
1333
|
}
|
|
@@ -1110,7 +1374,7 @@ function routeRequest(url, method) {
|
|
|
1110
1374
|
}
|
|
1111
1375
|
|
|
1112
1376
|
{
|
|
1113
|
-
const coachObservationActionMatch = pathname.match(/^\/cli\/coach-observations\/([^/]+)\/(seen|dismiss)$/);
|
|
1377
|
+
const coachObservationActionMatch = pathname.match(/^\/cli\/coach-observations\/([^/]+)\/(seen|dismiss|outcome|feedback)$/);
|
|
1114
1378
|
if (coachObservationActionMatch) {
|
|
1115
1379
|
return {
|
|
1116
1380
|
command: `coach-observations-${coachObservationActionMatch[2]}`,
|
|
@@ -1141,6 +1405,15 @@ function routeRequest(url, method) {
|
|
|
1141
1405
|
return { command: 'training-load', options: {} };
|
|
1142
1406
|
}
|
|
1143
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
|
+
|
|
1144
1417
|
// --- Social endpoints ---
|
|
1145
1418
|
if (pathname === '/cli/social/me/profile') {
|
|
1146
1419
|
return { command: 'social-me-profile', options: {} };
|
|
@@ -1505,55 +1778,10 @@ function routeRequest(url, method) {
|
|
|
1505
1778
|
return null;
|
|
1506
1779
|
}
|
|
1507
1780
|
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
const lines = ['[Increment Score]'];
|
|
1514
|
-
const tier = latest.dataTier ? ` · ${latest.dataTier}` : '';
|
|
1515
|
-
lines.push(`- Current: ${latest.score}/100${tier}`);
|
|
1516
|
-
|
|
1517
|
-
if (latest.components && typeof latest.components === 'object') {
|
|
1518
|
-
const parts = [];
|
|
1519
|
-
for (const [name, value] of Object.entries(latest.components)) {
|
|
1520
|
-
const num = typeof value === 'number' ? value : value?.score;
|
|
1521
|
-
if (typeof num === 'number') parts.push(`${name} ${num}`);
|
|
1522
|
-
}
|
|
1523
|
-
if (parts.length > 0) lines.push(`- Components: ${parts.join(', ')}`);
|
|
1524
|
-
}
|
|
1525
|
-
|
|
1526
|
-
const driverLabels = (list) => {
|
|
1527
|
-
if (!Array.isArray(list) || list.length === 0) return null;
|
|
1528
|
-
return list
|
|
1529
|
-
.slice(0, 3)
|
|
1530
|
-
.map((d) => d?.label ?? d?.id ?? d?.driver)
|
|
1531
|
-
.filter(Boolean)
|
|
1532
|
-
.join('; ');
|
|
1533
|
-
};
|
|
1534
|
-
const positives = driverLabels(latest.topPositiveDrivers);
|
|
1535
|
-
if (positives) lines.push(`- Top positive drivers: ${positives}`);
|
|
1536
|
-
const negatives = driverLabels(latest.topNegativeDrivers);
|
|
1537
|
-
if (negatives) lines.push(`- Top negative drivers: ${negatives}`);
|
|
1538
|
-
|
|
1539
|
-
if (snapshots.length > 1) {
|
|
1540
|
-
const prior = snapshots[1];
|
|
1541
|
-
if (typeof prior?.score === 'number') {
|
|
1542
|
-
const delta = latest.score - prior.score;
|
|
1543
|
-
const sign = delta > 0 ? '+' : '';
|
|
1544
|
-
lines.push(`- Day-over-day delta: ${sign}${delta}`);
|
|
1545
|
-
}
|
|
1546
|
-
const recent = snapshots
|
|
1547
|
-
.slice(0, 7)
|
|
1548
|
-
.map((s) => (typeof s?.score === 'number' ? s.score : null))
|
|
1549
|
-
.filter((s) => s != null);
|
|
1550
|
-
if (recent.length >= 3) {
|
|
1551
|
-
lines.push(`- Last ${recent.length} days: ${recent.join(', ')}`);
|
|
1552
|
-
}
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
return lines.join('\n');
|
|
1556
|
-
}
|
|
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 };
|
|
1557
1785
|
|
|
1558
1786
|
async function readJsonBody(request) {
|
|
1559
1787
|
const chunks = [];
|
|
@@ -1979,7 +2207,8 @@ export function syncServiceContractPayload({
|
|
|
1979
2207
|
tokenBootstrap: true,
|
|
1980
2208
|
deviceFlow: false,
|
|
1981
2209
|
browserApproval: false,
|
|
1982
|
-
devEmail: false
|
|
2210
|
+
devEmail: false,
|
|
2211
|
+
agentTokens: false
|
|
1983
2212
|
},
|
|
1984
2213
|
providers = {
|
|
1985
2214
|
apple: {
|
|
@@ -2042,6 +2271,10 @@ export function createSyncServiceRequestHandler({
|
|
|
2042
2271
|
completeGoogleWebAuth = null,
|
|
2043
2272
|
completeGoogleMobileAuth = null,
|
|
2044
2273
|
refreshSession,
|
|
2274
|
+
issueAgentToken,
|
|
2275
|
+
listAgentTokens,
|
|
2276
|
+
revokeAgentToken,
|
|
2277
|
+
authenticateAgentTokenManagement,
|
|
2045
2278
|
authenticateConnectedWriteToken,
|
|
2046
2279
|
allowManualDeviceApproval = false,
|
|
2047
2280
|
rateLimitConfig = null,
|
|
@@ -2067,6 +2300,7 @@ export function createSyncServiceRequestHandler({
|
|
|
2067
2300
|
getCurrentWeeklyCheckinForAccount = null,
|
|
2068
2301
|
upsertScheduledWeeklyCheckinForAccount = null,
|
|
2069
2302
|
transitionWeeklyCheckinForAccount = null,
|
|
2303
|
+
updateWeeklyCheckinRecapForAccount = null,
|
|
2070
2304
|
generateWeeklyCheckinRecapImpl = null,
|
|
2071
2305
|
generateCheckinQuestionsImpl = null,
|
|
2072
2306
|
saveAIFeedbackForAccount = null,
|
|
@@ -2083,10 +2317,21 @@ export function createSyncServiceRequestHandler({
|
|
|
2083
2317
|
listCurrentCoachObservationsForAccount = null,
|
|
2084
2318
|
markCoachObservationSeenForAccount = null,
|
|
2085
2319
|
dismissCoachObservationForAccount = null,
|
|
2320
|
+
recordCoachObservationOutcomeForAccount = null,
|
|
2321
|
+
recordCoachObservationFeedbackForAccount = null,
|
|
2086
2322
|
// Social
|
|
2087
2323
|
social = null,
|
|
2088
2324
|
onError = null
|
|
2089
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
|
+
|
|
2090
2335
|
const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
|
|
2091
2336
|
|
|
2092
2337
|
return async function handle(request, response) {
|
|
@@ -2883,6 +3128,101 @@ export function createSyncServiceRequestHandler({
|
|
|
2883
3128
|
const writeAuthenticator = authenticateWriteToken ?? authenticateToken;
|
|
2884
3129
|
const connectedWriteAuthenticator = authenticateConnectedWriteToken ?? readAuthenticator;
|
|
2885
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;
|
|
3136
|
+
|
|
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.');
|
|
3140
|
+
return;
|
|
3141
|
+
}
|
|
3142
|
+
|
|
3143
|
+
if (!requestToken) {
|
|
3144
|
+
unauthorized(response, request);
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3147
|
+
|
|
3148
|
+
const managementAccount = agentTokenManagementAuthenticator
|
|
3149
|
+
? await agentTokenManagementAuthenticator(requestToken)
|
|
3150
|
+
: null;
|
|
3151
|
+
if (!managementAccount) {
|
|
3152
|
+
unauthorized(response, request);
|
|
3153
|
+
return;
|
|
3154
|
+
}
|
|
3155
|
+
|
|
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
|
+
});
|
|
3187
|
+
return;
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
if (route.command === 'agent-token') {
|
|
3191
|
+
if (request.method !== 'DELETE') {
|
|
3192
|
+
methodNotAllowed(response, 'Use DELETE for /cli/agent-tokens/:id.');
|
|
3193
|
+
return;
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
if (!requestToken) {
|
|
3197
|
+
unauthorized(response, request);
|
|
3198
|
+
return;
|
|
3199
|
+
}
|
|
3200
|
+
|
|
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)
|
|
3208
|
+
: null;
|
|
3209
|
+
if (!managementAccount) {
|
|
3210
|
+
unauthorized(response, request);
|
|
3211
|
+
return;
|
|
3212
|
+
}
|
|
3213
|
+
|
|
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
|
+
}
|
|
2886
3226
|
|
|
2887
3227
|
if (route.command === 'mobile-sync-bootstrap') {
|
|
2888
3228
|
if (request.method !== 'GET') {
|
|
@@ -2929,19 +3269,21 @@ export function createSyncServiceRequestHandler({
|
|
|
2929
3269
|
}
|
|
2930
3270
|
|
|
2931
3271
|
if (route.command === 'score-snapshots') {
|
|
2932
|
-
const account = connectedWriteAuthenticator
|
|
2933
|
-
? await connectedWriteAuthenticator(requestToken)
|
|
2934
|
-
: null;
|
|
2935
|
-
if (!account) {
|
|
2936
|
-
unauthorized(response, request);
|
|
2937
|
-
return;
|
|
2938
|
-
}
|
|
2939
|
-
|
|
2940
3272
|
if (request.method === 'POST') {
|
|
2941
3273
|
if (!insertScoreSnapshotsForAccount) {
|
|
2942
3274
|
methodNotAllowed(response, 'Score snapshot upload is not enabled for this service mode.');
|
|
2943
3275
|
return;
|
|
2944
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
|
+
}
|
|
2945
3287
|
try {
|
|
2946
3288
|
const body = await readJsonBody(request);
|
|
2947
3289
|
if (!body || typeof body !== 'object' || !Array.isArray(body.snapshots)) {
|
|
@@ -2974,6 +3316,13 @@ export function createSyncServiceRequestHandler({
|
|
|
2974
3316
|
}
|
|
2975
3317
|
|
|
2976
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
|
+
}
|
|
2977
3326
|
if (!listScoreSnapshotsForAccount) {
|
|
2978
3327
|
methodNotAllowed(response, 'Score snapshot history is not enabled for this service mode.');
|
|
2979
3328
|
return;
|
|
@@ -3044,6 +3393,19 @@ export function createSyncServiceRequestHandler({
|
|
|
3044
3393
|
return;
|
|
3045
3394
|
}
|
|
3046
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
|
+
|
|
3047
3409
|
try {
|
|
3048
3410
|
await deleteAccountForUser(deleteAccount.id);
|
|
3049
3411
|
logRequest(request, 200);
|
|
@@ -3068,6 +3430,9 @@ export function createSyncServiceRequestHandler({
|
|
|
3068
3430
|
? { id: 'remote-user', email: null }
|
|
3069
3431
|
: null;
|
|
3070
3432
|
if (!proposalAccount) {
|
|
3433
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
3434
|
+
return;
|
|
3435
|
+
}
|
|
3071
3436
|
unauthorized(response, request);
|
|
3072
3437
|
return;
|
|
3073
3438
|
}
|
|
@@ -3136,6 +3501,9 @@ export function createSyncServiceRequestHandler({
|
|
|
3136
3501
|
? { id: 'remote-user', email: null }
|
|
3137
3502
|
: null;
|
|
3138
3503
|
if (!proposalAccount) {
|
|
3504
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
3505
|
+
return;
|
|
3506
|
+
}
|
|
3139
3507
|
unauthorized(response, request);
|
|
3140
3508
|
return;
|
|
3141
3509
|
}
|
|
@@ -3174,6 +3542,9 @@ export function createSyncServiceRequestHandler({
|
|
|
3174
3542
|
? await connectedWriteAuthenticator(requestToken)
|
|
3175
3543
|
: null;
|
|
3176
3544
|
if (!account) {
|
|
3545
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
3546
|
+
return;
|
|
3547
|
+
}
|
|
3177
3548
|
unauthorized(response, request);
|
|
3178
3549
|
return;
|
|
3179
3550
|
}
|
|
@@ -3258,6 +3629,9 @@ export function createSyncServiceRequestHandler({
|
|
|
3258
3629
|
? await connectedWriteAuthenticator(requestToken)
|
|
3259
3630
|
: null;
|
|
3260
3631
|
if (!account) {
|
|
3632
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
3633
|
+
return;
|
|
3634
|
+
}
|
|
3261
3635
|
unauthorized(response, request);
|
|
3262
3636
|
return;
|
|
3263
3637
|
}
|
|
@@ -3299,7 +3673,8 @@ export function createSyncServiceRequestHandler({
|
|
|
3299
3673
|
tokenBootstrap: Boolean(issueSession || token),
|
|
3300
3674
|
deviceFlow: Boolean(issueDeviceChallenge && consumeDeviceChallenge),
|
|
3301
3675
|
browserApproval: Boolean(approveDeviceChallenge),
|
|
3302
|
-
devEmail: Boolean(issueDevLogin)
|
|
3676
|
+
devEmail: Boolean(issueDevLogin),
|
|
3677
|
+
agentTokens: Boolean(issueAgentToken && listAgentTokens && revokeAgentToken)
|
|
3303
3678
|
}
|
|
3304
3679
|
}));
|
|
3305
3680
|
return;
|
|
@@ -3626,6 +4001,9 @@ export function createSyncServiceRequestHandler({
|
|
|
3626
4001
|
? account
|
|
3627
4002
|
: null;
|
|
3628
4003
|
if (!writeAccount) {
|
|
4004
|
+
if (await rejectInsufficientScopeForReadToken(response, request, requestToken, readAuthenticator)) {
|
|
4005
|
+
return;
|
|
4006
|
+
}
|
|
3629
4007
|
unauthorized(response, request);
|
|
3630
4008
|
return;
|
|
3631
4009
|
}
|
|
@@ -3680,6 +4058,18 @@ export function createSyncServiceRequestHandler({
|
|
|
3680
4058
|
json(response, 503, { error: 'Coach observations not available' });
|
|
3681
4059
|
return;
|
|
3682
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
|
+
}
|
|
3683
4073
|
let body = {};
|
|
3684
4074
|
try {
|
|
3685
4075
|
body = await readOptionalJsonBody(request);
|
|
@@ -3693,7 +4083,7 @@ export function createSyncServiceRequestHandler({
|
|
|
3693
4083
|
return;
|
|
3694
4084
|
}
|
|
3695
4085
|
try {
|
|
3696
|
-
const observation = await markCoachObservationSeenForAccount(
|
|
4086
|
+
const observation = await markCoachObservationSeenForAccount(writeAccount, route.options.id, { seenAt });
|
|
3697
4087
|
if (!observation) {
|
|
3698
4088
|
notFound(response, 'Observation not found');
|
|
3699
4089
|
return;
|
|
@@ -3715,6 +4105,18 @@ export function createSyncServiceRequestHandler({
|
|
|
3715
4105
|
json(response, 503, { error: 'Coach observations not available' });
|
|
3716
4106
|
return;
|
|
3717
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
|
+
}
|
|
3718
4120
|
try {
|
|
3719
4121
|
await readOptionalJsonBody(request);
|
|
3720
4122
|
} catch {
|
|
@@ -3722,7 +4124,7 @@ export function createSyncServiceRequestHandler({
|
|
|
3722
4124
|
return;
|
|
3723
4125
|
}
|
|
3724
4126
|
try {
|
|
3725
|
-
const observation = await dismissCoachObservationForAccount(
|
|
4127
|
+
const observation = await dismissCoachObservationForAccount(writeAccount, route.options.id, {});
|
|
3726
4128
|
if (!observation) {
|
|
3727
4129
|
notFound(response, 'Observation not found');
|
|
3728
4130
|
return;
|
|
@@ -3735,6 +4137,126 @@ export function createSyncServiceRequestHandler({
|
|
|
3735
4137
|
return;
|
|
3736
4138
|
}
|
|
3737
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
|
+
|
|
3738
4260
|
let snapshot;
|
|
3739
4261
|
try {
|
|
3740
4262
|
snapshot = loadSnapshotForAccount
|
|
@@ -3757,6 +4279,85 @@ export function createSyncServiceRequestHandler({
|
|
|
3757
4279
|
};
|
|
3758
4280
|
}
|
|
3759
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
|
+
};
|
|
3760
4361
|
|
|
3761
4362
|
if (route.command === 'increment-score-current') {
|
|
3762
4363
|
if (request.method !== 'GET') {
|
|
@@ -3791,6 +4392,12 @@ export function createSyncServiceRequestHandler({
|
|
|
3791
4392
|
if (route.options.toolName === 'get_increment_score') {
|
|
3792
4393
|
await hydrateIncrementScore(body?.historyDays ?? 14);
|
|
3793
4394
|
}
|
|
4395
|
+
const observationOptions = coachToolObservationOptions(route.options.toolName, body ?? {});
|
|
4396
|
+
if (observationOptions) {
|
|
4397
|
+
snapshot.coachObservations = listCurrentCoachObservationsForAccount
|
|
4398
|
+
? await listCurrentCoachObservationsForAccount(account, observationOptions) ?? []
|
|
4399
|
+
: [];
|
|
4400
|
+
}
|
|
3794
4401
|
json(response, 200, queries.executeCoachReadTool(snapshot, route.options.toolName, body ?? {}));
|
|
3795
4402
|
return;
|
|
3796
4403
|
} catch (error) {
|
|
@@ -3949,52 +4556,11 @@ export function createSyncServiceRequestHandler({
|
|
|
3949
4556
|
}
|
|
3950
4557
|
// Lazy-gen path: if scheduled and overdue, attempt to generate now.
|
|
3951
4558
|
if (row.status === 'scheduled' && row.nextRecapDueAt && new Date(row.nextRecapDueAt) <= new Date()) {
|
|
3952
|
-
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
|
3953
4559
|
let recap = null;
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
// canonical week, matching cron behaviour (onemore-8oc5).
|
|
3959
|
-
const referenceNow = new Date(`${row.weekStartDate}T23:59:59.999Z`);
|
|
3960
|
-
const ctx = weeklyCheckinContext(snapshot, account.id, { now: referenceNow });
|
|
3961
|
-
if (ctx) {
|
|
3962
|
-
let priorCommitmentRow = null;
|
|
3963
|
-
if (listActiveCoachCommitmentsForAccount) {
|
|
3964
|
-
try {
|
|
3965
|
-
const activeCommitments = await listActiveCoachCommitmentsForAccount(account, {
|
|
3966
|
-
limit: 1,
|
|
3967
|
-
weekStartDate: ctx.weekRangeIso?.start
|
|
3968
|
-
});
|
|
3969
|
-
priorCommitmentRow = activeCommitments[0] ?? null;
|
|
3970
|
-
} catch (commitmentErr) {
|
|
3971
|
-
console.error('Lazy weekly-checkin commitment read failed:', commitmentErr.message);
|
|
3972
|
-
}
|
|
3973
|
-
}
|
|
3974
|
-
const recapResult = await generateWeeklyCheckinRecapImpl(ctx, {
|
|
3975
|
-
apiKey: openrouterKey,
|
|
3976
|
-
user: aiUser,
|
|
3977
|
-
sessionId: `weekly-checkin:${row.id}:recap`,
|
|
3978
|
-
priorCommitment: priorCommitmentRow?.commitment ?? null,
|
|
3979
|
-
contextMetadata: {
|
|
3980
|
-
coachCommitmentIds: priorCommitmentRow?.id ? [priorCommitmentRow.id] : []
|
|
3981
|
-
}
|
|
3982
|
-
});
|
|
3983
|
-
const questionsResult = await generateCheckinQuestionsImpl(ctx, recapResult.text, {
|
|
3984
|
-
apiKey: openrouterKey,
|
|
3985
|
-
user: aiUser,
|
|
3986
|
-
sessionId: `weekly-checkin:${row.id}:questions`
|
|
3987
|
-
});
|
|
3988
|
-
recap = {
|
|
3989
|
-
recapText: recapResult.text,
|
|
3990
|
-
questions: questionsResult.questions,
|
|
3991
|
-
model: recapResult.model,
|
|
3992
|
-
generatedAt: new Date().toISOString()
|
|
3993
|
-
};
|
|
3994
|
-
}
|
|
3995
|
-
} catch (genErr) {
|
|
3996
|
-
console.error('Lazy weekly-checkin gen failed:', genErr.message);
|
|
3997
|
-
}
|
|
4560
|
+
try {
|
|
4561
|
+
recap = await generateWeeklyCheckinRecapPayload(row);
|
|
4562
|
+
} catch (genErr) {
|
|
4563
|
+
console.error('Lazy weekly-checkin gen failed:', genErr.message);
|
|
3998
4564
|
}
|
|
3999
4565
|
if (!recap) {
|
|
4000
4566
|
recap = { recapText: 'Your recap is being prepared.', questions: [], placeholder: true, generatedAt: new Date().toISOString() };
|
|
@@ -4012,12 +4578,14 @@ export function createSyncServiceRequestHandler({
|
|
|
4012
4578
|
}
|
|
4013
4579
|
}
|
|
4014
4580
|
}
|
|
4581
|
+
const freshness = await weeklyCheckinFreshnessForRow(row);
|
|
4015
4582
|
json(response, 200, {
|
|
4016
4583
|
id: row.id,
|
|
4017
4584
|
weekStartDate: row.weekStartDate,
|
|
4018
4585
|
status: row.status,
|
|
4019
4586
|
recap: row.recap,
|
|
4020
|
-
conversationId: row.conversationId
|
|
4587
|
+
conversationId: row.conversationId,
|
|
4588
|
+
freshness
|
|
4021
4589
|
});
|
|
4022
4590
|
} catch (err) {
|
|
4023
4591
|
console.error('Weekly check-in current error:', err.message);
|
|
@@ -4074,7 +4642,10 @@ export function createSyncServiceRequestHandler({
|
|
|
4074
4642
|
json(response, 503, { error: 'Weekly check-in not available' });
|
|
4075
4643
|
return;
|
|
4076
4644
|
}
|
|
4077
|
-
|
|
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);
|
|
4078
4649
|
if (!row) {
|
|
4079
4650
|
notFound(response, 'No weekly check-in scheduled.');
|
|
4080
4651
|
return;
|
|
@@ -4084,9 +4655,9 @@ export function createSyncServiceRequestHandler({
|
|
|
4084
4655
|
return;
|
|
4085
4656
|
}
|
|
4086
4657
|
const conversationId = `weekly-checkin:${row.id}`;
|
|
4087
|
-
|
|
4088
|
-
|
|
4089
|
-
|
|
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');
|
|
4090
4661
|
try {
|
|
4091
4662
|
const { AI_PROMPT_VERSIONS } = await import('./openrouter.js');
|
|
4092
4663
|
const existingConversation = getAskConversationForAccount
|
|
@@ -4102,6 +4673,61 @@ export function createSyncServiceRequestHandler({
|
|
|
4102
4673
|
});
|
|
4103
4674
|
return;
|
|
4104
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');
|
|
4105
4731
|
if (row.status !== 'in_progress') {
|
|
4106
4732
|
let openedRow = row;
|
|
4107
4733
|
if (openedRow.status === 'generated' || openedRow.status === 'delivered') {
|
|
@@ -4333,12 +4959,128 @@ export function createSyncServiceRequestHandler({
|
|
|
4333
4959
|
return;
|
|
4334
4960
|
}
|
|
4335
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
|
+
|
|
4336
5065
|
if (route.command === 'ask-ai') {
|
|
4337
5066
|
if (request.method !== 'POST') {
|
|
4338
5067
|
methodNotAllowed(response, 'Use POST for /cli/ask.');
|
|
4339
5068
|
return;
|
|
4340
5069
|
}
|
|
4341
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
|
+
|
|
4342
5084
|
let body;
|
|
4343
5085
|
try {
|
|
4344
5086
|
body = await readJsonBody(request);
|
|
@@ -4371,21 +5113,23 @@ export function createSyncServiceRequestHandler({
|
|
|
4371
5113
|
? sanitizeHistory(persistedConversation.messages)
|
|
4372
5114
|
: null;
|
|
4373
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);
|
|
4374
5121
|
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
|
4375
5122
|
if (!openrouterKey) {
|
|
4376
5123
|
json(response, 503, { error: 'AI not configured', code: 'not_configured' });
|
|
4377
5124
|
return;
|
|
4378
5125
|
}
|
|
4379
5126
|
|
|
4380
|
-
const queries = await import('./queries.js');
|
|
4381
5127
|
const exclude = parseExclude(body?.exclude);
|
|
4382
5128
|
const requestedCoachObservation = normalizeAskCoachObservationFollowUp(body?.coachObservation);
|
|
4383
5129
|
let coachFacts = [];
|
|
4384
5130
|
if (listCoachFactsForAccount) {
|
|
4385
5131
|
try {
|
|
4386
|
-
const kinds =
|
|
4387
|
-
? queries.coachFactKindsForAskQuestion(snapshot, question)
|
|
4388
|
-
: [];
|
|
5132
|
+
const kinds = coachFactKindsForAskQuestion(snapshot, question, { history: canonicalHistory });
|
|
4389
5133
|
coachFacts = await listCoachFactsForAccount(account, { kinds, limit: 30 });
|
|
4390
5134
|
} catch (factErr) {
|
|
4391
5135
|
console.error('Coach facts read error (ask):', factErr.message);
|
|
@@ -4407,22 +5151,35 @@ export function createSyncServiceRequestHandler({
|
|
|
4407
5151
|
};
|
|
4408
5152
|
}
|
|
4409
5153
|
let coachObservations = [];
|
|
4410
|
-
if (listCurrentCoachObservationsForAccount) {
|
|
5154
|
+
if (listCurrentCoachObservationsForAccount && !exclude.has('coach_observations')) {
|
|
4411
5155
|
try {
|
|
4412
|
-
coachObservations = await listCurrentCoachObservationsForAccount(account, {
|
|
5156
|
+
coachObservations = await listCurrentCoachObservationsForAccount(account, {
|
|
5157
|
+
limit: requestedCoachObservation ? ASK_REQUESTED_COACH_OBSERVATION_LIMIT : 3,
|
|
5158
|
+
includeOutcomeHistory: true
|
|
5159
|
+
}) ?? [];
|
|
4413
5160
|
} catch (observationErr) {
|
|
4414
5161
|
console.error('Coach observations read error (ask):', observationErr.message);
|
|
4415
5162
|
}
|
|
4416
5163
|
}
|
|
5164
|
+
snapshot.coachObservations = coachObservations;
|
|
4417
5165
|
const coachObservationFollowUp = selectAskCoachObservationFollowUp(requestedCoachObservation, coachObservations);
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
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 });
|
|
4424
5181
|
const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
|
|
4425
|
-
const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
|
|
5182
|
+
const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots, { question });
|
|
4426
5183
|
|
|
4427
5184
|
const preludes = [incrementScorePrelude].filter(Boolean);
|
|
4428
5185
|
const ctx = preludes.length > 0
|
|
@@ -4432,51 +5189,195 @@ export function createSyncServiceRequestHandler({
|
|
|
4432
5189
|
const askTone = ['default', 'hype', 'numbers-only'].includes(body?.tone) ? body.tone : undefined;
|
|
4433
5190
|
|
|
4434
5191
|
try {
|
|
4435
|
-
const { AI_PROMPT_VERSIONS,
|
|
4436
|
-
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;
|
|
4437
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
|
+
};
|
|
4438
5207
|
|
|
4439
|
-
const
|
|
4440
|
-
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
coachFactKinds: routedContext.metadata?.coachFactKinds ?? [],
|
|
4453
|
-
coachObservationIds: routedContext.metadata?.includedCoachObservationIds ?? []
|
|
4454
|
-
}
|
|
4455
|
-
});
|
|
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
|
+
});
|
|
4456
5221
|
|
|
4457
|
-
|
|
4458
|
-
|
|
4459
|
-
|
|
4460
|
-
|
|
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
|
+
}
|
|
4461
5277
|
// Check for system prompt leakage before persisting. We inspect only
|
|
4462
5278
|
// the user-visible prose, not the structured draft payload, so valid
|
|
4463
5279
|
// <program_draft> output does not false-positive as a prompt leak.
|
|
4464
|
-
|
|
4465
|
-
if (detectSystemPromptLeak(assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
|
|
5280
|
+
if (detectSystemPromptLeak(attempt.assistantAnswer, SYSTEM_PROMPTS_FOR_LEAK_CHECK)) {
|
|
4466
5281
|
console.error('SECURITY: System prompt leak detected in ask-ai response, blocking');
|
|
4467
5282
|
onError?.(new Error('System prompt leak detected in AI response'), { feature: 'ask-coach', security: true });
|
|
4468
|
-
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 });
|
|
4469
5284
|
return;
|
|
4470
5285
|
}
|
|
4471
5286
|
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
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,
|
|
4477
5378
|
{ role: 'user', content: question },
|
|
4478
|
-
{ role: 'assistant', content: assistantAnswer }
|
|
4479
|
-
];
|
|
5379
|
+
{ role: 'assistant', content: assistantAnswer, structured }
|
|
5380
|
+
], { allowStructured: true });
|
|
4480
5381
|
if (saveAskConversationForAccount) {
|
|
4481
5382
|
try {
|
|
4482
5383
|
await saveAskConversationForAccount(account, {
|
|
@@ -4564,7 +5465,9 @@ export function createSyncServiceRequestHandler({
|
|
|
4564
5465
|
answer: assistantAnswer,
|
|
4565
5466
|
model: askResult.model,
|
|
4566
5467
|
metadata,
|
|
4567
|
-
|
|
5468
|
+
structured,
|
|
5469
|
+
programDraft,
|
|
5470
|
+
planChangeset
|
|
4568
5471
|
});
|
|
4569
5472
|
} catch (err) {
|
|
4570
5473
|
console.error('AI ask error:', err.message);
|
|
@@ -4812,6 +5715,10 @@ export function createSyncServiceRequestHandler({
|
|
|
4812
5715
|
return;
|
|
4813
5716
|
}
|
|
4814
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
|
+
}
|
|
4815
5722
|
if (!report) {
|
|
4816
5723
|
notFound(response, 'User report not found.');
|
|
4817
5724
|
return;
|