@swapai/core 0.2.2 → 0.3.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.
package/README.md CHANGED
@@ -113,6 +113,49 @@ import { classifyWithReference } from "@swapai/core/effect";
113
113
  const program = classifyWithReference(relevance, input, callReferenceEffect);
114
114
  ```
115
115
 
116
+ ## Classifier monitor
117
+
118
+ Start the built-in local monitor against the same data directory as your
119
+ classifiers:
120
+
121
+ ```sh
122
+ npx @swapai/core classifiers-ui --data-directory .swapai
123
+ ```
124
+
125
+ Then open `http://127.0.0.1:4789`. The page shows every stored classifier, its
126
+ retained and lifetime example counts, last measured held-out error, target
127
+ error, and whether it is loaded, training, or trained. The page refreshes every
128
+ three seconds. SwapAI never returns classification inputs or saved model paths
129
+ from the monitor API.
130
+
131
+ If you want an npm script in your application:
132
+
133
+ ```json
134
+ {
135
+ "scripts": {
136
+ "swapai:classifiers": "swapai classifiers-ui"
137
+ }
138
+ }
139
+ ```
140
+
141
+ ```sh
142
+ npm run swapai:classifiers
143
+ ```
144
+
145
+ The server binds to `127.0.0.1` by default. Use `--host`, `--port`, or the
146
+ `SWAPAI_DATA_DIRECTORY` environment variable when needed. Applications can
147
+ also start it programmatically:
148
+
149
+ ```ts
150
+ import { startClassifiersUi } from "@swapai/core/classifiers-ui";
151
+
152
+ const monitor = await startClassifiersUi({
153
+ dataDirectory: ".swapai",
154
+ host: "127.0.0.1",
155
+ port: 4789,
156
+ });
157
+ ```
158
+
116
159
  ## Runtime
117
160
 
118
161
  Needle runs locally. On first use SwapAI creates its private runtime under
@@ -0,0 +1,253 @@
1
+ // src/classifiers-ui.ts
2
+ import { createServer } from "http";
3
+ import { resolve } from "path";
4
+
5
+ // src/status.ts
6
+ import { existsSync } from "fs";
7
+ import { createRequire } from "module";
8
+ import { join } from "path";
9
+ var { DatabaseSync } = createRequire(import.meta.url)("node:sqlite");
10
+ var DEFAULT_LIVE_WITHIN_MS = 1e4;
11
+ function readClassifierStatuses(options) {
12
+ const liveWithinMs = options.liveWithinMs ?? DEFAULT_LIVE_WITHIN_MS;
13
+ if (!Number.isSafeInteger(liveWithinMs) || liveWithinMs <= 0) {
14
+ throw new TypeError("liveWithinMs must be a positive integer");
15
+ }
16
+ const databasePath = join(options.dataDirectory, "swapai.sqlite");
17
+ if (!existsSync(databasePath)) return [];
18
+ const database = new DatabaseSync(databasePath, { readOnly: true });
19
+ try {
20
+ database.exec("PRAGMA busy_timeout = 5000");
21
+ if (!hasTable(database, "classifiers") || !hasTable(database, "generations")) {
22
+ return [];
23
+ }
24
+ const columns = tableColumns(database, "classifiers");
25
+ const errorColumn = columns.has("last_evaluated_error") ? "c.last_evaluated_error" : "NULL";
26
+ const evaluatedAtColumn = columns.has("last_evaluated_at") ? "c.last_evaluated_at" : "NULL";
27
+ const rows = database.prepare(`
28
+ SELECT
29
+ c.name,
30
+ c.config_json,
31
+ c.total_examples_logged,
32
+ c.new_examples_since_training,
33
+ c.training_attempts,
34
+ c.examples_used_for_training,
35
+ ${errorColumn} AS last_evaluated_error,
36
+ ${evaluatedAtColumn} AS last_evaluated_at,
37
+ c.total_local_classifications,
38
+ c.total_retests,
39
+ c.consecutive_retest_failures,
40
+ c.training_lease_owner,
41
+ c.training_lease_until,
42
+ c.updated_at,
43
+ g.trained,
44
+ g.needle_version,
45
+ (
46
+ SELECT COUNT(*)
47
+ FROM examples e
48
+ WHERE e.classifier_name = c.name
49
+ AND e.generation = c.active_generation
50
+ ) AS retained_examples
51
+ FROM classifiers c
52
+ JOIN generations g
53
+ ON g.classifier_name = c.name
54
+ AND g.generation = c.active_generation
55
+ ORDER BY c.name COLLATE NOCASE, c.name
56
+ `).all().map((value) => value);
57
+ const now = Date.now();
58
+ const liveAfter = now - liveWithinMs;
59
+ const runtimes = hasTable(database, "classifier_runtimes") ? database.prepare(`
60
+ SELECT classifier_name, runtime_id
61
+ FROM classifier_runtimes
62
+ WHERE heartbeat_at >= ?
63
+ `).all(liveAfter).map((value) => value) : [];
64
+ const liveByClassifier = /* @__PURE__ */ new Map();
65
+ for (const runtime of runtimes) {
66
+ const existing = liveByClassifier.get(runtime.classifier_name) ?? /* @__PURE__ */ new Set();
67
+ existing.add(runtime.runtime_id);
68
+ liveByClassifier.set(runtime.classifier_name, existing);
69
+ }
70
+ return rows.map((stored) => {
71
+ const config = parseConfig(stored.config_json);
72
+ const liveRuntimes = liveByClassifier.get(stored.name) ?? /* @__PURE__ */ new Set();
73
+ return {
74
+ name: stored.name,
75
+ resultType: config.resultType,
76
+ retainedExamples: stored.retained_examples,
77
+ totalExamplesLogged: stored.total_examples_logged,
78
+ newExamplesSinceTraining: stored.new_examples_since_training,
79
+ examplesUsedForLastTraining: stored.examples_used_for_training,
80
+ lastEvaluatedError: stored.last_evaluated_error,
81
+ lastEvaluatedAt: stored.last_evaluated_at,
82
+ acceptableError: config.acceptableError,
83
+ loaded: liveRuntimes.size > 0,
84
+ loadedProcessCount: liveRuntimes.size,
85
+ training: stored.training_lease_owner !== null && stored.training_lease_until !== null && stored.training_lease_until > now && liveRuntimes.has(stored.training_lease_owner),
86
+ trained: stored.trained === 1,
87
+ trainingAttempts: stored.training_attempts,
88
+ totalLocalClassifications: stored.total_local_classifications,
89
+ totalRetests: stored.total_retests,
90
+ consecutiveRetestFailures: stored.consecutive_retest_failures,
91
+ needleVersion: stored.needle_version,
92
+ updatedAt: stored.updated_at
93
+ };
94
+ });
95
+ } finally {
96
+ database.close();
97
+ }
98
+ }
99
+ function parseConfig(configJson) {
100
+ const config = JSON.parse(configJson);
101
+ const resultType = config.result?.type;
102
+ if (resultType !== "number" && resultType !== "boolean" && resultType !== "string") {
103
+ throw new Error("Stored classifier has an invalid result type");
104
+ }
105
+ const acceptableError = typeof config.acceptableError === "string" ? parsePercentage(config.acceptableError) : config.acceptableError;
106
+ if (typeof acceptableError !== "number" || !Number.isFinite(acceptableError) || acceptableError < 0 || acceptableError > 1) {
107
+ throw new Error("Stored classifier has an invalid acceptable error");
108
+ }
109
+ return { resultType, acceptableError };
110
+ }
111
+ function parsePercentage(value) {
112
+ const match = /^(\d+(?:\.\d+)?)%$/.exec(value);
113
+ return match === null ? Number.NaN : Number(match[1]) / 100;
114
+ }
115
+ function hasTable(database, name) {
116
+ return database.prepare(`
117
+ SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?
118
+ `).get(name) !== void 0;
119
+ }
120
+ function tableColumns(database, table) {
121
+ return new Set(
122
+ database.prepare(`PRAGMA table_info(${table})`).all().map(
123
+ (value) => value.name
124
+ )
125
+ );
126
+ }
127
+
128
+ // src/classifiers-ui.ts
129
+ async function startClassifiersUi(options = {}) {
130
+ const dataDirectory = resolve(
131
+ options.dataDirectory ?? process.env.SWAPAI_DATA_DIRECTORY ?? ".swapai"
132
+ );
133
+ const host = options.host ?? "127.0.0.1";
134
+ const port = options.port ?? 4789;
135
+ if (!Number.isSafeInteger(port) || port < 0 || port > 65535) {
136
+ throw new TypeError("port must be a whole number between 0 and 65535");
137
+ }
138
+ const server = createServer((request, response) => {
139
+ setHeaders(response);
140
+ if (request.method !== "GET" && request.method !== "HEAD") {
141
+ response.writeHead(405, { Allow: "GET, HEAD" });
142
+ response.end();
143
+ return;
144
+ }
145
+ const pathname = new URL(request.url ?? "/", "http://localhost").pathname;
146
+ if (pathname === "/health") {
147
+ sendJson(response, 200, { service: "swapai-classifiers-ui", status: "ok" }, request.method);
148
+ return;
149
+ }
150
+ if (pathname === "/api/classifiers") {
151
+ try {
152
+ sendJson(response, 200, {
153
+ generatedAt: Date.now(),
154
+ classifiers: readClassifierStatuses({ dataDirectory })
155
+ }, request.method);
156
+ } catch (error) {
157
+ sendJson(response, 500, {
158
+ error: error instanceof Error ? error.message : "Could not read classifier status"
159
+ }, request.method);
160
+ }
161
+ return;
162
+ }
163
+ if (pathname === "/") {
164
+ const body = request.method === "HEAD" ? void 0 : PAGE;
165
+ response.writeHead(200, {
166
+ "Content-Type": "text/html; charset=utf-8",
167
+ "Content-Length": Buffer.byteLength(PAGE)
168
+ });
169
+ response.end(body);
170
+ return;
171
+ }
172
+ response.writeHead(404);
173
+ response.end();
174
+ });
175
+ await new Promise((resolveListen, reject) => {
176
+ server.once("error", reject);
177
+ server.listen(port, host, () => {
178
+ server.off("error", reject);
179
+ resolveListen();
180
+ });
181
+ });
182
+ const address = server.address();
183
+ if (address === null || typeof address === "string") {
184
+ server.close();
185
+ throw new Error("Classifier monitor did not start on a TCP port");
186
+ }
187
+ const displayHost = host.includes(":") ? `[${host}]` : host;
188
+ return {
189
+ url: `http://${displayHost}:${address.port}/`,
190
+ close: () => new Promise((resolveClose, reject) => {
191
+ server.close((error) => error === void 0 ? resolveClose() : reject(error));
192
+ })
193
+ };
194
+ }
195
+ function setHeaders(response) {
196
+ response.setHeader("Cache-Control", "no-store");
197
+ response.setHeader("X-Content-Type-Options", "nosniff");
198
+ response.setHeader("Referrer-Policy", "no-referrer");
199
+ response.setHeader(
200
+ "Content-Security-Policy",
201
+ "default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'"
202
+ );
203
+ }
204
+ function sendJson(response, status, value, method) {
205
+ const serialized = JSON.stringify(value);
206
+ response.writeHead(status, {
207
+ "Content-Type": "application/json; charset=utf-8",
208
+ "Content-Length": Buffer.byteLength(serialized)
209
+ });
210
+ response.end(method === "HEAD" ? void 0 : serialized);
211
+ }
212
+ var PAGE = String.raw`<!doctype html>
213
+ <html lang="en">
214
+ <head>
215
+ <meta charset="utf-8">
216
+ <meta name="viewport" content="width=device-width, initial-scale=1">
217
+ <title>SwapAI classifiers</title>
218
+ <style>
219
+ :root { color-scheme: dark; --bg:#0b0b0c; --panel:#121214; --line:#26262a; --muted:#929299; --text:#f5f5f4; --yellow:#f7e45c; --green:#62d692; --blue:#77b7ff; --red:#ff7c7c; }
220
+ * { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
221
+ header { position:sticky; top:0; z-index:2; display:flex; align-items:center; justify-content:space-between; padding:18px 28px; background:rgba(11,11,12,.9); border-bottom:1px solid var(--line); backdrop-filter:blur(14px); }
222
+ .brand { display:flex; gap:12px; align-items:center; font-weight:700; font-size:16px } .mark { width:22px; height:22px; border-radius:50%; background:linear-gradient(135deg,#171719 22%,var(--yellow) 72%); box-shadow:0 0 20px #f7e45c33; }
223
+ .updated { color:var(--muted); font-size:12px } main { width:min(1180px,calc(100% - 40px)); margin:0 auto; padding:54px 0 80px; }
224
+ h1 { margin:0; font-size:clamp(30px,5vw,48px); letter-spacing:-.045em } .intro { color:var(--muted); font-size:17px; margin:8px 0 32px }
225
+ .summary { display:flex; gap:10px; margin-bottom:18px; flex-wrap:wrap } .summary span,.pill { border:1px solid var(--line); background:#171719; border-radius:999px; padding:5px 10px; }
226
+ .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(330px,1fr)); gap:14px } .card { background:var(--panel); border:1px solid var(--line); border-radius:15px; padding:20px; box-shadow:0 12px 45px #0003; }
227
+ .card-head { display:flex; justify-content:space-between; gap:15px; align-items:flex-start; margin-bottom:20px } h2 { margin:0; font-size:18px; letter-spacing:-.02em; overflow-wrap:anywhere } .type { color:var(--muted); font-size:12px; margin-top:3px }
228
+ .states { display:flex; flex-wrap:wrap; gap:6px; justify-content:flex-end } .pill { padding:3px 8px; font-size:11px; color:var(--muted) } .pill.on { color:var(--green); border-color:#62d69255; background:#62d69210 } .pill.training { color:var(--blue); border-color:#77b7ff55; background:#77b7ff10 }
229
+ dl { display:grid; grid-template-columns:1fr auto; margin:0; gap:0 } dt,dd { margin:0; padding:11px 0; border-top:1px solid var(--line) } dt { color:var(--muted) } dd { font-variant-numeric:tabular-nums; font-weight:600; text-align:right } .good { color:var(--green) } .bad { color:var(--red) }
230
+ .bar { grid-column:1/-1; height:5px; padding:0; margin:-3px 0 10px; border:0; border-radius:5px; background:#252529; overflow:hidden } .bar i { display:block; height:100%; background:var(--yellow); border-radius:inherit }
231
+ .empty,.error { border:1px dashed var(--line); border-radius:15px; padding:42px; color:var(--muted); text-align:center } .error { color:var(--red) }
232
+ @media (max-width:600px) { header { padding:15px 18px } main { width:min(100% - 28px,1180px); padding-top:36px } .grid { grid-template-columns:1fr } }
233
+ </style>
234
+ </head>
235
+ <body>
236
+ <header><div class="brand"><span class="mark"></span>SwapAI</div><div class="updated" id="updated">Connecting…</div></header>
237
+ <main><h1>Classifiers</h1><p class="intro">Live training progress and measured accuracy from this SwapAI data directory.</p><div id="summary" class="summary"></div><div id="content"></div></main>
238
+ <script>
239
+ const content=document.querySelector('#content'),summary=document.querySelector('#summary'),updated=document.querySelector('#updated');
240
+ const esc=value=>String(value).replace(/[&<>"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[char]));
241
+ const pct=value=>value===null?'Not measured':new Intl.NumberFormat(undefined,{style:'percent',maximumFractionDigits:2}).format(value);
242
+ const count=value=>new Intl.NumberFormat().format(value);
243
+ async function refresh(){try{const response=await fetch('/api/classifiers',{cache:'no-store'});if(!response.ok)throw new Error('Status API returned '+response.status);const data=await response.json(),items=data.classifiers;updated.textContent='Updated '+new Date(data.generatedAt).toLocaleTimeString();summary.innerHTML='<span>'+count(items.length)+' classifiers</span><span>'+count(items.filter(x=>x.loaded).length)+' loaded</span><span>'+count(items.filter(x=>x.trained).length)+' trained</span><span>'+count(items.filter(x=>x.training).length)+' training</span>';if(items.length===0){content.innerHTML='<div class="empty">No classifiers have used this data directory yet.</div>';return}content.className='grid';content.innerHTML=items.map(item=>{const measured=item.lastEvaluatedError,within=measured!==null&&measured<=item.acceptableError;const progress=measured===null?0:Math.min(100,item.acceptableError===0?(measured===0?100:0):item.acceptableError/measured*100);return '<article class="card"><div class="card-head"><div><h2>'+esc(item.name)+'</h2><div class="type">'+esc(item.resultType)+' result · '+esc(item.needleVersion||'No model yet')+'</div></div><div class="states"><span class="pill '+(item.loaded?'on':'')+'">'+(item.loaded?'Loaded':'Not loaded')+(item.loadedProcessCount>1?' ×'+item.loadedProcessCount:'')+'</span><span class="pill '+(item.training?'training':'')+'">'+(item.training?'Training':'Idle')+'</span><span class="pill '+(item.trained?'on':'')+'">'+(item.trained?'Trained':'Learning')+'</span></div></div><dl><dt>Collected examples</dt><dd>'+count(item.retainedExamples)+'</dd><dt>Lifetime examples</dt><dd>'+count(item.totalExamplesLogged)+'</dd><dt>Last measured error</dt><dd class="'+(within?'good':measured===null?'':'bad')+'">'+pct(measured)+'</dd><dt>Target error</dt><dd>'+pct(item.acceptableError)+' or less</dd><div class="bar"><i style="width:'+progress+'%"></i></div><dt>Training attempts</dt><dd>'+count(item.trainingAttempts)+'</dd><dt>Local classifications</dt><dd>'+count(item.totalLocalClassifications)+'</dd></dl></article>'}).join('')}catch(error){updated.textContent='Disconnected';content.className='';content.innerHTML='<div class="error">'+esc(error.message||error)+'</div>'}}
244
+ refresh();setInterval(refresh,3000);
245
+ </script>
246
+ </body>
247
+ </html>`;
248
+
249
+ export {
250
+ readClassifierStatuses,
251
+ startClassifiersUi
252
+ };
253
+ //# sourceMappingURL=chunk-AZVNSHXP.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/classifiers-ui.ts","../src/status.ts"],"sourcesContent":["import { createServer, type ServerResponse } from \"node:http\";\nimport { resolve } from \"node:path\";\n\nimport { readClassifierStatuses } from \"./status.js\";\n\nexport { readClassifierStatuses } from \"./status.js\";\nexport type {\n ClassifierStatus,\n ReadClassifierStatusesOptions,\n} from \"./status.js\";\n\nexport interface StartClassifiersUiOptions {\n readonly dataDirectory?: string;\n readonly host?: string;\n readonly port?: number;\n}\n\nexport interface ClassifiersUiServer {\n readonly url: string;\n close(): Promise<void>;\n}\n\nexport async function startClassifiersUi(\n options: StartClassifiersUiOptions = {},\n): Promise<ClassifiersUiServer> {\n const dataDirectory = resolve(\n options.dataDirectory ?? process.env.SWAPAI_DATA_DIRECTORY ?? \".swapai\",\n );\n const host = options.host ?? \"127.0.0.1\";\n const port = options.port ?? 4789;\n if (!Number.isSafeInteger(port) || port < 0 || port > 65_535) {\n throw new TypeError(\"port must be a whole number between 0 and 65535\");\n }\n\n const server = createServer((request, response) => {\n setHeaders(response);\n if (request.method !== \"GET\" && request.method !== \"HEAD\") {\n response.writeHead(405, { Allow: \"GET, HEAD\" });\n response.end();\n return;\n }\n const pathname = new URL(request.url ?? \"/\", \"http://localhost\").pathname;\n if (pathname === \"/health\") {\n sendJson(response, 200, { service: \"swapai-classifiers-ui\", status: \"ok\" }, request.method);\n return;\n }\n if (pathname === \"/api/classifiers\") {\n try {\n sendJson(response, 200, {\n generatedAt: Date.now(),\n classifiers: readClassifierStatuses({ dataDirectory }),\n }, request.method);\n } catch (error) {\n sendJson(response, 500, {\n error: error instanceof Error ? error.message : \"Could not read classifier status\",\n }, request.method);\n }\n return;\n }\n if (pathname === \"/\") {\n const body = request.method === \"HEAD\" ? undefined : PAGE;\n response.writeHead(200, {\n \"Content-Type\": \"text/html; charset=utf-8\",\n \"Content-Length\": Buffer.byteLength(PAGE),\n });\n response.end(body);\n return;\n }\n response.writeHead(404);\n response.end();\n });\n\n await new Promise<void>((resolveListen, reject) => {\n server.once(\"error\", reject);\n server.listen(port, host, () => {\n server.off(\"error\", reject);\n resolveListen();\n });\n });\n const address = server.address();\n if (address === null || typeof address === \"string\") {\n server.close();\n throw new Error(\"Classifier monitor did not start on a TCP port\");\n }\n const displayHost = host.includes(\":\") ? `[${host}]` : host;\n return {\n url: `http://${displayHost}:${address.port}/`,\n close: () => new Promise<void>((resolveClose, reject) => {\n server.close((error) => error === undefined ? resolveClose() : reject(error));\n }),\n };\n}\n\nfunction setHeaders(response: ServerResponse): void {\n response.setHeader(\"Cache-Control\", \"no-store\");\n response.setHeader(\"X-Content-Type-Options\", \"nosniff\");\n response.setHeader(\"Referrer-Policy\", \"no-referrer\");\n response.setHeader(\n \"Content-Security-Policy\",\n \"default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; frame-ancestors 'none'\",\n );\n}\n\nfunction sendJson(\n response: ServerResponse,\n status: number,\n value: unknown,\n method: string | undefined,\n): void {\n const serialized = JSON.stringify(value);\n response.writeHead(status, {\n \"Content-Type\": \"application/json; charset=utf-8\",\n \"Content-Length\": Buffer.byteLength(serialized),\n });\n response.end(method === \"HEAD\" ? undefined : serialized);\n}\n\nconst PAGE = String.raw`<!doctype html>\n<html lang=\"en\">\n<head>\n <meta charset=\"utf-8\">\n <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n <title>SwapAI classifiers</title>\n <style>\n :root { color-scheme: dark; --bg:#0b0b0c; --panel:#121214; --line:#26262a; --muted:#929299; --text:#f5f5f4; --yellow:#f7e45c; --green:#62d692; --blue:#77b7ff; --red:#ff7c7c; }\n * { box-sizing:border-box } body { margin:0; background:var(--bg); color:var(--text); font:14px/1.5 ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif; }\n header { position:sticky; top:0; z-index:2; display:flex; align-items:center; justify-content:space-between; padding:18px 28px; background:rgba(11,11,12,.9); border-bottom:1px solid var(--line); backdrop-filter:blur(14px); }\n .brand { display:flex; gap:12px; align-items:center; font-weight:700; font-size:16px } .mark { width:22px; height:22px; border-radius:50%; background:linear-gradient(135deg,#171719 22%,var(--yellow) 72%); box-shadow:0 0 20px #f7e45c33; }\n .updated { color:var(--muted); font-size:12px } main { width:min(1180px,calc(100% - 40px)); margin:0 auto; padding:54px 0 80px; }\n h1 { margin:0; font-size:clamp(30px,5vw,48px); letter-spacing:-.045em } .intro { color:var(--muted); font-size:17px; margin:8px 0 32px }\n .summary { display:flex; gap:10px; margin-bottom:18px; flex-wrap:wrap } .summary span,.pill { border:1px solid var(--line); background:#171719; border-radius:999px; padding:5px 10px; }\n .grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(330px,1fr)); gap:14px } .card { background:var(--panel); border:1px solid var(--line); border-radius:15px; padding:20px; box-shadow:0 12px 45px #0003; }\n .card-head { display:flex; justify-content:space-between; gap:15px; align-items:flex-start; margin-bottom:20px } h2 { margin:0; font-size:18px; letter-spacing:-.02em; overflow-wrap:anywhere } .type { color:var(--muted); font-size:12px; margin-top:3px }\n .states { display:flex; flex-wrap:wrap; gap:6px; justify-content:flex-end } .pill { padding:3px 8px; font-size:11px; color:var(--muted) } .pill.on { color:var(--green); border-color:#62d69255; background:#62d69210 } .pill.training { color:var(--blue); border-color:#77b7ff55; background:#77b7ff10 }\n dl { display:grid; grid-template-columns:1fr auto; margin:0; gap:0 } dt,dd { margin:0; padding:11px 0; border-top:1px solid var(--line) } dt { color:var(--muted) } dd { font-variant-numeric:tabular-nums; font-weight:600; text-align:right } .good { color:var(--green) } .bad { color:var(--red) }\n .bar { grid-column:1/-1; height:5px; padding:0; margin:-3px 0 10px; border:0; border-radius:5px; background:#252529; overflow:hidden } .bar i { display:block; height:100%; background:var(--yellow); border-radius:inherit }\n .empty,.error { border:1px dashed var(--line); border-radius:15px; padding:42px; color:var(--muted); text-align:center } .error { color:var(--red) }\n @media (max-width:600px) { header { padding:15px 18px } main { width:min(100% - 28px,1180px); padding-top:36px } .grid { grid-template-columns:1fr } }\n </style>\n</head>\n<body>\n <header><div class=\"brand\"><span class=\"mark\"></span>SwapAI</div><div class=\"updated\" id=\"updated\">Connecting…</div></header>\n <main><h1>Classifiers</h1><p class=\"intro\">Live training progress and measured accuracy from this SwapAI data directory.</p><div id=\"summary\" class=\"summary\"></div><div id=\"content\"></div></main>\n <script>\n const content=document.querySelector('#content'),summary=document.querySelector('#summary'),updated=document.querySelector('#updated');\n const esc=value=>String(value).replace(/[&<>\"']/g,char=>({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[char]));\n const pct=value=>value===null?'Not measured':new Intl.NumberFormat(undefined,{style:'percent',maximumFractionDigits:2}).format(value);\n const count=value=>new Intl.NumberFormat().format(value);\n async function refresh(){try{const response=await fetch('/api/classifiers',{cache:'no-store'});if(!response.ok)throw new Error('Status API returned '+response.status);const data=await response.json(),items=data.classifiers;updated.textContent='Updated '+new Date(data.generatedAt).toLocaleTimeString();summary.innerHTML='<span>'+count(items.length)+' classifiers</span><span>'+count(items.filter(x=>x.loaded).length)+' loaded</span><span>'+count(items.filter(x=>x.trained).length)+' trained</span><span>'+count(items.filter(x=>x.training).length)+' training</span>';if(items.length===0){content.innerHTML='<div class=\"empty\">No classifiers have used this data directory yet.</div>';return}content.className='grid';content.innerHTML=items.map(item=>{const measured=item.lastEvaluatedError,within=measured!==null&&measured<=item.acceptableError;const progress=measured===null?0:Math.min(100,item.acceptableError===0?(measured===0?100:0):item.acceptableError/measured*100);return '<article class=\"card\"><div class=\"card-head\"><div><h2>'+esc(item.name)+'</h2><div class=\"type\">'+esc(item.resultType)+' result · '+esc(item.needleVersion||'No model yet')+'</div></div><div class=\"states\"><span class=\"pill '+(item.loaded?'on':'')+'\">'+(item.loaded?'Loaded':'Not loaded')+(item.loadedProcessCount>1?' ×'+item.loadedProcessCount:'')+'</span><span class=\"pill '+(item.training?'training':'')+'\">'+(item.training?'Training':'Idle')+'</span><span class=\"pill '+(item.trained?'on':'')+'\">'+(item.trained?'Trained':'Learning')+'</span></div></div><dl><dt>Collected examples</dt><dd>'+count(item.retainedExamples)+'</dd><dt>Lifetime examples</dt><dd>'+count(item.totalExamplesLogged)+'</dd><dt>Last measured error</dt><dd class=\"'+(within?'good':measured===null?'':'bad')+'\">'+pct(measured)+'</dd><dt>Target error</dt><dd>'+pct(item.acceptableError)+' or less</dd><div class=\"bar\"><i style=\"width:'+progress+'%\"></i></div><dt>Training attempts</dt><dd>'+count(item.trainingAttempts)+'</dd><dt>Local classifications</dt><dd>'+count(item.totalLocalClassifications)+'</dd></dl></article>'}).join('')}catch(error){updated.textContent='Disconnected';content.className='';content.innerHTML='<div class=\"error\">'+esc(error.message||error)+'</div>'}}\n refresh();setInterval(refresh,3000);\n </script>\n</body>\n</html>`;\n","import { existsSync } from \"node:fs\";\nimport { createRequire } from \"node:module\";\nimport { join } from \"node:path\";\nimport type { DatabaseSync as DatabaseSyncType } from \"node:sqlite\";\n\nconst { DatabaseSync } = createRequire(import.meta.url)(\"node:sqlite\") as typeof import(\"node:sqlite\");\n\nconst DEFAULT_LIVE_WITHIN_MS = 10_000;\n\nexport interface ReadClassifierStatusesOptions {\n readonly dataDirectory: string;\n readonly liveWithinMs?: number;\n}\n\nexport interface ClassifierStatus {\n readonly name: string;\n readonly resultType: \"number\" | \"boolean\" | \"string\";\n readonly retainedExamples: number;\n readonly totalExamplesLogged: number;\n readonly newExamplesSinceTraining: number;\n readonly examplesUsedForLastTraining: number;\n readonly lastEvaluatedError: number | null;\n readonly lastEvaluatedAt: number | null;\n readonly acceptableError: number;\n readonly loaded: boolean;\n readonly loadedProcessCount: number;\n readonly training: boolean;\n readonly trained: boolean;\n readonly trainingAttempts: number;\n readonly totalLocalClassifications: number;\n readonly totalRetests: number;\n readonly consecutiveRetestFailures: number;\n readonly needleVersion: string | null;\n readonly updatedAt: number;\n}\n\ninterface StatusRow {\n name: string;\n config_json: string;\n total_examples_logged: number;\n new_examples_since_training: number;\n training_attempts: number;\n examples_used_for_training: number;\n last_evaluated_error: number | null;\n last_evaluated_at: number | null;\n total_local_classifications: number;\n total_retests: number;\n consecutive_retest_failures: number;\n training_lease_owner: string | null;\n training_lease_until: number | null;\n updated_at: number;\n trained: number;\n needle_version: string | null;\n retained_examples: number;\n}\n\ninterface RuntimeRow {\n classifier_name: string;\n runtime_id: string;\n}\n\nexport function readClassifierStatuses(\n options: ReadClassifierStatusesOptions,\n): ClassifierStatus[] {\n const liveWithinMs = options.liveWithinMs ?? DEFAULT_LIVE_WITHIN_MS;\n if (!Number.isSafeInteger(liveWithinMs) || liveWithinMs <= 0) {\n throw new TypeError(\"liveWithinMs must be a positive integer\");\n }\n const databasePath = join(options.dataDirectory, \"swapai.sqlite\");\n if (!existsSync(databasePath)) return [];\n\n const database = new DatabaseSync(databasePath, { readOnly: true });\n try {\n database.exec(\"PRAGMA busy_timeout = 5000\");\n if (!hasTable(database, \"classifiers\") || !hasTable(database, \"generations\")) {\n return [];\n }\n const columns = tableColumns(database, \"classifiers\");\n const errorColumn = columns.has(\"last_evaluated_error\")\n ? \"c.last_evaluated_error\"\n : \"NULL\";\n const evaluatedAtColumn = columns.has(\"last_evaluated_at\")\n ? \"c.last_evaluated_at\"\n : \"NULL\";\n const rows = database.prepare(`\n SELECT\n c.name,\n c.config_json,\n c.total_examples_logged,\n c.new_examples_since_training,\n c.training_attempts,\n c.examples_used_for_training,\n ${errorColumn} AS last_evaluated_error,\n ${evaluatedAtColumn} AS last_evaluated_at,\n c.total_local_classifications,\n c.total_retests,\n c.consecutive_retest_failures,\n c.training_lease_owner,\n c.training_lease_until,\n c.updated_at,\n g.trained,\n g.needle_version,\n (\n SELECT COUNT(*)\n FROM examples e\n WHERE e.classifier_name = c.name\n AND e.generation = c.active_generation\n ) AS retained_examples\n FROM classifiers c\n JOIN generations g\n ON g.classifier_name = c.name\n AND g.generation = c.active_generation\n ORDER BY c.name COLLATE NOCASE, c.name\n `).all().map((value) => value as unknown as StatusRow);\n\n const now = Date.now();\n const liveAfter = now - liveWithinMs;\n const runtimes = hasTable(database, \"classifier_runtimes\")\n ? database.prepare(`\n SELECT classifier_name, runtime_id\n FROM classifier_runtimes\n WHERE heartbeat_at >= ?\n `).all(liveAfter).map((value) => value as unknown as RuntimeRow)\n : [];\n const liveByClassifier = new Map<string, Set<string>>();\n for (const runtime of runtimes) {\n const existing = liveByClassifier.get(runtime.classifier_name) ?? new Set<string>();\n existing.add(runtime.runtime_id);\n liveByClassifier.set(runtime.classifier_name, existing);\n }\n\n return rows.map((stored) => {\n const config = parseConfig(stored.config_json);\n const liveRuntimes = liveByClassifier.get(stored.name) ?? new Set<string>();\n return {\n name: stored.name,\n resultType: config.resultType,\n retainedExamples: stored.retained_examples,\n totalExamplesLogged: stored.total_examples_logged,\n newExamplesSinceTraining: stored.new_examples_since_training,\n examplesUsedForLastTraining: stored.examples_used_for_training,\n lastEvaluatedError: stored.last_evaluated_error,\n lastEvaluatedAt: stored.last_evaluated_at,\n acceptableError: config.acceptableError,\n loaded: liveRuntimes.size > 0,\n loadedProcessCount: liveRuntimes.size,\n training:\n stored.training_lease_owner !== null &&\n stored.training_lease_until !== null &&\n stored.training_lease_until > now &&\n liveRuntimes.has(stored.training_lease_owner),\n trained: stored.trained === 1,\n trainingAttempts: stored.training_attempts,\n totalLocalClassifications: stored.total_local_classifications,\n totalRetests: stored.total_retests,\n consecutiveRetestFailures: stored.consecutive_retest_failures,\n needleVersion: stored.needle_version,\n updatedAt: stored.updated_at,\n };\n });\n } finally {\n database.close();\n }\n}\n\nfunction parseConfig(configJson: string): {\n resultType: \"number\" | \"boolean\" | \"string\";\n acceptableError: number;\n} {\n const config = JSON.parse(configJson) as {\n result?: { type?: unknown };\n acceptableError?: unknown;\n };\n const resultType = config.result?.type;\n if (resultType !== \"number\" && resultType !== \"boolean\" && resultType !== \"string\") {\n throw new Error(\"Stored classifier has an invalid result type\");\n }\n const acceptableError = typeof config.acceptableError === \"string\"\n ? parsePercentage(config.acceptableError)\n : config.acceptableError;\n if (\n typeof acceptableError !== \"number\" ||\n !Number.isFinite(acceptableError) ||\n acceptableError < 0 ||\n acceptableError > 1\n ) {\n throw new Error(\"Stored classifier has an invalid acceptable error\");\n }\n return { resultType, acceptableError };\n}\n\nfunction parsePercentage(value: string): number {\n const match = /^(\\d+(?:\\.\\d+)?)%$/.exec(value);\n return match === null ? Number.NaN : Number(match[1]) / 100;\n}\n\nfunction hasTable(database: DatabaseSyncType, name: string): boolean {\n return database.prepare(`\n SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?\n `).get(name) !== undefined;\n}\n\nfunction tableColumns(database: DatabaseSyncType, table: string): Set<string> {\n return new Set(\n database.prepare(`PRAGMA table_info(${table})`).all().map((value) =>\n (value as { name: string }).name,\n ),\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,39 @@
1
+ interface ReadClassifierStatusesOptions {
2
+ readonly dataDirectory: string;
3
+ readonly liveWithinMs?: number;
4
+ }
5
+ interface ClassifierStatus {
6
+ readonly name: string;
7
+ readonly resultType: "number" | "boolean" | "string";
8
+ readonly retainedExamples: number;
9
+ readonly totalExamplesLogged: number;
10
+ readonly newExamplesSinceTraining: number;
11
+ readonly examplesUsedForLastTraining: number;
12
+ readonly lastEvaluatedError: number | null;
13
+ readonly lastEvaluatedAt: number | null;
14
+ readonly acceptableError: number;
15
+ readonly loaded: boolean;
16
+ readonly loadedProcessCount: number;
17
+ readonly training: boolean;
18
+ readonly trained: boolean;
19
+ readonly trainingAttempts: number;
20
+ readonly totalLocalClassifications: number;
21
+ readonly totalRetests: number;
22
+ readonly consecutiveRetestFailures: number;
23
+ readonly needleVersion: string | null;
24
+ readonly updatedAt: number;
25
+ }
26
+ declare function readClassifierStatuses(options: ReadClassifierStatusesOptions): ClassifierStatus[];
27
+
28
+ interface StartClassifiersUiOptions {
29
+ readonly dataDirectory?: string;
30
+ readonly host?: string;
31
+ readonly port?: number;
32
+ }
33
+ interface ClassifiersUiServer {
34
+ readonly url: string;
35
+ close(): Promise<void>;
36
+ }
37
+ declare function startClassifiersUi(options?: StartClassifiersUiOptions): Promise<ClassifiersUiServer>;
38
+
39
+ export { type ClassifierStatus, type ClassifiersUiServer, type ReadClassifierStatusesOptions, type StartClassifiersUiOptions, readClassifierStatuses, startClassifiersUi };
@@ -0,0 +1,9 @@
1
+ import {
2
+ readClassifierStatuses,
3
+ startClassifiersUi
4
+ } from "./chunk-AZVNSHXP.js";
5
+ export {
6
+ readClassifierStatuses,
7
+ startClassifiersUi
8
+ };
9
+ //# sourceMappingURL=classifiers-ui.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/dist/cli.d.ts ADDED
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node
package/dist/cli.js ADDED
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ startClassifiersUi
4
+ } from "./chunk-AZVNSHXP.js";
5
+
6
+ // src/cli.ts
7
+ var args = process.argv.slice(2);
8
+ var command = args.shift();
9
+ if (command === "--help" || command === "-h" || command === void 0) {
10
+ printHelp();
11
+ process.exit(command === void 0 ? 1 : 0);
12
+ }
13
+ if (command !== "classifiers-ui") {
14
+ console.error(`Unknown command: ${command}`);
15
+ printHelp();
16
+ process.exit(1);
17
+ }
18
+ var options = parseOptions(args);
19
+ var server = await startClassifiersUi(options);
20
+ console.log(`SwapAI classifier monitor: ${server.url}`);
21
+ console.log(`Data directory: ${options.dataDirectory ?? process.env.SWAPAI_DATA_DIRECTORY ?? ".swapai"}`);
22
+ for (const signal of ["SIGINT", "SIGTERM"]) {
23
+ process.once(signal, () => {
24
+ void server.close().finally(() => process.exit(0));
25
+ });
26
+ }
27
+ function parseOptions(values) {
28
+ const options2 = {};
29
+ for (let index = 0; index < values.length; index += 1) {
30
+ const flag = values[index];
31
+ if (flag === "--help" || flag === "-h") {
32
+ printHelp();
33
+ process.exit(0);
34
+ }
35
+ const value = values[index + 1];
36
+ if (value === void 0) throw new TypeError(`${flag} needs a value`);
37
+ if (flag === "--data-directory") options2.dataDirectory = value;
38
+ else if (flag === "--host") options2.host = value;
39
+ else if (flag === "--port") options2.port = Number(value);
40
+ else throw new TypeError(`Unknown option: ${flag}`);
41
+ index += 1;
42
+ }
43
+ return options2;
44
+ }
45
+ function printHelp() {
46
+ console.log(`Usage: swapai classifiers-ui [options]
47
+
48
+ Starts the local SwapAI classifier monitor.
49
+
50
+ Options:
51
+ --data-directory <path> SwapAI data directory (default: .swapai)
52
+ --host <host> Listen host (default: 127.0.0.1)
53
+ --port <port> Listen port (default: 4789)
54
+ -h, --help Show this help`);
55
+ }
56
+ //# sourceMappingURL=cli.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { startClassifiersUi } from \"./classifiers-ui.js\";\n\nconst args = process.argv.slice(2);\nconst command = args.shift();\n\nif (command === \"--help\" || command === \"-h\" || command === undefined) {\n printHelp();\n process.exit(command === undefined ? 1 : 0);\n}\nif (command !== \"classifiers-ui\") {\n console.error(`Unknown command: ${command}`);\n printHelp();\n process.exit(1);\n}\n\nconst options = parseOptions(args);\nconst server = await startClassifiersUi(options);\nconsole.log(`SwapAI classifier monitor: ${server.url}`);\nconsole.log(`Data directory: ${options.dataDirectory ?? process.env.SWAPAI_DATA_DIRECTORY ?? \".swapai\"}`);\n\nfor (const signal of [\"SIGINT\", \"SIGTERM\"] as const) {\n process.once(signal, () => {\n void server.close().finally(() => process.exit(0));\n });\n}\n\nfunction parseOptions(values: string[]): {\n dataDirectory?: string;\n host?: string;\n port?: number;\n} {\n const options: { dataDirectory?: string; host?: string; port?: number } = {};\n for (let index = 0; index < values.length; index += 1) {\n const flag = values[index];\n if (flag === \"--help\" || flag === \"-h\") {\n printHelp();\n process.exit(0);\n }\n const value = values[index + 1];\n if (value === undefined) throw new TypeError(`${flag} needs a value`);\n if (flag === \"--data-directory\") options.dataDirectory = value;\n else if (flag === \"--host\") options.host = value;\n else if (flag === \"--port\") options.port = Number(value);\n else throw new TypeError(`Unknown option: ${flag}`);\n index += 1;\n }\n return options;\n}\n\nfunction printHelp(): void {\n console.log(`Usage: swapai classifiers-ui [options]\n\nStarts the local SwapAI classifier monitor.\n\nOptions:\n --data-directory <path> SwapAI data directory (default: .swapai)\n --host <host> Listen host (default: 127.0.0.1)\n --port <port> Listen port (default: 4789)\n -h, --help Show this help`);\n}\n"],"mappings":";;;;;;AAGA,IAAM,OAAO,QAAQ,KAAK,MAAM,CAAC;AACjC,IAAM,UAAU,KAAK,MAAM;AAE3B,IAAI,YAAY,YAAY,YAAY,QAAQ,YAAY,QAAW;AACrE,YAAU;AACV,UAAQ,KAAK,YAAY,SAAY,IAAI,CAAC;AAC5C;AACA,IAAI,YAAY,kBAAkB;AAChC,UAAQ,MAAM,oBAAoB,OAAO,EAAE;AAC3C,YAAU;AACV,UAAQ,KAAK,CAAC;AAChB;AAEA,IAAM,UAAU,aAAa,IAAI;AACjC,IAAM,SAAS,MAAM,mBAAmB,OAAO;AAC/C,QAAQ,IAAI,8BAA8B,OAAO,GAAG,EAAE;AACtD,QAAQ,IAAI,mBAAmB,QAAQ,iBAAiB,QAAQ,IAAI,yBAAyB,SAAS,EAAE;AAExG,WAAW,UAAU,CAAC,UAAU,SAAS,GAAY;AACnD,UAAQ,KAAK,QAAQ,MAAM;AACzB,SAAK,OAAO,MAAM,EAAE,QAAQ,MAAM,QAAQ,KAAK,CAAC,CAAC;AAAA,EACnD,CAAC;AACH;AAEA,SAAS,aAAa,QAIpB;AACA,QAAMA,WAAoE,CAAC;AAC3E,WAAS,QAAQ,GAAG,QAAQ,OAAO,QAAQ,SAAS,GAAG;AACrD,UAAM,OAAO,OAAO,KAAK;AACzB,QAAI,SAAS,YAAY,SAAS,MAAM;AACtC,gBAAU;AACV,cAAQ,KAAK,CAAC;AAAA,IAChB;AACA,UAAM,QAAQ,OAAO,QAAQ,CAAC;AAC9B,QAAI,UAAU,OAAW,OAAM,IAAI,UAAU,GAAG,IAAI,gBAAgB;AACpE,QAAI,SAAS,mBAAoB,CAAAA,SAAQ,gBAAgB;AAAA,aAChD,SAAS,SAAU,CAAAA,SAAQ,OAAO;AAAA,aAClC,SAAS,SAAU,CAAAA,SAAQ,OAAO,OAAO,KAAK;AAAA,QAClD,OAAM,IAAI,UAAU,mBAAmB,IAAI,EAAE;AAClD,aAAS;AAAA,EACX;AACA,SAAOA;AACT;AAEA,SAAS,YAAkB;AACzB,UAAQ,IAAI;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,0CAQ4B;AAC1C;","names":["options"]}