@krtclcdy/tersio 2.25.1 → 2.25.3

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 (57) hide show
  1. package/README.md +1 -1
  2. package/dashboard/dist/index.html +1 -1
  3. package/{cli → dist/cli}/common.js +4 -3
  4. package/{cli → dist/cli}/dashboard.js +30 -13
  5. package/{cli → dist/cli}/doctor-fix.js +19 -30
  6. package/{cli → dist/cli}/doctor.js +19 -6
  7. package/dist/cli/headroom-install.js +266 -0
  8. package/dist/cli/headroom.js +492 -0
  9. package/{cli → dist/cli}/install.js +116 -71
  10. package/{cli → dist/cli}/interactive.js +2 -2
  11. package/{cli → dist/cli}/manifest.js +4 -1
  12. package/{cli → dist/cli}/rtk-wiring.js +5 -2
  13. package/{cli → dist/cli}/settings.js +5 -23
  14. package/{cli → dist/cli}/uninstall.js +23 -11
  15. package/{cli → dist/cli}/update.js +10 -8
  16. package/{extensions → dist/extensions}/ai-addons-updater/index.js +60 -44
  17. package/{extensions → dist/extensions}/caveman-session/index.js +11 -11
  18. package/dist/extensions/caveman-session/rule-megacave.md +63 -0
  19. package/dist/extensions/caveman-session/rule-ultra.md +63 -0
  20. package/dist/extensions/caveman-session/rule.md +79 -0
  21. package/{extensions → dist/extensions}/combo-toggle/index.js +37 -27
  22. package/dist/extensions/headroom-session/index.js +93 -0
  23. package/{extensions → dist/extensions}/lib/utils.js +27 -1
  24. package/{extensions → dist/extensions}/opencode/server.js +33 -3
  25. package/dist/extensions/shared/headroom.js +131 -0
  26. package/{extensions → dist/extensions}/shared/plugin-settings.js +1 -1
  27. package/{extensions → dist/extensions}/shared/session-state.js +14 -2
  28. package/{extensions → dist/extensions}/shared/status.js +7 -1
  29. package/{tersio.js → dist/tersio.js} +1 -1
  30. package/extensions/ai-addons-updater/index.ts +52 -40
  31. package/extensions/caveman-session/index.ts +10 -11
  32. package/extensions/caveman-session/rule-megacave.md +63 -0
  33. package/extensions/caveman-session/rule-ultra.md +63 -0
  34. package/extensions/caveman-session/rule.md +4 -15
  35. package/extensions/combo-toggle/index.ts +35 -27
  36. package/extensions/lib/utils.ts +23 -1
  37. package/extensions/opencode/server.ts +29 -3
  38. package/extensions/shared/plugin-settings.ts +1 -1
  39. package/extensions/shared/session-state.ts +15 -2
  40. package/extensions/shared/status.ts +8 -1
  41. package/package.json +17 -15
  42. package/extensions/opencode/index.js +0 -104
  43. /package/{cli → dist/cli}/banner.js +0 -0
  44. /package/{cli → dist/cli}/currency.js +0 -0
  45. /package/{cli → dist/cli}/hosts.js +0 -0
  46. /package/{cli → dist/cli}/profile.js +0 -0
  47. /package/{cli → dist/cli}/reset.js +0 -0
  48. /package/{cli → dist/cli}/usage.js +0 -0
  49. /package/{extensions → dist/extensions}/rtk-session/index.js +0 -0
  50. /package/{extensions → dist/extensions}/shared/carbon.js +0 -0
  51. /package/{extensions → dist/extensions}/shared/host.js +0 -0
  52. /package/{extensions → dist/extensions}/shared/pricing.js +0 -0
  53. /package/{extensions → dist/extensions}/shared/rtk-gain.js +0 -0
  54. /package/{extensions → dist/extensions}/shared/types.js +0 -0
  55. /package/{extensions → dist/extensions}/shared/usage-ledger.js +0 -0
  56. /package/{extensions → dist/extensions}/shared/usage-store.js +0 -0
  57. /package/{extensions → dist/extensions}/tersio-commands/index.js +0 -0
@@ -6,10 +6,10 @@ import comboToggleExtension from '../combo-toggle/index.js';
6
6
  import tersioCommandsExtension from '../tersio-commands/index.js';
7
7
  import { getSharedComboState } from '../shared/session-state.js';
8
8
  import { formatStatus } from '../shared/status.js';
9
- // Plain object, not Plugin.define: define is an identity wrapper, and a value
10
- // import would force every install to vendor the whole @opencode dependency tree.
9
+ import { resolveRtkBinary } from '../lib/utils.js';
10
+ // The npm name, so the plugin list reads the same as the OMP and pi installs.
11
11
  const plugin = {
12
- id: 'tersio',
12
+ id: '@krtclcdy/tersio',
13
13
  async setup(ctx) {
14
14
  // Entries are scoped per session; an unseen ID restores on its first hook.
15
15
  const sessions = new Map();
@@ -20,7 +20,10 @@ const plugin = {
20
20
  if (own.length)
21
21
  return own;
22
22
  // Pre-scoped installs kept one global list; adopt it once, then diverge.
23
+ // Clear it after adoption so it can never shadow saved defaults later.
23
24
  const legacy = (await ctx.storage.get('entries')) ?? [];
25
+ if (legacy.length)
26
+ void ctx.storage.set('entries', []).catch(() => { });
24
27
  return [...legacy];
25
28
  };
26
29
  const sessionEntriesOf = (sid) => {
@@ -185,6 +188,33 @@ const plugin = {
185
188
  for (const handler of inputHandlers)
186
189
  void handler({ text: event.prompt.text, source: 'interactive' });
187
190
  });
191
+ // rtk has its own OpenCode plugin; doing this here keeps one entry in the plugin list.
192
+ await ctx.tool.hook('execute.before', async (event) => {
193
+ const tool = String(event.tool ?? '').toLowerCase();
194
+ if (tool !== 'bash' && tool !== 'shell')
195
+ return;
196
+ const input = event.input;
197
+ if (typeof input?.command !== 'string' || !input.command)
198
+ return;
199
+ const rewritten = await rewriteCommand(input.command);
200
+ if (rewritten)
201
+ input.command = rewritten;
202
+ });
188
203
  },
189
204
  };
205
+ function rewriteCommand(command) {
206
+ const bin = resolveRtkBinary();
207
+ if (!bin)
208
+ return Promise.resolve(null);
209
+ return new Promise((resolve) => {
210
+ execFile(bin, ['rewrite', command], { timeout: 5000 }, (error, stdout) => {
211
+ // A kill or timeout means untrustworthy output; pass the command through.
212
+ const err = error;
213
+ if (err && (err.killed || err.signal))
214
+ return resolve(null);
215
+ const out = String(stdout).trim();
216
+ resolve(out && out !== command ? out : null);
217
+ });
218
+ });
219
+ }
190
220
  export default plugin;
@@ -0,0 +1,131 @@
1
+ // Headroom is a local proxy that compresses input-side context. Agent-neutral core: one adapter row per agent, no agent name in the logic below.
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { patchPluginSettings, readPluginSettings } from './plugin-settings.js';
4
+ export const HEADROOM_AGENTS = ['omp', 'pi', 'opencode'];
5
+ export const HEADROOM_DEFAULT_PORT = 8787;
6
+ /** Lowest Headroom the spike verified. */
7
+ export const HEADROOM_MIN_VERSION = '0.40.0';
8
+ /** Newest Headroom the spike verified; a newer one only warns. */
9
+ export const HEADROOM_TESTED_VERSION = '0.40.0';
10
+ /** What to say when the binary is absent. Headroom ships with the add-ons, so the fix is a Tersio install, not a hand-run Python tool. */
11
+ export const HEADROOM_INSTALL_HINT = 'run `tersio install` (or `tersio update`) to add the bundled Headroom add-on';
12
+ /** Native or manual, from the spike's `wrap` table. */
13
+ export function headroomCapability(agent) {
14
+ return agent === 'pi' ? 'manual' : 'native';
15
+ }
16
+ export function isHeadroomAgent(value) {
17
+ return typeof value === 'string' && HEADROOM_AGENTS.includes(value);
18
+ }
19
+ /** `headroom 0.40.0` and a bare `0.40.0` both parse. */
20
+ export function parseHeadroomVersion(raw) {
21
+ const match = /\d+\.\d+\.\d+/.exec(String(raw ?? ''));
22
+ return match ? match[0] : null;
23
+ }
24
+ export function compareVersions(a, b) {
25
+ const left = a.split('.').map((part) => Number.parseInt(part, 10) || 0);
26
+ const right = b.split('.').map((part) => Number.parseInt(part, 10) || 0);
27
+ for (let i = 0; i < 3; i += 1) {
28
+ const delta = (left[i] ?? 0) - (right[i] ?? 0);
29
+ if (delta !== 0)
30
+ return delta > 0 ? 1 : -1;
31
+ }
32
+ return 0;
33
+ }
34
+ export function headroomVersionIssue(version) {
35
+ if (!version)
36
+ return 'unknown';
37
+ if (compareVersions(version, HEADROOM_MIN_VERSION) < 0)
38
+ return 'old';
39
+ if (compareVersions(version, HEADROOM_TESTED_VERSION) > 0)
40
+ return 'newer';
41
+ return 'ok';
42
+ }
43
+ function asAgentState(raw) {
44
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
45
+ return null;
46
+ const value = raw;
47
+ return { enabled: value.enabled === true, wrappedByTersio: value.wrappedByTersio === true };
48
+ }
49
+ function asAgentMap(raw) {
50
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw))
51
+ return {};
52
+ const out = {};
53
+ for (const [key, value] of Object.entries(raw)) {
54
+ if (!isHeadroomAgent(key))
55
+ continue;
56
+ const state = asAgentState(value);
57
+ if (state)
58
+ out[key] = state;
59
+ }
60
+ return out;
61
+ }
62
+ export function readHeadroomConfig() {
63
+ const settings = readPluginSettings();
64
+ const port = Number(settings.headroomPort);
65
+ return {
66
+ support: settings.headroomSupport === true,
67
+ port: Number.isInteger(port) && port > 0 && port < 65536 ? port : HEADROOM_DEFAULT_PORT,
68
+ agents: asAgentMap(settings.headroom),
69
+ };
70
+ }
71
+ export function headroomAgentState(config, agent) {
72
+ return config.agents[agent] ?? { enabled: false, wrappedByTersio: false };
73
+ }
74
+ /** The one write path for the kill switch, the port, and the per-agent flags. Keeps unrelated settings keys intact. */
75
+ export function writeHeadroomConfig(config) {
76
+ return patchPluginSettings({ headroomSupport: config.support, headroomPort: config.port, headroom: config.agents });
77
+ }
78
+ export function headroomProxyUrl(port = HEADROOM_DEFAULT_PORT) {
79
+ return `http://127.0.0.1:${port}`;
80
+ }
81
+ /** Fails open: a dead or absent proxy reports false, never an exception. */
82
+ export async function headroomProxyUp(port = HEADROOM_DEFAULT_PORT, timeoutMs = 400) {
83
+ try {
84
+ const res = await fetch(`${headroomProxyUrl(port)}/health`, { signal: AbortSignal.timeout(timeoutMs) });
85
+ return res.ok;
86
+ }
87
+ catch {
88
+ return false;
89
+ }
90
+ }
91
+ export function headroomStatusSegment(state) {
92
+ if (state === null)
93
+ return null;
94
+ if (state === 'on')
95
+ return '🗜️ headroom=ON';
96
+ if (state === 'manual')
97
+ return '🗜️ headroom=ON (manual)';
98
+ if (state === 'down')
99
+ return '🗜️ headroom=DOWN';
100
+ return '🗜️ headroom=OFF';
101
+ }
102
+ // One process paints one status line, so a module mirror is enough. null means "no Headroom in this install".
103
+ let status = null;
104
+ export function getHeadroomStatus() {
105
+ return status;
106
+ }
107
+ export function setHeadroomStatus(next) {
108
+ status = next;
109
+ }
110
+ /** The status for one agent, from its stored flags. `off` when support is off or nothing is wired. */
111
+ export function headroomStatusFor(config, agent, proxyUp) {
112
+ if (!config.support)
113
+ return 'off';
114
+ const state = headroomAgentState(config, agent);
115
+ if (!state.enabled)
116
+ return 'off';
117
+ return proxyUp ? (headroomCapability(agent) === 'manual' ? 'manual' : 'on') : 'down';
118
+ }
119
+ /** True when the agent's config file already carries a Headroom marker. */
120
+ export function headroomWiringPresent(files) {
121
+ for (const file of files) {
122
+ try {
123
+ if (!existsSync(file))
124
+ continue;
125
+ if (/headroom|127\.0\.0\.1:8787/i.test(readFileSync(file, 'utf8')))
126
+ return true;
127
+ }
128
+ catch { /* unreadable counts as unwired */ }
129
+ }
130
+ return false;
131
+ }
@@ -43,7 +43,7 @@ export function readPluginSettings() {
43
43
  return readSettingsFile() ?? legacyOmpSettings() ?? {};
44
44
  }
45
45
  const COMBO_LEVELS = new Set(['off', 'medium', 'balanced', 'max']);
46
- const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
46
+ const CAVEMAN_MODES = new Set(['off', 'lite', 'full', 'ultra', 'megacave', 'megacave-lite', 'megacave-full', 'megacave-ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']);
47
47
  const PONYTAIL_MODES = new Set(['off', 'lite', 'full', 'ultra']);
48
48
  function readStringDefault(key, valid) {
49
49
  const raw = readPluginSettings()[key];
@@ -9,10 +9,12 @@ export const COMBO_LEVELS = Object.freeze({
9
9
  max: Object.freeze({ level: 'max', caveman: 'ultra', rtk: 'on', ponytail: 'ultra' }),
10
10
  });
11
11
  const MODE_VALUES = {
12
- caveman: new Set(['off', 'lite', 'full', 'ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']),
12
+ caveman: new Set(['off', 'lite', 'full', 'ultra', 'megacave', 'megacave-lite', 'megacave-full', 'megacave-ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']),
13
13
  rtk: new Set(['off', 'on']),
14
14
  ponytail: new Set(['off', 'lite', 'full', 'ultra']),
15
15
  };
16
+ // Upstream renamed wenyan to megacave and kept the old spelling as an alias.
17
+ const CAVEMAN_LEGACY_PREFIX = /^wenyan/;
16
18
  const MODE_ENTRY_TYPES = {
17
19
  'caveman-mode': 'caveman',
18
20
  'rtk-mode': 'rtk',
@@ -29,7 +31,10 @@ export function normalizeMode(name, value) {
29
31
  const mode = String(value ?? '').trim().toLowerCase();
30
32
  if (!MODE_VALUES[name]?.has(mode))
31
33
  return null;
32
- return name === 'caveman' && mode === 'wenyan' ? 'wenyan-full' : mode;
34
+ if (name !== 'caveman')
35
+ return mode;
36
+ const canonical = mode.replace(CAVEMAN_LEGACY_PREFIX, 'megacave');
37
+ return canonical === 'megacave' ? 'megacave-full' : canonical;
33
38
  }
34
39
  export function deriveLevel(modes) {
35
40
  for (const level of Object.keys(COMBO_LEVELS)) {
@@ -127,6 +132,13 @@ export function setSharedComboMode(name, value) {
127
132
  // Derive the level from the final triplet so a redundant same-value write cannot strand a matching preset at 'custom' and drop the combo bar.
128
133
  return publish(normalizedState(modes));
129
134
  }
135
+ export function setSharedComboModes(modes) {
136
+ return publish(normalizedState({
137
+ caveman: normalizeMode('caveman', modes.caveman) || 'off',
138
+ rtk: normalizeMode('rtk', modes.rtk) || 'off',
139
+ ponytail: normalizeMode('ponytail', modes.ponytail) || 'off',
140
+ }));
141
+ }
130
142
  export function reconcileSharedComboEntries(entries) {
131
143
  let modes = { ...COMBO_LEVELS.off };
132
144
  if (Array.isArray(entries)) {
@@ -8,6 +8,10 @@ export function formatStatus(state) {
8
8
  const label = level === 'custom' ? 'CUSTOM' : level.toUpperCase();
9
9
  return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()}`;
10
10
  }
11
+ // The string upstream's skill tells the model to relay for `/caveman status`.
12
+ export function formatCavemanStatus(mode) {
13
+ return `Caveman mode: ${mode && mode !== 'off' ? mode : 'unknown'}`;
14
+ }
11
15
  // Stale ctx access throws, so never let it abort a mode restore.
12
16
  function statusUi(ctx) {
13
17
  try {
@@ -24,7 +28,8 @@ let lastText;
24
28
  let lastUi;
25
29
  // Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
26
30
  export function announceStatus(ctx) {
27
- const text = formatStatus(getSharedComboState());
31
+ const state = getSharedComboState();
32
+ const text = formatStatus(state);
28
33
  const ui = statusUi(ctx) ?? statusUi(remembered);
29
34
  if (statusUi(ctx))
30
35
  remembered = ctx;
@@ -35,4 +40,5 @@ export function announceStatus(ctx) {
35
40
  lastText = text;
36
41
  lastUi = ui;
37
42
  ui.setStatus?.('tersio', text);
43
+ ui.setStatus?.('caveman', formatCavemanStatus(state.caveman));
38
44
  }
@@ -32,7 +32,7 @@ Options:
32
32
  --keep-ponytail (uninstall: keep the bundled Ponytail copy — removed by default)
33
33
  --remove-rtk (uninstall: also remove the RTK binary; its OMP wiring always goes)
34
34
  --combo-default off|medium|balanced|max (implies caveman, rtk, ponytail)
35
- --caveman-default off|lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra (override; default follows combo)
35
+ --caveman-default off|lite|full|ultra|megacave-lite|megacave-full|megacave-ultra (override; default follows combo)
36
36
  --ponytail-default off|lite|full|ultra (override; default follows combo)
37
37
  --rtk-default on|off (override; default follows combo)
38
38
  --yes, -y
@@ -9,7 +9,8 @@ import { fileURLToPath } from 'node:url';
9
9
 
10
10
  import { setExtensionLabel } from '../shared/host.ts';
11
11
  import {
12
- CAVEMAN_REMOTE_RULE as CAVEMAN_REMOTE,
12
+ CAVEMAN_REMOTE_ULTRA,
13
+ CAVEMAN_REMOTE_MEGACAVE,
13
14
  RTK_RELEASE_API,
14
15
  RtkRelease,
15
16
  fetchJson,
@@ -31,8 +32,13 @@ const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
31
32
 
32
33
  const PONYTAIL_REMOTE = 'https://raw.githubusercontent.com/DietrichGebert/ponytail/main/package.json';
33
34
  const RTK_BINARY = resolveRtkBinary();
34
- // Beside this file, not a host path: that is the rule.md /caveman reads.
35
- const CAVEMAN_LOCAL = path.join(EXTENSION_DIR, '..', 'caveman-session', 'rule.md');
35
+ // Beside this file, not a host path: these are the rules /caveman reads.
36
+ const cavemanRule = (name: string) => path.join(EXTENSION_DIR, '..', 'caveman-session', name);
37
+ const CAVEMAN_LOCAL = cavemanRule('rule.md');
38
+ const CAVEMAN_TRACKED: ReadonlyArray<{ file: string; remote: string }> = [
39
+ { file: cavemanRule('rule-ultra.md'), remote: CAVEMAN_REMOTE_ULTRA },
40
+ { file: cavemanRule('rule-megacave.md'), remote: CAVEMAN_REMOTE_MEGACAVE },
41
+ ];
36
42
  const HOST_NAME = isPiProcess() ? 'Pi' : 'OMP';
37
43
  const RELOAD_MSG = `Reminder: restart ${HOST_NAME} (or reload extensions) for updates to take effect.`;
38
44
 
@@ -129,17 +135,19 @@ function checkRtk(): Promise<AddonStatus> {
129
135
  });
130
136
  }
131
137
 
132
- // Caveman (rule.md)
133
138
  function checkCaveman(): Promise<AddonStatus> {
134
139
  return runCheck('Caveman', async () => {
135
- const remote = await httpsGet(CAVEMAN_REMOTE);
136
- const remoteHash = shortHash(remote);
137
- const local = await readTextIfExists(CAVEMAN_LOCAL);
138
- const localHash = local ? shortHash(local) : null;
139
- const status = !local ? 'rule.md missing'
140
- : localHash === remoteHash ? 'rule.md up to date'
141
- : 'rule.md update available';
142
- return `Caveman ${status}: local=${localHash || '—'} remote=${remoteHash}`;
140
+ const parts: string[] = [(await readTextIfExists(CAVEMAN_LOCAL)) ? 'rule.md ok' : 'rule.md missing'];
141
+ for (const { file, remote } of CAVEMAN_TRACKED) {
142
+ const name = path.basename(file);
143
+ try {
144
+ const [remoteText, localText] = await Promise.all([httpsGet(remote), readTextIfExists(file)]);
145
+ parts.push(!localText ? `${name} missing` : shortHash(localText) === shortHash(remoteText) ? `${name} up to date` : `${name} update available`);
146
+ } catch {
147
+ parts.push(`${name} check skipped`);
148
+ }
149
+ }
150
+ return `Caveman ${parts.join(', ')}`;
143
151
  });
144
152
  }
145
153
 
@@ -352,37 +360,41 @@ async function updateRtk(ctx: AddonUpdaterCtx, dryRun = false): Promise<string>
352
360
  }
353
361
 
354
362
  async function updateCaveman(ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
355
- let remote: string;
356
- try { remote = await httpsGet(CAVEMAN_REMOTE); }
357
- catch (e) { return report(ctx, `Caveman update failed: ${(e as Error).message}`, 'warning'); }
358
-
359
- const remoteHash = shortHash(remote);
360
- const oldLocal = await readTextIfExists(CAVEMAN_LOCAL);
361
- const oldHash = oldLocal ? shortHash(oldLocal) : null;
362
-
363
- if (dryRun) {
364
- const m = `Caveman dry-run: would write ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}.`;
365
- return report(ctx, m, 'info');
366
- }
363
+ const results: string[] = [];
364
+ for (const { file, remote } of CAVEMAN_TRACKED) {
365
+ const name = path.basename(file);
366
+ let remoteText: string;
367
+ try { remoteText = await httpsGet(remote); }
368
+ catch (e) { results.push(report(ctx, `Caveman ${name} update failed: ${(e as Error).message}`, 'warning')); continue; }
369
+
370
+ const remoteHash = shortHash(remoteText);
371
+ const oldLocal = await readTextIfExists(file);
372
+ const oldHash = oldLocal ? shortHash(oldLocal) : null;
373
+ if (oldHash === remoteHash) { results.push(`${name} up to date: ${remoteHash}`); continue; }
374
+
375
+ if (dryRun) {
376
+ results.push(`Caveman dry-run: would write ${file}\nold=${oldHash || '—'} new=${remoteHash}.`);
377
+ continue;
378
+ }
367
379
 
368
- try {
369
- await fs.mkdir(path.dirname(CAVEMAN_LOCAL), { recursive: true });
370
- const backupPath = `${CAVEMAN_LOCAL}.bak`;
371
- if (oldLocal !== null) await fs.writeFile(backupPath, oldLocal, 'utf8');
372
- await fs.writeFile(CAVEMAN_LOCAL, remote, 'utf8');
373
- const written = await fs.readFile(CAVEMAN_LOCAL, 'utf8');
374
- const writtenHash = shortHash(written);
375
- if (writtenHash !== remoteHash) {
376
- if (oldLocal !== null) await fs.writeFile(CAVEMAN_LOCAL, oldLocal, 'utf8');
377
- throw new Error(`written hash ${writtenHash} did not match remote ${remoteHash}${oldLocal !== null ? '; restored backup' : ''}`);
380
+ try {
381
+ await fs.mkdir(path.dirname(file), { recursive: true });
382
+ const backupPath = `${file}.bak`;
383
+ if (oldLocal !== null) await fs.writeFile(backupPath, oldLocal, 'utf8');
384
+ await fs.writeFile(file, remoteText, 'utf8');
385
+ const writtenHash = shortHash(await fs.readFile(file, 'utf8'));
386
+ if (writtenHash !== remoteHash) {
387
+ if (oldLocal !== null) await fs.writeFile(file, oldLocal, 'utf8');
388
+ throw new Error(`written hash ${writtenHash} did not match remote ${remoteHash}${oldLocal !== null ? '; restored backup' : ''}`);
389
+ }
390
+ results.push(`Caveman ${name} updated → ${file}\nold=${oldHash || '—'} new=${remoteHash}\nbackup=${oldLocal !== null ? backupPath : '—'}\n${RELOAD_MSG}`);
391
+ } catch (e) {
392
+ results.push(`Caveman ${name} update failed: ${(e as Error).message}`);
378
393
  }
379
- const m = `Caveman rule.md updated → ${CAVEMAN_LOCAL}\nold=${oldHash || '—'} new=${remoteHash}\nbackup=${oldLocal !== null ? backupPath : '—'}\n${RELOAD_MSG}`;
380
- notify(ctx, 'Caveman rule.md updated. ' + RELOAD_MSG, 'info');
381
- return m;
382
- } catch (e) {
383
- const m = `Caveman update failed: ${(e as Error).message}`;
384
- return report(ctx, m, 'warning');
385
394
  }
395
+ const summary = results.join('\n');
396
+ notify(ctx, `Caveman update finished. ${RELOAD_MSG}`, 'info');
397
+ return summary;
386
398
  }
387
399
 
388
400
  export default function aiAddonsUpdaterExtension(pi: AddonUpdaterPi): void {
@@ -8,20 +8,19 @@ import { readCavemanDefault } from '../shared/plugin-settings.ts';
8
8
  import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
9
9
 
10
10
  const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
11
- const RULE_PATH = join(CAVERN_DIR, 'rule.md');
12
-
13
- // Fail loud: a wrong rule reads to the model as the real one.
14
- function readFullRule(): string {
15
- try { return readFileSync(RULE_PATH, 'utf8'); } catch {
16
- return 'Caveman full requested, but rule.md is missing. Run: tersio doctor --fix extensions';
17
- }
18
- }
11
+ // Upstream ships one skill per register: default, ultracave, megacave.
12
+ const RULE_FILES = { lite: 'rule.md', full: 'rule.md', ultra: 'rule-ultra.md', 'megacave-lite': 'rule-megacave.md', 'megacave-full': 'rule-megacave.md', 'megacave-ultra': 'rule-megacave.md' };
19
13
 
20
14
  const DEFAULT_MODE = 'off';
21
15
 
22
- // Upstream ships one SKILL.md; the level is a directive inside it, so swap only the header.
23
16
  function buildInstruction(mode: string): string {
24
- return `Caveman ${mode} active for this session.\n${readFullRule()}`;
17
+ const name = RULE_FILES[mode as keyof typeof RULE_FILES] ?? RULE_FILES.full;
18
+ // Fail loud: a wrong rule reads to the model as the real one.
19
+ let body: string;
20
+ try { body = readFileSync(join(CAVERN_DIR, name), 'utf8'); } catch {
21
+ body = `Caveman ${mode} requested, but ${name} is missing. Run: tersio doctor --fix extensions`;
22
+ }
23
+ return `Caveman ${mode} active for this session.\n${body}`;
25
24
  }
26
25
 
27
26
  function resolveMode(entries: SessionEntry[] | null | undefined, fallback: string = DEFAULT_MODE): string {
@@ -68,7 +67,7 @@ export default function cavemanSessionExtension(pi: ExtensionApi): void {
68
67
  return;
69
68
  }
70
69
  if (!setMode(arg, ctx)) {
71
- ctx?.ui?.notify?.('Usage: /caveman [lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off|status]', 'warning');
70
+ ctx?.ui?.notify?.('Usage: /caveman [lite|full|ultra|megacave-lite|megacave-full|megacave-ultra|off|status]', 'warning');
72
71
  }
73
72
  },
74
73
  });
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: megacave
3
+ description: >
4
+ Caveman in Classical Chinese: 文言文 register, far fewer characters, technical
5
+ terms verbatim. Invoke only with /megacave or /caveman wenyan. Stays on until
6
+ "stop caveman" or "normal mode".
7
+ disable-model-invocation: true
8
+ ---
9
+
10
+ # megacave
11
+
12
+ 以文言答。技術之實皆存,唯贅言去之。
13
+
14
+ Megacave is caveman in Classical Chinese. 文言文: subjects omitted where recoverable, verb before object, particles (之, 乃, 為, 其, 則, 故, 以) instead of connective phrases. Fewer characters on screen. Token savings are small and noisy; never claim them.
15
+
16
+ ## Persistence
17
+
18
+ Every response, whole session, until "stop caveman" or "normal mode". Unsure? Still on. `/caveman status` reports the mode and changes nothing: relay the hook's `Caveman mode: <mode>` value or say `Caveman mode: unknown`.
19
+
20
+ ## Floor
21
+
22
+ Never cut or translate: code, commands, paths, API names, error strings, in their original script. Negation (不, 非, 勿, 未, 毋). Numbers and units, Arabic numerals. One term per thing. A clause ambiguous in 文言 becomes 白話.
23
+
24
+ ## Rules
25
+
26
+ ### 1. Classical register
27
+
28
+ 文言, not 白話. Verb before object. Subjects omitted where recoverable.
29
+
30
+ Bad: "你的組件每次渲染都會重新渲染,因為你每次都創建了新的對象引用。請用 useMemo 包起來。"
31
+ Good: "每繪新生對象參照,故重繪;以 `useMemo` 包之則免。"
32
+
33
+ ### 2. Particles carry structure
34
+
35
+ One particle, one relation.
36
+
37
+ Bad: "因為連接池會重複使用已經打開的連接,所以不需要每次請求都新建連接,這樣就省去了握手的開銷。"
38
+ Good: "池蓄已開之連,不逐請而新開,省握手之費。"
39
+
40
+ ### 3. Each fact once
41
+
42
+ Good: "新參照則重繪。`useMemo` 包之。"
43
+
44
+ ### 4. Payload in original script
45
+
46
+ Bad: "以記憶化鉤子包之。"
47
+ Good: "以 `useMemo` 包之。"
48
+
49
+ ### 5. Tool runs
50
+
51
+ One 文言 line in, one out. Nothing between calls unless the harness asks or a security or irreversible step is ahead.
52
+
53
+ ### 6. Never perform
54
+
55
+ No "文言模式啟", no modern answer plus classical copy. 文言 not shorter than 白話 for a sentence? Use 白話.
56
+
57
+ ## When to break the rules
58
+
59
+ 白話 or the user's language, full sentences, then resume: security warning. Irreversible action, confirm first. Any clause with two readings. User confused. Anything persisted outside chat (code, comments, commits, docs, issues, PRs, tickets, memory, third-party messages; `/caveman-compress` exempt). Harness asks for a status line.
60
+
61
+ ## Pre-send check
62
+
63
+ Identifiers verbatim, original script? Negations present? Two readings? 白話. Flavor character? Cut.
@@ -0,0 +1,63 @@
1
+ ---
2
+ name: ultracave
3
+ description: >
4
+ Caveman at maximum compression: fragments, one word when one word is enough,
5
+ each fact once. Invoke only with /ultracave or /caveman ultra. Stays on until
6
+ "stop caveman" or "normal mode".
7
+ disable-model-invocation: true
8
+ ---
9
+
10
+ # ultracave
11
+
12
+ Respond terse like smart caveman. All technical substance stay. Only fluff die. Then cut again.
13
+
14
+ Ultracave is caveman with the grammar stripped. Payload only.
15
+
16
+ ## Persistence
17
+
18
+ Every response, whole session, until "stop caveman" or "normal mode". Unsure? Still on. `/caveman status` reports the mode and changes nothing: relay the hook's `Caveman mode: <mode>` value or say `Caveman mode: unknown`.
19
+
20
+ ## Floor
21
+
22
+ Never cut: code, commands, paths, API names, error strings (verbatim). not/never/no/only/except. Numbers and units. The user's language. One term per thing. No invented abbreviations, no arrows.
23
+
24
+ ## Rules
25
+
26
+ ### 1. Fragments
27
+
28
+ Drop articles, copulas, connectives when order stays clear.
29
+
30
+ Bad: "The component re-renders because an inline object prop creates a new reference each render."
31
+ Good: "Inline object prop, new ref, re-render. `useMemo`."
32
+
33
+ ### 2. One word when one word is enough
34
+
35
+ Bad: "Yes, that should work, though you'll want a null check first."
36
+ Good: "Yes. Null check first."
37
+
38
+ ### 3. Each fact once
39
+
40
+ No restating, no summary after a list.
41
+
42
+ Good: "Pool reuses open DB connections. No per-request handshake."
43
+
44
+ ### 4. Cut conjunctions only when order survives
45
+
46
+ Bad: "Migrate table drop column backup first."
47
+ Good: "Back up first. Then migrate: drops column."
48
+
49
+ ### 5. Tool runs
50
+
51
+ One line in, one line out. Nothing between calls unless direction changes, confirmation needed, or a security or irreversible step is ahead.
52
+
53
+ ### 6. Never perform
54
+
55
+ No prefix, no announcement, no mangled verbs for flavor. Fragment not shorter than the sentence? Use the sentence.
56
+
57
+ ## When to break the rules
58
+
59
+ Plain prose, then resume: security warning. Irreversible action, confirm first. Any fragment with two readings. User confused. Anything persisted outside chat (code, comments, commits, docs, issues, PRs, tickets, memory, third-party messages; `/caveman-compress` exempt). Harness asks for a status line.
60
+
61
+ ## Pre-send check
62
+
63
+ Two readings? Full sentence. Negations present? Payload verbatim? Flavor word? Cut.
@@ -2,7 +2,7 @@
2
2
  name: caveman
3
3
  description: >
4
4
  Ultra-compressed communication mode that cuts output tokens while keeping
5
- technical accuracy. Levels: lite, full, ultra and the wenyan variants. Use for
5
+ technical accuracy. Levels: lite, full, ultra and the megacave variants. Use for
6
6
  /caveman, "caveman mode", "talk like caveman", "be brief" or "less tokens".
7
7
  ---
8
8
 
@@ -12,7 +12,7 @@ Respond terse like smart caveman. All technical substance stay. Only fluff die.
12
12
 
13
13
  Default style for this whole session, every response, until user say "stop caveman" or "normal mode". Keep terse on long sessions no filler drift.
14
14
 
15
- Default: **full**. Switch: `/caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off`.
15
+ Default: **full**. Switch: `/caveman lite|full|ultra|megacave-lite|megacave-full|megacave-ultra|off`.
16
16
 
17
17
  ## Rules
18
18
 
@@ -28,6 +28,8 @@ Tool calls: fire direct. No preamble, plan, or progress note before or between c
28
28
 
29
29
  Follow explicit reply-language instructions from the user or project. Otherwise preserve the user's dominant language. Never switch because of example text or multilingual context elsewhere. Compress the style, not the language. Every emitted line in that language openings, pre-tool status lines, all not just final reply. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim unless user explicitly ask for translation.
30
30
 
31
+ Ownership: caveman owns reply compression, so project or host prose rules that set article use, sentence length, or filler never override it. Treat those as applying to persisted artifacts instead: code, comments, commits, docs, and issue text. Any project rule naming caveman or tersio directly wins over both.
32
+
31
33
  'Drop articles' = article languages only. Where small markers carry case/role (particles, postpositions), keep them grammar, not filler; compress politeness/filler instead.
32
34
 
33
35
  Answer directly in this style. Skip "caveman mode on", "me caveman think", "Caveman:" prefix or recap redundant with the reply itself. No normal answer plus caveman duplicate. User ask what mode is → say so plainly.
@@ -43,27 +45,14 @@ Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
43
45
  |-------|------------|
44
46
  | **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
45
47
  | **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations |
46
- | **ultra** | Strip conjunctions when cause-then-effect stay unambiguous. One word when one word enough. State each fact once. NO prose abbreviations (cfg/impl/req/res/fn/auth), NO arrows (X → Y) measured zero token saving under tokenizer, cost decode clarity. Code symbols, function names, API names, error strings: never touch |
47
- | **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register |
48
- | **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction chars, not tokens. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) |
49
- | **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse |
50
48
 
51
49
  Example "Why React component re-render?"
52
50
  - lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
53
51
  - full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
54
- - ultra: "Inline obj prop, new ref, re-render. `useMemo`."
55
- - wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。"
56
- - wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。"
57
- - wenyan-ultra: "新參照則重繪。useMemo 包之。"
58
52
 
59
53
  Example "Explain database connection pooling."
60
54
  - lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
61
55
  - full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
62
- - ultra: "Pool reuse open DB connections. No per-request handshake."
63
- - wenyan-full: "池蓄已開之連,不逐請而新開,省握手之費。"
64
- - wenyan-ultra: "池蓄連,免逐請新開,省握手。"
65
-
66
- Classical chars = wenyan modes only. Never swap a word to a classical char to shrink at non-wenyan levels.
67
56
 
68
57
  ## Auto-Clarity
69
58