neoagent 2.4.2-beta.4 → 2.4.2-beta.6

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.
Files changed (62) hide show
  1. package/.env.example +0 -9
  2. package/README.md +48 -18
  3. package/docs/configuration.md +0 -10
  4. package/flutter_app/lib/main.dart +0 -1
  5. package/flutter_app/lib/main_controller.dart +0 -160
  6. package/flutter_app/lib/main_models.dart +28 -0
  7. package/flutter_app/lib/main_operations.dart +17 -1
  8. package/flutter_app/lib/main_runtime.dart +0 -22
  9. package/flutter_app/lib/main_settings.dart +9 -0
  10. package/flutter_app/lib/main_shared.dart +0 -161
  11. package/flutter_app/lib/src/backend_client.dart +8 -0
  12. package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +0 -2
  13. package/flutter_app/pubspec.lock +0 -8
  14. package/flutter_app/pubspec.yaml +0 -1
  15. package/package.json +1 -1
  16. package/server/admin/admin.css +23 -0
  17. package/server/admin/admin.js +17 -19
  18. package/server/admin/analytics.js +254 -48
  19. package/server/admin/index.html +9 -2
  20. package/server/db/database.js +112 -0
  21. package/server/http/routes.js +1 -0
  22. package/server/public/.last_build_id +1 -1
  23. package/server/public/assets/AssetManifest.bin +1 -1
  24. package/server/public/assets/AssetManifest.bin.json +1 -1
  25. package/server/public/assets/NOTICES +0 -183
  26. package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
  27. package/server/public/flutter_bootstrap.js +2 -2
  28. package/server/public/main.dart.js +77723 -78469
  29. package/server/routes/admin.js +73 -24
  30. package/server/routes/mcp.js +9 -0
  31. package/server/routes/memory.js +35 -2
  32. package/server/routes/runtime.js +5 -2
  33. package/server/routes/settings.js +34 -1
  34. package/server/routes/skills.js +42 -0
  35. package/server/routes/task_webhooks.js +65 -0
  36. package/server/services/ai/engine.js +525 -24
  37. package/server/services/ai/learning.js +56 -8
  38. package/server/services/ai/models.js +4 -4
  39. package/server/services/ai/providers/anthropic.js +40 -8
  40. package/server/services/ai/providers/google.js +10 -0
  41. package/server/services/ai/providers/openai.js +3 -15
  42. package/server/services/ai/providers/openaiCompatible.js +11 -0
  43. package/server/services/ai/repetitionGuard.js +60 -0
  44. package/server/services/ai/systemPrompt.js +52 -16
  45. package/server/services/ai/taskAnalysis.js +15 -6
  46. package/server/services/ai/toolEvidence.js +3 -1
  47. package/server/services/ai/toolRunner.js +43 -1
  48. package/server/services/ai/toolSelector.js +92 -46
  49. package/server/services/ai/tools.js +89 -9
  50. package/server/services/ai/usage.js +114 -0
  51. package/server/services/manager.js +34 -0
  52. package/server/services/memory/manager.js +170 -4
  53. package/server/services/security/capability_audit.js +107 -0
  54. package/server/services/tasks/adapters/index.js +1 -0
  55. package/server/services/tasks/adapters/webhook.js +14 -0
  56. package/server/services/tasks/runtime.js +1 -1
  57. package/server/services/tasks/webhooks.js +146 -0
  58. package/server/services/workspace/code_navigation.js +194 -0
  59. package/server/services/workspace/structured_data.js +93 -0
  60. package/server/utils/cloud-security.js +24 -2
  61. package/flutter_app/lib/src/analytics_service.dart +0 -294
  62. package/server/config/analytics.js +0 -30
@@ -2,12 +2,21 @@
2
2
 
3
3
  // ── Analytics ─────────────────────────────────────────────────────────────
4
4
 
5
+ let _analyticsRange = 30;
6
+
7
+ function setAnalyticsRange(days, btn) {
8
+ _analyticsRange = days;
9
+ document.querySelectorAll('.range-btn').forEach((b) => b.classList.remove('active'));
10
+ if (btn) btn.classList.add('active');
11
+ loadAnalytics();
12
+ }
13
+
5
14
  async function loadAnalytics() {
6
15
  const el = document.getElementById('analytics-content');
7
16
  if (!el) return;
8
17
  el.innerHTML = '<div class="empty"><span class="spinner"></span></div>';
9
18
  try {
10
- const data = await api('/admin/api/analytics').then((r) => r.json());
19
+ const data = await api(`/admin/api/analytics?range=${_analyticsRange}`).then((r) => r.json());
11
20
  renderAnalytics(el, data);
12
21
  setTs('analytics-ts', 'Updated');
13
22
  } catch (err) {
@@ -15,89 +24,285 @@ async function loadAnalytics() {
15
24
  }
16
25
  }
17
26
 
18
- function renderAnalytics(el, { stats, topUsers, recentRuns }) {
27
+ function renderAnalytics(el, { stats, runsByDay, usersByDay, modelBreakdown, statusBreakdown, topUsers, recentRuns }) {
19
28
  const s = stats || {};
20
29
  el.innerHTML = `
21
- <div class="stat-grid">
22
- <div class="stat-card">
23
- <div class="stat-value">${fmtNum(s.totalUsers)}</div>
24
- <div class="stat-label">Total Users</div>
25
- </div>
26
- <div class="stat-card">
27
- <div class="stat-value">${fmtNum(s.activeToday)}</div>
28
- <div class="stat-label">Active Today</div>
29
- </div>
30
- <div class="stat-card">
31
- <div class="stat-value">${fmtNum(s.newThisWeek)}</div>
32
- <div class="stat-label">New This Week</div>
33
- </div>
34
- <div class="stat-card">
35
- <div class="stat-value">${fmtNum(s.totalRuns)}</div>
36
- <div class="stat-label">Total Runs</div>
30
+ ${renderStatGrid(s)}
31
+ ${renderCharts(runsByDay || [], usersByDay || [], _analyticsRange)}
32
+ ${renderBreakdowns(modelBreakdown || [], statusBreakdown || [])}
33
+ ${renderTopUsers(topUsers || [])}
34
+ ${renderRecentRuns(recentRuns || [])}
35
+ `;
36
+ }
37
+
38
+ // ── Stat cards ─────────────────────────────────────────────────────────────
39
+
40
+ function renderStatGrid(s) {
41
+ const cards = [
42
+ { label: 'Total Users', value: fmtNum(s.totalUsers), sub: null },
43
+ { label: 'Active Today', value: fmtNum(s.activeToday), sub: null },
44
+ { label: 'New This Week', value: fmtNum(s.newThisWeek), sub: null },
45
+ { label: 'Active Sessions', value: fmtNum(s.activeSessions), sub: null },
46
+ { label: 'Total Runs', value: fmtNum(s.totalRuns), sub: null },
47
+ { label: 'Runs Today', value: fmtNum(s.runsToday), sub: null },
48
+ { label: 'Runs This Week', value: fmtNum(s.runsThisWeek), sub: null },
49
+ { label: 'Success Rate', value: (s.successRate ?? '—') + (s.successRate != null ? '%' : ''), sub: null },
50
+ { label: 'Total Tokens', value: fmtTokens(s.totalTokens), sub: null },
51
+ { label: 'Tokens Today', value: fmtTokens(s.tokensToday), sub: null },
52
+ { label: 'Avg Tokens/Run', value: fmtTokens(s.avgTokensPerRun), sub: null },
53
+ { label: 'Artifact Storage',value: fmtBytes(s.totalStorage), sub: null },
54
+ ];
55
+ return `<div class="stat-grid">${cards.map((c) => `
56
+ <div class="stat-card">
57
+ <div class="stat-value">${c.value}</div>
58
+ <div class="stat-label">${c.label}</div>
59
+ </div>`).join('')}
60
+ </div>`;
61
+ }
62
+
63
+ // ── SVG Charts ─────────────────────────────────────────────────────────────
64
+
65
+ function renderCharts(runsByDay, usersByDay, range) {
66
+ return `
67
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:20px;">
68
+ <div class="card">
69
+ <div class="card-title" style="margin-bottom:12px;">Runs per Day</div>
70
+ ${buildBarChart(fillDates(runsByDay, range, 'runs'), 'runs', 'tokens')}
37
71
  </div>
38
- <div class="stat-card">
39
- <div class="stat-value">${fmtNum(s.runsToday)}</div>
40
- <div class="stat-label">Runs Today</div>
72
+ <div class="card">
73
+ <div class="card-title" style="margin-bottom:12px;">Tokens per Day</div>
74
+ ${buildAreaChart(fillDates(runsByDay, range, 'tokens'), 'tokens')}
41
75
  </div>
42
- <div class="stat-card">
43
- <div class="stat-value">${fmtTokens(s.totalTokens)}</div>
44
- <div class="stat-label">Total Tokens</div>
76
+ </div>`;
77
+ }
78
+
79
+ function fillDates(data, range, _key) {
80
+ const map = new Map((data || []).map((d) => [d.date, d]));
81
+ const out = [];
82
+ for (let i = range - 1; i >= 0; i--) {
83
+ const d = new Date(Date.now() - i * 86_400_000);
84
+ const key = d.toISOString().slice(0, 10);
85
+ out.push(map.get(key) || { date: key, runs: 0, tokens: 0 });
86
+ }
87
+ return out;
88
+ }
89
+
90
+ function buildBarChart(data, key, altKey) {
91
+ const W = 560, H = 110, pad = { top: 8, bottom: 22, left: 4, right: 4 };
92
+ const vals = data.map((d) => d[key] || 0);
93
+ const max = Math.max(...vals, 1);
94
+ const n = data.length;
95
+ const gap = 2;
96
+ const barW = Math.max(1, Math.floor((W - pad.left - pad.right - (n - 1) * gap) / n));
97
+ const innerH = H - pad.top - pad.bottom;
98
+
99
+ const accentColor = 'var(--accent)';
100
+ const mutedColor = 'rgba(126,210,126,0.18)';
101
+
102
+ const bars = data.map((d, i) => {
103
+ const v = d[key] || 0;
104
+ const bh = v === 0 ? 1 : Math.max(2, Math.round((v / max) * innerH));
105
+ const x = pad.left + i * (barW + gap);
106
+ const y = pad.top + innerH - bh;
107
+ const altV = altKey ? (d[altKey] || 0) : 0;
108
+ const label = altKey ? `${d.date}\n${key}: ${fmtNum(v)}\n${altKey}: ${fmtTokens(altV)}` : `${d.date}: ${fmtNum(v)}`;
109
+ return `<rect x="${x}" y="${y}" width="${barW}" height="${bh}"
110
+ fill="${v > 0 ? accentColor : mutedColor}" rx="1">
111
+ <title>${esc(label)}</title></rect>`;
112
+ });
113
+
114
+ // X-axis date labels — only first, middle, last
115
+ const labelIdxs = [0, Math.floor(n / 2), n - 1];
116
+ const labels = labelIdxs.map((i) => {
117
+ const x = pad.left + i * (barW + gap) + barW / 2;
118
+ const y = H - 4;
119
+ return `<text x="${x}" y="${y}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${esc(data[i]?.date?.slice(5) || '')}</text>`;
120
+ });
121
+
122
+ return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;">${bars.join('')}${labels.join('')}</svg>`;
123
+ }
124
+
125
+ function buildAreaChart(data, key) {
126
+ const W = 560, H = 110, pad = { top: 8, bottom: 22, left: 4, right: 4 };
127
+ const vals = data.map((d) => d[key] || 0);
128
+ const max = Math.max(...vals, 1);
129
+ const n = data.length;
130
+ const innerW = W - pad.left - pad.right;
131
+ const innerH = H - pad.top - pad.bottom;
132
+
133
+ const pts = data.map((d, i) => {
134
+ const x = pad.left + (n <= 1 ? innerW / 2 : (i / (n - 1)) * innerW);
135
+ const v = d[key] || 0;
136
+ const y = pad.top + innerH - Math.max(0, (v / max) * innerH);
137
+ return [x, y, d.date, v];
138
+ });
139
+
140
+ if (!pts.length) return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;"></svg>`;
141
+
142
+ const linePts = pts.map(([x, y]) => `${x},${y}`).join(' ');
143
+ const bottomY = pad.top + innerH;
144
+ const areaPath = `M ${pts[0][0]},${bottomY} L ${pts.map(([x, y]) => `${x},${y}`).join(' L ')} L ${pts[pts.length - 1][0]},${bottomY} Z`;
145
+
146
+ const dots = pts.map(([x, y, date, v]) =>
147
+ `<circle cx="${x}" cy="${y}" r="2.5" fill="var(--accent)" opacity="0.85">
148
+ <title>${esc(date)}: ${fmtTokens(v)}</title>
149
+ </circle>`);
150
+
151
+ const labelIdxs = [0, Math.floor(n / 2), n - 1];
152
+ const labels = labelIdxs.map((i) => {
153
+ const [x, , date] = pts[i] || [];
154
+ if (x == null) return '';
155
+ return `<text x="${x}" y="${H - 4}" text-anchor="middle" font-size="9" fill="var(--text-muted)">${esc((date || '').slice(5))}</text>`;
156
+ });
157
+
158
+ return `<svg viewBox="0 0 ${W} ${H}" style="width:100%;height:${H}px;display:block;">
159
+ <defs>
160
+ <linearGradient id="areaGrad" x1="0" y1="0" x2="0" y2="1">
161
+ <stop offset="0%" stop-color="var(--accent)" stop-opacity="0.28"/>
162
+ <stop offset="100%" stop-color="var(--accent)" stop-opacity="0.03"/>
163
+ </linearGradient>
164
+ </defs>
165
+ <path d="${areaPath}" fill="url(#areaGrad)"/>
166
+ <polyline points="${linePts}" fill="none" stroke="var(--accent)" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/>
167
+ ${dots.join('')}
168
+ ${labels.join('')}
169
+ </svg>`;
170
+ }
171
+
172
+ // ── Breakdowns ─────────────────────────────────────────────────────────────
173
+
174
+ function renderBreakdowns(modelBreakdown, statusBreakdown) {
175
+ return `
176
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px;">
177
+ ${renderModelBreakdown(modelBreakdown)}
178
+ ${renderStatusBreakdown(statusBreakdown)}
179
+ </div>`;
180
+ }
181
+
182
+ function renderModelBreakdown(models) {
183
+ if (!models.length) return '<div class="card"><div class="card-title">Model Usage</div><div class="empty" style="padding:20px 0;">No data</div></div>';
184
+ const maxRuns = Math.max(...models.map((m) => m.runs), 1);
185
+ return `
186
+ <div class="card">
187
+ <div class="card-title" style="margin-bottom:12px;">Model Usage (selected period)</div>
188
+ <table class="analytics-table">
189
+ <thead><tr><th>Model</th><th style="text-align:right;">Runs</th><th style="text-align:right;">Tokens</th><th style="width:80px;"></th></tr></thead>
190
+ <tbody>${models.map((m) => `
191
+ <tr>
192
+ <td style="font-family:var(--font-mono);font-size:11px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${esc(m.model)}</td>
193
+ <td style="text-align:right;">${fmtNum(m.runs)}</td>
194
+ <td style="text-align:right;">${fmtTokens(m.tokens)}</td>
195
+ <td><div class="mini-bar"><div class="mini-bar-fill" style="width:${Math.round((m.runs / maxRuns) * 100)}%"></div></div></td>
196
+ </tr>`).join('')}
197
+ </tbody>
198
+ </table>
199
+ </div>`;
200
+ }
201
+
202
+ const STATUS_COLORS = {
203
+ completed: 'var(--success)',
204
+ error: 'var(--danger)',
205
+ running: 'var(--accent)',
206
+ pending: 'var(--text-muted)',
207
+ };
208
+
209
+ function renderStatusBreakdown(statuses) {
210
+ if (!statuses.length) return '<div class="card"><div class="card-title">Run Status</div><div class="empty" style="padding:20px 0;">No data</div></div>';
211
+ const total = statuses.reduce((a, s) => a + s.count, 0);
212
+ const pills = statuses.map((s) => {
213
+ const pct = total > 0 ? Math.round((s.count / total) * 100) : 0;
214
+ const color = STATUS_COLORS[s.status] || 'var(--text-muted)';
215
+ return `<div style="display:flex;align-items:center;justify-content:space-between;padding:10px 0;border-bottom:1px solid var(--border);">
216
+ <div style="display:flex;align-items:center;gap:10px;">
217
+ <div style="width:10px;height:10px;border-radius:50%;background:${color};flex-shrink:0;"></div>
218
+ <span style="font-size:13px;text-transform:capitalize;">${esc(s.status)}</span>
45
219
  </div>
46
- <div class="stat-card">
47
- <div class="stat-value">${fmtNum(s.activeSessions)}</div>
48
- <div class="stat-label">Active Sessions</div>
220
+ <div style="display:flex;align-items:center;gap:12px;">
221
+ <span style="font-family:var(--font-mono);font-size:13px;font-weight:600;">${fmtNum(s.count)}</span>
222
+ <span style="font-size:11px;color:var(--text-muted);width:34px;text-align:right;">${pct}%</span>
49
223
  </div>
50
- <div class="stat-card">
51
- <div class="stat-value">${fmtBytes(s.totalStorage)}</div>
52
- <div class="stat-label">Artifact Storage</div>
224
+ </div>`;
225
+ });
226
+
227
+ // Mini donut from status percentages using SVG
228
+ const donut = buildDonut(statuses, total);
229
+
230
+ return `
231
+ <div class="card">
232
+ <div class="card-title" style="margin-bottom:12px;">Run Status (all time)</div>
233
+ <div style="display:flex;gap:20px;align-items:flex-start;">
234
+ <div style="flex:1;">${pills.join('')}</div>
235
+ <div style="flex-shrink:0;margin-top:4px;">${donut}</div>
53
236
  </div>
54
- </div>
237
+ </div>`;
238
+ }
55
239
 
56
- ${renderTopUsers(topUsers || [])}
57
- ${renderRecentRuns(recentRuns || [])}
58
- `;
240
+ function buildDonut(statuses, total) {
241
+ const R = 36, cx = 44, cy = 44, strokeW = 12;
242
+ const circum = 2 * Math.PI * R;
243
+ let offset = 0;
244
+ const slices = statuses.map((s) => {
245
+ const pct = total > 0 ? s.count / total : 0;
246
+ const dash = pct * circum;
247
+ const gap = circum - dash;
248
+ const color = STATUS_COLORS[s.status] || 'var(--text-muted)';
249
+ const slice = `<circle cx="${cx}" cy="${cy}" r="${R}" fill="none"
250
+ stroke="${color}" stroke-width="${strokeW}"
251
+ stroke-dasharray="${dash} ${gap}"
252
+ stroke-dashoffset="${-offset}"
253
+ transform="rotate(-90 ${cx} ${cy})"/>`;
254
+ offset += dash;
255
+ return slice;
256
+ });
257
+ return `<svg width="88" height="88" viewBox="0 0 88 88">
258
+ <circle cx="${cx}" cy="${cy}" r="${R}" fill="none" stroke="var(--border)" stroke-width="${strokeW}"/>
259
+ ${slices.join('')}
260
+ </svg>`;
59
261
  }
60
262
 
263
+ // ── Top Users ───────────────────────────────────────────────────────────────
264
+
61
265
  function renderTopUsers(users) {
62
266
  if (!users.length) return '';
63
- const maxRuns = Math.max(...users.map((u) => u.runs), 1);
267
+ const maxTokens = Math.max(...users.map((u) => u.tokens), 1);
64
268
  return `
65
- <div class="card" style="margin-top:20px;">
66
- <div class="card-title">Top Users by Runs</div>
269
+ <div class="card" style="margin-top:16px;">
270
+ <div class="card-title" style="margin-bottom:12px;">Top Users by Token Usage</div>
67
271
  <table class="analytics-table">
68
272
  <thead><tr>
69
- <th>User</th><th>Runs</th><th>Tokens</th><th>Storage</th><th>Activity</th>
273
+ <th>User</th><th style="text-align:right;">Runs</th><th style="text-align:right;">Tokens</th><th style="text-align:right;">Storage</th><th style="width:100px;"></th>
70
274
  </tr></thead>
71
275
  <tbody>${users.map((u) => `
72
276
  <tr>
73
277
  <td><span class="user-avatar">${esc((u.display_name || u.username || '?')[0]).toUpperCase()}</span> ${esc(u.display_name || u.username)}</td>
74
- <td>${fmtNum(u.runs)}</td>
75
- <td>${fmtTokens(u.tokens)}</td>
76
- <td>${fmtBytes(u.storage)}</td>
77
- <td>
78
- <div class="mini-bar"><div class="mini-bar-fill" style="width:${Math.round((u.runs / maxRuns) * 100)}%"></div></div>
79
- </td>
278
+ <td style="text-align:right;">${fmtNum(u.runs)}</td>
279
+ <td style="text-align:right;">${fmtTokens(u.tokens)}</td>
280
+ <td style="text-align:right;">${fmtBytes(u.storage)}</td>
281
+ <td><div class="mini-bar"><div class="mini-bar-fill" style="width:${Math.round((u.tokens / maxTokens) * 100)}%"></div></div></td>
80
282
  </tr>`).join('')}
81
283
  </tbody>
82
284
  </table>
83
285
  </div>`;
84
286
  }
85
287
 
288
+ // ── Recent Runs ─────────────────────────────────────────────────────────────
289
+
86
290
  function renderRecentRuns(runs) {
87
291
  if (!runs.length) return '';
88
292
  return `
89
293
  <div class="card" style="margin-top:16px;">
90
- <div class="card-title">Recent Runs</div>
294
+ <div class="card-title" style="margin-bottom:12px;">Recent Runs</div>
91
295
  <table class="analytics-table">
92
296
  <thead><tr>
93
- <th>User</th><th>Title</th><th>Status</th><th>Tokens</th><th>Started</th>
297
+ <th>User</th><th>Title</th><th>Model</th><th>Status</th><th style="text-align:right;">Tokens</th><th>Started</th>
94
298
  </tr></thead>
95
299
  <tbody>${runs.map((r) => `
96
300
  <tr>
97
301
  <td>${esc(r.username)}</td>
98
302
  <td class="cell-truncate">${esc(r.title || '(untitled)')}</td>
303
+ <td style="font-family:var(--font-mono);font-size:11px;color:var(--text-muted);">${esc(r.model || '—')}</td>
99
304
  <td><span class="run-badge run-${esc(r.status)}">${esc(r.status)}</span></td>
100
- <td>${fmtTokens(r.total_tokens)}</td>
305
+ <td style="text-align:right;">${fmtTokens(r.total_tokens)}</td>
101
306
  <td>${fmtTime(r.created_at)}</td>
102
307
  </tr>`).join('')}
103
308
  </tbody>
@@ -105,7 +310,7 @@ function renderRecentRuns(runs) {
105
310
  </div>`;
106
311
  }
107
312
 
108
- // ── Shared number helpers (also used by other modules) ────────────────────
313
+ // ── Shared number helpers ──────────────────────────────────────────────────
109
314
 
110
315
  function fmtNum(n) {
111
316
  if (n == null) return '—';
@@ -114,6 +319,7 @@ function fmtNum(n) {
114
319
 
115
320
  function fmtTokens(n) {
116
321
  if (!n) return '0';
322
+ if (n >= 1_000_000_000) return (n / 1_000_000_000).toFixed(1) + 'B';
117
323
  if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
118
324
  if (n >= 1_000) return (n / 1_000).toFixed(1) + 'K';
119
325
  return String(n);
@@ -915,9 +915,16 @@
915
915
  <div class="page-header">
916
916
  <div>
917
917
  <h1 class="page-title">Analytics</h1>
918
- <p class="page-subtitle">Usage statistics, top users, and recent activity</p>
918
+ <p class="page-subtitle">Usage statistics, model breakdown, and recent activity</p>
919
+ </div>
920
+ <div style="display:flex;align-items:center;gap:10px;">
921
+ <div style="display:flex;gap:4px;">
922
+ <button class="range-btn" onclick="setAnalyticsRange(7,this)">7d</button>
923
+ <button class="range-btn active" onclick="setAnalyticsRange(30,this)">30d</button>
924
+ <button class="range-btn" onclick="setAnalyticsRange(90,this)">90d</button>
925
+ </div>
926
+ <span class="refresh-hint" id="analytics-ts" aria-live="polite"></span>
919
927
  </div>
920
- <span class="refresh-hint" id="analytics-ts" aria-live="polite"></span>
921
928
  </div>
922
929
  <div class="content">
923
930
  <div id="analytics-content"><div class="empty"><span class="spinner"></span></div></div>
@@ -764,6 +764,112 @@ db.exec(`
764
764
  CREATE INDEX IF NOT EXISTS idx_agent_run_events_run ON agent_run_events(run_id, sequence_index, id);
765
765
  CREATE INDEX IF NOT EXISTS idx_agent_run_events_user ON agent_run_events(user_id, created_at DESC);
766
766
 
767
+ CREATE TABLE IF NOT EXISTS agent_model_usage (
768
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
769
+ run_id TEXT NOT NULL,
770
+ step_id TEXT,
771
+ user_id INTEGER NOT NULL,
772
+ agent_id TEXT,
773
+ provider TEXT NOT NULL,
774
+ model TEXT NOT NULL,
775
+ phase TEXT DEFAULT 'model_turn',
776
+ input_tokens INTEGER DEFAULT 0,
777
+ output_tokens INTEGER DEFAULT 0,
778
+ reasoning_tokens INTEGER DEFAULT 0,
779
+ cached_read_tokens INTEGER DEFAULT 0,
780
+ cache_write_tokens INTEGER DEFAULT 0,
781
+ total_tokens INTEGER DEFAULT 0,
782
+ estimated_cost_usd REAL,
783
+ latency_ms INTEGER DEFAULT 0,
784
+ metadata_json TEXT DEFAULT '{}',
785
+ created_at TEXT DEFAULT (datetime('now')),
786
+ FOREIGN KEY (run_id) REFERENCES agent_runs(id) ON DELETE CASCADE,
787
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
788
+ FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL,
789
+ FOREIGN KEY (step_id) REFERENCES agent_steps(id) ON DELETE SET NULL
790
+ );
791
+
792
+ CREATE INDEX IF NOT EXISTS idx_agent_model_usage_run ON agent_model_usage(run_id, id);
793
+ CREATE INDEX IF NOT EXISTS idx_agent_model_usage_user ON agent_model_usage(user_id, created_at DESC);
794
+
795
+ CREATE TABLE IF NOT EXISTS workspace_code_index (
796
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
797
+ user_id INTEGER NOT NULL,
798
+ path TEXT NOT NULL,
799
+ symbol TEXT,
800
+ symbol_type TEXT,
801
+ start_line INTEGER,
802
+ end_line INTEGER,
803
+ content TEXT NOT NULL,
804
+ content_hash TEXT NOT NULL,
805
+ embedding TEXT,
806
+ updated_at TEXT DEFAULT (datetime('now')),
807
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
808
+ UNIQUE(user_id, path, symbol, start_line)
809
+ );
810
+
811
+ CREATE INDEX IF NOT EXISTS idx_workspace_code_index_user
812
+ ON workspace_code_index(user_id, path);
813
+
814
+ CREATE TABLE IF NOT EXISTS task_webhook_deliveries (
815
+ id TEXT PRIMARY KEY,
816
+ task_id INTEGER NOT NULL,
817
+ user_id INTEGER NOT NULL,
818
+ request_id TEXT NOT NULL,
819
+ payload_hash TEXT NOT NULL,
820
+ status TEXT NOT NULL DEFAULT 'accepted',
821
+ response_json TEXT DEFAULT '{}',
822
+ created_at TEXT DEFAULT (datetime('now')),
823
+ completed_at TEXT,
824
+ FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE,
825
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
826
+ UNIQUE(task_id, request_id)
827
+ );
828
+
829
+ CREATE TABLE IF NOT EXISTS task_webhook_secrets (
830
+ task_id INTEGER PRIMARY KEY,
831
+ user_id INTEGER NOT NULL,
832
+ secret_encrypted TEXT NOT NULL,
833
+ secret_fingerprint TEXT NOT NULL,
834
+ rotated_at TEXT DEFAULT (datetime('now')),
835
+ created_at TEXT DEFAULT (datetime('now')),
836
+ FOREIGN KEY (task_id) REFERENCES scheduled_tasks(id) ON DELETE CASCADE,
837
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
838
+ );
839
+
840
+ CREATE INDEX IF NOT EXISTS idx_task_webhook_deliveries_task
841
+ ON task_webhook_deliveries(task_id, created_at DESC);
842
+
843
+ CREATE TABLE IF NOT EXISTS skill_metrics (
844
+ user_id INTEGER NOT NULL,
845
+ agent_id TEXT,
846
+ skill_name TEXT NOT NULL,
847
+ invocation_count INTEGER DEFAULT 0,
848
+ success_count INTEGER DEFAULT 0,
849
+ failure_count INTEGER DEFAULT 0,
850
+ correction_count INTEGER DEFAULT 0,
851
+ total_tokens INTEGER DEFAULT 0,
852
+ last_used_at TEXT,
853
+ metadata_json TEXT DEFAULT '{}',
854
+ updated_at TEXT DEFAULT (datetime('now')),
855
+ PRIMARY KEY (user_id, agent_id, skill_name),
856
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
857
+ FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE SET NULL
858
+ );
859
+
860
+ CREATE TABLE IF NOT EXISTS skill_workflow_observations (
861
+ user_id INTEGER NOT NULL,
862
+ agent_id TEXT NOT NULL,
863
+ workflow_signature TEXT NOT NULL,
864
+ observation_count INTEGER DEFAULT 0,
865
+ latest_run_id TEXT,
866
+ first_observed_at TEXT DEFAULT (datetime('now')),
867
+ last_observed_at TEXT DEFAULT (datetime('now')),
868
+ PRIMARY KEY (user_id, agent_id, workflow_signature),
869
+ FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
870
+ FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
871
+ );
872
+
767
873
  CREATE TABLE IF NOT EXISTS agent_settings (
768
874
  id INTEGER PRIMARY KEY AUTOINCREMENT,
769
875
  user_id INTEGER NOT NULL,
@@ -1159,6 +1265,12 @@ for (const col of [
1159
1265
  "ALTER TABLE desktop_companion_devices ADD COLUMN last_seen_at TEXT",
1160
1266
  "ALTER TABLE desktop_companion_devices ADD COLUMN revoked_at TEXT",
1161
1267
  "ALTER TABLE desktop_companion_devices ADD COLUMN updated_at TEXT DEFAULT (datetime('now'))",
1268
+ "ALTER TABLE memory_facts ADD COLUMN valid_from TEXT",
1269
+ "ALTER TABLE memory_facts ADD COLUMN valid_to TEXT",
1270
+ "ALTER TABLE memory_facts ADD COLUMN learned_at TEXT",
1271
+ "ALTER TABLE memory_facts ADD COLUMN invalidated_at TEXT",
1272
+ "ALTER TABLE memory_facts ADD COLUMN status TEXT DEFAULT 'active'",
1273
+ "ALTER TABLE memory_facts ADD COLUMN supersedes_fact_id TEXT",
1162
1274
  ]) {
1163
1275
  try { db.exec(col); } catch { /* column already exists */ }
1164
1276
  }
@@ -21,6 +21,7 @@ const routeRegistry = [
21
21
  { basePath: '/api/workspace', modulePath: '../routes/workspace' },
22
22
  { basePath: '/api/memory', modulePath: '../routes/memory' },
23
23
  { basePath: '/api/tasks', modulePath: '../routes/tasks' },
24
+ { basePath: '/api/task-webhooks', modulePath: '../routes/task_webhooks' },
24
25
  { basePath: '/api/widgets', modulePath: '../routes/widgets' },
25
26
  { basePath: '/api/browser', modulePath: '../routes/browser' },
26
27
  { basePath: '/api/browser-extension', modulePath: '../routes/browser_extension' },
@@ -1 +1 @@
1
- 4783d0646db3c87d4fbb189103797925
1
+ 7c0e23acbbf065a5042b91517cbe7d81
@@ -1 +1 @@
1
-
1
+
@@ -1 +1 @@
1
- "DQ4HIWFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8xMDI0LnBuZwwBDQEHBWFzc2V0ByFhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fMTAyNC5wbmcHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nDAENAQcFYXNzZXQHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwwBDQEHBWFzc2V0ByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwcnYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzEwMjQucG5nDAENAQcFYXNzZXQHJ2Fzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF8xMDI0LnBuZwcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nDAENAQcFYXNzZXQHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nByRhc3NldHMvYnJhbmRpbmcvb25ib2FyZGluZ19pbnRyby5tcDQMAQ0BBwVhc3NldAckYXNzZXRzL2JyYW5kaW5nL29uYm9hcmRpbmdfaW50cm8ubXA0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwwBDQEHBWFzc2V0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmDAENAQcFYXNzZXQHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmByxwYWNrYWdlcy9taXhwYW5lbF9mbHV0dGVyL2Fzc2V0cy9taXhwYW5lbC5qcwwBDQEHBWFzc2V0ByxwYWNrYWdlcy9taXhwYW5lbF9mbHV0dGVyL2Fzc2V0cy9taXhwYW5lbC5qcwc3cGFja2FnZXMvcmVjb3JkX3dlYi9hc3NldHMvanMvcmVjb3JkLmZpeHdlYm1kdXJhdGlvbi5qcwwBDQEHBWFzc2V0BzdwYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQuZml4d2VibWR1cmF0aW9uLmpzBy9wYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQud29ya2xldC5qcwwBDQEHBWFzc2V0By9wYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQud29ya2xldC5qcwcWd2ViL2ljb25zL0ljb24tMTkyLnBuZwwBDQEHBWFzc2V0BxZ3ZWIvaWNvbnMvSWNvbi0xOTIucG5n"
1
+ "DQ0HIWFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8xMDI0LnBuZwwBDQEHBWFzc2V0ByFhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fMTAyNC5wbmcHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nDAENAQcFYXNzZXQHIGFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl8yNTYucG5nByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwwBDQEHBWFzc2V0ByBhc3NldHMvYnJhbmRpbmcvYXBwX2ljb25fNTEyLnBuZwcnYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzEwMjQucG5nDAENAQcFYXNzZXQHJ2Fzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF8xMDI0LnBuZwcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL2FwcF9pY29uX2xpZ2h0XzI1Ni5wbmcHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nDAENAQcFYXNzZXQHJmFzc2V0cy9icmFuZGluZy9hcHBfaWNvbl9saWdodF81MTIucG5nByRhc3NldHMvYnJhbmRpbmcvb25ib2FyZGluZ19pbnRyby5tcDQMAQ0BBwVhc3NldAckYXNzZXRzL2JyYW5kaW5nL29uYm9hcmRpbmdfaW50cm8ubXA0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwwBDQEHBWFzc2V0Byxhc3NldHMvYnJhbmRpbmcvdHJheV9pY29uX2xpZ2h0X3RlbXBsYXRlLnBuZwcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcMAQ0BBwVhc3NldAcmYXNzZXRzL2JyYW5kaW5nL3RyYXlfaWNvbl90ZW1wbGF0ZS5wbmcHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmDAENAQcFYXNzZXQHMnBhY2thZ2VzL2N1cGVydGlub19pY29ucy9hc3NldHMvQ3VwZXJ0aW5vSWNvbnMudHRmBzdwYWNrYWdlcy9yZWNvcmRfd2ViL2Fzc2V0cy9qcy9yZWNvcmQuZml4d2VibWR1cmF0aW9uLmpzDAENAQcFYXNzZXQHN3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC5maXh3ZWJtZHVyYXRpb24uanMHL3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC53b3JrbGV0LmpzDAENAQcFYXNzZXQHL3BhY2thZ2VzL3JlY29yZF93ZWIvYXNzZXRzL2pzL3JlY29yZC53b3JrbGV0LmpzBxZ3ZWIvaWNvbnMvSWNvbi0xOTIucG5nDAENAQcFYXNzZXQHFndlYi9pY29ucy9JY29uLTE5Mi5wbmc="