@krtclcdy/tersio 2.18.0 → 2.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +2 -2
  2. package/cli/common.js +21 -3
  3. package/cli/currency.js +102 -0
  4. package/cli/dashboard.js +54 -2
  5. package/cli/doctor.js +9 -7
  6. package/cli/install.js +35 -101
  7. package/cli/profile.js +92 -0
  8. package/cli/reset.js +4 -2
  9. package/cli/settings.js +122 -0
  10. package/cli/usage.js +38 -24
  11. package/dashboard/app.js +297 -70
  12. package/dashboard/styles.css +58 -1
  13. package/dashboard/template.html +75 -3
  14. package/extensions/ai-addons-updater/index.ts +377 -0
  15. package/extensions/caveman-session/index.ts +161 -0
  16. package/extensions/combo-toggle/index.ts +217 -0
  17. package/extensions/lib/utils.ts +162 -0
  18. package/extensions/rtk-session/index.ts +164 -0
  19. package/extensions/shared/carbon.ts +123 -0
  20. package/extensions/shared/mode-reinforcement.ts +29 -0
  21. package/extensions/shared/plugin-settings.ts +57 -0
  22. package/extensions/shared/pricing.ts +168 -0
  23. package/extensions/shared/rtk-gain.ts +105 -0
  24. package/extensions/shared/session-state.ts +201 -0
  25. package/extensions/shared/types.ts +85 -0
  26. package/extensions/shared/usage-ledger.ts +406 -0
  27. package/extensions/shared/usage-store.ts +325 -0
  28. package/extensions/tersio-commands/index.ts +110 -0
  29. package/package.json +36 -24
  30. package/tersio.js +8 -1
  31. package/extensions/ai-addons-updater/index.js +0 -331
  32. package/extensions/caveman-session/index.js +0 -152
  33. package/extensions/combo-toggle/index.js +0 -165
  34. package/extensions/lib/utils.js +0 -154
  35. package/extensions/rtk-session/index.js +0 -148
  36. package/extensions/shared/carbon.js +0 -88
  37. package/extensions/shared/mode-reinforcement.js +0 -26
  38. package/extensions/shared/plugin-settings.js +0 -51
  39. package/extensions/shared/pricing.js +0 -163
  40. package/extensions/shared/rtk-gain.js +0 -82
  41. package/extensions/shared/session-state.js +0 -177
  42. package/extensions/shared/types.js +0 -3
  43. package/extensions/shared/usage-ledger.js +0 -333
  44. package/extensions/tersio-commands/index.js +0 -102
@@ -83,6 +83,10 @@
83
83
  <p class="dlg-name">Session transcripts <span class="dlg-own">host-owned · never touched</span></p>
84
84
  <p class="mono dlg-file" id="pathSessions" title="">–</p>
85
85
  </div>
86
+ <div class="dlg-path">
87
+ <p class="dlg-name">Usage DB <span class="dlg-own">tersio-owned · reset clears</span></p>
88
+ <p class="mono dlg-file" id="pathUsageDb" title="">–</p>
89
+ </div>
86
90
  </div>
87
91
  <div class="dlg-danger">
88
92
  <p class="dlg-label">Danger zone</p>
@@ -98,6 +102,76 @@
98
102
  </div>
99
103
  </dialog>
100
104
 
105
+ <!-- Model detail dialog (shadcn chart language, native modal): kpi strip,
106
+ gradient area chart with range tabs, rounded bars, token-mix donut. -->
107
+ <dialog id="modelDialog" class="dlg mdlg" aria-labelledby="mdTitle">
108
+ <div class="dlg-head">
109
+ <div class="min-w-0 flex items-center gap-3">
110
+ <span id="mdBadge"></span>
111
+ <div class="min-w-0">
112
+ <p class="dlg-title truncate" id="mdTitle">Model</p>
113
+ <p class="dlg-desc mono" id="mdSub">–</p>
114
+ </div>
115
+ </div>
116
+ <div class="flex shrink-0 items-center gap-2">
117
+ <span class="md-rankpill mono" id="mdRank">#–</span>
118
+ <button id="mdClose" class="btn-push flex shrink-0 items-center p-2 rounded-xl" style="border: 1px solid var(--line); color: var(--ink)" aria-label="Close model details">
119
+ <i data-lucide="x" class="size-4"></i>
120
+ </button>
121
+ </div>
122
+ </div>
123
+ <div class="md-body">
124
+ <div class="md-kpis" id="mdKpis"></div>
125
+ <div class="md-chart">
126
+ <div class="md-chart-head">
127
+ <div class="min-w-0">
128
+ <p class="md-chart-title">Token volume</p>
129
+ <p class="md-chart-sub mono" id="mdMomRange">–</p>
130
+ </div>
131
+ <div class="flex shrink-0 items-center gap-2">
132
+ <span class="md-trend mono" id="mdTrend"></span>
133
+ <span class="seg mono text-xs flex items-center gap-1 p-1 rounded-[10px]" style="border: 1px solid var(--line)" role="group" aria-label="Chart range">
134
+ <button data-mdspan="30" class="px-2.5 py-1" role="button">30D</button>
135
+ <button data-mdspan="90" class="px-2.5 py-1" role="button">90D</button>
136
+ <button data-mdspan="all" class="on px-2.5 py-1" role="button">ALL</button>
137
+ </span>
138
+ </div>
139
+ </div>
140
+ <svg id="mdSpark" viewBox="0 0 560 190" preserveAspectRatio="none" aria-hidden="true"></svg>
141
+ <div class="md-tip mono" id="mdTip"></div>
142
+ <div class="md-months mono" id="mdMonths" aria-hidden="true"></div>
143
+ </div>
144
+ <div class="md-cols">
145
+ <div class="md-chart">
146
+ <div class="md-chart-head">
147
+ <div class="min-w-0">
148
+ <p class="md-chart-title">Daily activity</p>
149
+ <p class="md-chart-sub mono" id="mdBarsSub">tokens per day</p>
150
+ </div>
151
+ <span class="md-trend mono" id="mdActive"></span>
152
+ </div>
153
+ <div class="md-bars" id="mdBars" aria-hidden="true"></div>
154
+ <div class="md-months mono" id="mdBarsMonths" aria-hidden="true"></div>
155
+ <div class="md-note mono" id="mdBarsNote"></div>
156
+ </div>
157
+ <div class="md-chart">
158
+ <div class="md-chart-head">
159
+ <div class="min-w-0">
160
+ <p class="md-chart-title">Token mix</p>
161
+ <p class="md-chart-sub mono">input vs output vs cache</p>
162
+ </div>
163
+ </div>
164
+ <div class="md-donut-wrap">
165
+ <svg id="mdMix" viewBox="0 0 140 140" aria-hidden="true"></svg>
166
+ <div class="md-donut-center"><p class="mono" id="mdMixTotal">–</p></div>
167
+ </div>
168
+ <div class="md-legend mono" id="mdMixLegend"></div>
169
+ </div>
170
+ </div>
171
+ <div class="md-grid" id="mdStats"></div>
172
+ </div>
173
+ </dialog>
174
+
101
175
  <!-- Hero: centered total -->
102
176
  <section class="mx-auto max-w-6xl text-center pt-14 pb-10 relative">
103
177
  <div class="hero-glow" aria-hidden="true"></div>
@@ -279,19 +353,17 @@
279
353
  <i data-lucide="terminal" class="size-4" style="color: var(--accent)"></i>
280
354
  <h2 class="display font-bold tracking-tight text-xl truncate min-w-0">Command tools</h2>
281
355
  <div class="flex items-center gap-2 ml-auto min-w-0">
282
- <select id="cmdProject" class="mono text-xs px-2 py-1.5 rounded-[10px]" style="border: 1px solid var(--line); background: var(--panel); color: var(--ink); max-width: 190px" aria-label="Filter command tools by project"></select>
283
356
  <span class="mono text-xs truncate min-w-0" style="color: var(--dim)" id="toolsScope">tool calls in sessions</span>
284
357
  </div>
285
358
  </div>
286
359
  <p class="mono text-xs mb-4 truncate" style="color: var(--dim)">count · tokens saved · avg rate · avg time · share of executions</p>
287
360
  <div class="overflow-x-auto">
288
361
  <table class="w-full mono text-[13px]" id="cmdTable">
289
- <colgroup><col style="width:40px"><col><col style="width:120px"><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:140px"></colgroup>
362
+ <colgroup><col style="width:40px"><col><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:90px"><col style="width:140px"></colgroup>
290
363
  <thead>
291
364
  <tr class="text-left text-[11px] uppercase tracking-[0.14em]" style="color: var(--dim)">
292
365
  <th class="font-normal text-right pr-3 py-2 w-10">#</th>
293
366
  <th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="name">Tool / Command <span class="arr">⇅</span></button></th>
294
- <th class="font-normal py-2 pr-3" aria-sort="none"><button class="thsort" data-sort="project">Project <span class="arr">⇅</span></button></th>
295
367
  <th class="font-normal text-right py-2 pr-3" aria-sort="descending"><button class="thsort num" data-sort="count">Count <span class="arr">↓</span></button></th>
296
368
  <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="saved">Saved <span class="arr">⇅</span></button></th>
297
369
  <th class="font-normal text-right py-2 pr-3" aria-sort="none"><button class="thsort num" data-sort="avgPct">Avg% <span class="arr">⇅</span></button></th>
@@ -0,0 +1,377 @@
1
+ // OMP extension: /ai-addons manual updater for Ponytail, RTK, Caveman.
2
+ // Built-in Node modules only. Default off; registers a single slash command.
3
+ // ponytail: `skipped: none` — semantics match one-liner: fetch + compare + run install.
4
+ // rtk: `skipped: signature verification` — checksums.txt ships only SHA256 of release assets; add sigchain when upstream publishes a signing key.
5
+ // caveman: `skipped: none` — exactly the ask: write rule.md, report old/new hash.
6
+
7
+ import { execFileSync } from 'node:child_process';
8
+ import { createHash } from 'node:crypto';
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ import {
14
+ CAVEMAN_REMOTE_RULE as CAVEMAN_REMOTE,
15
+ RTK_RELEASE_API,
16
+ RtkRelease,
17
+ fetchJson,
18
+ findFile,
19
+ httpsGet,
20
+ httpsDownload,
21
+ sha256Hex,
22
+ parseChecksum,
23
+ normalizeRtkVersion,
24
+ readTextIfExists,
25
+ rtkPlatformSpec,
26
+ } from '../lib/utils.ts';
27
+
28
+ const IS_WINDOWS = process.platform === 'win32';
29
+ const HOME = os.homedir();
30
+
31
+ const PONYTAIL_REMOTE = 'https://raw.githubusercontent.com/DietrichGebert/ponytail/main/package.json';
32
+ const PONYTAIL_NPM_SPEC = '@dietrichgebert/ponytail@latest';
33
+ const PONYTAIL_LOCAL = path.join(HOME, '.omp', 'plugins', 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
34
+ const RTK_BINARY = path.join(HOME, '.bun', 'bin', IS_WINDOWS ? 'rtk.exe' : 'rtk');
35
+ const CAVEMAN_LOCAL = path.join(HOME, '.omp', 'agent', 'extensions', 'caveman-session', 'rule.md');
36
+
37
+ const RELOAD_MSG = 'Reminder: restart OMP (or reload extensions) for updates to take effect.';
38
+
39
+ // --- Types ---
40
+
41
+ interface AddonUpdaterCtx {
42
+ cwd?: string;
43
+ ui?: {
44
+ notify?: (message: string, level?: string) => void;
45
+ };
46
+ }
47
+
48
+ interface AddonUpdaterPi {
49
+ setLabel?: (label: string) => void;
50
+ registerCommand?: (name: string, config: { description: string; handler: (args: string, ctx: AddonUpdaterCtx) => Promise<string> }) => void;
51
+ exec?: (cmd: string, args: string[], opts?: { cwd?: string }) => Promise<{ stdout: string; stderr: string; code: number }>;
52
+ cwd?: string;
53
+ }
54
+
55
+ type NotifyLevel = 'info' | 'warning';
56
+
57
+ function notify(ctx: AddonUpdaterCtx | undefined, msg: string, level: NotifyLevel): void {
58
+ ctx?.ui?.notify?.(String(msg), level);
59
+ }
60
+
61
+ function report(ctx: AddonUpdaterCtx | undefined, msg: string, level: NotifyLevel): string {
62
+ notify(ctx, msg, level);
63
+ return msg;
64
+ }
65
+
66
+ interface AddonStatus {
67
+ text: string;
68
+ level: NotifyLevel;
69
+ }
70
+
71
+ function shortHash(text: string): string {
72
+ return sha256Hex(text).slice(0, 16);
73
+ }
74
+
75
+ function checkFailed(name: string, e: unknown): AddonStatus {
76
+ return { text: `${name} check failed: ${(e as Error).message}`, level: 'warning' };
77
+ }
78
+
79
+ // Single error-handling source for the three probes; messages unchanged.
80
+ function runCheck(name: string, probe: () => Promise<string>): Promise<AddonStatus> {
81
+ return probe().then((text) => ({ text, level: 'info' as const }), (e) => checkFailed(name, e));
82
+ }
83
+
84
+ // Check: no mutation. Probes run concurrently via checkAddons below.
85
+ function checkPonytail(): Promise<AddonStatus> {
86
+ return runCheck('Ponytail', async () => {
87
+ const remoteJson = await fetchJson<{ version?: string }>(PONYTAIL_REMOTE);
88
+ const localRaw = await readTextIfExists(PONYTAIL_LOCAL);
89
+ const localVer = localRaw ? (JSON.parse(localRaw) as { version?: string }).version ?? null : null;
90
+ const remoteVer = remoteJson.version;
91
+ const status = !localVer ? 'not installed'
92
+ : localVer === remoteVer ? 'up to date'
93
+ : 'update available';
94
+ return `Ponytail ${status}: local=${localVer || '—'} latest=${remoteVer}`;
95
+ });
96
+ }
97
+
98
+ async function fetchRelease(): Promise<RtkRelease> {
99
+ return fetchJson<RtkRelease>(RTK_RELEASE_API);
100
+ }
101
+
102
+ function checkRtk(): Promise<AddonStatus> {
103
+ return runCheck('RTK', async () => {
104
+ const release = await fetchRelease();
105
+ const latestTag = release.tag_name || null;
106
+ let localVer: string | null = null;
107
+ try {
108
+ const out = execFileSync(RTK_BINARY, ['--version'], { encoding: 'utf8', windowsHide: true, shell: false, timeout: 10000 }) || '';
109
+ if (out) localVer = out.trim().split(/\r?\n/)[0];
110
+ } catch { localVer = null; }
111
+ const status = localVer === null ? 'not installed'
112
+ : normalizeRtkVersion(localVer) === normalizeRtkVersion(latestTag ?? undefined) ? 'up to date'
113
+ : 'update available';
114
+ return `RTK ${status}: local=${localVer || '—'} latest=${latestTag || '—'}`;
115
+ });
116
+ }
117
+
118
+ // Caveman (rule.md)
119
+ function checkCaveman(): Promise<AddonStatus> {
120
+ return runCheck('Caveman', async () => {
121
+ const remote = await httpsGet(CAVEMAN_REMOTE);
122
+ const remoteHash = shortHash(remote);
123
+ const local = await readTextIfExists(CAVEMAN_LOCAL);
124
+ const localHash = local ? shortHash(local) : null;
125
+ const status = !local ? 'rule.md missing'
126
+ : localHash === remoteHash ? 'rule.md up to date'
127
+ : 'rule.md update available';
128
+ return `Caveman ${status}: local=${localHash || '—'} remote=${remoteHash}`;
129
+ });
130
+ }
131
+
132
+ export async function checkAddonsSummary(ctx: AddonUpdaterCtx): Promise<string> {
133
+ return checkAddons(ctx);
134
+ }
135
+
136
+ export async function runAddonUpdate(pi: { exec?: AddonUpdaterPi['exec'] }, ctx: AddonUpdaterCtx, target: string, dryRun = false): Promise<string> {
137
+ const results: string[] = [];
138
+ const updaters: Record<string, () => Promise<string>> = {
139
+ ponytail: () => updatePonytail(pi, ctx, dryRun),
140
+ rtk: () => updateRtk(ctx, dryRun),
141
+ caveman: () => updateCaveman(ctx, dryRun),
142
+ };
143
+ if (target === 'all') {
144
+ notify(ctx, `ai-addons update all${dryRun ? ' dry-run' : ''}: starting ponytail → rtk → caveman sequentially…`, 'info');
145
+ for (const name of ['ponytail', 'rtk', 'caveman']) results.push(await updaters[name]());
146
+ if (!dryRun) results.push(RELOAD_MSG);
147
+ notify(ctx, `ai-addons update all ${dryRun ? 'dry-run ' : ''}complete.${dryRun ? '' : ` ${RELOAD_MSG}`}`, 'info');
148
+ } else if (Object.hasOwn(updaters, target)) {
149
+ results.push(await updaters[target]());
150
+ } else {
151
+ const m = 'Usage: /ai-addons update <ponytail|rtk|caveman|all> [--dry-run]';
152
+ return report(ctx, m, 'warning');
153
+ }
154
+ return results.join('\n\n');
155
+ }
156
+
157
+ async function checkAddons(ctx: AddonUpdaterCtx): Promise<string> {
158
+ const [ponytail, rtk, caveman] = await Promise.all([checkPonytail(), checkRtk(), checkCaveman()]);
159
+ const lines: string[] = [];
160
+ for (const status of [ponytail, rtk, caveman]) {
161
+ lines.push(status.text);
162
+ notify(ctx, status.text, status.level);
163
+ }
164
+ return lines.join('\n');
165
+ }
166
+
167
+ async function updatePonytail(pi: AddonUpdaterPi, ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
168
+ const pluginsDir = path.join(HOME, '.omp', 'plugins');
169
+ if (dryRun) {
170
+ const m = `Ponytail dry-run: would run \`npm install ${PONYTAIL_NPM_SPEC} --save --no-audit --no-fund\` in ${pluginsDir}.`;
171
+ return report(ctx, m, 'info');
172
+ }
173
+ notify(ctx, 'Ponytail: ensuring plugin directory exists…', 'info');
174
+ try {
175
+ await fs.mkdir(pluginsDir, { recursive: true });
176
+ } catch (e) {
177
+ const m = `Ponytail update failed: failed to create ${pluginsDir}: ${(e as Error).message}`;
178
+ return report(ctx, m, 'warning');
179
+ }
180
+ if (!pi.exec) {
181
+ const m = 'Ponytail update failed: extension host does not provide exec';
182
+ return report(ctx, m, 'warning');
183
+ }
184
+ notify(ctx, 'Ponytail: running npm install…', 'info');
185
+ let out = '';
186
+ try {
187
+ const r = await pi.exec('npm', ['install', PONYTAIL_NPM_SPEC, '--save', '--no-audit', '--no-fund'], { cwd: pluginsDir });
188
+ out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim();
189
+ if (r.code !== 0) throw new Error(r.stderr || `npm exited ${r.code}`);
190
+ } catch (e) {
191
+ const m = `Ponytail update failed: ${(e as Error).message}`;
192
+ return report(ctx, m, 'warning');
193
+ }
194
+ const m = `Ponytail update finished.${out ? `\n${out}` : ''}\n${RELOAD_MSG}`;
195
+ notify(ctx, 'Ponytail update finished. ' + RELOAD_MSG, 'info');
196
+ return m;
197
+ }
198
+
199
+ async function updateRtk(ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
200
+ let release: RtkRelease;
201
+ try {
202
+ release = await fetchRelease();
203
+ } catch (e) {
204
+ const m = `RTK: cannot fetch release info: ${(e as Error).message}`;
205
+ return report(ctx, m, 'warning');
206
+ }
207
+ const tag = release.tag_name || 'unknown';
208
+ const assets = Array.isArray(release.assets) ? release.assets : [];
209
+
210
+ // Cross-platform asset selection (mirrors installer stepRtk)
211
+ const PLATFORM = process.platform;
212
+ const ARCH = process.arch;
213
+ const spec = rtkPlatformSpec(PLATFORM, ARCH);
214
+ if (!spec) {
215
+ const m = `RTK: unsupported platform ${PLATFORM}/${ARCH}`;
216
+ return report(ctx, m, 'warning');
217
+ }
218
+ const { triple: assetTriple, ext: assetExt, binary: binaryName } = spec;
219
+
220
+ const asset = assets.find((a) => a.name === `rtk-${assetTriple}${assetExt}`);
221
+ const checksAsset = assets.find((a) => a.name === 'checksums.txt');
222
+ if (!asset || !checksAsset) {
223
+ const m = `RTK: required assets not found in release ${tag} (need rtk-${assetTriple}${assetExt} and checksums.txt)`;
224
+ return report(ctx, m, 'warning');
225
+ }
226
+
227
+ if (dryRun) {
228
+ const m = `RTK dry-run: would download ${asset.name} (${tag}), verify checksums.txt, and replace ${RTK_BINARY}.`;
229
+ return report(ctx, m, 'info');
230
+ }
231
+
232
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'rtk-update-'));
233
+ const archivePath = path.join(tmp, asset.name);
234
+ const checksPath = path.join(tmp, 'checksums.txt');
235
+
236
+ try {
237
+ notify(ctx, `RTK: downloading ${asset.name} (${tag})…`, 'info');
238
+ notify(ctx, 'RTK: downloading checksums.txt…', 'info');
239
+ await Promise.all([
240
+ httpsDownload(asset.browser_download_url, archivePath),
241
+ httpsDownload(checksAsset.browser_download_url, checksPath),
242
+ ]);
243
+ // Verify SHA256 against checksums.txt
244
+ const checks = await fs.readFile(checksPath, 'utf8');
245
+ const expected = parseChecksum(checks, asset.name);
246
+ if (!expected) {
247
+ const m = `RTK: checksums.txt has no entry for ${asset.name}`;
248
+ return report(ctx, m, 'warning');
249
+ }
250
+ const archiveBuf = await fs.readFile(archivePath);
251
+ const actual = createHash('sha256').update(archiveBuf).digest('hex').toLowerCase();
252
+ if (actual !== expected) {
253
+ const m = `RTK: checksum mismatch! expected=${expected.slice(0, 12)}… actual=${actual.slice(0, 12)}…`;
254
+ return report(ctx, m, 'warning');
255
+ }
256
+ notify(ctx, 'RTK: checksum verified.', 'info');
257
+
258
+ // Extract by archive format
259
+ const extractDir = path.join(tmp, 'extracted');
260
+ await fs.mkdir(extractDir, { recursive: true });
261
+
262
+ if (asset.name.endsWith('.zip')) {
263
+ if (IS_WINDOWS) {
264
+ execFileSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command',
265
+ `Expand-Archive -LiteralPath '${archivePath}' -DestinationPath '${extractDir}' -Force`],
266
+ { encoding: 'utf8', windowsHide: true, shell: false });
267
+ } else {
268
+ execFileSync('unzip', [archivePath, '-d', extractDir], { encoding: 'utf8', shell: false });
269
+ }
270
+ } else if (asset.name.endsWith('.tar.gz') || asset.name.endsWith('.tgz')) {
271
+ try {
272
+ execFileSync('tar', ['xzf', archivePath, '-C', extractDir], { encoding: 'utf8', shell: false });
273
+ } catch (e) {
274
+ throw new Error(`tar could not extract ${asset.name}: ${(e as Error).message}`);
275
+ }
276
+ } else {
277
+ throw new Error(`Unknown archive format: ${asset.name}`);
278
+ }
279
+ const rtkExtracted = await findFile(extractDir, binaryName);
280
+ if (!rtkExtracted) {
281
+ const m = `RTK: ${binaryName} not found in extracted archive`;
282
+ return report(ctx, m, 'warning');
283
+ }
284
+ await fs.mkdir(path.dirname(RTK_BINARY), { recursive: true });
285
+ const backupPath = `${RTK_BINARY}.bak`;
286
+ const backedUp = await fs.copyFile(RTK_BINARY, backupPath).then(() => true, () => false);
287
+
288
+ await fs.copyFile(rtkExtracted, RTK_BINARY);
289
+
290
+ // Set executable bit on Unix
291
+ if (!IS_WINDOWS) {
292
+ await fs.chmod(RTK_BINARY, 0o755);
293
+ }
294
+
295
+ let versionOut = '';
296
+ try {
297
+ versionOut = execFileSync(RTK_BINARY, ['--version'], { encoding: 'utf8', windowsHide: true, shell: false, timeout: 10000 }).trim();
298
+ } catch (e) {
299
+ if (backedUp) await fs.copyFile(backupPath, RTK_BINARY);
300
+ throw new Error(`new ${binaryName} failed --version${backedUp ? '; restored backup' : ''}: ${(e as Error).message}`);
301
+ }
302
+ if (normalizeRtkVersion(versionOut) !== normalizeRtkVersion(tag)) {
303
+ if (backedUp) await fs.copyFile(backupPath, RTK_BINARY);
304
+ throw new Error(`new ${binaryName} reports ${versionOut}, expected ${tag}${backedUp ? '; restored backup' : ''}`);
305
+ }
306
+ const m = `RTK updated to ${tag} → ${RTK_BINARY}\nbackup=${backedUp ? backupPath : '—'}\n${RELOAD_MSG}`;
307
+ notify(ctx, 'RTK update finished. ' + RELOAD_MSG, 'info');
308
+ return m;
309
+ } catch (e) {
310
+ const m = `RTK update failed: ${(e as Error).message}`;
311
+ return report(ctx, m, 'warning');
312
+ } finally {
313
+ fs.rm(tmp, { recursive: true, force: true }).catch(() => { });
314
+ }
315
+ }
316
+
317
+ async function updateCaveman(ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
318
+ let remote: string;
319
+ try { remote = await httpsGet(CAVEMAN_REMOTE); }
320
+ catch (e) { return report(ctx, `Caveman update failed: ${(e as Error).message}`, 'warning'); }
321
+
322
+ const remoteHash = shortHash(remote);
323
+ const oldLocal = await readTextIfExists(CAVEMAN_LOCAL);
324
+ const oldHash = oldLocal ? shortHash(oldLocal) : null;
325
+
326
+ if (dryRun) {
327
+ const m = `Caveman dry-run: would write ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}.`;
328
+ return report(ctx, m, 'info');
329
+ }
330
+
331
+ try {
332
+ await fs.mkdir(path.dirname(CAVEMAN_LOCAL), { recursive: true });
333
+ const backupPath = `${CAVEMAN_LOCAL}.bak`;
334
+ if (oldLocal !== null) await fs.writeFile(backupPath, oldLocal, 'utf8');
335
+ await fs.writeFile(CAVEMAN_LOCAL, remote, 'utf8');
336
+ const written = await fs.readFile(CAVEMAN_LOCAL, 'utf8');
337
+ const writtenHash = shortHash(written);
338
+ if (writtenHash !== remoteHash) {
339
+ if (oldLocal !== null) await fs.writeFile(CAVEMAN_LOCAL, oldLocal, 'utf8');
340
+ throw new Error(`written hash ${writtenHash} did not match remote ${remoteHash}${oldLocal !== null ? '; restored backup' : ''}`);
341
+ }
342
+ const m = `Caveman rule.md updated → ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}\nbackup=${oldLocal !== null ? backupPath : '—'}\n${RELOAD_MSG}`;
343
+ notify(ctx, 'Caveman rule.md updated. ' + RELOAD_MSG, 'info');
344
+ return m;
345
+ } catch (e) {
346
+ const m = `Caveman update failed: ${(e as Error).message}`;
347
+ return report(ctx, m, 'warning');
348
+ }
349
+ }
350
+
351
+ export default function aiAddonsUpdaterExtension(pi: AddonUpdaterPi): void {
352
+ pi.setLabel?.('AI add-ons updater');
353
+
354
+ pi.registerCommand?.('ai-addons', {
355
+ description: 'Check or update AI add-ons (ponytail/rtk/caveman/all). Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]',
356
+ handler: async (args, ctx) => {
357
+ const arg = String(args || '').trim().toLowerCase();
358
+ const parts = arg.split(/\s+/).filter(Boolean);
359
+ const dryRun = parts.includes('--dry-run') || parts.includes('dry-run');
360
+ const cleanParts = parts.filter((p) => p !== '--dry-run' && p !== 'dry-run');
361
+ const sub = cleanParts[0];
362
+
363
+ if (sub === 'check' || sub === 'status') {
364
+ const summary = await checkAddons(ctx);
365
+ notify(ctx, 'ai-addons check complete.', 'info');
366
+ return summary;
367
+ }
368
+ if (sub === 'update' && cleanParts[1]) {
369
+ return runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
370
+ }
371
+ const m = 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]';
372
+ return report(ctx, m, 'warning');
373
+ },
374
+ });
375
+ }
376
+
377
+ export { parseChecksum };
@@ -0,0 +1,161 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { activeModesSummary, asPromptArray, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode } from '../shared/session-state.ts';
4
+ import { dirname, join } from 'node:path';
5
+ import { readCavemanDefault } from '../shared/plugin-settings.ts';
6
+ import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
7
+
8
+ const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
9
+ const RULE_PATH = join(CAVERN_DIR, 'rule.md');
10
+
11
+ const FALLBACK_FULL_RULE = `Respond terse like smart caveman. All technical substance stay. Only fluff die.
12
+
13
+ Rules:
14
+ - Drop articles (a/an/the), filler (just/really/basically), pleasantries, hedging.
15
+ - Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
16
+ - Pattern: [thing] [action] [reason]. [next step].
17
+ - Not: 'Sure! I'd be happy to help you with that.'
18
+ - Yes: 'Bug in auth middleware. Fix:'
19
+
20
+ Auto-clarity: drop caveman for security warnings, irreversible actions, or when user seems confused. Resume after.`;
21
+
22
+ // ponytail: synchronous read each full-mode injection; ceiling = small file, cold session start. Upgrade path: cache file contents + mtime, invalidate on change.
23
+ function readFullRule(): string {
24
+ try { return readFileSync(RULE_PATH, 'utf8'); } catch { return FALLBACK_FULL_RULE; }
25
+ }
26
+
27
+ const DEFAULT_MODE = 'off';
28
+ const INSTRUCTIONS: Record<string, string | (() => string)> = {
29
+ lite: `Caveman lite active for this session.
30
+ Respond concise. Drop pleasantries, filler, and hedging. Keep complete technical substance. Code, commands, paths, errors, commits, and PR text stay normal/exact.`,
31
+ full: () => `Caveman full active for this session.\n${readFullRule()}`,
32
+ ultra: `Caveman ultra active for this session.
33
+ Maximum terse prose. Fragments preferred. No pleasantries, no tour, no recap unless needed. Keep all technical substance exact. Code, commands, commits, PR text, paths, and errors stay normal/exact. Drop caveman for security warnings, irreversible actions, or user confusion.`,
34
+ wenyan: `Caveman wenyan active for this session.
35
+ Use ultra-terse classical-Chinese-style prose only where it preserves clarity for the user. Keep technical terms, code, commands, commits, PR text, paths, and errors exact. If clarity would suffer, use caveman full instead.`,
36
+ };
37
+
38
+ function resolveMode(entries: SessionEntry[] | null | undefined, fallback: string = DEFAULT_MODE): string {
39
+ return lastCustomValue(entries, 'caveman-mode', (data) => normalizeMode('caveman', data?.mode)) ?? fallback;
40
+ }
41
+
42
+ function isOffCommand(text: unknown): boolean {
43
+ const t = normalizeInputCommand(text);
44
+ return t === 'stop caveman' || t === 'normal mode' || t === 'caveman off';
45
+ }
46
+
47
+ export default function cavemanSessionExtension(pi: ExtensionApi): void {
48
+ let currentMode = DEFAULT_MODE;
49
+ let isActive = false;
50
+ let lastCtx: ExtensionCtx | undefined = undefined;
51
+
52
+ function syncStatus(ctx?: ExtensionCtx): void {
53
+ lastCtx = paintableCtx(lastCtx, ctx);
54
+ const c = lastCtx;
55
+ if (!c?.ui?.setStatus) return;
56
+ // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
57
+ if (isComboPresetActive() || currentMode === 'off') {
58
+ c.ui.setStatus('caveman', undefined);
59
+ return;
60
+ }
61
+ paintStatusBar(c.ui, 'caveman', '🪨', `caveman: ${currentMode.toUpperCase()}`, isActive);
62
+ }
63
+
64
+ function setMode(mode: string, ctx?: ExtensionCtx): boolean {
65
+ const normalized = normalizeMode('caveman', mode);
66
+ if (!normalized) return false;
67
+ currentMode = normalized;
68
+ pi.appendEntry?.('caveman-mode', { mode: normalized });
69
+ setSharedComboMode('caveman', normalized);
70
+ syncStatus(ctx);
71
+ const active = activeModesSummary(getSharedComboState());
72
+ const msg = normalized === 'off'
73
+ ? `Caveman off. Active: ${active}.`
74
+ : `Caveman ${normalized} on — terse replies for this session. Active: ${active}.`;
75
+ ctx?.ui?.notify?.(msg, 'info');
76
+ return true;
77
+ }
78
+
79
+ pi.setLabel?.('Caveman session toggle');
80
+
81
+ // Live mirror: a /tersio or /combo switch publishes shared state — adopt
82
+ // it at once so the next turn injects the new mode with no session reload.
83
+ // Stable identity, so the bridge set dedupes across re-inits.
84
+ function syncFromShared(state: { caveman: string }): void {
85
+ const mode = normalizeMode('caveman', state.caveman);
86
+ if (mode) {
87
+ currentMode = mode;
88
+ syncStatus();
89
+ }
90
+ }
91
+ setSharedComboListener(syncFromShared);
92
+ pi.registerCommand?.('caveman', {
93
+ description: 'Toggle terse caveman replies for this session',
94
+ handler: async (args, ctx) => {
95
+ const arg = String(args || '').trim().toLowerCase();
96
+ if (!arg || arg === 'on') {
97
+ setMode('full', ctx);
98
+ return;
99
+ }
100
+ if (arg === 'status') {
101
+ ctx?.ui?.notify?.(`Caveman: ${currentMode}`, 'info');
102
+ return;
103
+ }
104
+ if (!setMode(arg, ctx)) {
105
+ ctx?.ui?.notify?.('Usage: /caveman [lite|full|ultra|wenyan|off|status]', 'warning');
106
+ }
107
+ },
108
+ });
109
+
110
+ pi.on<InputEvent>('input', async (event) => {
111
+ if (event?.source === 'extension') return;
112
+ if (currentMode !== 'off' && isOffCommand(event?.text)) setMode('off');
113
+ });
114
+
115
+ function restoreMode(ctx?: ExtensionCtx): void {
116
+ const entries = sessionEntries(ctx);
117
+ // Publish the persisted combo state (incl. combo-level) before painting:
118
+ // bar suppression reads the in-process bridge, which is empty in a fresh
119
+ // host until the combo extension reconciles — and its reconcile is
120
+ // UI-gated. Deriving it here makes suppression independent of load order.
121
+ reconcileSharedComboEntries(entries);
122
+ // Persisted session state wins; a fresh session falls back to the
123
+ // installer/user-configured default (off unless configured).
124
+ const persisted = resolveMode(entries, '');
125
+ currentMode = persisted || readCavemanDefault();
126
+ syncStatus(ctx);
127
+ }
128
+
129
+ pi.on('session_start', async (_event, ctx) => {
130
+ restoreMode(ctx);
131
+ ctx?.ui?.notify?.(`Caveman loaded: ${currentMode}`, 'info');
132
+ });
133
+
134
+ pi.on('session_branch', async (_event, ctx) => {
135
+ restoreMode(ctx);
136
+ });
137
+
138
+ pi.on('session_tree', async (_event, ctx) => {
139
+ restoreMode(ctx);
140
+ });
141
+
142
+ pi.on('agent_start', async (_event, ctx) => {
143
+ isActive = true;
144
+ syncStatus(ctx);
145
+ });
146
+
147
+ pi.on('agent_end', async (_event, ctx) => {
148
+ isActive = false;
149
+ syncStatus(ctx);
150
+ });
151
+
152
+ pi.on('before_agent_start', async (event: SystemPromptEvent) => {
153
+ const mode = isOmpSubagentPrompt(event.systemPrompt) ? getSharedComboState().caveman : currentMode;
154
+ if (!mode || mode === 'off') return;
155
+ const def = INSTRUCTIONS[mode];
156
+ if (!def) return;
157
+ const instruction = typeof def === 'function' ? def() : def;
158
+ const base = asPromptArray(event.systemPrompt);
159
+ return { systemPrompt: [...base, instruction] };
160
+ });
161
+ }