incremnt 0.6.0 → 0.7.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 +0 -3
- package/SKILL.md +57 -0
- package/package.json +4 -2
- package/src/browse.js +7 -1
- package/src/contract.js +57 -21
- package/src/fields.js +41 -0
- package/src/format.js +8 -2
- package/src/lib.js +116 -5
- package/src/mcp.js +60 -4
- package/src/openrouter.js +2 -70
- package/src/queries.js +344 -190
- package/src/remote.js +103 -12
- package/src/summary-evals.js +40 -1
- package/src/sync-service.js +48 -41
- package/src/validate.js +152 -0
package/src/remote.js
CHANGED
|
@@ -110,17 +110,17 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
|
|
|
110
110
|
return sessionsUrl;
|
|
111
111
|
}
|
|
112
112
|
case 'session-show':
|
|
113
|
-
return resolveServiceUrl(baseUrl, `/cli/sessions/${options.id}`);
|
|
113
|
+
return resolveServiceUrl(baseUrl, `/cli/sessions/${encodeURIComponent(options.id)}`);
|
|
114
114
|
case 'planned-vs-actual':
|
|
115
|
-
return resolveServiceUrl(baseUrl, `/cli/sessions/${options['session-id']}/compare`);
|
|
115
|
+
return resolveServiceUrl(baseUrl, `/cli/sessions/${encodeURIComponent(options['session-id'])}/compare`);
|
|
116
116
|
case 'why-did-this-change':
|
|
117
|
-
return resolveServiceUrl(baseUrl, `/cli/sessions/${options['session-id']}/explain`);
|
|
117
|
+
return resolveServiceUrl(baseUrl, `/cli/sessions/${encodeURIComponent(options['session-id'])}/explain`);
|
|
118
118
|
case 'program-list':
|
|
119
119
|
return resolveServiceUrl(baseUrl, '/cli/programs');
|
|
120
120
|
case 'program-summary':
|
|
121
121
|
return resolveServiceUrl(baseUrl, '/cli/programs/current');
|
|
122
122
|
case 'program-detail':
|
|
123
|
-
return resolveServiceUrl(baseUrl, options.id ? `/cli/programs/${options.id}` : '/cli/programs/active');
|
|
123
|
+
return resolveServiceUrl(baseUrl, options.id ? `/cli/programs/${encodeURIComponent(options.id)}` : '/cli/programs/active');
|
|
124
124
|
case 'cycle-summary-list': {
|
|
125
125
|
const cyclesUrl = resolveServiceUrl(baseUrl, '/cli/cycles');
|
|
126
126
|
if (options['program-id']) {
|
|
@@ -129,7 +129,7 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
|
|
|
129
129
|
return cyclesUrl;
|
|
130
130
|
}
|
|
131
131
|
case 'cycle-summary-show':
|
|
132
|
-
return resolveServiceUrl(baseUrl, `/cli/cycles/${options.id}`);
|
|
132
|
+
return resolveServiceUrl(baseUrl, `/cli/cycles/${encodeURIComponent(options.id)}`);
|
|
133
133
|
case 'exercise-history': {
|
|
134
134
|
const historyUrl = resolveServiceUrl(baseUrl, '/cli/exercises/history');
|
|
135
135
|
historyUrl.searchParams.set('name', options.name ?? options.exercise);
|
|
@@ -140,7 +140,7 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
|
|
|
140
140
|
case 'goals-list':
|
|
141
141
|
return resolveServiceUrl(baseUrl, '/cli/goals');
|
|
142
142
|
case 'goals-show':
|
|
143
|
-
return resolveServiceUrl(baseUrl, options.id ? `/cli/goals/${options.id}` : '/cli/goals');
|
|
143
|
+
return resolveServiceUrl(baseUrl, options.id ? `/cli/goals/${encodeURIComponent(options.id)}` : '/cli/goals');
|
|
144
144
|
case 'health-summary': {
|
|
145
145
|
const healthUrl = resolveServiceUrl(baseUrl, '/cli/health/summary');
|
|
146
146
|
if (options.days) {
|
|
@@ -160,9 +160,9 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
|
|
|
160
160
|
return askUrl;
|
|
161
161
|
}
|
|
162
162
|
case 'ask-show':
|
|
163
|
-
return resolveServiceUrl(baseUrl, `/cli/ask/history/${options.id}`);
|
|
163
|
+
return resolveServiceUrl(baseUrl, `/cli/ask/history/${encodeURIComponent(options.id)}`);
|
|
164
164
|
case 'program-share-fetch':
|
|
165
|
-
return resolveServiceUrl(baseUrl, `/program-share/${options.token}`);
|
|
165
|
+
return resolveServiceUrl(baseUrl, `/program-share/${encodeURIComponent(options.token)}`);
|
|
166
166
|
case 'increment-score-current': {
|
|
167
167
|
const url = resolveServiceUrl(baseUrl, '/cli/increment-score/current');
|
|
168
168
|
if (options.historyDays) url.searchParams.set('historyDays', options.historyDays);
|
|
@@ -238,6 +238,97 @@ async function executeRemoteCoachReadTool(toolName, input, sessionState) {
|
|
|
238
238
|
return executeLocalCoachReadTool(snapshot, toolName, input);
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
+
// Build the shape of a write request without executing it.
|
|
242
|
+
// Returns { method, url, body }. body is a parsed JS object or null.
|
|
243
|
+
// Used by the --dry-run path. Keep endpoint and body changes in sync with the
|
|
244
|
+
// real write handlers below; the drift test in dry-run.test.js asserts both
|
|
245
|
+
// paths emit the same { method, url, body }.
|
|
246
|
+
export async function buildWriteRequest(commandId, options, sessionState) {
|
|
247
|
+
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
248
|
+
if (!baseUrl) {
|
|
249
|
+
const error = new Error('--dry-run requires a remote session. Run `incremnt login` first.');
|
|
250
|
+
error.code = 'REMOTE_NOT_IMPLEMENTED';
|
|
251
|
+
throw error;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
switch (commandId) {
|
|
255
|
+
case 'programs-propose': {
|
|
256
|
+
if (!options.file) {
|
|
257
|
+
const error = new Error('--file is required for programs propose.');
|
|
258
|
+
error.code = 'MISSING_OPTION';
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
const raw = await fs.readFile(options.file, 'utf8');
|
|
262
|
+
const proposal = JSON.parse(raw);
|
|
263
|
+
return {
|
|
264
|
+
method: 'POST',
|
|
265
|
+
url: resolveServiceUrl(baseUrl, '/cli/programs/proposals').toString(),
|
|
266
|
+
body: proposal
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
case 'proposal-dismiss': {
|
|
270
|
+
if (!options.id) {
|
|
271
|
+
const error = new Error('--id is required for proposal dismiss.');
|
|
272
|
+
error.code = 'MISSING_OPTION';
|
|
273
|
+
throw error;
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
method: 'PATCH',
|
|
277
|
+
url: resolveServiceUrl(baseUrl, `/cli/programs/proposals/${encodeURIComponent(options.id)}`).toString(),
|
|
278
|
+
body: { status: 'dismissed' }
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
case 'program-share-create': {
|
|
282
|
+
if (!options['program-id']) {
|
|
283
|
+
const error = new Error('--program-id is required for programs share create.');
|
|
284
|
+
error.code = 'MISSING_OPTION';
|
|
285
|
+
throw error;
|
|
286
|
+
}
|
|
287
|
+
return {
|
|
288
|
+
method: 'POST',
|
|
289
|
+
url: resolveServiceUrl(baseUrl, `/cli/programs/${encodeURIComponent(options['program-id'])}/share`).toString(),
|
|
290
|
+
body: null
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
case 'program-share-revoke': {
|
|
294
|
+
if (!options['share-id']) {
|
|
295
|
+
const error = new Error('--share-id is required for programs share revoke.');
|
|
296
|
+
error.code = 'MISSING_OPTION';
|
|
297
|
+
throw error;
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
method: 'POST',
|
|
301
|
+
url: resolveServiceUrl(baseUrl, `/cli/program-share/${encodeURIComponent(options['share-id'])}/revoke`).toString(),
|
|
302
|
+
body: null
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
case 'increment-score-upload': {
|
|
306
|
+
if (!options.file) {
|
|
307
|
+
const error = new Error('--file is required for increment-score upload.');
|
|
308
|
+
error.code = 'MISSING_OPTION';
|
|
309
|
+
throw error;
|
|
310
|
+
}
|
|
311
|
+
const raw = await fs.readFile(options.file, 'utf8');
|
|
312
|
+
const body = JSON.parse(raw);
|
|
313
|
+
if (!body || !Array.isArray(body.snapshots)) {
|
|
314
|
+
const error = new Error('Invalid file: expected an object with a snapshots array.');
|
|
315
|
+
error.code = 'INVALID_PAYLOAD';
|
|
316
|
+
throw error;
|
|
317
|
+
}
|
|
318
|
+
return {
|
|
319
|
+
method: 'POST',
|
|
320
|
+
url: resolveServiceUrl(baseUrl, '/mobile/score-snapshots').toString(),
|
|
321
|
+
body
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
default: {
|
|
325
|
+
const error = new Error(`Command ${commandId} does not support --dry-run.`);
|
|
326
|
+
error.code = 'UNSUPPORTED_DRY_RUN';
|
|
327
|
+
throw error;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
241
332
|
const remoteWriteCommandHandlers = {
|
|
242
333
|
'programs-propose': async (options, sessionState) => {
|
|
243
334
|
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
@@ -310,7 +401,7 @@ const remoteWriteCommandHandlers = {
|
|
|
310
401
|
throw error;
|
|
311
402
|
}
|
|
312
403
|
|
|
313
|
-
const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/proposals/${options.id}`);
|
|
404
|
+
const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/proposals/${encodeURIComponent(options.id)}`);
|
|
314
405
|
const response = await fetch(endpoint, {
|
|
315
406
|
method: 'PATCH',
|
|
316
407
|
headers: {
|
|
@@ -345,7 +436,7 @@ const remoteWriteCommandHandlers = {
|
|
|
345
436
|
throw error;
|
|
346
437
|
}
|
|
347
438
|
|
|
348
|
-
const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/${options['program-id']}/share`);
|
|
439
|
+
const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/${encodeURIComponent(options['program-id'])}/share`);
|
|
349
440
|
const response = await fetch(endpoint, {
|
|
350
441
|
method: 'POST',
|
|
351
442
|
headers: {
|
|
@@ -377,7 +468,7 @@ const remoteWriteCommandHandlers = {
|
|
|
377
468
|
throw error;
|
|
378
469
|
}
|
|
379
470
|
|
|
380
|
-
const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/${options['program-id']}/shares`);
|
|
471
|
+
const endpoint = resolveServiceUrl(baseUrl, `/cli/programs/${encodeURIComponent(options['program-id'])}/shares`);
|
|
381
472
|
const response = await fetch(endpoint, {
|
|
382
473
|
headers: {
|
|
383
474
|
Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
|
|
@@ -441,7 +532,7 @@ const remoteWriteCommandHandlers = {
|
|
|
441
532
|
throw error;
|
|
442
533
|
}
|
|
443
534
|
|
|
444
|
-
const endpoint = resolveServiceUrl(baseUrl, `/cli/program-share/${options['share-id']}/revoke`);
|
|
535
|
+
const endpoint = resolveServiceUrl(baseUrl, `/cli/program-share/${encodeURIComponent(options['share-id'])}/revoke`);
|
|
445
536
|
const response = await fetch(endpoint, {
|
|
446
537
|
method: 'POST',
|
|
447
538
|
headers: {
|
package/src/summary-evals.js
CHANGED
|
@@ -733,6 +733,45 @@ function hasFatigueLanguage(output) {
|
|
|
733
733
|
return /\b(fatigue|fatigued|underrecovered|recovery debt|fatigue ceiling|limited by recovery|limited by fatigue|accumulated fatigue)\b/i.test(output);
|
|
734
734
|
}
|
|
735
735
|
|
|
736
|
+
function hasAskFatigueRecoveryLanguage(output) {
|
|
737
|
+
return hasFatigueLanguage(output)
|
|
738
|
+
|| /\b(?:poor|low|bad|incomplete)\s+recovery\b/i.test(output)
|
|
739
|
+
|| /\bunder[-\s]?recovery\b/i.test(output)
|
|
740
|
+
|| /\brecovery\s+(?:limited|held back|caused|explains|drove|deficit|issue|problem)\b/i.test(output);
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
function hasAskFatigueRecoveryUncertaintyLanguage(output) {
|
|
744
|
+
const missingRecoveryData = /\b(?:no|not enough|without|missing|lack(?:ing)?|insufficient)\s+(?:\w+\s+){0,4}?(?:recovery|readiness|vitals?|sleep|hrv|heart rate|data|info|signals?|metrics?)\b/i.test(output);
|
|
745
|
+
const refusesInference = /\b(?:cannot|can't|do not|don't|does not|doesn't|would not|wouldn't|not enough|isn't enough|is not enough|no basis to|hard to)\s+(?:\w+\s+){0,12}?(?:infer|tie|connect|attribute|blame|claim|say|show|prove|know|call)\s+(?:\w+\s+){0,12}?(?:fatigue|recovery|readiness|why)\b/i.test(output);
|
|
746
|
+
const recoveryDoesNotExplain = /\b(?:fatigue|recovery|readiness)\b\s+(?:\w+\s+){0,10}?(?:cannot|can't|does not|doesn't|would not|wouldn't|isn't|is not)\s+(?:\w+\s+){0,10}?(?:explain|prove|show|tell|account for)\b/i.test(output);
|
|
747
|
+
return missingRecoveryData || refusesInference || recoveryDoesNotExplain;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
function hasAskPositiveFatigueRecoveryAttribution(output) {
|
|
751
|
+
const concept = String.raw`(?:fatigue|fatigued|under[-\s]?recovered|under[-\s]?recovery|poor recovery|low recovery|incomplete recovery|recovery debt|fatigue ceiling|accumulated fatigue)`;
|
|
752
|
+
const causeVerb = String.raw`(?:because|due to|caused by|from|reflects?|suggests?|indicates?|points? to|explains?|limited|held back|drove|contributed to|tied to|tie\s+\w+\s+to)`;
|
|
753
|
+
const patterns = [
|
|
754
|
+
new RegExp(String.raw`\b${causeVerb}\b.{0,80}\b${concept}\b`, 'gi'),
|
|
755
|
+
new RegExp(String.raw`\b${concept}\b.{0,80}\b(?:caused|limited|held back|explains?|drove|led to|contributed to|accounts? for)\b`, 'gi')
|
|
756
|
+
];
|
|
757
|
+
for (const pattern of patterns) {
|
|
758
|
+
for (const match of output.matchAll(pattern)) {
|
|
759
|
+
const start = Math.max(0, (match.index ?? 0) - 40);
|
|
760
|
+
const window = output.slice(start, (match.index ?? 0) + match[0].length);
|
|
761
|
+
if (!/\b(?:not|no|cannot|can't|doesn't|does not|would not|wouldn't|isn't|is not)\b/i.test(window)) {
|
|
762
|
+
return true;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
return false;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function hasUnsupportedAskFatigueRecoveryClaim(output) {
|
|
770
|
+
if (!hasAskFatigueRecoveryLanguage(output)) return false;
|
|
771
|
+
if (hasAskPositiveFatigueRecoveryAttribution(output)) return true;
|
|
772
|
+
return !hasAskFatigueRecoveryUncertaintyLanguage(output);
|
|
773
|
+
}
|
|
774
|
+
|
|
736
775
|
function matchesHistoricalFamilyName(claimName, actualName) {
|
|
737
776
|
const claimVariants = new Set(historicalExerciseVariants(claimName));
|
|
738
777
|
const actualVariants = new Set(historicalExerciseVariants(actualName));
|
|
@@ -1182,7 +1221,7 @@ function evaluateAskClaims(output, snapshot, testCase) {
|
|
|
1182
1221
|
}
|
|
1183
1222
|
}
|
|
1184
1223
|
|
|
1185
|
-
if (
|
|
1224
|
+
if (hasUnsupportedAskFatigueRecoveryClaim(normalized) && !hasAskFatigueSupport(snapshot)) {
|
|
1186
1225
|
failures.push('Ask answer uses fatigue/recovery language but the snapshot has no recent vitals, sleep, or rep-dropoff signals to support it.');
|
|
1187
1226
|
}
|
|
1188
1227
|
|
package/src/sync-service.js
CHANGED
|
@@ -21,6 +21,7 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
21
21
|
'weekly-checkin-current': 30,
|
|
22
22
|
'weekly-checkin-ack': 30,
|
|
23
23
|
'weekly-checkin-start': 10,
|
|
24
|
+
'weekly-digest-current': 30,
|
|
24
25
|
'dev-login': 10,
|
|
25
26
|
'device-start': 20,
|
|
26
27
|
'device-poll': 300,
|
|
@@ -1050,6 +1051,10 @@ function routeRequest(url, method) {
|
|
|
1050
1051
|
return { command: 'weekly-checkin-start', options: {} };
|
|
1051
1052
|
}
|
|
1052
1053
|
|
|
1054
|
+
if (pathname === '/cli/weekly-digest/current') {
|
|
1055
|
+
return { command: 'weekly-digest-current', options: {} };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1053
1058
|
if (pathname === '/cli/health/ai') {
|
|
1054
1059
|
return {
|
|
1055
1060
|
command: 'health-ai',
|
|
@@ -1434,41 +1439,6 @@ function routeRequest(url, method) {
|
|
|
1434
1439
|
return null;
|
|
1435
1440
|
}
|
|
1436
1441
|
|
|
1437
|
-
/// Formats a `ProgramPhaseWindowContext` (sent by iOS in the request body) as
|
|
1438
|
-
/// a short text prelude prepended to the AI context. Without this the model
|
|
1439
|
-
/// would have to infer "is this a deload week / was last week deload?" from
|
|
1440
|
-
/// session prose; with it the structured phase facts are explicit.
|
|
1441
|
-
function formatProgramPhasePrelude(programPhase) {
|
|
1442
|
-
if (!programPhase || typeof programPhase !== 'object') return null;
|
|
1443
|
-
const current = programPhase.current;
|
|
1444
|
-
const previous = programPhase.previousWeek;
|
|
1445
|
-
const next = programPhase.nextWeek;
|
|
1446
|
-
if (!current?.phase || typeof current.displayWeek !== 'number') return null;
|
|
1447
|
-
const describe = (phase) => {
|
|
1448
|
-
if (!phase?.phase) return null;
|
|
1449
|
-
const week = typeof phase.displayWeek === 'number' ? `week ${phase.displayWeek}` : 'week ?';
|
|
1450
|
-
return `${week} (${phase.phase})${phase.isDeload ? ' · deload week' : ''}`;
|
|
1451
|
-
};
|
|
1452
|
-
const describeList = (phases) => {
|
|
1453
|
-
if (!Array.isArray(phases) || phases.length === 0) return null;
|
|
1454
|
-
return phases.map(describe).filter(Boolean).join(', ');
|
|
1455
|
-
};
|
|
1456
|
-
const lines = [
|
|
1457
|
-
'[Program phase]',
|
|
1458
|
-
`- Current: ${describe(current)}`
|
|
1459
|
-
];
|
|
1460
|
-
if (previous?.phase) lines.push(`- Previous: ${describe(previous)}`);
|
|
1461
|
-
if (next?.phase) lines.push(`- Next: ${describe(next)}`);
|
|
1462
|
-
if (programPhase.isPostDeloadReturn === true) {
|
|
1463
|
-
lines.push('- Post-deload return: yes (last week was deload, this week is build)');
|
|
1464
|
-
}
|
|
1465
|
-
const range = describeList(programPhase.phasesInRange);
|
|
1466
|
-
if (range) lines.push(`- Range phases: ${range}`);
|
|
1467
|
-
const previousRange = describeList(programPhase.previousRangePhases);
|
|
1468
|
-
if (previousRange) lines.push(`- Previous range phases: ${previousRange}`);
|
|
1469
|
-
return lines.join('\n');
|
|
1470
|
-
}
|
|
1471
|
-
|
|
1472
1442
|
export function formatIncrementScorePrelude(snapshots) {
|
|
1473
1443
|
if (!Array.isArray(snapshots) || snapshots.length === 0) return null;
|
|
1474
1444
|
const latest = snapshots[0];
|
|
@@ -2021,6 +1991,7 @@ export function createSyncServiceRequestHandler({
|
|
|
2021
1991
|
pushMobileSyncChangesForAccount = null,
|
|
2022
1992
|
insertScoreSnapshotsForAccount = null,
|
|
2023
1993
|
listScoreSnapshotsForAccount = null,
|
|
1994
|
+
getCurrentWeeklyScoreDigestForAccount = null,
|
|
2024
1995
|
// Social
|
|
2025
1996
|
social = null,
|
|
2026
1997
|
onError = null
|
|
@@ -3488,6 +3459,43 @@ export function createSyncServiceRequestHandler({
|
|
|
3488
3459
|
return;
|
|
3489
3460
|
}
|
|
3490
3461
|
|
|
3462
|
+
if (route.command === 'weekly-digest-current') {
|
|
3463
|
+
if (request.method !== 'GET') {
|
|
3464
|
+
methodNotAllowed(response, 'Use GET for /cli/weekly-digest/current.');
|
|
3465
|
+
return;
|
|
3466
|
+
}
|
|
3467
|
+
if (!getCurrentWeeklyScoreDigestForAccount) {
|
|
3468
|
+
json(response, 503, { error: 'Weekly digest not available' });
|
|
3469
|
+
return;
|
|
3470
|
+
}
|
|
3471
|
+
try {
|
|
3472
|
+
const row = await getCurrentWeeklyScoreDigestForAccount(account);
|
|
3473
|
+
if (!row) {
|
|
3474
|
+
response.writeHead(204);
|
|
3475
|
+
response.end();
|
|
3476
|
+
return;
|
|
3477
|
+
}
|
|
3478
|
+
json(response, 200, {
|
|
3479
|
+
id: row.id,
|
|
3480
|
+
weekStartDate: row.weekStartDate,
|
|
3481
|
+
status: row.status,
|
|
3482
|
+
score: row.score,
|
|
3483
|
+
scoreDelta: row.scoreDelta,
|
|
3484
|
+
components: row.components,
|
|
3485
|
+
componentsDelta: row.componentsDelta,
|
|
3486
|
+
signals: row.signals,
|
|
3487
|
+
observation: row.observation,
|
|
3488
|
+
formulaVersion: row.formulaVersion,
|
|
3489
|
+
generatedAt: row.generatedAt,
|
|
3490
|
+
seenAt: row.seenAt,
|
|
3491
|
+
});
|
|
3492
|
+
} catch (err) {
|
|
3493
|
+
console.error('Weekly digest read error:', err.message);
|
|
3494
|
+
json(response, 500, { error: 'Failed to read weekly digest' });
|
|
3495
|
+
}
|
|
3496
|
+
return;
|
|
3497
|
+
}
|
|
3498
|
+
|
|
3491
3499
|
let snapshot;
|
|
3492
3500
|
try {
|
|
3493
3501
|
snapshot = loadSnapshotForAccount
|
|
@@ -3707,7 +3715,10 @@ export function createSyncServiceRequestHandler({
|
|
|
3707
3715
|
if (openrouterKey && generateWeeklyCheckinRecapImpl && generateCheckinQuestionsImpl) {
|
|
3708
3716
|
try {
|
|
3709
3717
|
const { weeklyCheckinContext } = await import('./queries.js');
|
|
3710
|
-
|
|
3718
|
+
// Anchor to row.weekStartDate so lazy-gen describes the
|
|
3719
|
+
// canonical week, matching cron behaviour (onemore-8oc5).
|
|
3720
|
+
const referenceNow = new Date(`${row.weekStartDate}T23:59:59.999Z`);
|
|
3721
|
+
const ctx = weeklyCheckinContext(snapshot, account.id, { now: referenceNow });
|
|
3711
3722
|
if (ctx) {
|
|
3712
3723
|
let priorCommitmentRow = null;
|
|
3713
3724
|
if (listActiveCoachCommitmentsForAccount) {
|
|
@@ -4160,13 +4171,9 @@ export function createSyncServiceRequestHandler({
|
|
|
4160
4171
|
? queries.askRoutedContext(snapshot, question, { exclude, coachFacts })
|
|
4161
4172
|
: { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
|
|
4162
4173
|
const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
|
|
4163
|
-
const serverProgramPhase = persistedKind === 'weekly-checkin'
|
|
4164
|
-
? queries.weeklyCheckinContext?.(snapshot, account.id)?.programPhase
|
|
4165
|
-
: null;
|
|
4166
|
-
const programPhasePrelude = formatProgramPhasePrelude(body?.programPhase ?? serverProgramPhase);
|
|
4167
4174
|
const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
|
|
4168
4175
|
|
|
4169
|
-
const preludes = [
|
|
4176
|
+
const preludes = [incrementScorePrelude].filter(Boolean);
|
|
4170
4177
|
const ctx = preludes.length > 0
|
|
4171
4178
|
? `${preludes.join('\n\n')}\n\n${routedContext.context}`
|
|
4172
4179
|
: routedContext.context;
|
package/src/validate.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Per-option input validation. Defends against agent-hallucinated values
|
|
2
|
+
// (path traversals, embedded query params in IDs, control characters).
|
|
3
|
+
//
|
|
4
|
+
// Formats:
|
|
5
|
+
// 'id' — ^[A-Za-z0-9_-]+$ (no ?, &, #, %, /, whitespace, control chars)
|
|
6
|
+
// 'path' — no .. segments, no NUL, no control chars, no URL schemes
|
|
7
|
+
// 'url' — must parse as http(s) URL
|
|
8
|
+
// 'enum' — must be one of opt.enum
|
|
9
|
+
// 'text' — reject ASCII <0x20 except \t \n; length cap (default 4 KB)
|
|
10
|
+
// undefined → 'text'
|
|
11
|
+
|
|
12
|
+
const ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
13
|
+
// Any RFC-3986-style scheme: letter followed by letters/digits/+/-/. then ://.
|
|
14
|
+
// Matches http://, https://, file://, ftp://, FILE://, custom://, etc.
|
|
15
|
+
const URL_SCHEME_RE = /^[a-z][a-z0-9+.-]*:\/\//i;
|
|
16
|
+
// eslint-disable-next-line no-control-regex
|
|
17
|
+
const CONTROL_RE = /[\x00-\x08\x0B\x0C\x0E-\x1F]/;
|
|
18
|
+
// eslint-disable-next-line no-control-regex
|
|
19
|
+
const ANY_CONTROL_RE = /[\x00-\x1F]/;
|
|
20
|
+
const DEFAULT_TEXT_CAP = 4096;
|
|
21
|
+
const FIELDS_OPTION = {
|
|
22
|
+
name: 'fields',
|
|
23
|
+
type: 'string',
|
|
24
|
+
format: 'text',
|
|
25
|
+
maxLength: 1024
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export class ValidationError extends Error {
|
|
29
|
+
constructor(message, { option, format, code = 'INVALID_OPTION' } = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'ValidationError';
|
|
32
|
+
this.code = code;
|
|
33
|
+
this.option = option;
|
|
34
|
+
this.format = format;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function validateOption(opt, rawValue) {
|
|
39
|
+
if (rawValue === undefined) return rawValue;
|
|
40
|
+
|
|
41
|
+
const format = opt.format ?? (opt.type === 'number' ? 'number' : 'text');
|
|
42
|
+
|
|
43
|
+
if (rawValue === null) {
|
|
44
|
+
throw new ValidationError(`--${opt.name} must not be null`, { option: opt.name, format, code: 'MISSING_OPTION' });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (opt.type === 'number' || format === 'number') {
|
|
48
|
+
if (typeof rawValue !== 'string' && typeof rawValue !== 'number') {
|
|
49
|
+
throw new ValidationError(`--${opt.name} must be a number (got ${JSON.stringify(rawValue)})`, { option: opt.name, format });
|
|
50
|
+
}
|
|
51
|
+
if (typeof rawValue === 'string' && rawValue.trim() === '') {
|
|
52
|
+
throw new ValidationError(`--${opt.name} must be a number (got ${JSON.stringify(rawValue)})`, { option: opt.name, format });
|
|
53
|
+
}
|
|
54
|
+
const num = typeof rawValue === 'number' ? rawValue : Number(rawValue);
|
|
55
|
+
if (!Number.isFinite(num)) {
|
|
56
|
+
throw new ValidationError(`--${opt.name} must be a number (got ${JSON.stringify(rawValue)})`, { option: opt.name, format });
|
|
57
|
+
}
|
|
58
|
+
return num;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (typeof rawValue !== 'string') {
|
|
62
|
+
throw new ValidationError(`--${opt.name} must be a string`, { option: opt.name, format });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (format === 'id') {
|
|
66
|
+
if (!ID_RE.test(rawValue)) {
|
|
67
|
+
throw new ValidationError(
|
|
68
|
+
`--${opt.name} contains disallowed characters (allowed: letters, digits, '-', '_')`,
|
|
69
|
+
{ option: opt.name, format }
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return rawValue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (format === 'path') {
|
|
76
|
+
if (ANY_CONTROL_RE.test(rawValue)) {
|
|
77
|
+
throw new ValidationError(`--${opt.name} contains control characters`, { option: opt.name, format });
|
|
78
|
+
}
|
|
79
|
+
if (URL_SCHEME_RE.test(rawValue)) {
|
|
80
|
+
throw new ValidationError(`--${opt.name} must be a filesystem path, not a URL`, { option: opt.name, format });
|
|
81
|
+
}
|
|
82
|
+
const segments = rawValue.split(/[\\/]/);
|
|
83
|
+
if (segments.some((seg) => seg === '..')) {
|
|
84
|
+
throw new ValidationError(`--${opt.name} must not contain '..' path segments`, { option: opt.name, format });
|
|
85
|
+
}
|
|
86
|
+
return rawValue;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (format === 'url') {
|
|
90
|
+
if (ANY_CONTROL_RE.test(rawValue)) {
|
|
91
|
+
throw new ValidationError(`--${opt.name} contains control characters`, { option: opt.name, format });
|
|
92
|
+
}
|
|
93
|
+
let parsed;
|
|
94
|
+
try {
|
|
95
|
+
parsed = new URL(rawValue);
|
|
96
|
+
} catch {
|
|
97
|
+
throw new ValidationError(`--${opt.name} is not a valid URL`, { option: opt.name, format });
|
|
98
|
+
}
|
|
99
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
100
|
+
throw new ValidationError(`--${opt.name} must be http(s)`, { option: opt.name, format });
|
|
101
|
+
}
|
|
102
|
+
return rawValue;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (format === 'enum') {
|
|
106
|
+
const allowed = opt.enum ?? [];
|
|
107
|
+
if (!allowed.includes(rawValue)) {
|
|
108
|
+
throw new ValidationError(
|
|
109
|
+
`--${opt.name} must be one of: ${allowed.join(', ')}`,
|
|
110
|
+
{ option: opt.name, format }
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return rawValue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// 'text' (default)
|
|
117
|
+
if (CONTROL_RE.test(rawValue)) {
|
|
118
|
+
throw new ValidationError(`--${opt.name} contains control characters`, { option: opt.name, format });
|
|
119
|
+
}
|
|
120
|
+
const cap = opt.maxLength ?? DEFAULT_TEXT_CAP;
|
|
121
|
+
if (rawValue.length > cap) {
|
|
122
|
+
throw new ValidationError(`--${opt.name} exceeds max length (${cap})`, { option: opt.name, format });
|
|
123
|
+
}
|
|
124
|
+
return rawValue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function validateOptions(commandEntry, options) {
|
|
128
|
+
if (!commandEntry?.options) return options;
|
|
129
|
+
const out = { ...options };
|
|
130
|
+
for (const opt of commandEntry.options) {
|
|
131
|
+
if (out[opt.name] === undefined) continue;
|
|
132
|
+
if (out[opt.name] === true) {
|
|
133
|
+
const format = opt.format ?? (opt.type === 'number' ? 'number' : 'text');
|
|
134
|
+
throw new ValidationError(`--${opt.name} requires a value`, { option: opt.name, format, code: 'MISSING_OPTION' });
|
|
135
|
+
}
|
|
136
|
+
out[opt.name] = validateOption(opt, out[opt.name]);
|
|
137
|
+
}
|
|
138
|
+
if (commandEntry.supportsFields && out.fields !== undefined) {
|
|
139
|
+
if (out.fields === true) {
|
|
140
|
+
throw new ValidationError('--fields requires a value', { option: 'fields', format: 'text', code: 'MISSING_OPTION' });
|
|
141
|
+
}
|
|
142
|
+
out.fields = validateOption(FIELDS_OPTION, out.fields);
|
|
143
|
+
const keys = out.fields
|
|
144
|
+
.split(',')
|
|
145
|
+
.map((s) => s.trim())
|
|
146
|
+
.filter((s) => s.length > 0);
|
|
147
|
+
if (keys.length === 0) {
|
|
148
|
+
throw new ValidationError('--fields requires at least one field name', { option: 'fields', format: 'text' });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|