@krtclcdy/tersio 2.23.0 → 2.24.1
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 +56 -45
- package/cli/agent-hosts.js +42 -0
- package/cli/agents.js +266 -0
- package/cli/banner.js +2 -3
- package/cli/common.js +16 -20
- package/cli/currency.js +11 -19
- package/cli/dashboard.js +216 -40
- package/cli/doctor-fix.js +40 -26
- package/cli/doctor.js +41 -41
- package/cli/hosts.js +114 -0
- package/cli/install.js +284 -179
- package/cli/interactive.js +62 -2
- package/cli/layer.js +22 -0
- package/cli/manifest.js +33 -0
- package/cli/omp-layer.js +51 -0
- package/cli/pi-layer.js +37 -0
- package/cli/pi-wiring.js +92 -0
- package/cli/profile.js +48 -48
- package/cli/reset.js +5 -32
- package/cli/rtk-wiring.js +7 -17
- package/cli/settings.js +3 -8
- package/cli/uninstall.js +162 -52
- package/cli/update.js +19 -27
- package/cli/usage.js +15 -5
- package/dashboard/dist/index.html +18 -18
- package/extensions/ai-addons-updater/index.js +37 -29
- package/extensions/ai-addons-updater/index.ts +39 -38
- package/extensions/caveman-session/index.js +19 -34
- package/extensions/caveman-session/index.ts +20 -35
- package/extensions/combo-toggle/index.js +47 -49
- package/extensions/combo-toggle/index.ts +42 -58
- package/extensions/lib/utils.js +40 -9
- package/extensions/lib/utils.ts +40 -9
- package/extensions/rtk-session/index.js +34 -38
- package/extensions/rtk-session/index.ts +32 -41
- package/extensions/shared/carbon.js +3 -11
- package/extensions/shared/carbon.ts +4 -11
- package/extensions/shared/host.js +58 -0
- package/extensions/shared/host.ts +94 -0
- package/extensions/shared/omp-prompt.js +12 -0
- package/extensions/shared/plugin-settings.js +33 -30
- package/extensions/shared/plugin-settings.ts +35 -26
- package/extensions/shared/pricing.js +25 -16
- package/extensions/shared/pricing.ts +27 -17
- package/extensions/shared/rtk-gain.js +6 -22
- package/extensions/shared/rtk-gain.ts +6 -22
- package/extensions/shared/session-state.js +44 -36
- package/extensions/shared/session-state.ts +48 -35
- package/extensions/shared/types.ts +10 -3
- package/extensions/shared/usage-ledger.js +160 -51
- package/extensions/shared/usage-ledger.ts +170 -67
- package/extensions/shared/usage-store.js +178 -24
- package/extensions/shared/usage-store.ts +176 -25
- package/extensions/tersio-commands/index.js +3 -6
- package/extensions/tersio-commands/index.ts +3 -6
- package/package.json +6 -5
- package/tersio.js +4 -6
- package/extensions/shared/mode-reinforcement.js +0 -26
package/cli/currency.js
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
// cli/currency.ts —
|
|
2
|
-
//
|
|
3
|
-
// converter (dashboard/app); the dashboard replaces rates with live
|
|
4
|
-
// frankfurter figures when reachable, the CLI always uses the snapshot.
|
|
1
|
+
// cli/currency.ts — USD-base currency helpers on offline snapshot rates. Same
|
|
2
|
+
// 10 currencies as the dashboard, which prefers live frankfurter figures.
|
|
5
3
|
import { existsSync, readFileSync } from 'node:fs';
|
|
6
|
-
import
|
|
7
|
-
import path from 'node:path';
|
|
4
|
+
import { tersioSettingsFile } from '../extensions/shared/plugin-settings.js';
|
|
8
5
|
const CURRENCY_CODES = [
|
|
9
6
|
'USD',
|
|
10
7
|
'PHP',
|
|
@@ -32,22 +29,16 @@ function parseCurrencyFlag(raw) {
|
|
|
32
29
|
}
|
|
33
30
|
return v;
|
|
34
31
|
}
|
|
35
|
-
// Stored default
|
|
36
|
-
// USD when missing, corrupt, or holding an unknown code. Sync so flag
|
|
37
|
-
// parsing in cli/common.ts can fall back to it at startup.
|
|
32
|
+
// Stored default; USD when missing, corrupt, or an unknown code.
|
|
38
33
|
function readStoredCurrency() {
|
|
39
|
-
const
|
|
40
|
-
|
|
41
|
-
if (!existsSync(lock))
|
|
34
|
+
const file = tersioSettingsFile();
|
|
35
|
+
if (!existsSync(file))
|
|
42
36
|
return DEFAULT_CURRENCY;
|
|
43
37
|
try {
|
|
44
|
-
const parsed = JSON.parse(readFileSync(
|
|
45
|
-
if (!parsed || typeof parsed !== 'object'
|
|
38
|
+
const parsed = JSON.parse(readFileSync(file, 'utf8'));
|
|
39
|
+
if (!parsed || typeof parsed !== 'object')
|
|
46
40
|
return DEFAULT_CURRENCY;
|
|
47
|
-
const
|
|
48
|
-
if (!entry || typeof entry !== 'object')
|
|
49
|
-
return DEFAULT_CURRENCY;
|
|
50
|
-
const raw = entry.currency;
|
|
41
|
+
const raw = parsed.currency;
|
|
51
42
|
return typeof raw === 'string' && isCurrencyCode(raw.trim().toUpperCase())
|
|
52
43
|
? raw.trim().toUpperCase()
|
|
53
44
|
: DEFAULT_CURRENCY;
|
|
@@ -68,7 +59,8 @@ const CURRENCY_META = {
|
|
|
68
59
|
CAD: { symbol: 'C$', decimals: 2 },
|
|
69
60
|
INR: { symbol: '₹', decimals: 2 },
|
|
70
61
|
};
|
|
71
|
-
|
|
62
|
+
// Exported so a test can hold it against the Dashboard's copy of the same table.
|
|
63
|
+
export const FX_SNAPSHOT_RATES = {
|
|
72
64
|
USD: 1,
|
|
73
65
|
PHP: 58.7,
|
|
74
66
|
EUR: 0.92,
|
package/cli/dashboard.js
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// Note: no Promise.withResolvers here — engines still allow Node 20.12,
|
|
4
|
-
// which lacks it; the listening server itself keeps the process alive.
|
|
1
|
+
// Serves the built Dashboard with local usage APIs. Binds 127.0.0.1 only;
|
|
2
|
+
// --export writes a file instead. No Promise.withResolvers: Node 20.12 lacks it.
|
|
5
3
|
import { spawn } from 'node:child_process';
|
|
6
4
|
import { execFileSync } from 'node:child_process';
|
|
7
5
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
@@ -12,9 +10,12 @@ import { fileURLToPath } from 'node:url';
|
|
|
12
10
|
import { clearUsageLedger, markReset, readUsage } from '../extensions/shared/usage-ledger.js';
|
|
13
11
|
import { pricesCachePath } from '../extensions/shared/pricing.js';
|
|
14
12
|
import { summarizeUsage } from './usage.js';
|
|
13
|
+
import { deleteUsageBackup, listUsageBackups, restoreUsageBackup } from '../extensions/shared/usage-store.js';
|
|
15
14
|
import { isCurrencyCode } from './currency.js';
|
|
16
15
|
import { OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_VERSION, } from './common.js';
|
|
17
|
-
import { storedProfile, writePluginSettings } from './profile.js';
|
|
16
|
+
import { BACKUP_SCHEDULES, storedProfile, writePluginSettings } from './profile.js';
|
|
17
|
+
import { normalizeComboLevel } from '../extensions/shared/session-state.js';
|
|
18
|
+
import { CAVEMAN_DEFAULTS, PONYTAIL_DEFAULTS } from './common.js';
|
|
18
19
|
import { PACKAGE_NAME } from './common.js';
|
|
19
20
|
import { resolveRtkBinary } from '../extensions/lib/utils.js';
|
|
20
21
|
const DASHBOARD_DIST = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'dashboard', 'dist');
|
|
@@ -52,10 +53,9 @@ async function brandDataUri() {
|
|
|
52
53
|
function dataJson() {
|
|
53
54
|
return JSON.stringify(summarizeUsage(readUsage()));
|
|
54
55
|
}
|
|
55
|
-
// Local-only health
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const names = process.platform === 'win32' ? ['omp.cmd', 'omp.exe', 'omp.bat', 'omp'] : ['omp'];
|
|
56
|
+
// Local-only health: find each agent on PATH, run `--version`, report both.
|
|
57
|
+
function binOnPath(bin) {
|
|
58
|
+
const names = process.platform === 'win32' ? [`${bin}.cmd`, `${bin}.exe`, `${bin}.bat`, bin] : [bin];
|
|
59
59
|
for (const dir of (process.env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
|
60
60
|
for (const name of names) {
|
|
61
61
|
const candidate = path.join(dir, name);
|
|
@@ -68,15 +68,28 @@ function ompPath() {
|
|
|
68
68
|
}
|
|
69
69
|
return null;
|
|
70
70
|
}
|
|
71
|
-
function
|
|
71
|
+
function ompPath() {
|
|
72
|
+
return binOnPath('omp');
|
|
73
|
+
}
|
|
74
|
+
function piPath() {
|
|
75
|
+
return binOnPath('pi');
|
|
76
|
+
}
|
|
77
|
+
// omp prints `omp/18.4.1`, pi prints a bare `0.87.1`; both read as name/version.
|
|
78
|
+
function versionLabel(bin, raw) {
|
|
79
|
+
const out = raw.trim();
|
|
80
|
+
return out.includes('/') ? out : `${bin}/${out}`;
|
|
81
|
+
}
|
|
82
|
+
function agentVersion(bin) {
|
|
72
83
|
if (!bin)
|
|
73
84
|
return null;
|
|
74
85
|
try {
|
|
75
86
|
if (process.platform === 'win32') {
|
|
76
87
|
const exe = process.env.ComSpec || 'cmd.exe';
|
|
77
|
-
|
|
88
|
+
const out = execFileSync(exe, ['/d', '/s', '/c', bin, '--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true });
|
|
89
|
+
return out.trim() ? versionLabel(path.basename(bin), out) : null;
|
|
78
90
|
}
|
|
79
|
-
|
|
91
|
+
const out = execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true });
|
|
92
|
+
return out.trim() ? versionLabel(path.basename(bin), out) : null;
|
|
80
93
|
}
|
|
81
94
|
catch {
|
|
82
95
|
return null;
|
|
@@ -104,12 +117,15 @@ function healthJson() {
|
|
|
104
117
|
const rtkBin = resolveRtkBinary();
|
|
105
118
|
const rtkPresent = rtkBin !== null;
|
|
106
119
|
const detectedOmpPath = ompPath();
|
|
120
|
+
const detectedPiPath = piPath();
|
|
107
121
|
return JSON.stringify({
|
|
108
122
|
tersio: PACKAGE_VERSION,
|
|
109
123
|
node: process.version,
|
|
110
124
|
platform: `${process.platform}/${process.arch}`,
|
|
111
|
-
omp:
|
|
125
|
+
omp: agentVersion(detectedOmpPath),
|
|
112
126
|
ompPath: detectedOmpPath,
|
|
127
|
+
pi: agentVersion(detectedPiPath),
|
|
128
|
+
piPath: detectedPiPath,
|
|
113
129
|
provider: ompDefaultModel(),
|
|
114
130
|
rtk: { present: rtkPresent, version: rtkPresent ? rtkVersion(rtkBin) : null, path: rtkBin || 'not found in PATH' },
|
|
115
131
|
home: tersioHomePath(),
|
|
@@ -125,8 +141,7 @@ const DIAG_TTL_MS = {
|
|
|
125
141
|
monthly: 30 * 24 * 3600 * 1000,
|
|
126
142
|
};
|
|
127
143
|
function diagPaths() {
|
|
128
|
-
//
|
|
129
|
-
// so test runs never touch the real ~/.tersio/diag.json.
|
|
144
|
+
// Keep the report beside the overridden DB so tests never touch the real one.
|
|
130
145
|
const dbOverride = process.env.TERSIO_USAGE_DB;
|
|
131
146
|
if (dbOverride)
|
|
132
147
|
return { report: path.join(path.dirname(dbOverride), 'diag.json') };
|
|
@@ -185,7 +200,6 @@ function relAge(ms) {
|
|
|
185
200
|
}
|
|
186
201
|
function computeDoctorRows() {
|
|
187
202
|
const rows = [];
|
|
188
|
-
const extDir = path.join(OMP_AGENT_DIR, 'extensions');
|
|
189
203
|
const rtkBin = resolveRtkBinary();
|
|
190
204
|
const ponytailPkg = path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
|
|
191
205
|
const tersioPluginDir = path.join(OMP_PLUGINS_DIR, 'node_modules', ...PACKAGE_NAME.split('/'));
|
|
@@ -206,8 +220,12 @@ function computeDoctorRows() {
|
|
|
206
220
|
catch { /* missing config is reported by extension rows */ }
|
|
207
221
|
const duplicateExtensions = [...new Set(explicitEntries.filter((entry, index) => explicitEntries.indexOf(entry) !== index))];
|
|
208
222
|
addon('Unique config registrations', duplicateExtensions.length === 0, duplicateExtensions.length ? duplicateExtensions.join(', ') : 'ok', 'Extensions');
|
|
209
|
-
|
|
210
|
-
|
|
223
|
+
// A listed extension whose file is gone makes the host warn on every load, so
|
|
224
|
+
// surface it rather than leaving the user with only a startup message.
|
|
225
|
+
const dangling = explicitEntries
|
|
226
|
+
.filter((entry) => entry.includes(`extensions${path.sep}`) || entry.includes('extensions/'))
|
|
227
|
+
.filter((entry) => !ok(path.isAbsolute(entry) ? entry : path.resolve(OMP_AGENT_DIR, entry)));
|
|
228
|
+
addon('Extension files present', dangling.length === 0, dangling.length ? dangling.join(', ') : 'ok', 'Extensions');
|
|
211
229
|
ext('Caveman extension', path.join(tersioPluginDir, 'extensions', 'caveman-session', 'index.ts'));
|
|
212
230
|
ext('RTK extension', path.join(tersioPluginDir, 'extensions', 'rtk-session', 'index.ts'));
|
|
213
231
|
ext('Ponytail extension', path.join(OMP_PLUGINS_DIR, 'node_modules', '@dietrichgebert', 'ponytail', 'pi-extension', 'index.js'));
|
|
@@ -219,8 +237,6 @@ function computeDoctorRows() {
|
|
|
219
237
|
const rtkAge = rtkBin ? ageStr(rtkBin) : null;
|
|
220
238
|
const rtkAt = rtkBin ? absTime(rtkBin) : null;
|
|
221
239
|
addon('RTK binary', rtkVer !== null, rtkVer && rtkAge && rtkAt ? `${rtkVer} (updated ${rtkAge} · ${rtkAt})` : rtkBin || 'not found in PATH');
|
|
222
|
-
const wiring = path.join(extDir, 'rtk.ts');
|
|
223
|
-
addon('RTK OMP wiring (rtk.ts)', ok(wiring), ok(wiring) ? 'loaded' : wiring);
|
|
224
240
|
let ponytailVer = null;
|
|
225
241
|
try {
|
|
226
242
|
ponytailVer = JSON.parse(readFileSync(ponytailPkg, 'utf8')).version ?? null;
|
|
@@ -237,10 +253,8 @@ function computeDoctorRows() {
|
|
|
237
253
|
addon('Prices feed', pricesAge !== null, pricesAge && pricesAt ? `(pulled ${pricesAge} · ${pricesAt})` : prices, 'Usage');
|
|
238
254
|
return rows;
|
|
239
255
|
}
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
// Labels retired from the report. A saved report containing any of them
|
|
243
|
-
// is stale by definition and always recomputes.
|
|
256
|
+
// Recompute when forced, missing, or past the schedule; a report holding a
|
|
257
|
+
// retired label is stale by definition.
|
|
244
258
|
const RETIRED_DIAG_LABELS = new Set([
|
|
245
259
|
'OMP CLI',
|
|
246
260
|
'Tersio CLI',
|
|
@@ -251,17 +265,16 @@ const RETIRED_DIAG_LABELS = new Set([
|
|
|
251
265
|
'Self plugin',
|
|
252
266
|
'Ponytail registered',
|
|
253
267
|
'Usage store',
|
|
268
|
+
'RTK OMP wiring (rtk.ts)',
|
|
254
269
|
]);
|
|
255
270
|
function isRetiredReport(report) {
|
|
256
271
|
return report.rows.some((r) => RETIRED_DIAG_LABELS.has(r.label));
|
|
257
272
|
}
|
|
258
|
-
//
|
|
259
|
-
// than the schedule. Otherwise return the saved report.
|
|
273
|
+
// Recompute when forced, missing, retired, or past the schedule.
|
|
260
274
|
function getDoctorReport(force) {
|
|
261
275
|
const saved = readDiagReport();
|
|
262
276
|
const ttl = saved ? DIAG_TTL_MS[saved.schedule] : 0;
|
|
263
|
-
// Manual
|
|
264
|
-
// report until stale. Otherwise a retained snapshot hides new rows.
|
|
277
|
+
// Manual recomputes on open; a dated schedule reuses the saved report.
|
|
265
278
|
if (!force && saved && !isRetiredReport(saved) && ttl > 0 && Date.now() - saved.checkedAt < ttl)
|
|
266
279
|
return saved;
|
|
267
280
|
const report = { rows: computeDoctorRows(), checkedAt: Date.now(), schedule: saved?.schedule ?? 'manual' };
|
|
@@ -274,13 +287,55 @@ function setDiagSchedule(schedule) {
|
|
|
274
287
|
writeDiagReport(base);
|
|
275
288
|
return base;
|
|
276
289
|
}
|
|
290
|
+
// One field per call, validated against the same sets the CLI uses.
|
|
291
|
+
async function saveDefaults(body) {
|
|
292
|
+
const profile = await storedProfile();
|
|
293
|
+
if (body.comboDefault !== undefined) {
|
|
294
|
+
const level = normalizeComboLevel(body.comboDefault);
|
|
295
|
+
if (!level)
|
|
296
|
+
return { ok: false, error: 'unknown combo level' };
|
|
297
|
+
profile.comboDefault = level;
|
|
298
|
+
}
|
|
299
|
+
if (body.cavemanDefault !== undefined) {
|
|
300
|
+
const v = String(body.cavemanDefault);
|
|
301
|
+
if (!CAVEMAN_DEFAULTS.has(v))
|
|
302
|
+
return { ok: false, error: 'unknown caveman level' };
|
|
303
|
+
profile.cavemanDefault = v;
|
|
304
|
+
}
|
|
305
|
+
if (body.ponytailDefault !== undefined) {
|
|
306
|
+
const v = String(body.ponytailDefault);
|
|
307
|
+
if (!PONYTAIL_DEFAULTS.has(v))
|
|
308
|
+
return { ok: false, error: 'unknown ponytail level' };
|
|
309
|
+
profile.ponytailDefault = v;
|
|
310
|
+
}
|
|
311
|
+
if (body.backupSchedule !== undefined) {
|
|
312
|
+
const v = String(body.backupSchedule);
|
|
313
|
+
if (!BACKUP_SCHEDULES.has(v))
|
|
314
|
+
return { ok: false, error: 'unknown backup schedule' };
|
|
315
|
+
profile.backupSchedule = v;
|
|
316
|
+
}
|
|
317
|
+
if (body.rtkDefault !== undefined) {
|
|
318
|
+
if (typeof body.rtkDefault !== 'boolean')
|
|
319
|
+
return { ok: false, error: 'rtk default must be true or false' };
|
|
320
|
+
profile.rtkDefault = body.rtkDefault;
|
|
321
|
+
}
|
|
322
|
+
await writePluginSettings(profile, {});
|
|
323
|
+
return {
|
|
324
|
+
ok: true,
|
|
325
|
+
profile: {
|
|
326
|
+
comboDefault: profile.comboDefault,
|
|
327
|
+
cavemanDefault: profile.cavemanDefault,
|
|
328
|
+
rtkDefault: profile.rtkDefault,
|
|
329
|
+
ponytailDefault: profile.ponytailDefault,
|
|
330
|
+
backupSchedule: profile.backupSchedule,
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
}
|
|
277
334
|
function readDiagSchedule() {
|
|
278
335
|
return readDiagReport()?.schedule ?? 'manual';
|
|
279
336
|
}
|
|
280
|
-
// Persist the
|
|
281
|
-
//
|
|
282
|
-
// browser's localStorage alone cannot survive a restart. The stored default
|
|
283
|
-
// feeds data.json and `tersio usage` on the next run.
|
|
337
|
+
// Persist the picker choice: every run serves a fresh port, so localStorage
|
|
338
|
+
// alone cannot survive a restart.
|
|
284
339
|
async function saveDashboardCurrency(raw) {
|
|
285
340
|
if (typeof raw !== 'string')
|
|
286
341
|
return null;
|
|
@@ -292,6 +347,50 @@ async function saveDashboardCurrency(raw) {
|
|
|
292
347
|
await writePluginSettings(profile, {});
|
|
293
348
|
return code;
|
|
294
349
|
}
|
|
350
|
+
export const EXPORT_FORMATS = ['json', 'jsonl', 'csv'];
|
|
351
|
+
export function exportRows(report) {
|
|
352
|
+
return report.recent.map((r) => ({
|
|
353
|
+
timestamp: new Date(r.t).toISOString(),
|
|
354
|
+
local: new Date(r.t).toLocaleString(),
|
|
355
|
+
agent: r.h ?? 'pi',
|
|
356
|
+
model: r.m,
|
|
357
|
+
input: r.i,
|
|
358
|
+
output: r.o,
|
|
359
|
+
cacheRead: r.cr ?? 0,
|
|
360
|
+
cacheWrite: r.cw ?? 0,
|
|
361
|
+
costUsd: r.usd,
|
|
362
|
+
elapsedMs: r.d,
|
|
363
|
+
status: r.st,
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
export function csvCell(v) {
|
|
367
|
+
const s = v === undefined || v === null ? '' : String(v);
|
|
368
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
369
|
+
}
|
|
370
|
+
export function exportBody(format, report) {
|
|
371
|
+
const stamp = new Date().toISOString().slice(0, 10);
|
|
372
|
+
if (format === 'json') {
|
|
373
|
+
return { body: JSON.stringify({ exportedAt: new Date().toISOString(), version: report.version, source: report.source, report }, null, 2), type: 'application/json' };
|
|
374
|
+
}
|
|
375
|
+
const rows = exportRows(report);
|
|
376
|
+
if (format === 'jsonl') {
|
|
377
|
+
return { body: rows.map((r) => JSON.stringify(r)).join('\n') + '\n', type: 'application/x-ndjson' };
|
|
378
|
+
}
|
|
379
|
+
const cols = Object.keys(rows[0] ?? { timestamp: '', agent: '' });
|
|
380
|
+
const head = cols.join(',');
|
|
381
|
+
const body = [head, ...rows.map((r) => cols.map((c) => csvCell(r[c])).join(','))].join('\n') + '\n';
|
|
382
|
+
return { body, type: 'text/csv; charset=utf-8' };
|
|
383
|
+
}
|
|
384
|
+
function serveExport(req, res) {
|
|
385
|
+
const format = ((req.url || '').split('?')[1] || '').match(/format=([a-z]+)/)?.[1];
|
|
386
|
+
const fmt = format && EXPORT_FORMATS.includes(format) ? format : 'json';
|
|
387
|
+
const { body, type } = exportBody(fmt, summarizeUsage(readUsage()));
|
|
388
|
+
res.writeHead(200, {
|
|
389
|
+
'Content-Type': type,
|
|
390
|
+
'Content-Disposition': `attachment; filename="tersio-usage-${new Date().toISOString().slice(0, 10)}.${fmt === 'jsonl' ? 'ndjson' : fmt}"`,
|
|
391
|
+
});
|
|
392
|
+
res.end(body);
|
|
393
|
+
}
|
|
295
394
|
function readBody(req) {
|
|
296
395
|
return new Promise((resolve) => {
|
|
297
396
|
let body = '';
|
|
@@ -304,21 +403,30 @@ function openBrowser(url) {
|
|
|
304
403
|
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
305
404
|
spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
|
|
306
405
|
}
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
// expand as match-suffix patterns and corrupt the file.
|
|
310
|
-
// RTK-metered command lines routinely contain literal `</script>` (Vue
|
|
311
|
-
// SFC probes), which would close the inlined <script> early and dump the
|
|
312
|
-
// rest of the JSON as page text. Escape it; JSON.parse never sees the
|
|
313
|
-
// backslash form inside a string literal, the browser decodes it first.
|
|
406
|
+
// Replacers throughout: session data holds `$'` (shell quoting) and literal
|
|
407
|
+
// `</script>`, which String.replace would mangle.
|
|
314
408
|
function escapeInline(json) {
|
|
315
409
|
return json.replace(/<\/(script)/gi, '<\\/$1');
|
|
316
410
|
}
|
|
411
|
+
// Our own JSON.stringify output, so a parse failure means a bad value: name it.
|
|
412
|
+
function parseOwnJson(json, field) {
|
|
413
|
+
let parsed;
|
|
414
|
+
try {
|
|
415
|
+
parsed = JSON.parse(json);
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
throw new Error(`dashboard snapshot ${field} is not valid JSON`);
|
|
419
|
+
}
|
|
420
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
421
|
+
throw new Error(`dashboard snapshot ${field} is not an object`);
|
|
422
|
+
}
|
|
423
|
+
return parsed;
|
|
424
|
+
}
|
|
317
425
|
async function exportDashboard(exportFile) {
|
|
318
426
|
requireDashboardBundle();
|
|
319
427
|
const [bundle, icon] = await Promise.all([readSegment(DASHBOARD_INDEX), brandDataUri()]);
|
|
320
428
|
const inline = bundle
|
|
321
|
-
.replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ data:
|
|
429
|
+
.replace('window.__TERSIO_SNAP = null;', () => `window.__TERSIO_SNAP = ${escapeInline(JSON.stringify({ data: parseOwnJson(dataJson(), 'usage data'), health: parseOwnJson(healthJson(), 'health'), doctor: getDoctorReport(false) }))};`)
|
|
322
430
|
.replace(/href="brand\.webp"/g, () => `href="${icon}"`)
|
|
323
431
|
.replace(/src="brand\.webp"/g, () => `src="${icon}"`)
|
|
324
432
|
.replace('fetch("brand.webp")', () => `Promise.resolve({ ok: true, blob: async () => new Blob([atob("${icon.split(',')[1]}")], { type: "image/webp" }) })`);
|
|
@@ -339,6 +447,70 @@ async function runDashboard(options) {
|
|
|
339
447
|
res.end(JSON.stringify({ ok: true, rows }));
|
|
340
448
|
return;
|
|
341
449
|
}
|
|
450
|
+
if (req.url === '/backups' && req.method === 'GET') {
|
|
451
|
+
const rows = listUsageBackups().map((b) => ({ file: path.basename(b.file), mtime: b.mtime, size: b.size }));
|
|
452
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
453
|
+
res.end(JSON.stringify({ backups: rows }));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (req.url === '/backups/delete' && req.method === 'POST') {
|
|
457
|
+
let file = '';
|
|
458
|
+
try {
|
|
459
|
+
file = String(JSON.parse(await readBody(req)).file ?? '');
|
|
460
|
+
}
|
|
461
|
+
catch {
|
|
462
|
+
file = '';
|
|
463
|
+
}
|
|
464
|
+
const ok = deleteUsageBackup(file);
|
|
465
|
+
res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
466
|
+
res.end(JSON.stringify(ok ? { ok: true, file } : { ok: false, error: 'unknown backup' }));
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (req.url === '/backups/restore' && req.method === 'POST') {
|
|
470
|
+
let file = '';
|
|
471
|
+
try {
|
|
472
|
+
file = String(JSON.parse(await readBody(req)).file ?? '');
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
file = '';
|
|
476
|
+
}
|
|
477
|
+
const ok = restoreUsageBackup(file);
|
|
478
|
+
res.writeHead(ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
479
|
+
res.end(JSON.stringify(ok ? { ok: true, file } : { ok: false, error: 'unknown backup' }));
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (req.url === '/settings' && req.method === 'GET') {
|
|
483
|
+
storedProfile().then((profile) => {
|
|
484
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
485
|
+
res.end(JSON.stringify({
|
|
486
|
+
comboDefault: profile.comboDefault,
|
|
487
|
+
cavemanDefault: profile.cavemanDefault,
|
|
488
|
+
rtkDefault: profile.rtkDefault,
|
|
489
|
+
ponytailDefault: profile.ponytailDefault,
|
|
490
|
+
backupSchedule: profile.backupSchedule,
|
|
491
|
+
}));
|
|
492
|
+
});
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
if (req.url === '/settings' && req.method === 'POST') {
|
|
496
|
+
let body = {};
|
|
497
|
+
try {
|
|
498
|
+
body = JSON.parse(await readBody(req)) ?? {};
|
|
499
|
+
}
|
|
500
|
+
catch {
|
|
501
|
+
body = {};
|
|
502
|
+
}
|
|
503
|
+
const saved = await saveDefaults(body);
|
|
504
|
+
if (!saved.ok) {
|
|
505
|
+
res.writeHead(400, { 'Content-Type': 'application/json' });
|
|
506
|
+
res.end(JSON.stringify({ ok: false, error: saved.error }));
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
510
|
+
res.end(JSON.stringify({ ok: true, ...saved.profile }));
|
|
511
|
+
}
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
342
514
|
if (req.url === '/currency' && req.method === 'POST') {
|
|
343
515
|
let code = null;
|
|
344
516
|
try {
|
|
@@ -363,6 +535,10 @@ async function runDashboard(options) {
|
|
|
363
535
|
res.end(dataJson());
|
|
364
536
|
return;
|
|
365
537
|
}
|
|
538
|
+
if (req.url?.startsWith('/export')) {
|
|
539
|
+
serveExport(req, res);
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
366
542
|
if (req.url === '/health') {
|
|
367
543
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
368
544
|
res.end(healthJson());
|
package/cli/doctor-fix.js
CHANGED
|
@@ -1,40 +1,54 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
1
|
+
import { existsSync, promises as fs } from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { BUN_BIN_DIR, OMP_AGENT_DIR, OMP_PLUGINS_DIR, PACKAGE_NAME, PACKAGE_VERSION, RTK_BINARY_NAME, removeExtensionFromConfig, dryRun, verbose, debug, execP, readPluginsPackage, writeIfChanged, } from './common.js';
|
|
6
|
-
import { execNetwork } from './interactive.js';
|
|
6
|
+
import { execNetwork, sayTagged } from './interactive.js';
|
|
7
7
|
import { wireRtkOmp, ensureRtkInConfig } from './rtk-wiring.js';
|
|
8
8
|
import { CAVEMAN_REMOTE_RULE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, parseChecksum, readTextIfExists, rtkPlatformSpec, sha256File, } from '../extensions/lib/utils.js';
|
|
9
9
|
import { runLatestUpdate } from './update.js';
|
|
10
|
+
import { TREE_FILES, sourcePath } from './manifest.js';
|
|
11
|
+
import { detectHosts, hostExtensionsDir } from './hosts.js';
|
|
10
12
|
const EXT_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'extensions');
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
13
|
+
// A written tree is the install for both hosts now, so --fix restores it for
|
|
14
|
+
// whichever hosts have it, alongside the OMP package when that is present.
|
|
15
|
+
async function fixExtensionTrees() {
|
|
16
|
+
for (const host of detectHosts()) {
|
|
17
|
+
const dest = hostExtensionsDir(host.id);
|
|
18
|
+
// Repair a host whose tree is partly there: a half-written tree is exactly
|
|
19
|
+
// the case this runs for, and it reads as "not installed" to the detector.
|
|
20
|
+
const present = TREE_FILES.filter((file) => existsSync(path.join(dest, file))).length;
|
|
21
|
+
if (present === 0)
|
|
22
|
+
continue;
|
|
23
|
+
await fs.mkdir(dest, { recursive: true });
|
|
24
|
+
for (const file of TREE_FILES) {
|
|
25
|
+
const from = path.join(EXT_DIR, ...file.split('/'));
|
|
26
|
+
const text = await readTextIfExists(from);
|
|
27
|
+
if (text === null)
|
|
28
|
+
sayTagged(` [warn] bundled source missing: ${from}`);
|
|
29
|
+
else
|
|
30
|
+
await writeIfChanged(path.join(dest, file), text, { dryRun, verbose });
|
|
31
|
+
}
|
|
32
|
+
if (!dryRun)
|
|
33
|
+
sayTagged(` [ok] ${host.label} extension tree: ${dest}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
26
36
|
async function fixExtensions(pluginsDir) {
|
|
37
|
+
await fixExtensionTrees();
|
|
27
38
|
console.log(' Doctor --fix: restoring plugin extension files');
|
|
28
39
|
const tersioPluginDir = path.join(pluginsDir, 'node_modules', '@krtclcdy', 'tersio');
|
|
29
40
|
const pluginExtDir = path.join(tersioPluginDir, 'extensions');
|
|
30
41
|
await fs.mkdir(pluginExtDir, { recursive: true });
|
|
31
|
-
|
|
32
|
-
|
|
42
|
+
// rule.md is skipped: its live content is the upstream fetch, not the bundle.
|
|
43
|
+
const bundledTree = TREE_FILES.filter((file) => file !== 'caveman-session/rule.md');
|
|
44
|
+
for (const file of bundledTree) {
|
|
45
|
+
const text = await readTextIfExists(sourcePath(file));
|
|
33
46
|
if (text === null)
|
|
34
|
-
|
|
47
|
+
sayTagged(` [warn] bundled source missing: ${sourcePath(file)}`);
|
|
35
48
|
else
|
|
36
|
-
await writeIfChanged(path.join(pluginExtDir,
|
|
49
|
+
await writeIfChanged(path.join(pluginExtDir, file), text, { dryRun, verbose });
|
|
37
50
|
}
|
|
51
|
+
// Restore only a missing rule; a stale one is /ai-addons' to report.
|
|
38
52
|
const ruleDest = path.join(pluginExtDir, 'caveman-session', 'rule.md');
|
|
39
53
|
if ((await readTextIfExists(ruleDest)) === null) {
|
|
40
54
|
const bundled = await readTextIfExists(path.join(EXT_DIR, 'caveman-session', 'rule.md'));
|
|
@@ -58,7 +72,7 @@ async function fixRegistrations(agentDir, pluginsDir) {
|
|
|
58
72
|
const configPath = path.join(agentDir, 'config.yml');
|
|
59
73
|
if ((await readTextIfExists(configPath)) === null) {
|
|
60
74
|
if (dryRun)
|
|
61
|
-
|
|
75
|
+
sayTagged(` [dry-run] would create ${configPath}`);
|
|
62
76
|
else {
|
|
63
77
|
await fs.mkdir(agentDir, { recursive: true });
|
|
64
78
|
await fs.writeFile(configPath, 'extensions:\n', 'utf8');
|
|
@@ -77,7 +91,7 @@ async function fixRegistrations(agentDir, pluginsDir) {
|
|
|
77
91
|
if (!(PACKAGE_NAME in pkg.dependencies)) {
|
|
78
92
|
pkg.dependencies[PACKAGE_NAME] = `^${PACKAGE_VERSION}`;
|
|
79
93
|
if (dryRun)
|
|
80
|
-
|
|
94
|
+
sayTagged(` [dry-run] would register ${PACKAGE_NAME} in ${pkgPath}`);
|
|
81
95
|
else {
|
|
82
96
|
await fs.mkdir(pluginsDir, { recursive: true });
|
|
83
97
|
await fs.writeFile(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf8');
|
|
@@ -88,7 +102,7 @@ async function fixRtk(binDir) {
|
|
|
88
102
|
console.log(' Doctor --fix: repairing RTK binary + wiring');
|
|
89
103
|
const binDest = path.join(binDir, RTK_BINARY_NAME);
|
|
90
104
|
if (dryRun) {
|
|
91
|
-
|
|
105
|
+
sayTagged(` [dry-run] would download a checksum-verified rtk to ${binDest} and wire it into OMP`);
|
|
92
106
|
return;
|
|
93
107
|
}
|
|
94
108
|
const spec = rtkPlatformSpec();
|
|
@@ -169,11 +183,11 @@ async function runDoctorRepairs(targets) {
|
|
|
169
183
|
try {
|
|
170
184
|
await work();
|
|
171
185
|
if (!dryRun)
|
|
172
|
-
|
|
186
|
+
sayTagged(` [ok] ${label}`);
|
|
173
187
|
}
|
|
174
188
|
catch (e) {
|
|
175
189
|
failed.push(label);
|
|
176
|
-
|
|
190
|
+
sayTagged(` [fail] ${label}: ${(e.message || '').slice(0, 200)}`);
|
|
177
191
|
}
|
|
178
192
|
};
|
|
179
193
|
if (want('extensions'))
|