@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.
Files changed (58) hide show
  1. package/README.md +56 -45
  2. package/cli/agent-hosts.js +42 -0
  3. package/cli/agents.js +266 -0
  4. package/cli/banner.js +2 -3
  5. package/cli/common.js +16 -20
  6. package/cli/currency.js +11 -19
  7. package/cli/dashboard.js +216 -40
  8. package/cli/doctor-fix.js +40 -26
  9. package/cli/doctor.js +41 -41
  10. package/cli/hosts.js +114 -0
  11. package/cli/install.js +284 -179
  12. package/cli/interactive.js +62 -2
  13. package/cli/layer.js +22 -0
  14. package/cli/manifest.js +33 -0
  15. package/cli/omp-layer.js +51 -0
  16. package/cli/pi-layer.js +37 -0
  17. package/cli/pi-wiring.js +92 -0
  18. package/cli/profile.js +48 -48
  19. package/cli/reset.js +5 -32
  20. package/cli/rtk-wiring.js +7 -17
  21. package/cli/settings.js +3 -8
  22. package/cli/uninstall.js +162 -52
  23. package/cli/update.js +19 -27
  24. package/cli/usage.js +15 -5
  25. package/dashboard/dist/index.html +18 -18
  26. package/extensions/ai-addons-updater/index.js +37 -29
  27. package/extensions/ai-addons-updater/index.ts +39 -38
  28. package/extensions/caveman-session/index.js +19 -34
  29. package/extensions/caveman-session/index.ts +20 -35
  30. package/extensions/combo-toggle/index.js +47 -49
  31. package/extensions/combo-toggle/index.ts +42 -58
  32. package/extensions/lib/utils.js +40 -9
  33. package/extensions/lib/utils.ts +40 -9
  34. package/extensions/rtk-session/index.js +34 -38
  35. package/extensions/rtk-session/index.ts +32 -41
  36. package/extensions/shared/carbon.js +3 -11
  37. package/extensions/shared/carbon.ts +4 -11
  38. package/extensions/shared/host.js +58 -0
  39. package/extensions/shared/host.ts +94 -0
  40. package/extensions/shared/omp-prompt.js +12 -0
  41. package/extensions/shared/plugin-settings.js +33 -30
  42. package/extensions/shared/plugin-settings.ts +35 -26
  43. package/extensions/shared/pricing.js +25 -16
  44. package/extensions/shared/pricing.ts +27 -17
  45. package/extensions/shared/rtk-gain.js +6 -22
  46. package/extensions/shared/rtk-gain.ts +6 -22
  47. package/extensions/shared/session-state.js +44 -36
  48. package/extensions/shared/session-state.ts +48 -35
  49. package/extensions/shared/types.ts +10 -3
  50. package/extensions/shared/usage-ledger.js +160 -51
  51. package/extensions/shared/usage-ledger.ts +170 -67
  52. package/extensions/shared/usage-store.js +178 -24
  53. package/extensions/shared/usage-store.ts +176 -25
  54. package/extensions/tersio-commands/index.js +3 -6
  55. package/extensions/tersio-commands/index.ts +3 -6
  56. package/package.json +6 -5
  57. package/tersio.js +4 -6
  58. package/extensions/shared/mode-reinforcement.js +0 -26
package/cli/currency.js CHANGED
@@ -1,10 +1,7 @@
1
- // cli/currency.ts — display-currency helpers (USD-base, offline snapshot rates).
2
- // Same 10 currencies, symbols, and snapshot rates as the dashboard's
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 os from 'node:os';
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 from the omp plugin lock file (written by tersio settings);
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 home = process.env.HOME || process.env.USERPROFILE || os.homedir();
40
- const lock = path.join(home, '.omp', 'plugins', 'omp-plugins.lock.json');
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(lock, 'utf8'));
45
- if (!parsed || typeof parsed !== 'object' || !('settings' in parsed))
38
+ const parsed = JSON.parse(readFileSync(file, 'utf8'));
39
+ if (!parsed || typeof parsed !== 'object')
46
40
  return DEFAULT_CURRENCY;
47
- const entry = parsed.settings?.['@krtclcdy/tersio'];
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
- const FX_SNAPSHOT_RATES = {
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
- // cli/dashboard.ts — serves the built shadcn Dashboard with local usage APIs.
2
- // Binds 127.0.0.1 only; --export writes a file://-ready file instead.
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 + diagnosis for the settings modal. No network: version
56
- // probes run with short timeouts, file checks are existsSync.
57
- function ompPath() {
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 ompVersion(bin = ompPath()) {
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
- return execFileSync(exe, ['/d', '/s', '/c', bin, '--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim() || null;
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
- return execFileSync(bin, ['--version'], { encoding: 'utf8', timeout: 5000, windowsHide: true }).trim() || null;
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: ompVersion(detectedOmpPath),
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
- // Hermetic env (tests) overrides the DB path: keep the report next to it
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
- const retiredReinforcement = explicitEntries.filter((entry) => entry.endsWith('/shared/mode-reinforcement.ts')).length;
210
- addon('No retired reinforcement', retiredReinforcement === 0, retiredReinforcement ? `${retiredReinforcement} registration(s)` : 'ok', 'Extensions');
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
- // Retained diagnosis: recompute when forced, missing, or older than the
241
- // schedule. Otherwise return the saved report so users never re-run.
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
- // Retained diagnosis: recompute when forced, missing, retired, or older
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 always recomputes on open; dated schedules reuse the saved
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 dashboard's picker choice so close → reopen keeps it: each
281
- // `tersio dashboard` run serves a fresh ephemeral port (a new origin), so the
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
- // Replacer functions throughout: session data routinely contains `$'`
308
- // sequences (shell quoting in tool details), which String.replace would
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: JSON.parse(dataJson()), health: JSON.parse(healthJson()), doctor: getDoctorReport(false) }))};`)
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
- const SOURCES = [
12
- [path.join(EXT_DIR, 'shared', 'session-state.ts'), path.join('shared', 'session-state.ts')],
13
- [path.join(EXT_DIR, 'shared', 'types.ts'), path.join('shared', 'types.ts')],
14
- [path.join(EXT_DIR, 'lib', 'utils.ts'), path.join('lib', 'utils.ts')],
15
- [path.join(EXT_DIR, 'shared', 'plugin-settings.ts'), path.join('shared', 'plugin-settings.ts')],
16
- [path.join(EXT_DIR, 'shared', 'usage-ledger.ts'), path.join('shared', 'usage-ledger.ts')],
17
- [path.join(EXT_DIR, 'shared', 'pricing.ts'), path.join('shared', 'pricing.ts')],
18
- [path.join(EXT_DIR, 'shared', 'carbon.ts'), path.join('shared', 'carbon.ts')],
19
- [path.join(EXT_DIR, 'caveman-session', 'index.ts'), path.join('caveman-session', 'index.ts')],
20
- [path.join(EXT_DIR, 'caveman-session', 'rule.md'), path.join('caveman-session', 'rule.md')],
21
- [path.join(EXT_DIR, 'rtk-session', 'index.ts'), path.join('rtk-session', 'index.ts')],
22
- [path.join(EXT_DIR, 'ai-addons-updater', 'index.ts'), path.join('ai-addons-updater', 'index.ts')],
23
- [path.join(EXT_DIR, 'combo-toggle', 'index.ts'), path.join('combo-toggle', 'index.ts')],
24
- [path.join(EXT_DIR, 'tersio-commands', 'index.ts'), path.join('tersio-commands', 'index.ts')],
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
- for (const [from, to] of SOURCES) {
32
- const text = await readTextIfExists(from);
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
- console.log(` [warn] bundled source missing: ${from}`);
47
+ sayTagged(` [warn] bundled source missing: ${sourcePath(file)}`);
35
48
  else
36
- await writeIfChanged(path.join(pluginExtDir, to), text, { dryRun, verbose });
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
- console.log(` [dry-run] would create ${configPath}`);
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
- console.log(` [dry-run] would register ${PACKAGE_NAME} in ${pkgPath}`);
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
- console.log(` [dry-run] would download a checksum-verified rtk to ${binDest} and wire it into OMP`);
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
- console.log(` [ok] ${label}`);
186
+ sayTagged(` [ok] ${label}`);
173
187
  }
174
188
  catch (e) {
175
189
  failed.push(label);
176
- console.log(` [fail] ${label}: ${(e.message || '').slice(0, 200)}`);
190
+ sayTagged(` [fail] ${label}: ${(e.message || '').slice(0, 200)}`);
177
191
  }
178
192
  };
179
193
  if (want('extensions'))