neoagent 1.4.3 → 1.4.4

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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "neoagent",
3
- "version": "1.4.3",
3
+ "version": "1.4.4",
4
4
  "description": "Proactive personal AI agent with no limits",
5
5
  "license": "MIT",
6
6
  "main": "server/index.js",
@@ -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: packageJson.version,
179
+ version,
180
+ packageVersion: packageJson.version,
173
181
  gitSha
174
182
  });
175
183
  });
@@ -1823,7 +1823,7 @@ $("#settingsBtn").addEventListener("click", async () => {
1823
1823
 
1824
1824
  try {
1825
1825
  const backendVersion = await api("/version");
1826
- backendVersionLabel = `${backendVersion?.version || "unknown"}${backendVersion?.gitSha ? ` (${backendVersion.gitSha})` : ""}`;
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;
@@ -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
+ };