@krtclcdy/tersio 2.25.2 → 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.
- package/README.md +1 -1
- package/dashboard/dist/index.html +1 -1
- package/{cli → dist/cli}/common.js +4 -3
- package/{cli → dist/cli}/dashboard.js +30 -13
- package/{cli → dist/cli}/doctor-fix.js +1 -3
- package/{cli → dist/cli}/doctor.js +12 -5
- package/dist/cli/headroom-install.js +266 -0
- package/dist/cli/headroom.js +492 -0
- package/{cli → dist/cli}/install.js +19 -7
- package/{cli → dist/cli}/manifest.js +3 -2
- package/{cli → dist/cli}/settings.js +5 -23
- package/{cli → dist/cli}/uninstall.js +1 -1
- package/{cli → dist/cli}/update.js +4 -5
- package/{extensions → dist/extensions}/ai-addons-updater/index.js +2 -2
- package/{extensions → dist/extensions}/caveman-session/index.js +2 -2
- package/dist/extensions/caveman-session/rule-ultra.md +63 -0
- package/dist/extensions/caveman-session/rule.md +79 -0
- package/{extensions → dist/extensions}/combo-toggle/index.js +37 -27
- package/dist/extensions/headroom-session/index.js +93 -0
- package/{extensions → dist/extensions}/lib/utils.js +26 -1
- package/dist/extensions/shared/headroom.js +131 -0
- package/{extensions → dist/extensions}/shared/plugin-settings.js +1 -1
- package/{extensions → dist/extensions}/shared/session-state.js +14 -2
- package/{extensions → dist/extensions}/shared/status.js +7 -1
- package/{tersio.js → dist/tersio.js} +1 -1
- package/extensions/ai-addons-updater/index.ts +2 -2
- package/extensions/caveman-session/index.ts +2 -2
- package/extensions/caveman-session/rule-megacave.md +63 -0
- package/extensions/caveman-session/rule.md +4 -2
- package/extensions/combo-toggle/index.ts +35 -27
- package/extensions/lib/utils.ts +22 -1
- package/extensions/shared/plugin-settings.ts +1 -1
- package/extensions/shared/session-state.ts +15 -2
- package/extensions/shared/status.ts +8 -1
- package/package.json +15 -15
- package/extensions/opencode/index.js +0 -104
- /package/{cli → dist/cli}/banner.js +0 -0
- /package/{cli → dist/cli}/currency.js +0 -0
- /package/{cli → dist/cli}/hosts.js +0 -0
- /package/{cli → dist/cli}/interactive.js +0 -0
- /package/{cli → dist/cli}/profile.js +0 -0
- /package/{cli → dist/cli}/reset.js +0 -0
- /package/{cli → dist/cli}/rtk-wiring.js +0 -0
- /package/{cli → dist/cli}/usage.js +0 -0
- /package/{extensions/caveman-session/rule-wenyan.md → dist/extensions/caveman-session/rule-megacave.md} +0 -0
- /package/{extensions → dist/extensions}/opencode/server.js +0 -0
- /package/{extensions → dist/extensions}/rtk-session/index.js +0 -0
- /package/{extensions → dist/extensions}/shared/carbon.js +0 -0
- /package/{extensions → dist/extensions}/shared/host.js +0 -0
- /package/{extensions → dist/extensions}/shared/pricing.js +0 -0
- /package/{extensions → dist/extensions}/shared/rtk-gain.js +0 -0
- /package/{extensions → dist/extensions}/shared/types.js +0 -0
- /package/{extensions → dist/extensions}/shared/usage-ledger.js +0 -0
- /package/{extensions → dist/extensions}/shared/usage-store.js +0 -0
- /package/{extensions → dist/extensions}/tersio-commands/index.js +0 -0
|
@@ -6,7 +6,7 @@ import { IS_WINDOWS, OMP_PLUGINS_DIR, PACKAGE_BIN, PACKAGE_NAME, PACKAGE_VERSION
|
|
|
6
6
|
import { execNetwork, sayTagged } from './interactive.js';
|
|
7
7
|
import { readTextIfExists, resolveRtkBinary, tersioDataPath } from '../extensions/lib/utils.js';
|
|
8
8
|
import { refreshPrices } from '../extensions/shared/pricing.js';
|
|
9
|
-
import { CAVEMAN_REMOTE_ULTRA,
|
|
9
|
+
import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_MEGACAVE, RTK_RELEASE_API, httpsGet, normalizeRtkVersion, sha256Hex, } from '../extensions/lib/utils.js';
|
|
10
10
|
import { parseHostArg } from './hosts.js';
|
|
11
11
|
const UPDATE_CHECK_TTL_MS = 6 * 60 * 60 * 1000;
|
|
12
12
|
function newerThan(a, b) {
|
|
@@ -84,9 +84,9 @@ async function probeUpdatePlan(cliLatest) {
|
|
|
84
84
|
const [rtkLocal, rtkRelease, ruleLocal, ruleRemote, ponytailLocalText] = await Promise.all([
|
|
85
85
|
settle(rtkBin ? execP(rtkBin, ['--version'], { timeout: 5000 }).then((r) => normalizeRtkVersion(r.stdout.trim() || r.stderr.trim()) || null) : Promise.resolve(null), 6000),
|
|
86
86
|
settle(httpsGet(RTK_RELEASE_API, { signal: controller.signal }).then((text) => normalizeRtkVersion(JSON.parse(text).tag_name) || null), 1500),
|
|
87
|
-
Promise.all([readTextIfExists(tersioRule('rule-ultra.md')), readTextIfExists(tersioRule('rule-
|
|
87
|
+
Promise.all([readTextIfExists(tersioRule('rule-ultra.md')), readTextIfExists(tersioRule('rule-megacave.md'))])
|
|
88
88
|
.then((texts) => (texts.every(Boolean) ? texts.map((t) => sha256Hex(t).slice(0, 8)).join('+') : null)),
|
|
89
|
-
settle(Promise.all([httpsGet(CAVEMAN_REMOTE_ULTRA, { signal: controller.signal }), httpsGet(
|
|
89
|
+
settle(Promise.all([httpsGet(CAVEMAN_REMOTE_ULTRA, { signal: controller.signal }), httpsGet(CAVEMAN_REMOTE_MEGACAVE, { signal: controller.signal })])
|
|
90
90
|
.then((texts) => texts.map((t) => sha256Hex(t).slice(0, 8)).join('+')), 1500),
|
|
91
91
|
readTextIfExists(ponytailPkg),
|
|
92
92
|
]);
|
|
@@ -214,8 +214,7 @@ async function runLatestUpdate() {
|
|
|
214
214
|
// Inherited stdio, no outer spinner; the delegated installer runs quiet and this parent owns both the plan line and the closing summary.
|
|
215
215
|
try {
|
|
216
216
|
await execInherit(npmCommand, npmCommandArgs);
|
|
217
|
-
|
|
218
|
-
console.log(`Done — tersio ${plan.cli ?? PACKAGE_VERSION}. Restart ${hostName}.`);
|
|
217
|
+
console.log(`Done — tersio ${plan.cli ?? PACKAGE_VERSION} and add-ons updated.`);
|
|
219
218
|
if (!dryRun) {
|
|
220
219
|
try {
|
|
221
220
|
await refreshPrices();
|
|
@@ -6,7 +6,7 @@ import os from 'node:os';
|
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { setExtensionLabel } from '../shared/host.js';
|
|
9
|
-
import { CAVEMAN_REMOTE_ULTRA,
|
|
9
|
+
import { CAVEMAN_REMOTE_ULTRA, CAVEMAN_REMOTE_MEGACAVE, RTK_RELEASE_API, fetchJson, findFile, findHoistedPackage, httpsGet, httpsDownload, isPiProcess, sha256Hex, parseChecksum, normalizeRtkVersion, readTextIfExists, rtkPlatformSpec, resolveRtkBinary, } from '../lib/utils.js';
|
|
10
10
|
const IS_WINDOWS = process.platform === 'win32';
|
|
11
11
|
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
12
12
|
const PONYTAIL_REMOTE = 'https://raw.githubusercontent.com/DietrichGebert/ponytail/main/package.json';
|
|
@@ -16,7 +16,7 @@ const cavemanRule = (name) => path.join(EXTENSION_DIR, '..', 'caveman-session',
|
|
|
16
16
|
const CAVEMAN_LOCAL = cavemanRule('rule.md');
|
|
17
17
|
const CAVEMAN_TRACKED = [
|
|
18
18
|
{ file: cavemanRule('rule-ultra.md'), remote: CAVEMAN_REMOTE_ULTRA },
|
|
19
|
-
{ file: cavemanRule('rule-
|
|
19
|
+
{ file: cavemanRule('rule-megacave.md'), remote: CAVEMAN_REMOTE_MEGACAVE },
|
|
20
20
|
];
|
|
21
21
|
const HOST_NAME = isPiProcess() ? 'Pi' : 'OMP';
|
|
22
22
|
const RELOAD_MSG = `Reminder: restart ${HOST_NAME} (or reload extensions) for updates to take effect.`;
|
|
@@ -7,7 +7,7 @@ import { announceStatus } from '../shared/status.js';
|
|
|
7
7
|
import { readCavemanDefault } from '../shared/plugin-settings.js';
|
|
8
8
|
const CAVERN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
9
9
|
// Upstream ships one skill per register: default, ultracave, megacave.
|
|
10
|
-
const RULE_FILES = { lite: 'rule.md', full: 'rule.md', ultra: 'rule-ultra.md', '
|
|
10
|
+
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' };
|
|
11
11
|
const DEFAULT_MODE = 'off';
|
|
12
12
|
function buildInstruction(mode) {
|
|
13
13
|
const name = RULE_FILES[mode] ?? RULE_FILES.full;
|
|
@@ -62,7 +62,7 @@ export default function cavemanSessionExtension(pi) {
|
|
|
62
62
|
return;
|
|
63
63
|
}
|
|
64
64
|
if (!setMode(arg, ctx)) {
|
|
65
|
-
ctx?.ui?.notify?.('Usage: /caveman [lite|full|ultra|
|
|
65
|
+
ctx?.ui?.notify?.('Usage: /caveman [lite|full|ultra|megacave-lite|megacave-full|megacave-ultra|off|status]', 'warning');
|
|
66
66
|
}
|
|
67
67
|
},
|
|
68
68
|
});
|
|
@@ -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.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: caveman
|
|
3
|
+
description: >
|
|
4
|
+
Ultra-compressed communication mode that cuts output tokens while keeping
|
|
5
|
+
technical accuracy. Levels: lite, full, ultra and the megacave variants. Use for
|
|
6
|
+
/caveman, "caveman mode", "talk like caveman", "be brief" or "less tokens".
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
|
10
|
+
|
|
11
|
+
## Persistence
|
|
12
|
+
|
|
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
|
+
|
|
15
|
+
Default: **full**. Switch: `/caveman lite|full|ultra|megacave-lite|megacave-full|megacave-ultra|off`.
|
|
16
|
+
|
|
17
|
+
## Rules
|
|
18
|
+
|
|
19
|
+
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations (cfg/impl/req/res/fn) tokenizer split them same as full word: zero token saved, reader still decode. Full word cheaper AND clearer. No causal arrows (→) either own token, save nothing. Technical terms exact. Code blocks unchanged. Errors quoted exact.
|
|
20
|
+
|
|
21
|
+
Never drop not/never/no/only/except flip meaning worse than any token saved. Numbers, units exact.
|
|
22
|
+
|
|
23
|
+
Never ADD word to sound caveman. Compression only style never grow output. No inserted pronoun or copula to fake broken grammar: "when it not" cost one token more than "when not" and say same thing. Keep correct verb form when correct form cost same "sees" one token, "see" one token, so mangle buy nothing and read worse. Same rule as abbreviations and arrows: if caveman phrasing not shorter than plain phrasing, use plain.
|
|
24
|
+
|
|
25
|
+
Clarity register: mix ASD-STE100 Simplified Technical English into caveman, always. One idea per sentence. Sentence short, target 20 words max. Active voice. Present tense where true. One word one meaning: same term for same thing every time, no synonym rotation. Instruction = imperative: "Run X", not "X should be run". Noun cluster 3 words max. Pronoun only with one clear referent, else repeat noun. Caveman cut filler; STE keep what make meaning unambiguous. Conflict between them → clarity win.
|
|
26
|
+
|
|
27
|
+
Tool calls: fire direct. No preamble, plan, or progress note before or between calls. After result: next call direct or final answer never announce next call. Text before call only to clarify, warn security/irreversible, or resolve ambiguity.
|
|
28
|
+
|
|
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
|
+
|
|
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
|
+
|
|
33
|
+
'Drop articles' = article languages only. Where small markers carry case/role (particles, postpositions), keep them grammar, not filler; compress politeness/filler instead.
|
|
34
|
+
|
|
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.
|
|
36
|
+
|
|
37
|
+
Pattern: `[thing] [action] [reason]. [next step].`
|
|
38
|
+
|
|
39
|
+
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
|
|
40
|
+
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
|
|
41
|
+
|
|
42
|
+
## Intensity
|
|
43
|
+
|
|
44
|
+
| Level | What change |
|
|
45
|
+
|-------|------------|
|
|
46
|
+
| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight |
|
|
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 |
|
|
48
|
+
|
|
49
|
+
Example "Why React component re-render?"
|
|
50
|
+
- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`."
|
|
51
|
+
- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`."
|
|
52
|
+
|
|
53
|
+
Example "Explain database connection pooling."
|
|
54
|
+
- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead."
|
|
55
|
+
- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead."
|
|
56
|
+
|
|
57
|
+
## Auto-Clarity
|
|
58
|
+
|
|
59
|
+
Drop caveman when:
|
|
60
|
+
- Security warnings
|
|
61
|
+
- Irreversible action confirmations
|
|
62
|
+
- Multi-step sequences where fragment order or omitted conjunctions risk misread
|
|
63
|
+
- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` order unclear without articles/conjunctions)
|
|
64
|
+
- User asks to clarify or repeats question
|
|
65
|
+
|
|
66
|
+
Resume caveman after clear part done.
|
|
67
|
+
|
|
68
|
+
Example shows FORMAT only write warning in session language, not example's.
|
|
69
|
+
|
|
70
|
+
Example destructive op:
|
|
71
|
+
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
|
|
72
|
+
> ```sql
|
|
73
|
+
> DROP TABLE users;
|
|
74
|
+
> ```
|
|
75
|
+
> Caveman resume. Verify backup exist first.
|
|
76
|
+
|
|
77
|
+
## Boundaries
|
|
78
|
+
|
|
79
|
+
Persisted outside chat: write normal prose code, comments, commits, docs, issue/PR/MR/defect/ticket/bug-report text, memory files, third-party messages (/caveman-compress exempt). "Open a defect" or "file a bug" mean the same as "open issue": body go to other humans, so body normal English. "stop caveman" or "normal mode": revert. Level persist until changed or session end.
|
|
@@ -10,10 +10,10 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
10
10
|
import path from 'node:path';
|
|
11
11
|
import { existsSync } from 'node:fs';
|
|
12
12
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
13
|
-
import { activeModesSummary, COMBO_LEVELS, getSharedComboState, hasModeState, normalizeComboLevel, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboListener,
|
|
13
|
+
import { activeModesSummary, COMBO_LEVELS, getSharedComboState, hasModeState, normalizeComboLevel, reconcileSharedComboEntries, sessionEntries, setSharedComboLevel, setSharedComboListener, setSharedComboModes, systemPromptIncludes, } from '../shared/session-state.js';
|
|
14
14
|
import { hostSelect, injectPromptText, onHostEvent, setExtensionLabel } from '../shared/host.js';
|
|
15
15
|
import { announceStatus } from '../shared/status.js';
|
|
16
|
-
import { isComboSetupComplete, readComboDefault, readPonytailDefault, saveComboSetup, } from '../shared/plugin-settings.js';
|
|
16
|
+
import { isComboSetupComplete, readCavemanDefault, readComboDefault, readPluginSettings, readPonytailDefault, readRtkDefault, saveComboSetup, } from '../shared/plugin-settings.js';
|
|
17
17
|
import { findHoistedPackage, isPiProcess } from '../lib/utils.js';
|
|
18
18
|
const EXTENSION_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
19
19
|
const PONYTAIL_FALLBACK_INTENSITY = {
|
|
@@ -45,6 +45,8 @@ export default function comboToggleExtension(pi) {
|
|
|
45
45
|
setExtensionLabel(pi, 'Combo session toggle (all 3 add-ons)');
|
|
46
46
|
let setupPrompted = false;
|
|
47
47
|
let lastInjected = undefined;
|
|
48
|
+
// opencode has no UI event stream, so only its empty branch reconciles.
|
|
49
|
+
const OPENCODE = pi.hostId === 'opencode';
|
|
48
50
|
// Siblings restore from these entries, so the fallback must write them too.
|
|
49
51
|
function persistPreset(level) {
|
|
50
52
|
const modes = COMBO_LEVELS[level];
|
|
@@ -53,20 +55,41 @@ export default function comboToggleExtension(pi) {
|
|
|
53
55
|
pi.appendEntry?.('ponytail-mode', { mode: modes.ponytail });
|
|
54
56
|
pi.appendEntry?.('combo-level', { level });
|
|
55
57
|
}
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
+
function restoreConfiguredDefaults() {
|
|
59
|
+
const settings = readPluginSettings();
|
|
60
|
+
const hasModeDefaults = ['cavemanDefault', 'rtkDefault', 'ponytailDefault'].some((key) => key in settings);
|
|
61
|
+
const combo = readComboDefault();
|
|
62
|
+
const preset = COMBO_LEVELS[combo];
|
|
63
|
+
const modes = hasModeDefaults
|
|
64
|
+
? { caveman: readCavemanDefault(), rtk: readRtkDefault() ? 'on' : 'off', ponytail: readPonytailDefault() }
|
|
65
|
+
: preset;
|
|
66
|
+
setSharedComboModes(modes);
|
|
67
|
+
if (modes.caveman === 'off' && modes.rtk === 'off' && modes.ponytail === 'off')
|
|
68
|
+
return;
|
|
69
|
+
pi.appendEntry?.('caveman-mode', { mode: modes.caveman });
|
|
70
|
+
pi.appendEntry?.('rtk-mode', { enabled: modes.rtk === 'on' });
|
|
71
|
+
pi.appendEntry?.('ponytail-mode', { mode: modes.ponytail });
|
|
72
|
+
if (preset && modes.caveman === preset.caveman && modes.rtk === preset.rtk && modes.ponytail === preset.ponytail) {
|
|
73
|
+
pi.appendEntry?.('combo-level', { level: combo });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
function reconcile(ctx, resetEmpty = false) {
|
|
77
|
+
if (!ctx?.hasUI && !(resetEmpty && OPENCODE))
|
|
58
78
|
return getSharedComboState();
|
|
59
|
-
|
|
79
|
+
const entries = sessionEntries(ctx);
|
|
80
|
+
if (!resetEmpty && entries.length === 0)
|
|
81
|
+
return getSharedComboState();
|
|
82
|
+
return reconcileSharedComboEntries(entries);
|
|
60
83
|
}
|
|
61
84
|
function listen(ctx) {
|
|
62
85
|
if (!ctx?.hasUI)
|
|
63
86
|
return;
|
|
64
87
|
setSharedComboListener('combo', (state) => { announceStatus(ctx); return state; });
|
|
65
88
|
}
|
|
66
|
-
function track(ctx) {
|
|
89
|
+
function track(ctx, resetEmpty = false) {
|
|
67
90
|
listen(ctx);
|
|
68
|
-
if (ctx?.hasUI)
|
|
69
|
-
reconcile(ctx);
|
|
91
|
+
if (ctx?.hasUI || (resetEmpty && OPENCODE))
|
|
92
|
+
reconcile(ctx, resetEmpty);
|
|
70
93
|
}
|
|
71
94
|
pi.registerCommand?.('combo', {
|
|
72
95
|
description: 'Toggle all 3 OMP add-ons at once. Usage: /combo <off|medium|balanced|max|status>',
|
|
@@ -113,26 +136,13 @@ export default function comboToggleExtension(pi) {
|
|
|
113
136
|
}
|
|
114
137
|
}
|
|
115
138
|
pi.on('session_start', async (_event, ctx) => {
|
|
116
|
-
track(ctx);
|
|
117
|
-
|
|
118
|
-
|
|
139
|
+
track(ctx, true);
|
|
140
|
+
if (!isComboSetupComplete() && ctx?.hasUI && ctx.ui?.select)
|
|
141
|
+
await runFirstRunSetup(ctx);
|
|
142
|
+
// The configured defaults apply when a fresh session has no persisted mode state.
|
|
119
143
|
const entries = sessionEntries(ctx);
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
const fallback = readComboDefault();
|
|
123
|
-
if (fallback !== 'off') {
|
|
124
|
-
persistPreset(fallback);
|
|
125
|
-
setSharedComboLevel(fallback);
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
// No sibling restores a standalone ponytail default, so apply it here; skip it when the active preset already writes the same mode.
|
|
129
|
-
if (!entries.some((e) => e?.type === 'custom' && e.customType === 'ponytail-mode')) {
|
|
130
|
-
const ponytailFallback = readPonytailDefault();
|
|
131
|
-
if (ponytailFallback !== 'off' && ponytailFallback !== getSharedComboState().ponytail) {
|
|
132
|
-
pi.appendEntry?.('ponytail-mode', { mode: ponytailFallback });
|
|
133
|
-
setSharedComboMode('ponytail', ponytailFallback);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
144
|
+
if (!hasModeState(entries) && (ctx?.hasUI || OPENCODE))
|
|
145
|
+
restoreConfiguredDefaults();
|
|
136
146
|
// Announced last, so any default above is reflected.
|
|
137
147
|
announceStatus(ctx);
|
|
138
148
|
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// /headroom session toggle. Headroom is a machine-level add-on, so this extension mirrors the stored per-agent flags and delegates every state change to the CLI.
|
|
2
|
+
import { HEADROOM_INSTALL_HINT, headroomAgentState, headroomCapability, headroomProxyUp, headroomStatusFor, readHeadroomConfig, setHeadroomStatus, } from '../shared/headroom.js';
|
|
3
|
+
import { onHostEvent, setExtensionLabel } from '../shared/host.js';
|
|
4
|
+
import { announceStatus } from '../shared/status.js';
|
|
5
|
+
/** The host's own key in the per-agent Headroom flags. */
|
|
6
|
+
function agentOf(pi) {
|
|
7
|
+
return pi.hostId ?? 'omp';
|
|
8
|
+
}
|
|
9
|
+
function statusText(agent, status) {
|
|
10
|
+
const manual = headroomCapability(agent) === 'manual';
|
|
11
|
+
const port = readHeadroomConfig().port;
|
|
12
|
+
if (status === 'down')
|
|
13
|
+
return `Headroom is on for ${agent}${manual ? ' (manual base URL)' : ''}, but the proxy does not answer. Restart it with: headroom proxy --port ${port}`;
|
|
14
|
+
if (status === 'manual')
|
|
15
|
+
return `Headroom is on for ${agent} (manual base URL). Point the model base URL at the proxy.`;
|
|
16
|
+
if (status === 'on')
|
|
17
|
+
return `Headroom is on for ${agent}.`;
|
|
18
|
+
return `Headroom is off for ${agent}.`;
|
|
19
|
+
}
|
|
20
|
+
export default function headroomSessionExtension(pi) {
|
|
21
|
+
setExtensionLabel(pi, 'Headroom session toggle');
|
|
22
|
+
const agent = agentOf(pi);
|
|
23
|
+
let shaperWarned = false;
|
|
24
|
+
// Reads the stored flags, then probes the proxy only when this agent is wired. Off installs never touch the network.
|
|
25
|
+
async function refresh(ctx) {
|
|
26
|
+
const config = readHeadroomConfig();
|
|
27
|
+
const state = headroomAgentState(config, agent);
|
|
28
|
+
// No segment until the user opts in, so a machine that merely has Headroom installed keeps the old status line.
|
|
29
|
+
if (!config.support || !state.enabled) {
|
|
30
|
+
setHeadroomStatus(null);
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
const up = await headroomProxyUp(config.port);
|
|
34
|
+
const status = headroomStatusFor(config, agent, up);
|
|
35
|
+
setHeadroomStatus(status);
|
|
36
|
+
// Caveman already owns terse output, so two shapers would fight over one prompt.
|
|
37
|
+
if (!shaperWarned && process.env.HEADROOM_OUTPUT_SHAPER && process.env.HEADROOM_OUTPUT_SHAPER !== '0') {
|
|
38
|
+
shaperWarned = true;
|
|
39
|
+
ctx?.ui?.notify?.(`HEADROOM_OUTPUT_SHAPER is set while Caveman is active. Unset it: one prompt owner only.`, 'warning');
|
|
40
|
+
}
|
|
41
|
+
return status;
|
|
42
|
+
}
|
|
43
|
+
async function runCli(action, ctx) {
|
|
44
|
+
if (typeof pi.exec !== 'function') {
|
|
45
|
+
ctx?.ui?.notify?.(`Run in a shell: tersio headroom ${action} --agent ${agent}`, 'warning');
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const result = await pi.exec('tersio', ['headroom', action, '--agent', agent], { cwd: ctx?.cwd ?? pi.cwd });
|
|
50
|
+
const text = [result.stdout, result.stderr].map((part) => part.trim()).filter(Boolean).join('\n');
|
|
51
|
+
ctx?.ui?.notify?.(text || `tersio headroom ${action} exited ${result.code}`, result.code === 0 ? 'info' : 'warning');
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
ctx?.ui?.notify?.(`tersio headroom ${action} failed (${e.message}). Run it in a shell.`, 'warning');
|
|
55
|
+
}
|
|
56
|
+
await refresh(ctx);
|
|
57
|
+
announceStatus(ctx);
|
|
58
|
+
}
|
|
59
|
+
pi.registerCommand?.('headroom', {
|
|
60
|
+
description: 'Toggle the Headroom compression proxy for this agent',
|
|
61
|
+
handler: async (args, ctx) => {
|
|
62
|
+
const arg = String(args || '').trim().toLowerCase();
|
|
63
|
+
if (!arg || arg === 'status') {
|
|
64
|
+
const status = await refresh(ctx);
|
|
65
|
+
ctx?.ui?.notify?.(status === null
|
|
66
|
+
? `Headroom is not installed — ${HEADROOM_INSTALL_HINT}`
|
|
67
|
+
: statusText(agent, status), 'info');
|
|
68
|
+
announceStatus(ctx);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
if (['on', 'enable', 'enabled', 'true'].includes(arg)) {
|
|
72
|
+
await runCli('enable', ctx);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (['off', 'disable', 'disabled', 'false'].includes(arg)) {
|
|
76
|
+
await runCli('disable', ctx);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
ctx?.ui?.notify?.('Usage: /headroom [on|off|status]', 'warning');
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
// Start, resume, branch, and compaction all land here, so every session type shows the state.
|
|
83
|
+
pi.on('session_start', async (_event, ctx) => {
|
|
84
|
+
await refresh(ctx);
|
|
85
|
+
announceStatus(ctx);
|
|
86
|
+
});
|
|
87
|
+
for (const event of ['session_branch', 'session_tree', 'session_switch', 'session_compact']) {
|
|
88
|
+
onHostEvent(pi, event, async (_event, ctx) => {
|
|
89
|
+
await refresh(ctx);
|
|
90
|
+
announceStatus(ctx);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
@@ -19,7 +19,20 @@ export function homeDir() {
|
|
|
19
19
|
}
|
|
20
20
|
export const RTK_RELEASE_API = 'https://api.github.com/repos/rtk-ai/rtk/releases/latest';
|
|
21
21
|
export const CAVEMAN_REMOTE_ULTRA = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/ultracave/SKILL.md';
|
|
22
|
-
export const
|
|
22
|
+
export const CAVEMAN_REMOTE_MEGACAVE = 'https://raw.githubusercontent.com/JuliusBrussee/caveman/main/skills/megacave/SKILL.md';
|
|
23
|
+
// A fetch that returns an error page or a truncated body must not reach the extension tree.
|
|
24
|
+
export function ruleBodyProblem(text) {
|
|
25
|
+
const trimmed = text.trim();
|
|
26
|
+
if (trimmed === '')
|
|
27
|
+
return 'empty body';
|
|
28
|
+
if (/^\s*<(!doctype|html)/i.test(trimmed))
|
|
29
|
+
return 'HTML page, not markdown';
|
|
30
|
+
if (/^\s*404\b/im.test(trimmed))
|
|
31
|
+
return 'upstream 404 page';
|
|
32
|
+
if (trimmed.length < 800)
|
|
33
|
+
return `truncated (${trimmed.length} chars)`;
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
23
36
|
const RTK_PLATFORM_SPECS = {
|
|
24
37
|
'win32/x64': { triple: 'x86_64-pc-windows-msvc', ext: '.zip', binary: 'rtk.exe' },
|
|
25
38
|
'linux/x64': { triple: 'x86_64-unknown-linux-musl', ext: '.tar.gz', binary: 'rtk' },
|
|
@@ -55,6 +68,18 @@ export function resolveHostBinary(name, managedDir = path.join(homeDir(), '.bun'
|
|
|
55
68
|
export function rtkPlatformSpec(platform = process.platform, arch = process.arch) {
|
|
56
69
|
return RTK_PLATFORM_SPECS[`${platform}/${arch}`] || null;
|
|
57
70
|
}
|
|
71
|
+
// Walk up to the nearest package.json; identical for the source and compiled trees.
|
|
72
|
+
export function findPackageRoot(fromDir) {
|
|
73
|
+
let dir = fromDir;
|
|
74
|
+
for (;;) {
|
|
75
|
+
if (existsSync(path.join(dir, 'package.json')))
|
|
76
|
+
return dir;
|
|
77
|
+
const parent = path.dirname(dir);
|
|
78
|
+
if (parent === dir)
|
|
79
|
+
throw new Error('package root not found');
|
|
80
|
+
dir = parent;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
58
83
|
// The 3xx+location redirect rule shared by httpsGet/httpsDownload.
|
|
59
84
|
function redirectNext(res, url) {
|
|
60
85
|
if (res.statusCode !== undefined && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
@@ -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
|
-
|
|
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)) {
|