@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
@@ -1,21 +1,26 @@
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.
1
+ // /ai-addons manual updater. Node built-ins only. RTK ships SHA256 checksums
2
+ // with no signature, so checksum-only is the best available.
6
3
  import { execFileSync } from 'node:child_process';
7
4
  import { createHash } from 'node:crypto';
8
5
  import fs from 'node:fs/promises';
9
6
  import os from 'node:os';
10
7
  import path from 'node:path';
11
- import { CAVEMAN_REMOTE_RULE as CAVEMAN_REMOTE, RTK_RELEASE_API, fetchJson, findFile, httpsGet, httpsDownload, sha256Hex, parseChecksum, normalizeRtkVersion, readTextIfExists, rtkPlatformSpec, resolveRtkBinary, } from '../lib/utils.js';
8
+ import { fileURLToPath } from 'node:url';
9
+ import { setExtensionLabel } from '../shared/host.js';
10
+ import { CAVEMAN_REMOTE_RULE as CAVEMAN_REMOTE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, isPiProcess, sha256Hex, parseChecksum, normalizeRtkVersion, readTextIfExists, rtkPlatformSpec, resolveRtkBinary, } from '../lib/utils.js';
12
11
  const IS_WINDOWS = process.platform === 'win32';
13
- const HOME = os.homedir();
12
+ const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
14
13
  const PONYTAIL_REMOTE = 'https://raw.githubusercontent.com/DietrichGebert/ponytail/main/package.json';
15
- const PONYTAIL_LOCAL = path.join(HOME, '.omp', 'plugins', 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
16
14
  const RTK_BINARY = resolveRtkBinary();
17
- const CAVEMAN_LOCAL = path.join(HOME, '.omp', 'plugins', 'node_modules', '@krtclcdy', 'tersio', 'extensions', 'caveman-session', 'rule.md');
18
- const RELOAD_MSG = 'Reminder: restart OMP (or reload extensions) for updates to take effect.';
15
+ // Beside this file, not a host path: that is the rule.md /caveman reads.
16
+ const CAVEMAN_LOCAL = path.join(EXTENSION_DIR, '..', 'caveman-session', 'rule.md');
17
+ const HOST_NAME = isPiProcess() ? 'Pi' : 'OMP';
18
+ const RELOAD_MSG = `Reminder: restart ${HOST_NAME} (or reload extensions) for updates to take effect.`;
19
+ // Ponytail is a hoisted dependency, so the installed copy is found by walking
20
+ // up rather than by naming a host directory.
21
+ function ponytailLocal() {
22
+ return findHoistedPackage('@dietrichgebert/ponytail', EXTENSION_DIR, 'package.json');
23
+ }
19
24
  function notify(ctx, msg, level) {
20
25
  ctx?.ui?.notify?.(String(msg), level);
21
26
  }
@@ -29,16 +34,25 @@ function shortHash(text) {
29
34
  function checkFailed(name, e) {
30
35
  return { text: `${name} check failed: ${e.message}`, level: 'warning' };
31
36
  }
32
- // Single error-handling source for the three probes; messages unchanged.
33
37
  function runCheck(name, probe) {
34
38
  return probe().then((text) => ({ text, level: 'info' }), (e) => checkFailed(name, e));
35
39
  }
36
- // Check: no mutation. Probes run concurrently via checkAddons below.
40
+ // A missing file is "not installed"; an unreadable one is a reportable fault.
41
+ function parsePackageVersion(raw) {
42
+ if (!raw)
43
+ return null;
44
+ try {
45
+ return JSON.parse(raw).version ?? null;
46
+ }
47
+ catch {
48
+ throw new Error('ponytail package.json is not readable JSON');
49
+ }
50
+ }
37
51
  function checkPonytail() {
38
52
  return runCheck('Ponytail', async () => {
39
53
  const remoteJson = await fetchJson(PONYTAIL_REMOTE);
40
- const localRaw = await readTextIfExists(PONYTAIL_LOCAL);
41
- const localVer = localRaw ? JSON.parse(localRaw).version ?? null : null;
54
+ const localRaw = await readTextIfExists(ponytailLocal() ?? '');
55
+ const localVer = parsePackageVersion(localRaw);
42
56
  const remoteVer = remoteJson.version;
43
57
  const status = !localVer ? 'not installed'
44
58
  : localVer === remoteVer ? 'up to date'
@@ -120,13 +134,10 @@ async function checkAddons(ctx) {
120
134
  return lines.join('\n');
121
135
  }
122
136
  async function updatePonytail(pi, ctx, dryRun = false) {
123
- // Bundled with tersio: no separate package to refresh. `tersio update`
124
- // pulls the bundled copy with the CLI.
137
+ // Bundled with tersio: `tersio update` pulls the copy with the CLI.
125
138
  let localVer = null;
126
139
  try {
127
- const raw = await readTextIfExists(PONYTAIL_LOCAL);
128
- if (raw)
129
- localVer = JSON.parse(raw).version ?? null;
140
+ localVer = parsePackageVersion(await readTextIfExists(ponytailLocal() ?? ''));
130
141
  }
131
142
  catch {
132
143
  localVer = null;
@@ -150,7 +161,6 @@ async function updateRtk(ctx, dryRun = false) {
150
161
  const assets = Array.isArray(release.assets) ? release.assets : [];
151
162
  if (!RTK_BINARY)
152
163
  return report(ctx, 'RTK: executable not found in PATH', 'warning');
153
- // Cross-platform asset selection (mirrors installer stepRtk)
154
164
  const PLATFORM = process.platform;
155
165
  const ARCH = process.arch;
156
166
  const spec = rtkPlatformSpec(PLATFORM, ARCH);
@@ -179,7 +189,6 @@ async function updateRtk(ctx, dryRun = false) {
179
189
  httpsDownload(asset.browser_download_url, archivePath),
180
190
  httpsDownload(checksAsset.browser_download_url, checksPath),
181
191
  ]);
182
- // Verify SHA256 against checksums.txt
183
192
  const checks = await fs.readFile(checksPath, 'utf8');
184
193
  const expected = parseChecksum(checks, asset.name);
185
194
  if (!expected) {
@@ -193,7 +202,6 @@ async function updateRtk(ctx, dryRun = false) {
193
202
  return report(ctx, m, 'warning');
194
203
  }
195
204
  notify(ctx, 'RTK: checksum verified.', 'info');
196
- // Extract by archive format
197
205
  const extractDir = path.join(tmp, 'extracted');
198
206
  await fs.mkdir(extractDir, { recursive: true });
199
207
  if (asset.name.endsWith('.zip')) {
@@ -225,7 +233,6 @@ async function updateRtk(ctx, dryRun = false) {
225
233
  const backupPath = `${RTK_BINARY}.bak`;
226
234
  const backedUp = await fs.copyFile(RTK_BINARY, backupPath).then(() => true, () => false);
227
235
  await fs.copyFile(rtkExtracted, RTK_BINARY);
228
- // Set executable bit on Unix
229
236
  if (!IS_WINDOWS) {
230
237
  await fs.chmod(RTK_BINARY, 0o755);
231
238
  }
@@ -293,7 +300,7 @@ async function updateCaveman(ctx, dryRun = false) {
293
300
  }
294
301
  }
295
302
  export default function aiAddonsUpdaterExtension(pi) {
296
- pi.setLabel?.('AI add-ons updater');
303
+ setExtensionLabel(pi, 'AI add-ons updater');
297
304
  pi.registerCommand?.('ai-addons', {
298
305
  description: 'Check or update AI add-ons (ponytail/rtk/caveman/all). Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]',
299
306
  handler: async (args, ctx) => {
@@ -303,15 +310,16 @@ export default function aiAddonsUpdaterExtension(pi) {
303
310
  const cleanParts = parts.filter((p) => p !== '--dry-run' && p !== 'dry-run');
304
311
  const sub = cleanParts[0];
305
312
  if (sub === 'check' || sub === 'status') {
306
- const summary = await checkAddons(ctx);
313
+ await checkAddons(ctx);
307
314
  notify(ctx, 'ai-addons check complete.', 'info');
308
- return summary;
315
+ return;
309
316
  }
310
317
  if (sub === 'update' && cleanParts[1]) {
311
- return runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
318
+ await runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
319
+ return;
312
320
  }
313
- const m = 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]';
314
- return report(ctx, m, 'warning');
321
+ report(ctx, 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]', 'warning');
322
+ return;
315
323
  },
316
324
  });
317
325
  }
@@ -1,23 +1,24 @@
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.
1
+ // /ai-addons manual updater. Node built-ins only. RTK ships SHA256 checksums
2
+ // with no signature, so checksum-only is the best available.
6
3
 
7
4
  import { execFileSync } from 'node:child_process';
8
5
  import { createHash } from 'node:crypto';
9
6
  import fs from 'node:fs/promises';
10
7
  import os from 'node:os';
11
8
  import path from 'node:path';
9
+ import { fileURLToPath } from 'node:url';
12
10
 
11
+ import { setExtensionLabel } from '../shared/host.ts';
13
12
  import {
14
13
  CAVEMAN_REMOTE_RULE as CAVEMAN_REMOTE,
15
14
  RTK_RELEASE_API,
16
15
  RtkRelease,
17
16
  fetchJson,
18
17
  findFile,
18
+ findHoistedPackage,
19
19
  httpsGet,
20
20
  httpsDownload,
21
+ isPiProcess,
21
22
  sha256Hex,
22
23
  parseChecksum,
23
24
  normalizeRtkVersion,
@@ -27,23 +28,20 @@ import {
27
28
  } from '../lib/utils.ts';
28
29
 
29
30
  const IS_WINDOWS = process.platform === 'win32';
30
- const HOME = os.homedir();
31
+ 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
- const PONYTAIL_LOCAL = path.join(HOME, '.omp', 'plugins', 'node_modules', '@dietrichgebert', 'ponytail', 'package.json');
34
34
  const RTK_BINARY = resolveRtkBinary();
35
- const CAVEMAN_LOCAL = path.join(
36
- HOME,
37
- '.omp',
38
- 'plugins',
39
- 'node_modules',
40
- '@krtclcdy',
41
- 'tersio',
42
- 'extensions',
43
- 'caveman-session',
44
- 'rule.md',
45
- );
46
- const RELOAD_MSG = 'Reminder: restart OMP (or reload extensions) for updates to take effect.';
35
+ // Beside this file, not a host path: that is the rule.md /caveman reads.
36
+ const CAVEMAN_LOCAL = path.join(EXTENSION_DIR, '..', 'caveman-session', 'rule.md');
37
+ const HOST_NAME = isPiProcess() ? 'Pi' : 'OMP';
38
+ const RELOAD_MSG = `Reminder: restart ${HOST_NAME} (or reload extensions) for updates to take effect.`;
39
+
40
+ // Ponytail is a hoisted dependency, so the installed copy is found by walking
41
+ // up rather than by naming a host directory.
42
+ function ponytailLocal(): string | null {
43
+ return findHoistedPackage('@dietrichgebert/ponytail', EXTENSION_DIR, 'package.json');
44
+ }
47
45
 
48
46
  // --- Types ---
49
47
 
@@ -56,7 +54,7 @@ interface AddonUpdaterCtx {
56
54
 
57
55
  interface AddonUpdaterPi {
58
56
  setLabel?: (label: string) => void;
59
- registerCommand?: (name: string, config: { description: string; handler: (args: string, ctx: AddonUpdaterCtx) => Promise<string> }) => void;
57
+ registerCommand?: (name: string, config: { description: string; handler: (args: string, ctx: AddonUpdaterCtx) => Promise<void> }) => void;
60
58
  exec?: (cmd: string, args: string[], opts?: { cwd?: string }) => Promise<{ stdout: string; stderr: string; code: number }>;
61
59
  cwd?: string;
62
60
  }
@@ -85,17 +83,25 @@ function checkFailed(name: string, e: unknown): AddonStatus {
85
83
  return { text: `${name} check failed: ${(e as Error).message}`, level: 'warning' };
86
84
  }
87
85
 
88
- // Single error-handling source for the three probes; messages unchanged.
89
86
  function runCheck(name: string, probe: () => Promise<string>): Promise<AddonStatus> {
90
87
  return probe().then((text) => ({ text, level: 'info' as const }), (e) => checkFailed(name, e));
91
88
  }
92
89
 
93
- // Check: no mutation. Probes run concurrently via checkAddons below.
90
+ // A missing file is "not installed"; an unreadable one is a reportable fault.
91
+ function parsePackageVersion(raw: string | null): string | null {
92
+ if (!raw) return null;
93
+ try {
94
+ return (JSON.parse(raw) as { version?: string }).version ?? null;
95
+ } catch {
96
+ throw new Error('ponytail package.json is not readable JSON');
97
+ }
98
+ }
99
+
94
100
  function checkPonytail(): Promise<AddonStatus> {
95
101
  return runCheck('Ponytail', async () => {
96
102
  const remoteJson = await fetchJson<{ version?: string }>(PONYTAIL_REMOTE);
97
- const localRaw = await readTextIfExists(PONYTAIL_LOCAL);
98
- const localVer = localRaw ? (JSON.parse(localRaw) as { version?: string }).version ?? null : null;
103
+ const localRaw = await readTextIfExists(ponytailLocal() ?? '');
104
+ const localVer = parsePackageVersion(localRaw);
99
105
  const remoteVer = remoteJson.version;
100
106
  const status = !localVer ? 'not installed'
101
107
  : localVer === remoteVer ? 'up to date'
@@ -175,12 +181,10 @@ async function checkAddons(ctx: AddonUpdaterCtx): Promise<string> {
175
181
  }
176
182
 
177
183
  async function updatePonytail(pi: AddonUpdaterPi, ctx: AddonUpdaterCtx, dryRun = false): Promise<string> {
178
- // Bundled with tersio: no separate package to refresh. `tersio update`
179
- // pulls the bundled copy with the CLI.
184
+ // Bundled with tersio: `tersio update` pulls the copy with the CLI.
180
185
  let localVer: string | null = null;
181
186
  try {
182
- const raw = await readTextIfExists(PONYTAIL_LOCAL);
183
- if (raw) localVer = (JSON.parse(raw) as { version?: string }).version ?? null;
187
+ localVer = parsePackageVersion(await readTextIfExists(ponytailLocal() ?? ''));
184
188
  } catch { localVer = null; }
185
189
  void pi;
186
190
  const m = dryRun
@@ -201,7 +205,6 @@ async function updateRtk(ctx: AddonUpdaterCtx, dryRun = false): Promise<string>
201
205
  const assets = Array.isArray(release.assets) ? release.assets : [];
202
206
  if (!RTK_BINARY) return report(ctx, 'RTK: executable not found in PATH', 'warning');
203
207
 
204
- // Cross-platform asset selection (mirrors installer stepRtk)
205
208
  const PLATFORM = process.platform;
206
209
  const ARCH = process.arch;
207
210
  const spec = rtkPlatformSpec(PLATFORM, ARCH);
@@ -234,7 +237,6 @@ async function updateRtk(ctx: AddonUpdaterCtx, dryRun = false): Promise<string>
234
237
  httpsDownload(asset.browser_download_url, archivePath),
235
238
  httpsDownload(checksAsset.browser_download_url, checksPath),
236
239
  ]);
237
- // Verify SHA256 against checksums.txt
238
240
  const checks = await fs.readFile(checksPath, 'utf8');
239
241
  const expected = parseChecksum(checks, asset.name);
240
242
  if (!expected) {
@@ -249,7 +251,6 @@ async function updateRtk(ctx: AddonUpdaterCtx, dryRun = false): Promise<string>
249
251
  }
250
252
  notify(ctx, 'RTK: checksum verified.', 'info');
251
253
 
252
- // Extract by archive format
253
254
  const extractDir = path.join(tmp, 'extracted');
254
255
  await fs.mkdir(extractDir, { recursive: true });
255
256
 
@@ -281,7 +282,6 @@ async function updateRtk(ctx: AddonUpdaterCtx, dryRun = false): Promise<string>
281
282
 
282
283
  await fs.copyFile(rtkExtracted, RTK_BINARY);
283
284
 
284
- // Set executable bit on Unix
285
285
  if (!IS_WINDOWS) {
286
286
  await fs.chmod(RTK_BINARY, 0o755);
287
287
  }
@@ -343,7 +343,7 @@ async function updateCaveman(ctx: AddonUpdaterCtx, dryRun = false): Promise<stri
343
343
  }
344
344
 
345
345
  export default function aiAddonsUpdaterExtension(pi: AddonUpdaterPi): void {
346
- pi.setLabel?.('AI add-ons updater');
346
+ setExtensionLabel(pi, 'AI add-ons updater');
347
347
 
348
348
  pi.registerCommand?.('ai-addons', {
349
349
  description: 'Check or update AI add-ons (ponytail/rtk/caveman/all). Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]',
@@ -355,15 +355,16 @@ export default function aiAddonsUpdaterExtension(pi: AddonUpdaterPi): void {
355
355
  const sub = cleanParts[0];
356
356
 
357
357
  if (sub === 'check' || sub === 'status') {
358
- const summary = await checkAddons(ctx);
358
+ await checkAddons(ctx);
359
359
  notify(ctx, 'ai-addons check complete.', 'info');
360
- return summary;
360
+ return;
361
361
  }
362
362
  if (sub === 'update' && cleanParts[1]) {
363
- return runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
363
+ await runAddonUpdate(pi, ctx, cleanParts.slice(1).join(' '), dryRun);
364
+ return;
364
365
  }
365
- const m = 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]';
366
- return report(ctx, m, 'warning');
366
+ report(ctx, 'Usage: /ai-addons <check|status|update ponytail|rtk|caveman|all> [--dry-run]', 'warning');
367
+ return;
367
368
  },
368
369
  });
369
370
  }
@@ -1,31 +1,18 @@
1
1
  import { readFileSync } from 'node:fs';
2
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.js';
3
+ import { activeModesSummary, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode, statusUi, systemPromptIncludes } from '../shared/session-state.js';
4
4
  import { dirname, join } from 'node:path';
5
+ import { injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host.js';
5
6
  import { readCavemanDefault } from '../shared/plugin-settings.js';
6
7
  const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
7
8
  const RULE_PATH = join(CAVERN_DIR, 'rule.md');
8
- const FALLBACK_FULL_RULE = `Caveman full active for this session.
9
- Respond terse like smart caveman. Keep all technical substance. Drop filler, pleasantries, and hedging.
10
-
11
- Rules:
12
- - Drop articles where meaning stays clear. Keep fragments and short sentences.
13
- - Keep technical terms, code, commands, paths, API names, numbers, units, and errors exact.
14
- - Never invent abbreviations or causal arrows. Do not add words to sound caveman.
15
- - Use ASD-STE100 Simplified Technical English. Keep one idea per sentence, target 20 words, active voice, and consistent terms.
16
- - Make no tool-call narration. Use no decorative tables or emoji.
17
- - Preserve the user's reply language. Compress style, not language.
18
- - Drop caveman for security warnings, irreversible actions, ambiguous multi-step sequences, or requests to clarify.
19
- - Write code, comments, commits, docs, issues, pull requests, and third-party messages in normal prose.
20
-
21
- Default: **full**. Switch: \`/caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off\`. Stop: "stop caveman" or "normal mode".`;
22
- // ponytail: synchronous read each full-mode injection; ceiling = small file, cold session start. Upgrade path: cache file contents + mtime, invalidate on change.
9
+ // Fail loud: a wrong rule reads to the model as the real one.
23
10
  function readFullRule() {
24
11
  try {
25
12
  return readFileSync(RULE_PATH, 'utf8');
26
13
  }
27
14
  catch {
28
- return FALLBACK_FULL_RULE;
15
+ return 'Caveman full requested, but rule.md is missing. Run: tersio doctor --fix extensions';
29
16
  }
30
17
  }
31
18
  const DEFAULT_MODE = 'off';
@@ -52,15 +39,15 @@ export default function cavemanSessionExtension(pi) {
52
39
  let lastCtx = undefined;
53
40
  function syncStatus(ctx) {
54
41
  lastCtx = paintableCtx(lastCtx, ctx);
55
- const c = lastCtx;
56
- if (!c?.ui?.setStatus)
42
+ const ui = statusUi(lastCtx);
43
+ if (!ui)
57
44
  return;
58
45
  // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
59
46
  if (isComboPresetActive() || currentMode === 'off') {
60
- c.ui.setStatus('caveman', undefined);
47
+ ui.setStatus('caveman', undefined);
61
48
  return;
62
49
  }
63
- paintStatusBar(c.ui, 'caveman', '🪨', `caveman: ${currentMode.toUpperCase()}`, isActive);
50
+ paintStatusBar(ui, 'caveman', '🪨', `caveman: ${currentMode.toUpperCase()}`, isActive);
64
51
  }
65
52
  function setMode(mode, ctx) {
66
53
  const normalized = normalizeMode('caveman', mode);
@@ -77,10 +64,8 @@ export default function cavemanSessionExtension(pi) {
77
64
  ctx?.ui?.notify?.(msg, 'info');
78
65
  return true;
79
66
  }
80
- pi.setLabel?.('Caveman session toggle');
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.
67
+ setExtensionLabel(pi, 'Caveman session toggle');
68
+ // Adopt a /tersio or /combo switch at once, so the next turn needs no reload.
84
69
  function syncFromShared(state) {
85
70
  const mode = normalizeMode('caveman', state.caveman);
86
71
  if (mode) {
@@ -88,7 +73,7 @@ export default function cavemanSessionExtension(pi) {
88
73
  syncStatus();
89
74
  }
90
75
  }
91
- setSharedComboListener(syncFromShared);
76
+ setSharedComboListener('caveman', syncFromShared);
92
77
  pi.registerCommand?.('caveman', {
93
78
  description: 'Toggle terse caveman replies for this session',
94
79
  handler: async (args, ctx) => {
@@ -106,7 +91,7 @@ export default function cavemanSessionExtension(pi) {
106
91
  }
107
92
  },
108
93
  });
109
- pi.on('input', async (event) => {
94
+ onHostEvent(pi, 'input', async (event) => {
110
95
  if (event?.source === 'extension')
111
96
  return;
112
97
  if (currentMode !== 'off' && isOffCommand(event?.text))
@@ -114,10 +99,7 @@ export default function cavemanSessionExtension(pi) {
114
99
  });
115
100
  function restoreMode(ctx) {
116
101
  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.
102
+ // Derive the bridge here so bar suppression does not depend on load order.
121
103
  reconcileSharedComboEntries(entries);
122
104
  // Persisted session state wins; a fresh session falls back to the
123
105
  // installer/user-configured default (off unless configured).
@@ -129,7 +111,7 @@ export default function cavemanSessionExtension(pi) {
129
111
  restoreMode(ctx);
130
112
  ctx?.ui?.notify?.(`Caveman loaded: ${currentMode}`, 'info');
131
113
  });
132
- pi.on('session_branch', async (_event, ctx) => {
114
+ onHostEvent(pi, 'session_branch', async (_event, ctx) => {
133
115
  restoreMode(ctx);
134
116
  });
135
117
  pi.on('session_tree', async (_event, ctx) => {
@@ -151,7 +133,10 @@ export default function cavemanSessionExtension(pi) {
151
133
  if (!def)
152
134
  return;
153
135
  const instruction = typeof def === 'function' ? def() : def;
154
- const base = asPromptArray(event.systemPrompt);
155
- return { systemPrompt: [...base, instruction] };
136
+ // Skip when already present, so a host that re-presents the mutated prompt
137
+ // cannot stack it; matching the whole instruction keeps upstream rule.md intact.
138
+ if (systemPromptIncludes(event.systemPrompt, instruction))
139
+ return;
140
+ return injectPromptText(pi, event, instruction);
156
141
  });
157
142
  }
@@ -1,31 +1,19 @@
1
1
  import { readFileSync } from 'node:fs';
2
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';
3
+ import { activeModesSummary, getSharedComboState, isComboPresetActive, isOmpSubagentPrompt, lastCustomValue, normalizeInputCommand, normalizeMode, paintStatusBar, paintableCtx, reconcileSharedComboEntries, sessionEntries, setSharedComboListener, setSharedComboMode, statusUi, systemPromptIncludes } from '../shared/session-state.ts';
4
4
  import { dirname, join } from 'node:path';
5
+ import { injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host.ts';
5
6
  import { readCavemanDefault } from '../shared/plugin-settings.ts';
6
7
  import type { ExtensionApi, ExtensionCtx, InputEvent, SessionEntry, SystemPromptEvent } from '../shared/types.ts';
7
8
 
8
9
  const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
9
10
  const RULE_PATH = join(CAVERN_DIR, 'rule.md');
10
11
 
11
- const FALLBACK_FULL_RULE = `Caveman full active for this session.
12
- Respond terse like smart caveman. Keep all technical substance. Drop filler, pleasantries, and hedging.
13
-
14
- Rules:
15
- - Drop articles where meaning stays clear. Keep fragments and short sentences.
16
- - Keep technical terms, code, commands, paths, API names, numbers, units, and errors exact.
17
- - Never invent abbreviations or causal arrows. Do not add words to sound caveman.
18
- - Use ASD-STE100 Simplified Technical English. Keep one idea per sentence, target 20 words, active voice, and consistent terms.
19
- - Make no tool-call narration. Use no decorative tables or emoji.
20
- - Preserve the user's reply language. Compress style, not language.
21
- - Drop caveman for security warnings, irreversible actions, ambiguous multi-step sequences, or requests to clarify.
22
- - Write code, comments, commits, docs, issues, pull requests, and third-party messages in normal prose.
23
-
24
- Default: **full**. Switch: \`/caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra|off\`. Stop: "stop caveman" or "normal mode".`;
25
-
26
- // ponytail: synchronous read each full-mode injection; ceiling = small file, cold session start. Upgrade path: cache file contents + mtime, invalidate on change.
12
+ // Fail loud: a wrong rule reads to the model as the real one.
27
13
  function readFullRule(): string {
28
- try { return readFileSync(RULE_PATH, 'utf8'); } catch { return FALLBACK_FULL_RULE; }
14
+ try { return readFileSync(RULE_PATH, 'utf8'); } catch {
15
+ return 'Caveman full requested, but rule.md is missing. Run: tersio doctor --fix extensions';
16
+ }
29
17
  }
30
18
 
31
19
  const DEFAULT_MODE = 'off';
@@ -56,14 +44,14 @@ export default function cavemanSessionExtension(pi: ExtensionApi): void {
56
44
 
57
45
  function syncStatus(ctx?: ExtensionCtx): void {
58
46
  lastCtx = paintableCtx(lastCtx, ctx);
59
- const c = lastCtx;
60
- if (!c?.ui?.setStatus) return;
47
+ const ui = statusUi(lastCtx);
48
+ if (!ui) return;
61
49
  // Combo owns the bar when any preset is active; keep ours empty to avoid duplication.
62
50
  if (isComboPresetActive() || currentMode === 'off') {
63
- c.ui.setStatus('caveman', undefined);
51
+ ui.setStatus('caveman', undefined);
64
52
  return;
65
53
  }
66
- paintStatusBar(c.ui, 'caveman', '🪨', `caveman: ${currentMode.toUpperCase()}`, isActive);
54
+ paintStatusBar(ui, 'caveman', '🪨', `caveman: ${currentMode.toUpperCase()}`, isActive);
67
55
  }
68
56
 
69
57
  function setMode(mode: string, ctx?: ExtensionCtx): boolean {
@@ -81,11 +69,9 @@ export default function cavemanSessionExtension(pi: ExtensionApi): void {
81
69
  return true;
82
70
  }
83
71
 
84
- pi.setLabel?.('Caveman session toggle');
72
+ setExtensionLabel(pi, 'Caveman session toggle');
85
73
 
86
- // Live mirror: a /tersio or /combo switch publishes shared state — adopt
87
- // it at once so the next turn injects the new mode with no session reload.
88
- // Stable identity, so the bridge set dedupes across re-inits.
74
+ // Adopt a /tersio or /combo switch at once, so the next turn needs no reload.
89
75
  function syncFromShared(state: { caveman: string }): void {
90
76
  const mode = normalizeMode('caveman', state.caveman);
91
77
  if (mode) {
@@ -93,7 +79,7 @@ export default function cavemanSessionExtension(pi: ExtensionApi): void {
93
79
  syncStatus();
94
80
  }
95
81
  }
96
- setSharedComboListener(syncFromShared);
82
+ setSharedComboListener('caveman', syncFromShared);
97
83
  pi.registerCommand?.('caveman', {
98
84
  description: 'Toggle terse caveman replies for this session',
99
85
  handler: async (args, ctx) => {
@@ -112,17 +98,14 @@ export default function cavemanSessionExtension(pi: ExtensionApi): void {
112
98
  },
113
99
  });
114
100
 
115
- pi.on<InputEvent>('input', async (event) => {
101
+ onHostEvent<InputEvent>(pi, 'input', async (event) => {
116
102
  if (event?.source === 'extension') return;
117
103
  if (currentMode !== 'off' && isOffCommand(event?.text)) setMode('off');
118
104
  });
119
105
 
120
106
  function restoreMode(ctx?: ExtensionCtx): void {
121
107
  const entries = sessionEntries(ctx);
122
- // Publish the persisted combo state (incl. combo-level) before painting:
123
- // bar suppression reads the in-process bridge, which is empty in a fresh
124
- // host until the combo extension reconciles — and its reconcile is
125
- // UI-gated. Deriving it here makes suppression independent of load order.
108
+ // Derive the bridge here so bar suppression does not depend on load order.
126
109
  reconcileSharedComboEntries(entries);
127
110
  // Persisted session state wins; a fresh session falls back to the
128
111
  // installer/user-configured default (off unless configured).
@@ -136,7 +119,7 @@ export default function cavemanSessionExtension(pi: ExtensionApi): void {
136
119
  ctx?.ui?.notify?.(`Caveman loaded: ${currentMode}`, 'info');
137
120
  });
138
121
 
139
- pi.on('session_branch', async (_event, ctx) => {
122
+ onHostEvent(pi, 'session_branch', async (_event, ctx) => {
140
123
  restoreMode(ctx);
141
124
  });
142
125
 
@@ -160,7 +143,9 @@ export default function cavemanSessionExtension(pi: ExtensionApi): void {
160
143
  const def = INSTRUCTIONS[mode];
161
144
  if (!def) return;
162
145
  const instruction = typeof def === 'function' ? def() : def;
163
- const base = asPromptArray(event.systemPrompt);
164
- return { systemPrompt: [...base, instruction] };
146
+ // Skip when already present, so a host that re-presents the mutated prompt
147
+ // cannot stack it; matching the whole instruction keeps upstream rule.md intact.
148
+ if (systemPromptIncludes(event.systemPrompt, instruction)) return;
149
+ return injectPromptText(pi, event, instruction);
165
150
  });
166
151
  }