incremnt 0.1.12 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "incremnt",
3
- "version": "0.1.12",
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 { spawn } from 'node:child_process';
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,
@@ -71,7 +73,7 @@ export async function runCli(argv, stdout, stderr) {
71
73
  const isAuthenticated = Boolean(sessionState?.session && !isSessionExpired(sessionState.session));
72
74
 
73
75
  if (!command || options.help) {
74
- printLogo(stdout);
76
+ await printLogo(stdout);
75
77
  stdout.write(`${formatHelp({ isAuthenticated })}\n`);
76
78
  return 0;
77
79
  }
@@ -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/logo.js CHANGED
@@ -28,7 +28,9 @@ const SHADOW_OFFSET_Y = 1;
28
28
  const totalLines = 5 + SHADOW_OFFSET_Y;
29
29
  const lineLength = rawLines[0].length + SHADOW_OFFSET_X;
30
30
 
31
- export function printLogo(stdout = process.stdout) {
31
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
32
+
33
+ export async function printLogo(stdout = process.stdout) {
32
34
  // Gradient starts at the 'M' character position
33
35
  // I: 0, N: 3, C: 14, R: 21, E: 29, M: 37
34
36
  const gradientStartIdx = 37;
@@ -55,12 +57,12 @@ export function printLogo(stdout = process.stdout) {
55
57
  lineStr += chalk.rgb(col.r, col.g, col.b)('█');
56
58
  }
57
59
  } else if (hasBg) {
60
+ const shadowFactor = 0.35;
58
61
  if (bgX < gradientStartIdx) {
59
- lineStr += chalk.rgb(55, 55, 65)('█');
62
+ lineStr += chalk.rgb(Math.round(255 * shadowFactor), Math.round(255 * shadowFactor), Math.round(255 * shadowFactor))('█');
60
63
  } else {
61
64
  const factor = Math.max(0, Math.min(1, (bgX - gradientStartIdx) / (rawLines[0].length - gradientStartIdx - 2)));
62
65
  const col = interpolateColor(startColor, endColor, factor);
63
- const shadowFactor = 0.35;
64
66
  lineStr += chalk.rgb(Math.round(col.r * shadowFactor), Math.round(col.g * shadowFactor), Math.round(col.b * shadowFactor))('█');
65
67
  }
66
68
  } else {
@@ -68,6 +70,7 @@ export function printLogo(stdout = process.stdout) {
68
70
  }
69
71
  }
70
72
  stdout.write(lineStr + '\n');
73
+ await sleep(80);
71
74
  }
72
75
  stdout.write('\n');
73
76
  }
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
- Rules:
178
- - Only state what the data shows. Never claim how something "felt" — you have numbers, not feelings.
179
- - Never use words like: impressive, crushed, solid, demonstrating, significant, incredible, fantastic, smooth, controlled.
180
- - Never use -ing clauses that add fake depth ("indicating progressive overload", "demonstrating strength gains").
181
- - Never say things "felt smooth", "felt controlled", "feels about average" you cannot know this.
182
- - Never restate PRs, total volume, or effort score the user already sees these in the app.
183
- - Write like a training partner looking at a logbook, not a motivational coach.
184
- - Short sentences, no filler, no cheerleading. Questions are good.
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 lines = [
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
- lines.push(` ${ex.exerciseName}: ${ex.completedSets} sets${topPart}`);
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
- lines.push(` ${comp.date}: ${comp.totalVolume} kg volume${effort}`);
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
- // Detect PRs compare each exercise's best e1RM against all prior sessions
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 (!prior || score > prior) {
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
  }
@@ -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')) {