@polderlabs/bizar 4.4.13 → 4.5.0

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 (90) hide show
  1. package/bizar-dash/CHANGELOG.md +37 -276
  2. package/bizar-dash/dist/assets/main-CDFKHzBg.css +1 -0
  3. package/bizar-dash/dist/assets/main-NYFpS2wY.js +312 -0
  4. package/bizar-dash/dist/assets/main-NYFpS2wY.js.map +1 -0
  5. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js → mobile--0FBIKX3.js} +2 -2
  6. package/bizar-dash/dist/assets/{mobile-DSb-t42Y.js.map → mobile--0FBIKX3.js.map} +1 -1
  7. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js +352 -0
  8. package/bizar-dash/dist/assets/mobile-OgRp8VIb.js.map +1 -0
  9. package/bizar-dash/dist/index.html +3 -3
  10. package/bizar-dash/dist/mobile.html +2 -2
  11. package/bizar-dash/skills/agent-baseline/SKILL.md +80 -0
  12. package/bizar-dash/skills/bizar/SKILL.md +96 -0
  13. package/bizar-dash/skills/chat/SKILL.md +74 -0
  14. package/bizar-dash/skills/lightrag/SKILL.md +75 -0
  15. package/bizar-dash/skills/minimax/SKILL.md +80 -0
  16. package/bizar-dash/skills/obsidian/SKILL.md +55 -0
  17. package/bizar-dash/skills/providers/SKILL.md +75 -0
  18. package/bizar-dash/skills/sdk/SKILL.md +138 -0
  19. package/bizar-dash/skills/self-improvement/SKILL.md +53 -0
  20. package/bizar-dash/skills/skills-cli/SKILL.md +94 -0
  21. package/bizar-dash/skills/usage/SKILL.md +62 -0
  22. package/bizar-dash/src/server/api.mjs +12 -0
  23. package/bizar-dash/src/server/memory-lightrag.mjs +5 -2
  24. package/bizar-dash/src/server/memory-store.mjs +38 -0
  25. package/bizar-dash/src/server/minimax-usage-store.mjs +372 -0
  26. package/bizar-dash/src/server/minimax.mjs +196 -5
  27. package/bizar-dash/src/server/providers-store.mjs +956 -0
  28. package/bizar-dash/src/server/routes/config.mjs +52 -1
  29. package/bizar-dash/src/server/routes/env-vars.mjs +165 -0
  30. package/bizar-dash/src/server/routes/lightrag.mjs +154 -0
  31. package/bizar-dash/src/server/routes/memory.mjs +241 -1
  32. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +14 -29
  33. package/bizar-dash/src/server/routes/opencode-sessions.mjs +205 -3
  34. package/bizar-dash/src/server/routes/providers.mjs +266 -5
  35. package/bizar-dash/src/server/routes/skills.mjs +32 -43
  36. package/bizar-dash/src/server/routes/update.mjs +340 -0
  37. package/bizar-dash/src/server/routes/usage.mjs +136 -0
  38. package/bizar-dash/src/server/serve-info.mjs +135 -4
  39. package/bizar-dash/src/server/server.mjs +4 -0
  40. package/bizar-dash/src/server/skills-store.mjs +152 -262
  41. package/bizar-dash/src/web/App.tsx +118 -29
  42. package/bizar-dash/src/web/components/EnvVarManager.tsx +247 -0
  43. package/bizar-dash/src/web/components/SettingsSearch.tsx +213 -0
  44. package/bizar-dash/src/web/components/Topbar.tsx +0 -1
  45. package/bizar-dash/src/web/components/UsageChart.tsx +250 -0
  46. package/bizar-dash/src/web/components/UsageTable.tsx +90 -0
  47. package/bizar-dash/src/web/components/chat/ChatComposer.tsx +21 -25
  48. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +199 -37
  49. package/bizar-dash/src/web/components/chat/ChatThread.tsx +29 -17
  50. package/bizar-dash/src/web/components/chat/FloatingComposer.tsx +7 -1
  51. package/bizar-dash/src/web/components/chat/InfoPanel.tsx +71 -6
  52. package/bizar-dash/src/web/components/chat/useChat.ts +751 -257
  53. package/bizar-dash/src/web/lib/api.ts +43 -0
  54. package/bizar-dash/src/web/main.tsx +1 -0
  55. package/bizar-dash/src/web/mobile/views/MobileChat.tsx +110 -35
  56. package/bizar-dash/src/web/styles/chat.css +135 -1
  57. package/bizar-dash/src/web/styles/main.css +46 -0
  58. package/bizar-dash/src/web/styles/minimax-usage.css +335 -0
  59. package/bizar-dash/src/web/styles/settings.css +418 -0
  60. package/bizar-dash/src/web/styles/skills.css +302 -0
  61. package/bizar-dash/src/web/styles/tasks.css +288 -0
  62. package/bizar-dash/src/web/views/Chat.tsx +276 -48
  63. package/bizar-dash/src/web/views/Config.tsx +3 -2065
  64. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +476 -461
  65. package/bizar-dash/src/web/views/Settings.tsx +6 -0
  66. package/bizar-dash/src/web/views/Skills.tsx +208 -260
  67. package/bizar-dash/src/web/views/Tasks.tsx +348 -1119
  68. package/bizar-dash/tests/chat-session-create.test.mjs +391 -0
  69. package/bizar-dash/tests/chat-session-stream.test.mjs +308 -0
  70. package/bizar-dash/tests/env-vars-store.test.mjs +216 -0
  71. package/bizar-dash/tests/lightrag-defaults.node.test.mjs +118 -0
  72. package/bizar-dash/tests/minimax-chat-usage.test.mjs +178 -0
  73. package/bizar-dash/tests/minimax-usage-store.node.test.mjs +293 -0
  74. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +12 -9
  75. package/bizar-dash/tests/providers-store-backup-keys.node.test.mjs +479 -3
  76. package/bizar-dash/tests/providers-store-search.node.test.mjs +166 -0
  77. package/bizar-dash/tests/skills-list.test.mjs +232 -0
  78. package/bizar-dash/tests/skills-search.test.mjs +222 -0
  79. package/bizar-dash/tests/tasks-create.test.mjs +187 -0
  80. package/bizar-dash/tests/update-check.test.mjs +127 -0
  81. package/bizar-dash/tests/update-run.test.mjs +266 -0
  82. package/cli/bin.mjs +82 -1
  83. package/cli/provision.mjs +118 -4
  84. package/config/agents/_shared/SKILLS.md +109 -0
  85. package/package.json +1 -1
  86. package/bizar-dash/dist/assets/main-BB5mJurD.js +0 -352
  87. package/bizar-dash/dist/assets/main-BB5mJurD.js.map +0 -1
  88. package/bizar-dash/dist/assets/main-BsnQLXdh.css +0 -1
  89. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js +0 -354
  90. package/bizar-dash/dist/assets/mobile-Dl1q7Cyq.js.map +0 -1
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * src/server/routes/skills.mjs
3
3
  *
4
- * /api/skills list (?category=foo to filter)
5
- * /api/skills/search — fuzzy search by name/description
6
- * /api/skills/install (POST) — install by name or source URL
7
- * /api/skills/:id/disable (POST) disable
8
- * /api/skills/:id/enable (POST) — enable
4
+ * v4.0.0 Serves locally-scanned SKILL.md files across all sources.
5
+ *
6
+ * Endpoints:
7
+ * GET /skills list all skills
8
+ * GET /skills/search?q= — fuzzy search (plain data, no ANSI)
9
+ * GET /skills/:source/:name — single skill detail
10
+ * POST /skills/refresh — invalidate cache + rescan
9
11
  */
10
12
  import { Router } from 'express';
11
13
  import { skillsStore } from '../skills-store.mjs';
@@ -19,57 +21,44 @@ import { wrap } from './_shared.mjs';
19
21
  export function createSkillsRouter({ broadcast }) {
20
22
  const router = Router();
21
23
 
22
- // v3.3.0 — `?category=foo` filters the list server-side. The
23
- // frontend uses this to power the collapsible categories view
24
- // (the old "all in one list" mode is now the default "Show all"
25
- // toggle in the UI).
24
+ // GET /skills
26
25
  router.get('/skills', wrap(async (req, res) => {
27
- const skills = await skillsStore.list();
28
- let out = skills;
29
- if (req.query.category) {
30
- const wanted = String(req.query.category).toLowerCase();
31
- if (wanted !== 'all') {
32
- out = skills.filter((s) => (s.category || '').toLowerCase() === wanted);
33
- }
34
- }
35
- res.json({ skills: out, categories: skillsStore.CATEGORIES, total: skills.length });
26
+ const all = await skillsStore.list();
27
+ // Group counts by source
28
+ const counts = { shipped: 0, user: 0, project: 0 };
29
+ for (const s of all) counts[s.source] = (counts[s.source] || 0) + 1;
30
+ res.json({ skills: all, count: all.length, counts });
36
31
  }));
37
32
 
33
+ // GET /skills/search?q=
38
34
  router.get('/skills/search', wrap(async (req, res) => {
39
35
  const q = (req.query.q || '').toString();
40
36
  const results = await skillsStore.search(q);
41
- res.json({ results, query: q, categories: skillsStore.CATEGORIES });
37
+ // Ensure plain strings strip any accidental ANSI from CLI output
38
+ const clean = results.map((s) => ({
39
+ name: s.name,
40
+ description: s.description,
41
+ source: s.source,
42
+ path: s.path,
43
+ }));
44
+ res.json({ results: clean, query: q, count: clean.length });
42
45
  }));
43
46
 
44
- router.post('/skills/install', wrap(async (req, res) => {
45
- const { name, source } = req.body || {};
46
- if (!name && !source) {
47
- res.status(400).json({ error: 'bad_request', message: 'name or source is required' });
47
+ // GET /skills/:source/:name
48
+ router.get('/skills/:source/:name', wrap(async (req, res) => {
49
+ const skill = await skillsStore.get(req.params.source, req.params.name);
50
+ if (!skill) {
51
+ res.status(404).json({ error: 'not_found', message: 'skill not found' });
48
52
  return;
49
53
  }
50
- try {
51
- const result = await skillsStore.install(name, source);
52
- if (!result.ok) {
53
- res.status(502).json({ error: 'install_failed', message: result.error || 'skills CLI install failed' });
54
- return;
55
- }
56
- broadcast({ type: 'skills:change' });
57
- res.status(202).json(result);
58
- } catch (err) {
59
- res.status(500).json({ error: 'install_failed', message: err.message });
60
- }
61
- }));
62
-
63
- router.post('/skills/:id/disable', wrap(async (req, res) => {
64
- const out = skillsStore.disable(req.params.id);
65
- broadcast({ type: 'skills:change' });
66
- res.json(out);
54
+ res.json(skill);
67
55
  }));
68
56
 
69
- router.post('/skills/:id/enable', wrap(async (req, res) => {
70
- const out = skillsStore.enable(req.params.id);
57
+ // POST /skills/refresh
58
+ router.post('/skills/refresh', wrap(async (req, res) => {
59
+ const skills = skillsStore.refresh();
71
60
  broadcast({ type: 'skills:change' });
72
- res.json(out);
61
+ res.json({ ok: true, count: skills.length });
73
62
  }));
74
63
 
75
64
  return router;
@@ -0,0 +1,340 @@
1
+ /**
2
+ * src/server/routes/update.mjs
3
+ *
4
+ * v4.6.0 — Dashboard update endpoints.
5
+ *
6
+ * GET /api/updates/status — installed package versions only
7
+ * GET /api/updates/check — installed + latest + hasUpdates
8
+ * POST /api/updates/apply — trigger install for given packages
9
+ * Streams progress via WS events:
10
+ * update:progress { type, pkg, status, error?, newVersion? }
11
+ * update:log { type, pkg, line }
12
+ * update:complete{ type }
13
+ *
14
+ * Why these paths (vs. /api/update/*):
15
+ * thor-settings wired Settings.tsx to `/updates/status`, `/updates/check`,
16
+ * and `/updates/apply` and listens for the WS event shapes above. Aligning
17
+ * with that contract is cheaper than rewriting Settings.tsx.
18
+ *
19
+ * What this endpoint does NOT do:
20
+ * - It does NOT restart the dashboard. The Settings UI displays a
21
+ * confirmation prompt before calling, and `bizar update` (the CLI)
22
+ * is the restart-aware path. The dashboard stays up; npm replaces
23
+ * files in the global install dir while the dashboard runs from its
24
+ * own already-loaded module graph. The next `bizar dash start`
25
+ * picks up the new code, just like the CLI flow.
26
+ * - It does NOT touch the opencode plugin in-place. That lives in the
27
+ * CLI flow because the plugin path is dev-symlinked in this repo.
28
+ *
29
+ * Safety:
30
+ * - Concurrency guard: only one apply run at a time. A second call
31
+ * returns 409 with `error: 'already_running'`.
32
+ * - Network/npm failures emit `update:log` for each stderr/stdout
33
+ * line and a final `update:progress` with `status: 'error'`.
34
+ * - Whitelist of npm package names; anything else is rejected 400.
35
+ */
36
+ import { Router } from 'express';
37
+ import { existsSync, readFileSync } from 'node:fs';
38
+ import { join, dirname } from 'node:path';
39
+ import { fileURLToPath } from 'node:url';
40
+ import { spawn, execFileSync } from 'node:child_process';
41
+ import { wrap } from './_shared.mjs';
42
+
43
+ const __dirname = dirname(fileURLToPath(import.meta.url));
44
+
45
+ // v4.4.13 — packages the dashboard knows how to update. Aligned with
46
+ // the Settings UI's "Installed / Latest" panels and the CLI's update
47
+ // flow. All three resolve on npmjs.org (bizar-dash and bizar-plugin are
48
+ // deprecated but still resolvable).
49
+ const KNOWN_PACKAGES = [
50
+ { id: 'bizar', npmName: '@polderlabs/bizar', label: 'Bizar CLI' },
51
+ { id: 'bizar-dash', npmName: '@polderlabs/bizar-dash', label: 'Dashboard' },
52
+ { id: 'bizar-plugin', npmName: '@polderlabs/bizar-plugin', label: 'Opencode Plugin' },
53
+ ];
54
+
55
+ const KNOWN_BY_ID = new Map(KNOWN_PACKAGES.map((p) => [p.id, p]));
56
+
57
+ // Cache the latest-version lookup so /api/updates/check is fast on
58
+ // repeated dashboard renders. 1 hour is short enough that a new release
59
+ // shows up within an hour of publishing.
60
+ const LATEST_CACHE_TTL_MS = 60 * 60 * 1000;
61
+ const _latestCache = new Map(); // npmName -> { version, ts }
62
+
63
+ /**
64
+ * Resolve the on-disk version of the dashboard itself (used as the
65
+ * "current" version for @polderlabs/bizar). Walks up from this file
66
+ * looking for the package.json that declares `@polderlabs/bizar`.
67
+ *
68
+ * Why "walk up" instead of `import.meta.resolve`? When the dashboard
69
+ * runs out of `dist/` (the published layout), the package.json lives
70
+ * at the dist root, not at the file's location. The walk is bounded
71
+ * to 6 levels so a misconfigured install fails fast instead of looping.
72
+ */
73
+ function resolveInstalledVersion() {
74
+ const markers = ['package.json'];
75
+ let cur = __dirname;
76
+ for (let i = 0; i < 6; i++) {
77
+ for (const m of markers) {
78
+ const p = join(cur, m);
79
+ if (existsSync(p)) {
80
+ try {
81
+ const pkg = JSON.parse(readFileSync(p, 'utf8'));
82
+ if (pkg.name === '@polderlabs/bizar' && pkg.version) return pkg.version;
83
+ } catch { /* malformed — try parent */ }
84
+ }
85
+ }
86
+ const parent = dirname(cur);
87
+ if (parent === cur) break;
88
+ cur = parent;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ const INSTALLED_BIZAR_VERSION = resolveInstalledVersion();
94
+
95
+ /**
96
+ * `npm view <pkg> version` — returns the latest published version or
97
+ * null on any failure. Cached per package for 1 hour.
98
+ */
99
+ function fetchLatestVersion(npmName) {
100
+ const cached = _latestCache.get(npmName);
101
+ if (cached && Date.now() - cached.ts < LATEST_CACHE_TTL_MS) {
102
+ return cached.version;
103
+ }
104
+ try {
105
+ const out = execFileSync(
106
+ 'npm',
107
+ ['view', npmName, 'version'],
108
+ { stdio: ['ignore', 'pipe', 'ignore'], timeout: 15000 },
109
+ ).toString().trim();
110
+ const version = out || null;
111
+ _latestCache.set(npmName, { version, ts: Date.now() });
112
+ return version;
113
+ } catch {
114
+ // Negative-cache for 5 minutes so a transient registry hiccup
115
+ // doesn't make every render call npm.
116
+ _latestCache.set(npmName, { version: null, ts: Date.now() - (LATEST_CACHE_TTL_MS - 5 * 60 * 1000) });
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Reset the latest-version cache — exposed for tests so each case
123
+ * starts from a known state.
124
+ */
125
+ export function resetUpdateCache() {
126
+ _latestCache.clear();
127
+ }
128
+
129
+ /**
130
+ * Compute the per-package installed map. For the dashboard package
131
+ * itself we use INSTALLED_BIZAR_VERSION (the @polderlabs/bizar version
132
+ * that bundles the dashboard). The other packages fall back to null
133
+ * when not resolvable; the UI renders "—" for those rows.
134
+ */
135
+ function currentVersions() {
136
+ const out = {};
137
+ for (const p of KNOWN_PACKAGES) {
138
+ if (p.id === 'bizar') {
139
+ out[p.id] = INSTALLED_BIZAR_VERSION;
140
+ } else {
141
+ // Deprecated standalone packages — fall back to running dashboard
142
+ // version as a sane default so the row isn't permanently "—".
143
+ out[p.id] = INSTALLED_BIZAR_VERSION;
144
+ }
145
+ }
146
+ return out;
147
+ }
148
+
149
+ /**
150
+ * Compute latest map and hasUpdates flag. Iterates KNOWN_PACKAGES so
151
+ * the UI gets a stable order and predictable keys even when one of the
152
+ * `npm view` calls fails (those resolve to null).
153
+ */
154
+ function latestVersionsAndFlag(current) {
155
+ const latest = {};
156
+ let hasUpdates = false;
157
+ for (const p of KNOWN_PACKAGES) {
158
+ const lat = fetchLatestVersion(p.npmName);
159
+ latest[p.id] = lat;
160
+ const cur = current[p.id];
161
+ if (cur && lat && cur !== lat) hasUpdates = true;
162
+ }
163
+ return { latest, hasUpdates };
164
+ }
165
+
166
+ // ─── Apply concurrency guard ───────────────────────────────────────────────
167
+ // One active run per server process. A second POST /updates/apply while
168
+ // the first is still going returns 409 immediately.
169
+
170
+ let _activeRun = null; // { packages: string[], startedAt: number } | null
171
+
172
+ function isRunning() {
173
+ return _activeRun !== null;
174
+ }
175
+
176
+ /**
177
+ * Run `npm install -g <pkg>@latest` for each requested id. Emits WS
178
+ * events on the broadcast channel for every stdout/stderr line and for
179
+ * the per-package status transitions. Returns the result object
180
+ * (sync — the actual install runs in the background and finishes via
181
+ * WS events).
182
+ */
183
+ function startApplyRun({ packages, channel, broadcast }) {
184
+ if (_activeRun) {
185
+ throw Object.assign(new Error('update already running'), { code: 'already_running' });
186
+ }
187
+ const ids = (Array.isArray(packages) && packages.length > 0) ? packages : KNOWN_PACKAGES.map((p) => p.id);
188
+ const validIds = ids.filter((id) => KNOWN_BY_ID.has(id));
189
+ if (validIds.length === 0) {
190
+ throw Object.assign(new Error('no valid packages requested'), { code: 'bad_request' });
191
+ }
192
+
193
+ _activeRun = { packages: validIds, startedAt: Date.now() };
194
+
195
+ // Kick the run async. Errors are reported via the WS bus.
196
+ (async () => {
197
+ try {
198
+ for (const id of validIds) {
199
+ const pkg = KNOWN_BY_ID.get(id);
200
+ if (!pkg) continue;
201
+ broadcast({ type: 'update:progress', pkg: id, status: 'starting' });
202
+
203
+ const versionTag = channel && channel === 'beta' ? '@beta' : '@latest';
204
+ const code = await runNpmInstall(pkg.npmName, versionTag, (line) => {
205
+ broadcast({ type: 'update:log', pkg: id, line });
206
+ });
207
+
208
+ if (code === 0) {
209
+ const newVer = fetchLatestVersion(pkg.npmName); // warm cache
210
+ broadcast({
211
+ type: 'update:progress',
212
+ pkg: id,
213
+ status: 'done',
214
+ newVersion: newVer || undefined,
215
+ });
216
+ } else {
217
+ broadcast({
218
+ type: 'update:progress',
219
+ pkg: id,
220
+ status: 'error',
221
+ error: `npm install exited with code ${code}`,
222
+ });
223
+ }
224
+ }
225
+ } finally {
226
+ _activeRun = null;
227
+ broadcast({ type: 'update:complete' });
228
+ }
229
+ })().catch((err) => {
230
+ _activeRun = null;
231
+ broadcast({ type: 'update:progress', pkg: '__global', status: 'error', error: err?.message || String(err) });
232
+ broadcast({ type: 'update:complete' });
233
+ });
234
+
235
+ return { ok: true, started: validIds, channel: channel || 'stable' };
236
+ }
237
+
238
+ /**
239
+ * Spawn `npm install -g <pkg>@<tag>`. Captures stdout/stderr line by
240
+ * line and forwards each via the `onLine` callback. Resolves with the
241
+ * exit code (0 on success, non-zero otherwise).
242
+ *
243
+ * Why spawn instead of execSync: npm install can take 30+ seconds and
244
+ * blocks the event loop. We want concurrent dashboard requests to keep
245
+ * serving while an update is in flight.
246
+ */
247
+ function runNpmInstall(npmName, versionTag, onLine) {
248
+ return new Promise((resolve) => {
249
+ let proc;
250
+ try {
251
+ proc = spawn('npm', ['install', '-g', `${npmName}${versionTag}`], {
252
+ stdio: ['ignore', 'pipe', 'pipe'],
253
+ env: process.env,
254
+ });
255
+ } catch (err) {
256
+ onLine(`failed to spawn npm: ${err.message}`);
257
+ resolve(127);
258
+ return;
259
+ }
260
+
261
+ const pipeLine = (stream) => {
262
+ let buf = '';
263
+ stream.setEncoding('utf8');
264
+ stream.on('data', (chunk) => {
265
+ buf += chunk;
266
+ let nl;
267
+ while ((nl = buf.indexOf('\n')) >= 0) {
268
+ const line = buf.slice(0, nl).replace(/\r$/, '');
269
+ buf = buf.slice(nl + 1);
270
+ if (line) onLine(line);
271
+ }
272
+ });
273
+ stream.on('end', () => {
274
+ if (buf) onLine(buf);
275
+ });
276
+ };
277
+ pipeLine(proc.stdout);
278
+ pipeLine(proc.stderr);
279
+
280
+ proc.on('error', (err) => {
281
+ onLine(`npm process error: ${err.message}`);
282
+ resolve(127);
283
+ });
284
+ proc.on('close', (code) => {
285
+ resolve(code ?? 1);
286
+ });
287
+ });
288
+ }
289
+
290
+ // ─── Router factory ────────────────────────────────────────────────────────
291
+
292
+ export function createUpdateRouter({ broadcast = () => {} } = {}) {
293
+ const router = Router();
294
+
295
+ /**
296
+ * GET /api/updates/status
297
+ * Quick status — installed versions only. Cheap; no npm calls.
298
+ */
299
+ router.get('/updates/status', wrap(async (_req, res) => {
300
+ const current = currentVersions();
301
+ res.json({ current });
302
+ }));
303
+
304
+ /**
305
+ * GET /api/updates/check
306
+ * Installed + latest + hasUpdates. Calls `npm view` (cached).
307
+ */
308
+ router.get('/updates/check', wrap(async (_req, res) => {
309
+ const current = currentVersions();
310
+ const { latest, hasUpdates } = latestVersionsAndFlag(current);
311
+ res.json({ current, latest, hasUpdates });
312
+ }));
313
+
314
+ /**
315
+ * POST /api/updates/apply
316
+ * Body: { packages?: string[], channel?: 'stable' | 'beta' }
317
+ * Returns 202 immediately. Progress streams via WS events.
318
+ * Returns 409 if another apply is in flight.
319
+ */
320
+ router.post('/updates/apply', wrap(async (req, res) => {
321
+ if (isRunning()) {
322
+ res.status(409).json({ error: 'already_running', message: 'an update is already running' });
323
+ return;
324
+ }
325
+ const body = req.body || {};
326
+ const channel = body.channel === 'beta' ? 'beta' : 'stable';
327
+ try {
328
+ const result = startApplyRun({ packages: body.packages, channel, broadcast });
329
+ res.status(202).json(result);
330
+ } catch (err) {
331
+ const status = err?.code === 'bad_request' ? 400 : 500;
332
+ res.status(status).json({
333
+ error: err?.code || 'apply_failed',
334
+ message: err?.message || String(err),
335
+ });
336
+ }
337
+ }));
338
+
339
+ return router;
340
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * src/server/routes/usage.mjs
3
+ *
4
+ * Endpoints for usage analytics:
5
+ * GET /api/usage — aggregate usage (range, from, to, providerId, modelId)
6
+ * GET /api/usage/limits — live quota limits + usage percent (agents call this)
7
+ * GET /api/usage/recent — last N raw records (live tail)
8
+ *
9
+ * The agent-awareness path (/api/usage/limits) calls fetchRemains() for
10
+ * live quota data, then merges with the JSONL rolling totals.
11
+ */
12
+ import { Router } from 'express';
13
+ import { wrap } from './_shared.mjs';
14
+ import {
15
+ queryUsage,
16
+ getUsageLimitsForAgent,
17
+ } from '../minimax-usage-store.mjs';
18
+
19
+ export function createUsageRouter() {
20
+ const router = Router();
21
+
22
+ // GET /api/usage?range=24h|7d|30d|custom&from=&to=&providerId=&modelId=
23
+ router.get('/usage', wrap(async (req, res) => {
24
+ const range = String(req.query.range || '24h');
25
+ const from = req.query.from ? Number(req.query.from) : undefined;
26
+ const to = req.query.to ? Number(req.query.to) : undefined;
27
+ const providerId = req.query.providerId ? String(req.query.providerId) : undefined;
28
+ const modelId = req.query.modelId ? String(req.query.modelId) : undefined;
29
+
30
+ if (!['24h', '7d', '30d', 'custom'].includes(range)) {
31
+ return res.status(400).json({ error: 'invalid_range', message: 'range must be 24h, 7d, 30d, or custom' });
32
+ }
33
+ if (range === 'custom' && (!from || !to)) {
34
+ return res.status(400).json({ error: 'missing_dates', message: 'range=custom requires `from` and `to` (unix ms)' });
35
+ }
36
+
37
+ const opts = {
38
+ range,
39
+ from,
40
+ to,
41
+ providerId,
42
+ modelId,
43
+ };
44
+
45
+ let result;
46
+ try {
47
+ result = queryUsage(opts);
48
+ } catch (err) {
49
+ return res.status(400).json({ error: 'query_error', message: String((err).message) });
50
+ }
51
+ return res.json(result);
52
+ }));
53
+
54
+ // GET /api/usage/limits
55
+ // Returns live quota info from fetchRemains() merged with rolling usage totals.
56
+ router.get('/usage/limits', wrap(async (_req, res) => {
57
+ // Dynamic import to avoid pulling in the full minimax client bundle on every
58
+ // dashboard page load. The usage store is lightweight.
59
+ /** @type {Record<string, unknown>} */
60
+ let remainsData = { models: [] };
61
+ try {
62
+ const { fetchRemains } = await import('../minimax.mjs');
63
+ const remains = await fetchRemains({ force: false });
64
+ if (remains.ok) {
65
+ // Cast through unknown to avoid TypeScript pollution in plain .mjs.
66
+ remainsData = /** @type {Record<string, unknown>} */ (remains);
67
+ }
68
+ } catch { /* best-effort — return zeros */ }
69
+
70
+ // Rolling usage from JSONL.
71
+ const agentSummary = await getUsageLimitsForAgent('minimax');
72
+
73
+ // Merge: per-model usage from JSONL + per-model quota from remains.
74
+ /** @type {Array<{model_name?: string, current_interval_remaining_percent?: number, current_weekly_remaining_percent?: number, intervalResetInHuman?: string, weeklyResetInHuman?: string}>} */
75
+ const models = (remainsData.models || []);
76
+
77
+ // Build per-model percentUsed from the JSONL per-model data.
78
+ const usageResult = queryUsage({ range: '24h', providerId: 'minimax' });
79
+ const perModelUsage = new Map(usageResult.perModel.map(m => [`minimax/${m.modelId}`, m]));
80
+
81
+ const perModelLimits = models.map(m => {
82
+ const usage = perModelUsage.get(`minimax/${m.model_name}`);
83
+ const usedTokens = usage?.totalTokens ?? 0;
84
+ const remainingPct = m.current_interval_remaining_percent ?? 100;
85
+ let estimatedDailyBudget = 0;
86
+ let percentUsed = 0;
87
+ if (remainingPct < 100 && usedTokens > 0) {
88
+ estimatedDailyBudget = Math.round(usedTokens / (1 - remainingPct / 100));
89
+ percentUsed = Math.round((usedTokens / estimatedDailyBudget) * 1000) / 10;
90
+ }
91
+ return {
92
+ modelId: m.model_name ?? 'unknown',
93
+ totalTokensUsed: usedTokens,
94
+ requestCount: usage?.requests ?? 0,
95
+ limits: {
96
+ dailyTokens: estimatedDailyBudget || null,
97
+ intervalResetIn: m.intervalResetInHuman ?? null,
98
+ weeklyResetIn: m.weeklyResetInHuman ?? null,
99
+ },
100
+ percentUsed24h: percentUsed,
101
+ };
102
+ });
103
+
104
+ return res.json({
105
+ provider: 'minimax',
106
+ requestsLast5min: agentSummary.requestsLast5min,
107
+ tokensLast5min: agentSummary.tokensLast5min,
108
+ requestsLast24h: agentSummary.requestsLast24h,
109
+ tokensLast24h: agentSummary.tokensLast24h,
110
+ limits: agentSummary.limits,
111
+ percentUsed24h: agentSummary.percentUsed24h,
112
+ estimatedTimeUntilReset: agentSummary.estimatedTimeUntilReset,
113
+ warning: agentSummary.warning,
114
+ perModel: perModelLimits,
115
+ fetchedAt: remainsData.fetchedAt ? new Date(remainsData.fetchedAt).toISOString() : null,
116
+ });
117
+ }));
118
+
119
+ // GET /api/usage/recent?limit=50
120
+ router.get('/usage/recent', wrap(async (req, res) => {
121
+ const limit = Math.min(Math.max(Number(req.query.limit) || 50, 1), 200);
122
+
123
+ // Read last N lines from the JSONL.
124
+ const { readAllRecords } = await import('../minimax-usage-store.mjs');
125
+ const all = readAllRecords();
126
+ const recent = all.slice(-limit).reverse(); // newest first
127
+
128
+ return res.json({
129
+ records: recent,
130
+ total: all.length,
131
+ returned: recent.length,
132
+ });
133
+ }));
134
+
135
+ return router;
136
+ }