neoagent 1.4.3 → 1.4.5
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/lib/manager.js +6 -0
- package/package.json +1 -1
- package/server/db/database.js +41 -0
- package/server/index.js +9 -1
- package/server/public/js/app.js +1 -1
- package/server/routes/mobile-health.js +28 -0
- package/server/services/ai/providers/google.js +27 -1
- package/server/services/health/ingestion.js +175 -0
package/lib/manager.js
CHANGED
|
@@ -438,6 +438,12 @@ async function cmdStatus() {
|
|
|
438
438
|
logWarn(`not reachable on port ${port}`);
|
|
439
439
|
}
|
|
440
440
|
|
|
441
|
+
const gitVersion = runQuiet('git', ['describe', '--tags', '--always', '--dirty']);
|
|
442
|
+
if (gitVersion.status === 0) {
|
|
443
|
+
console.log(` version ${gitVersion.stdout.trim().replace(/^v/, '')}`);
|
|
444
|
+
return;
|
|
445
|
+
}
|
|
446
|
+
|
|
441
447
|
const gitSha = runQuiet('git', ['rev-parse', '--short', 'HEAD']);
|
|
442
448
|
if (gitSha.status === 0) {
|
|
443
449
|
console.log(` version ${gitSha.stdout.trim()}`);
|
package/package.json
CHANGED
package/server/db/database.js
CHANGED
|
@@ -228,6 +228,47 @@ db.exec(`
|
|
|
228
228
|
CREATE INDEX IF NOT EXISTS idx_protocols_user ON protocols(user_id);
|
|
229
229
|
|
|
230
230
|
CREATE INDEX IF NOT EXISTS idx_core_memory_user ON core_memory(user_id, key);
|
|
231
|
+
|
|
232
|
+
CREATE TABLE IF NOT EXISTS health_sync_runs (
|
|
233
|
+
id TEXT PRIMARY KEY,
|
|
234
|
+
user_id INTEGER NOT NULL,
|
|
235
|
+
source TEXT NOT NULL,
|
|
236
|
+
provider TEXT,
|
|
237
|
+
sync_window_start TEXT,
|
|
238
|
+
sync_window_end TEXT,
|
|
239
|
+
record_count INTEGER DEFAULT 0,
|
|
240
|
+
summary_json TEXT,
|
|
241
|
+
payload_json TEXT,
|
|
242
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
243
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
CREATE TABLE IF NOT EXISTS health_metric_samples (
|
|
247
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
248
|
+
user_id INTEGER NOT NULL,
|
|
249
|
+
sync_run_id TEXT NOT NULL,
|
|
250
|
+
metric_type TEXT NOT NULL,
|
|
251
|
+
record_id TEXT NOT NULL,
|
|
252
|
+
start_time TEXT,
|
|
253
|
+
end_time TEXT,
|
|
254
|
+
recorded_at TEXT,
|
|
255
|
+
numeric_value REAL,
|
|
256
|
+
text_value TEXT,
|
|
257
|
+
unit TEXT,
|
|
258
|
+
source_app_id TEXT,
|
|
259
|
+
source_device TEXT,
|
|
260
|
+
last_modified_time TEXT,
|
|
261
|
+
payload_json TEXT,
|
|
262
|
+
created_at TEXT DEFAULT (datetime('now')),
|
|
263
|
+
updated_at TEXT DEFAULT (datetime('now')),
|
|
264
|
+
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
|
265
|
+
FOREIGN KEY (sync_run_id) REFERENCES health_sync_runs(id) ON DELETE CASCADE,
|
|
266
|
+
UNIQUE(user_id, metric_type, record_id)
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
CREATE INDEX IF NOT EXISTS idx_health_sync_runs_user ON health_sync_runs(user_id, created_at DESC);
|
|
270
|
+
CREATE INDEX IF NOT EXISTS idx_health_metric_samples_user ON health_metric_samples(user_id, metric_type, updated_at DESC);
|
|
271
|
+
CREATE INDEX IF NOT EXISTS idx_health_metric_samples_time ON health_metric_samples(user_id, start_time DESC, end_time DESC);
|
|
231
272
|
`);
|
|
232
273
|
|
|
233
274
|
try {
|
package/server/index.js
CHANGED
|
@@ -107,6 +107,7 @@ app.use('/api/store', require('./routes/store'));
|
|
|
107
107
|
app.use('/api/memory', require('./routes/memory'));
|
|
108
108
|
app.use('/api/scheduler', require('./routes/scheduler'));
|
|
109
109
|
app.use('/api/browser', require('./routes/browser'));
|
|
110
|
+
app.use('/api/mobile/health', require('./routes/mobile-health'));
|
|
110
111
|
|
|
111
112
|
// ── Telnyx voice webhook ──
|
|
112
113
|
setupTelnyxWebhook(app);
|
|
@@ -155,9 +156,15 @@ app.get('/api/health', requireAuth, (req, res) => {
|
|
|
155
156
|
});
|
|
156
157
|
|
|
157
158
|
app.get('/api/version', requireAuth, (req, res) => {
|
|
159
|
+
let version = packageJson.version;
|
|
158
160
|
let gitSha = null;
|
|
159
161
|
try {
|
|
160
162
|
const { execSync } = require('child_process');
|
|
163
|
+
version = execSync('git describe --tags --always --dirty', {
|
|
164
|
+
cwd: APP_DIR,
|
|
165
|
+
encoding: 'utf8',
|
|
166
|
+
stdio: ['ignore', 'pipe', 'ignore']
|
|
167
|
+
}).trim().replace(/^v/, '') || packageJson.version;
|
|
161
168
|
gitSha = execSync('git rev-parse --short HEAD', {
|
|
162
169
|
cwd: APP_DIR,
|
|
163
170
|
encoding: 'utf8',
|
|
@@ -169,7 +176,8 @@ app.get('/api/version', requireAuth, (req, res) => {
|
|
|
169
176
|
|
|
170
177
|
res.json({
|
|
171
178
|
name: packageJson.name,
|
|
172
|
-
version
|
|
179
|
+
version,
|
|
180
|
+
packageVersion: packageJson.version,
|
|
173
181
|
gitSha
|
|
174
182
|
});
|
|
175
183
|
});
|
package/server/public/js/app.js
CHANGED
|
@@ -1823,7 +1823,7 @@ $("#settingsBtn").addEventListener("click", async () => {
|
|
|
1823
1823
|
|
|
1824
1824
|
try {
|
|
1825
1825
|
const backendVersion = await api("/version");
|
|
1826
|
-
backendVersionLabel =
|
|
1826
|
+
backendVersionLabel = backendVersion?.version || "unknown";
|
|
1827
1827
|
const vEl = $("#settingsAppVersion");
|
|
1828
1828
|
if (vEl && backendVersionLabel !== "unknown") {
|
|
1829
1829
|
vEl.textContent = `v${backendVersionLabel}`;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const express = require('express');
|
|
2
|
+
const router = express.Router();
|
|
3
|
+
const { requireAuth } = require('../middleware/auth');
|
|
4
|
+
const { sanitizeError } = require('../utils/security');
|
|
5
|
+
const { getHealthSyncStatus, ingestHealthSync } = require('../services/health/ingestion');
|
|
6
|
+
|
|
7
|
+
router.use(requireAuth);
|
|
8
|
+
|
|
9
|
+
router.get('/status', (req, res) => {
|
|
10
|
+
try {
|
|
11
|
+
res.json(getHealthSyncStatus(req.session.userId));
|
|
12
|
+
} catch (err) {
|
|
13
|
+
res.status(500).json({ error: sanitizeError(err) });
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
router.post('/sync', (req, res) => {
|
|
18
|
+
try {
|
|
19
|
+
const result = ingestHealthSync(req.session.userId, req.body);
|
|
20
|
+
res.status(201).json({ success: true, ...result });
|
|
21
|
+
} catch (err) {
|
|
22
|
+
const message = sanitizeError(err);
|
|
23
|
+
const status = /payload|Missing user/i.test(message) ? 400 : 500;
|
|
24
|
+
res.status(status).json({ error: message });
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
module.exports = router;
|
|
@@ -81,7 +81,33 @@ class GoogleProvider extends BaseProvider {
|
|
|
81
81
|
});
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
const normalizedHistory = [];
|
|
85
|
+
let currentRole = null;
|
|
86
|
+
let currentParts = [];
|
|
87
|
+
|
|
88
|
+
for (const msg of history) {
|
|
89
|
+
if (msg.role === currentRole) {
|
|
90
|
+
currentParts.push(...msg.parts);
|
|
91
|
+
} else {
|
|
92
|
+
if (currentRole) {
|
|
93
|
+
normalizedHistory.push({ role: currentRole, parts: currentParts });
|
|
94
|
+
}
|
|
95
|
+
currentRole = msg.role;
|
|
96
|
+
currentParts = [...msg.parts];
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (currentRole) {
|
|
100
|
+
normalizedHistory.push({ role: currentRole, parts: currentParts });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (normalizedHistory.length > 0 && normalizedHistory[0].role !== 'user') {
|
|
104
|
+
normalizedHistory.unshift({
|
|
105
|
+
role: 'user',
|
|
106
|
+
parts: [{ text: '[Conversation Resumed]' }]
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { systemInstruction, history: normalizedHistory };
|
|
85
111
|
}
|
|
86
112
|
|
|
87
113
|
async chat(messages, tools = [], options = {}) {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const crypto = require('crypto');
|
|
2
|
+
const db = require('../../db/database');
|
|
3
|
+
const { v4: uuidv4 } = require('uuid');
|
|
4
|
+
|
|
5
|
+
function parseIsoOrNull(value) {
|
|
6
|
+
if (!value) return null;
|
|
7
|
+
const date = new Date(value);
|
|
8
|
+
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function asJson(value) {
|
|
12
|
+
return JSON.stringify(value == null ? null : value);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function asText(value) {
|
|
16
|
+
if (value == null) return null;
|
|
17
|
+
const text = String(value).trim();
|
|
18
|
+
return text.length ? text : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function buildFallbackRecordId(metricType, record) {
|
|
22
|
+
const hash = crypto.createHash('sha1');
|
|
23
|
+
hash.update(metricType || '');
|
|
24
|
+
hash.update('\n');
|
|
25
|
+
hash.update(asJson(record));
|
|
26
|
+
return hash.digest('hex');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeRecord(record = {}) {
|
|
30
|
+
const metricType = asText(record.metricType || record.type);
|
|
31
|
+
if (!metricType) return null;
|
|
32
|
+
|
|
33
|
+
const payload = record.payload && typeof record.payload === 'object'
|
|
34
|
+
? record.payload
|
|
35
|
+
: {};
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
metricType,
|
|
39
|
+
recordId: asText(record.recordId || payload.recordId) || buildFallbackRecordId(metricType, record),
|
|
40
|
+
startTime: parseIsoOrNull(record.startTime || payload.startTime),
|
|
41
|
+
endTime: parseIsoOrNull(record.endTime || payload.endTime),
|
|
42
|
+
recordedAt: parseIsoOrNull(record.recordedAt || payload.recordedAt || record.endTime || record.startTime),
|
|
43
|
+
numericValue: Number.isFinite(Number(record.numericValue)) ? Number(record.numericValue) : null,
|
|
44
|
+
textValue: asText(record.textValue),
|
|
45
|
+
unit: asText(record.unit),
|
|
46
|
+
sourceAppId: asText(record.sourceAppId || payload.sourceAppId),
|
|
47
|
+
sourceDevice: asText(record.sourceDevice || payload.sourceDevice),
|
|
48
|
+
lastModifiedTime: parseIsoOrNull(record.lastModifiedTime || payload.lastModifiedTime),
|
|
49
|
+
payloadJson: asJson(payload),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const insertRun = db.prepare(`
|
|
54
|
+
INSERT INTO health_sync_runs (
|
|
55
|
+
id, user_id, source, provider, sync_window_start, sync_window_end,
|
|
56
|
+
record_count, summary_json, payload_json
|
|
57
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
58
|
+
`);
|
|
59
|
+
|
|
60
|
+
const upsertSample = db.prepare(`
|
|
61
|
+
INSERT INTO health_metric_samples (
|
|
62
|
+
user_id, sync_run_id, metric_type, record_id, start_time, end_time, recorded_at,
|
|
63
|
+
numeric_value, text_value, unit, source_app_id, source_device, last_modified_time, payload_json
|
|
64
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
65
|
+
ON CONFLICT(user_id, metric_type, record_id) DO UPDATE SET
|
|
66
|
+
sync_run_id = excluded.sync_run_id,
|
|
67
|
+
start_time = excluded.start_time,
|
|
68
|
+
end_time = excluded.end_time,
|
|
69
|
+
recorded_at = excluded.recorded_at,
|
|
70
|
+
numeric_value = excluded.numeric_value,
|
|
71
|
+
text_value = excluded.text_value,
|
|
72
|
+
unit = excluded.unit,
|
|
73
|
+
source_app_id = excluded.source_app_id,
|
|
74
|
+
source_device = excluded.source_device,
|
|
75
|
+
last_modified_time = excluded.last_modified_time,
|
|
76
|
+
payload_json = excluded.payload_json,
|
|
77
|
+
updated_at = datetime('now')
|
|
78
|
+
`);
|
|
79
|
+
|
|
80
|
+
const ingestHealthSyncTx = db.transaction((userId, body) => {
|
|
81
|
+
const runId = uuidv4();
|
|
82
|
+
const source = asText(body.source) || 'android-health-connect';
|
|
83
|
+
const provider = asText(body.provider);
|
|
84
|
+
const windowStart = parseIsoOrNull(body.windowStart);
|
|
85
|
+
const windowEnd = parseIsoOrNull(body.windowEnd);
|
|
86
|
+
const records = Array.isArray(body.records)
|
|
87
|
+
? body.records.map(normalizeRecord).filter(Boolean)
|
|
88
|
+
: [];
|
|
89
|
+
|
|
90
|
+
insertRun.run(
|
|
91
|
+
runId,
|
|
92
|
+
userId,
|
|
93
|
+
source,
|
|
94
|
+
provider,
|
|
95
|
+
windowStart,
|
|
96
|
+
windowEnd,
|
|
97
|
+
records.length,
|
|
98
|
+
asJson(body.summary || {}),
|
|
99
|
+
asJson(body),
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
for (const record of records) {
|
|
103
|
+
upsertSample.run(
|
|
104
|
+
userId,
|
|
105
|
+
runId,
|
|
106
|
+
record.metricType,
|
|
107
|
+
record.recordId,
|
|
108
|
+
record.startTime,
|
|
109
|
+
record.endTime,
|
|
110
|
+
record.recordedAt,
|
|
111
|
+
record.numericValue,
|
|
112
|
+
record.textValue,
|
|
113
|
+
record.unit,
|
|
114
|
+
record.sourceAppId,
|
|
115
|
+
record.sourceDevice,
|
|
116
|
+
record.lastModifiedTime,
|
|
117
|
+
record.payloadJson,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
runId,
|
|
123
|
+
source,
|
|
124
|
+
provider,
|
|
125
|
+
windowStart,
|
|
126
|
+
windowEnd,
|
|
127
|
+
recordCount: records.length,
|
|
128
|
+
acceptedMetrics: [...new Set(records.map((record) => record.metricType))],
|
|
129
|
+
};
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function ingestHealthSync(userId, body = {}) {
|
|
133
|
+
if (!userId) throw new Error('Missing user');
|
|
134
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
135
|
+
throw new Error('Health sync payload must be a JSON object');
|
|
136
|
+
}
|
|
137
|
+
return ingestHealthSyncTx(userId, body);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function getHealthSyncStatus(userId) {
|
|
141
|
+
const lastRun = db.prepare(`
|
|
142
|
+
SELECT id, source, provider, sync_window_start, sync_window_end, record_count, summary_json, created_at
|
|
143
|
+
FROM health_sync_runs
|
|
144
|
+
WHERE user_id = ?
|
|
145
|
+
ORDER BY created_at DESC
|
|
146
|
+
LIMIT 1
|
|
147
|
+
`).get(userId);
|
|
148
|
+
|
|
149
|
+
const metrics = db.prepare(`
|
|
150
|
+
SELECT metric_type, COUNT(*) AS sample_count, MAX(COALESCE(end_time, recorded_at, start_time)) AS last_seen_at
|
|
151
|
+
FROM health_metric_samples
|
|
152
|
+
WHERE user_id = ?
|
|
153
|
+
GROUP BY metric_type
|
|
154
|
+
ORDER BY metric_type ASC
|
|
155
|
+
`).all(userId);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
lastRun: lastRun ? {
|
|
159
|
+
...lastRun,
|
|
160
|
+
summary: (() => {
|
|
161
|
+
try { return JSON.parse(lastRun.summary_json || '{}'); } catch { return {}; }
|
|
162
|
+
})(),
|
|
163
|
+
} : null,
|
|
164
|
+
metrics: metrics.map((metric) => ({
|
|
165
|
+
metricType: metric.metric_type,
|
|
166
|
+
sampleCount: Number(metric.sample_count || 0),
|
|
167
|
+
lastSeenAt: metric.last_seen_at || null,
|
|
168
|
+
})),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
getHealthSyncStatus,
|
|
174
|
+
ingestHealthSync,
|
|
175
|
+
};
|