incremnt 0.1.5 → 0.1.8
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 +1 -1
- package/package.json +6 -3
- package/src/contract.js +83 -1
- package/src/format.js +50 -2
- package/src/lib.js +31 -3
- package/src/mcp.js +60 -0
- package/src/openrouter.js +193 -0
- package/src/queries.js +289 -0
- package/src/remote.js +121 -1
- package/src/sync-service.js +435 -34
- package/src/transport.js +5 -0
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npm install -g incremnt
|
|
|
12
12
|
|
|
13
13
|
### Hosted sync (recommended)
|
|
14
14
|
|
|
15
|
-
After connecting with Apple in the iOS app (Settings >
|
|
15
|
+
After connecting with Apple in the iOS app (Settings > Cloud Sync), your workouts sync automatically. To access them from the CLI:
|
|
16
16
|
|
|
17
17
|
```bash
|
|
18
18
|
incremnt login
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "incremnt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Command-line tool for querying your incremnt strength training data",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
|
-
"incremnt": "./src/index.js"
|
|
8
|
+
"incremnt": "./src/index.js",
|
|
9
|
+
"incremnt-mcp": "./src/mcp.js"
|
|
9
10
|
},
|
|
10
11
|
"files": [
|
|
11
12
|
"src/"
|
|
@@ -15,6 +16,8 @@
|
|
|
15
16
|
"dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js"
|
|
16
17
|
},
|
|
17
18
|
"dependencies": {
|
|
18
|
-
"
|
|
19
|
+
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
20
|
+
"chalk": "^5.6.2",
|
|
21
|
+
"zod": "^3.24.0"
|
|
19
22
|
}
|
|
20
23
|
}
|
package/src/contract.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
export const contractVersion = 1;
|
|
2
2
|
|
|
3
3
|
export const capabilities = {
|
|
4
|
-
readOnly:
|
|
4
|
+
readOnly: false,
|
|
5
5
|
localSnapshots: true,
|
|
6
6
|
remoteReads: true,
|
|
7
|
+
remoteWrites: true,
|
|
7
8
|
remoteAuthShell: true,
|
|
8
9
|
remoteBootstrap: true
|
|
9
10
|
};
|
|
@@ -82,11 +83,92 @@ export const commandSchema = [
|
|
|
82
83
|
id: 'records',
|
|
83
84
|
description: 'Personal records',
|
|
84
85
|
options: []
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
command: 'goals list',
|
|
89
|
+
id: 'goals-list',
|
|
90
|
+
description: 'List strength plans and lift goals',
|
|
91
|
+
options: []
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
command: 'goals show',
|
|
95
|
+
id: 'goals-show',
|
|
96
|
+
description: 'Show strength plan goal details',
|
|
97
|
+
usage: 'goals show --id <plan-id>',
|
|
98
|
+
options: [
|
|
99
|
+
{ name: 'id', type: 'string', required: true, description: 'Plan ID' }
|
|
100
|
+
]
|
|
85
101
|
}
|
|
86
102
|
];
|
|
87
103
|
|
|
104
|
+
export const writeCommandSchema = [
|
|
105
|
+
{
|
|
106
|
+
command: 'programs propose',
|
|
107
|
+
id: 'programs-propose',
|
|
108
|
+
description: 'Submit a program proposal',
|
|
109
|
+
usage: 'programs propose --file <file>',
|
|
110
|
+
options: [
|
|
111
|
+
{ name: 'file', type: 'string', required: true, description: 'Path to proposal JSON file' }
|
|
112
|
+
]
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
command: 'programs proposals',
|
|
116
|
+
id: 'programs-proposals',
|
|
117
|
+
description: 'List program proposals',
|
|
118
|
+
options: [
|
|
119
|
+
{ name: 'status', type: 'string', required: false, description: 'Filter by status (pending, accepted, dismissed)' }
|
|
120
|
+
]
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
command: 'programs proposal dismiss',
|
|
124
|
+
id: 'proposal-dismiss',
|
|
125
|
+
description: 'Dismiss a program proposal',
|
|
126
|
+
usage: 'programs proposal dismiss --id <id>',
|
|
127
|
+
options: [
|
|
128
|
+
{ name: 'id', type: 'string', required: true, description: 'Proposal ID' }
|
|
129
|
+
]
|
|
130
|
+
}
|
|
131
|
+
];
|
|
132
|
+
|
|
133
|
+
export const writeCommands = new Set(writeCommandSchema.map((c) => c.id));
|
|
134
|
+
|
|
135
|
+
export const proposalSchema = {
|
|
136
|
+
description: 'JSON structure for programs propose --file. Weights are omitted — iOS calculates from history.',
|
|
137
|
+
required: ['name', 'equipmentTier', 'days'],
|
|
138
|
+
properties: {
|
|
139
|
+
name: { type: 'string', description: 'Program name' },
|
|
140
|
+
equipmentTier: { type: 'string', enum: ['fullGym', 'benchDumbbells', 'dumbbellsOnly', 'bodyweightOnly'] },
|
|
141
|
+
trainingWeekdays: { type: 'array', items: { type: 'integer', minimum: 0, maximum: 6 }, description: 'Days of week (0=Sun). Optional.' },
|
|
142
|
+
rationale: { type: 'string', description: 'Why this program was suggested. Optional.' },
|
|
143
|
+
days: {
|
|
144
|
+
type: 'array',
|
|
145
|
+
minItems: 1,
|
|
146
|
+
items: {
|
|
147
|
+
required: ['title', 'exercises'],
|
|
148
|
+
properties: {
|
|
149
|
+
title: { type: 'string', description: 'Day name, e.g. "Upper A"' },
|
|
150
|
+
exercises: {
|
|
151
|
+
type: 'array',
|
|
152
|
+
minItems: 1,
|
|
153
|
+
items: {
|
|
154
|
+
required: ['name', 'sets', 'reps'],
|
|
155
|
+
properties: {
|
|
156
|
+
name: { type: 'string', description: 'Exercise name (use canonical names from records or exercises history)' },
|
|
157
|
+
muscleGroup: { type: 'string', description: 'e.g. "Chest", "Back". Optional.' },
|
|
158
|
+
sets: { type: 'integer', minimum: 1 },
|
|
159
|
+
reps: { type: 'integer', minimum: 1 }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
88
169
|
export const officialCommands = [
|
|
89
170
|
...commandSchema.map((c) => c.usage ?? c.command),
|
|
171
|
+
...writeCommandSchema.map((c) => c.usage ?? c.command),
|
|
90
172
|
'status',
|
|
91
173
|
'contract',
|
|
92
174
|
'login',
|
package/src/format.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
|
-
import { commandSchema } from './contract.js';
|
|
2
|
+
import { commandSchema, writeCommandSchema } from './contract.js';
|
|
3
3
|
|
|
4
4
|
const shortMonths = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
5
5
|
const shortDays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
|
@@ -366,6 +366,48 @@ function formatWhyDidThisChange(payload) {
|
|
|
366
366
|
return lines.join('\n');
|
|
367
367
|
}
|
|
368
368
|
|
|
369
|
+
function formatProposalCreated(payload) {
|
|
370
|
+
if (!payload) return 'No proposal data.';
|
|
371
|
+
const lines = [
|
|
372
|
+
header('PROPOSAL CREATED'),
|
|
373
|
+
'',
|
|
374
|
+
keyValue('ID', payload.id),
|
|
375
|
+
keyValue('Status', payload.status),
|
|
376
|
+
keyValue('Program', payload.proposal?.name ?? 'Untitled'),
|
|
377
|
+
keyValue('Days', String(payload.proposal?.days?.length ?? 0))
|
|
378
|
+
];
|
|
379
|
+
return lines.join('\n');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function formatProposalsList(payload) {
|
|
383
|
+
if (!Array.isArray(payload) || payload.length === 0) {
|
|
384
|
+
return 'No proposals found.';
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const statusBadge = (status) => {
|
|
388
|
+
if (status === 'pending') return chalk.yellow(status);
|
|
389
|
+
if (status === 'accepted') return chalk.green(status);
|
|
390
|
+
if (status === 'dismissed') return chalk.dim(status);
|
|
391
|
+
return status;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
const lines = [header('PROGRAM PROPOSALS'), ''];
|
|
395
|
+
|
|
396
|
+
for (const p of payload) {
|
|
397
|
+
const name = p.proposal?.name ?? 'Untitled';
|
|
398
|
+
const days = p.proposal?.days?.length ?? 0;
|
|
399
|
+
const date = formatShortDate(p.createdAt);
|
|
400
|
+
lines.push(` ${statusBadge(p.status)} ${chalk.bold(name)}${dimDot()}${chalk.dim(`${days} days`)}${dimDot()}${chalk.dim(date)}${dimDot()}${chalk.dim(p.id)}`);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
return lines.join('\n');
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function formatProposalDismissed(payload) {
|
|
407
|
+
if (!payload) return 'Proposal not found.';
|
|
408
|
+
return ` Proposal ${chalk.bold(payload.id)} dismissed.`;
|
|
409
|
+
}
|
|
410
|
+
|
|
369
411
|
// --- Main export ---
|
|
370
412
|
|
|
371
413
|
export function formatHelp() {
|
|
@@ -380,6 +422,9 @@ export function formatHelp() {
|
|
|
380
422
|
header('COMMANDS'),
|
|
381
423
|
...commandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
|
|
382
424
|
'',
|
|
425
|
+
header('WRITE COMMANDS'),
|
|
426
|
+
...writeCommandSchema.map((c) => cmd(c.usage ?? c.command, c.description)),
|
|
427
|
+
'',
|
|
383
428
|
header('AUTH'),
|
|
384
429
|
cmd('login', 'Sign in (opens browser)'),
|
|
385
430
|
cmd('login --base-url <url>', 'Sign in to a specific server'),
|
|
@@ -413,7 +458,10 @@ export function formatPretty(command, payload) {
|
|
|
413
458
|
'program-list': formatProgramList,
|
|
414
459
|
'program-detail': formatProgramDetail,
|
|
415
460
|
'planned-vs-actual': formatPlannedVsActual,
|
|
416
|
-
'why-did-this-change': formatWhyDidThisChange
|
|
461
|
+
'why-did-this-change': formatWhyDidThisChange,
|
|
462
|
+
'programs-propose': formatProposalCreated,
|
|
463
|
+
'programs-proposals': formatProposalsList,
|
|
464
|
+
'proposal-dismiss': formatProposalDismissed
|
|
417
465
|
}[command];
|
|
418
466
|
|
|
419
467
|
return formatter ? formatter(payload) : null;
|
package/src/lib.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
|
-
import { capabilities, commandSchema, contractVersion, officialCommands, readCommands } from './contract.js';
|
|
3
|
+
import { capabilities, commandSchema, contractVersion, officialCommands, proposalSchema, readCommands, writeCommands, writeCommandSchema } from './contract.js';
|
|
4
4
|
import {
|
|
5
5
|
bootstrapSessionFromRemoteBaseUrl,
|
|
6
6
|
bootstrapSessionFromRemoteBaseUrlWithDeviceFlow,
|
|
@@ -54,12 +54,16 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
54
54
|
const { command, options } = parseArgs(argv);
|
|
55
55
|
const normalizedCommand = ({
|
|
56
56
|
...Object.fromEntries(commandSchema.map((c) => [c.command, c.id])),
|
|
57
|
+
...Object.fromEntries(writeCommandSchema.map((c) => [c.command, c.id])),
|
|
57
58
|
insights: 'session-insights',
|
|
58
59
|
history: 'exercise-history',
|
|
59
60
|
prs: 'records',
|
|
60
61
|
program: 'program-summary',
|
|
61
62
|
compare: 'planned-vs-actual',
|
|
62
|
-
explain: 'why-did-this-change'
|
|
63
|
+
explain: 'why-did-this-change',
|
|
64
|
+
propose: 'programs-propose',
|
|
65
|
+
proposals: 'programs-proposals',
|
|
66
|
+
dismiss: 'proposal-dismiss'
|
|
63
67
|
})[command] ?? command;
|
|
64
68
|
|
|
65
69
|
if (!command || options.help) {
|
|
@@ -109,7 +113,9 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
109
113
|
binary: 'incremnt',
|
|
110
114
|
capabilities,
|
|
111
115
|
officialCommands,
|
|
112
|
-
schema: commandSchema
|
|
116
|
+
schema: commandSchema,
|
|
117
|
+
writeSchema: writeCommandSchema,
|
|
118
|
+
proposalSchema
|
|
113
119
|
}, null, 2)}\n`);
|
|
114
120
|
return 0;
|
|
115
121
|
}
|
|
@@ -263,6 +269,28 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
263
269
|
const wantJson = options.json || !(stdout.isTTY ?? false);
|
|
264
270
|
const explicitJson = Boolean(options.json);
|
|
265
271
|
|
|
272
|
+
if (writeCommands.has(normalizedCommand)) {
|
|
273
|
+
try {
|
|
274
|
+
const payload = await transport.executeWriteCommand(normalizedCommand, options);
|
|
275
|
+
if (wantJson) {
|
|
276
|
+
stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
|
277
|
+
} else {
|
|
278
|
+
const pretty = formatPretty(normalizedCommand, payload);
|
|
279
|
+
stdout.write(`${pretty ?? JSON.stringify(payload, null, 2)}\n`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return 0;
|
|
283
|
+
} catch (error) {
|
|
284
|
+
if (explicitJson) {
|
|
285
|
+
stdout.write(`${JSON.stringify({ error: error.message, code: error.code ?? null }, null, 2)}\n`);
|
|
286
|
+
} else {
|
|
287
|
+
stderr.write(`${error.message}\n`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return 1;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
266
294
|
if (!readCommands.has(normalizedCommand)) {
|
|
267
295
|
const message = `Unknown command: ${command}`;
|
|
268
296
|
if (explicitJson) {
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { z } from 'zod';
|
|
6
|
+
import { commandSchema } from './contract.js';
|
|
7
|
+
import { readSessionState } from './state.js';
|
|
8
|
+
import { createTransport } from './transport.js';
|
|
9
|
+
|
|
10
|
+
const server = new McpServer({
|
|
11
|
+
name: 'incremnt',
|
|
12
|
+
version: '0.1.0'
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
for (const cmd of commandSchema) {
|
|
16
|
+
const shape = {};
|
|
17
|
+
|
|
18
|
+
for (const opt of cmd.options) {
|
|
19
|
+
let field = opt.type === 'number' ? z.number() : z.string();
|
|
20
|
+
if (!opt.required) field = field.optional();
|
|
21
|
+
field = field.describe(opt.description);
|
|
22
|
+
shape[opt.name] = field;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
server.tool(cmd.id, cmd.description, shape, async (args) => {
|
|
26
|
+
try {
|
|
27
|
+
const sessionState = await readSessionState();
|
|
28
|
+
const transport = await createTransport({}, sessionState);
|
|
29
|
+
|
|
30
|
+
if (transport.expired) {
|
|
31
|
+
return {
|
|
32
|
+
content: [{ type: 'text', text: 'Session expired. Run `incremnt login` to re-authenticate.' }],
|
|
33
|
+
isError: true
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const result = await transport.executeReadCommand(cmd.id, args);
|
|
38
|
+
return {
|
|
39
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
|
|
40
|
+
};
|
|
41
|
+
} catch (error) {
|
|
42
|
+
const message = error?.message ?? String(error);
|
|
43
|
+
|
|
44
|
+
if (error?.code === 'SNAPSHOT_NOT_FOUND') {
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: 'text', text: 'Not logged in. Run `incremnt login` first.' }],
|
|
47
|
+
isError: true
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
content: [{ type: 'text', text: message }],
|
|
53
|
+
isError: true
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const transport = new StdioServerTransport();
|
|
60
|
+
await server.connect(transport);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
const DEFAULT_MODEL = 'meta-llama/llama-3.1-8b-instruct:free';
|
|
2
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
3
|
+
const DEFAULT_MAX_TOKENS = 300;
|
|
4
|
+
|
|
5
|
+
const SYSTEM_PROMPT = `You are a strength coach writing a brief note after reviewing a trainee's week of training. Write 2-3 short paragraphs separated by blank lines.
|
|
6
|
+
|
|
7
|
+
Rules:
|
|
8
|
+
- Reference specific exercises, weights, and reps from the data. Use numbers, not vague praise.
|
|
9
|
+
- If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
|
|
10
|
+
- Note what went well and what lagged, with specifics.
|
|
11
|
+
- End with one concrete suggestion for next week. Be specific about which exercise and what to change.
|
|
12
|
+
- Write like a real person texting a training partner — short sentences, no filler, no cheerleading.
|
|
13
|
+
- Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting.
|
|
14
|
+
- Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
|
|
15
|
+
- No bullet points or lists.`;
|
|
16
|
+
|
|
17
|
+
export async function generateCoachingSummary(cycleContext, { apiKey, model, timeoutMs } = {}) {
|
|
18
|
+
const resolvedModel = model || DEFAULT_MODEL;
|
|
19
|
+
const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
20
|
+
|
|
21
|
+
const controller = new AbortController();
|
|
22
|
+
const timer = setTimeout(() => controller.abort(), resolvedTimeout);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const userContent = formatCycleContext(cycleContext);
|
|
26
|
+
|
|
27
|
+
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: {
|
|
30
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
31
|
+
'Content-Type': 'application/json',
|
|
32
|
+
'HTTP-Referer': 'https://incremnt.app',
|
|
33
|
+
'X-Title': 'incremnt'
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
model: resolvedModel,
|
|
37
|
+
messages: [
|
|
38
|
+
{ role: 'system', content: SYSTEM_PROMPT },
|
|
39
|
+
{ role: 'user', content: userContent }
|
|
40
|
+
],
|
|
41
|
+
max_tokens: DEFAULT_MAX_TOKENS,
|
|
42
|
+
temperature: 0.7
|
|
43
|
+
}),
|
|
44
|
+
signal: controller.signal
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
const text = await response.text().catch(() => '');
|
|
49
|
+
throw new Error(`OpenRouter API error ${response.status}: ${text}`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const data = await response.json();
|
|
53
|
+
const content = data.choices?.[0]?.message?.content;
|
|
54
|
+
if (!content) {
|
|
55
|
+
throw new Error('No content in OpenRouter response');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return content.trim();
|
|
59
|
+
} finally {
|
|
60
|
+
clearTimeout(timer);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function formatCycleContext(ctx) {
|
|
65
|
+
const lines = [
|
|
66
|
+
`Program: ${ctx.programName}, Week ${ctx.cycleNumber}, ${ctx.totalSessions} session(s).`
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
for (const session of ctx.sessions) {
|
|
70
|
+
const parts = [session.dayName || 'Session'];
|
|
71
|
+
if (session.totalVolume) parts.push(`${session.totalVolume} kg volume`);
|
|
72
|
+
if (session.effortScore) parts.push(`effort ${session.effortScore}/10`);
|
|
73
|
+
lines.push(parts.join(', '));
|
|
74
|
+
|
|
75
|
+
for (const ex of session.exercises) {
|
|
76
|
+
const setPart = ex.plannedSets != null
|
|
77
|
+
? `${ex.completedSets}/${ex.plannedSets} sets`
|
|
78
|
+
: `${ex.completedSets} sets`;
|
|
79
|
+
const topPart = ex.topSet
|
|
80
|
+
? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})`
|
|
81
|
+
: '';
|
|
82
|
+
lines.push(` ${ex.exerciseName}: ${setPart}${topPart}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (ctx.prsThisCycle.length > 0) {
|
|
87
|
+
lines.push('PRs this week:');
|
|
88
|
+
for (const pr of ctx.prsThisCycle) {
|
|
89
|
+
lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (ctx.goalProgress) {
|
|
94
|
+
lines.push('Goal progress:');
|
|
95
|
+
for (const g of ctx.goalProgress) {
|
|
96
|
+
lines.push(` ${g.exerciseName}: ${g.progressPercent ?? '?'}% toward ${g.targetE1RM} e1RM`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return lines.join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export const WORKOUT_COACH_PROMPT = `You are a strength coach writing a brief note after reviewing one training session. Write 2-3 short paragraphs separated by blank lines.
|
|
104
|
+
|
|
105
|
+
Rules:
|
|
106
|
+
- Reference specific exercises, weights, and reps from the data. Use numbers, not vague praise.
|
|
107
|
+
- If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
|
|
108
|
+
- Compare volume or effort to recent sessions if the data is there. Just state what changed.
|
|
109
|
+
- End with one concrete suggestion for next session. Be specific about which exercise and what to try.
|
|
110
|
+
- Write like a real person texting a training partner — short sentences, no filler, no cheerleading.
|
|
111
|
+
- Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting.
|
|
112
|
+
- Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
|
|
113
|
+
- No bullet points or lists.`;
|
|
114
|
+
|
|
115
|
+
export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, model, timeoutMs } = {}) {
|
|
116
|
+
const resolvedModel = model || DEFAULT_MODEL;
|
|
117
|
+
const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
118
|
+
|
|
119
|
+
const controller = new AbortController();
|
|
120
|
+
const timer = setTimeout(() => controller.abort(), resolvedTimeout);
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const userContent = formatWorkoutContext(workoutContext);
|
|
124
|
+
|
|
125
|
+
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: {
|
|
128
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
129
|
+
'Content-Type': 'application/json',
|
|
130
|
+
'HTTP-Referer': 'https://incremnt.app',
|
|
131
|
+
'X-Title': 'incremnt'
|
|
132
|
+
},
|
|
133
|
+
body: JSON.stringify({
|
|
134
|
+
model: resolvedModel,
|
|
135
|
+
messages: [
|
|
136
|
+
{ role: 'system', content: WORKOUT_COACH_PROMPT },
|
|
137
|
+
{ role: 'user', content: userContent }
|
|
138
|
+
],
|
|
139
|
+
max_tokens: DEFAULT_MAX_TOKENS,
|
|
140
|
+
temperature: 0.7
|
|
141
|
+
}),
|
|
142
|
+
signal: controller.signal
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
if (!response.ok) {
|
|
146
|
+
const text = await response.text().catch(() => '');
|
|
147
|
+
throw new Error(`OpenRouter API error ${response.status}: ${text}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const data = await response.json();
|
|
151
|
+
const content = data.choices?.[0]?.message?.content;
|
|
152
|
+
if (!content) {
|
|
153
|
+
throw new Error('No content in OpenRouter response');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return content.trim();
|
|
157
|
+
} finally {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export function formatWorkoutContext(ctx) {
|
|
163
|
+
const lines = [
|
|
164
|
+
`Session: ${ctx.dayName}, ${ctx.sessionDate}, ${ctx.totalVolume} kg total volume.`
|
|
165
|
+
];
|
|
166
|
+
|
|
167
|
+
if (ctx.effortScore) {
|
|
168
|
+
lines.push(`Effort rating: ${ctx.effortScore}/10.`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
lines.push('Exercises:');
|
|
172
|
+
for (const ex of ctx.exercises) {
|
|
173
|
+
const topPart = ex.topSet ? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})` : '';
|
|
174
|
+
lines.push(` ${ex.exerciseName}: ${ex.completedSets} sets${topPart}`);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (ctx.prs.length > 0) {
|
|
178
|
+
lines.push('PRs this session:');
|
|
179
|
+
for (const pr of ctx.prs) {
|
|
180
|
+
lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (ctx.recentComparisons.length > 0) {
|
|
185
|
+
lines.push('Recent same-day sessions for comparison:');
|
|
186
|
+
for (const comp of ctx.recentComparisons) {
|
|
187
|
+
const effort = comp.effortScore ? `, effort ${comp.effortScore}/10` : '';
|
|
188
|
+
lines.push(` ${comp.date}: ${comp.totalVolume} kg volume${effort}`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return lines.join('\n');
|
|
193
|
+
}
|