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.
- package/README.md +11 -9
- package/package.json +3 -2
- package/src/contract.js +25 -0
- package/src/lib.js +57 -1
- package/src/mcp.js +57 -30
- package/src/openrouter.js +206 -119
- package/src/queries.js +293 -10
- package/src/remote.js +39 -1
- package/src/sync-service.js +259 -18
package/src/sync-service.js
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
820
|
-
|
|
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
|
|
1678
|
-
|
|
1679
|
-
|
|
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
|
-
|
|
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
|
|
1710
|
-
|
|
1711
|
-
|
|
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
|
-
|
|
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
|
}
|