neoagent 1.4.12 → 1.5.1
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 +1 -1
- package/server/public/app.html +14 -0
- package/server/public/js/app.js +18 -1
- package/server/services/ai/engine.js +79 -22
- package/server/services/ai/models.js +27 -0
- package/server/services/ai/providers/ollama.js +28 -0
- package/server/services/ai/settings.js +13 -5
- package/server/services/ai/tools.js +1 -1
- package/server/services/health/ingestion.js +29 -1
package/package.json
CHANGED
package/server/public/app.html
CHANGED
|
@@ -593,6 +593,13 @@
|
|
|
593
593
|
<span>Create disabled draft skills from successful multi-step runs</span>
|
|
594
594
|
</label>
|
|
595
595
|
</div>
|
|
596
|
+
<div class="form-group">
|
|
597
|
+
<label class="form-label">Smart Selection</label>
|
|
598
|
+
<label class="flex items-center gap-2" style="cursor:pointer;">
|
|
599
|
+
<input type="checkbox" id="settingSmarterModelSelector" autocomplete="off" data-bwignore="true">
|
|
600
|
+
<span>Automatically select best model based on task type (coding, planning, etc.)</span>
|
|
601
|
+
</label>
|
|
602
|
+
</div>
|
|
596
603
|
<div class="form-group">
|
|
597
604
|
<label class="form-label">Default Chat Model</label>
|
|
598
605
|
<select id="settingDefaultChatModel" class="input" autocomplete="off" data-bwignore="true">
|
|
@@ -605,6 +612,13 @@
|
|
|
605
612
|
<option value="auto">Smart Selector (Auto)</option>
|
|
606
613
|
</select>
|
|
607
614
|
</div>
|
|
615
|
+
<div class="form-group">
|
|
616
|
+
<label class="form-label">Fallback Model</label>
|
|
617
|
+
<select id="settingFallbackModelId" class="input" autocomplete="off" data-bwignore="true">
|
|
618
|
+
<option value="gpt-5-nano">GPT-5 Nano (Default Cloud Fallback)</option>
|
|
619
|
+
</select>
|
|
620
|
+
<small class="text-muted" style="font-size: 11px; display: block; margin-top: 4px;">Used if the primary model fails or is offline.</small>
|
|
621
|
+
</div>
|
|
608
622
|
<div class="form-group">
|
|
609
623
|
<label class="form-label">Smart Selector Allowed Models</label>
|
|
610
624
|
<div id="modelCheckboxesContainer" style="display:flex; flex-direction:column; gap:8px;">
|
package/server/public/js/app.js
CHANGED
|
@@ -1849,6 +1849,9 @@ $("#settingsBtn").addEventListener("click", async () => {
|
|
|
1849
1849
|
$("#settingAutoSkillLearning").checked =
|
|
1850
1850
|
settings.auto_skill_learning !== false &&
|
|
1851
1851
|
settings.auto_skill_learning !== "false";
|
|
1852
|
+
$("#settingSmarterModelSelector").checked =
|
|
1853
|
+
settings.smarter_model_selector !== false &&
|
|
1854
|
+
settings.smarter_model_selector !== "false";
|
|
1852
1855
|
|
|
1853
1856
|
const enabledModels = Array.isArray(settings.enabled_models) ? settings.enabled_models : (meta.models || []).map(m => m.id);
|
|
1854
1857
|
|
|
@@ -1858,6 +1861,8 @@ $("#settingsBtn").addEventListener("click", async () => {
|
|
|
1858
1861
|
if (chatModelSelect && subagentModelSelect && meta.models) {
|
|
1859
1862
|
chatModelSelect.innerHTML = '<option value="auto">Smart Selector (Auto)</option>';
|
|
1860
1863
|
subagentModelSelect.innerHTML = '<option value="auto">Smart Selector (Auto)</option>';
|
|
1864
|
+
const fallbackModelSelect = $("#settingFallbackModelId");
|
|
1865
|
+
if (fallbackModelSelect) fallbackModelSelect.innerHTML = "";
|
|
1861
1866
|
|
|
1862
1867
|
for (const modelDef of meta.models) {
|
|
1863
1868
|
const chatOption = document.createElement("option");
|
|
@@ -1869,10 +1874,20 @@ $("#settingsBtn").addEventListener("click", async () => {
|
|
|
1869
1874
|
subagentOption.value = modelDef.id;
|
|
1870
1875
|
subagentOption.textContent = modelDef.label;
|
|
1871
1876
|
subagentModelSelect.appendChild(subagentOption);
|
|
1877
|
+
|
|
1878
|
+
if (fallbackModelSelect) {
|
|
1879
|
+
const fallbackOption = document.createElement("option");
|
|
1880
|
+
fallbackOption.value = modelDef.id;
|
|
1881
|
+
fallbackOption.textContent = modelDef.label;
|
|
1882
|
+
fallbackModelSelect.appendChild(fallbackOption);
|
|
1883
|
+
}
|
|
1872
1884
|
}
|
|
1873
1885
|
|
|
1874
1886
|
chatModelSelect.value = settings.default_chat_model || "auto";
|
|
1875
1887
|
subagentModelSelect.value = settings.default_subagent_model || "auto";
|
|
1888
|
+
if ($("#settingFallbackModelId")) {
|
|
1889
|
+
$("#settingFallbackModelId").value = settings.fallback_model_id || "gpt-5-nano";
|
|
1890
|
+
}
|
|
1876
1891
|
|
|
1877
1892
|
const indicator = $("#modelIndicator");
|
|
1878
1893
|
if (indicator) {
|
|
@@ -1947,9 +1962,11 @@ $("#saveSettings").addEventListener("click", async () => {
|
|
|
1947
1962
|
heartbeat_enabled: $("#settingHeartbeat").checked,
|
|
1948
1963
|
headless_browser: $("#settingHeadlessBrowser").checked,
|
|
1949
1964
|
auto_skill_learning: $("#settingAutoSkillLearning").checked,
|
|
1965
|
+
smarter_model_selector: $("#settingSmarterModelSelector").checked,
|
|
1950
1966
|
enabled_models: enabledModels,
|
|
1951
1967
|
default_chat_model: defaultChatModel,
|
|
1952
|
-
default_subagent_model: defaultSubagentModel
|
|
1968
|
+
default_subagent_model: defaultSubagentModel,
|
|
1969
|
+
fallback_model_id: $("#settingFallbackModelId") ? $("#settingFallbackModelId").value : 'gpt-5-nano'
|
|
1953
1970
|
},
|
|
1954
1971
|
});
|
|
1955
1972
|
|
|
@@ -25,9 +25,11 @@ function getProviderForUser(userId, task = '', isSubagent = false, modelOverride
|
|
|
25
25
|
let defaultChatModel = 'auto';
|
|
26
26
|
let defaultSubagentModel = 'auto';
|
|
27
27
|
|
|
28
|
+
let smarterSelection = true;
|
|
29
|
+
|
|
28
30
|
try {
|
|
29
|
-
const rows = db.prepare('SELECT key, value FROM user_settings WHERE user_id = ? AND key IN (?, ?, ?)')
|
|
30
|
-
.all(userId, 'enabled_models', 'default_chat_model', 'default_subagent_model');
|
|
31
|
+
const rows = db.prepare('SELECT key, value FROM user_settings WHERE user_id = ? AND key IN (?, ?, ?, ?)')
|
|
32
|
+
.all(userId, 'enabled_models', 'default_chat_model', 'default_subagent_model', 'smarter_model_selector');
|
|
31
33
|
|
|
32
34
|
for (const row of rows) {
|
|
33
35
|
if (!row.value) continue;
|
|
@@ -40,6 +42,7 @@ function getProviderForUser(userId, task = '', isSubagent = false, modelOverride
|
|
|
40
42
|
if (row.key === 'enabled_models') enabledIds = parsedVal;
|
|
41
43
|
if (row.key === 'default_chat_model') defaultChatModel = parsedVal;
|
|
42
44
|
if (row.key === 'default_subagent_model') defaultSubagentModel = parsedVal;
|
|
45
|
+
if (row.key === 'smarter_model_selector') smarterSelection = parsedVal !== false && parsedVal !== 'false';
|
|
43
46
|
}
|
|
44
47
|
} catch (e) {
|
|
45
48
|
console.error('Failed to fetch model settings:', e.message);
|
|
@@ -70,9 +73,21 @@ function getProviderForUser(userId, task = '', isSubagent = false, modelOverride
|
|
|
70
73
|
selectedModelDef = SUPPORTED_MODELS.find((m) => m.id === userSelectedDefault) || fallbackModel;
|
|
71
74
|
} else {
|
|
72
75
|
const taskStr = String(task || '').toLowerCase();
|
|
73
|
-
|
|
76
|
+
|
|
77
|
+
// Basic detection
|
|
78
|
+
let isPlanning = /\b(plan|think|analy[sz]e|complex|step by step)\b/.test(taskStr);
|
|
79
|
+
let isCoding = false;
|
|
80
|
+
|
|
81
|
+
// Enhanced detection if enabled
|
|
82
|
+
if (smarterSelection) {
|
|
83
|
+
isPlanning = isPlanning || /\b(reason|strategy|logical|math|complex)\b/.test(taskStr);
|
|
84
|
+
isCoding = /\b(code|program|script|debug|refactor|function|implementation|logic)\b/.test(taskStr);
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
if (isPlanning) {
|
|
75
88
|
selectedModelDef = availableModels.find((m) => m.purpose === 'planning') || fallbackModel;
|
|
89
|
+
} else if (isCoding) {
|
|
90
|
+
selectedModelDef = availableModels.find((m) => m.purpose === 'coding') || availableModels.find((m) => m.purpose === 'planning') || fallbackModel;
|
|
76
91
|
} else if (isSubagent) {
|
|
77
92
|
selectedModelDef = availableModels.find((m) => m.purpose === 'fast') || fallbackModel;
|
|
78
93
|
} else {
|
|
@@ -302,28 +317,70 @@ class AgentEngine {
|
|
|
302
317
|
let streamContent = '';
|
|
303
318
|
const callOptions = { model, reasoningEffort: this.getReasoningEffort(providerName, options) };
|
|
304
319
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
320
|
+
const tryModelCall = async (retryForFallback = true) => {
|
|
321
|
+
try {
|
|
322
|
+
if (options.stream !== false) {
|
|
323
|
+
const gen = provider.stream(messages, tools, callOptions);
|
|
324
|
+
for await (const chunk of gen) {
|
|
325
|
+
if (chunk.type === 'content') {
|
|
326
|
+
streamContent += chunk.content;
|
|
327
|
+
this.emit(userId, 'run:stream', { runId, content: streamContent, iteration });
|
|
328
|
+
}
|
|
329
|
+
if (chunk.type === 'done') {
|
|
330
|
+
response = chunk;
|
|
331
|
+
}
|
|
332
|
+
if (chunk.type === 'tool_calls') {
|
|
333
|
+
response = {
|
|
334
|
+
content: chunk.content || streamContent,
|
|
335
|
+
toolCalls: chunk.toolCalls,
|
|
336
|
+
finishReason: 'tool_calls',
|
|
337
|
+
usage: chunk.usage || null
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
} else {
|
|
342
|
+
response = await provider.chat(messages, tools, callOptions);
|
|
314
343
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
344
|
+
} catch (err) {
|
|
345
|
+
console.error(`[Engine] Model call failed (${model}):`, err.message);
|
|
346
|
+
if (retryForFallback && aiSettings.fallback_model_id && aiSettings.fallback_model_id !== model) {
|
|
347
|
+
console.log(`[Engine] Attempting fallback to: ${aiSettings.fallback_model_id}`);
|
|
348
|
+
const fallback = getProviderForUser(userId, userMessage, triggerType === 'subagent', aiSettings.fallback_model_id);
|
|
349
|
+
// Update local state for the retry
|
|
350
|
+
const nextProvider = fallback.provider;
|
|
351
|
+
const nextModel = fallback.model;
|
|
352
|
+
const nextProviderName = fallback.providerName;
|
|
353
|
+
|
|
354
|
+
// Recursive call once
|
|
355
|
+
const retryOptions = { ...callOptions, model: nextModel, reasoningEffort: this.getReasoningEffort(nextProviderName, options) };
|
|
356
|
+
|
|
357
|
+
if (options.stream !== false) {
|
|
358
|
+
const gen = nextProvider.stream(messages, tools, retryOptions);
|
|
359
|
+
for await (const chunk of gen) {
|
|
360
|
+
if (chunk.type === 'content') {
|
|
361
|
+
streamContent += chunk.content;
|
|
362
|
+
this.emit(userId, 'run:stream', { runId, content: streamContent, iteration });
|
|
363
|
+
}
|
|
364
|
+
if (chunk.type === 'done') response = chunk;
|
|
365
|
+
if (chunk.type === 'tool_calls') {
|
|
366
|
+
response = {
|
|
367
|
+
content: chunk.content || streamContent,
|
|
368
|
+
toolCalls: chunk.toolCalls,
|
|
369
|
+
finishReason: 'tool_calls',
|
|
370
|
+
usage: chunk.usage || null
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
} else {
|
|
375
|
+
response = await nextProvider.chat(messages, tools, retryOptions);
|
|
376
|
+
}
|
|
377
|
+
} else {
|
|
378
|
+
throw err;
|
|
322
379
|
}
|
|
323
380
|
}
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
|
|
381
|
+
};
|
|
382
|
+
|
|
383
|
+
await tryModelCall();
|
|
327
384
|
|
|
328
385
|
if (!response) {
|
|
329
386
|
response = { content: streamContent, toolCalls: [], finishReason: 'stop', usage: null };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const { GrokProvider } = require('./providers/grok');
|
|
2
2
|
const { OpenAIProvider } = require('./providers/openai');
|
|
3
3
|
const { GoogleProvider } = require('./providers/google');
|
|
4
|
+
const { OllamaProvider } = require('./providers/ollama');
|
|
4
5
|
|
|
5
6
|
const SUPPORTED_MODELS = [
|
|
6
7
|
{
|
|
@@ -26,6 +27,30 @@ const SUPPORTED_MODELS = [
|
|
|
26
27
|
label: 'Gemini 3.1 Flash Lite (Preview)',
|
|
27
28
|
provider: 'google',
|
|
28
29
|
purpose: 'general'
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: 'llama3.1:8b',
|
|
33
|
+
label: 'Llama 3.1 8B (Local / General)',
|
|
34
|
+
provider: 'ollama',
|
|
35
|
+
purpose: 'general'
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'phi4-mini',
|
|
39
|
+
label: 'Phi-4 Mini (Local / Fast)',
|
|
40
|
+
provider: 'ollama',
|
|
41
|
+
purpose: 'fast'
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
id: 'phi4',
|
|
45
|
+
label: 'Phi-4 (Local / Planning)',
|
|
46
|
+
provider: 'ollama',
|
|
47
|
+
purpose: 'planning'
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: 'qwen2.5-coder:7b',
|
|
51
|
+
label: 'Qwen 2.5 Coder 7B (Local / Coding)',
|
|
52
|
+
provider: 'ollama',
|
|
53
|
+
purpose: 'coding'
|
|
29
54
|
}
|
|
30
55
|
];
|
|
31
56
|
|
|
@@ -36,6 +61,8 @@ function createProviderInstance(providerStr) {
|
|
|
36
61
|
return new OpenAIProvider({ apiKey: process.env.OPENAI_API_KEY });
|
|
37
62
|
} else if (providerStr === 'google') {
|
|
38
63
|
return new GoogleProvider({ apiKey: process.env.GOOGLE_AI_KEY });
|
|
64
|
+
} else if (providerStr === 'ollama') {
|
|
65
|
+
return new OllamaProvider({ baseUrl: process.env.OLLAMA_URL });
|
|
39
66
|
}
|
|
40
67
|
throw new Error(`Unknown provider: ${providerStr}`);
|
|
41
68
|
}
|
|
@@ -19,6 +19,32 @@ class OllamaProvider extends BaseProvider {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
async ensureModel(model) {
|
|
23
|
+
const models = await this.listModels();
|
|
24
|
+
// Normalization: Ollama often adds :latest if no tag is specified
|
|
25
|
+
const normalizedModel = model.includes(':') ? model : `${model}:latest`;
|
|
26
|
+
const found = models.some(m => m === model || m === normalizedModel);
|
|
27
|
+
|
|
28
|
+
if (found) return true;
|
|
29
|
+
|
|
30
|
+
console.log(`[Ollama] Model '${model}' not found, pulling from registry...`);
|
|
31
|
+
try {
|
|
32
|
+
const res = await fetch(`${this.baseUrl}/api/pull`, {
|
|
33
|
+
method: 'POST',
|
|
34
|
+
headers: { 'Content-Type': 'application/json' },
|
|
35
|
+
body: JSON.stringify({ name: model, stream: false })
|
|
36
|
+
});
|
|
37
|
+
if (!res.ok) throw new Error(`Pull failed: ${res.statusText}`);
|
|
38
|
+
console.log(`[Ollama] Model '${model}' pulled successfully.`);
|
|
39
|
+
// Refresh local model list
|
|
40
|
+
await this.listModels();
|
|
41
|
+
return true;
|
|
42
|
+
} catch (e) {
|
|
43
|
+
console.error(`[Ollama] Failed to pull model '${model}':`, e.message);
|
|
44
|
+
throw e;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
22
48
|
getContextWindow(model) {
|
|
23
49
|
return 128000;
|
|
24
50
|
}
|
|
@@ -36,6 +62,7 @@ class OllamaProvider extends BaseProvider {
|
|
|
36
62
|
|
|
37
63
|
async chat(messages, tools = [], options = {}) {
|
|
38
64
|
const model = options.model || this.config.model || 'llama3.1';
|
|
65
|
+
await this.ensureModel(model);
|
|
39
66
|
const body = {
|
|
40
67
|
model,
|
|
41
68
|
messages: messages.map(m => ({
|
|
@@ -86,6 +113,7 @@ class OllamaProvider extends BaseProvider {
|
|
|
86
113
|
|
|
87
114
|
async *stream(messages, tools = [], options = {}) {
|
|
88
115
|
const model = options.model || this.config.model || 'llama3.1';
|
|
116
|
+
await this.ensureModel(model);
|
|
89
117
|
const body = {
|
|
90
118
|
model,
|
|
91
119
|
messages: messages.map(m => ({
|
|
@@ -5,7 +5,9 @@ const DEFAULT_AI_SETTINGS = Object.freeze({
|
|
|
5
5
|
chat_history_window: 8,
|
|
6
6
|
tool_replay_budget_chars: 1200,
|
|
7
7
|
subagent_max_iterations: 6,
|
|
8
|
-
auto_skill_learning: true
|
|
8
|
+
auto_skill_learning: true,
|
|
9
|
+
fallback_model_id: 'gpt-5-nano',
|
|
10
|
+
smarter_model_selector: true
|
|
9
11
|
});
|
|
10
12
|
|
|
11
13
|
function parseSettingValue(value) {
|
|
@@ -21,14 +23,16 @@ function ensureDefaultAiSettings(userId) {
|
|
|
21
23
|
if (!userId) return { ...DEFAULT_AI_SETTINGS };
|
|
22
24
|
|
|
23
25
|
const existing = db.prepare(
|
|
24
|
-
'SELECT key, value FROM user_settings WHERE user_id = ? AND key IN (?, ?, ?, ?, ?)'
|
|
26
|
+
'SELECT key, value FROM user_settings WHERE user_id = ? AND key IN (?, ?, ?, ?, ?, ?, ?)'
|
|
25
27
|
).all(
|
|
26
28
|
userId,
|
|
27
29
|
'cost_mode',
|
|
28
30
|
'chat_history_window',
|
|
29
31
|
'tool_replay_budget_chars',
|
|
30
32
|
'subagent_max_iterations',
|
|
31
|
-
'auto_skill_learning'
|
|
33
|
+
'auto_skill_learning',
|
|
34
|
+
'fallback_model_id',
|
|
35
|
+
'smarter_model_selector'
|
|
32
36
|
);
|
|
33
37
|
|
|
34
38
|
const seen = new Set(existing.map((row) => row.key));
|
|
@@ -49,14 +53,16 @@ function getAiSettings(userId) {
|
|
|
49
53
|
if (!userId) return { ...DEFAULT_AI_SETTINGS };
|
|
50
54
|
|
|
51
55
|
const rows = db.prepare(
|
|
52
|
-
'SELECT key, value FROM user_settings WHERE user_id = ? AND key IN (?, ?, ?, ?, ?)'
|
|
56
|
+
'SELECT key, value FROM user_settings WHERE user_id = ? AND key IN (?, ?, ?, ?, ?, ?, ?)'
|
|
53
57
|
).all(
|
|
54
58
|
userId,
|
|
55
59
|
'cost_mode',
|
|
56
60
|
'chat_history_window',
|
|
57
61
|
'tool_replay_budget_chars',
|
|
58
62
|
'subagent_max_iterations',
|
|
59
|
-
'auto_skill_learning'
|
|
63
|
+
'auto_skill_learning',
|
|
64
|
+
'fallback_model_id',
|
|
65
|
+
'smarter_model_selector'
|
|
60
66
|
);
|
|
61
67
|
|
|
62
68
|
const settings = { ...DEFAULT_AI_SETTINGS };
|
|
@@ -69,6 +75,8 @@ function getAiSettings(userId) {
|
|
|
69
75
|
settings.subagent_max_iterations = Math.max(2, Math.min(Number(settings.subagent_max_iterations) || DEFAULT_AI_SETTINGS.subagent_max_iterations, 12));
|
|
70
76
|
settings.cost_mode = typeof settings.cost_mode === 'string' ? settings.cost_mode : DEFAULT_AI_SETTINGS.cost_mode;
|
|
71
77
|
settings.auto_skill_learning = settings.auto_skill_learning !== false && settings.auto_skill_learning !== 'false';
|
|
78
|
+
settings.smarter_model_selector = settings.smarter_model_selector !== false && settings.smarter_model_selector !== 'false';
|
|
79
|
+
settings.fallback_model_id = typeof settings.fallback_model_id === 'string' ? settings.fallback_model_id : DEFAULT_AI_SETTINGS.fallback_model_id;
|
|
72
80
|
|
|
73
81
|
return settings;
|
|
74
82
|
}
|
|
@@ -588,7 +588,7 @@ function getAvailableTools(app, options = {}) {
|
|
|
588
588
|
parameters: {
|
|
589
589
|
type: 'object',
|
|
590
590
|
properties: {
|
|
591
|
-
metric_type: { type: 'string', description: 'Metric to query: "steps", "heart_rate", "sleep_session", "exercise_session", "weight". Omit to see what is available.' },
|
|
591
|
+
metric_type: { type: 'string', description: 'Metric to query. Canonical values: "steps" (also accepts: "step", "step_count"), "heart_rate" (also accepts: "heartbeat", "heartrate", "pulse", "bpm"), "sleep_session" (also accepts: "sleep"), "exercise_session" (also accepts: "exercise", "workout", "activity"), "weight" (also accepts: "body_weight"). Omit to see what is available.' },
|
|
592
592
|
limit: { type: 'number', description: 'Max recent records to return (default 10, max 200). Use a small number unless the user explicitly asks for a full history.' }
|
|
593
593
|
}
|
|
594
594
|
}
|
|
@@ -169,13 +169,41 @@ function getHealthSyncStatus(userId) {
|
|
|
169
169
|
};
|
|
170
170
|
}
|
|
171
171
|
|
|
172
|
+
// Aliases map collapsed/synonym forms → canonical stored metric_type values.
|
|
173
|
+
// Applied after camelCase/space normalization, so keys here are already lowercase+underscored.
|
|
174
|
+
const METRIC_TYPE_ALIASES = {
|
|
175
|
+
// heart rate variants
|
|
176
|
+
heartbeat: 'heart_rate',
|
|
177
|
+
heartrate: 'heart_rate',
|
|
178
|
+
heart_beat: 'heart_rate',
|
|
179
|
+
bpm: 'heart_rate',
|
|
180
|
+
pulse: 'heart_rate',
|
|
181
|
+
// sleep variants
|
|
182
|
+
sleep: 'sleep_session',
|
|
183
|
+
sleeping: 'sleep_session',
|
|
184
|
+
// exercise variants
|
|
185
|
+
exercise: 'exercise_session',
|
|
186
|
+
workout: 'exercise_session',
|
|
187
|
+
activity: 'exercise_session',
|
|
188
|
+
// weight variants
|
|
189
|
+
body_weight: 'weight',
|
|
190
|
+
bodyweight: 'weight',
|
|
191
|
+
mass: 'weight',
|
|
192
|
+
// steps variants
|
|
193
|
+
step_count: 'steps',
|
|
194
|
+
stepcount: 'steps',
|
|
195
|
+
step: 'steps',
|
|
196
|
+
};
|
|
197
|
+
|
|
172
198
|
function normalizeMetricType(raw) {
|
|
173
199
|
// Accept any casing/spacing: "HeartRate" → "heart_rate", "Steps" → "steps", etc.
|
|
174
|
-
|
|
200
|
+
const normalized = String(raw || '')
|
|
175
201
|
.trim()
|
|
176
202
|
.replace(/([a-z])([A-Z])/g, '$1_$2') // camelCase/PascalCase → snake_case
|
|
177
203
|
.replace(/[\s-]+/g, '_') // spaces/dashes → underscore
|
|
178
204
|
.toLowerCase();
|
|
205
|
+
// Resolve known synonyms to canonical stored values
|
|
206
|
+
return METRIC_TYPE_ALIASES[normalized] || normalized;
|
|
179
207
|
}
|
|
180
208
|
|
|
181
209
|
function readHealthData(userId, metricType, limit = 50) {
|