incremnt 0.4.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 +16 -3
- package/package.json +20 -4
- package/src/anonymize.js +12 -0
- package/src/coach-bakeoff.js +300 -0
- package/src/coach-facts.js +100 -0
- package/src/coach-prompt-variants.js +106 -0
- package/src/contract.js +32 -5
- package/src/exercise-aliases.js +163 -0
- package/src/format.js +104 -1
- package/src/increment-score-replay-data.js +486 -0
- package/src/increment-score-replay.js +822 -0
- package/src/lib.js +14 -2
- package/src/local.js +3 -3
- package/src/mcp.js +67 -0
- package/src/openrouter.js +979 -182
- package/src/program-phase-resolver.js +206 -0
- package/src/prompt-security.js +1 -1
- package/src/promptfoo-domain-assert.cjs +4 -0
- package/src/promptfoo-evals.js +166 -0
- package/src/promptfoo-langfuse-scores.js +354 -0
- package/src/promptfoo-provider.cjs +14 -0
- package/src/promptfoo-tests.cjs +4 -0
- package/src/queries.js +2281 -197
- package/src/remote.js +99 -6
- package/src/score-context.js +182 -0
- package/src/state.js +9 -2
- package/src/stored-summary-eval-report.js +85 -52
- package/src/summary-evals.js +900 -21
- package/src/sync-service.js +1275 -131
- package/src/transport.js +9 -1
package/README.md
CHANGED
|
@@ -67,13 +67,21 @@ 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 |
|
|
71
|
+
| `increment-score history` | Historical Increment Score snapshots |
|
|
72
|
+
| `increment-score upload --file <file>` | Upload Increment Score snapshots |
|
|
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 |
|
|
76
|
+
| `programs share create --program-id <id>` | Create a public share token for a program |
|
|
77
|
+
| `programs share list --program-id <id>` | List share artifacts for a program |
|
|
78
|
+
| `programs share fetch --token <token>` | Fetch a publicly shared program |
|
|
79
|
+
| `programs share revoke --share-id <id>` | Revoke a previously issued share |
|
|
73
80
|
| `browse` | Interactive Ink browser for sessions, programs, records, goals, cycles, and health |
|
|
74
81
|
| `tui` | Alias for `browse` |
|
|
75
82
|
| `mcp install` | Register `incremnt-mcp` with Claude Desktop, Claude Code, and Codex CLI |
|
|
76
|
-
| `login` | Authenticate with the hosted sync service |
|
|
83
|
+
| `login` | Authenticate with the hosted sync service (opens browser) |
|
|
84
|
+
| `login --no-browser` | Headless login flow for SSH/Termius/remote shells |
|
|
77
85
|
| `logout` | Clear stored session |
|
|
78
86
|
| `status` | Show current mode, auth state, and config paths |
|
|
79
87
|
| `contract` | Machine-readable command surface for scripts |
|
|
@@ -114,7 +122,7 @@ incremnt login --session-file ~/Downloads/session.json
|
|
|
114
122
|
|
|
115
123
|
## MCP Server
|
|
116
124
|
|
|
117
|
-
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.
|
|
118
126
|
|
|
119
127
|
Run `incremnt mcp install` to auto-register the server with Claude Desktop, Claude Code, and Codex CLI (see [Setup](#setup) above).
|
|
120
128
|
|
|
@@ -135,7 +143,12 @@ The MCP server uses the same auth session as the CLI — run `incremnt login` fi
|
|
|
135
143
|
|
|
136
144
|
### MCP tool surface
|
|
137
145
|
|
|
138
|
-
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.
|
|
139
152
|
|
|
140
153
|
You can inspect the exact machine-readable contract at any time:
|
|
141
154
|
|
package/package.json
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "incremnt",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Command-line tool for querying your incremnt strength training data",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.0.0"
|
|
9
|
+
},
|
|
7
10
|
"bin": {
|
|
8
11
|
"incremnt": "./src/index.js",
|
|
9
12
|
"incremnt-mcp": "./src/mcp.js"
|
|
@@ -17,15 +20,28 @@
|
|
|
17
20
|
"test:evals:real": "SUMMARY_EVAL_CASE_SET=anonymized-render node --test test/summary-evals.test.js",
|
|
18
21
|
"evals:stored": "node ./scripts/run-summary-evals.js",
|
|
19
22
|
"evals:stored:render": "node ./scripts/run-render-summary-evals.js",
|
|
23
|
+
"evals:promptfoo": "promptfoo eval -c promptfooconfig.yaml",
|
|
24
|
+
"evals:promptfoo:production-replay": "SUMMARY_EVAL_CASE_SET=production-replay promptfoo eval -c promptfooconfig.yaml",
|
|
25
|
+
"evals:promptfoo:live": "PROMPTFOO_LIVE=1 SUMMARY_EVALS_LIVE=1 promptfoo eval -c promptfooconfig.yaml",
|
|
20
26
|
"evals:workout-bakeoff": "node ./scripts/run-workout-prompt-bakeoff.js",
|
|
27
|
+
"evals:coach-bakeoff": "node ./scripts/run-production-coach-bakeoff.js",
|
|
28
|
+
"evals:coach-replay": "node ./scripts/run-production-coach-replay.js",
|
|
29
|
+
"increment-score:replay": "node ./scripts/run-increment-score-replay.js",
|
|
30
|
+
"increment-score:replay-data": "node ./scripts/export-increment-score-replay-data.js",
|
|
31
|
+
"langfuse:smoke": "node ./scripts/smoke-langfuse-score.js",
|
|
21
32
|
"dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js",
|
|
22
33
|
"postinstall": "node -e \"process.stdout.write('\\n incremnt MCP server installed.\\n Run: incremnt mcp install\\n to register it with Claude and Codex.\\n\\n')\""
|
|
23
34
|
},
|
|
24
35
|
"dependencies": {
|
|
36
|
+
"@langfuse/tracing": "^5.2.0",
|
|
25
37
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
26
38
|
"chalk": "^5.6.2",
|
|
27
|
-
"ink": "^
|
|
28
|
-
"
|
|
29
|
-
"
|
|
39
|
+
"ink": "^7.0.1",
|
|
40
|
+
"openai": "^6.34.0",
|
|
41
|
+
"react": "^19.2.5",
|
|
42
|
+
"zod": "^4.3.6"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"promptfoo": "^0.121.8"
|
|
30
46
|
}
|
|
31
47
|
}
|
package/src/anonymize.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
export function anonymizeAccountId(accountId) {
|
|
4
|
+
if (typeof accountId !== 'string' || !accountId.trim()) {
|
|
5
|
+
return 'anon:unknown';
|
|
6
|
+
}
|
|
7
|
+
const digest = createHash('sha256')
|
|
8
|
+
.update(`account:${accountId}`)
|
|
9
|
+
.digest('hex')
|
|
10
|
+
.slice(0, 12);
|
|
11
|
+
return `anon:${digest}`;
|
|
12
|
+
}
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { askContext, workoutSummaryContext } from './queries.js';
|
|
2
|
+
import { COACH_PROMPT_VARIANTS } from './coach-prompt-variants.js';
|
|
3
|
+
|
|
4
|
+
export const DEFAULT_COACH_BAKEOFF_MODELS = Object.freeze([
|
|
5
|
+
'anthropic/claude-haiku-4.5',
|
|
6
|
+
'openai/gpt-5.4-mini',
|
|
7
|
+
'google/gemini-3-flash-preview',
|
|
8
|
+
'z-ai/glm-5.1'
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
export const DEFAULT_COACH_REPLAY_CONFIGS = Object.freeze([
|
|
12
|
+
{
|
|
13
|
+
name: 'current-haiku',
|
|
14
|
+
label: 'Current Haiku',
|
|
15
|
+
model: 'anthropic/claude-haiku-4.5',
|
|
16
|
+
workoutPrompt: COACH_PROMPT_VARIANTS.current.workoutPrompt,
|
|
17
|
+
askPrompt: COACH_PROMPT_VARIANTS.current.askPrompt
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: 'revised-haiku',
|
|
21
|
+
label: 'Revised Haiku',
|
|
22
|
+
model: 'anthropic/claude-haiku-4.5',
|
|
23
|
+
workoutPrompt: COACH_PROMPT_VARIANTS.personality.workoutPrompt,
|
|
24
|
+
askPrompt: COACH_PROMPT_VARIANTS.warm.askPrompt
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
name: 'revised-mini',
|
|
28
|
+
label: 'Revised GPT-5.4 Mini',
|
|
29
|
+
model: 'openai/gpt-5.4-mini',
|
|
30
|
+
workoutPrompt: COACH_PROMPT_VARIANTS.personality.workoutPrompt,
|
|
31
|
+
askPrompt: COACH_PROMPT_VARIANTS.warm.askPrompt
|
|
32
|
+
}
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
function stableSortByDateDesc(items, selector) {
|
|
36
|
+
return [...items].sort((lhs, rhs) => String(selector(rhs)).localeCompare(String(selector(lhs))));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function completionDateForSession(session) {
|
|
40
|
+
return session?.completedAt
|
|
41
|
+
?? session?.updatedAt
|
|
42
|
+
?? session?.startTime
|
|
43
|
+
?? session?.date
|
|
44
|
+
?? '';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildStoredWorkoutEvalCase(session, snapshotLabel = 'snapshot') {
|
|
48
|
+
const anyOf = [
|
|
49
|
+
...(session.exercises ?? []).map((exercise) => exercise.name),
|
|
50
|
+
session.dayName
|
|
51
|
+
].filter(Boolean);
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
id: `stored-workout-${session.id}`,
|
|
55
|
+
name: `Stored workout summary ${session.id}`,
|
|
56
|
+
surface: 'workout',
|
|
57
|
+
source: 'stored',
|
|
58
|
+
snapshotLabel,
|
|
59
|
+
selector: { sessionId: session.id },
|
|
60
|
+
output: session.summary?.aiCoachNotes ?? '',
|
|
61
|
+
metadata: null,
|
|
62
|
+
requiredMentions: [],
|
|
63
|
+
requiredAnyOfMentions: anyOf,
|
|
64
|
+
forbiddenPhrases: ['solid progress', 'trust the process', 'keep it up', 'quality work', 'in a great place', 'continue progressive overload', 'as fatigue accumulates'],
|
|
65
|
+
forbiddenMentions: [],
|
|
66
|
+
personaEval: { profile: 'motivated-athlete' },
|
|
67
|
+
expectNoInsight: false,
|
|
68
|
+
shouldPass: true
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function buildStoredAskEvalCase(conversation, snapshotLabel = 'snapshot') {
|
|
73
|
+
return {
|
|
74
|
+
id: `stored-ask-${conversation.id}`,
|
|
75
|
+
name: `Stored ask conversation ${conversation.id}`,
|
|
76
|
+
surface: 'ask',
|
|
77
|
+
source: 'stored',
|
|
78
|
+
snapshotLabel,
|
|
79
|
+
context: { question: '', conversationId: conversation.id },
|
|
80
|
+
output: extractAskReplay(conversation)?.answer ?? '',
|
|
81
|
+
metadata: null,
|
|
82
|
+
requiredMentions: [],
|
|
83
|
+
requiredAnyOfMentions: [],
|
|
84
|
+
forbiddenPhrases: [],
|
|
85
|
+
forbiddenMentions: [],
|
|
86
|
+
personaEval: { profile: 'motivated-athlete' },
|
|
87
|
+
expectNoInsight: false,
|
|
88
|
+
shouldPass: true
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function findLatestStoredWorkoutSummarySession(snapshot) {
|
|
93
|
+
return stableSortByDateDesc(
|
|
94
|
+
(snapshot.sessions ?? []).filter((session) => session?.summary?.aiCoachNotes),
|
|
95
|
+
(session) => completionDateForSession(session)
|
|
96
|
+
)[0] ?? null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function extractAskReplay(conversation) {
|
|
100
|
+
if (!conversation || !Array.isArray(conversation.messages)) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const assistantIndex = [...conversation.messages]
|
|
105
|
+
.map((message, index) => ({ message, index }))
|
|
106
|
+
.filter(({ message }) => message?.role === 'assistant' && typeof message?.content === 'string' && message.content.trim())
|
|
107
|
+
.at(-1)?.index;
|
|
108
|
+
|
|
109
|
+
if (assistantIndex == null) {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let questionIndex = -1;
|
|
114
|
+
for (let index = assistantIndex - 1; index >= 0; index -= 1) {
|
|
115
|
+
const message = conversation.messages[index];
|
|
116
|
+
if (message?.role === 'user' && typeof message?.content === 'string' && message.content.trim()) {
|
|
117
|
+
questionIndex = index;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (questionIndex === -1) {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const history = conversation.messages
|
|
127
|
+
.slice(0, questionIndex)
|
|
128
|
+
.filter((message) => ['user', 'assistant'].includes(message?.role))
|
|
129
|
+
.map((message) => ({ role: message.role, content: message.content }));
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
question: conversation.messages[questionIndex].content,
|
|
133
|
+
history,
|
|
134
|
+
answer: conversation.messages[assistantIndex].content
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function findLatestAskConversation(snapshot) {
|
|
139
|
+
return stableSortByDateDesc(
|
|
140
|
+
(snapshot.askConversations ?? []).filter((conversation) => extractAskReplay(conversation)),
|
|
141
|
+
(conversation) => conversation?.createdAt ?? ''
|
|
142
|
+
)[0] ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function buildCoachBakeoffTargets(snapshot, { sessionId, conversationId, exclude = new Set() } = {}) {
|
|
146
|
+
const selectedSession = sessionId
|
|
147
|
+
? (snapshot.sessions ?? []).find((session) => session.id === sessionId) ?? null
|
|
148
|
+
: findLatestStoredWorkoutSummarySession(snapshot);
|
|
149
|
+
if (!selectedSession) {
|
|
150
|
+
throw new Error('No workout session with stored AI coach notes found.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const selectedConversation = conversationId
|
|
154
|
+
? (snapshot.askConversations ?? []).find((conversation) => conversation.id === conversationId) ?? null
|
|
155
|
+
: findLatestAskConversation(snapshot);
|
|
156
|
+
if (!selectedConversation) {
|
|
157
|
+
throw new Error('No ask conversation with a replayable user/assistant pair found.');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const askReplay = extractAskReplay(selectedConversation);
|
|
161
|
+
if (!askReplay) {
|
|
162
|
+
throw new Error(`Could not extract ask replay from conversation ${selectedConversation.id}.`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const workout = {
|
|
166
|
+
sessionId: selectedSession.id,
|
|
167
|
+
dayName: selectedSession.dayName ?? 'Session',
|
|
168
|
+
completedAt: completionDateForSession(selectedSession),
|
|
169
|
+
storedOutput: selectedSession.summary?.aiCoachNotes ?? null,
|
|
170
|
+
context: workoutSummaryContext(snapshot, selectedSession.id, { exclude })
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
if (!workout.context) {
|
|
174
|
+
throw new Error(`Could not build workout context for session ${selectedSession.id}.`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
workout,
|
|
179
|
+
ask: {
|
|
180
|
+
conversationId: selectedConversation.id,
|
|
181
|
+
createdAt: selectedConversation.createdAt ?? null,
|
|
182
|
+
question: askReplay.question,
|
|
183
|
+
history: askReplay.history,
|
|
184
|
+
storedOutput: askReplay.answer,
|
|
185
|
+
context: askContext(snapshot, { exclude })
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function harvestRecentCoachReplayCases(
|
|
191
|
+
snapshot,
|
|
192
|
+
{ maxWorkoutCases = 5, maxAskCases = 5, exclude = new Set(), snapshotLabel = 'snapshot' } = {}
|
|
193
|
+
) {
|
|
194
|
+
const askContextText = askContext(snapshot, { exclude });
|
|
195
|
+
|
|
196
|
+
const workoutCases = stableSortByDateDesc(
|
|
197
|
+
(snapshot.sessions ?? []).filter((session) => session?.summary?.aiCoachNotes),
|
|
198
|
+
(session) => completionDateForSession(session)
|
|
199
|
+
)
|
|
200
|
+
.slice(0, maxWorkoutCases)
|
|
201
|
+
.map((session) => ({
|
|
202
|
+
id: session.id,
|
|
203
|
+
surface: 'workout',
|
|
204
|
+
label: `${session.dayName ?? 'Session'} ${completionDateForSession(session)}`,
|
|
205
|
+
completedAt: completionDateForSession(session),
|
|
206
|
+
storedOutput: session.summary?.aiCoachNotes ?? '',
|
|
207
|
+
context: workoutSummaryContext(snapshot, session.id, { exclude }),
|
|
208
|
+
evalCase: buildStoredWorkoutEvalCase(session, snapshotLabel)
|
|
209
|
+
}))
|
|
210
|
+
.filter((entry) => entry.context);
|
|
211
|
+
|
|
212
|
+
const askCases = stableSortByDateDesc(
|
|
213
|
+
(snapshot.askConversations ?? []).filter((conversation) => extractAskReplay(conversation)),
|
|
214
|
+
(conversation) => conversation?.createdAt ?? ''
|
|
215
|
+
)
|
|
216
|
+
.slice(0, maxAskCases)
|
|
217
|
+
.map((conversation) => {
|
|
218
|
+
const replay = extractAskReplay(conversation);
|
|
219
|
+
return {
|
|
220
|
+
id: conversation.id,
|
|
221
|
+
surface: 'ask',
|
|
222
|
+
label: `Ask ${conversation.createdAt ?? conversation.id}`,
|
|
223
|
+
createdAt: conversation.createdAt ?? null,
|
|
224
|
+
question: replay.question,
|
|
225
|
+
history: replay.history,
|
|
226
|
+
storedOutput: replay.answer,
|
|
227
|
+
context: askContextText,
|
|
228
|
+
evalCase: buildStoredAskEvalCase(conversation, snapshotLabel)
|
|
229
|
+
};
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
return { workoutCases, askCases };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function formatGenerationLine(result) {
|
|
236
|
+
const bits = [result.model];
|
|
237
|
+
if (result.resolvedModel && result.resolvedModel !== result.model) {
|
|
238
|
+
bits.push(`resolved ${result.resolvedModel}`);
|
|
239
|
+
}
|
|
240
|
+
if (result.durationMs != null) {
|
|
241
|
+
bits.push(`${result.durationMs}ms`);
|
|
242
|
+
}
|
|
243
|
+
if (result.fallback) bits.push('fallback');
|
|
244
|
+
if (result.error) bits.push(`error: ${result.error}`);
|
|
245
|
+
return bits.join(' | ');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function pushOutputBlock(lines, heading, text, metaLine = null) {
|
|
249
|
+
lines.push(heading);
|
|
250
|
+
if (metaLine) lines.push(`- ${metaLine}`);
|
|
251
|
+
lines.push('');
|
|
252
|
+
lines.push(text.trim());
|
|
253
|
+
lines.push('');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export function formatCoachBakeoffMarkdownReport(report) {
|
|
257
|
+
const lines = [
|
|
258
|
+
'# Production Coach Bakeoff',
|
|
259
|
+
'',
|
|
260
|
+
`- Generated: ${report.generatedAt}`,
|
|
261
|
+
`- User: ${report.userId}`,
|
|
262
|
+
`- Workout session: ${report.targets.workout.sessionId} (${report.targets.workout.dayName}, ${report.targets.workout.completedAt})`,
|
|
263
|
+
`- Ask conversation: ${report.targets.ask.conversationId} (${report.targets.ask.createdAt})`,
|
|
264
|
+
`- Models: ${report.models.join(', ')}`,
|
|
265
|
+
`- Variants: ${report.variants.map((variant) => `${variant.name} (${variant.label})`).join(', ')}`,
|
|
266
|
+
''
|
|
267
|
+
];
|
|
268
|
+
|
|
269
|
+
lines.push('## Stored Baseline', '');
|
|
270
|
+
pushOutputBlock(lines, '### Workout', report.targets.workout.storedOutput ?? 'No stored workout summary.');
|
|
271
|
+
lines.push(`### Ask question`, '', report.targets.ask.question.trim(), '');
|
|
272
|
+
pushOutputBlock(lines, '### Ask stored answer', report.targets.ask.storedOutput ?? 'No stored ask answer.');
|
|
273
|
+
|
|
274
|
+
lines.push('## Context', '');
|
|
275
|
+
lines.push('### Workout context', '');
|
|
276
|
+
lines.push('```text');
|
|
277
|
+
lines.push(report.contextPreview.workout.trim());
|
|
278
|
+
lines.push('```', '');
|
|
279
|
+
lines.push('### Ask context', '');
|
|
280
|
+
lines.push('```text');
|
|
281
|
+
lines.push(report.contextPreview.ask.trim());
|
|
282
|
+
lines.push('```', '');
|
|
283
|
+
|
|
284
|
+
for (const surface of ['workout', 'ask']) {
|
|
285
|
+
lines.push(`## ${surface === 'workout' ? 'Workout summary bakeoff' : 'Ask follow-up bakeoff'}`, '');
|
|
286
|
+
for (const variant of report.variants) {
|
|
287
|
+
lines.push(`### ${variant.label}`, '');
|
|
288
|
+
for (const result of report.results.filter((entry) => entry.surface === surface && entry.variant === variant.name)) {
|
|
289
|
+
pushOutputBlock(
|
|
290
|
+
lines,
|
|
291
|
+
`#### ${result.model}`,
|
|
292
|
+
result.error ? `[generation failed]\n${result.error}` : result.output,
|
|
293
|
+
formatGenerationLine(result)
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return `${lines.join('\n')}\n`;
|
|
300
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
export const COACH_FACT_KINDS = Object.freeze(['preference', 'constraint', 'injury', 'goal_signal', 'tone']);
|
|
2
|
+
|
|
3
|
+
const COACH_FACT_KIND_SET = new Set(COACH_FACT_KINDS);
|
|
4
|
+
|
|
5
|
+
const PROMPT_INJECTION_PATTERNS = [
|
|
6
|
+
/\b(ignore|disregard|override)\b.{0,40}\b(instructions?|rules?|system|developer|previous)\b/i,
|
|
7
|
+
/\b(system prompt|developer message|jailbreak|act as|you are chatgpt)\b/i
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const DERIVED_TRAINING_PATTERNS = [
|
|
11
|
+
/\b(e1rm|1rm|one[-\s]?rep max|estimated one[-\s]?rep max)\b/i,
|
|
12
|
+
/\b(personal record|rep pr|volume pr|\bpr\b)\b/i,
|
|
13
|
+
/\b(tonnage|training volume|total volume|session count|sets completed|missed sets)\b/i,
|
|
14
|
+
/\b\d+(?:\.\d+)?\s*(?:kg|kgs|kilograms?|lb|lbs|pounds?)\b.{0,40}\b(bench|squat|deadlift|press|curl|row|pulldown|leg|lift|rep|set|volume)\b/i,
|
|
15
|
+
/\b(bench|squat|deadlift|press|curl|row|pulldown|leg|lift)\b.{0,40}\b\d+(?:\.\d+)?\s*(?:kg|kgs|kilograms?|lb|lbs|pounds?)\b/i,
|
|
16
|
+
/\b\d+(?:\.\d+)?\s*x\s*\d+\b/i,
|
|
17
|
+
/\b(?:trained|completed|logged|did)\s+\d+\s+(?:sessions?|workouts?)\b/i
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const EPHEMERAL_PATTERNS = [
|
|
21
|
+
/\b(today|yesterday|tomorrow|this week|last week|next week|last session|latest session|recent session)\b/i,
|
|
22
|
+
/\b(current|currently|latest|recent)\s+(?:best|e1rm|1rm|volume|pr|record|session|workout)\b/i
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
export function normalizeCoachFactText(value) {
|
|
26
|
+
return String(value ?? '').replace(/\s+/g, ' ').trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeCoachFactKind(value) {
|
|
30
|
+
return String(value ?? '').trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isCoachFactKind(value) {
|
|
34
|
+
return COACH_FACT_KIND_SET.has(normalizeCoachFactKind(value));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function coachFactPolicyViolation(candidate) {
|
|
38
|
+
const kind = normalizeCoachFactKind(candidate?.kind);
|
|
39
|
+
const fact = normalizeCoachFactText(candidate?.fact);
|
|
40
|
+
if (!isCoachFactKind(kind)) return 'invalid_kind';
|
|
41
|
+
if (fact.length < 3) return 'too_short';
|
|
42
|
+
if (fact.length > 160) return 'too_long';
|
|
43
|
+
if (!/^the trainee\b/i.test(fact)) return 'not_third_person';
|
|
44
|
+
if (PROMPT_INJECTION_PATTERNS.some((pattern) => pattern.test(fact))) return 'prompt_injection';
|
|
45
|
+
if (DERIVED_TRAINING_PATTERNS.some((pattern) => pattern.test(fact))) return 'derived_training_fact';
|
|
46
|
+
if (EPHEMERAL_PATTERNS.some((pattern) => pattern.test(fact))) return 'ephemeral_fact';
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function normalizeCoachFactCandidate(candidate) {
|
|
51
|
+
if (!candidate || typeof candidate !== 'object') return null;
|
|
52
|
+
const normalized = {
|
|
53
|
+
kind: normalizeCoachFactKind(candidate.kind),
|
|
54
|
+
fact: normalizeCoachFactText(candidate.fact),
|
|
55
|
+
confidence: Number(candidate.confidence ?? 0.7)
|
|
56
|
+
};
|
|
57
|
+
if (coachFactPolicyViolation(normalized)) return null;
|
|
58
|
+
return {
|
|
59
|
+
...normalized,
|
|
60
|
+
confidence: Number.isFinite(normalized.confidence) ? Math.max(0, Math.min(1, normalized.confidence)) : 0.7
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function coachFactConflictKey(candidate) {
|
|
65
|
+
const kind = normalizeCoachFactKind(candidate?.kind);
|
|
66
|
+
const fact = normalizeCoachFactText(candidate?.fact).toLowerCase();
|
|
67
|
+
if (kind === 'tone' && /\b(concise|brief|short|terse|direct|succinct|detailed|thorough|long[-\s]?form|explain)\b/.test(fact)) {
|
|
68
|
+
return 'tone:verbosity';
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function coachFactConflictSearchPattern(candidate) {
|
|
74
|
+
const key = coachFactConflictKey(candidate);
|
|
75
|
+
if (key !== 'tone:verbosity') return null;
|
|
76
|
+
const fact = normalizeCoachFactText(candidate?.fact).toLowerCase();
|
|
77
|
+
if (/\b(concise|brief|short|terse|direct|succinct)\b/.test(fact)) {
|
|
78
|
+
return '\\m(detailed|thorough|long[-[:space:]]?form|explain)\\M';
|
|
79
|
+
}
|
|
80
|
+
if (/\b(detailed|thorough|long[-\s]?form|explain)\b/.test(fact)) {
|
|
81
|
+
return '\\m(concise|brief|short|terse|direct|succinct)\\M';
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function dedupeCoachFactCandidates(candidates, { limit = 3 } = {}) {
|
|
87
|
+
const byKey = new Map();
|
|
88
|
+
for (const candidate of Array.isArray(candidates) ? candidates : []) {
|
|
89
|
+
const normalized = normalizeCoachFactCandidate(candidate);
|
|
90
|
+
if (!normalized) continue;
|
|
91
|
+
const key = coachFactConflictKey(normalized) ?? `${normalized.kind}:${normalized.fact.toLowerCase()}`;
|
|
92
|
+
const existing = byKey.get(key);
|
|
93
|
+
if (!existing || normalized.confidence >= existing.confidence) {
|
|
94
|
+
byKey.set(key, normalized);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return [...byKey.values()]
|
|
98
|
+
.sort((a, b) => b.confidence - a.confidence)
|
|
99
|
+
.slice(0, limit);
|
|
100
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { ASK_PROMPT, SECURITY_PREAMBLE, WORKOUT_COACH_PROMPT } from './openrouter.js';
|
|
2
|
+
|
|
3
|
+
export const WARM_WORKOUT_COACH_PROMPT = `${SECURITY_PREAMBLE}You are a training coach reviewing a completed session. Write a short post-workout note in 2-3 sentences, single paragraph.
|
|
4
|
+
|
|
5
|
+
Goal order:
|
|
6
|
+
1. Leave the user feeling good about training.
|
|
7
|
+
2. Surface one real signal from the log.
|
|
8
|
+
3. Mention a miss lightly, only if it matters.
|
|
9
|
+
|
|
10
|
+
Style:
|
|
11
|
+
- Lead with a warm, grounded opener.
|
|
12
|
+
- Start with what went well or what the session covered before any watch item.
|
|
13
|
+
- One observation is enough. Use exact exercises and numbers.
|
|
14
|
+
- If you mention muscle coverage or session balance, anchor it to the logged exercises. Do not use generic filler.
|
|
15
|
+
- If the strongest watch item is minor, keep it brief and neutral.
|
|
16
|
+
- If the note would add nothing beyond the visible workout log, return exactly: NO_INSIGHT.
|
|
17
|
+
|
|
18
|
+
Rules:
|
|
19
|
+
- No bullet points, no questions.
|
|
20
|
+
- No audit language like "fell short of plan volume", "concern", "risk", "execution issue", or "red flag".
|
|
21
|
+
- Do not force a problem into every note.
|
|
22
|
+
- Do not infer fatigue, under-recovery, or cardio interference unless the context includes explicit recovery support.
|
|
23
|
+
- Use exact exercise names from the current session data.
|
|
24
|
+
- If you mention the next session, keep it short and optional. The next session title alone is enough.
|
|
25
|
+
- Never mention skipped or absent exercises.
|
|
26
|
+
- Never use hype language such as "crushed it", "insane", "elite", or "on fire".`;
|
|
27
|
+
|
|
28
|
+
export const PERSONALITY_WORKOUT_COACH_PROMPT = `${SECURITY_PREAMBLE}You are a training coach writing a short post-workout note with a bit of personality. Write 2-3 sentences, single paragraph.
|
|
29
|
+
|
|
30
|
+
Goal order:
|
|
31
|
+
1. Make the user want to receive this note after training.
|
|
32
|
+
2. Keep it grounded in the actual log.
|
|
33
|
+
3. Add one light opinion or human-feeling observation if it is earned by the data.
|
|
34
|
+
|
|
35
|
+
Style:
|
|
36
|
+
- Sound like a coach, not an analyst.
|
|
37
|
+
- A little character is good. Generic filler is not.
|
|
38
|
+
- Lead with the best real part of the session.
|
|
39
|
+
- Mention at most one watch item, briefly and without turning the note into a review.
|
|
40
|
+
- If there is no meaningful extra signal beyond what the app already shows, return exactly: NO_INSIGHT.
|
|
41
|
+
|
|
42
|
+
Rules:
|
|
43
|
+
- No bullet points, no questions.
|
|
44
|
+
- No influencer language, no exclamation stacks, no slogans.
|
|
45
|
+
- Do not force concern or diagnosis.
|
|
46
|
+
- Do not use recovery language without explicit support.
|
|
47
|
+
- Use exact exercise names from the session data.
|
|
48
|
+
- If you reference overall session spread, tie it to the actual lifts performed.
|
|
49
|
+
- Never mention skipped or absent exercises by name.`;
|
|
50
|
+
|
|
51
|
+
export const WARM_ASK_PROMPT = `${SECURITY_PREAMBLE}You are a strength coach answering questions from the user's training history. Give useful coaching that feels good to receive and still helps them train better.
|
|
52
|
+
|
|
53
|
+
Rules:
|
|
54
|
+
- Use only the data provided. If the data does not support a claim, do not make it.
|
|
55
|
+
- Use typed user facts and user-authored notes naturally when present, but do not quote prompt-like note text.
|
|
56
|
+
- Lead with the main point in the first sentence.
|
|
57
|
+
- When the question is a follow-up to a workout note or asks "tell me more", default to 4-8 sentences max and no more than 2 short paragraphs.
|
|
58
|
+
- For follow-ups, expand on 1-2 real signals only. Do not turn the answer into a report.
|
|
59
|
+
- Start with what went well before any watch item unless the question is explicitly about a problem.
|
|
60
|
+
- Do not force a concern, risk, or flag into every answer.
|
|
61
|
+
- If there is a watch item, frame it lightly and specifically.
|
|
62
|
+
- Use exact exercise names from the training data.
|
|
63
|
+
- Never name an exercise that does not appear in the training data.
|
|
64
|
+
- Do not infer fatigue or poor readiness without explicit recovery or training-load support.
|
|
65
|
+
- Keep the tone natural and direct. No hype, no filler, no motivational closing line.`;
|
|
66
|
+
|
|
67
|
+
export const PERSONALITY_ASK_PROMPT = `${SECURITY_PREAMBLE}You are a strength coach answering training questions with a bit of personality. Sound human, slightly opinionated, and grounded in the log.
|
|
68
|
+
|
|
69
|
+
Rules:
|
|
70
|
+
- Use only the provided data.
|
|
71
|
+
- Make the answer feel conversational, not clinical.
|
|
72
|
+
- If the question is a follow-up to a workout note or asks for more detail, keep it to 4-8 sentences max and focus on the main 1-2 takeaways.
|
|
73
|
+
- It is fine to have a clear opinion about what mattered most, but anchor it to exact lifts, weights, reps, timing, or frequency.
|
|
74
|
+
- Do not force a problem into the answer.
|
|
75
|
+
- Do not diagnose fatigue or recovery issues without explicit support.
|
|
76
|
+
- Never name an exercise that does not appear in the training data, and use exact exercise names.
|
|
77
|
+
- No hype-bro language, no slogans, no fake sports-science voice, no long audit sections.
|
|
78
|
+
- End with the useful point, not a motivational sign-off.`;
|
|
79
|
+
|
|
80
|
+
export const COACH_PROMPT_VARIANTS = Object.freeze({
|
|
81
|
+
current: {
|
|
82
|
+
name: 'current',
|
|
83
|
+
label: 'Current control',
|
|
84
|
+
workoutPrompt: WORKOUT_COACH_PROMPT,
|
|
85
|
+
askPrompt: ASK_PROMPT
|
|
86
|
+
},
|
|
87
|
+
warm: {
|
|
88
|
+
name: 'warm',
|
|
89
|
+
label: 'Warm coach',
|
|
90
|
+
workoutPrompt: WARM_WORKOUT_COACH_PROMPT,
|
|
91
|
+
askPrompt: WARM_ASK_PROMPT
|
|
92
|
+
},
|
|
93
|
+
personality: {
|
|
94
|
+
name: 'personality',
|
|
95
|
+
label: 'More personality',
|
|
96
|
+
workoutPrompt: PERSONALITY_WORKOUT_COACH_PROMPT,
|
|
97
|
+
askPrompt: PERSONALITY_ASK_PROMPT
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
export const DEFAULT_COACH_BAKEOFF_VARIANTS = Object.freeze(['current', 'warm', 'personality']);
|
|
102
|
+
|
|
103
|
+
export function resolveCoachPromptVariant(name) {
|
|
104
|
+
if (!name) return COACH_PROMPT_VARIANTS.current;
|
|
105
|
+
return COACH_PROMPT_VARIANTS[name] ?? null;
|
|
106
|
+
}
|
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,
|
|
@@ -161,6 +161,24 @@ export const commandSchema = [
|
|
|
161
161
|
options: [
|
|
162
162
|
{ name: 'token', type: 'string', required: true, description: 'Program share token' }
|
|
163
163
|
]
|
|
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
|
+
},
|
|
173
|
+
{
|
|
174
|
+
command: 'increment-score history',
|
|
175
|
+
id: 'increment-score-history',
|
|
176
|
+
description: 'List historical Increment Score snapshots for the authenticated user',
|
|
177
|
+
options: [
|
|
178
|
+
{ name: 'from', type: 'string', required: false, description: 'Earliest snapshot_at (ISO 8601)' },
|
|
179
|
+
{ name: 'to', type: 'string', required: false, description: 'Latest snapshot_at (ISO 8601)' },
|
|
180
|
+
{ name: 'limit', type: 'number', required: false, description: 'Max snapshots to return (default 200, max 1000)' }
|
|
181
|
+
]
|
|
164
182
|
}
|
|
165
183
|
];
|
|
166
184
|
|
|
@@ -194,7 +212,7 @@ export const writeCommandSchema = [
|
|
|
194
212
|
{
|
|
195
213
|
command: 'programs share create',
|
|
196
214
|
id: 'program-share-create',
|
|
197
|
-
description: 'Create
|
|
215
|
+
description: 'Create a new share token for one program',
|
|
198
216
|
usage: 'programs share create --program-id <program-id>',
|
|
199
217
|
options: [
|
|
200
218
|
{ name: 'program-id', type: 'string', required: true, description: 'Program ID' }
|
|
@@ -212,10 +230,19 @@ export const writeCommandSchema = [
|
|
|
212
230
|
{
|
|
213
231
|
command: 'programs share revoke',
|
|
214
232
|
id: 'program-share-revoke',
|
|
215
|
-
description: 'Revoke a previously issued program share
|
|
216
|
-
usage: 'programs share revoke --
|
|
233
|
+
description: 'Revoke a previously issued program share by id',
|
|
234
|
+
usage: 'programs share revoke --share-id <share-id>',
|
|
217
235
|
options: [
|
|
218
|
-
{ name: '
|
|
236
|
+
{ name: 'share-id', type: 'string', required: true, description: 'Program share id' }
|
|
237
|
+
]
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
command: 'increment-score upload',
|
|
241
|
+
id: 'increment-score-upload',
|
|
242
|
+
description: 'Upload one or more Increment Score snapshots for the authenticated user',
|
|
243
|
+
usage: 'increment-score upload --file <file>',
|
|
244
|
+
options: [
|
|
245
|
+
{ name: 'file', type: 'string', required: true, description: 'Path to JSON file with { snapshots: [...] }' }
|
|
219
246
|
]
|
|
220
247
|
}
|
|
221
248
|
];
|