incremnt 0.7.0 → 0.7.1
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 +4 -1
- package/SKILL.md +5 -2
- package/package.json +1 -1
- package/src/contract.js +36 -1
- package/src/format.js +34 -1
- package/src/mcp.js +1 -1
- package/src/queries.js +44 -2
- package/src/remote.js +103 -1
- package/src/sync-service.js +154 -2
package/README.md
CHANGED
|
@@ -67,6 +67,9 @@ incremnt login --session-file ~/Downloads/session.json
|
|
|
67
67
|
| `increment-score current` | Latest Increment Score summary with components, drivers, trend, and data-quality flags |
|
|
68
68
|
| `increment-score history` | Historical Increment Score snapshots |
|
|
69
69
|
| `increment-score upload --file <file>` | Upload Increment Score snapshots |
|
|
70
|
+
| `coach observations list` | Current persisted coach observations |
|
|
71
|
+
| `coach observations seen --id <id>` | Mark a coach observation as seen |
|
|
72
|
+
| `coach observations dismiss --id <id>` | Dismiss an observation and suppress matching follow-ups |
|
|
70
73
|
| `programs propose --file <file>` | Submit a program proposal |
|
|
71
74
|
| `programs proposals` | List proposals |
|
|
72
75
|
| `programs proposal dismiss --id <id>` | Dismiss a proposal |
|
|
@@ -142,7 +145,7 @@ The MCP server uses the same auth session as the CLI — run `incremnt login` fi
|
|
|
142
145
|
|
|
143
146
|
The MCP server exposes two tool families:
|
|
144
147
|
|
|
145
|
-
- Command-shaped tools from the public CLI contract, including sessions, programs, cycles, goals, health, training load, ask history/show, `increment-score-current`, `increment-score-history`, `increment-score-upload`, program proposals, and program shares.
|
|
148
|
+
- Command-shaped tools from the public CLI contract, including sessions, programs, cycles, goals, health, training load, ask history/show, `increment-score-current`, `increment-score-history`, `increment-score-upload`, `coach-observations-current`, program proposals, and program shares.
|
|
146
149
|
- Typed coach read tools for agent-native context retrieval, including `get_increment_score`, `get_recent_sessions`, `get_exercise_history`, `get_next_session`, `get_readiness_snapshot`, `get_body_weight_snapshot`, `get_goal_status`, and `get_records`.
|
|
147
150
|
|
|
148
151
|
`get_increment_score` returns the same privacy-safe score summary as `increment-score current`: score, snapshot timestamp, formula version, data tier, component scores, positive/negative drivers, day-over-day delta, recent trend, and data-quality flags. It does not expose raw HealthKit values.
|
package/SKILL.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: incremnt-cli
|
|
3
|
-
description: Query a user's strength training data (sessions, programs, records, Increment Score, health vitals) from the incremnt iOS app via CLI or MCP.
|
|
3
|
+
description: Query a user's strength training data (sessions, programs, records, Increment Score, coach observations, health vitals) from the incremnt iOS app via CLI or MCP.
|
|
4
4
|
binary: incremnt
|
|
5
5
|
mcp_binary: incremnt-mcp
|
|
6
6
|
contract_command: incremnt contract
|
|
@@ -20,7 +20,7 @@ contract_command: incremnt contract
|
|
|
20
20
|
|
|
21
21
|
## Write-flow commands
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
Seven commands mutate state. Two additional lookup commands are listed in `writeSchema` because they support write flows. All require an authenticated session.
|
|
24
24
|
|
|
25
25
|
Append `--dry-run` to any mutating command to preview the HTTP request as `{ dryRun: true, command, request: { method, url, body } }` without sending it. Prefer this before any irreversible action.
|
|
26
26
|
|
|
@@ -33,6 +33,8 @@ Append `--dry-run` to any mutating command to preview the HTTP request as `{ dry
|
|
|
33
33
|
| `programs share list --program-id <id>` | Read-only lookup | Use before revoking a share. `share-id` is not the public token. |
|
|
34
34
|
| `programs share revoke --share-id <id>` | Irreversible | `share-id` is from `programs share list`, not the public token. |
|
|
35
35
|
| `increment-score upload --file <f>` | Overwrites by timestamp | File shape: `{ snapshots: [...] }`. Same `snapshot_at` replaces prior data. |
|
|
36
|
+
| `coach observations seen --id <id>` | Reversible by later lifecycle updates | IDs come from `coach observations list`. CLI/API-only; not exposed as an MCP write. |
|
|
37
|
+
| `coach observations dismiss --id <id>` | Suppresses matching follow-ups for the server window | IDs come from `coach observations list`. CLI/API-only; not exposed as an MCP write. |
|
|
36
38
|
|
|
37
39
|
Confirm with the user before any mutating command unless they explicitly authorised the specific action.
|
|
38
40
|
|
|
@@ -42,6 +44,7 @@ Confirm with the user before any mutating command unless they explicitly authori
|
|
|
42
44
|
- `records` is the cheapest way to discover the canonical exercise names a user has actually trained. Use it before composing a `programs propose` payload.
|
|
43
45
|
- `exercises history --name "Bench Press"` uses canonical synonym matching — it finds `Barbell Bench Press` without pulling in incline/dumbbell variants.
|
|
44
46
|
- `increment-score current` returns a privacy-safe summary only (score, components, drivers, trend, data-quality flags) — no raw HealthKit values.
|
|
47
|
+
- `coach observations list` returns persisted coach insights generated from training patterns. MCP exposes this read surface as `coach-observations-current`; seen/dismiss writes stay CLI/API-only.
|
|
45
48
|
- `health summary --days <n>` and `training load` give physiological context (resting HR, HRV, ATL/CTL/TSB).
|
|
46
49
|
|
|
47
50
|
## Input validation (already enforced)
|
package/package.json
CHANGED
package/src/contract.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const contractVersion =
|
|
1
|
+
export const contractVersion = 15;
|
|
2
2
|
|
|
3
3
|
export const capabilities = {
|
|
4
4
|
readOnly: false,
|
|
@@ -203,6 +203,16 @@ export const commandSchema = [
|
|
|
203
203
|
{ name: 'to', type: 'string', required: false, description: 'Latest snapshot_at (ISO 8601)' },
|
|
204
204
|
{ name: 'limit', type: 'number', required: false, description: 'Max snapshots to return (default 200, max 1000)' }
|
|
205
205
|
]
|
|
206
|
+
},
|
|
207
|
+
{
|
|
208
|
+
command: 'coach observations list',
|
|
209
|
+
id: 'coach-observations-current',
|
|
210
|
+
description: 'List current persisted coach observations',
|
|
211
|
+
supportsFields: true,
|
|
212
|
+
agentNotes: 'Read-only persisted insight surface. Returns generated/seen observations with evidence and lifecycle status. Use IDs from this command for CLI seen/dismiss actions.',
|
|
213
|
+
options: [
|
|
214
|
+
{ name: 'limit', type: 'number', required: false, description: 'Max observations to return (default 5, max 20)' }
|
|
215
|
+
]
|
|
206
216
|
}
|
|
207
217
|
];
|
|
208
218
|
|
|
@@ -280,6 +290,31 @@ export const writeCommandSchema = [
|
|
|
280
290
|
options: [
|
|
281
291
|
{ name: 'file', type: 'string', format: 'path', required: true, description: 'Path to JSON file with { snapshots: [...] }' }
|
|
282
292
|
]
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
command: 'coach observations seen',
|
|
296
|
+
id: 'coach-observations-seen',
|
|
297
|
+
description: 'Mark a coach observation as seen',
|
|
298
|
+
usage: 'coach observations seen --id <observation-id>',
|
|
299
|
+
dryRun: true,
|
|
300
|
+
mcp: false,
|
|
301
|
+
agentNotes: 'Mutation. IDs come from coach observations list. This is intentionally CLI/API-only for now. Pass --dry-run to preview the request without sending.',
|
|
302
|
+
options: [
|
|
303
|
+
{ name: 'id', type: 'string', format: 'id', required: true, description: 'Observation ID' },
|
|
304
|
+
{ name: 'seenAt', type: 'string', required: false, description: 'Optional ISO timestamp to record as seenAt' }
|
|
305
|
+
]
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
command: 'coach observations dismiss',
|
|
309
|
+
id: 'coach-observations-dismiss',
|
|
310
|
+
description: 'Dismiss a coach observation and suppress matching follow-ups',
|
|
311
|
+
usage: 'coach observations dismiss --id <observation-id>',
|
|
312
|
+
dryRun: true,
|
|
313
|
+
mcp: false,
|
|
314
|
+
agentNotes: 'Mutation. IDs come from coach observations list. Creates the server-side suppression window for the same observation pattern. This is intentionally CLI/API-only for now. Pass --dry-run to preview the request without sending.',
|
|
315
|
+
options: [
|
|
316
|
+
{ name: 'id', type: 'string', format: 'id', required: true, description: 'Observation ID' }
|
|
317
|
+
]
|
|
283
318
|
}
|
|
284
319
|
];
|
|
285
320
|
|
package/src/format.js
CHANGED
|
@@ -744,6 +744,36 @@ function formatIncrementScoreUpload(payload) {
|
|
|
744
744
|
return ` Uploaded ${chalk.bold(String(inserted))} Increment Score snapshot${inserted === 1 ? '' : 's'}.`;
|
|
745
745
|
}
|
|
746
746
|
|
|
747
|
+
function formatCoachObservationsCurrent(payload) {
|
|
748
|
+
const observations = payload?.observations;
|
|
749
|
+
if (!Array.isArray(observations) || observations.length === 0) {
|
|
750
|
+
return 'No current coach observations found.';
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
const lines = [header('COACH OBSERVATIONS'), ''];
|
|
754
|
+
for (const observation of observations) {
|
|
755
|
+
const status = observation.status ? `${dimDot()}${chalk.dim(observation.status)}` : '';
|
|
756
|
+
const confidenceValue = Number(observation.confidence);
|
|
757
|
+
const confidence = Number.isFinite(confidenceValue)
|
|
758
|
+
? `${dimDot()}${chalk.dim(`confidence ${confidenceValue.toFixed(2)}`)}`
|
|
759
|
+
: '';
|
|
760
|
+
lines.push(` ${chalk.bold(observation.title ?? observation.kind ?? 'Observation')}${status}${confidence}`);
|
|
761
|
+
if (observation.summary) lines.push(` ${observation.summary}`);
|
|
762
|
+
if (observation.actionText) lines.push(` ${chalk.dim('Action')} ${observation.actionText}`);
|
|
763
|
+
if (observation.id) lines.push(` ${chalk.dim(observation.id)}`);
|
|
764
|
+
lines.push('');
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
while (lines.at(-1) === '') lines.pop();
|
|
768
|
+
return lines.join('\n');
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
function formatCoachObservationMutation(payload) {
|
|
772
|
+
const observation = payload?.observation;
|
|
773
|
+
if (!observation) return 'Coach observation not found.';
|
|
774
|
+
return ` Coach observation ${chalk.bold(observation.id)} is ${chalk.bold(observation.status)}.`;
|
|
775
|
+
}
|
|
776
|
+
|
|
747
777
|
// --- Main export ---
|
|
748
778
|
|
|
749
779
|
export function formatHelp(opts = {}) {
|
|
@@ -815,7 +845,10 @@ export function formatPretty(command, payload) {
|
|
|
815
845
|
'proposal-dismiss': formatProposalDismissed,
|
|
816
846
|
'increment-score-current': formatIncrementScoreCurrent,
|
|
817
847
|
'increment-score-history': formatIncrementScoreHistory,
|
|
818
|
-
'increment-score-upload': formatIncrementScoreUpload
|
|
848
|
+
'increment-score-upload': formatIncrementScoreUpload,
|
|
849
|
+
'coach-observations-current': formatCoachObservationsCurrent,
|
|
850
|
+
'coach-observations-seen': formatCoachObservationMutation,
|
|
851
|
+
'coach-observations-dismiss': formatCoachObservationMutation
|
|
819
852
|
}[command];
|
|
820
853
|
|
|
821
854
|
return formatter ? formatter(payload) : null;
|
package/src/mcp.js
CHANGED
|
@@ -68,7 +68,7 @@ export function registerMcpTools(server, {
|
|
|
68
68
|
readSessionStateFn = readSessionState,
|
|
69
69
|
createTransportFn = createTransport
|
|
70
70
|
} = {}) {
|
|
71
|
-
for (const cmd of [...commandSchema, ...writeCommandSchema]) {
|
|
71
|
+
for (const cmd of [...commandSchema, ...writeCommandSchema].filter((entry) => entry.mcp !== false)) {
|
|
72
72
|
const description = cmd.agentNotes
|
|
73
73
|
? `${cmd.description}\n\nNotes for agents:\n${cmd.agentNotes}`
|
|
74
74
|
: cmd.description;
|
package/src/queries.js
CHANGED
|
@@ -3610,7 +3610,42 @@ function askToolMetadata(tools = [], provenance = []) {
|
|
|
3610
3610
|
};
|
|
3611
3611
|
}
|
|
3612
3612
|
|
|
3613
|
-
|
|
3613
|
+
function appendCoachObservationsContextBeforeExcludeNote(lines, observations, exclude = new Set()) {
|
|
3614
|
+
if (exclude.has('coach_observations')) return [];
|
|
3615
|
+
const usable = (Array.isArray(observations) ? observations : [])
|
|
3616
|
+
.filter((observation) => observation?.id && observation?.summary)
|
|
3617
|
+
.slice(0, 3);
|
|
3618
|
+
if (usable.length === 0) return [];
|
|
3619
|
+
|
|
3620
|
+
const note = buildExcludeNote(exclude);
|
|
3621
|
+
const noteAtEnd = note && lines.at(-1) === note;
|
|
3622
|
+
if (noteAtEnd) {
|
|
3623
|
+
lines.pop();
|
|
3624
|
+
if (lines.at(-1) === '') lines.pop();
|
|
3625
|
+
}
|
|
3626
|
+
const section = [
|
|
3627
|
+
'',
|
|
3628
|
+
'Coach observations (derived from training data, not user-stated facts):'
|
|
3629
|
+
];
|
|
3630
|
+
for (const observation of usable) {
|
|
3631
|
+
const parts = [
|
|
3632
|
+
`[${observation.kind ?? 'observation'}] ${observation.summary}`,
|
|
3633
|
+
observation.actionText ? `Action: ${observation.actionText}` : null,
|
|
3634
|
+
observation.sourceComponent ? `component=${observation.sourceComponent}` : null,
|
|
3635
|
+
`confidence=${Number(observation.confidence ?? 0).toFixed(2)}`,
|
|
3636
|
+
`observation-id=${observation.id}`
|
|
3637
|
+
].filter(Boolean);
|
|
3638
|
+
section.push(` ${parts.join(' ')}`);
|
|
3639
|
+
}
|
|
3640
|
+
lines.push(...section);
|
|
3641
|
+
if (noteAtEnd) {
|
|
3642
|
+
lines.push('');
|
|
3643
|
+
lines.push(note);
|
|
3644
|
+
}
|
|
3645
|
+
return usable.map((observation) => observation.id);
|
|
3646
|
+
}
|
|
3647
|
+
|
|
3648
|
+
export function askRoutedContext(snapshot, question, { exclude = new Set(), coachFacts = null, coachObservations = null } = {}) {
|
|
3614
3649
|
const { route, namedExercises } = routeAskQuestion(snapshot, question);
|
|
3615
3650
|
let effectiveRoute = route;
|
|
3616
3651
|
let fallbackRoute = null;
|
|
@@ -3657,6 +3692,7 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
3657
3692
|
const factLines = built.context.split('\n');
|
|
3658
3693
|
const includedFacts = rankedCoachFactsForAsk(snapshot, question, effectiveRoute, { facts: coachFacts });
|
|
3659
3694
|
const includedCoachFactIds = appendCoachFactsContextBeforeExcludeNote(factLines, includedFacts, exclude);
|
|
3695
|
+
const includedCoachObservationIds = appendCoachObservationsContextBeforeExcludeNote(factLines, coachObservations, exclude);
|
|
3660
3696
|
const includedCoachFactKinds = uniqueArray(includedFacts.map((fact) => fact.kind));
|
|
3661
3697
|
const includedCoachFactSources = uniqueArray(includedFacts.map((fact) => {
|
|
3662
3698
|
const sourceSessionId = String(fact.sourceSessionId ?? '');
|
|
@@ -3666,7 +3702,11 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
3666
3702
|
}).filter(Boolean));
|
|
3667
3703
|
built = {
|
|
3668
3704
|
context: factLines.join('\n'),
|
|
3669
|
-
sections:
|
|
3705
|
+
sections: [
|
|
3706
|
+
...built.sections,
|
|
3707
|
+
...(includedFacts.length > 0 ? ['coach_facts'] : []),
|
|
3708
|
+
...(includedCoachObservationIds.length > 0 ? ['coach_observations'] : [])
|
|
3709
|
+
]
|
|
3670
3710
|
};
|
|
3671
3711
|
|
|
3672
3712
|
return {
|
|
@@ -3683,6 +3723,8 @@ export function askRoutedContext(snapshot, question, { exclude = new Set(), coac
|
|
|
3683
3723
|
coachFactIds: includedCoachFactIds,
|
|
3684
3724
|
coachFactKinds: includedCoachFactKinds,
|
|
3685
3725
|
coachFactSources: includedCoachFactSources,
|
|
3726
|
+
includedCoachObservationIds,
|
|
3727
|
+
coachObservationIds: includedCoachObservationIds,
|
|
3686
3728
|
contextCharCount: built.context.length,
|
|
3687
3729
|
...toolMetadata
|
|
3688
3730
|
}
|
package/src/remote.js
CHANGED
|
@@ -42,7 +42,8 @@ const remoteCommandHandlers = {
|
|
|
42
42
|
'ask-show': executeRemoteRead,
|
|
43
43
|
'program-share-fetch': executeRemoteRead,
|
|
44
44
|
'increment-score-current': executeRemoteRead,
|
|
45
|
-
'increment-score-history': executeRemoteRead
|
|
45
|
+
'increment-score-history': executeRemoteRead,
|
|
46
|
+
'coach-observations-current': executeRemoteRead
|
|
46
47
|
};
|
|
47
48
|
|
|
48
49
|
async function executeRemoteRead(options, sessionState, normalizedCommand) {
|
|
@@ -175,6 +176,11 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
|
|
|
175
176
|
if (options.limit) url.searchParams.set('limit', options.limit);
|
|
176
177
|
return url;
|
|
177
178
|
}
|
|
179
|
+
case 'coach-observations-current': {
|
|
180
|
+
const url = resolveServiceUrl(baseUrl, '/cli/coach-observations/current');
|
|
181
|
+
if (options.limit) url.searchParams.set('limit', options.limit);
|
|
182
|
+
return url;
|
|
183
|
+
}
|
|
178
184
|
default:
|
|
179
185
|
return resolveServiceUrl(baseUrl, '/');
|
|
180
186
|
}
|
|
@@ -321,6 +327,32 @@ export async function buildWriteRequest(commandId, options, sessionState) {
|
|
|
321
327
|
body
|
|
322
328
|
};
|
|
323
329
|
}
|
|
330
|
+
case 'coach-observations-seen': {
|
|
331
|
+
if (!options.id) {
|
|
332
|
+
const error = new Error('--id is required for coach observations seen.');
|
|
333
|
+
error.code = 'MISSING_OPTION';
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
const body = {};
|
|
337
|
+
if (options.seenAt) body.seenAt = options.seenAt;
|
|
338
|
+
return {
|
|
339
|
+
method: 'POST',
|
|
340
|
+
url: resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/seen`).toString(),
|
|
341
|
+
body
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
case 'coach-observations-dismiss': {
|
|
345
|
+
if (!options.id) {
|
|
346
|
+
const error = new Error('--id is required for coach observations dismiss.');
|
|
347
|
+
error.code = 'MISSING_OPTION';
|
|
348
|
+
throw error;
|
|
349
|
+
}
|
|
350
|
+
return {
|
|
351
|
+
method: 'POST',
|
|
352
|
+
url: resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/dismiss`).toString(),
|
|
353
|
+
body: {}
|
|
354
|
+
};
|
|
355
|
+
}
|
|
324
356
|
default: {
|
|
325
357
|
const error = new Error(`Command ${commandId} does not support --dry-run.`);
|
|
326
358
|
error.code = 'UNSUPPORTED_DRY_RUN';
|
|
@@ -553,6 +585,76 @@ const remoteWriteCommandHandlers = {
|
|
|
553
585
|
throw error;
|
|
554
586
|
}
|
|
555
587
|
return response.json();
|
|
588
|
+
},
|
|
589
|
+
|
|
590
|
+
'coach-observations-seen': async (options, sessionState) => {
|
|
591
|
+
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
592
|
+
if (!baseUrl) throw notImplementedError();
|
|
593
|
+
if (!options.id) {
|
|
594
|
+
const error = new Error('--id is required for coach observations seen.');
|
|
595
|
+
error.code = 'MISSING_OPTION';
|
|
596
|
+
throw error;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const body = {};
|
|
600
|
+
if (options.seenAt) body.seenAt = options.seenAt;
|
|
601
|
+
const endpoint = resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/seen`);
|
|
602
|
+
const response = await fetch(endpoint, {
|
|
603
|
+
method: 'POST',
|
|
604
|
+
headers: {
|
|
605
|
+
'Content-Type': 'application/json',
|
|
606
|
+
Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
|
|
607
|
+
},
|
|
608
|
+
body: JSON.stringify(body)
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
if (response.status === 401 || response.status === 403) throw authenticationFailedError();
|
|
612
|
+
if (response.status === 404) {
|
|
613
|
+
const error = new Error(`Coach observation not found: ${options.id}`);
|
|
614
|
+
error.code = 'REMOTE_NOT_FOUND';
|
|
615
|
+
throw error;
|
|
616
|
+
}
|
|
617
|
+
if (!response.ok) {
|
|
618
|
+
const payload = await response.json().catch(() => null);
|
|
619
|
+
const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
|
|
620
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
621
|
+
throw error;
|
|
622
|
+
}
|
|
623
|
+
return response.json();
|
|
624
|
+
},
|
|
625
|
+
|
|
626
|
+
'coach-observations-dismiss': async (options, sessionState) => {
|
|
627
|
+
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
628
|
+
if (!baseUrl) throw notImplementedError();
|
|
629
|
+
if (!options.id) {
|
|
630
|
+
const error = new Error('--id is required for coach observations dismiss.');
|
|
631
|
+
error.code = 'MISSING_OPTION';
|
|
632
|
+
throw error;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
const endpoint = resolveServiceUrl(baseUrl, `/cli/coach-observations/${encodeURIComponent(options.id)}/dismiss`);
|
|
636
|
+
const response = await fetch(endpoint, {
|
|
637
|
+
method: 'POST',
|
|
638
|
+
headers: {
|
|
639
|
+
'Content-Type': 'application/json',
|
|
640
|
+
Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
|
|
641
|
+
},
|
|
642
|
+
body: JSON.stringify({})
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
if (response.status === 401 || response.status === 403) throw authenticationFailedError();
|
|
646
|
+
if (response.status === 404) {
|
|
647
|
+
const error = new Error(`Coach observation not found: ${options.id}`);
|
|
648
|
+
error.code = 'REMOTE_NOT_FOUND';
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
if (!response.ok) {
|
|
652
|
+
const payload = await response.json().catch(() => null);
|
|
653
|
+
const error = new Error(payload?.error ?? `Unexpected error (HTTP ${response.status}).`);
|
|
654
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
return response.json();
|
|
556
658
|
}
|
|
557
659
|
};
|
|
558
660
|
|
package/src/sync-service.js
CHANGED
|
@@ -22,6 +22,9 @@ const DEFAULT_RATE_LIMIT_RULES = {
|
|
|
22
22
|
'weekly-checkin-ack': 30,
|
|
23
23
|
'weekly-checkin-start': 10,
|
|
24
24
|
'weekly-digest-current': 30,
|
|
25
|
+
'coach-observations-current': 30,
|
|
26
|
+
'coach-observations-seen': 30,
|
|
27
|
+
'coach-observations-dismiss': 30,
|
|
25
28
|
'dev-login': 10,
|
|
26
29
|
'device-start': 20,
|
|
27
30
|
'device-poll': 300,
|
|
@@ -682,6 +685,11 @@ function createRateLimiter({
|
|
|
682
685
|
};
|
|
683
686
|
}
|
|
684
687
|
|
|
688
|
+
function parseLimit(value, { defaultValue, max }) {
|
|
689
|
+
const parsed = value ? Number.parseInt(value, 10) : defaultValue;
|
|
690
|
+
return Number.isFinite(parsed) && parsed > 0 ? Math.min(parsed, max) : defaultValue;
|
|
691
|
+
}
|
|
692
|
+
|
|
685
693
|
function routeRequest(url, method) {
|
|
686
694
|
const pathname = url.pathname;
|
|
687
695
|
|
|
@@ -1055,6 +1063,22 @@ function routeRequest(url, method) {
|
|
|
1055
1063
|
return { command: 'weekly-digest-current', options: {} };
|
|
1056
1064
|
}
|
|
1057
1065
|
|
|
1066
|
+
if (pathname === '/cli/coach-observations/current') {
|
|
1067
|
+
return { command: 'coach-observations-current', options: { limit: url.searchParams.get('limit') ?? undefined } };
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
{
|
|
1071
|
+
const coachObservationActionMatch = pathname.match(/^\/cli\/coach-observations\/([^/]+)\/(seen|dismiss)$/);
|
|
1072
|
+
if (coachObservationActionMatch) {
|
|
1073
|
+
return {
|
|
1074
|
+
command: `coach-observations-${coachObservationActionMatch[2]}`,
|
|
1075
|
+
options: {
|
|
1076
|
+
id: decodeURIComponent(coachObservationActionMatch[1])
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1058
1082
|
if (pathname === '/cli/health/ai') {
|
|
1059
1083
|
return {
|
|
1060
1084
|
command: 'health-ai',
|
|
@@ -1512,6 +1536,27 @@ async function readJsonBody(request) {
|
|
|
1512
1536
|
}
|
|
1513
1537
|
}
|
|
1514
1538
|
|
|
1539
|
+
async function readOptionalJsonBody(request) {
|
|
1540
|
+
const chunks = [];
|
|
1541
|
+
let totalSize = 0;
|
|
1542
|
+
for await (const chunk of request) {
|
|
1543
|
+
totalSize += chunk.length;
|
|
1544
|
+
if (totalSize > MAX_BODY_BYTES) {
|
|
1545
|
+
throw new Error('Request body too large.');
|
|
1546
|
+
}
|
|
1547
|
+
chunks.push(chunk);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
const raw = Buffer.concat(chunks).toString('utf8');
|
|
1551
|
+
if (!raw.trim()) return {};
|
|
1552
|
+
|
|
1553
|
+
try {
|
|
1554
|
+
return JSON.parse(raw);
|
|
1555
|
+
} catch {
|
|
1556
|
+
throw new Error('Invalid JSON in request body.');
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1515
1560
|
async function readUrlEncodedBody(request) {
|
|
1516
1561
|
const chunks = [];
|
|
1517
1562
|
let totalSize = 0;
|
|
@@ -1992,6 +2037,9 @@ export function createSyncServiceRequestHandler({
|
|
|
1992
2037
|
insertScoreSnapshotsForAccount = null,
|
|
1993
2038
|
listScoreSnapshotsForAccount = null,
|
|
1994
2039
|
getCurrentWeeklyScoreDigestForAccount = null,
|
|
2040
|
+
listCurrentCoachObservationsForAccount = null,
|
|
2041
|
+
markCoachObservationSeenForAccount = null,
|
|
2042
|
+
dismissCoachObservationForAccount = null,
|
|
1995
2043
|
// Social
|
|
1996
2044
|
social = null,
|
|
1997
2045
|
onError = null
|
|
@@ -3496,6 +3544,92 @@ export function createSyncServiceRequestHandler({
|
|
|
3496
3544
|
return;
|
|
3497
3545
|
}
|
|
3498
3546
|
|
|
3547
|
+
if (route.command === 'coach-observations-current') {
|
|
3548
|
+
if (request.method !== 'GET') {
|
|
3549
|
+
methodNotAllowed(response, 'Use GET for /cli/coach-observations/current.');
|
|
3550
|
+
return;
|
|
3551
|
+
}
|
|
3552
|
+
if (!listCurrentCoachObservationsForAccount) {
|
|
3553
|
+
json(response, 503, { error: 'Coach observations not available' });
|
|
3554
|
+
return;
|
|
3555
|
+
}
|
|
3556
|
+
try {
|
|
3557
|
+
const limit = parseLimit(route.options.limit, { defaultValue: 5, max: 20 });
|
|
3558
|
+
const observations = await listCurrentCoachObservationsForAccount(account, {
|
|
3559
|
+
limit
|
|
3560
|
+
});
|
|
3561
|
+
json(response, 200, { observations: observations ?? [] });
|
|
3562
|
+
} catch (err) {
|
|
3563
|
+
console.error('Coach observations read error:', err.message);
|
|
3564
|
+
json(response, 500, { error: 'Failed to read coach observations' });
|
|
3565
|
+
}
|
|
3566
|
+
return;
|
|
3567
|
+
}
|
|
3568
|
+
|
|
3569
|
+
if (route.command === 'coach-observations-seen') {
|
|
3570
|
+
if (request.method !== 'POST') {
|
|
3571
|
+
methodNotAllowed(response, 'Use POST for /cli/coach-observations/:id/seen.');
|
|
3572
|
+
return;
|
|
3573
|
+
}
|
|
3574
|
+
if (!markCoachObservationSeenForAccount) {
|
|
3575
|
+
json(response, 503, { error: 'Coach observations not available' });
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
3578
|
+
let body = {};
|
|
3579
|
+
try {
|
|
3580
|
+
body = await readOptionalJsonBody(request);
|
|
3581
|
+
} catch {
|
|
3582
|
+
badRequest(response, 'Invalid request body.');
|
|
3583
|
+
return;
|
|
3584
|
+
}
|
|
3585
|
+
const seenAt = body?.seenAt ? new Date(body.seenAt) : null;
|
|
3586
|
+
if (seenAt && Number.isNaN(seenAt.getTime())) {
|
|
3587
|
+
badRequest(response, 'Invalid seenAt.');
|
|
3588
|
+
return;
|
|
3589
|
+
}
|
|
3590
|
+
try {
|
|
3591
|
+
const observation = await markCoachObservationSeenForAccount(account, route.options.id, { seenAt });
|
|
3592
|
+
if (!observation) {
|
|
3593
|
+
notFound(response, 'Observation not found');
|
|
3594
|
+
return;
|
|
3595
|
+
}
|
|
3596
|
+
json(response, 200, { observation });
|
|
3597
|
+
} catch (err) {
|
|
3598
|
+
console.error('Coach observation seen error:', err.message);
|
|
3599
|
+
json(response, 500, { error: 'Failed to mark observation seen' });
|
|
3600
|
+
}
|
|
3601
|
+
return;
|
|
3602
|
+
}
|
|
3603
|
+
|
|
3604
|
+
if (route.command === 'coach-observations-dismiss') {
|
|
3605
|
+
if (request.method !== 'POST') {
|
|
3606
|
+
methodNotAllowed(response, 'Use POST for /cli/coach-observations/:id/dismiss.');
|
|
3607
|
+
return;
|
|
3608
|
+
}
|
|
3609
|
+
if (!dismissCoachObservationForAccount) {
|
|
3610
|
+
json(response, 503, { error: 'Coach observations not available' });
|
|
3611
|
+
return;
|
|
3612
|
+
}
|
|
3613
|
+
try {
|
|
3614
|
+
await readOptionalJsonBody(request);
|
|
3615
|
+
} catch {
|
|
3616
|
+
badRequest(response, 'Invalid request body.');
|
|
3617
|
+
return;
|
|
3618
|
+
}
|
|
3619
|
+
try {
|
|
3620
|
+
const observation = await dismissCoachObservationForAccount(account, route.options.id, {});
|
|
3621
|
+
if (!observation) {
|
|
3622
|
+
notFound(response, 'Observation not found');
|
|
3623
|
+
return;
|
|
3624
|
+
}
|
|
3625
|
+
json(response, 200, { observation });
|
|
3626
|
+
} catch (err) {
|
|
3627
|
+
console.error('Coach observation dismiss error:', err.message);
|
|
3628
|
+
json(response, 500, { error: 'Failed to dismiss observation' });
|
|
3629
|
+
}
|
|
3630
|
+
return;
|
|
3631
|
+
}
|
|
3632
|
+
|
|
3499
3633
|
let snapshot;
|
|
3500
3634
|
try {
|
|
3501
3635
|
snapshot = loadSnapshotForAccount
|
|
@@ -4166,9 +4300,17 @@ export function createSyncServiceRequestHandler({
|
|
|
4166
4300
|
history: enrichedSnapshots
|
|
4167
4301
|
};
|
|
4168
4302
|
}
|
|
4303
|
+
let coachObservations = [];
|
|
4304
|
+
if (listCurrentCoachObservationsForAccount) {
|
|
4305
|
+
try {
|
|
4306
|
+
coachObservations = await listCurrentCoachObservationsForAccount(account, { limit: 3 }) ?? [];
|
|
4307
|
+
} catch (observationErr) {
|
|
4308
|
+
console.error('Coach observations read error (ask):', observationErr.message);
|
|
4309
|
+
}
|
|
4310
|
+
}
|
|
4169
4311
|
|
|
4170
4312
|
const routedContext = queries.askRoutedContext
|
|
4171
|
-
? queries.askRoutedContext(snapshot, question, { exclude, coachFacts })
|
|
4313
|
+
? queries.askRoutedContext(snapshot, question, { exclude, coachFacts, coachObservations })
|
|
4172
4314
|
: { context: queries.askContext(snapshot, { exclude }), metadata: { route: 'legacy' } };
|
|
4173
4315
|
const persistedKind = persistedConversation?.kind ?? (conversationId?.startsWith('weekly-checkin:') ? 'weekly-checkin' : 'ask');
|
|
4174
4316
|
const incrementScorePrelude = formatIncrementScorePrelude(scoreSnapshots);
|
|
@@ -4198,7 +4340,8 @@ export function createSyncServiceRequestHandler({
|
|
|
4198
4340
|
historyTurnCount: canonicalHistory.filter((m) => m.role === 'user').length,
|
|
4199
4341
|
coachFactsIncluded: (routedContext.metadata?.includedCoachFactIds ?? []).length > 0,
|
|
4200
4342
|
coachFactIds: routedContext.metadata?.includedCoachFactIds ?? [],
|
|
4201
|
-
coachFactKinds: routedContext.metadata?.coachFactKinds ?? []
|
|
4343
|
+
coachFactKinds: routedContext.metadata?.coachFactKinds ?? [],
|
|
4344
|
+
coachObservationIds: routedContext.metadata?.includedCoachObservationIds ?? []
|
|
4202
4345
|
}
|
|
4203
4346
|
});
|
|
4204
4347
|
|
|
@@ -4255,6 +4398,15 @@ export function createSyncServiceRequestHandler({
|
|
|
4255
4398
|
});
|
|
4256
4399
|
});
|
|
4257
4400
|
}
|
|
4401
|
+
if (markCoachObservationSeenForAccount) {
|
|
4402
|
+
for (const observationId of routedContext.metadata?.includedCoachObservationIds ?? []) {
|
|
4403
|
+
try {
|
|
4404
|
+
await markCoachObservationSeenForAccount(account, observationId, {});
|
|
4405
|
+
} catch (seenErr) {
|
|
4406
|
+
console.error('Failed to mark coach observation seen:', seenErr.message);
|
|
4407
|
+
}
|
|
4408
|
+
}
|
|
4409
|
+
}
|
|
4258
4410
|
const updatedUserTurns = updatedMessages.filter((m) => m.role === 'user').length;
|
|
4259
4411
|
if (
|
|
4260
4412
|
persistedKind === 'weekly-checkin' &&
|