incremnt 0.1.13 → 0.1.16

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.
@@ -4,9 +4,11 @@ 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,
11
+ 'ask-ai': 5,
10
12
  'dev-login': 10,
11
13
  'device-start': 20,
12
14
  'device-poll': 300,
@@ -29,6 +31,14 @@ function json(response, statusCode, payload) {
29
31
  response.end(JSON.stringify(payload));
30
32
  }
31
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
+
32
42
  function logRequest(request, statusCode, extra = '') {
33
43
  const method = request.method ?? '?';
34
44
  const rawUrl = request.url ?? '/';
@@ -54,8 +64,9 @@ function methodNotAllowed(response, message = 'Method not allowed') {
54
64
  json(response, 405, { error: message });
55
65
  }
56
66
 
57
- function internalError(response, error) {
67
+ function internalError(response, error, onError) {
58
68
  console.error('Internal error:', error.message);
69
+ if (onError) onError(error);
59
70
  json(response, 500, { error: 'Internal server error' });
60
71
  }
61
72
 
@@ -119,16 +130,22 @@ function createRateLimiter({
119
130
 
120
131
  if (!bucket || bucket.resetAt <= now) {
121
132
  buckets.set(key, { count: 1, resetAt: now + windowMs });
122
- return { allowed: true };
133
+ return { allowed: true, retryAfterSec: Math.ceil(windowMs / 1000) };
123
134
  }
124
135
 
125
136
  if (bucket.count >= limit) {
126
- return { allowed: false };
137
+ return {
138
+ allowed: false,
139
+ retryAfterSec: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))
140
+ };
127
141
  }
128
142
 
129
143
  bucket.count += 1;
130
144
  buckets.set(key, bucket);
131
- return { allowed: true };
145
+ return {
146
+ allowed: true,
147
+ retryAfterSec: Math.max(1, Math.ceil((bucket.resetAt - now) / 1000))
148
+ };
132
149
  }
133
150
  };
134
151
  }
@@ -261,6 +278,20 @@ function routeRequest(url) {
261
278
  return { command: 'goals-show', options: { id: decodeURIComponent(goalsShowMatch[1]) } };
262
279
  }
263
280
 
281
+ if (pathname === '/cli/cycles') {
282
+ return {
283
+ command: 'cycle-summary-list',
284
+ options: {
285
+ 'program-id': url.searchParams.get('program-id') ?? undefined
286
+ }
287
+ };
288
+ }
289
+
290
+ const cyclesShowMatch = pathname.match(/^\/cli\/cycles\/([^/]+)$/);
291
+ if (cyclesShowMatch) {
292
+ return { command: 'cycle-summary-show', options: { id: decodeURIComponent(cyclesShowMatch[1]) } };
293
+ }
294
+
264
295
  const compareMatch = pathname.match(/^\/cli\/sessions\/([^/]+)\/compare$/);
265
296
  if (compareMatch) {
266
297
  return {
@@ -309,6 +340,30 @@ function routeRequest(url) {
309
340
  };
310
341
  }
311
342
 
343
+ if (pathname === '/cli/ask') {
344
+ return { command: 'ask-ai', options: {} };
345
+ }
346
+
347
+ if (pathname === '/cli/ask/history') {
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
+ };
365
+ }
366
+
312
367
  return null;
313
368
  }
314
369
 
@@ -779,7 +834,10 @@ export function createSyncServiceRequestHandler({
779
834
  createProposalForAccount = null,
780
835
  listProposalsForAccount = null,
781
836
  updateProposalForAccount = null,
782
- updateAnalysisConsentForAccount = null
837
+ updateAnalysisConsentForAccount = null,
838
+ saveAskConversationForAccount = null,
839
+ listAskConversationsForAccount = null,
840
+ onError = null
783
841
  }) {
784
842
  const rateLimiter = createRateLimiter(rateLimitConfig ?? {});
785
843
 
@@ -816,8 +874,14 @@ export function createSyncServiceRequestHandler({
816
874
  return;
817
875
  }
818
876
 
819
- if (!rateLimiter.check(request, route.command).allowed) {
820
- json(response, 429, { error: 'Too many requests' });
877
+ const rateLimitResult = rateLimiter.check(request, route.command);
878
+ if (!rateLimitResult.allowed) {
879
+ jsonWithHeaders(
880
+ response,
881
+ 429,
882
+ { error: 'Too many requests', code: 'rate_limited' },
883
+ { 'Retry-After': String(rateLimitResult.retryAfterSec ?? 60) }
884
+ );
821
885
  return;
822
886
  }
823
887
 
@@ -1668,18 +1732,32 @@ export function createSyncServiceRequestHandler({
1668
1732
 
1669
1733
  const openrouterKey = process.env.OPENROUTER_API_KEY;
1670
1734
  if (!openrouterKey) {
1671
- json(response, 503, { error: 'AI summaries not configured' });
1735
+ json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
1672
1736
  return;
1673
1737
  }
1674
1738
 
1675
1739
  try {
1676
1740
  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 });
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 });
1680
1753
  } catch (err) {
1681
1754
  console.error('AI workout summary error:', err.message);
1682
- json(response, 502, { error: 'Failed to generate AI summary' });
1755
+ onError?.(err, {
1756
+ feature: 'workout-summary',
1757
+ modelErrors: err.modelErrors,
1758
+ durationMs: err.durationMs
1759
+ });
1760
+ json(response, 502, { error: 'Failed to generate AI summary', code: 'ai_unavailable' });
1683
1761
  }
1684
1762
  return;
1685
1763
  }
@@ -1700,18 +1778,181 @@ export function createSyncServiceRequestHandler({
1700
1778
 
1701
1779
  const openrouterKey = process.env.OPENROUTER_API_KEY;
1702
1780
  if (!openrouterKey) {
1703
- json(response, 503, { error: 'AI summaries not configured' });
1781
+ json(response, 503, { error: 'AI summaries not configured', code: 'not_configured' });
1704
1782
  return;
1705
1783
  }
1706
1784
 
1707
1785
  try {
1708
1786
  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 });
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 });
1712
1799
  } catch (err) {
1713
1800
  console.error('AI cycle summary error:', err.message);
1714
- json(response, 502, { error: 'Failed to generate AI summary' });
1801
+ onError?.(err, {
1802
+ feature: 'cycle-summary',
1803
+ modelErrors: err.modelErrors,
1804
+ durationMs: err.durationMs
1805
+ });
1806
+ json(response, 502, { error: 'Failed to generate AI summary', code: 'ai_unavailable' });
1807
+ }
1808
+ return;
1809
+ }
1810
+
1811
+ if (route.command === 'ask-ai') {
1812
+ if (request.method !== 'POST') {
1813
+ methodNotAllowed(response, 'Use POST for /cli/ask.');
1814
+ return;
1815
+ }
1816
+
1817
+ let body;
1818
+ try {
1819
+ body = await readJsonBody(request);
1820
+ } catch {
1821
+ badRequest(response, 'Invalid JSON body.');
1822
+ return;
1823
+ }
1824
+
1825
+ const question = body?.question;
1826
+ if (!question || typeof question !== 'string' || question.trim().length === 0) {
1827
+ badRequest(response, 'question is required');
1828
+ return;
1829
+ }
1830
+ if (question.length > 500) {
1831
+ badRequest(response, 'question must be 500 characters or fewer');
1832
+ return;
1833
+ }
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
+
1855
+ const openrouterKey = process.env.OPENROUTER_API_KEY;
1856
+ if (!openrouterKey) {
1857
+ json(response, 503, { error: 'AI not configured', code: 'not_configured' });
1858
+ return;
1859
+ }
1860
+
1861
+ const { askContext } = await import('./queries.js');
1862
+ const ctx = askContext(snapshot);
1863
+
1864
+ try {
1865
+ const { generateAskAnswer } = await import('./openrouter.js');
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
+ ];
1872
+ if (saveAskConversationForAccount) {
1873
+ try {
1874
+ await saveAskConversationForAccount(account, {
1875
+ id: conversationId,
1876
+ messages: updatedMessages,
1877
+ model: askResult.model
1878
+ });
1879
+ } catch (saveErr) {
1880
+ console.error('Failed to save ask conversation:', saveErr.message);
1881
+ }
1882
+ }
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 });
1894
+ } catch (err) {
1895
+ console.error('AI ask error:', err.message);
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' });
1902
+ }
1903
+ return;
1904
+ }
1905
+
1906
+ if (route.command === 'ask-history') {
1907
+ if (request.method !== 'GET') {
1908
+ methodNotAllowed(response, 'Use GET for /cli/ask/history.');
1909
+ return;
1910
+ }
1911
+
1912
+ if (!listAskConversationsForAccount) {
1913
+ json(response, 503, { error: 'Ask history not available' });
1914
+ return;
1915
+ }
1916
+
1917
+ try {
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 });
1927
+ } catch (err) {
1928
+ console.error('Ask history error:', err.message);
1929
+ json(response, 500, { error: 'Failed to load ask history' });
1930
+ }
1931
+ return;
1932
+ }
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' });
1715
1956
  }
1716
1957
  return;
1717
1958
  }
@@ -1729,7 +1970,7 @@ export function createSyncServiceRequestHandler({
1729
1970
 
1730
1971
  json(response, 200, result.payload);
1731
1972
  } catch (error) {
1732
- internalError(response, error);
1973
+ internalError(response, error, onError);
1733
1974
  }
1734
1975
  };
1735
1976
  }