incremnt 0.5.0 → 0.6.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 +8 -2
- package/package.json +1 -1
- package/src/contract.js +9 -1
- package/src/format.js +45 -0
- package/src/mcp.js +67 -0
- package/src/openrouter.js +2 -2
- package/src/queries.js +130 -24
- package/src/remote.js +48 -1
- package/src/score-context.js +182 -0
- package/src/summary-evals.js +277 -4
- package/src/sync-service.js +79 -3
- package/src/transport.js +9 -1
package/README.md
CHANGED
|
@@ -67,6 +67,7 @@ incremnt login --session-file ~/Downloads/session.json
|
|
|
67
67
|
| `health summary` / `health ai` | Health metrics and AI summary |
|
|
68
68
|
| `training load` | ATL/CTL/TSB and workload context |
|
|
69
69
|
| `ask history` / `ask show --id <id>` | Coach conversation history |
|
|
70
|
+
| `increment-score current` | Latest Increment Score summary with components, drivers, trend, and data-quality flags |
|
|
70
71
|
| `increment-score history` | Historical Increment Score snapshots |
|
|
71
72
|
| `increment-score upload --file <file>` | Upload Increment Score snapshots |
|
|
72
73
|
| `programs propose --file <file>` | Submit a program proposal |
|
|
@@ -121,7 +122,7 @@ incremnt login --session-file ~/Downloads/session.json
|
|
|
121
122
|
|
|
122
123
|
## MCP Server
|
|
123
124
|
|
|
124
|
-
The package includes an [MCP](https://modelcontextprotocol.io) server
|
|
125
|
+
The package includes an [MCP](https://modelcontextprotocol.io) server for AI assistants like Claude and Codex.
|
|
125
126
|
|
|
126
127
|
Run `incremnt mcp install` to auto-register the server with Claude Desktop, Claude Code, and Codex CLI (see [Setup](#setup) above).
|
|
127
128
|
|
|
@@ -142,7 +143,12 @@ The MCP server uses the same auth session as the CLI — run `incremnt login` fi
|
|
|
142
143
|
|
|
143
144
|
### MCP tool surface
|
|
144
145
|
|
|
145
|
-
The MCP server exposes
|
|
146
|
+
The MCP server exposes two tool families:
|
|
147
|
+
|
|
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`, program proposals, and program shares.
|
|
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`.
|
|
150
|
+
|
|
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.
|
|
146
152
|
|
|
147
153
|
You can inspect the exact machine-readable contract at any time:
|
|
148
154
|
|
package/package.json
CHANGED
package/src/contract.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const contractVersion =
|
|
1
|
+
export const contractVersion = 11;
|
|
2
2
|
|
|
3
3
|
export const capabilities = {
|
|
4
4
|
readOnly: false,
|
|
@@ -162,6 +162,14 @@ export const commandSchema = [
|
|
|
162
162
|
{ name: 'token', type: 'string', required: true, description: 'Program share token' }
|
|
163
163
|
]
|
|
164
164
|
},
|
|
165
|
+
{
|
|
166
|
+
command: 'increment-score current',
|
|
167
|
+
id: 'increment-score-current',
|
|
168
|
+
description: 'Show the latest Increment Score snapshot summary',
|
|
169
|
+
options: [
|
|
170
|
+
{ name: 'historyDays', type: 'number', required: false, description: 'Recent score history window (default 14, max 60)' }
|
|
171
|
+
]
|
|
172
|
+
},
|
|
165
173
|
{
|
|
166
174
|
command: 'increment-score history',
|
|
167
175
|
id: 'increment-score-history',
|
package/src/format.js
CHANGED
|
@@ -689,6 +689,50 @@ function formatIncrementScoreHistory(payload) {
|
|
|
689
689
|
return lines.join('\n');
|
|
690
690
|
}
|
|
691
691
|
|
|
692
|
+
function formatIncrementScoreCurrent(payload) {
|
|
693
|
+
if (!payload?.available) {
|
|
694
|
+
return 'No Increment Score snapshots found.';
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
const lines = [
|
|
698
|
+
` ${chalk.bold('INCREMENT SCORE')}${dimDot()}${chalk.bold(String(payload.score))}${dimDot()}${chalk.dim(payload.dataTier ?? '')}`,
|
|
699
|
+
''
|
|
700
|
+
];
|
|
701
|
+
|
|
702
|
+
if (payload.snapshotAt) lines.push(` Snapshot ${chalk.dim(formatShortDate(payload.snapshotAt))}`);
|
|
703
|
+
if (payload.formulaVersion) lines.push(` Formula ${chalk.dim(payload.formulaVersion)}`);
|
|
704
|
+
if (payload.dayOverDayDelta != null) {
|
|
705
|
+
const sign = payload.dayOverDayDelta > 0 ? '+' : '';
|
|
706
|
+
lines.push(` Delta ${chalk.dim(`${sign}${payload.dayOverDayDelta}`)}`);
|
|
707
|
+
}
|
|
708
|
+
if (lines.at(-1) !== '') lines.push('');
|
|
709
|
+
|
|
710
|
+
if (payload.components && typeof payload.components === 'object' && Object.keys(payload.components).length > 0) {
|
|
711
|
+
lines.push(` ${chalk.bold('Components')}`);
|
|
712
|
+
for (const [name, value] of Object.entries(payload.components)) {
|
|
713
|
+
lines.push(` ${name.padEnd(12)} ${chalk.dim(String(value))}`);
|
|
714
|
+
}
|
|
715
|
+
lines.push('');
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
const drivers = (label, list) => {
|
|
719
|
+
if (!Array.isArray(list) || list.length === 0) return;
|
|
720
|
+
lines.push(` ${chalk.bold(label)}`);
|
|
721
|
+
for (const text of list.slice(0, 3)) lines.push(` ${text}`);
|
|
722
|
+
lines.push('');
|
|
723
|
+
};
|
|
724
|
+
drivers('Top positive drivers', payload.topPositiveDrivers);
|
|
725
|
+
drivers('Top negative drivers', payload.topNegativeDrivers);
|
|
726
|
+
|
|
727
|
+
if (Array.isArray(payload.dataQualityNotes) && payload.dataQualityNotes.length > 0) {
|
|
728
|
+
lines.push(` ${chalk.bold('Data quality')}`);
|
|
729
|
+
for (const note of payload.dataQualityNotes) lines.push(` ${note}`);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
while (lines.at(-1) === '') lines.pop();
|
|
733
|
+
return lines.join('\n');
|
|
734
|
+
}
|
|
735
|
+
|
|
692
736
|
function formatIncrementScoreUpload(payload) {
|
|
693
737
|
const inserted = payload?.inserted ?? payload?.count ?? 0;
|
|
694
738
|
return ` Uploaded ${chalk.bold(String(inserted))} Increment Score snapshot${inserted === 1 ? '' : 's'}.`;
|
|
@@ -763,6 +807,7 @@ export function formatPretty(command, payload) {
|
|
|
763
807
|
'programs-propose': formatProposalCreated,
|
|
764
808
|
'programs-proposals': formatProposalsList,
|
|
765
809
|
'proposal-dismiss': formatProposalDismissed,
|
|
810
|
+
'increment-score-current': formatIncrementScoreCurrent,
|
|
766
811
|
'increment-score-history': formatIncrementScoreHistory,
|
|
767
812
|
'increment-score-upload': formatIncrementScoreUpload
|
|
768
813
|
}[command];
|
package/src/mcp.js
CHANGED
|
@@ -8,6 +8,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
8
8
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
9
9
|
import { z } from 'zod';
|
|
10
10
|
import { commandSchema, writeCommands, writeCommandSchema } from './contract.js';
|
|
11
|
+
import { listCoachReadTools } from './queries.js';
|
|
11
12
|
import { readSessionState } from './state.js';
|
|
12
13
|
import { createTransport } from './transport.js';
|
|
13
14
|
|
|
@@ -26,6 +27,32 @@ function commandShape(cmd) {
|
|
|
26
27
|
return shape;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
function coachToolShape(tool) {
|
|
31
|
+
const shape = {};
|
|
32
|
+
const props = tool.inputSchema?.properties ?? {};
|
|
33
|
+
const required = new Set(tool.inputSchema?.required ?? []);
|
|
34
|
+
|
|
35
|
+
for (const [name, schema] of Object.entries(props)) {
|
|
36
|
+
let field;
|
|
37
|
+
if (schema.type === 'integer' || schema.type === 'number') {
|
|
38
|
+
field = z.number();
|
|
39
|
+
} else if (schema.type === 'boolean') {
|
|
40
|
+
field = z.boolean();
|
|
41
|
+
} else if (schema.type === 'array') {
|
|
42
|
+
field = z.array(z.any());
|
|
43
|
+
} else if (schema.type === 'object') {
|
|
44
|
+
field = z.record(z.string(), z.any());
|
|
45
|
+
} else {
|
|
46
|
+
field = z.string();
|
|
47
|
+
}
|
|
48
|
+
if (!required.has(name)) field = field.optional();
|
|
49
|
+
if (schema.description) field = field.describe(schema.description);
|
|
50
|
+
shape[name] = field;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return shape;
|
|
54
|
+
}
|
|
55
|
+
|
|
29
56
|
export function registerMcpTools(server, {
|
|
30
57
|
readSessionStateFn = readSessionState,
|
|
31
58
|
createTransportFn = createTransport
|
|
@@ -68,6 +95,41 @@ export function registerMcpTools(server, {
|
|
|
68
95
|
});
|
|
69
96
|
}
|
|
70
97
|
|
|
98
|
+
for (const tool of listCoachReadTools()) {
|
|
99
|
+
server.tool(tool.name, tool.description, coachToolShape(tool), async (args) => {
|
|
100
|
+
try {
|
|
101
|
+
const sessionState = await readSessionStateFn();
|
|
102
|
+
const transport = await createTransportFn({}, sessionState);
|
|
103
|
+
|
|
104
|
+
if (transport.expired) {
|
|
105
|
+
return {
|
|
106
|
+
content: [{ type: 'text', text: 'Session expired. Run `incremnt login` to re-authenticate.' }],
|
|
107
|
+
isError: true
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const result = await transport.executeCoachReadTool(tool.name, args);
|
|
112
|
+
return {
|
|
113
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
114
|
+
};
|
|
115
|
+
} catch (error) {
|
|
116
|
+
const message = error && error.message ? error.message : String(error);
|
|
117
|
+
|
|
118
|
+
if (error && error.code === 'SNAPSHOT_NOT_FOUND') {
|
|
119
|
+
return {
|
|
120
|
+
content: [{ type: 'text', text: 'Not logged in. Run `incremnt login` first.' }],
|
|
121
|
+
isError: true
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
content: [{ type: 'text', text: message }],
|
|
127
|
+
isError: true
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
71
133
|
return server;
|
|
72
134
|
}
|
|
73
135
|
|
|
@@ -88,6 +150,11 @@ export function createSandboxServer() {
|
|
|
88
150
|
sandbox: true,
|
|
89
151
|
ok: true
|
|
90
152
|
}),
|
|
153
|
+
executeCoachReadTool: async (toolName) => ({
|
|
154
|
+
toolName,
|
|
155
|
+
sandbox: true,
|
|
156
|
+
ok: true
|
|
157
|
+
}),
|
|
91
158
|
executeWriteCommand: async (commandId) => ({
|
|
92
159
|
commandId,
|
|
93
160
|
sandbox: true,
|
package/src/openrouter.js
CHANGED
|
@@ -24,7 +24,7 @@ const TRACE_DETAIL_METADATA = 'metadata';
|
|
|
24
24
|
const TRACE_DETAIL_RAW_INTERNAL = 'raw_internal';
|
|
25
25
|
|
|
26
26
|
export const AI_PROMPT_VERSIONS = Object.freeze({
|
|
27
|
-
workout: '
|
|
27
|
+
workout: 'workout_v2026_05_06_1',
|
|
28
28
|
cycle: 'cycle_v2026_04_18_1',
|
|
29
29
|
vitals: 'vitals_v2026_04_16_1',
|
|
30
30
|
checkpoint: 'checkpoint_v2026_04_16_1',
|
|
@@ -892,7 +892,7 @@ The app already shows PRs, total volume, effort score, exercise breakdown, and p
|
|
|
892
892
|
Rules:
|
|
893
893
|
- No bullet points, no questions.
|
|
894
894
|
- Be specific — use exact exercise names from the session data. Do not shorten or generalize.
|
|
895
|
-
- Only mention exercises that appear in the current session, the next session list,
|
|
895
|
+
- Only mention exercises that appear in the current session, the next session list, the recorded PR list, or the plan comparison block. You may name a skipped exercise from plan comparison if it adds insight (e.g. context for the day's shape), but at most one such mention, and never speculate on why it was skipped unless the context states a reason.
|
|
896
896
|
- Do not summarize PRs with a count in workout notes. Name the specific lift or lifts instead.
|
|
897
897
|
- Never use the phrase "rep PR" in a workout note.
|
|
898
898
|
- Do not state a percentage change unless the exact percentage is directly supported by the comparison block.
|
package/src/queries.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { coachFactPolicyViolation } from './coach-facts.js';
|
|
2
2
|
import { exerciseAliasMapping } from './exercise-aliases.js';
|
|
3
3
|
import { programPhaseWindowContext, resolveProgramPhase } from './program-phase-resolver.js';
|
|
4
|
+
import { enrichScoreSnapshots } from './score-context.js';
|
|
4
5
|
|
|
5
6
|
function completionDateForSession(session) {
|
|
6
7
|
return session.completedAt ?? session.summary?.date ?? session.date;
|
|
@@ -2883,53 +2884,150 @@ export function getRecords(snapshot, { exercises = [], limit = 15 } = {}) {
|
|
|
2883
2884
|
});
|
|
2884
2885
|
}
|
|
2885
2886
|
|
|
2886
|
-
|
|
2887
|
-
const
|
|
2887
|
+
function scoreComponentNumber(value) {
|
|
2888
|
+
const num = typeof value === 'number' ? value : value?.score;
|
|
2889
|
+
return typeof num === 'number' && Number.isFinite(num) ? num : null;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
function scoreDriverLabels(list, limit = 5) {
|
|
2893
|
+
if (!Array.isArray(list)) return [];
|
|
2894
|
+
return list.slice(0, limit).map((d) => d?.label ?? d?.message ?? d?.id ?? d?.driver).filter(Boolean);
|
|
2895
|
+
}
|
|
2896
|
+
|
|
2897
|
+
function normalizeScoreHistory(raw) {
|
|
2888
2898
|
const history = Array.isArray(raw?.history) ? raw.history : Array.isArray(raw) ? raw : [];
|
|
2889
2899
|
const latest = raw?.latest ?? history[0] ?? null;
|
|
2900
|
+
const first = history[0] ?? null;
|
|
2901
|
+
const sameFirst = latest && first && (
|
|
2902
|
+
(latest.snapshotAt && first.snapshotAt && latest.snapshotAt === first.snapshotAt) ||
|
|
2903
|
+
(latest === first)
|
|
2904
|
+
);
|
|
2905
|
+
const mergedHistory = latest && sameFirst
|
|
2906
|
+
? [{ ...first, ...latest }, ...history.slice(1)]
|
|
2907
|
+
: latest
|
|
2908
|
+
? [latest, ...history]
|
|
2909
|
+
: history;
|
|
2910
|
+
return enrichScoreSnapshots(mergedHistory);
|
|
2911
|
+
}
|
|
2912
|
+
|
|
2913
|
+
export function incrementScoreSummary(snapshot, { historyDays = 14 } = {}) {
|
|
2914
|
+
const raw = snapshot?.incrementScore;
|
|
2915
|
+
const history = normalizeScoreHistory(raw);
|
|
2916
|
+
const latest = history[0] ?? null;
|
|
2917
|
+
const boundedHistoryDays = boundedInteger(historyDays, { defaultValue: 14, min: 1, max: 60 });
|
|
2890
2918
|
|
|
2891
2919
|
if (!latest || typeof latest.score !== 'number') {
|
|
2892
|
-
return
|
|
2893
|
-
|
|
2920
|
+
return {
|
|
2921
|
+
available: false,
|
|
2922
|
+
score: null,
|
|
2923
|
+
snapshotAt: null,
|
|
2924
|
+
formulaVersion: null,
|
|
2925
|
+
dataTier: null,
|
|
2926
|
+
components: {},
|
|
2927
|
+
topPositiveDrivers: [],
|
|
2928
|
+
topNegativeDrivers: [],
|
|
2929
|
+
dayOverDayDelta: null,
|
|
2930
|
+
recentTrend: [],
|
|
2931
|
+
dataQualityNotes: ['No Increment Score snapshots found.'],
|
|
2894
2932
|
missingDataFlags: ['no_increment_score']
|
|
2895
|
-
}
|
|
2933
|
+
};
|
|
2896
2934
|
}
|
|
2897
2935
|
|
|
2898
2936
|
const components = {};
|
|
2899
2937
|
if (latest.components && typeof latest.components === 'object') {
|
|
2900
2938
|
for (const [name, value] of Object.entries(latest.components)) {
|
|
2901
|
-
const num =
|
|
2902
|
-
if (
|
|
2939
|
+
const num = scoreComponentNumber(value);
|
|
2940
|
+
if (num != null) components[name] = num;
|
|
2903
2941
|
}
|
|
2904
2942
|
}
|
|
2905
2943
|
|
|
2906
|
-
const trimmedHistory = history.slice(0,
|
|
2907
|
-
const recentScores = trimmedHistory
|
|
2908
|
-
.map((entry) => (typeof entry?.score === 'number' ? entry.score : null))
|
|
2909
|
-
.filter((s) => s != null);
|
|
2910
|
-
|
|
2944
|
+
const trimmedHistory = history.slice(0, boundedHistoryDays);
|
|
2911
2945
|
const prior = trimmedHistory[1];
|
|
2912
2946
|
const dayOverDayDelta = (typeof prior?.score === 'number')
|
|
2913
2947
|
? latest.score - prior.score
|
|
2914
2948
|
: null;
|
|
2915
2949
|
|
|
2916
|
-
const
|
|
2917
|
-
|
|
2918
|
-
|
|
2950
|
+
const missingDataFlags = [];
|
|
2951
|
+
const dataQualityNotes = [];
|
|
2952
|
+
if (Object.keys(components).length === 0) {
|
|
2953
|
+
missingDataFlags.push('no_components');
|
|
2954
|
+
dataQualityNotes.push('Component scores are missing for this snapshot.');
|
|
2955
|
+
}
|
|
2956
|
+
if (!latest.dataTier) {
|
|
2957
|
+
missingDataFlags.push('no_data_tier');
|
|
2958
|
+
dataQualityNotes.push('Data tier is missing for this snapshot.');
|
|
2959
|
+
}
|
|
2960
|
+
if (!latest.formulaVersion) {
|
|
2961
|
+
missingDataFlags.push('no_formula_version');
|
|
2962
|
+
dataQualityNotes.push('Formula version is missing for this snapshot.');
|
|
2963
|
+
}
|
|
2964
|
+
|
|
2965
|
+
const recentTrend = trimmedHistory
|
|
2966
|
+
.filter((entry) => typeof entry?.score === 'number')
|
|
2967
|
+
.map((entry) => ({
|
|
2968
|
+
snapshotAt: entry.snapshotAt ?? null,
|
|
2969
|
+
score: entry.score,
|
|
2970
|
+
dataTier: entry.dataTier ?? null,
|
|
2971
|
+
formulaVersion: entry.formulaVersion ?? null
|
|
2972
|
+
}));
|
|
2973
|
+
|
|
2974
|
+
return {
|
|
2975
|
+
available: true,
|
|
2976
|
+
score: latest.score,
|
|
2977
|
+
snapshotAt: latest.snapshotAt ?? null,
|
|
2978
|
+
formulaVersion: latest.formulaVersion ?? null,
|
|
2979
|
+
dataTier: latest.dataTier ?? null,
|
|
2980
|
+
components,
|
|
2981
|
+
topPositiveDrivers: scoreDriverLabels(latest.topPositiveDrivers),
|
|
2982
|
+
topNegativeDrivers: scoreDriverLabels(latest.topNegativeDrivers),
|
|
2983
|
+
dayOverDayDelta,
|
|
2984
|
+
recentTrend,
|
|
2985
|
+
dataQualityNotes,
|
|
2986
|
+
missingDataFlags,
|
|
2987
|
+
scoreBand: latest.scoreBand ?? null,
|
|
2988
|
+
summaryText: latest.summaryText ?? null
|
|
2919
2989
|
};
|
|
2990
|
+
}
|
|
2991
|
+
|
|
2992
|
+
export function incrementScoreCurrent(snapshot, options = {}) {
|
|
2993
|
+
return incrementScoreSummary(snapshot, options);
|
|
2994
|
+
}
|
|
2995
|
+
|
|
2996
|
+
export function incrementScoreHistory(snapshot, options = {}) {
|
|
2997
|
+
const raw = snapshot?.incrementScore;
|
|
2998
|
+
const history = normalizeScoreHistory(raw);
|
|
2999
|
+
const limit = boundedInteger(options.limit, { defaultValue: 200, min: 1, max: 1000 });
|
|
3000
|
+
const from = options.from ? new Date(options.from) : null;
|
|
3001
|
+
const to = options.to ? new Date(options.to) : null;
|
|
3002
|
+
const filtered = history.filter((entry) => {
|
|
3003
|
+
if (!entry?.snapshotAt) return true;
|
|
3004
|
+
const date = new Date(entry.snapshotAt);
|
|
3005
|
+
if (Number.isNaN(date.getTime())) return true;
|
|
3006
|
+
if (from && !Number.isNaN(from.getTime()) && date < from) return false;
|
|
3007
|
+
if (to && !Number.isNaN(to.getTime()) && date > to) return false;
|
|
3008
|
+
return true;
|
|
3009
|
+
});
|
|
3010
|
+
|
|
3011
|
+
return { snapshots: filtered.slice(0, limit) };
|
|
3012
|
+
}
|
|
3013
|
+
|
|
3014
|
+
export function getIncrementScore(snapshot, { historyDays = 14 } = {}) {
|
|
3015
|
+
const summary = incrementScoreSummary(snapshot, { historyDays });
|
|
3016
|
+
|
|
3017
|
+
if (!summary.available) {
|
|
3018
|
+
return coachToolResult('get_increment_score', { historyDays }, {
|
|
3019
|
+
facts: {},
|
|
3020
|
+
missingDataFlags: summary.missingDataFlags
|
|
3021
|
+
});
|
|
3022
|
+
}
|
|
2920
3023
|
|
|
2921
3024
|
return coachToolResult('get_increment_score', { historyDays }, {
|
|
2922
3025
|
facts: {
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
components,
|
|
2926
|
-
topPositiveDrivers: driverLabels(latest.topPositiveDrivers),
|
|
2927
|
-
topNegativeDrivers: driverLabels(latest.topNegativeDrivers),
|
|
2928
|
-
dayOverDayDelta,
|
|
2929
|
-
recentScores
|
|
3026
|
+
...summary,
|
|
3027
|
+
recentScores: summary.recentTrend.map((entry) => entry.score)
|
|
2930
3028
|
},
|
|
2931
|
-
sourceTimestamp:
|
|
2932
|
-
missingDataFlags:
|
|
3029
|
+
sourceTimestamp: summary.snapshotAt,
|
|
3030
|
+
missingDataFlags: summary.missingDataFlags
|
|
2933
3031
|
});
|
|
2934
3032
|
}
|
|
2935
3033
|
|
|
@@ -4308,6 +4406,14 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
|
|
|
4308
4406
|
return { ok: true, payload: trainingLoad(snapshot) };
|
|
4309
4407
|
}
|
|
4310
4408
|
|
|
4409
|
+
if (normalizedCommand === 'increment-score-current') {
|
|
4410
|
+
return { ok: true, payload: incrementScoreCurrent(snapshot, options) };
|
|
4411
|
+
}
|
|
4412
|
+
|
|
4413
|
+
if (normalizedCommand === 'increment-score-history') {
|
|
4414
|
+
return { ok: true, payload: incrementScoreHistory(snapshot, options) };
|
|
4415
|
+
}
|
|
4416
|
+
|
|
4311
4417
|
if (normalizedCommand === 'ask-history') {
|
|
4312
4418
|
const conversations = Array.isArray(snapshot.askConversations)
|
|
4313
4419
|
? snapshot.askConversations
|
package/src/remote.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import { readSnapshot } from './local.js';
|
|
3
|
-
import { executeReadCommand } from './queries.js';
|
|
3
|
+
import { executeCoachReadTool as executeLocalCoachReadTool, executeReadCommand } from './queries.js';
|
|
4
4
|
import { resolveServiceUrl } from './service-url.js';
|
|
5
5
|
|
|
6
6
|
function notImplementedError() {
|
|
@@ -41,6 +41,7 @@ const remoteCommandHandlers = {
|
|
|
41
41
|
'ask-history': executeRemoteRead,
|
|
42
42
|
'ask-show': executeRemoteRead,
|
|
43
43
|
'program-share-fetch': executeRemoteRead,
|
|
44
|
+
'increment-score-current': executeRemoteRead,
|
|
44
45
|
'increment-score-history': executeRemoteRead
|
|
45
46
|
};
|
|
46
47
|
|
|
@@ -162,6 +163,11 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
|
|
|
162
163
|
return resolveServiceUrl(baseUrl, `/cli/ask/history/${options.id}`);
|
|
163
164
|
case 'program-share-fetch':
|
|
164
165
|
return resolveServiceUrl(baseUrl, `/program-share/${options.token}`);
|
|
166
|
+
case 'increment-score-current': {
|
|
167
|
+
const url = resolveServiceUrl(baseUrl, '/cli/increment-score/current');
|
|
168
|
+
if (options.historyDays) url.searchParams.set('historyDays', options.historyDays);
|
|
169
|
+
return url;
|
|
170
|
+
}
|
|
165
171
|
case 'increment-score-history': {
|
|
166
172
|
const url = resolveServiceUrl(baseUrl, '/mobile/score-snapshots');
|
|
167
173
|
if (options.from) url.searchParams.set('from', options.from);
|
|
@@ -198,6 +204,40 @@ function resourceNotFoundMessage(normalizedCommand, options) {
|
|
|
198
204
|
return 'Requested resource was not found.';
|
|
199
205
|
}
|
|
200
206
|
|
|
207
|
+
async function executeRemoteCoachReadTool(toolName, input, sessionState) {
|
|
208
|
+
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
209
|
+
if (baseUrl) {
|
|
210
|
+
const endpoint = resolveServiceUrl(baseUrl, `/cli/coach-tools/${encodeURIComponent(toolName)}`);
|
|
211
|
+
const response = await fetch(endpoint, {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
headers: {
|
|
214
|
+
'Content-Type': 'application/json',
|
|
215
|
+
Authorization: `Bearer ${sessionState.session?.auth?.accessToken ?? ''}`
|
|
216
|
+
},
|
|
217
|
+
body: JSON.stringify(input ?? {})
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
if (response.status === 401 || response.status === 403) throw authenticationFailedError();
|
|
221
|
+
if (response.status === 404) {
|
|
222
|
+
const error = new Error(`Unknown coach read tool: ${toolName}`);
|
|
223
|
+
error.code = 'REMOTE_NOT_FOUND';
|
|
224
|
+
throw error;
|
|
225
|
+
}
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
const payload = await response.json().catch(() => null);
|
|
228
|
+
const error = new Error(payload?.error ?? `Unexpected error from incremnt sync service (HTTP ${response.status}).`);
|
|
229
|
+
error.code = 'REMOTE_HTTP_ERROR';
|
|
230
|
+
throw error;
|
|
231
|
+
}
|
|
232
|
+
return response.json();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const fixturePath = sessionState.session?.transport?.fixturePath;
|
|
236
|
+
if (!fixturePath) throw notImplementedError();
|
|
237
|
+
const snapshot = await readSnapshot(fixturePath);
|
|
238
|
+
return executeLocalCoachReadTool(snapshot, toolName, input);
|
|
239
|
+
}
|
|
240
|
+
|
|
201
241
|
const remoteWriteCommandHandlers = {
|
|
202
242
|
'programs-propose': async (options, sessionState) => {
|
|
203
243
|
const baseUrl = sessionState.session?.transport?.baseUrl;
|
|
@@ -453,6 +493,13 @@ export function createRemoteTransport(sessionState, transportOptions = {}) {
|
|
|
453
493
|
|
|
454
494
|
return handler(options, sessionState, normalizedCommand);
|
|
455
495
|
},
|
|
496
|
+
async executeCoachReadTool(toolName, input = {}) {
|
|
497
|
+
if (transportOptions.expired) {
|
|
498
|
+
throw expiredSessionError();
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return executeRemoteCoachReadTool(toolName, input, sessionState);
|
|
502
|
+
},
|
|
456
503
|
async executeWriteCommand(normalizedCommand, options = {}) {
|
|
457
504
|
if (transportOptions.expired) {
|
|
458
505
|
throw expiredSessionError();
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Derived context for INCREMNT Score snapshots.
|
|
2
|
+
//
|
|
3
|
+
// These fields are computed at response time from existing snapshot data.
|
|
4
|
+
// They are NOT persisted — pure projections of (current snapshot, previous
|
|
5
|
+
// snapshot) into agent-friendly explanatory shape. See GitHub issue #498.
|
|
6
|
+
|
|
7
|
+
// Score bands. Inclusive lower bound, exclusive upper bound (except 'peak').
|
|
8
|
+
// 0..40 weak
|
|
9
|
+
// 40..60 developing
|
|
10
|
+
// 60..75 solid
|
|
11
|
+
// 75..90 strong
|
|
12
|
+
// 90..100 peak
|
|
13
|
+
export const SCORE_BANDS = [
|
|
14
|
+
{ name: 'weak', min: 0, max: 40 },
|
|
15
|
+
{ name: 'developing', min: 40, max: 60 },
|
|
16
|
+
{ name: 'solid', min: 60, max: 75 },
|
|
17
|
+
{ name: 'strong', min: 75, max: 90 },
|
|
18
|
+
{ name: 'peak', min: 90, max: 101 }
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
export function computeScoreBand(score) {
|
|
22
|
+
if (typeof score !== 'number' || !Number.isFinite(score)) return null;
|
|
23
|
+
for (const band of SCORE_BANDS) {
|
|
24
|
+
if (score >= band.min && score < band.max) return band.name;
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Component-keyed action templates surfaced as `recommendedNextActions` for
|
|
30
|
+
// each top-2 negative driver. Keep these short, imperative, single-line.
|
|
31
|
+
const COMPONENT_ACTIONS = {
|
|
32
|
+
coverage: 'Add the missing muscle groups to your next session to close coverage gaps.',
|
|
33
|
+
recovery: 'Prioritise sleep and an easier session to let recovery rebound.',
|
|
34
|
+
stimulus: 'Push closer to productive weekly volume on your lagging muscle groups.',
|
|
35
|
+
execution: 'Hit your planned sets and reps with cleaner technique next session.',
|
|
36
|
+
progression: 'Add a small load or rep increase on your main lifts next session.'
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const GENERIC_ACTION = 'Address this driver in your next session to lift the score.';
|
|
40
|
+
|
|
41
|
+
function actionForDriver(driver) {
|
|
42
|
+
if (!driver || typeof driver !== 'object') return GENERIC_ACTION;
|
|
43
|
+
const component = typeof driver.component === 'string' ? driver.component : null;
|
|
44
|
+
if (component && COMPONENT_ACTIONS[component]) {
|
|
45
|
+
return COMPONENT_ACTIONS[component];
|
|
46
|
+
}
|
|
47
|
+
return GENERIC_ACTION;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function driverDisplayMessage(driver) {
|
|
51
|
+
if (!driver || typeof driver !== 'object') return null;
|
|
52
|
+
if (typeof driver.message === 'string' && driver.message.trim()) return driver.message;
|
|
53
|
+
if (typeof driver.label === 'string' && driver.label.trim()) return driver.label;
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function computeRecommendedNextActions(topNegativeDrivers) {
|
|
58
|
+
if (!Array.isArray(topNegativeDrivers) || topNegativeDrivers.length === 0) return [];
|
|
59
|
+
return topNegativeDrivers.slice(0, 2).map((driver) => ({
|
|
60
|
+
component: typeof driver?.component === 'string' ? driver.component : null,
|
|
61
|
+
driverMessage: driverDisplayMessage(driver),
|
|
62
|
+
action: actionForDriver(driver)
|
|
63
|
+
}));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Top-2 component movers (by absolute change) between current and previous
|
|
67
|
+
// snapshot. Keys present in either side are considered; missing values
|
|
68
|
+
// treated as 0.
|
|
69
|
+
export function computeDeltaDrivers(currentComponents, previousComponents) {
|
|
70
|
+
if (
|
|
71
|
+
!currentComponents || typeof currentComponents !== 'object' ||
|
|
72
|
+
!previousComponents || typeof previousComponents !== 'object'
|
|
73
|
+
) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
const keys = new Set([
|
|
77
|
+
...Object.keys(currentComponents),
|
|
78
|
+
...Object.keys(previousComponents)
|
|
79
|
+
]);
|
|
80
|
+
const moves = [];
|
|
81
|
+
for (const key of keys) {
|
|
82
|
+
const cur = Number(currentComponents[key]);
|
|
83
|
+
const prev = Number(previousComponents[key]);
|
|
84
|
+
if (!Number.isFinite(cur) && !Number.isFinite(prev)) continue;
|
|
85
|
+
const c = Number.isFinite(cur) ? cur : 0;
|
|
86
|
+
const p = Number.isFinite(prev) ? prev : 0;
|
|
87
|
+
const delta = c - p;
|
|
88
|
+
if (delta === 0) continue;
|
|
89
|
+
moves.push({
|
|
90
|
+
component: key,
|
|
91
|
+
previousValue: p,
|
|
92
|
+
currentValue: c,
|
|
93
|
+
delta: Number(delta.toFixed(2))
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
moves.sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta));
|
|
97
|
+
return moves.slice(0, 2);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function formatDelta(delta) {
|
|
101
|
+
if (delta === null || delta === undefined) return null;
|
|
102
|
+
if (delta === 0) return 'flat';
|
|
103
|
+
return delta > 0 ? `up ${delta}` : `down ${Math.abs(delta)}`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function computeSummaryText(enriched) {
|
|
107
|
+
if (!enriched || typeof enriched.score !== 'number') return null;
|
|
108
|
+
const parts = [];
|
|
109
|
+
const bandLabel = enriched.scoreBand ? ` (${enriched.scoreBand})` : '';
|
|
110
|
+
parts.push(`INCREMNT Score ${enriched.score}${bandLabel}.`);
|
|
111
|
+
|
|
112
|
+
if (typeof enriched.previousScore === 'number') {
|
|
113
|
+
const trend = formatDelta(enriched.delta);
|
|
114
|
+
if (enriched.comparisonSafe) {
|
|
115
|
+
parts.push(`${trend === 'flat' ? 'Essentially flat' : `Trend ${trend}`} vs previous ${enriched.previousScore}.`);
|
|
116
|
+
} else {
|
|
117
|
+
parts.push(`Previous ${enriched.previousScore} (formula version differs or is unavailable, comparison unsafe).`);
|
|
118
|
+
}
|
|
119
|
+
} else {
|
|
120
|
+
parts.push('No prior snapshot for comparison.');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const topNeg = Array.isArray(enriched.topNegativeDrivers) ? enriched.topNegativeDrivers[0] : null;
|
|
124
|
+
const topNegMessage = driverDisplayMessage(topNeg);
|
|
125
|
+
if (topNegMessage) {
|
|
126
|
+
parts.push(`Top drag: ${topNegMessage}.`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const firstAction = Array.isArray(enriched.recommendedNextActions) ? enriched.recommendedNextActions[0] : null;
|
|
130
|
+
if (firstAction && typeof firstAction.action === 'string') {
|
|
131
|
+
parts.push(`Next: ${firstAction.action}`);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return parts.join(' ');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Enrich an array of snapshots (newest first, as returned by listScoreSnapshots).
|
|
138
|
+
// Adds derived fields to each snapshot in-place via a shallow copy. Existing
|
|
139
|
+
// fields are preserved; only new fields are added.
|
|
140
|
+
export function enrichScoreSnapshots(snapshots) {
|
|
141
|
+
if (!Array.isArray(snapshots) || snapshots.length === 0) return [];
|
|
142
|
+
return snapshots.map((snapshot, index) => {
|
|
143
|
+
const previous = snapshots[index + 1] ?? null;
|
|
144
|
+
return enrichScoreSnapshot(snapshot, previous);
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function enrichScoreSnapshot(current, previous) {
|
|
149
|
+
if (!current || typeof current !== 'object') return current;
|
|
150
|
+
|
|
151
|
+
const previousScore = previous && typeof previous.score === 'number' ? previous.score : null;
|
|
152
|
+
const delta = previousScore !== null && typeof current.score === 'number'
|
|
153
|
+
? current.score - previousScore
|
|
154
|
+
: null;
|
|
155
|
+
|
|
156
|
+
const comparisonSafe = !!(
|
|
157
|
+
previous &&
|
|
158
|
+
typeof previous.score === 'number' &&
|
|
159
|
+
typeof current.formulaVersion === 'string' &&
|
|
160
|
+
typeof previous.formulaVersion === 'string' &&
|
|
161
|
+
current.formulaVersion === previous.formulaVersion
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
const deltaDrivers = comparisonSafe
|
|
165
|
+
? computeDeltaDrivers(current.components, previous.components)
|
|
166
|
+
: [];
|
|
167
|
+
|
|
168
|
+
const scoreBand = computeScoreBand(current.score);
|
|
169
|
+
const recommendedNextActions = computeRecommendedNextActions(current.topNegativeDrivers);
|
|
170
|
+
|
|
171
|
+
const enriched = {
|
|
172
|
+
...current,
|
|
173
|
+
previousScore,
|
|
174
|
+
delta,
|
|
175
|
+
comparisonSafe,
|
|
176
|
+
deltaDrivers,
|
|
177
|
+
scoreBand,
|
|
178
|
+
recommendedNextActions
|
|
179
|
+
};
|
|
180
|
+
enriched.summaryText = computeSummaryText(enriched);
|
|
181
|
+
return enriched;
|
|
182
|
+
}
|
package/src/summary-evals.js
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
generateVitalsSummary,
|
|
19
19
|
generateWorkoutCoachingSummary
|
|
20
20
|
} from './openrouter.js';
|
|
21
|
+
import { computeScoreBand } from './score-context.js';
|
|
21
22
|
|
|
22
23
|
const __filename = fileURLToPath(import.meta.url);
|
|
23
24
|
const __dirname = path.dirname(__filename);
|
|
@@ -94,6 +95,8 @@ export function buildSummaryEvalContext(snapshot, testCase) {
|
|
|
94
95
|
routedMetadata: routed?.metadata ?? null
|
|
95
96
|
};
|
|
96
97
|
}
|
|
98
|
+
case 'scoreCommentary':
|
|
99
|
+
return buildScoreCommentaryContext(snapshot, testCase);
|
|
97
100
|
default:
|
|
98
101
|
throw new Error(`Unsupported summary eval surface: ${testCase.surface}`);
|
|
99
102
|
}
|
|
@@ -121,6 +124,8 @@ export async function generateSummaryEvalOutputWithMetadata(testCase, context, s
|
|
|
121
124
|
|
|
122
125
|
let result;
|
|
123
126
|
switch (testCase.surface) {
|
|
127
|
+
case 'scoreCommentary':
|
|
128
|
+
return { output: testCase.output, metadata: {} };
|
|
124
129
|
case 'workout':
|
|
125
130
|
result = await generateWorkoutCoachingSummary(context, { apiKey });
|
|
126
131
|
break;
|
|
@@ -166,6 +171,77 @@ function normalizeText(value) {
|
|
|
166
171
|
return String(value ?? '').trim();
|
|
167
172
|
}
|
|
168
173
|
|
|
174
|
+
function parseJsonOutput(output) {
|
|
175
|
+
const normalized = normalizeText(output);
|
|
176
|
+
if (!normalized) return null;
|
|
177
|
+
try {
|
|
178
|
+
return JSON.parse(normalized);
|
|
179
|
+
} catch {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function scoreCommentaryPayload(output) {
|
|
185
|
+
const parsed = parseJsonOutput(output);
|
|
186
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
187
|
+
return parsed;
|
|
188
|
+
}
|
|
189
|
+
return { summaryText: normalizeText(output) };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function scoreCommentaryText(output) {
|
|
193
|
+
const payload = scoreCommentaryPayload(output);
|
|
194
|
+
const pieces = [
|
|
195
|
+
payload.headline,
|
|
196
|
+
payload.subhead,
|
|
197
|
+
payload.summaryText,
|
|
198
|
+
payload.scoreBand
|
|
199
|
+
];
|
|
200
|
+
const actions = Array.isArray(payload.recommendedNextActions) ? payload.recommendedNextActions : [];
|
|
201
|
+
for (const action of actions) {
|
|
202
|
+
pieces.push(action?.driverMessage, action?.action);
|
|
203
|
+
}
|
|
204
|
+
const deltaDrivers = Array.isArray(payload.deltaDrivers) ? payload.deltaDrivers : [];
|
|
205
|
+
for (const driver of deltaDrivers) {
|
|
206
|
+
pieces.push(driver?.component);
|
|
207
|
+
}
|
|
208
|
+
return pieces.filter(Boolean).join(' ');
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function scoreHistoryFromSnapshot(snapshot) {
|
|
212
|
+
const raw = snapshot?.incrementScore;
|
|
213
|
+
if (Array.isArray(raw)) return raw;
|
|
214
|
+
|
|
215
|
+
const history = Array.isArray(raw?.history) ? raw.history : [];
|
|
216
|
+
const latest = raw?.latest;
|
|
217
|
+
if (!latest) return history;
|
|
218
|
+
|
|
219
|
+
return [
|
|
220
|
+
latest,
|
|
221
|
+
...history.filter((entry) => {
|
|
222
|
+
if (!entry) return false;
|
|
223
|
+
if (latest?.id != null && entry?.id != null) return entry.id !== latest.id;
|
|
224
|
+
if (latest?.snapshotAt != null && entry?.snapshotAt != null) {
|
|
225
|
+
return entry.snapshotAt !== latest.snapshotAt;
|
|
226
|
+
}
|
|
227
|
+
return entry !== latest;
|
|
228
|
+
})
|
|
229
|
+
];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function buildScoreCommentaryContext(snapshot, testCase) {
|
|
233
|
+
const history = scoreHistoryFromSnapshot(snapshot);
|
|
234
|
+
const selector = testCase.selector ?? {};
|
|
235
|
+
if (selector.snapshotId) {
|
|
236
|
+
return history.find((entry) => entry.id === selector.snapshotId) ?? null;
|
|
237
|
+
}
|
|
238
|
+
if (selector.snapshotAt) {
|
|
239
|
+
return history.find((entry) => entry.snapshotAt === selector.snapshotAt) ?? null;
|
|
240
|
+
}
|
|
241
|
+
const index = Number.isInteger(selector.index) ? selector.index : 0;
|
|
242
|
+
return history[index] ?? null;
|
|
243
|
+
}
|
|
244
|
+
|
|
169
245
|
function paragraphCount(text) {
|
|
170
246
|
return normalizeText(text)
|
|
171
247
|
.split(/\n\s*\n/)
|
|
@@ -279,6 +355,9 @@ function collectAllowedExerciseNames(surface, context) {
|
|
|
279
355
|
for (const pr of context.repPrs ?? []) {
|
|
280
356
|
if (pr.exerciseName) names.add(pr.exerciseName);
|
|
281
357
|
}
|
|
358
|
+
for (const sc of context.planComparison?.setsComparison ?? []) {
|
|
359
|
+
if (sc.exercise) names.add(sc.exercise);
|
|
360
|
+
}
|
|
282
361
|
}
|
|
283
362
|
|
|
284
363
|
if (surface === 'cycle' && context && typeof context === 'object') {
|
|
@@ -307,6 +386,16 @@ function collectAllowedExerciseNames(surface, context) {
|
|
|
307
386
|
}
|
|
308
387
|
}
|
|
309
388
|
|
|
389
|
+
if (surface === 'scoreCommentary' && context && typeof context === 'object') {
|
|
390
|
+
for (const driver of [
|
|
391
|
+
...(context.topPositiveDrivers ?? []),
|
|
392
|
+
...(context.topNegativeDrivers ?? [])
|
|
393
|
+
]) {
|
|
394
|
+
if (driver?.relatedExerciseName) names.add(driver.relatedExerciseName);
|
|
395
|
+
if (driver?.exerciseName) names.add(driver.exerciseName);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
310
399
|
return [...names];
|
|
311
400
|
}
|
|
312
401
|
|
|
@@ -340,6 +429,7 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
|
|
|
340
429
|
};
|
|
341
430
|
}
|
|
342
431
|
|
|
432
|
+
const outputText = surface === 'scoreCommentary' ? scoreCommentaryText(output) : output;
|
|
343
433
|
const isStored = testCase.source === 'stored';
|
|
344
434
|
const allowed = new Set();
|
|
345
435
|
for (const name of [
|
|
@@ -352,7 +442,7 @@ function evaluateExerciseMentions(output, snapshot, context, surface, testCase)
|
|
|
352
442
|
}
|
|
353
443
|
}
|
|
354
444
|
const allNames = collectAllExerciseNames(snapshot);
|
|
355
|
-
const normalizedOutput = normalizeExerciseName(
|
|
445
|
+
const normalizedOutput = normalizeExerciseName(outputText);
|
|
356
446
|
const mentions = [];
|
|
357
447
|
|
|
358
448
|
for (const exerciseName of allNames) {
|
|
@@ -453,7 +543,9 @@ function evaluateNoInsight(output, testCase) {
|
|
|
453
543
|
}
|
|
454
544
|
|
|
455
545
|
function evaluateShape(output, testCase) {
|
|
456
|
-
const normalized =
|
|
546
|
+
const normalized = testCase.surface === 'scoreCommentary'
|
|
547
|
+
? scoreCommentaryText(output)
|
|
548
|
+
: normalizeText(output);
|
|
457
549
|
if (normalized === 'NO_INSIGHT') {
|
|
458
550
|
return {
|
|
459
551
|
key: 'shape',
|
|
@@ -462,8 +554,8 @@ function evaluateShape(output, testCase) {
|
|
|
462
554
|
};
|
|
463
555
|
}
|
|
464
556
|
|
|
465
|
-
const sentences = sentenceCount(
|
|
466
|
-
const paragraphs = paragraphCount(
|
|
557
|
+
const sentences = sentenceCount(normalized);
|
|
558
|
+
const paragraphs = paragraphCount(normalized);
|
|
467
559
|
const isStored = testCase.source === 'stored';
|
|
468
560
|
let passed = true;
|
|
469
561
|
const reasons = [];
|
|
@@ -527,6 +619,12 @@ function evaluateShape(output, testCase) {
|
|
|
527
619
|
reasons.push(`Ask-coach answers must be 1-12 sentences, got ${sentences}.`);
|
|
528
620
|
}
|
|
529
621
|
break;
|
|
622
|
+
case 'scoreCommentary':
|
|
623
|
+
if (sentences < 1 || sentences > 8) {
|
|
624
|
+
passed = false;
|
|
625
|
+
reasons.push(`Score commentary must be 1-8 sentences, got ${sentences}.`);
|
|
626
|
+
}
|
|
627
|
+
break;
|
|
530
628
|
default:
|
|
531
629
|
break;
|
|
532
630
|
}
|
|
@@ -1124,6 +1222,173 @@ function evaluateAskToolProvenance(output, context, testCase) {
|
|
|
1124
1222
|
};
|
|
1125
1223
|
}
|
|
1126
1224
|
|
|
1225
|
+
function firstAction(payload) {
|
|
1226
|
+
const actions = Array.isArray(payload?.recommendedNextActions) ? payload.recommendedNextActions : [];
|
|
1227
|
+
return actions.find((action) => typeof action?.action === 'string' && action.action.trim());
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
function evaluateScoreCommentaryAction(output, context, testCase) {
|
|
1231
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1232
|
+
return { key: 'score_commentary_action', passed: true, reason: 'Not score commentary.' };
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
const payload = scoreCommentaryPayload(output);
|
|
1236
|
+
const hasNegativeDrivers = Array.isArray(context?.topNegativeDrivers) && context.topNegativeDrivers.length > 0;
|
|
1237
|
+
const hasAction = Boolean(firstAction(payload));
|
|
1238
|
+
const noTrainingSignal = context?.dataTier === 'noTrainingSignal';
|
|
1239
|
+
const failures = [];
|
|
1240
|
+
|
|
1241
|
+
if (noTrainingSignal && hasAction) {
|
|
1242
|
+
failures.push('No-training-signal score commentary must not include a recommended action.');
|
|
1243
|
+
}
|
|
1244
|
+
if (!noTrainingSignal && hasNegativeDrivers && !hasAction) {
|
|
1245
|
+
failures.push('Score commentary with negative drivers must include a recommended action.');
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
return {
|
|
1249
|
+
key: 'score_commentary_action',
|
|
1250
|
+
passed: failures.length === 0,
|
|
1251
|
+
reason: failures.length === 0
|
|
1252
|
+
? 'Score commentary action presence matches score state.'
|
|
1253
|
+
: failures.join(' ')
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function driverKeyword(driver) {
|
|
1258
|
+
const text = normalizeText(driver?.message ?? driver?.label ?? driver?.driverMessage ?? driver?.component ?? '');
|
|
1259
|
+
const tokens = text
|
|
1260
|
+
.toLowerCase()
|
|
1261
|
+
.split(/[^a-z0-9]+/)
|
|
1262
|
+
.filter((token) => token.length >= 4 && !['score', 'sets', 'work', 'this', 'that', 'with', 'from', 'your'].includes(token));
|
|
1263
|
+
return tokens[0] ?? null;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
function evaluateScoreCommentarySynthesis(output, context, testCase) {
|
|
1267
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1268
|
+
return { key: 'score_commentary_synthesis', passed: true, reason: 'Not score commentary.' };
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
const positives = context?.topPositiveDrivers ?? [];
|
|
1272
|
+
const negatives = context?.topNegativeDrivers ?? [];
|
|
1273
|
+
if (positives.length === 0 || negatives.length === 0) {
|
|
1274
|
+
return {
|
|
1275
|
+
key: 'score_commentary_synthesis',
|
|
1276
|
+
passed: true,
|
|
1277
|
+
reason: 'Mixed-driver synthesis is not required for one-sided commentary.'
|
|
1278
|
+
};
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
const text = scoreCommentaryText(output).toLowerCase();
|
|
1282
|
+
const positiveKeyword = driverKeyword(positives[0]);
|
|
1283
|
+
const negativeKeyword = driverKeyword(negatives[0]);
|
|
1284
|
+
const hasPositive = positiveKeyword ? text.includes(positiveKeyword) : true;
|
|
1285
|
+
const hasNegative = negativeKeyword ? text.includes(negativeKeyword) : true;
|
|
1286
|
+
const hasContrast = /\bbut\b|\bwhile\b|\balthough\b|\bhowever\b/.test(text);
|
|
1287
|
+
|
|
1288
|
+
return {
|
|
1289
|
+
key: 'score_commentary_synthesis',
|
|
1290
|
+
passed: hasPositive && hasNegative && hasContrast,
|
|
1291
|
+
reason: hasPositive && hasNegative && hasContrast
|
|
1292
|
+
? 'Mixed positive and negative drivers are synthesized.'
|
|
1293
|
+
: 'Mixed-driver commentary must reference both directions with contrast language.'
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function evaluateScoreCommentaryExerciseInvention(output, snapshot, context, testCase) {
|
|
1298
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1299
|
+
return { key: 'score_commentary_no_exercise_invention', passed: true, reason: 'Not score commentary.' };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
const allowedText = [
|
|
1303
|
+
...(context?.topPositiveDrivers ?? []),
|
|
1304
|
+
...(context?.topNegativeDrivers ?? [])
|
|
1305
|
+
].map((driver) => [
|
|
1306
|
+
driver?.message,
|
|
1307
|
+
driver?.label,
|
|
1308
|
+
driver?.relatedExerciseName,
|
|
1309
|
+
driver?.relatedExerciseSlug,
|
|
1310
|
+
driver?.exerciseName
|
|
1311
|
+
].filter(Boolean).join(' ')).join(' ');
|
|
1312
|
+
const normalizedAllowed = normalizeExerciseName(allowedText);
|
|
1313
|
+
const normalizedOutput = normalizeExerciseName(scoreCommentaryText(output));
|
|
1314
|
+
const invented = [];
|
|
1315
|
+
|
|
1316
|
+
for (const exerciseName of collectAllExerciseNames(snapshot)) {
|
|
1317
|
+
const normalizedName = normalizeExerciseName(exerciseName);
|
|
1318
|
+
if (!normalizedName) continue;
|
|
1319
|
+
const pattern = new RegExp(`(?<!\\S)${escapeRegex(normalizedName)}(?!\\S)`);
|
|
1320
|
+
if (pattern.test(normalizedOutput) && !pattern.test(normalizedAllowed)) {
|
|
1321
|
+
invented.push(exerciseName);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
return {
|
|
1326
|
+
key: 'score_commentary_no_exercise_invention',
|
|
1327
|
+
passed: invented.length === 0,
|
|
1328
|
+
reason: invented.length === 0
|
|
1329
|
+
? 'Score commentary only names exercises present in score drivers.'
|
|
1330
|
+
: `Invented exercise mention(s): ${uniqueStrings(invented).join(', ')}`
|
|
1331
|
+
};
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
function evaluateScoreCommentaryBand(output, context, testCase) {
|
|
1335
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1336
|
+
return { key: 'score_commentary_score_band', passed: true, reason: 'Not score commentary.' };
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
const payload = scoreCommentaryPayload(output);
|
|
1340
|
+
const expected = computeScoreBand(context?.score);
|
|
1341
|
+
const actual = payload.scoreBand ?? null;
|
|
1342
|
+
return {
|
|
1343
|
+
key: 'score_commentary_score_band',
|
|
1344
|
+
passed: actual === expected,
|
|
1345
|
+
reason: actual === expected
|
|
1346
|
+
? 'Score band matches canonical thresholds.'
|
|
1347
|
+
: `Expected scoreBand ${expected ?? 'null'}, got ${actual ?? 'null'}.`
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
function evaluateScoreCommentaryTone(output, testCase) {
|
|
1352
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1353
|
+
return { key: 'score_commentary_tone', passed: true, reason: 'Not score commentary.' };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
const text = scoreCommentaryText(output);
|
|
1357
|
+
const failures = [];
|
|
1358
|
+
if (text.includes('!')) failures.push('exclamation mark');
|
|
1359
|
+
if (/\b(?:great job|awesome|crushing it|amazing work)\b/i.test(text)) failures.push('cheerleading phrase');
|
|
1360
|
+
if (/[\u{1F300}-\u{1FAFF}]/u.test(text)) failures.push('emoji');
|
|
1361
|
+
|
|
1362
|
+
return {
|
|
1363
|
+
key: 'score_commentary_tone',
|
|
1364
|
+
passed: failures.length === 0,
|
|
1365
|
+
reason: failures.length === 0
|
|
1366
|
+
? 'Score commentary tone is calm and non-cheerleading.'
|
|
1367
|
+
: `Tone violation(s): ${failures.join(', ')}.`
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
function evaluateScoreCommentaryLength(output, testCase) {
|
|
1372
|
+
if (testCase.surface !== 'scoreCommentary') {
|
|
1373
|
+
return { key: 'score_commentary_length', passed: true, reason: 'Not score commentary.' };
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
const payload = scoreCommentaryPayload(output);
|
|
1377
|
+
const summary = normalizeText(payload.subhead ?? payload.summaryText);
|
|
1378
|
+
const action = normalizeText(firstAction(payload)?.action);
|
|
1379
|
+
const failures = [];
|
|
1380
|
+
if (summary.length > 140) failures.push(`summaryText/subhead is ${summary.length} chars, expected <= 140.`);
|
|
1381
|
+
if (action && action.length > 140) failures.push(`action is ${action.length} chars, expected <= 140.`);
|
|
1382
|
+
|
|
1383
|
+
return {
|
|
1384
|
+
key: 'score_commentary_length',
|
|
1385
|
+
passed: failures.length === 0,
|
|
1386
|
+
reason: failures.length === 0
|
|
1387
|
+
? 'Score commentary text fits length bounds.'
|
|
1388
|
+
: failures.join(' ')
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1127
1392
|
function personaEvalConfig(testCase) {
|
|
1128
1393
|
if (testCase.personaEval === false) return null;
|
|
1129
1394
|
if (testCase.personaEval && typeof testCase.personaEval === 'object') {
|
|
@@ -1234,6 +1499,12 @@ export function evaluateSummaryOutputFromSnapshot(testCase, snapshot, output) {
|
|
|
1234
1499
|
evaluateWorkoutClaims(output, context, testCase),
|
|
1235
1500
|
evaluateAskClaims(output, snapshot, testCase),
|
|
1236
1501
|
evaluateAskToolProvenance(output, context, testCase),
|
|
1502
|
+
evaluateScoreCommentaryAction(output, context, testCase),
|
|
1503
|
+
evaluateScoreCommentarySynthesis(output, context, testCase),
|
|
1504
|
+
evaluateScoreCommentaryExerciseInvention(output, snapshot, context, testCase),
|
|
1505
|
+
evaluateScoreCommentaryBand(output, context, testCase),
|
|
1506
|
+
evaluateScoreCommentaryTone(output, testCase),
|
|
1507
|
+
evaluateScoreCommentaryLength(output, testCase),
|
|
1237
1508
|
evaluatePersonaMotivation(output, context, testCase)
|
|
1238
1509
|
];
|
|
1239
1510
|
|
|
@@ -1269,6 +1540,8 @@ function genericForbiddenPhrasesForSurface(surface) {
|
|
|
1269
1540
|
return [];
|
|
1270
1541
|
case 'ask':
|
|
1271
1542
|
return [];
|
|
1543
|
+
case 'scoreCommentary':
|
|
1544
|
+
return ['great job', 'awesome', 'crushing it', 'amazing work'];
|
|
1272
1545
|
default:
|
|
1273
1546
|
return [];
|
|
1274
1547
|
}
|
package/src/sync-service.js
CHANGED
|
@@ -3,6 +3,7 @@ import { anonymizeAccountId } from './anonymize.js';
|
|
|
3
3
|
import { capabilities as cliCapabilities, contractVersion, officialCommands } from './contract.js';
|
|
4
4
|
import { executeReadCommand } from './queries.js';
|
|
5
5
|
import { sanitizeHistory, detectSystemPromptLeak, stripXMLTagBlocks } from './prompt-security.js';
|
|
6
|
+
import { enrichScoreSnapshots } from './score-context.js';
|
|
6
7
|
|
|
7
8
|
const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
|
|
8
9
|
const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
@@ -783,6 +784,27 @@ function routeRequest(url, method) {
|
|
|
783
784
|
};
|
|
784
785
|
}
|
|
785
786
|
|
|
787
|
+
if (pathname === '/cli/increment-score/current') {
|
|
788
|
+
return {
|
|
789
|
+
command: 'increment-score-current',
|
|
790
|
+
options: {
|
|
791
|
+
historyDays: url.searchParams.get('historyDays') ?? undefined
|
|
792
|
+
}
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
{
|
|
797
|
+
const coachToolMatch = pathname.match(/^\/cli\/coach-tools\/([^/]+)$/);
|
|
798
|
+
if (coachToolMatch) {
|
|
799
|
+
return {
|
|
800
|
+
command: 'coach-tool',
|
|
801
|
+
options: {
|
|
802
|
+
toolName: decodeURIComponent(coachToolMatch[1])
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
786
808
|
if (pathname === '/cli/account') {
|
|
787
809
|
return { command: 'delete-account', options: {} };
|
|
788
810
|
}
|
|
@@ -2879,7 +2901,8 @@ export function createSyncServiceRequestHandler({
|
|
|
2879
2901
|
return;
|
|
2880
2902
|
}
|
|
2881
2903
|
try {
|
|
2882
|
-
const
|
|
2904
|
+
const rawSnapshots = await listScoreSnapshotsForAccount(account, route.options ?? {});
|
|
2905
|
+
const snapshots = enrichScoreSnapshots(rawSnapshots);
|
|
2883
2906
|
json(response, 200, { snapshots });
|
|
2884
2907
|
return;
|
|
2885
2908
|
} catch (error) {
|
|
@@ -3476,6 +3499,58 @@ export function createSyncServiceRequestHandler({
|
|
|
3476
3499
|
// Parse comma-separated exclude param into a Set for AI context builders
|
|
3477
3500
|
const parseExclude = (raw) => new Set((raw ?? '').split(',').map((s) => s.trim()).filter(Boolean));
|
|
3478
3501
|
const aiUser = anonymizeAccountId(account.id);
|
|
3502
|
+
const hydrateIncrementScore = async (limit = 14) => {
|
|
3503
|
+
if (!listScoreSnapshotsForAccount) return;
|
|
3504
|
+
const scoreSnapshots = await listScoreSnapshotsForAccount(account, { limit }) ?? [];
|
|
3505
|
+
if (scoreSnapshots.length > 0) {
|
|
3506
|
+
const enrichedSnapshots = enrichScoreSnapshots(scoreSnapshots);
|
|
3507
|
+
snapshot.incrementScore = {
|
|
3508
|
+
latest: enrichedSnapshots[0],
|
|
3509
|
+
history: enrichedSnapshots
|
|
3510
|
+
};
|
|
3511
|
+
}
|
|
3512
|
+
};
|
|
3513
|
+
|
|
3514
|
+
if (route.command === 'increment-score-current') {
|
|
3515
|
+
if (request.method !== 'GET') {
|
|
3516
|
+
methodNotAllowed(response, 'Use GET for /cli/increment-score/current.');
|
|
3517
|
+
return;
|
|
3518
|
+
}
|
|
3519
|
+
|
|
3520
|
+
try {
|
|
3521
|
+
await hydrateIncrementScore(route.options.historyDays ?? 14);
|
|
3522
|
+
const queries = await import('./queries.js');
|
|
3523
|
+
json(response, 200, queries.incrementScoreCurrent(snapshot, route.options));
|
|
3524
|
+
return;
|
|
3525
|
+
} catch (error) {
|
|
3526
|
+
badRequest(response, error.message);
|
|
3527
|
+
return;
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
|
|
3531
|
+
if (route.command === 'coach-tool') {
|
|
3532
|
+
if (request.method !== 'POST') {
|
|
3533
|
+
methodNotAllowed(response, 'Use POST for /cli/coach-tools/:toolName.');
|
|
3534
|
+
return;
|
|
3535
|
+
}
|
|
3536
|
+
|
|
3537
|
+
try {
|
|
3538
|
+
const body = await readJsonBody(request);
|
|
3539
|
+
const queries = await import('./queries.js');
|
|
3540
|
+
if (!queries.COACH_READ_TOOL_NAMES.includes(route.options.toolName)) {
|
|
3541
|
+
notFound(response, `Unknown coach read tool: ${route.options.toolName}`);
|
|
3542
|
+
return;
|
|
3543
|
+
}
|
|
3544
|
+
if (route.options.toolName === 'get_increment_score') {
|
|
3545
|
+
await hydrateIncrementScore(body?.historyDays ?? 14);
|
|
3546
|
+
}
|
|
3547
|
+
json(response, 200, queries.executeCoachReadTool(snapshot, route.options.toolName, body ?? {}));
|
|
3548
|
+
return;
|
|
3549
|
+
} catch (error) {
|
|
3550
|
+
badRequest(response, error.message);
|
|
3551
|
+
return;
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3479
3554
|
|
|
3480
3555
|
if (route.command === 'workout-summary-ai') {
|
|
3481
3556
|
const sessionId = route.options['session-id'];
|
|
@@ -4074,9 +4149,10 @@ export function createSyncServiceRequestHandler({
|
|
|
4074
4149
|
}
|
|
4075
4150
|
}
|
|
4076
4151
|
if (scoreSnapshots.length > 0) {
|
|
4152
|
+
const enrichedSnapshots = enrichScoreSnapshots(scoreSnapshots);
|
|
4077
4153
|
snapshot.incrementScore = {
|
|
4078
|
-
latest:
|
|
4079
|
-
history:
|
|
4154
|
+
latest: enrichedSnapshots[0],
|
|
4155
|
+
history: enrichedSnapshots
|
|
4080
4156
|
};
|
|
4081
4157
|
}
|
|
4082
4158
|
|
package/src/transport.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readSnapshot, resolveSnapshotSource } from './local.js';
|
|
2
2
|
import { createRemoteTransport } from './remote.js';
|
|
3
|
-
import { executeReadCommand } from './queries.js';
|
|
3
|
+
import { executeCoachReadTool, executeReadCommand } from './queries.js';
|
|
4
4
|
import { isSessionExpired } from './state.js';
|
|
5
5
|
|
|
6
6
|
function prefersLocal(options) {
|
|
@@ -33,6 +33,14 @@ function createLocalTransport(snapshotSource) {
|
|
|
33
33
|
|
|
34
34
|
return result.payload;
|
|
35
35
|
},
|
|
36
|
+
async executeCoachReadTool(toolName, input = {}) {
|
|
37
|
+
if (!snapshotSource) {
|
|
38
|
+
throw snapshotNotFoundError();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const snapshot = await readSnapshot(snapshotSource.path);
|
|
42
|
+
return executeCoachReadTool(snapshot, toolName, input);
|
|
43
|
+
},
|
|
36
44
|
async executeWriteCommand() {
|
|
37
45
|
const error = new Error('Write commands require a remote session. Run incremnt login first.');
|
|
38
46
|
error.code = 'WRITE_REQUIRES_REMOTE';
|