incremnt 0.1.15 → 0.1.17

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 CHANGED
@@ -2,13 +2,15 @@
2
2
 
3
3
  Command-line tool and MCP server for querying your [incremnt](https://incremnt.app) strength training data.
4
4
 
5
- ## Install
5
+ ## Setup
6
6
 
7
7
  ```bash
8
8
  npm install -g incremnt
9
+ incremnt login
10
+ incremnt mcp install # registers with Claude Desktop, Claude Code, and Codex CLI
9
11
  ```
10
12
 
11
- This gives you two commands: `incremnt` (CLI) and `incremnt-mcp` (MCP server).
13
+ This gives you two commands: `incremnt` (CLI) and `incremnt-mcp` (MCP server). The `mcp install` step auto-registers the MCP server with Claude Desktop, Claude Code, and Codex CLI — restart your AI assistant for it to take effect.
12
14
 
13
15
  ## CLI
14
16
 
@@ -44,6 +46,8 @@ If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPS
44
46
  | `sessions show --id <id>` | Details for a single session |
45
47
  | `programs current` | Active program state |
46
48
  | `programs list` | All programs |
49
+ | `cycles list [--program-id <id>]` | Completed cycle summaries |
50
+ | `cycles show --id <id>` | Details for a completed cycle summary |
47
51
  | `exercises history --name <name>` | Set-by-set history for an exercise |
48
52
  | `records` | Personal records (best e1RM per exercise) |
49
53
  | `login` | Authenticate with the hosted sync service |
@@ -58,6 +62,7 @@ If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPS
58
62
  | `--pretty` | Human-readable formatted output (default is JSON) |
59
63
  | `--input <path>` | Path to a local `.onemore.json` snapshot |
60
64
  | `--limit <n>` | Limit number of results (for `sessions list`) |
65
+ | `--program-id <id>` | Filter cycle summaries to a program |
61
66
 
62
67
  ### Exercise matching
63
68
 
@@ -65,26 +70,23 @@ If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPS
65
70
 
66
71
  ## MCP Server
67
72
 
68
- The package includes an [MCP](https://modelcontextprotocol.io) server that exposes the same queries as tools for AI assistants like Claude.
73
+ The package includes an [MCP](https://modelcontextprotocol.io) server that exposes the same read queries and program proposal commands as tools for AI assistants like Claude.
69
74
 
70
- ### Setup
75
+ Run `incremnt mcp install` to auto-register the server with Claude Desktop, Claude Code, and Codex CLI (see [Setup](#setup) above).
71
76
 
72
- Add to your Claude Code project config (`.mcp.json`):
77
+ To register manually instead, add to your Claude Code project config (`.mcp.json`):
73
78
 
74
79
  ```json
75
80
  {
76
81
  "mcpServers": {
77
82
  "incremnt": {
78
83
  "type": "stdio",
79
- "command": "npx",
80
- "args": ["-y", "incremnt-mcp"]
84
+ "command": "incremnt-mcp"
81
85
  }
82
86
  }
83
87
  }
84
88
  ```
85
89
 
86
- Or run directly: `npx incremnt-mcp`
87
-
88
90
  The MCP server uses the same auth session as the CLI — run `incremnt login` first.
89
91
 
90
92
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.1.15",
3
+ "version": "0.1.17",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,7 +14,7 @@
14
14
  "scripts": {
15
15
  "test": "node --test",
16
16
  "dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js",
17
- "postinstall": "node -e \"process.stdout.write('\\n incremnt MCP server installed.\\n Run: incremnt mcp install\\n to register it with Claude.\\n\\n')\""
17
+ "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')\""
18
18
  },
19
19
  "dependencies": {
20
20
  "@modelcontextprotocol/sdk": "^1.12.1",
package/src/contract.js CHANGED
@@ -115,6 +115,31 @@ export const commandSchema = [
115
115
  options: [
116
116
  { name: 'id', type: 'string', required: true, description: 'Cycle summary ID' }
117
117
  ]
118
+ },
119
+ {
120
+ command: 'health summary',
121
+ id: 'health-summary',
122
+ description: 'Health metrics summary (resting HR, HRV, VO2 Max, sleep, cardio)',
123
+ options: [
124
+ { name: 'days', type: 'number', required: false, description: 'Last N days (default: 14)' }
125
+ ]
126
+ },
127
+ {
128
+ command: 'ask history',
129
+ id: 'ask-history',
130
+ description: 'List past AI coach conversations',
131
+ options: [
132
+ { name: 'limit', type: 'number', required: false, description: 'Max conversations to return (default: 20)' }
133
+ ]
134
+ },
135
+ {
136
+ command: 'ask show',
137
+ id: 'ask-show',
138
+ description: 'Show a full AI coach conversation',
139
+ usage: 'ask show --id <conversation-id>',
140
+ options: [
141
+ { name: 'id', type: 'string', required: true, description: 'Conversation ID' }
142
+ ]
118
143
  }
119
144
  ];
120
145
 
package/src/lib.js CHANGED
@@ -167,7 +167,26 @@ export async function runCli(argv, stdout, stderr) {
167
167
  await fs.writeFile(cliPath, JSON.stringify(cliConfig, null, 2) + '\n');
168
168
  installed.push(` Claude Code: ${cliPath}`);
169
169
 
170
- stdout.write(`Registered incremnt MCP server in:\n${installed.join('\n')}\n\nRestart Claude for the changes to take effect.\n`);
170
+ // Codex CLI
171
+ const codexPath = path.join(os.homedir(), '.codex', 'config.toml');
172
+ try {
173
+ let codexConfig = '';
174
+ try {
175
+ codexConfig = await fs.readFile(codexPath, 'utf8');
176
+ } catch { /* file doesn't exist yet */ }
177
+ const block = `[mcp_servers.incremnt]\ncommand = "${mcpBin}"\n`;
178
+ if (!codexConfig.includes('[mcp_servers.incremnt]')) {
179
+ await fs.mkdir(path.dirname(codexPath), { recursive: true });
180
+ await fs.writeFile(codexPath, codexConfig + (codexConfig && !codexConfig.endsWith('\n') ? '\n' : '') + block);
181
+ installed.push(` Codex CLI: ${codexPath}`);
182
+ } else {
183
+ installed.push(` Codex CLI: ${codexPath} (already registered)`);
184
+ }
185
+ } catch (error) {
186
+ installed.push(` Codex CLI: skipped (${error.message})`);
187
+ }
188
+
189
+ stdout.write(`Registered incremnt MCP server in:\n${installed.join('\n')}\n\nRestart Claude/Codex for the changes to take effect.\n`);
171
190
  return 0;
172
191
  } catch (error) {
173
192
  stderr.write(`${error.message}\n`);
package/src/mcp.js CHANGED
@@ -1,18 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
3
5
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
6
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
7
  import { z } from 'zod';
6
- import { commandSchema } from './contract.js';
8
+ import { commandSchema, writeCommands, writeCommandSchema } from './contract.js';
7
9
  import { readSessionState } from './state.js';
8
10
  import { createTransport } from './transport.js';
9
11
 
10
- const server = new McpServer({
11
- name: 'incremnt',
12
- version: '0.1.0'
13
- });
14
-
15
- for (const cmd of commandSchema) {
12
+ function commandShape(cmd) {
16
13
  const shape = {};
17
14
 
18
15
  for (const opt of cmd.options) {
@@ -22,39 +19,69 @@ for (const cmd of commandSchema) {
22
19
  shape[opt.name] = field;
23
20
  }
24
21
 
25
- server.tool(cmd.id, cmd.description, shape, async (args) => {
26
- try {
27
- const sessionState = await readSessionState();
28
- const transport = await createTransport({}, sessionState);
22
+ return shape;
23
+ }
24
+
25
+ export function registerMcpTools(server, {
26
+ readSessionStateFn = readSessionState,
27
+ createTransportFn = createTransport
28
+ } = {}) {
29
+ for (const cmd of [...commandSchema, ...writeCommandSchema]) {
30
+ server.tool(cmd.id, cmd.description, commandShape(cmd), async (args) => {
31
+ try {
32
+ const sessionState = await readSessionStateFn();
33
+ const transport = await createTransportFn({}, sessionState);
34
+
35
+ if (transport.expired) {
36
+ return {
37
+ content: [{ type: 'text', text: 'Session expired. Run `incremnt login` to re-authenticate.' }],
38
+ isError: true
39
+ };
40
+ }
41
+
42
+ const result = writeCommands.has(cmd.id)
43
+ ? await transport.executeWriteCommand(cmd.id, args)
44
+ : await transport.executeReadCommand(cmd.id, args);
29
45
 
30
- if (transport.expired) {
31
46
  return {
32
- content: [{ type: 'text', text: 'Session expired. Run `incremnt login` to re-authenticate.' }],
33
- isError: true
47
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
34
48
  };
35
- }
49
+ } catch (error) {
50
+ const message = error?.message ?? String(error);
36
51
 
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);
52
+ if (error?.code === 'SNAPSHOT_NOT_FOUND') {
53
+ return {
54
+ content: [{ type: 'text', text: 'Not logged in. Run `incremnt login` first.' }],
55
+ isError: true
56
+ };
57
+ }
43
58
 
44
- if (error?.code === 'SNAPSHOT_NOT_FOUND') {
45
59
  return {
46
- content: [{ type: 'text', text: 'Not logged in. Run `incremnt login` first.' }],
60
+ content: [{ type: 'text', text: message }],
47
61
  isError: true
48
62
  };
49
63
  }
64
+ });
65
+ }
66
+
67
+ return server;
68
+ }
50
69
 
51
- return {
52
- content: [{ type: 'text', text: message }],
53
- isError: true
54
- };
55
- }
70
+ export function createMcpServer(deps) {
71
+ const server = new McpServer({
72
+ name: 'incremnt',
73
+ version: '0.1.0'
56
74
  });
75
+
76
+ return registerMcpTools(server, deps);
57
77
  }
58
78
 
59
- const transport = new StdioServerTransport();
60
- await server.connect(transport);
79
+ async function main() {
80
+ const transport = new StdioServerTransport();
81
+ const server = createMcpServer();
82
+ await server.connect(transport);
83
+ }
84
+
85
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
86
+ await main();
87
+ }
package/src/openrouter.js CHANGED
@@ -1,13 +1,83 @@
1
- // Default model used only for local dev. In production the sync service
2
- // sets OPENROUTER_MODEL via env vars (currently Claude 3.5 Haiku).
3
- const DEFAULT_MODEL = 'meta-llama/llama-3.1-8b-instruct:free';
4
- const DEFAULT_TIMEOUT_MS = 15_000;
1
+ const SUMMARY_MODEL_CHAIN = [
2
+ 'deepseek/deepseek-v3.2-20251201',
3
+ 'anthropic/claude-3.5-haiku'
4
+ ];
5
+ const ASK_MODEL_CHAIN = [
6
+ 'anthropic/claude-3.5-haiku',
7
+ 'deepseek/deepseek-v3.2-20251201'
8
+ ];
9
+ const TIMEOUT_PER_MODEL_MS = 12_000;
10
+ const ASK_TIMEOUT_MS = 8_000;
5
11
  const DEFAULT_MAX_TOKENS = 500;
6
12
 
7
- const CYCLE_SUMMARY_PROMPT = `You are a strength coach reviewing a trainee's completed training cycle (typically one week). Write 3-4 short paragraphs separated by blank lines.
13
+ async function callOpenRouter(messages, { apiKey, models, temperature, maxTokens, timeoutMs }) {
14
+ const chain = models ?? SUMMARY_MODEL_CHAIN;
15
+ const timeout = timeoutMs ?? TIMEOUT_PER_MODEL_MS;
16
+ const errors = [];
17
+
18
+ const startTotal = Date.now();
19
+
20
+ for (const model of chain) {
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), timeout);
23
+ const startModel = Date.now();
24
+
25
+ try {
26
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
27
+ method: 'POST',
28
+ headers: {
29
+ 'Authorization': `Bearer ${apiKey}`,
30
+ 'Content-Type': 'application/json',
31
+ 'HTTP-Referer': 'https://incremnt.app',
32
+ 'X-Title': 'incremnt'
33
+ },
34
+ body: JSON.stringify({
35
+ model,
36
+ messages,
37
+ max_tokens: maxTokens ?? DEFAULT_MAX_TOKENS,
38
+ temperature: temperature ?? 0.5
39
+ }),
40
+ signal: controller.signal
41
+ });
42
+
43
+ if (!response.ok) {
44
+ const text = await response.text().catch(() => '');
45
+ throw new Error(`OpenRouter API error ${response.status}: ${text}`);
46
+ }
47
+
48
+ const data = await response.json();
49
+ const content = data.choices?.[0]?.message?.content;
50
+ if (!content) {
51
+ throw new Error('No content in OpenRouter response');
52
+ }
53
+
54
+ return {
55
+ text: content.trim(),
56
+ model,
57
+ fallback: model !== chain[0],
58
+ durationMs: Date.now() - startTotal,
59
+ errors: errors.length > 0 ? errors : undefined
60
+ };
61
+ } catch (err) {
62
+ errors.push({ model, error: err.message, durationMs: Date.now() - startModel });
63
+ console.error(`OpenRouter ${model} failed (${Date.now() - startModel}ms): ${err.message}`);
64
+ } finally {
65
+ clearTimeout(timer);
66
+ }
67
+ }
68
+
69
+ const err = new Error(`All models failed: ${errors.map((e) => `${e.model}: ${e.error}`).join('; ')}`);
70
+ err.modelErrors = errors;
71
+ err.durationMs = Date.now() - startTotal;
72
+ throw err;
73
+ }
74
+
75
+ export const CYCLE_SUMMARY_PROMPT = `You are a strength coach reviewing a trainee's completed training cycle (typically one week). Write 3-4 short paragraphs separated by blank lines.
8
76
 
9
77
  Your job is to give a cycle-level review — not a session-by-session recap. The app already shows set completion rate, individual session breakdowns, and deload adjustments — do NOT repeat any of that. Synthesize across the cycle.
10
78
 
79
+ The data tells the story — your job is to interpret it honestly, not to make the trainee feel good.
80
+
11
81
  Cover these in order of relevance (skip any that don't apply):
12
82
  1. Overall cycle assessment: was this a build/deload/peak week? Did volume and intensity match the intent? If it was a deload, don't flag low numbers as a problem.
13
83
  2. Progression commentary: the app made auto-progression decisions listed below. Comment on whether they look right given the data.
@@ -15,61 +85,24 @@ Cover these in order of relevance (skip any that don't apply):
15
85
  4. Goal progress: if the trainee has strength goals, comment on trajectory.
16
86
  5. One concrete thing to watch or change next cycle. Be specific.
17
87
 
18
- Rules:
19
- - Only state what the data shows. Never claim how something "felt."
20
- - Reference specific exercises, weights, and reps. Use numbers, not vague praise.
21
- - If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
22
- - If exercises were swapped from the plan, note the pattern and ask about it if recurring.
23
- - Write like a training partner looking at a logbook, not a motivational coach. Short sentences, no filler, no cheerleading. Questions are good.
24
- - Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting, smooth, controlled.
25
- - Never use -ing clauses that add fake depth.
26
- - No bullet points or lists.`;
88
+ Only state what the data shows. Never claim how something "felt." Reference specific exercises, weights, and reps — use numbers, not vague descriptions. If there are PRs, mention them matter-of-factly. If exercises were swapped from the plan, note the pattern and ask about it if recurring. Write like a training partner looking at a logbook. Short sentences, no filler, no cheerleading. Questions are good.
27
89
 
28
- export async function generateCoachingSummary(cycleContext, { apiKey, model, timeoutMs } = {}) {
29
- const resolvedModel = model || DEFAULT_MODEL;
30
- const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
31
-
32
- const controller = new AbortController();
33
- const timer = setTimeout(() => controller.abort(), resolvedTimeout);
34
-
35
- try {
36
- const userContent = formatCycleContext(cycleContext);
37
-
38
- const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
39
- method: 'POST',
40
- headers: {
41
- 'Authorization': `Bearer ${apiKey}`,
42
- 'Content-Type': 'application/json',
43
- 'HTTP-Referer': 'https://incremnt.app',
44
- 'X-Title': 'incremnt'
45
- },
46
- body: JSON.stringify({
47
- model: resolvedModel,
48
- messages: [
49
- { role: 'system', content: CYCLE_SUMMARY_PROMPT },
50
- { role: 'user', content: userContent }
51
- ],
52
- max_tokens: DEFAULT_MAX_TOKENS,
53
- temperature: 0.7
54
- }),
55
- signal: controller.signal
56
- });
57
-
58
- if (!response.ok) {
59
- const text = await response.text().catch(() => '');
60
- throw new Error(`OpenRouter API error ${response.status}: ${text}`);
61
- }
90
+ If you catch yourself writing something that sounds like a performance review or a fitness influencer post, rewrite it. No -ing clauses that add fake depth. No bullet points or lists.`;
62
91
 
63
- const data = await response.json();
64
- const content = data.choices?.[0]?.message?.content;
65
- if (!content) {
66
- throw new Error('No content in OpenRouter response');
92
+ export async function generateCoachingSummary(cycleContext, { apiKey, model, timeoutMs } = {}) {
93
+ const userContent = formatCycleContext(cycleContext);
94
+ return callOpenRouter(
95
+ [
96
+ { role: 'system', content: CYCLE_SUMMARY_PROMPT },
97
+ { role: 'user', content: userContent }
98
+ ],
99
+ {
100
+ apiKey,
101
+ models: model ? [model] : SUMMARY_MODEL_CHAIN,
102
+ temperature: 0.5,
103
+ timeoutMs
67
104
  }
68
-
69
- return content.trim();
70
- } finally {
71
- clearTimeout(timer);
72
- }
105
+ );
73
106
  }
74
107
 
75
108
  export function formatCycleContext(ctx) {
@@ -163,77 +196,60 @@ export function formatCycleContext(ctx) {
163
196
  }
164
197
  }
165
198
 
199
+ const recoveryParts = [];
200
+ if (ctx.avgRestingHR) recoveryParts.push(`avg resting HR ${ctx.avgRestingHR} bpm`);
201
+ if (ctx.avgHRV) recoveryParts.push(`avg HRV ${ctx.avgHRV} ms`);
202
+ if (ctx.latestVO2Max) recoveryParts.push(`VO2 max ${ctx.latestVO2Max} ml/kg/min`);
203
+ if (ctx.avgSleepMins) recoveryParts.push(`avg sleep ${(ctx.avgSleepMins / 60).toFixed(1)}h`);
204
+ if (ctx.latestBodyWeightKg) recoveryParts.push(`body weight ${ctx.latestBodyWeightKg} kg`);
205
+ if (recoveryParts.length > 0) {
206
+ lines.push('');
207
+ lines.push(`Recovery metrics this cycle: ${recoveryParts.join(', ')}`);
208
+ }
209
+
210
+ if (ctx.cycleCardio?.length > 0) {
211
+ lines.push('');
212
+ lines.push('Cardio this cycle:');
213
+ for (const w of ctx.cycleCardio) {
214
+ const parts = [w.durationSecs ? `${Math.round(w.durationSecs / 60)} min` : '? min'];
215
+ if (w.distanceKm) parts.push(`${w.distanceKm.toFixed(1)} km`);
216
+ if (w.avgHR) parts.push(`avg HR ${w.avgHR} bpm`);
217
+ if (w.effortScore) parts.push(`effort ${w.effortScore}/10`);
218
+ lines.push(` ${w.date} ${w.workoutType}: ${parts.join(', ')}`);
219
+ }
220
+ }
221
+
166
222
  return lines.join('\n');
167
223
  }
168
224
 
169
225
  export const WORKOUT_COACH_PROMPT = `You are reviewing a training session log. Write 2-3 short paragraphs separated by blank lines.
170
226
 
171
- Your job is to surface things the user wouldn't notice from glancing at their workout summary. The app already shows them PRs, total volume, effort score, and exercise breakdown — do NOT repeat any of that.
172
-
173
- Focus on:
174
- - Plan deviations: exercises swapped, skipped, or added vs the plan. Ask why if something looks unusual.
175
- - Set completion: if they did fewer sets than planned on an exercise, note it and ask about it.
176
- - Cross-session patterns: trends across recent sessions (volume direction on specific lifts, consistent cutoffs, same weight for weeks).
177
- - If exercises are marked "first session" they have zero prior history. State this plainly — don't frame it as "exploring" or "a new loading strategy."
178
- - If this is an adhoc session (no program), note any overlap with programmed exercises.
179
- - Ask 1-2 genuine questions about choices that look interesting or unusual.
180
-
181
- Voice rules — follow these exactly:
182
- - Only state what the data shows. Never claim how something "felt."
183
- - Be specific. Use numbers and exercise names, not vague descriptions.
184
- - No hedging: don't soften with "suggests", "appears to", "looks like", "seems", "might", "could potentially", "may want to." State it.
185
- - No filler preambles: don't start sentences with "The session shows", "Your performance indicates", "It's worth noting." Just say it.
186
- - No -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains", "suggesting increased capacity").
187
- - Never use: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, smooth, controlled, highlighting, journey, empower.
188
- - Never restate PRs, total volume, or effort score — the user already sees these.
189
- - Write like a training partner reading a logbook. Short sentences, no filler, no cheerleading.
190
- - No bullet points or lists.`;
227
+ Your job is to surface things the user wouldn't notice from glancing at their workout summary. The app already shows them PRs, total volume, effort score, and exercise breakdown — do NOT repeat any of that. The data tells the story — your job is to interpret it honestly, not to make the user feel good.
191
228
 
192
- export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, model, timeoutMs } = {}) {
193
- const resolvedModel = model || DEFAULT_MODEL;
194
- const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
195
-
196
- const controller = new AbortController();
197
- const timer = setTimeout(() => controller.abort(), resolvedTimeout);
198
-
199
- try {
200
- const userContent = formatWorkoutContext(workoutContext);
201
-
202
- const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
203
- method: 'POST',
204
- headers: {
205
- 'Authorization': `Bearer ${apiKey}`,
206
- 'Content-Type': 'application/json',
207
- 'HTTP-Referer': 'https://incremnt.app',
208
- 'X-Title': 'incremnt'
209
- },
210
- body: JSON.stringify({
211
- model: resolvedModel,
212
- messages: [
213
- { role: 'system', content: WORKOUT_COACH_PROMPT },
214
- { role: 'user', content: userContent }
215
- ],
216
- max_tokens: DEFAULT_MAX_TOKENS,
217
- temperature: 0.7
218
- }),
219
- signal: controller.signal
220
- });
221
-
222
- if (!response.ok) {
223
- const text = await response.text().catch(() => '');
224
- throw new Error(`OpenRouter API error ${response.status}: ${text}`);
225
- }
229
+ Focus on plan deviations (exercises swapped, skipped, or added vs the plan), set completion (if they did fewer sets than planned, note it and ask about it), and cross-session patterns (volume direction on specific lifts, consistent cutoffs, same weight for weeks). If exercises are marked "first session" they have zero prior history — state this plainly, don't frame it as "exploring" or "a new loading strategy." If this is an adhoc session, note any overlap with programmed exercises.
226
230
 
227
- const data = await response.json();
228
- const content = data.choices?.[0]?.message?.content;
229
- if (!content) {
230
- throw new Error('No content in OpenRouter response');
231
- }
231
+ Never name an exercise that does not appear in the workout data below. [first session] means no prior history for that exact exercise. It does not mean the user switched from another exercise.
232
232
 
233
- return content.trim();
234
- } finally {
235
- clearTimeout(timer);
236
- }
233
+ Ask 1-2 genuine questions about choices that look interesting or unusual. This is the most valuable thing you can do — a good question is worth more than restating what happened.
234
+
235
+ Only state what the data shows. Never claim how something "felt." Be specific — use numbers and exercise names. Don't soften with "suggests", "appears to", "seems", "might." State it. Don't start sentences with "The session shows", "Your performance indicates", "It's worth noting." Just say it.
236
+
237
+ If you catch yourself writing something that sounds like a performance review or a fitness influencer post, rewrite it. No -ing clauses that add fake depth. No bullet points or lists.`;
238
+
239
+ export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, model, timeoutMs } = {}) {
240
+ const userContent = formatWorkoutContext(workoutContext);
241
+ return callOpenRouter(
242
+ [
243
+ { role: 'system', content: WORKOUT_COACH_PROMPT },
244
+ { role: 'user', content: userContent }
245
+ ],
246
+ {
247
+ apiKey,
248
+ models: model ? [model] : SUMMARY_MODEL_CHAIN,
249
+ temperature: 0.5,
250
+ timeoutMs
251
+ }
252
+ );
237
253
  }
238
254
 
239
255
  export function formatWorkoutContext(ctx) {
@@ -290,54 +306,66 @@ export function formatWorkoutContext(ctx) {
290
306
  }
291
307
  }
292
308
 
293
- return lines.join('\n');
294
- }
309
+ // Recovery context
310
+ const recoveryParts = [];
311
+ if (ctx.restingHROnDay) recoveryParts.push(`resting HR ${Math.round(ctx.restingHROnDay)} bpm`);
312
+ if (ctx.hrvOnDay) recoveryParts.push(`HRV ${Math.round(ctx.hrvOnDay)} ms`);
313
+ if (ctx.vo2MaxLatest) recoveryParts.push(`VO2 max ${ctx.vo2MaxLatest} ml/kg/min`);
314
+ if (ctx.sleepNight) recoveryParts.push(`sleep ${(ctx.sleepNight.durationMins / 60).toFixed(1)}h`);
315
+ if (ctx.bodyWeightKg) recoveryParts.push(`body weight ${ctx.bodyWeightKg} kg`);
316
+ if (recoveryParts.length > 0) {
317
+ lines.push(`Recovery (session day): ${recoveryParts.join(', ')}`);
318
+ }
295
319
 
296
- const ASK_PROMPT = `You are a knowledgeable strength training assistant. Answer questions about the user's workout history concisely. Reference specific numbers, dates, and exercises from their data. If the data doesn't contain information to answer the question, say so briefly. Keep answers to 2-4 sentences.`;
297
-
298
- export async function generateAskAnswer(context, question, { apiKey, model, timeoutMs } = {}) {
299
- const resolvedModel = model || DEFAULT_MODEL;
300
- const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
301
-
302
- const controller = new AbortController();
303
- const timer = setTimeout(() => controller.abort(), resolvedTimeout);
304
-
305
- try {
306
- const userContent = `${context}\n\nQuestion: ${question}`;
307
-
308
- const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
309
- method: 'POST',
310
- headers: {
311
- 'Authorization': `Bearer ${apiKey}`,
312
- 'Content-Type': 'application/json',
313
- 'HTTP-Referer': 'https://incremnt.app',
314
- 'X-Title': 'incremnt'
315
- },
316
- body: JSON.stringify({
317
- model: resolvedModel,
318
- messages: [
319
- { role: 'system', content: ASK_PROMPT },
320
- { role: 'user', content: userContent }
321
- ],
322
- max_tokens: 300,
323
- temperature: 0.3
324
- }),
325
- signal: controller.signal
326
- });
327
-
328
- if (!response.ok) {
329
- const text = await response.text().catch(() => '');
330
- throw new Error(`OpenRouter API error ${response.status}: ${text}`);
320
+ if (ctx.nearbyCardio?.length > 0) {
321
+ lines.push('Cardio in the 7 days before this session:');
322
+ for (const w of ctx.nearbyCardio) {
323
+ const parts = [w.durationSecs ? `${Math.round(w.durationSecs / 60)} min` : '? min'];
324
+ if (w.distanceKm) parts.push(`${w.distanceKm.toFixed(1)} km`);
325
+ if (w.avgHR) parts.push(`avg HR ${w.avgHR} bpm`);
326
+ if (w.effortScore) parts.push(`effort ${w.effortScore}/10`);
327
+ lines.push(` ${w.date} ${w.workoutType}: ${parts.join(', ')}`);
331
328
  }
329
+ }
332
330
 
333
- const data = await response.json();
334
- const content = data.choices?.[0]?.message?.content;
335
- if (!content) {
336
- throw new Error('No content in OpenRouter response');
337
- }
331
+ return lines.join('\n');
332
+ }
333
+
334
+ export const ASK_PROMPT = `You are a strength coach answering questions from the user's training history. Give concrete, useful coaching, not hype.
338
335
 
339
- return content.trim();
340
- } finally {
341
- clearTimeout(timer);
336
+ Rules:
337
+ - Use only the data provided. If the data does not support a claim, do not make it.
338
+ - Focus on trend, weak points, tradeoffs, and next steps. Be specific with exercises, weights, reps, volume, and timing when relevant.
339
+ - Match the response length to the question. Short or playful prompts get a short conversational reply plus an invitation to ask something specific.
340
+ - Keep the tone natural and direct. No hype, no filler, no emoji, no "let's dive in", no performance-review language.
341
+ - Never name an exercise that does not appear in the training data below.
342
+ - If data is missing or ambiguous, say so plainly.
343
+
344
+ When the user asks for analysis, answer like a coach who has watched their training over time. When they ask for a plan, give a clear next-session recommendation. Bullet points are fine when they make the answer easier to use.`;
345
+
346
+ export async function generateAskAnswer(context, question, { apiKey, model, timeoutMs, history = [] } = {}) {
347
+ // First user message includes the workout context; follow-ups are plain questions
348
+ const firstUserContent = `${context}\n\nQuestion: ${question}`;
349
+ const isFollowUp = history.length > 0;
350
+ const newUserContent = isFollowUp ? question : firstUserContent;
351
+
352
+ const priorMessages = history.map((m) => ({ role: m.role, content: m.content }));
353
+ // Prepend context to the first user message in history if needed
354
+ if (isFollowUp && priorMessages.length > 0 && priorMessages[0].role === 'user') {
355
+ priorMessages[0] = { role: 'user', content: `${context}\n\nQuestion: ${priorMessages[0].content}` };
342
356
  }
357
+
358
+ return callOpenRouter(
359
+ [
360
+ { role: 'system', content: ASK_PROMPT },
361
+ ...priorMessages,
362
+ { role: 'user', content: newUserContent }
363
+ ],
364
+ {
365
+ apiKey,
366
+ models: model ? [model] : ASK_MODEL_CHAIN,
367
+ temperature: 0.3,
368
+ timeoutMs: timeoutMs ?? ASK_TIMEOUT_MS
369
+ }
370
+ );
343
371
  }
package/src/queries.js CHANGED
@@ -58,7 +58,8 @@ function sessionSummary(session) {
58
58
  recommendations: session.recommendations ?? {},
59
59
  historicalContext: session.historicalContext ?? null,
60
60
  prescriptionSnapshot: session.prescriptionSnapshot ?? null,
61
- aiCoachNotes: session.summary?.aiCoachNotes ?? null
61
+ aiCoachNotes: session.summary?.aiCoachNotes ?? null,
62
+ aiCoachModel: session.summary?.aiCoachModel ?? null
62
63
  };
63
64
  }
64
65
 
@@ -648,6 +649,39 @@ export function cycleSummaryContext(snapshot, programId) {
648
649
  ? { completed: matchingSummary.totalSetsCompleted ?? 0, planned: matchingSummary.totalSetsPlanned ?? 0 }
649
650
  : null;
650
651
 
652
+ // Health metrics spanning the cycle
653
+ const cycleStart = String(cycleSessions[0] ? completionDateForSession(cycleSessions[0]) : '');
654
+ const cycleEnd = String(cycleSessions[cycleSessions.length - 1]
655
+ ? completionDateForSession(cycleSessions[cycleSessions.length - 1]) : '');
656
+ const cycleCardio = (snapshot.healthMetrics?.otherWorkouts ?? [])
657
+ .filter((w) => w.date >= cycleStart && w.date <= cycleEnd);
658
+ const cycleRestingHR = (snapshot.healthMetrics?.restingHR ?? [])
659
+ .filter((m) => m.date >= cycleStart && m.date <= cycleEnd);
660
+ const cycleHRV = (snapshot.healthMetrics?.hrv ?? [])
661
+ .filter((m) => m.date >= cycleStart && m.date <= cycleEnd);
662
+ const cycleVO2Max = (snapshot.healthMetrics?.vo2Max ?? [])
663
+ .filter((m) => m.date >= cycleStart && m.date <= cycleEnd);
664
+ const cycleSleep = (snapshot.healthMetrics?.sleep ?? [])
665
+ .filter((m) => m.date >= cycleStart && m.date <= cycleEnd);
666
+
667
+ const avgRestingHR = cycleRestingHR.length > 0
668
+ ? Math.round(cycleRestingHR.reduce((s, m) => s + m.value, 0) / cycleRestingHR.length)
669
+ : null;
670
+ const avgHRV = cycleHRV.length > 0
671
+ ? Math.round(cycleHRV.reduce((s, m) => s + m.value, 0) / cycleHRV.length)
672
+ : null;
673
+ const latestVO2Max = cycleVO2Max.length > 0
674
+ ? Math.round(cycleVO2Max.at(-1).value * 10) / 10
675
+ : null;
676
+ const avgSleepMins = cycleSleep.length > 0
677
+ ? Math.round(cycleSleep.reduce((s, m) => s + m.durationMins, 0) / cycleSleep.length)
678
+ : null;
679
+ const cycleBodyWeight = (snapshot.healthMetrics?.bodyWeight ?? [])
680
+ .filter((m) => m.date >= cycleStart && m.date <= cycleEnd);
681
+ const latestBodyWeightKg = cycleBodyWeight.length > 0
682
+ ? Math.round(cycleBodyWeight.at(-1).value * 10) / 10
683
+ : null;
684
+
651
685
  return {
652
686
  programName: program.name,
653
687
  cycleNumber: cycleWeekNumber,
@@ -661,7 +695,13 @@ export function cycleSummaryContext(snapshot, programId) {
661
695
  cycleIntent,
662
696
  adaptationNote,
663
697
  previousCycles,
664
- exerciseTrends
698
+ exerciseTrends,
699
+ cycleCardio: cycleCardio.length > 0 ? cycleCardio : null,
700
+ avgRestingHR,
701
+ avgHRV,
702
+ latestVO2Max,
703
+ avgSleepMins,
704
+ latestBodyWeightKg
665
705
  };
666
706
  }
667
707
 
@@ -772,6 +812,31 @@ export function workoutSummaryContext(snapshot, sessionId) {
772
812
  }
773
813
 
774
814
  const isAdhoc = !session.programId;
815
+
816
+ // Include other workouts from the 7 days before this session for coach context
817
+ const sessionDateStr = String(sessionDate);
818
+ const weekBefore = new Date(new Date(sessionDateStr).getTime() - 7 * 24 * 60 * 60 * 1000)
819
+ .toISOString().slice(0, 10);
820
+ const nearbyCardio = (snapshot.healthMetrics?.otherWorkouts ?? [])
821
+ .filter((w) => w.date >= weekBefore && w.date <= sessionDateStr);
822
+
823
+ const restingHROnDay = (snapshot.healthMetrics?.restingHR ?? [])
824
+ .find((m) => m.date === sessionDateStr);
825
+ const hrvOnDay = (snapshot.healthMetrics?.hrv ?? [])
826
+ .find((m) => m.date === sessionDateStr);
827
+ const sleepNight = (snapshot.healthMetrics?.sleep ?? [])
828
+ .find((m) => m.date === sessionDateStr);
829
+ const vo2MaxRecent = (snapshot.healthMetrics?.vo2Max ?? [])
830
+ .filter((m) => m.date >= weekBefore && m.date <= sessionDateStr);
831
+ const vo2MaxLatest = vo2MaxRecent.length > 0
832
+ ? Math.round(vo2MaxRecent.at(-1).value * 10) / 10
833
+ : null;
834
+ const bodyWeightOnDay = (snapshot.healthMetrics?.bodyWeight ?? [])
835
+ .find((m) => m.date === sessionDateStr);
836
+ const bodyWeightKg = bodyWeightOnDay
837
+ ? Math.round(bodyWeightOnDay.value * 10) / 10
838
+ : null;
839
+
775
840
  const result = {
776
841
  sessionDate,
777
842
  dayName,
@@ -781,7 +846,13 @@ export function workoutSummaryContext(snapshot, sessionId) {
781
846
  effortScore: session.summary?.effortScore ?? null,
782
847
  exercises,
783
848
  recentComparisons,
784
- prs
849
+ prs,
850
+ nearbyCardio: nearbyCardio.length > 0 ? nearbyCardio : null,
851
+ restingHROnDay: restingHROnDay?.value ?? null,
852
+ hrvOnDay: hrvOnDay?.value ?? null,
853
+ vo2MaxLatest,
854
+ sleepNight: sleepNight ?? null,
855
+ bodyWeightKg
785
856
  };
786
857
  if (planComparison) result.planComparison = planComparison;
787
858
  return result;
@@ -863,9 +934,123 @@ export function askContext(snapshot) {
863
934
  }
864
935
  }
865
936
 
937
+ appendHealthMetricsContext(lines, snapshot.healthMetrics, { recentDays: 14 });
938
+
866
939
  return lines.join('\n');
867
940
  }
868
941
 
942
+ function appendHealthMetricsContext(lines, metrics, { recentDays = 14 } = {}) {
943
+ if (!metrics) return;
944
+
945
+ const cutoff = new Date(Date.now() - recentDays * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
946
+
947
+ const recentWorkouts = (metrics.otherWorkouts ?? []).filter((w) => w.date >= cutoff);
948
+ if (recentWorkouts.length > 0) {
949
+ lines.push('');
950
+ lines.push(`Other workouts (last ${recentDays} days):`);
951
+ for (const w of recentWorkouts) {
952
+ const parts = [`${w.durationSecs ? Math.round(w.durationSecs / 60) : '?'} min`];
953
+ if (w.distanceKm) parts.push(`${w.distanceKm.toFixed(1)} km`);
954
+ if (w.avgHR) parts.push(`avg HR ${w.avgHR} bpm`);
955
+ if (w.calories) parts.push(`${w.calories} kcal`);
956
+ if (w.effortScore) parts.push(`effort ${w.effortScore}/10`);
957
+ lines.push(` ${w.date} ${w.workoutType}: ${parts.join(', ')}`);
958
+ }
959
+ }
960
+
961
+ const recentRestingHR = (metrics.restingHR ?? []).filter((m) => m.date >= cutoff);
962
+ if (recentRestingHR.length > 0) {
963
+ const avg = Math.round(recentRestingHR.reduce((s, m) => s + m.value, 0) / recentRestingHR.length);
964
+ const latest = recentRestingHR[recentRestingHR.length - 1];
965
+ lines.push('');
966
+ lines.push(`Resting HR (last ${recentDays} days): avg ${avg} bpm, latest ${Math.round(latest.value)} bpm (${latest.date})`);
967
+ }
968
+
969
+ const recentHRV = (metrics.hrv ?? []).filter((m) => m.date >= cutoff);
970
+ if (recentHRV.length > 0) {
971
+ const avg = Math.round(recentHRV.reduce((s, m) => s + m.value, 0) / recentHRV.length);
972
+ const latest = recentHRV[recentHRV.length - 1];
973
+ lines.push(`HRV (last ${recentDays} days): avg ${avg} ms, latest ${Math.round(latest.value)} ms (${latest.date})`);
974
+ }
975
+
976
+ const recentVO2Max = (metrics.vo2Max ?? []).filter((m) => m.date >= cutoff);
977
+ if (recentVO2Max.length > 0) {
978
+ const latest = recentVO2Max[recentVO2Max.length - 1];
979
+ lines.push(`VO2 Max: ${Math.round(latest.value * 10) / 10} ml/kg/min (${latest.date})`);
980
+ }
981
+
982
+ const recentSleep = (metrics.sleep ?? []).filter((m) => m.date >= cutoff);
983
+ if (recentSleep.length > 0) {
984
+ const avgMins = Math.round(recentSleep.reduce((s, m) => s + m.durationMins, 0) / recentSleep.length);
985
+ const avgHours = (avgMins / 60).toFixed(1);
986
+ lines.push(`Sleep (last ${recentDays} days): avg ${avgHours}h/night`);
987
+ }
988
+
989
+ const recentBodyWeight = (metrics.bodyWeight ?? []).filter((m) => m.date >= cutoff);
990
+ if (recentBodyWeight.length > 0) {
991
+ const latest = recentBodyWeight[recentBodyWeight.length - 1];
992
+ const earliest = recentBodyWeight[0];
993
+ const delta = (latest.value - earliest.value).toFixed(1);
994
+ const trend = delta > 0 ? `+${delta}` : delta;
995
+ lines.push(`Body weight (last ${recentDays} days): latest ${latest.value.toFixed(1)} kg (${latest.date}), ${recentBodyWeight.length} readings, trend ${trend} kg`);
996
+ }
997
+ }
998
+
999
+ function healthSummary(snapshot, days = 14) {
1000
+ const metrics = snapshot.healthMetrics;
1001
+ if (!metrics) return { available: false };
1002
+
1003
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
1004
+
1005
+ const recentWorkouts = (metrics.otherWorkouts ?? []).filter((w) => w.date >= cutoff);
1006
+ const recentRestingHR = (metrics.restingHR ?? []).filter((m) => m.date >= cutoff);
1007
+ const recentHRV = (metrics.hrv ?? []).filter((m) => m.date >= cutoff);
1008
+ const recentVO2Max = (metrics.vo2Max ?? []).filter((m) => m.date >= cutoff);
1009
+ const recentSleep = (metrics.sleep ?? []).filter((m) => m.date >= cutoff);
1010
+
1011
+ const avg = (arr) => arr.length > 0 ? arr.reduce((s, m) => s + m.value, 0) / arr.length : null;
1012
+
1013
+ return {
1014
+ available: true,
1015
+ days,
1016
+ cardio: recentWorkouts.map((w) => ({
1017
+ date: w.date,
1018
+ workoutType: w.workoutType,
1019
+ durationMins: w.durationSecs ? Math.round(w.durationSecs / 60) : null,
1020
+ distanceKm: w.distanceKm ?? null,
1021
+ avgHR: w.avgHR ?? null,
1022
+ calories: w.calories ?? null
1023
+ })),
1024
+ restingHR: {
1025
+ avg: recentRestingHR.length > 0 ? Math.round(avg(recentRestingHR)) : null,
1026
+ latest: recentRestingHR.length > 0 ? { value: Math.round(recentRestingHR.at(-1).value), date: recentRestingHR.at(-1).date } : null,
1027
+ readings: recentRestingHR.length
1028
+ },
1029
+ hrv: {
1030
+ avg: recentHRV.length > 0 ? Math.round(avg(recentHRV)) : null,
1031
+ latest: recentHRV.length > 0 ? { value: Math.round(recentHRV.at(-1).value), date: recentHRV.at(-1).date } : null,
1032
+ readings: recentHRV.length
1033
+ },
1034
+ vo2Max: {
1035
+ latest: recentVO2Max.length > 0 ? { value: Math.round(recentVO2Max.at(-1).value * 10) / 10, date: recentVO2Max.at(-1).date } : null,
1036
+ readings: recentVO2Max.length
1037
+ },
1038
+ sleep: {
1039
+ avgHours: recentSleep.length > 0 ? Math.round(recentSleep.reduce((s, m) => s + m.durationMins, 0) / recentSleep.length / 60 * 10) / 10 : null,
1040
+ nights: recentSleep.length
1041
+ },
1042
+ bodyWeight: (() => {
1043
+ const recent = (metrics.bodyWeight ?? []).filter((m) => m.date >= cutoff);
1044
+ if (recent.length === 0) return { latest: null, readings: 0 };
1045
+ return {
1046
+ latest: { value: Math.round(recent.at(-1).value * 10) / 10, date: recent.at(-1).date },
1047
+ trend: Math.round((recent.at(-1).value - recent[0].value) * 10) / 10,
1048
+ readings: recent.length
1049
+ };
1050
+ })()
1051
+ };
1052
+ }
1053
+
869
1054
  function requiredOption(options, primaryKey, legacyKey = null) {
870
1055
  return options[primaryKey] ?? (legacyKey ? options[legacyKey] : undefined);
871
1056
  }
@@ -987,5 +1172,10 @@ export function executeReadCommand(snapshot, normalizedCommand, options = {}) {
987
1172
  return { ok: true, payload };
988
1173
  }
989
1174
 
1175
+ if (normalizedCommand === 'health-summary') {
1176
+ const days = Number.parseInt(options.days ?? '14', 10);
1177
+ return { ok: true, payload: healthSummary(snapshot, Number.isNaN(days) ? 14 : days) };
1178
+ }
1179
+
990
1180
  return { ok: false, error: `Unknown read command: ${normalizedCommand}` };
991
1181
  }
package/src/remote.js CHANGED
@@ -29,10 +29,15 @@ const remoteCommandHandlers = {
29
29
  'program-list': executeRemoteRead,
30
30
  'program-summary': executeRemoteRead,
31
31
  'program-detail': executeRemoteRead,
32
+ 'cycle-summary-list': executeRemoteRead,
33
+ 'cycle-summary-show': executeRemoteRead,
32
34
  'planned-vs-actual': executeRemoteRead,
33
35
  'why-did-this-change': executeRemoteRead,
34
36
  'goals-list': executeRemoteRead,
35
- 'goals-show': executeRemoteRead
37
+ 'goals-show': executeRemoteRead,
38
+ 'health-summary': executeRemoteRead,
39
+ 'ask-history': executeRemoteRead,
40
+ 'ask-show': executeRemoteRead
36
41
  };
37
42
 
38
43
  async function executeRemoteRead(options, sessionState, normalizedCommand) {
@@ -111,6 +116,15 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
111
116
  return resolveServiceUrl(baseUrl, '/cli/programs/current');
112
117
  case 'program-detail':
113
118
  return resolveServiceUrl(baseUrl, options.id ? `/cli/programs/${options.id}` : '/cli/programs/active');
119
+ case 'cycle-summary-list': {
120
+ const cyclesUrl = resolveServiceUrl(baseUrl, '/cli/cycles');
121
+ if (options['program-id']) {
122
+ cyclesUrl.searchParams.set('program-id', options['program-id']);
123
+ }
124
+ return cyclesUrl;
125
+ }
126
+ case 'cycle-summary-show':
127
+ return resolveServiceUrl(baseUrl, `/cli/cycles/${options.id}`);
114
128
  case 'exercise-history': {
115
129
  const historyUrl = resolveServiceUrl(baseUrl, '/cli/exercises/history');
116
130
  historyUrl.searchParams.set('name', options.name ?? options.exercise);
@@ -122,6 +136,22 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
122
136
  return resolveServiceUrl(baseUrl, '/cli/goals');
123
137
  case 'goals-show':
124
138
  return resolveServiceUrl(baseUrl, options.id ? `/cli/goals/${options.id}` : '/cli/goals');
139
+ case 'health-summary': {
140
+ const healthUrl = resolveServiceUrl(baseUrl, '/cli/health/summary');
141
+ if (options.days) {
142
+ healthUrl.searchParams.set('days', options.days);
143
+ }
144
+ return healthUrl;
145
+ }
146
+ case 'ask-history': {
147
+ const askUrl = resolveServiceUrl(baseUrl, '/cli/ask/history');
148
+ if (options.limit) {
149
+ askUrl.searchParams.set('limit', options.limit);
150
+ }
151
+ return askUrl;
152
+ }
153
+ case 'ask-show':
154
+ return resolveServiceUrl(baseUrl, `/cli/ask/history/${options.id}`);
125
155
  default:
126
156
  return resolveServiceUrl(baseUrl, '/');
127
157
  }
@@ -136,6 +166,14 @@ function resourceNotFoundMessage(normalizedCommand, options) {
136
166
  return `Session not found: ${options['session-id']}`;
137
167
  }
138
168
 
169
+ if (normalizedCommand === 'cycle-summary-show') {
170
+ return `Cycle summary not found: ${options.id}`;
171
+ }
172
+
173
+ if (normalizedCommand === 'ask-show') {
174
+ return `Conversation not found: ${options.id}`;
175
+ }
176
+
139
177
  return 'Requested resource was not found.';
140
178
  }
141
179
 
@@ -4,6 +4,7 @@ import { executeReadCommand } from './queries.js';
4
4
 
5
5
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
6
6
  const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
7
+ const MAX_ASK_USER_TURNS = 3;
7
8
  const DEFAULT_RATE_LIMIT_RULES = {
8
9
  'workout-summary-ai': 3,
9
10
  'cycle-summary-ai': 3,
@@ -30,6 +31,14 @@ function json(response, statusCode, payload) {
30
31
  response.end(JSON.stringify(payload));
31
32
  }
32
33
 
34
+ function jsonWithHeaders(response, statusCode, payload, headers = {}) {
35
+ response.writeHead(statusCode, {
36
+ 'content-type': 'application/json',
37
+ ...headers
38
+ });
39
+ response.end(JSON.stringify(payload));
40
+ }
41
+
33
42
  function logRequest(request, statusCode, extra = '') {
34
43
  const method = request.method ?? '?';
35
44
  const rawUrl = request.url ?? '/';
@@ -55,8 +64,9 @@ function methodNotAllowed(response, message = 'Method not allowed') {
55
64
  json(response, 405, { error: message });
56
65
  }
57
66
 
58
- function internalError(response, error) {
67
+ function internalError(response, error, onError) {
59
68
  console.error('Internal error:', error.message);
69
+ if (onError) onError(error);
60
70
  json(response, 500, { error: 'Internal server error' });
61
71
  }
62
72
 
@@ -120,16 +130,22 @@ function createRateLimiter({
120
130
 
121
131
  if (!bucket || bucket.resetAt <= now) {
122
132
  buckets.set(key, { count: 1, resetAt: now + windowMs });
123
- return { allowed: true };
133
+ return { allowed: true, retryAfterSec: Math.ceil(windowMs / 1000) };
124
134
  }
125
135
 
126
136
  if (bucket.count >= limit) {
127
- return { allowed: false };
137
+ return {
138
+ allowed: false,
139
+ retryAfterSec: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))
140
+ };
128
141
  }
129
142
 
130
143
  bucket.count += 1;
131
144
  buckets.set(key, bucket);
132
- return { allowed: true };
145
+ return {
146
+ allowed: true,
147
+ retryAfterSec: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))
148
+ };
133
149
  }
134
150
  };
135
151
  }
@@ -329,7 +345,23 @@ function routeRequest(url) {
329
345
  }
330
346
 
331
347
  if (pathname === '/cli/ask/history') {
332
- return { command: 'ask-history', options: {} };
348
+ return { command: 'ask-history', options: { limit: url.searchParams.get('limit') ?? undefined } };
349
+ }
350
+
351
+ {
352
+ const askShowMatch = pathname.match(/^\/cli\/ask\/history\/(.+)$/);
353
+ if (askShowMatch) {
354
+ return { command: 'ask-show', options: { id: askShowMatch[1] } };
355
+ }
356
+ }
357
+
358
+ if (pathname === '/cli/health/summary') {
359
+ return {
360
+ command: 'health-summary',
361
+ options: {
362
+ days: url.searchParams.get('days') ?? undefined
363
+ }
364
+ };
333
365
  }
334
366
 
335
367
  return null;
@@ -804,7 +836,8 @@ export function createSyncServiceRequestHandler({
804
836
  updateProposalForAccount = null,
805
837
  updateAnalysisConsentForAccount = null,
806
838
  saveAskConversationForAccount = null,
807
- listAskConversationsForAccount = null
839
+ listAskConversationsForAccount = null,
840
+ onError = null
808
841
  }) {
809
842
  const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
810
843
 
@@ -841,8 +874,14 @@ export function createSyncServiceRequestHandler({
841
874
  return;
842
875
  }
843
876
 
844
- if (!rateLimiter.check(request, route.command).allowed) {
845
- json(response, 429, { error: 'Too many requests' });
877
+ const rateLimitResult = rateLimiter.check(request, route.command);
878
+ if (!rateLimitResult.allowed) {
879
+ jsonWithHeaders(
880
+ response,
881
+ 429,
882
+ { error: 'Too many requests', code: 'rate_limited' },
883
+ { 'Retry-After': String(rateLimitResult.retryAfterSec ?? 60) }
884
+ );
846
885
  return;
847
886
  }
848
887
 
@@ -1693,18 +1732,32 @@ export function createSyncServiceRequestHandler({
1693
1732
 
1694
1733
  const openrouterKey = process.env.OPENROUTER_API_KEY;
1695
1734
  if (!openrouterKey) {
1696
- json(response, 503, { error: 'AI summaries not configured' });
1735
+ json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
1697
1736
  return;
1698
1737
  }
1699
1738
 
1700
1739
  try {
1701
1740
  const { generateWorkoutCoachingSummary } = await import('./openrouter.js');
1702
- const model = process.env.OPENROUTER_MODEL || undefined;
1703
- const summary = await generateWorkoutCoachingSummary(ctx, { apiKey: openrouterKey, model });
1704
- json(response, 200, { summary });
1741
+ const result = await generateWorkoutCoachingSummary(ctx, { apiKey: openrouterKey });
1742
+ if (result.fallback && onError) {
1743
+ const warning = new Error(`AI workout-summary used fallback model ${result.model}`);
1744
+ warning.level = 'warning';
1745
+ onError(warning, {
1746
+ feature: 'workout-summary',
1747
+ modelErrors: result.errors,
1748
+ durationMs: result.durationMs,
1749
+ fallbackModel: result.model
1750
+ });
1751
+ }
1752
+ json(response, 200, { summary: result.text, model: result.model });
1705
1753
  } catch (err) {
1706
1754
  console.error('AI workout summary error:', err.message);
1707
- json(response, 502, { error: 'Failed to generate AI summary' });
1755
+ onError?.(err, {
1756
+ feature: 'workout-summary',
1757
+ modelErrors: err.modelErrors,
1758
+ durationMs: err.durationMs
1759
+ });
1760
+ json(response, 502, { error: 'Failed to generate AI summary', code: 'ai_unavailable' });
1708
1761
  }
1709
1762
  return;
1710
1763
  }
@@ -1725,18 +1778,32 @@ export function createSyncServiceRequestHandler({
1725
1778
 
1726
1779
  const openrouterKey = process.env.OPENROUTER_API_KEY;
1727
1780
  if (!openrouterKey) {
1728
- json(response, 503, { error: 'AI summaries not configured' });
1781
+ json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
1729
1782
  return;
1730
1783
  }
1731
1784
 
1732
1785
  try {
1733
1786
  const { generateCoachingSummary } = await import('./openrouter.js');
1734
- const model = process.env.OPENROUTER_MODEL || undefined;
1735
- const summary = await generateCoachingSummary(ctx, { apiKey: openrouterKey, model });
1736
- json(response, 200, { summary });
1787
+ const result = await generateCoachingSummary(ctx, { apiKey: openrouterKey });
1788
+ if (result.fallback && onError) {
1789
+ const warning = new Error(`AI cycle-summary used fallback model ${result.model}`);
1790
+ warning.level = 'warning';
1791
+ onError(warning, {
1792
+ feature: 'cycle-summary',
1793
+ modelErrors: result.errors,
1794
+ durationMs: result.durationMs,
1795
+ fallbackModel: result.model
1796
+ });
1797
+ }
1798
+ json(response, 200, { summary: result.text, model: result.model });
1737
1799
  } catch (err) {
1738
1800
  console.error('AI cycle summary error:', err.message);
1739
- json(response, 502, { error: 'Failed to generate AI summary' });
1801
+ onError?.(err, {
1802
+ feature: 'cycle-summary',
1803
+ modelErrors: err.modelErrors,
1804
+ durationMs: err.durationMs
1805
+ });
1806
+ json(response, 502, { error: 'Failed to generate AI summary', code: 'ai_unavailable' });
1740
1807
  }
1741
1808
  return;
1742
1809
  }
@@ -1765,9 +1832,29 @@ export function createSyncServiceRequestHandler({
1765
1832
  return;
1766
1833
  }
1767
1834
 
1835
+ const conversationId = body?.conversationId;
1836
+ if (!conversationId || typeof conversationId !== 'string') {
1837
+ badRequest(response, 'conversationId is required');
1838
+ return;
1839
+ }
1840
+
1841
+ const history = Array.isArray(body?.history) ? body.history : [];
1842
+ const validHistory = history.every(
1843
+ (m) => m && typeof m.role === 'string' && typeof m.content === 'string'
1844
+ );
1845
+ if (!validHistory) {
1846
+ badRequest(response, 'history must be an array of {role, content} objects');
1847
+ return;
1848
+ }
1849
+ const priorUserTurns = history.filter((m) => m.role === 'user').length;
1850
+ if (priorUserTurns >= MAX_ASK_USER_TURNS) {
1851
+ json(response, 400, { error: `Ask Coach supports up to ${MAX_ASK_USER_TURNS} questions per conversation. Start a new conversation.`, code: 'conversation_limit' });
1852
+ return;
1853
+ }
1854
+
1768
1855
  const openrouterKey = process.env.OPENROUTER_API_KEY;
1769
1856
  if (!openrouterKey) {
1770
- json(response, 503, { error: 'AI not configured' });
1857
+ json(response, 503, { error: 'AI not configured', code: 'not_configured' });
1771
1858
  return;
1772
1859
  }
1773
1860
 
@@ -1776,19 +1863,42 @@ export function createSyncServiceRequestHandler({
1776
1863
 
1777
1864
  try {
1778
1865
  const { generateAskAnswer } = await import('./openrouter.js');
1779
- const model = process.env.OPENROUTER_MODEL || undefined;
1780
- const answer = await generateAskAnswer(ctx, question, { apiKey: openrouterKey, model });
1866
+ const askResult = await generateAskAnswer(ctx, question, { apiKey: openrouterKey, history });
1867
+ const updatedMessages = [
1868
+ ...history,
1869
+ { role: 'user', content: question },
1870
+ { role: 'assistant', content: askResult.text }
1871
+ ];
1781
1872
  if (saveAskConversationForAccount) {
1782
1873
  try {
1783
- await saveAskConversationForAccount(account, { question, answer });
1874
+ await saveAskConversationForAccount(account, {
1875
+ id: conversationId,
1876
+ messages: updatedMessages,
1877
+ model: askResult.model
1878
+ });
1784
1879
  } catch (saveErr) {
1785
1880
  console.error('Failed to save ask conversation:', saveErr.message);
1786
1881
  }
1787
1882
  }
1788
- json(response, 200, { answer });
1883
+ if (askResult.fallback && onError) {
1884
+ const warning = new Error(`AI ask-coach used fallback model ${askResult.model}`);
1885
+ warning.level = 'warning';
1886
+ onError(warning, {
1887
+ feature: 'ask-coach',
1888
+ modelErrors: askResult.errors,
1889
+ durationMs: askResult.durationMs,
1890
+ fallbackModel: askResult.model
1891
+ });
1892
+ }
1893
+ json(response, 200, { answer: askResult.text, model: askResult.model });
1789
1894
  } catch (err) {
1790
1895
  console.error('AI ask error:', err.message);
1791
- json(response, 502, { error: 'Failed to generate AI answer' });
1896
+ onError?.(err, {
1897
+ feature: 'ask-coach',
1898
+ modelErrors: err.modelErrors,
1899
+ durationMs: err.durationMs
1900
+ });
1901
+ json(response, 502, { error: 'Failed to generate AI answer', code: 'ai_unavailable' });
1792
1902
  }
1793
1903
  return;
1794
1904
  }
@@ -1805,8 +1915,15 @@ export function createSyncServiceRequestHandler({
1805
1915
  }
1806
1916
 
1807
1917
  try {
1808
- const conversations = await listAskConversationsForAccount(account);
1809
- json(response, 200, { conversations });
1918
+ const limit = route.options.limit ? parseInt(route.options.limit, 10) : 20;
1919
+ const conversations = await listAskConversationsForAccount(account, { limit });
1920
+ const summaries = conversations.map((c) => ({
1921
+ id: c.id,
1922
+ preview: (c.messages?.[0]?.content ?? '').slice(0, 120),
1923
+ messageCount: c.messages?.length ?? 0,
1924
+ createdAt: c.createdAt
1925
+ }));
1926
+ json(response, 200, { conversations: summaries });
1810
1927
  } catch (err) {
1811
1928
  console.error('Ask history error:', err.message);
1812
1929
  json(response, 500, { error: 'Failed to load ask history' });
@@ -1814,6 +1931,32 @@ export function createSyncServiceRequestHandler({
1814
1931
  return;
1815
1932
  }
1816
1933
 
1934
+ if (route.command === 'ask-show') {
1935
+ if (request.method !== 'GET') {
1936
+ methodNotAllowed(response, 'Use GET for /cli/ask/history/:id.');
1937
+ return;
1938
+ }
1939
+
1940
+ if (!listAskConversationsForAccount) {
1941
+ json(response, 503, { error: 'Ask history not available' });
1942
+ return;
1943
+ }
1944
+
1945
+ try {
1946
+ const conversations = await listAskConversationsForAccount(account);
1947
+ const conversation = conversations.find((c) => c.id === route.options.id);
1948
+ if (!conversation) {
1949
+ notFound(response, `Conversation not found: ${route.options.id}`);
1950
+ return;
1951
+ }
1952
+ json(response, 200, conversation);
1953
+ } catch (err) {
1954
+ console.error('Ask show error:', err.message);
1955
+ json(response, 500, { error: 'Failed to load conversation' });
1956
+ }
1957
+ return;
1958
+ }
1959
+
1817
1960
  const result = executeReadCommand(snapshot, route.command, route.options);
1818
1961
  if (!result.ok) {
1819
1962
  if (result.error.startsWith('Session not found')) {
@@ -1827,7 +1970,7 @@ export function createSyncServiceRequestHandler({
1827
1970
 
1828
1971
  json(response, 200, result.payload);
1829
1972
  } catch (error) {
1830
- internalError(response, error);
1973
+ internalError(response, error, onError);
1831
1974
  }
1832
1975
  };
1833
1976
  }