incremnt 0.1.13 → 0.1.15
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/package.json +3 -2
- package/src/lib.js +57 -1
- package/src/openrouter.js +73 -14
- package/src/queries.js +101 -8
- package/src/sync-service.js +99 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "incremnt",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.15",
|
|
4
4
|
"description": "Command-line tool for querying your incremnt strength training data",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
],
|
|
14
14
|
"scripts": {
|
|
15
15
|
"test": "node --test",
|
|
16
|
-
"dev:sync-fixture": "node ./scripts/dev-sync-fixture-server.js"
|
|
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
18
|
},
|
|
18
19
|
"dependencies": {
|
|
19
20
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
package/src/lib.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
-
import
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { spawn, execFileSync } from 'node:child_process';
|
|
3
5
|
import { capabilities, commandSchema, contractVersion, officialCommands, proposalSchema, readCommands, writeCommands, writeCommandSchema } from './contract.js';
|
|
4
6
|
import {
|
|
5
7
|
bootstrapSessionFromRemoteBaseUrl,
|
|
@@ -139,6 +141,40 @@ export async function runCli(argv, stdout, stderr) {
|
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
if (normalizedCommand === 'mcp install') {
|
|
145
|
+
try {
|
|
146
|
+
const mcpBin = resolveMcpBin();
|
|
147
|
+
const installed = [];
|
|
148
|
+
|
|
149
|
+
// Claude Desktop
|
|
150
|
+
const desktopPath = claudeDesktopConfigPath();
|
|
151
|
+
let desktopConfig = {};
|
|
152
|
+
try {
|
|
153
|
+
desktopConfig = JSON.parse(await fs.readFile(desktopPath, 'utf8'));
|
|
154
|
+
} catch { /* file doesn't exist yet */ }
|
|
155
|
+
desktopConfig.mcpServers = { ...desktopConfig.mcpServers, incremnt: { command: mcpBin } };
|
|
156
|
+
await fs.mkdir(path.dirname(desktopPath), { recursive: true });
|
|
157
|
+
await fs.writeFile(desktopPath, JSON.stringify(desktopConfig, null, 2) + '\n');
|
|
158
|
+
installed.push(` Claude Desktop: ${desktopPath}`);
|
|
159
|
+
|
|
160
|
+
// Claude Code CLI
|
|
161
|
+
const cliPath = path.join(os.homedir(), '.claude.json');
|
|
162
|
+
let cliConfig = {};
|
|
163
|
+
try {
|
|
164
|
+
cliConfig = JSON.parse(await fs.readFile(cliPath, 'utf8'));
|
|
165
|
+
} catch { /* file doesn't exist yet */ }
|
|
166
|
+
cliConfig.mcpServers = { ...cliConfig.mcpServers, incremnt: { type: 'stdio', command: mcpBin } };
|
|
167
|
+
await fs.writeFile(cliPath, JSON.stringify(cliConfig, null, 2) + '\n');
|
|
168
|
+
installed.push(` Claude Code: ${cliPath}`);
|
|
169
|
+
|
|
170
|
+
stdout.write(`Registered incremnt MCP server in:\n${installed.join('\n')}\n\nRestart Claude for the changes to take effect.\n`);
|
|
171
|
+
return 0;
|
|
172
|
+
} catch (error) {
|
|
173
|
+
stderr.write(`${error.message}\n`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
142
178
|
if (normalizedCommand === 'login') {
|
|
143
179
|
if (options.snapshot) {
|
|
144
180
|
try {
|
|
@@ -380,6 +416,26 @@ function browserCommandForPlatform() {
|
|
|
380
416
|
return null;
|
|
381
417
|
}
|
|
382
418
|
|
|
419
|
+
function claudeDesktopConfigPath() {
|
|
420
|
+
if (process.platform === 'darwin') {
|
|
421
|
+
return path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'claude_desktop_config.json');
|
|
422
|
+
}
|
|
423
|
+
if (process.platform === 'win32') {
|
|
424
|
+
return path.join(process.env.APPDATA ?? path.join(os.homedir(), 'AppData', 'Roaming'), 'Claude', 'claude_desktop_config.json');
|
|
425
|
+
}
|
|
426
|
+
return path.join(os.homedir(), '.config', 'Claude', 'claude_desktop_config.json');
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function resolveMcpBin() {
|
|
430
|
+
try {
|
|
431
|
+
return execFileSync('which', ['incremnt-mcp'], { encoding: 'utf8' }).trim();
|
|
432
|
+
} catch {
|
|
433
|
+
// fall back to sibling binary next to the current incremnt binary
|
|
434
|
+
const dir = path.dirname(process.argv[1]);
|
|
435
|
+
return path.join(dir, 'incremnt-mcp');
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
383
439
|
function configuredAuthProviders(auth) {
|
|
384
440
|
const providers = auth?.providers ?? {};
|
|
385
441
|
const labels = [];
|
package/src/openrouter.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
// Default model used only for local dev. In production the sync service
|
|
2
|
+
// sets OPENROUTER_MODEL via env vars (currently Claude 3.5 Haiku).
|
|
1
3
|
const DEFAULT_MODEL = 'meta-llama/llama-3.1-8b-instruct:free';
|
|
2
4
|
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
3
5
|
const DEFAULT_MAX_TOKENS = 500;
|
|
@@ -172,16 +174,19 @@ Focus on:
|
|
|
172
174
|
- Plan deviations: exercises swapped, skipped, or added vs the plan. Ask why if something looks unusual.
|
|
173
175
|
- Set completion: if they did fewer sets than planned on an exercise, note it and ask about it.
|
|
174
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.
|
|
175
179
|
- Ask 1-2 genuine questions about choices that look interesting or unusual.
|
|
176
180
|
|
|
177
|
-
|
|
178
|
-
- Only state what the data shows. Never claim how something "felt"
|
|
179
|
-
-
|
|
180
|
-
-
|
|
181
|
-
-
|
|
182
|
-
-
|
|
183
|
-
-
|
|
184
|
-
-
|
|
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.
|
|
185
190
|
- No bullet points or lists.`;
|
|
186
191
|
|
|
187
192
|
export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, model, timeoutMs } = {}) {
|
|
@@ -232,9 +237,10 @@ export async function generateWorkoutCoachingSummary(workoutContext, { apiKey, m
|
|
|
232
237
|
}
|
|
233
238
|
|
|
234
239
|
export function formatWorkoutContext(ctx) {
|
|
235
|
-
const
|
|
236
|
-
`Session: ${ctx.dayName}, ${ctx.sessionDate}, ${ctx.totalVolume} kg total volume.`
|
|
237
|
-
|
|
240
|
+
const sessionLabel = ctx.isAdhoc
|
|
241
|
+
? `Session: ${ctx.dayName}, ${ctx.sessionDate}, adhoc (no program), ${ctx.totalVolume} kg total volume.`
|
|
242
|
+
: `Session: ${ctx.dayName}, ${ctx.sessionDate}, program "${ctx.programName}", ${ctx.totalVolume} kg total volume.`;
|
|
243
|
+
const lines = [sessionLabel];
|
|
238
244
|
|
|
239
245
|
if (ctx.effortScore) {
|
|
240
246
|
lines.push(`Effort rating: ${ctx.effortScore}/10.`);
|
|
@@ -243,11 +249,14 @@ export function formatWorkoutContext(ctx) {
|
|
|
243
249
|
lines.push('Exercises:');
|
|
244
250
|
for (const ex of ctx.exercises) {
|
|
245
251
|
const topPart = ex.topSet ? ` (top: ${ex.topSet.weight}×${ex.topSet.reps})` : '';
|
|
246
|
-
|
|
252
|
+
const historyPart = ex.priorSessions === 0
|
|
253
|
+
? ' [first session]'
|
|
254
|
+
: ` [${ex.priorSessions} prior sessions]`;
|
|
255
|
+
lines.push(` ${ex.exerciseName}: ${ex.completedSets} sets${topPart}${historyPart}`);
|
|
247
256
|
}
|
|
248
257
|
|
|
249
258
|
if (ctx.prs.length > 0) {
|
|
250
|
-
lines.push('PRs this session:');
|
|
259
|
+
lines.push('PRs this session (only exercises with prior history):');
|
|
251
260
|
for (const pr of ctx.prs) {
|
|
252
261
|
lines.push(` ${pr.exerciseName}: ${pr.weight}×${pr.reps} (e1RM ${pr.estimatedOneRM})`);
|
|
253
262
|
}
|
|
@@ -257,7 +266,8 @@ export function formatWorkoutContext(ctx) {
|
|
|
257
266
|
lines.push('Recent same-day sessions for comparison:');
|
|
258
267
|
for (const comp of ctx.recentComparisons) {
|
|
259
268
|
const effort = comp.effortScore ? `, effort ${comp.effortScore}/10` : '';
|
|
260
|
-
|
|
269
|
+
const prog = comp.programName ? ` (${comp.programName})` : '';
|
|
270
|
+
lines.push(` ${comp.date}: ${comp.totalVolume} kg volume${effort}${prog}`);
|
|
261
271
|
}
|
|
262
272
|
}
|
|
263
273
|
|
|
@@ -282,3 +292,52 @@ export function formatWorkoutContext(ctx) {
|
|
|
282
292
|
|
|
283
293
|
return lines.join('\n');
|
|
284
294
|
}
|
|
295
|
+
|
|
296
|
+
const ASK_PROMPT = `You are a knowledgeable strength training assistant. Answer questions about the user's workout history concisely. Reference specific numbers, dates, and exercises from their data. If the data doesn't contain information to answer the question, say so briefly. Keep answers to 2-4 sentences.`;
|
|
297
|
+
|
|
298
|
+
export async function generateAskAnswer(context, question, { apiKey, model, timeoutMs } = {}) {
|
|
299
|
+
const resolvedModel = model || DEFAULT_MODEL;
|
|
300
|
+
const resolvedTimeout = timeoutMs || DEFAULT_TIMEOUT_MS;
|
|
301
|
+
|
|
302
|
+
const controller = new AbortController();
|
|
303
|
+
const timer = setTimeout(() => controller.abort(), resolvedTimeout);
|
|
304
|
+
|
|
305
|
+
try {
|
|
306
|
+
const userContent = `${context}\n\nQuestion: ${question}`;
|
|
307
|
+
|
|
308
|
+
const response = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
309
|
+
method: 'POST',
|
|
310
|
+
headers: {
|
|
311
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
312
|
+
'Content-Type': 'application/json',
|
|
313
|
+
'HTTP-Referer': 'https://incremnt.app',
|
|
314
|
+
'X-Title': 'incremnt'
|
|
315
|
+
},
|
|
316
|
+
body: JSON.stringify({
|
|
317
|
+
model: resolvedModel,
|
|
318
|
+
messages: [
|
|
319
|
+
{ role: 'system', content: ASK_PROMPT },
|
|
320
|
+
{ role: 'user', content: userContent }
|
|
321
|
+
],
|
|
322
|
+
max_tokens: 300,
|
|
323
|
+
temperature: 0.3
|
|
324
|
+
}),
|
|
325
|
+
signal: controller.signal
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
if (!response.ok) {
|
|
329
|
+
const text = await response.text().catch(() => '');
|
|
330
|
+
throw new Error(`OpenRouter API error ${response.status}: ${text}`);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const data = await response.json();
|
|
334
|
+
const content = data.choices?.[0]?.message?.content;
|
|
335
|
+
if (!content) {
|
|
336
|
+
throw new Error('No content in OpenRouter response');
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return content.trim();
|
|
340
|
+
} finally {
|
|
341
|
+
clearTimeout(timer);
|
|
342
|
+
}
|
|
343
|
+
}
|
package/src/queries.js
CHANGED
|
@@ -58,7 +58,7 @@ function sessionSummary(session) {
|
|
|
58
58
|
recommendations: session.recommendations ?? {},
|
|
59
59
|
historicalContext: session.historicalContext ?? null,
|
|
60
60
|
prescriptionSnapshot: session.prescriptionSnapshot ?? null,
|
|
61
|
-
aiCoachNotes: session.aiCoachNotes ?? null
|
|
61
|
+
aiCoachNotes: session.summary?.aiCoachNotes ?? null
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
@@ -692,24 +692,26 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
692
692
|
};
|
|
693
693
|
});
|
|
694
694
|
|
|
695
|
-
// Find recent sessions with same dayName for comparison (up to 3, excluding current)
|
|
695
|
+
// Find recent sessions with same dayName for comparison (up to 3, excluding current).
|
|
696
|
+
// Match across programs so context survives program switches.
|
|
696
697
|
const recentComparisons = sessions
|
|
697
698
|
.filter(
|
|
698
699
|
(s) =>
|
|
699
700
|
s.id !== sessionId &&
|
|
700
|
-
s.dayName === dayName
|
|
701
|
-
s.programId === session.programId
|
|
701
|
+
s.dayName === dayName
|
|
702
702
|
)
|
|
703
703
|
.sort((a, b) => String(completionDateForSession(b)).localeCompare(String(completionDateForSession(a))))
|
|
704
704
|
.slice(0, 3)
|
|
705
705
|
.map((s) => ({
|
|
706
706
|
date: completionDateForSession(s),
|
|
707
707
|
totalVolume: s.summary?.totalVolume ?? s.volume ?? 0,
|
|
708
|
-
effortScore: s.summary?.effortScore ?? null
|
|
708
|
+
effortScore: s.summary?.effortScore ?? null,
|
|
709
|
+
programName: s.programName ?? null
|
|
709
710
|
}));
|
|
710
711
|
|
|
711
|
-
//
|
|
712
|
+
// Count prior sessions per exercise and track best e1RM scores
|
|
712
713
|
const priorBests = new Map();
|
|
714
|
+
const exerciseSessionCounts = new Map();
|
|
713
715
|
for (const s of sessions) {
|
|
714
716
|
if (s.id === sessionId) continue;
|
|
715
717
|
const sDate = String(completionDateForSession(s));
|
|
@@ -717,9 +719,10 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
717
719
|
if (sDate >= currentDate) continue;
|
|
718
720
|
|
|
719
721
|
for (const exercise of s.exercises ?? []) {
|
|
722
|
+
const key = normalizeExerciseName(exercise.name);
|
|
723
|
+
exerciseSessionCounts.set(key, (exerciseSessionCounts.get(key) ?? 0) + 1);
|
|
720
724
|
for (const set of exercise.sets ?? []) {
|
|
721
725
|
if (!set.isComplete) continue;
|
|
722
|
-
const key = normalizeExerciseName(exercise.name);
|
|
723
726
|
const score = Number(set.weight) * (1 + Number(set.reps) / 30);
|
|
724
727
|
const current = priorBests.get(key);
|
|
725
728
|
if (!current || score > current) priorBests.set(key, score);
|
|
@@ -727,14 +730,22 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
727
730
|
}
|
|
728
731
|
}
|
|
729
732
|
|
|
733
|
+
// Attach prior session count to each exercise
|
|
734
|
+
for (const ex of exercises) {
|
|
735
|
+
const key = normalizeExerciseName(ex.exerciseName);
|
|
736
|
+
ex.priorSessions = exerciseSessionCounts.get(key) ?? 0;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// Detect PRs — skip first-time exercises (every set is trivially a "PR")
|
|
730
740
|
const prs = [];
|
|
731
741
|
for (const exercise of session.exercises ?? []) {
|
|
732
742
|
const key = normalizeExerciseName(exercise.name);
|
|
743
|
+
if (!priorBests.has(key)) continue;
|
|
733
744
|
for (const set of exercise.sets ?? []) {
|
|
734
745
|
if (!set.isComplete) continue;
|
|
735
746
|
const score = Number(set.weight) * (1 + Number(set.reps) / 30);
|
|
736
747
|
const prior = priorBests.get(key);
|
|
737
|
-
if (
|
|
748
|
+
if (score > prior) {
|
|
738
749
|
prs.push({
|
|
739
750
|
exerciseName: exercise.name,
|
|
740
751
|
weight: set.weight,
|
|
@@ -760,9 +771,12 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
760
771
|
}
|
|
761
772
|
}
|
|
762
773
|
|
|
774
|
+
const isAdhoc = !session.programId;
|
|
763
775
|
const result = {
|
|
764
776
|
sessionDate,
|
|
765
777
|
dayName,
|
|
778
|
+
programName: session.programName ?? null,
|
|
779
|
+
isAdhoc,
|
|
766
780
|
totalVolume: session.summary?.totalVolume ?? session.volume ?? 0,
|
|
767
781
|
effortScore: session.summary?.effortScore ?? null,
|
|
768
782
|
exercises,
|
|
@@ -773,6 +787,85 @@ export function workoutSummaryContext(snapshot, sessionId) {
|
|
|
773
787
|
return result;
|
|
774
788
|
}
|
|
775
789
|
|
|
790
|
+
export function askContext(snapshot) {
|
|
791
|
+
const sessions = snapshot.sessions ?? [];
|
|
792
|
+
const lines = [];
|
|
793
|
+
|
|
794
|
+
lines.push(`Training overview: ${sessions.length} total workouts logged.`);
|
|
795
|
+
|
|
796
|
+
// Training frequency (last 4 weeks)
|
|
797
|
+
const fourWeeksAgo = new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString();
|
|
798
|
+
const recentCount = sessions.filter((s) => String(completionDateForSession(s)) >= fourWeeksAgo).length;
|
|
799
|
+
if (recentCount > 0) {
|
|
800
|
+
const perWeek = (recentCount / 4).toFixed(1);
|
|
801
|
+
lines.push(`Recent frequency: ${perWeek} sessions/week (last 4 weeks).`);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// Current program
|
|
805
|
+
const program = activeProgram(snapshot);
|
|
806
|
+
if (program) {
|
|
807
|
+
lines.push(`Current program: ${program.name}, ${program.daysPerWeek} days/week, equipment: ${program.equipmentTier ?? 'unknown'}.`);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
// Best e1RM records (top 15)
|
|
811
|
+
const bestByExercise = new Map();
|
|
812
|
+
for (const session of sessions) {
|
|
813
|
+
for (const exercise of session.exercises ?? []) {
|
|
814
|
+
const key = normalizeExerciseName(exercise.name);
|
|
815
|
+
for (const set of exercise.sets ?? []) {
|
|
816
|
+
if (!set.isComplete) continue;
|
|
817
|
+
const e1rm = Number(set.weight) * (1 + Number(set.reps) / 30);
|
|
818
|
+
const current = bestByExercise.get(key);
|
|
819
|
+
if (!current || e1rm > current.e1rm) {
|
|
820
|
+
bestByExercise.set(key, { name: exercise.name, e1rm });
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
const records = [...bestByExercise.values()]
|
|
827
|
+
.filter((r) => r.e1rm > 0)
|
|
828
|
+
.sort((a, b) => b.e1rm - a.e1rm)
|
|
829
|
+
.slice(0, 15);
|
|
830
|
+
|
|
831
|
+
if (records.length > 0) {
|
|
832
|
+
lines.push('');
|
|
833
|
+
lines.push('Best estimated 1RM records:');
|
|
834
|
+
for (const r of records) {
|
|
835
|
+
lines.push(` ${r.name}: ${r.e1rm.toFixed(1)} kg`);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
// Recent sessions (last 10)
|
|
840
|
+
const recentSessions = sessions.slice(-10);
|
|
841
|
+
if (recentSessions.length > 0) {
|
|
842
|
+
lines.push('');
|
|
843
|
+
lines.push('Recent sessions (newest last):');
|
|
844
|
+
for (const session of recentSessions) {
|
|
845
|
+
const dateStr = completionDateForSession(session);
|
|
846
|
+
const dayLabel = session.dayName ?? session.programName ?? 'Workout';
|
|
847
|
+
const exerciseNames = (session.exercises ?? []).map((e) => e.name).join(', ');
|
|
848
|
+
const volume = session.summary?.totalVolume ?? session.volume ?? 0;
|
|
849
|
+
let line = ` ${dateStr} - ${dayLabel}: ${exerciseNames}`;
|
|
850
|
+
if (volume > 0) line += ` (${volume} kg volume)`;
|
|
851
|
+
lines.push(line);
|
|
852
|
+
|
|
853
|
+
for (const exercise of session.exercises ?? []) {
|
|
854
|
+
const completedSets = (exercise.sets ?? []).filter((s) => s.isComplete);
|
|
855
|
+
if (completedSets.length === 0) continue;
|
|
856
|
+
const topSet = completedSets.reduce((best, s) => {
|
|
857
|
+
const score = Number(s.weight) * Number(s.reps);
|
|
858
|
+
const bestScore = Number(best.weight) * Number(best.reps);
|
|
859
|
+
return score > bestScore ? s : best;
|
|
860
|
+
});
|
|
861
|
+
lines.push(` ${exercise.name}: ${completedSets.length} sets, top ${Number(topSet.weight).toFixed(1)}x${topSet.reps}`);
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
return lines.join('\n');
|
|
867
|
+
}
|
|
868
|
+
|
|
776
869
|
function requiredOption(options, primaryKey, legacyKey = null) {
|
|
777
870
|
return options[primaryKey] ?? (legacyKey ? options[legacyKey] : undefined);
|
|
778
871
|
}
|
package/src/sync-service.js
CHANGED
|
@@ -7,6 +7,7 @@ const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
|
7
7
|
const DEFAULT_RATE_LIMIT_RULES = {
|
|
8
8
|
'workout-summary-ai': 3,
|
|
9
9
|
'cycle-summary-ai': 3,
|
|
10
|
+
'ask-ai': 5,
|
|
10
11
|
'dev-login': 10,
|
|
11
12
|
'device-start': 20,
|
|
12
13
|
'device-poll': 300,
|
|
@@ -261,6 +262,20 @@ function routeRequest(url) {
|
|
|
261
262
|
return { command: 'goals-show', options: { id: decodeURIComponent(goalsShowMatch[1]) } };
|
|
262
263
|
}
|
|
263
264
|
|
|
265
|
+
if (pathname === '/cli/cycles') {
|
|
266
|
+
return {
|
|
267
|
+
command: 'cycle-summary-list',
|
|
268
|
+
options: {
|
|
269
|
+
'program-id': url.searchParams.get('program-id') ?? undefined
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const cyclesShowMatch = pathname.match(/^\/cli\/cycles\/([^/]+)$/);
|
|
275
|
+
if (cyclesShowMatch) {
|
|
276
|
+
return { command: 'cycle-summary-show', options: { id: decodeURIComponent(cyclesShowMatch[1]) } };
|
|
277
|
+
}
|
|
278
|
+
|
|
264
279
|
const compareMatch = pathname.match(/^\/cli\/sessions\/([^/]+)\/compare$/);
|
|
265
280
|
if (compareMatch) {
|
|
266
281
|
return {
|
|
@@ -309,6 +324,14 @@ function routeRequest(url) {
|
|
|
309
324
|
};
|
|
310
325
|
}
|
|
311
326
|
|
|
327
|
+
if (pathname === '/cli/ask') {
|
|
328
|
+
return { command: 'ask-ai', options: {} };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (pathname === '/cli/ask/history') {
|
|
332
|
+
return { command: 'ask-history', options: {} };
|
|
333
|
+
}
|
|
334
|
+
|
|
312
335
|
return null;
|
|
313
336
|
}
|
|
314
337
|
|
|
@@ -779,7 +802,9 @@ export function createSyncServiceRequestHandler({
|
|
|
779
802
|
createProposalForAccount = null,
|
|
780
803
|
listProposalsForAccount = null,
|
|
781
804
|
updateProposalForAccount = null,
|
|
782
|
-
updateAnalysisConsentForAccount = null
|
|
805
|
+
updateAnalysisConsentForAccount = null,
|
|
806
|
+
saveAskConversationForAccount = null,
|
|
807
|
+
listAskConversationsForAccount = null
|
|
783
808
|
}) {
|
|
784
809
|
const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
|
|
785
810
|
|
|
@@ -1716,6 +1741,79 @@ export function createSyncServiceRequestHandler({
|
|
|
1716
1741
|
return;
|
|
1717
1742
|
}
|
|
1718
1743
|
|
|
1744
|
+
if (route.command === 'ask-ai') {
|
|
1745
|
+
if (request.method !== 'POST') {
|
|
1746
|
+
methodNotAllowed(response, 'Use POST for /cli/ask.');
|
|
1747
|
+
return;
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
let body;
|
|
1751
|
+
try {
|
|
1752
|
+
body = await readJsonBody(request);
|
|
1753
|
+
} catch {
|
|
1754
|
+
badRequest(response, 'Invalid JSON body.');
|
|
1755
|
+
return;
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
const question = body?.question;
|
|
1759
|
+
if (!question || typeof question !== 'string' || question.trim().length === 0) {
|
|
1760
|
+
badRequest(response, 'question is required');
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
if (question.length > 500) {
|
|
1764
|
+
badRequest(response, 'question must be 500 characters or fewer');
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
const openrouterKey = process.env.OPENROUTER_API_KEY;
|
|
1769
|
+
if (!openrouterKey) {
|
|
1770
|
+
json(response, 503, { error: 'AI not configured' });
|
|
1771
|
+
return;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
const { askContext } = await import('./queries.js');
|
|
1775
|
+
const ctx = askContext(snapshot);
|
|
1776
|
+
|
|
1777
|
+
try {
|
|
1778
|
+
const { generateAskAnswer } = await import('./openrouter.js');
|
|
1779
|
+
const model = process.env.OPENROUTER_MODEL || undefined;
|
|
1780
|
+
const answer = await generateAskAnswer(ctx, question, { apiKey: openrouterKey, model });
|
|
1781
|
+
if (saveAskConversationForAccount) {
|
|
1782
|
+
try {
|
|
1783
|
+
await saveAskConversationForAccount(account, { question, answer });
|
|
1784
|
+
} catch (saveErr) {
|
|
1785
|
+
console.error('Failed to save ask conversation:', saveErr.message);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
json(response, 200, { answer });
|
|
1789
|
+
} catch (err) {
|
|
1790
|
+
console.error('AI ask error:', err.message);
|
|
1791
|
+
json(response, 502, { error: 'Failed to generate AI answer' });
|
|
1792
|
+
}
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
if (route.command === 'ask-history') {
|
|
1797
|
+
if (request.method !== 'GET') {
|
|
1798
|
+
methodNotAllowed(response, 'Use GET for /cli/ask/history.');
|
|
1799
|
+
return;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
if (!listAskConversationsForAccount) {
|
|
1803
|
+
json(response, 503, { error: 'Ask history not available' });
|
|
1804
|
+
return;
|
|
1805
|
+
}
|
|
1806
|
+
|
|
1807
|
+
try {
|
|
1808
|
+
const conversations = await listAskConversationsForAccount(account);
|
|
1809
|
+
json(response, 200, { conversations });
|
|
1810
|
+
} catch (err) {
|
|
1811
|
+
console.error('Ask history error:', err.message);
|
|
1812
|
+
json(response, 500, { error: 'Failed to load ask history' });
|
|
1813
|
+
}
|
|
1814
|
+
return;
|
|
1815
|
+
}
|
|
1816
|
+
|
|
1719
1817
|
const result = executeReadCommand(snapshot, route.command, route.options);
|
|
1720
1818
|
if (!result.ok) {
|
|
1721
1819
|
if (result.error.startsWith('Session not found')) {
|