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 +11 -9
- package/package.json +2 -2
- package/src/contract.js +25 -0
- package/src/lib.js +20 -1
- package/src/mcp.js +57 -30
- package/src/openrouter.js +193 -165
- package/src/queries.js +193 -3
- package/src/remote.js +39 -1
- package/src/sync-service.js +170 -27
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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": "
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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:
|
|
60
|
+
content: [{ type: 'text', text: message }],
|
|
47
61
|
isError: true
|
|
48
62
|
};
|
|
49
63
|
}
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return server;
|
|
68
|
+
}
|
|
50
69
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
package/src/sync-service.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
845
|
-
|
|
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
|
|
1703
|
-
|
|
1704
|
-
|
|
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
|
-
|
|
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
|
|
1735
|
-
|
|
1736
|
-
|
|
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
|
-
|
|
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
|
|
1780
|
-
const
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1809
|
-
|
|
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
|
}
|