incremnt 0.1.7 → 0.1.9

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
@@ -1,6 +1,6 @@
1
1
  # incremnt
2
2
 
3
- Command-line tool for querying your [incremnt](https://incremnt.app) strength training data.
3
+ Command-line tool and MCP server for querying your [incremnt](https://incremnt.app) strength training data.
4
4
 
5
5
  ## Install
6
6
 
@@ -8,11 +8,13 @@ Command-line tool for querying your [incremnt](https://incremnt.app) strength tr
8
8
  npm install -g incremnt
9
9
  ```
10
10
 
11
- ## Usage
11
+ This gives you two commands: `incremnt` (CLI) and `incremnt-mcp` (MCP server).
12
+
13
+ ## CLI
12
14
 
13
15
  ### Hosted sync (recommended)
14
16
 
15
- After connecting with Apple in the iOS app (Settings > Workout Sync), your workouts sync automatically. To access them from the CLI:
17
+ After connecting with Apple in the iOS app (Settings > Cloud Sync), your workouts sync automatically. To access them from the CLI:
16
18
 
17
19
  ```bash
18
20
  incremnt login
@@ -34,7 +36,7 @@ incremnt records --input ~/Downloads/export.onemore.json --pretty
34
36
 
35
37
  If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPSHOT`, then common local paths, then the most recent `.onemore.json` in `~/Downloads`.
36
38
 
37
- ## Commands
39
+ ### Commands
38
40
 
39
41
  | Command | Description |
40
42
  |---------|-------------|
@@ -49,7 +51,7 @@ If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPS
49
51
  | `status` | Show current mode, auth state, and config paths |
50
52
  | `contract` | Machine-readable command surface for scripts |
51
53
 
52
- ## Flags
54
+ ### Flags
53
55
 
54
56
  | Flag | Description |
55
57
  |------|-------------|
@@ -57,10 +59,34 @@ If `--input` is omitted, the CLI checks `INCREMNT_SNAPSHOT`, then `ONEMORE_SNAPS
57
59
  | `--input <path>` | Path to a local `.onemore.json` snapshot |
58
60
  | `--limit <n>` | Limit number of results (for `sessions list`) |
59
61
 
60
- ## Exercise matching
62
+ ### Exercise matching
61
63
 
62
64
  `exercises history --name "Bench Press"` uses canonical synonym matching, so it finds `Barbell Bench Press` without pulling in incline, machine, or dumbbell variants.
63
65
 
66
+ ## MCP Server
67
+
68
+ The package includes an [MCP](https://modelcontextprotocol.io) server that exposes the same queries as tools for AI assistants like Claude.
69
+
70
+ ### Setup
71
+
72
+ Add to your Claude Code project config (`.mcp.json`):
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "incremnt": {
78
+ "type": "stdio",
79
+ "command": "npx",
80
+ "args": ["-y", "incremnt-mcp"]
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ Or run directly: `npx incremnt-mcp`
87
+
88
+ The MCP server uses the same auth session as the CLI — run `incremnt login` first.
89
+
64
90
  ## License
65
91
 
66
92
  MIT
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Command-line tool for querying your incremnt strength training data",
5
5
  "license": "MIT",
6
6
  "type": "module",
7
7
  "bin": {
8
- "incremnt": "./src/index.js"
8
+ "incremnt": "./src/index.js",
9
+ "incremnt-mcp": "./src/mcp.js"
9
10
  },
10
11
  "files": [
11
12
  "src/"
@@ -15,6 +16,8 @@
15
16
  "dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js"
16
17
  },
17
18
  "dependencies": {
18
- "chalk": "^5.6.2"
19
+ "@modelcontextprotocol/sdk": "^1.12.1",
20
+ "chalk": "^5.6.2",
21
+ "zod": "^3.24.0"
19
22
  }
20
23
  }
package/src/mcp.js ADDED
@@ -0,0 +1,60 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import { z } from 'zod';
6
+ import { commandSchema } from './contract.js';
7
+ import { readSessionState } from './state.js';
8
+ import { createTransport } from './transport.js';
9
+
10
+ const server = new McpServer({
11
+ name: 'incremnt',
12
+ version: '0.1.0'
13
+ });
14
+
15
+ for (const cmd of commandSchema) {
16
+ const shape = {};
17
+
18
+ for (const opt of cmd.options) {
19
+ let field = opt.type === 'number' ? z.number() : z.string();
20
+ if (!opt.required) field = field.optional();
21
+ field = field.describe(opt.description);
22
+ shape[opt.name] = field;
23
+ }
24
+
25
+ server.tool(cmd.id, cmd.description, shape, async (args) => {
26
+ try {
27
+ const sessionState = await readSessionState();
28
+ const transport = await createTransport({}, sessionState);
29
+
30
+ if (transport.expired) {
31
+ return {
32
+ content: [{ type: 'text', text: 'Session expired. Run `incremnt login` to re-authenticate.' }],
33
+ isError: true
34
+ };
35
+ }
36
+
37
+ const result = await transport.executeReadCommand(cmd.id, args);
38
+ return {
39
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }]
40
+ };
41
+ } catch (error) {
42
+ const message = error?.message ?? String(error);
43
+
44
+ if (error?.code === 'SNAPSHOT_NOT_FOUND') {
45
+ return {
46
+ content: [{ type: 'text', text: 'Not logged in. Run `incremnt login` first.' }],
47
+ isError: true
48
+ };
49
+ }
50
+
51
+ return {
52
+ content: [{ type: 'text', text: message }],
53
+ isError: true
54
+ };
55
+ }
56
+ });
57
+ }
58
+
59
+ const transport = new StdioServerTransport();
60
+ await server.connect(transport);
@@ -0,0 +1,193 @@
1
+ const DEFAULT_MODEL = 'meta-llama/llama-3.1-8b-instruct:free';
2
+ const DEFAULT_TIMEOUT_MS = 15_000;
3
+ const DEFAULT_MAX_TOKENS = 300;
4
+
5
+ const SYSTEM_PROMPT = `You are a strength coach writing a brief note after reviewing a trainee's week of training. Write 2-3 short paragraphs separated by blank lines.
6
+
7
+ Rules:
8
+ - Reference specific exercises, weights, and reps from the data. Use numbers, not vague praise.
9
+ - If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
10
+ - Note what went well and what lagged, with specifics.
11
+ - End with one concrete suggestion for next week. Be specific about which exercise and what to change.
12
+ - Write like a real person texting a training partner — short sentences, no filler, no cheerleading.
13
+ - Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting.
14
+ - Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
15
+ - No bullet points or lists.`;
16
+
17
+ export async function generateCoachingSummary(cycleContext, { apiKey, model, timeoutMs } = {}) {
18
+ const resolvedModel = model || DEFAULT_MODEL;
19
+ const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
20
+
21
+ const controller = new AbortController();
22
+ const timer = setTimeout(() => controller.abort(), resolvedTimeout);
23
+
24
+ try {
25
+ const userContent = formatCycleContext(cycleContext);
26
+
27
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
28
+ method: 'POST',
29
+ headers: {
30
+ 'Authorization': `Bearer ${apiKey}`,
31
+ 'Content-Type': 'application/json',
32
+ 'HTTP-Referer': 'https://incremnt.app',
33
+ 'X-Title': 'incremnt'
34
+ },
35
+ body: JSON.stringify({
36
+ model: resolvedModel,
37
+ messages: [
38
+ { role: 'system', content: SYSTEM_PROMPT },
39
+ { role: 'user', content: userContent }
40
+ ],
41
+ max_tokens: DEFAULT_MAX_TOKENS,
42
+ temperature: 0.7
43
+ }),
44
+ signal: controller.signal
45
+ });
46
+
47
+ if (!response.ok) {
48
+ const text = await response.text().catch(() => '');
49
+ throw new Error(`OpenRouter API error ${response.status}: ${text}`);
50
+ }
51
+
52
+ const data = await response.json();
53
+ const content = data.choices?.[0]?.message?.content;
54
+ if (!content) {
55
+ throw new Error('No content in OpenRouter response');
56
+ }
57
+
58
+ return content.trim();
59
+ } finally {
60
+ clearTimeout(timer);
61
+ }
62
+ }
63
+
64
+ function formatCycleContext(ctx) {
65
+ const lines = [
66
+ `Program: ${ctx.programName}, Week ${ctx.cycleNumber}, ${ctx.totalSessions} session(s).`
67
+ ];
68
+
69
+ for (const session of ctx.sessions) {
70
+ const parts = [session.dayName || 'Session'];
71
+ if (session.totalVolume) parts.push(`${session.totalVolume} kg volume`);
72
+ if (session.effortScore) parts.push(`effort ${session.effortScore}/10`);
73
+ lines.push(parts.join(', '));
74
+
75
+ for (const ex of session.exercises) {
76
+ const setPart = ex.plannedSets != null
77
+ ? `${ex.completedSets}/${ex.plannedSets} sets`
78
+ : `${ex.completedSets} sets`;
79
+ const topPart = ex.topSet
80
+ ? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})`
81
+ : '';
82
+ lines.push(` ${ex.exerciseName}: ${setPart}${topPart}`);
83
+ }
84
+ }
85
+
86
+ if (ctx.prsThisCycle.length > 0) {
87
+ lines.push('PRs this week:');
88
+ for (const pr of ctx.prsThisCycle) {
89
+ lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
90
+ }
91
+ }
92
+
93
+ if (ctx.goalProgress) {
94
+ lines.push('Goal progress:');
95
+ for (const g of ctx.goalProgress) {
96
+ lines.push(` ${g.exerciseName}: ${g.progressPercent ?? '?'}% toward ${g.targetE1RM} e1RM`);
97
+ }
98
+ }
99
+
100
+ return lines.join('\n');
101
+ }
102
+
103
+ export const WORKOUT_COACH_PROMPT = `You are a strength coach writing a brief note after reviewing one training session. Write 2-3 short paragraphs separated by blank lines.
104
+
105
+ Rules:
106
+ - Reference specific exercises, weights, and reps from the data. Use numbers, not vague praise.
107
+ - If there are PRs, mention them matter-of-factly. Do not celebrate or inflate them.
108
+ - Compare volume or effort to recent sessions if the data is there. Just state what changed.
109
+ - End with one concrete suggestion for next session. Be specific about which exercise and what to try.
110
+ - Write like a real person texting a training partner — short sentences, no filler, no cheerleading.
111
+ - Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, highlighting.
112
+ - Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
113
+ - No bullet points or lists.`;
114
+
115
+ export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, model, timeoutMs } = {}) {
116
+ const resolvedModel = model || DEFAULT_MODEL;
117
+ const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
118
+
119
+ const controller = new AbortController();
120
+ const timer = setTimeout(() => controller.abort(), resolvedTimeout);
121
+
122
+ try {
123
+ const userContent = formatWorkoutContext(workoutContext);
124
+
125
+ const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
126
+ method: 'POST',
127
+ headers: {
128
+ 'Authorization': `Bearer ${apiKey}`,
129
+ 'Content-Type': 'application/json',
130
+ 'HTTP-Referer': 'https://incremnt.app',
131
+ 'X-Title': 'incremnt'
132
+ },
133
+ body: JSON.stringify({
134
+ model: resolvedModel,
135
+ messages: [
136
+ { role: 'system', content: WORKOUT_COACH_PROMPT },
137
+ { role: 'user', content: userContent }
138
+ ],
139
+ max_tokens: DEFAULT_MAX_TOKENS,
140
+ temperature: 0.7
141
+ }),
142
+ signal: controller.signal
143
+ });
144
+
145
+ if (!response.ok) {
146
+ const text = await response.text().catch(() => '');
147
+ throw new Error(`OpenRouter API error ${response.status}: ${text}`);
148
+ }
149
+
150
+ const data = await response.json();
151
+ const content = data.choices?.[0]?.message?.content;
152
+ if (!content) {
153
+ throw new Error('No content in OpenRouter response');
154
+ }
155
+
156
+ return content.trim();
157
+ } finally {
158
+ clearTimeout(timer);
159
+ }
160
+ }
161
+
162
+ export function formatWorkoutContext(ctx) {
163
+ const lines = [
164
+ `Session: ${ctx.dayName}, ${ctx.sessionDate}, ${ctx.totalVolume} kg total volume.`
165
+ ];
166
+
167
+ if (ctx.effortScore) {
168
+ lines.push(`Effort rating: ${ctx.effortScore}/10.`);
169
+ }
170
+
171
+ lines.push('Exercises:');
172
+ for (const ex of ctx.exercises) {
173
+ const topPart = ex.topSet ? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})` : '';
174
+ lines.push(` ${ex.exerciseName}: ${ex.completedSets} sets${topPart}`);
175
+ }
176
+
177
+ if (ctx.prs.length > 0) {
178
+ lines.push('PRs this session:');
179
+ for (const pr of ctx.prs) {
180
+ lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
181
+ }
182
+ }
183
+
184
+ if (ctx.recentComparisons.length > 0) {
185
+ lines.push('Recent same-day sessions for comparison:');
186
+ for (const comp of ctx.recentComparisons) {
187
+ const effort = comp.effortScore ? `, effort ${comp.effortScore}/10` : '';
188
+ lines.push(` ${comp.date}: ${comp.totalVolume} kg volume${effort}`);
189
+ }
190
+ }
191
+
192
+ return lines.join('\n');
193
+ }
package/src/queries.js CHANGED
@@ -347,6 +347,231 @@ export function goalDetail(snapshot, planId) {
347
347
  };
348
348
  }
349
349
 
350
+ export function cycleSummaryContext(snapshot, programId) {
351
+ const programs = snapshot.programs ?? [];
352
+ const program = programId
353
+ ? programs.find((p) => p.id === programId)
354
+ : programs.find((p) => p.id === snapshot.activeProgramId) ?? programs[0];
355
+
356
+ if (!program) return null;
357
+
358
+ const completedCycles = Number(program.completedCyclesCount ?? 0);
359
+ if (completedCycles === 0) return null;
360
+
361
+ const cycleWeekNumber = completedCycles;
362
+
363
+ const cycleSessions = (snapshot.sessions ?? [])
364
+ .filter(
365
+ (s) =>
366
+ s.programId === program.id &&
367
+ s.historicalContext?.programWeekNumber === cycleWeekNumber
368
+ )
369
+ .sort((a, b) =>
370
+ String(completionDateForSession(a)).localeCompare(
371
+ String(completionDateForSession(b))
372
+ )
373
+ );
374
+
375
+ if (cycleSessions.length === 0) return null;
376
+
377
+ const priorSessions = (snapshot.sessions ?? []).filter(
378
+ (s) =>
379
+ s.programId === program.id &&
380
+ (s.historicalContext?.programWeekNumber ?? 0) < cycleWeekNumber
381
+ );
382
+
383
+ const priorBests = new Map();
384
+ for (const session of priorSessions) {
385
+ for (const exercise of session.exercises ?? []) {
386
+ for (const set of exercise.sets ?? []) {
387
+ if (!set.isComplete) continue;
388
+ const key = normalizeExerciseName(exercise.name);
389
+ const score = Number(set.weight) * (1 + Number(set.reps) / 30);
390
+ const current = priorBests.get(key);
391
+ if (!current || score > current) {
392
+ priorBests.set(key, score);
393
+ }
394
+ }
395
+ }
396
+ }
397
+
398
+ const prsThisCycle = [];
399
+
400
+ const sessions = cycleSessions.map((session) => {
401
+ const plannedByExercise = new Map(
402
+ (session.prescriptionSnapshot?.exercises ?? []).map((e) => [
403
+ normalizeExerciseName(e.exerciseName),
404
+ e
405
+ ])
406
+ );
407
+
408
+ const exercises = (session.exercises ?? []).map((exercise) => {
409
+ const key = normalizeExerciseName(exercise.name);
410
+ const planned = plannedByExercise.get(key);
411
+ const completeSets = (exercise.sets ?? []).filter((s) => s.isComplete);
412
+
413
+ for (const set of completeSets) {
414
+ const score = Number(set.weight) * (1 + Number(set.reps) / 30);
415
+ const prior = priorBests.get(key);
416
+ if (!prior || score > prior) {
417
+ prsThisCycle.push({
418
+ exerciseName: exercise.name,
419
+ weight: set.weight,
420
+ reps: set.reps,
421
+ estimatedOneRM: Math.round(score * 10) / 10
422
+ });
423
+ priorBests.set(key, score);
424
+ }
425
+ }
426
+
427
+ return {
428
+ exerciseName: exercise.name,
429
+ muscleGroup: exercise.muscleGroup ?? null,
430
+ completedSets: completeSets.length,
431
+ plannedSets: planned?.targetSets?.length ?? null,
432
+ topSet: completeSets.length > 0
433
+ ? completeSets.reduce((best, s) => {
434
+ const score = Number(s.weight) * (1 + Number(s.reps) / 30);
435
+ const bestScore =
436
+ Number(best.weight) * (1 + Number(best.reps) / 30);
437
+ return score > bestScore ? s : best;
438
+ })
439
+ : null
440
+ };
441
+ });
442
+
443
+ return {
444
+ sessionDate: completionDateForSession(session),
445
+ dayName: session.dayName ?? null,
446
+ totalVolume: session.summary?.totalVolume ?? session.volume ?? 0,
447
+ effortScore: session.summary?.effortScore ?? null,
448
+ averageHeartRate: session.summary?.averageHeartRate ?? null,
449
+ exercises
450
+ };
451
+ });
452
+
453
+ let goalProgress = null;
454
+ const plans = snapshot.strengthPlans ?? [];
455
+ const activePlan = plans.find(
456
+ (p) => p.status === 'active' && p.programId === program.id
457
+ );
458
+ if (activePlan) {
459
+ goalProgress = (activePlan.liftGoals ?? []).map((g) => {
460
+ const range = g.targetE1RM - g.startingE1RM;
461
+ const gained = g.currentBestE1RM - g.startingE1RM;
462
+ const progressPct =
463
+ range > 0 ? Math.round((gained / range) * 100) : null;
464
+ return {
465
+ exerciseName: g.exerciseDisplayName,
466
+ progressPercent: progressPct,
467
+ currentBestE1RM: g.currentBestE1RM,
468
+ targetE1RM: g.targetE1RM
469
+ };
470
+ });
471
+ }
472
+
473
+ return {
474
+ programName: program.name,
475
+ cycleNumber: cycleWeekNumber,
476
+ totalSessions: cycleSessions.length,
477
+ sessions,
478
+ prsThisCycle,
479
+ goalProgress
480
+ };
481
+ }
482
+
483
+ export function workoutSummaryContext(snapshot, sessionId) {
484
+ const sessions = snapshot.sessions ?? [];
485
+ const session = sessions.find((s) => s.id === sessionId);
486
+ if (!session) return null;
487
+
488
+ const sessionDate = completionDateForSession(session);
489
+ const dayName = session.dayName ?? 'Session';
490
+
491
+ // Build exercise details
492
+ const exercises = (session.exercises ?? []).map((exercise) => {
493
+ const completeSets = (exercise.sets ?? []).filter((s) => s.isComplete);
494
+ const topSet = completeSets.length > 0
495
+ ? completeSets.reduce((best, s) => {
496
+ const score = Number(s.weight) * (1 + Number(s.reps) / 30);
497
+ const bestScore = Number(best.weight) * (1 + Number(best.reps) / 30);
498
+ return score > bestScore ? s : best;
499
+ })
500
+ : null;
501
+
502
+ return {
503
+ exerciseName: exercise.name,
504
+ muscleGroup: exercise.muscleGroup ?? null,
505
+ completedSets: completeSets.length,
506
+ topSet: topSet ? { weight: topSet.weight, reps: topSet.reps } : null
507
+ };
508
+ });
509
+
510
+ // Find recent sessions with same dayName for comparison (up to 3, excluding current)
511
+ const recentComparisons = sessions
512
+ .filter(
513
+ (s) =>
514
+ s.id !== sessionId &&
515
+ s.dayName === dayName &&
516
+ s.programId === session.programId
517
+ )
518
+ .sort((a, b) => String(completionDateForSession(b)).localeCompare(String(completionDateForSession(a))))
519
+ .slice(0, 3)
520
+ .map((s) => ({
521
+ date: completionDateForSession(s),
522
+ totalVolume: s.summary?.totalVolume ?? s.volume ?? 0,
523
+ effortScore: s.summary?.effortScore ?? null
524
+ }));
525
+
526
+ // Detect PRs — compare each exercise's best e1RM against all prior sessions
527
+ const priorBests = new Map();
528
+ for (const s of sessions) {
529
+ if (s.id === sessionId) continue;
530
+ const sDate = String(completionDateForSession(s));
531
+ const currentDate = String(sessionDate);
532
+ if (sDate >= currentDate) continue;
533
+
534
+ for (const exercise of s.exercises ?? []) {
535
+ for (const set of exercise.sets ?? []) {
536
+ if (!set.isComplete) continue;
537
+ const key = normalizeExerciseName(exercise.name);
538
+ const score = Number(set.weight) * (1 + Number(set.reps) / 30);
539
+ const current = priorBests.get(key);
540
+ if (!current || score > current) priorBests.set(key, score);
541
+ }
542
+ }
543
+ }
544
+
545
+ const prs = [];
546
+ for (const exercise of session.exercises ?? []) {
547
+ const key = normalizeExerciseName(exercise.name);
548
+ for (const set of exercise.sets ?? []) {
549
+ if (!set.isComplete) continue;
550
+ const score = Number(set.weight) * (1 + Number(set.reps) / 30);
551
+ const prior = priorBests.get(key);
552
+ if (!prior || score > prior) {
553
+ prs.push({
554
+ exerciseName: exercise.name,
555
+ weight: set.weight,
556
+ reps: set.reps,
557
+ estimatedOneRM: Math.round(score * 10) / 10
558
+ });
559
+ priorBests.set(key, score);
560
+ }
561
+ }
562
+ }
563
+
564
+ return {
565
+ sessionDate,
566
+ dayName,
567
+ totalVolume: session.summary?.totalVolume ?? session.volume ?? 0,
568
+ effortScore: session.summary?.effortScore ?? null,
569
+ exercises,
570
+ recentComparisons,
571
+ prs
572
+ };
573
+ }
574
+
350
575
  function requiredOption(options, primaryKey, legacyKey = null) {
351
576
  return options[primaryKey] ?? (legacyKey ? options[legacyKey] : undefined);
352
577
  }
package/src/remote.js CHANGED
@@ -118,6 +118,10 @@ function endpointForCommand(baseUrl, normalizedCommand, options) {
118
118
  }
119
119
  case 'records':
120
120
  return resolveServiceUrl(baseUrl, '/cli/records');
121
+ case 'goals-list':
122
+ return resolveServiceUrl(baseUrl, '/cli/goals');
123
+ case 'goals-show':
124
+ return resolveServiceUrl(baseUrl, options.id ? `/cli/goals/${options.id}` : '/cli/goals');
121
125
  default:
122
126
  return resolveServiceUrl(baseUrl, '/');
123
127
  }
@@ -5,6 +5,8 @@ import { executeReadCommand } from './queries.js';
5
5
  const MAX_BODY_BYTES = 10 * 1024 * 1024; // 10 MB
6
6
  const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
7
7
  const DEFAULT_RATE_LIMIT_RULES = {
8
+ 'workout-summary-ai': 3,
9
+ 'cycle-summary-ai': 3,
8
10
  'dev-login': 10,
9
11
  'device-start': 20,
10
12
  'device-poll': 300,
@@ -17,6 +19,7 @@ const DEFAULT_RATE_LIMIT_RULES = {
17
19
  'web-auth-callback': 20,
18
20
  'session-login': 60,
19
21
  'session-refresh': 30,
22
+ 'sync-account-preferences': 30,
20
23
  'proposals': 30,
21
24
  'proposal-update': 30
22
25
  };
@@ -197,6 +200,10 @@ function routeRequest(url) {
197
200
  return { command: 'sync-upload', options: {} };
198
201
  }
199
202
 
203
+ if (pathname === '/sync/account/preferences') {
204
+ return { command: 'sync-account-preferences', options: {} };
205
+ }
206
+
200
207
  if (pathname === '/cli/sessions') {
201
208
  return {
202
209
  command: 'session-insights',
@@ -284,6 +291,24 @@ function routeRequest(url) {
284
291
  };
285
292
  }
286
293
 
294
+ if (pathname === '/cli/workout-summary/ai') {
295
+ return {
296
+ command: 'workout-summary-ai',
297
+ options: {
298
+ 'session-id': url.searchParams.get('session-id') ?? undefined
299
+ }
300
+ };
301
+ }
302
+
303
+ if (pathname === '/cli/cycle-summary/ai') {
304
+ return {
305
+ command: 'cycle-summary-ai',
306
+ options: {
307
+ 'program-id': url.searchParams.get('program-id') ?? undefined
308
+ }
309
+ };
310
+ }
311
+
287
312
  return null;
288
313
  }
289
314
 
@@ -350,41 +375,67 @@ function deviceApprovalPage({
350
375
  const escapedUserCode = escapeHtml(userCode);
351
376
  const escapedEmail = escapeHtml(email);
352
377
  const escapedUserId = escapeHtml(userId);
353
- const accent = isError ? '#7f1d1d' : '#16324f';
354
- const panel = isError ? '#fef2f2' : '#f6fbff';
378
+ const badgeBg = isError ? 'rgba(255,69,58,0.15)' : 'rgba(0,255,163,0.1)';
379
+ const badgeColor = isError ? '#FF453A' : '#00ffa3';
355
380
 
356
381
  return `<!doctype html>
357
382
  <html lang="en">
358
383
  <head>
359
384
  <meta charset="utf-8" />
360
385
  <meta name="viewport" content="width=device-width, initial-scale=1" />
361
- <title>${escapedTitle}</title>
386
+ <title>${escapedTitle} - INCREMNT</title>
387
+ <link rel="icon" type="image/png" href="https://www.incremnt.app/assets/images/favicon.png">
388
+ <link rel="preconnect" href="https://fonts.googleapis.com">
389
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
390
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
362
391
  <style>
363
392
  :root {
364
- color-scheme: light;
365
- font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
393
+ color-scheme: dark;
394
+ font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
366
395
  }
367
396
  body {
368
397
  margin: 0;
369
398
  min-height: 100vh;
370
399
  display: grid;
371
400
  place-items: center;
372
- background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%);
373
- color: #12212f;
401
+ background: #000000;
402
+ color: #ffffff;
374
403
  }
375
404
  main {
376
405
  width: min(92vw, 28rem);
377
- background: white;
406
+ background: #121212;
378
407
  border-radius: 24px;
379
- box-shadow: 0 18px 50px rgba(17, 38, 57, 0.12);
408
+ border: 1px solid rgba(255,255,255,0.1);
380
409
  padding: 1.5rem;
381
410
  }
411
+ .logo {
412
+ display: flex;
413
+ align-items: center;
414
+ gap: 0.6rem;
415
+ margin-bottom: 1.2rem;
416
+ }
417
+ .logo img {
418
+ width: 32px;
419
+ height: 32px;
420
+ border-radius: 8px;
421
+ }
422
+ .logo-text {
423
+ font-size: 1.1rem;
424
+ font-weight: 800;
425
+ letter-spacing: 0.02em;
426
+ }
427
+ .logo-gradient {
428
+ background: linear-gradient(to right, #00ffa3, #3b82f6);
429
+ -webkit-background-clip: text;
430
+ -webkit-text-fill-color: transparent;
431
+ background-clip: text;
432
+ }
382
433
  .badge {
383
434
  display: inline-block;
384
435
  padding: 0.35rem 0.65rem;
385
436
  border-radius: 999px;
386
- background: ${panel};
387
- color: ${accent};
437
+ background: ${badgeBg};
438
+ color: ${badgeColor};
388
439
  font-size: 0.85rem;
389
440
  font-weight: 600;
390
441
  }
@@ -395,7 +446,7 @@ function deviceApprovalPage({
395
446
  }
396
447
  p {
397
448
  margin: 0 0 1rem;
398
- color: #41576d;
449
+ color: #a1a1a6;
399
450
  }
400
451
  form {
401
452
  display: grid;
@@ -411,42 +462,61 @@ function deviceApprovalPage({
411
462
  text-align: center;
412
463
  text-decoration: none;
413
464
  border-radius: 14px;
414
- background: #ffffff;
415
- color: #12212f;
416
- border: 1px solid #d0dded;
465
+ background: rgba(255,255,255,0.1);
466
+ color: #ffffff;
467
+ border: 1px solid rgba(255,255,255,0.2);
417
468
  font-weight: 700;
418
469
  padding: 0.9rem 1rem;
470
+ transition: background 0.15s, border-color 0.15s;
471
+ }
472
+ .oauth-link:hover {
473
+ background: rgba(255,255,255,0.15);
474
+ border-color: rgba(255,255,255,0.3);
419
475
  }
420
476
  label {
421
477
  display: grid;
422
478
  gap: 0.35rem;
423
479
  font-size: 0.95rem;
424
480
  font-weight: 600;
481
+ color: #ffffff;
425
482
  }
426
483
  input {
427
- border: 1px solid #d0dded;
484
+ border: 1px solid rgba(255,255,255,0.2);
428
485
  border-radius: 12px;
429
486
  padding: 0.8rem 0.9rem;
430
487
  font: inherit;
488
+ background: rgba(255,255,255,0.05);
489
+ color: #ffffff;
490
+ }
491
+ input::placeholder {
492
+ color: #a1a1a6;
431
493
  }
432
494
  button {
433
495
  margin-top: 0.4rem;
434
496
  border: 0;
435
497
  border-radius: 14px;
436
- background: #0f4c81;
437
- color: white;
498
+ background: linear-gradient(to right, #00ffa3, #3b82f6);
499
+ color: #000000;
438
500
  font: inherit;
439
501
  font-weight: 700;
440
502
  padding: 0.9rem 1rem;
503
+ cursor: pointer;
441
504
  }
442
505
  small {
443
- color: #66798b;
506
+ color: #a1a1a6;
507
+ }
508
+ code {
509
+ color: #00ffa3;
444
510
  }
445
511
  </style>
446
512
  </head>
447
513
  <body>
448
514
  <main>
449
- <span class="badge">incremnt device login</span>
515
+ <div class="logo">
516
+ <img src="https://www.incremnt.app/assets/images/favicon.png" alt="" />
517
+ <span class="logo-text">INCRE<span class="logo-gradient">MNT</span></span>
518
+ </div>
519
+ <span class="badge">device login</span>
450
520
  <h1>${escapedTitle}</h1>
451
521
  <p>${escapedMessage}</p>
452
522
  ${(appleStartPath || googleStartPath) ? `
@@ -487,31 +557,68 @@ function deviceApprovalSuccessPage({ email, userId }) {
487
557
  <head>
488
558
  <meta charset="utf-8" />
489
559
  <meta name="viewport" content="width=device-width, initial-scale=1" />
490
- <title>Connected</title>
560
+ <title>Connected - INCREMNT</title>
561
+ <link rel="icon" type="image/png" href="https://www.incremnt.app/assets/images/favicon.png">
562
+ <link rel="preconnect" href="https://fonts.googleapis.com">
563
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
564
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
491
565
  <style>
492
566
  :root {
493
- color-scheme: light;
494
- font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "Helvetica Neue", sans-serif;
567
+ color-scheme: dark;
568
+ font-family: Inter, -apple-system, BlinkMacSystemFont, sans-serif;
495
569
  }
496
570
  body {
497
571
  margin: 0;
498
572
  min-height: 100vh;
499
573
  display: grid;
500
574
  place-items: center;
501
- background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%);
502
- color: #12212f;
575
+ background: #000000;
576
+ color: #ffffff;
503
577
  }
504
578
  main {
505
579
  width: min(92vw, 28rem);
506
- background: white;
580
+ background: #121212;
507
581
  border-radius: 24px;
508
- box-shadow: 0 18px 50px rgba(17, 38, 57, 0.12);
582
+ border: 1px solid rgba(255,255,255,0.1);
509
583
  padding: 2rem 1.5rem;
510
584
  text-align: center;
511
585
  }
586
+ .logo {
587
+ display: flex;
588
+ align-items: center;
589
+ justify-content: center;
590
+ gap: 0.6rem;
591
+ margin-bottom: 1.2rem;
592
+ }
593
+ .logo img {
594
+ width: 32px;
595
+ height: 32px;
596
+ border-radius: 8px;
597
+ }
598
+ .logo-text {
599
+ font-size: 1.1rem;
600
+ font-weight: 800;
601
+ letter-spacing: 0.02em;
602
+ }
603
+ .logo-gradient {
604
+ background: linear-gradient(to right, #00ffa3, #3b82f6);
605
+ -webkit-background-clip: text;
606
+ -webkit-text-fill-color: transparent;
607
+ background-clip: text;
608
+ }
512
609
  .checkmark {
513
- font-size: 3rem;
514
- margin-bottom: 0.5rem;
610
+ width: 48px;
611
+ height: 48px;
612
+ border-radius: 50%;
613
+ background: rgba(0,255,163,0.15);
614
+ display: flex;
615
+ align-items: center;
616
+ justify-content: center;
617
+ margin: 0 auto 1rem;
618
+ }
619
+ .checkmark svg {
620
+ width: 24px;
621
+ height: 24px;
515
622
  }
516
623
  h1 {
517
624
  margin: 0 0 0.5rem;
@@ -520,13 +627,21 @@ function deviceApprovalSuccessPage({ email, userId }) {
520
627
  }
521
628
  p {
522
629
  margin: 0;
523
- color: #41576d;
630
+ color: #a1a1a6;
524
631
  }
525
632
  </style>
526
633
  </head>
527
634
  <body>
528
635
  <main>
529
- <div class="checkmark">&#x2705;</div>
636
+ <div class="logo">
637
+ <img src="https://www.incremnt.app/assets/images/favicon.png" alt="" />
638
+ <span class="logo-text">INCRE<span class="logo-gradient">MNT</span></span>
639
+ </div>
640
+ <div class="checkmark">
641
+ <svg viewBox="0 0 24 24" fill="none" stroke="#00ffa3" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
642
+ <polyline points="20 6 9 17 4 12"></polyline>
643
+ </svg>
644
+ </div>
530
645
  <h1>You're all set</h1>
531
646
  <p>Signed in as ${displayName}. You can close this tab and return to your terminal.</p>
532
647
  </main>
@@ -663,7 +778,8 @@ export function createSyncServiceRequestHandler({
663
778
  corsOrigins = [],
664
779
  createProposalForAccount = null,
665
780
  listProposalsForAccount = null,
666
- updateProposalForAccount = null
781
+ updateProposalForAccount = null,
782
+ updateAnalysisConsentForAccount = null
667
783
  }) {
668
784
  const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
669
785
 
@@ -1475,6 +1591,49 @@ export function createSyncServiceRequestHandler({
1475
1591
  }
1476
1592
  }
1477
1593
 
1594
+ if (route.command === 'sync-account-preferences') {
1595
+ if (request.method !== 'PUT' && request.method !== 'PATCH') {
1596
+ methodNotAllowed(response, 'Use PUT or PATCH for /sync/account/preferences.');
1597
+ return;
1598
+ }
1599
+
1600
+ if (!updateAnalysisConsentForAccount) {
1601
+ methodNotAllowed(response, 'Account preference updates are not enabled for this service mode.');
1602
+ return;
1603
+ }
1604
+
1605
+ const account = writeAuthenticator
1606
+ ? await writeAuthenticator(requestToken)
1607
+ : requestToken === token
1608
+ ? { id: 'remote-user', email: null }
1609
+ : null;
1610
+ if (!account) {
1611
+ unauthorized(response, request);
1612
+ return;
1613
+ }
1614
+
1615
+ try {
1616
+ const body = await readJsonBody(request);
1617
+ if (typeof body.analysisConsentEnabled !== 'boolean') {
1618
+ badRequest(response, 'analysisConsentEnabled must be a boolean.');
1619
+ return;
1620
+ }
1621
+
1622
+ const updatedAccount = await updateAnalysisConsentForAccount(account, {
1623
+ enabled: body.analysisConsentEnabled,
1624
+ version: body.consentVersion ?? 1
1625
+ });
1626
+ json(response, 200, {
1627
+ ok: true,
1628
+ account: updatedAccount
1629
+ });
1630
+ return;
1631
+ } catch (error) {
1632
+ badRequest(response, error.message);
1633
+ return;
1634
+ }
1635
+ }
1636
+
1478
1637
  const account = readAuthenticator
1479
1638
  ? await readAuthenticator(requestToken)
1480
1639
  : requestToken === token
@@ -1493,6 +1652,70 @@ export function createSyncServiceRequestHandler({
1493
1652
  } catch {
1494
1653
  snapshot = { sessions: [], programs: [], activeProgramId: null };
1495
1654
  }
1655
+ if (route.command === 'workout-summary-ai') {
1656
+ const sessionId = route.options['session-id'];
1657
+ if (!sessionId) {
1658
+ badRequest(response, 'session-id query parameter is required');
1659
+ return;
1660
+ }
1661
+
1662
+ const { workoutSummaryContext } = await import('./queries.js');
1663
+ const ctx = workoutSummaryContext(snapshot, sessionId);
1664
+ if (!ctx) {
1665
+ notFound(response, `Session not found: ${sessionId}`);
1666
+ return;
1667
+ }
1668
+
1669
+ const openrouterKey = process.env.OPENROUTER_API_KEY;
1670
+ if (!openrouterKey) {
1671
+ json(response, 503, { error: 'AI summaries not configured' });
1672
+ return;
1673
+ }
1674
+
1675
+ try {
1676
+ const { generateWorkoutCoachingSummary } = await import('./openrouter.js');
1677
+ const model = process.env.OPENROUTER_MODEL || undefined;
1678
+ const summary = await generateWorkoutCoachingSummary(ctx, { apiKey: openrouterKey, model });
1679
+ json(response, 200, { summary });
1680
+ } catch (err) {
1681
+ console.error('AI workout summary error:', err.message);
1682
+ json(response, 502, { error: 'Failed to generate AI summary' });
1683
+ }
1684
+ return;
1685
+ }
1686
+
1687
+ if (route.command === 'cycle-summary-ai') {
1688
+ const programId = route.options['program-id'];
1689
+ if (!programId) {
1690
+ badRequest(response, 'program-id query parameter is required');
1691
+ return;
1692
+ }
1693
+
1694
+ const { cycleSummaryContext } = await import('./queries.js');
1695
+ const ctx = cycleSummaryContext(snapshot, programId);
1696
+ if (!ctx) {
1697
+ notFound(response, `No completed cycle found for program: ${programId}`);
1698
+ return;
1699
+ }
1700
+
1701
+ const openrouterKey = process.env.OPENROUTER_API_KEY;
1702
+ if (!openrouterKey) {
1703
+ json(response, 503, { error: 'AI summaries not configured' });
1704
+ return;
1705
+ }
1706
+
1707
+ try {
1708
+ const { generateCoachingSummary } = await import('./openrouter.js');
1709
+ const model = process.env.OPENROUTER_MODEL || undefined;
1710
+ const summary = await generateCoachingSummary(ctx, { apiKey: openrouterKey, model });
1711
+ json(response, 200, { summary });
1712
+ } catch (err) {
1713
+ console.error('AI cycle summary error:', err.message);
1714
+ json(response, 502, { error: 'Failed to generate AI summary' });
1715
+ }
1716
+ return;
1717
+ }
1718
+
1496
1719
  const result = executeReadCommand(snapshot, route.command, route.options);
1497
1720
  if (!result.ok) {
1498
1721
  if (result.error.startsWith('Session not found')) {