@krtclcdy/tersio 2.25.5 → 2.25.6

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 (73) hide show
  1. package/README.md +17 -36
  2. package/dashboard/dist/index.html +15 -15
  3. package/dist/cli/backups.js +7 -14
  4. package/dist/cli/banner.js +4 -3
  5. package/dist/cli/common.js +28 -12
  6. package/dist/cli/currency.js +1 -1
  7. package/dist/cli/dashboard.js +66 -35
  8. package/dist/cli/doctor-fix.js +204 -19
  9. package/dist/cli/doctor.js +226 -39
  10. package/dist/cli/hosts.js +25 -5
  11. package/dist/cli/install.js +144 -48
  12. package/dist/cli/interactive.js +3 -3
  13. package/dist/cli/manifest.js +87 -7
  14. package/dist/cli/profile.js +13 -8
  15. package/dist/cli/rtk-wiring.js +1 -1
  16. package/dist/cli/settings.js +20 -5
  17. package/dist/cli/uninstall.js +3 -3
  18. package/dist/cli/update.js +6 -7
  19. package/dist/cli/usage.js +6 -9
  20. package/dist/extensions/caveman/index.js +4 -2
  21. package/dist/extensions/caveman/rule-megacave.md +5 -3
  22. package/dist/extensions/caveman/rule-ultra.md +4 -4
  23. package/dist/extensions/caveman/rule.md +55 -45
  24. package/dist/extensions/combo/index.js +62 -21
  25. package/dist/extensions/commands/index.js +1 -1
  26. package/dist/extensions/pstack/index.js +119 -0
  27. package/dist/extensions/pstack/rule.md +65 -0
  28. package/dist/extensions/rtk/filter.js +0 -1
  29. package/dist/extensions/rtk/session.js +4 -4
  30. package/dist/extensions/server.js +90 -6
  31. package/dist/extensions/shared/agent-stats.js +214 -278
  32. package/dist/extensions/shared/brand-marks.js +33 -34
  33. package/dist/extensions/shared/carbon.js +1 -1
  34. package/dist/extensions/shared/plugin-settings.js +11 -6
  35. package/dist/extensions/shared/pricing.js +8 -16
  36. package/dist/extensions/shared/rtk-gain.js +4 -9
  37. package/dist/extensions/shared/session-state.js +15 -10
  38. package/dist/extensions/shared/sqlite.js +168 -0
  39. package/dist/extensions/shared/status.js +8 -8
  40. package/dist/extensions/shared/types.js +1 -1
  41. package/dist/extensions/shared/usage-ledger.js +53 -41
  42. package/dist/extensions/shared/usage-store.js +111 -52
  43. package/dist/extensions/shared/utils.js +5 -5
  44. package/dist/extensions/timing/index.js +31 -0
  45. package/dist/extensions/updater/index.js +3 -6
  46. package/dist/tersio.js +8 -3
  47. package/extensions/caveman/index.ts +4 -2
  48. package/extensions/caveman/rule-megacave.md +5 -3
  49. package/extensions/caveman/rule-ultra.md +4 -4
  50. package/extensions/caveman/rule.md +55 -45
  51. package/extensions/combo/index.ts +62 -19
  52. package/extensions/commands/index.ts +1 -1
  53. package/extensions/pstack/index.ts +128 -0
  54. package/extensions/pstack/rule.md +65 -0
  55. package/extensions/rtk/filter.ts +0 -1
  56. package/extensions/rtk/session.ts +4 -4
  57. package/extensions/server.ts +72 -7
  58. package/extensions/shared/agent-stats.ts +226 -278
  59. package/extensions/shared/brand-marks.ts +34 -34
  60. package/extensions/shared/carbon.ts +1 -1
  61. package/extensions/shared/plugin-settings.ts +12 -6
  62. package/extensions/shared/pricing.ts +10 -26
  63. package/extensions/shared/rtk-gain.ts +4 -9
  64. package/extensions/shared/session-state.ts +18 -13
  65. package/extensions/shared/sqlite.ts +168 -0
  66. package/extensions/shared/status.ts +9 -8
  67. package/extensions/shared/types.ts +2 -1
  68. package/extensions/shared/usage-ledger.ts +60 -47
  69. package/extensions/shared/usage-store.ts +120 -52
  70. package/extensions/shared/utils.ts +5 -5
  71. package/extensions/timing/index.ts +45 -0
  72. package/extensions/updater/index.ts +3 -8
  73. package/package.json +17 -8
@@ -1,10 +1,4 @@
1
- // Brand mark catalog: names, not bytes. One list serves both consumers — the dashboard draws from
2
- // it at runtime, and the CLI export inlines the same URLs so a single-file dashboard keeps its
3
- // logos with the network off. Simple Icons files are CC0-1.0; the marks stay their owners'
4
- // property and the dashboard uses them nominatively, to name the service that served a request.
5
- // A vendor with no Simple Icons entry keeps its owner-hosted mark, one with no hosted mark is
6
- // drawn inline (`dashboard/app/src/lib/marks/inline.ts`), and bumping `SIMPLE_ICONS` once moves
7
- // every Simple Icons entry with it.
1
+ // Brand mark catalog: names not bytes; Simple Icons files are CC0-1.0, marks remain their owners'.
8
2
  const SIMPLE_ICONS = "https://cdn.jsdelivr.net/npm/simple-icons@16/icons";
9
3
  /** Slug to mark URL. Only slugs named here resolve to a mark; the rest get a monogram. */
10
4
  export const REMOTE_MARKS = {
@@ -33,6 +27,8 @@ export const REMOTE_MARKS = {
33
27
  // Owner-hosted, nominative use.
34
28
  groq: "https://groq.com/favicon.svg",
35
29
  poolside: "https://poolside.ai/favicon/favicon.svg",
30
+ // AWS owns Kiro; Wikimedia hosts its vector, as AWS serves only a 16x16 ICO that cannot grow into a tile.
31
+ amazonaws: "https://upload.wikimedia.org/wikipedia/commons/5/5c/AWS_Simple_Icons_AWS_Cloud.svg",
36
32
  cerebras: "https://cdn.sanity.io/images/e4qjo92p/production/e7a55ae5ab7e2c4fdfd4e66a51f628d1f2f44207-967x967.png?w=256&h=256&fit=max",
37
33
  inclusionai: "https://cdn-avatars.huggingface.co/v1/production/uploads/662e1f9da266499277937d33/fyKuazRifqiaIO34xrhhm.jpeg",
38
34
  // Owner avatars: the vendor or gateway posts no standalone icon, so its account picture stands in.
@@ -46,13 +42,16 @@ export const REMOTE_MARKS = {
46
42
  magpie: "https://usemagpie.ai/favicon.png",
47
43
  gmicloud: "https://gmicloud.ai/favicon.ico",
48
44
  charmhyper: "https://github.com/charmbracelet.png?size=80",
45
+ // WorkBuddy's own favicon, a hashed asset on the Tencent CDN its page serves.
46
+ workbuddy: "https://download.codebuddy.ai/web/workbuddy/913b2390aae396c66b937bd9143ae5259306d7d7/assets/logo.svg",
47
+ // Trae's own favicon: a dark tile carrying its green mark, so the tile matches the artwork.
48
+ trae: "https://lf16-web-neutral.traecdn.ai/obj/trae-ai-static/trae_website/favicon.png",
49
+ // Kiro's own icon, a purple tile with the mark drawn inside it.
50
+ kiro: "https://kiro.dev/icon.svg",
51
+ // Meituan's own icon: its yellow tile with the logotype.
52
+ meituan: "https://s3plus.meituan.net/v1/mss_e2821d7f0cfe4ac1bf9202ecf9590e67/cdn-prod/file:1040877d/favicon-mt.ico",
49
53
  };
50
- /**
51
- * Tile fill, not text. A mark whose tile carries its brand color reads as the brand, and a mark
52
- * whose logo already fills its own box never shows the fill. Values come from the brand (the
53
- * service's own page or its own asset), never from a guess; a slug absent here keeps the plain
54
- * panel and the theme ink.
55
- */
54
+ /** Tile fill per slug from the brand's own page, never a guess; an absent slug keeps the plain panel. */
56
55
  export const MARK_BG = {
57
56
  // Simple Icons marks: the vendor's brand color, taken from its own brand page.
58
57
  anthropic: "#d97757",
@@ -77,8 +76,7 @@ export const MARK_BG = {
77
76
  instagram: "#e4405f",
78
77
  reddit: "#ff4500",
79
78
  linkedin: "#0a66c2",
80
- // Owner-hosted marks: the tile matches the asset's own square, and a slug whose artwork already
81
- // carries its own color takes a white tile so the art reads.
79
+ // Owner-hosted marks match the asset's own square; art that already carries color takes a white tile.
82
80
  groq: "#f43e01",
83
81
  poolside: "#4137ff",
84
82
  cerebras: "#ffffff",
@@ -87,17 +85,21 @@ export const MARK_BG = {
87
85
  stepfun: "#ffffff",
88
86
  kilo: "#f7f576",
89
87
  opencode: "#131010",
88
+ // The AWS cloud mark draws its own orange and its own white, so the tile stays plain white.
89
+ amazonaws: "#ffffff",
90
90
  // The mark's own artwork is white, so the tile is white and the glyph is black.
91
91
  openai: "#ffffff",
92
92
  commandcode: "#ffffff",
93
93
  gmicloud: "#ffffff",
94
94
  magpie: "#0b0b0c",
95
+ // WorkBuddy's mark is a full-bleed teal tile carrying its own white art.
96
+ workbuddy: "#01c886",
97
+ // The mark is a full-bleed tile, so the tile fill repeats the artwork's own background.
98
+ trae: "#000000",
99
+ kiro: "#9046ff",
100
+ meituan: "#ffd100",
95
101
  };
96
- /**
97
- * Glyph scale per slug, applied to the glyph box the tile clips. Simple Icons art fills its 24 box
98
- * by very different amounts, so the measured ink box decides the scale: a logo that runs to its
99
- * box edge shrinks and one with an invisible margin grows. One is the default.
100
- */
102
+ /** Glyph scale per slug over the tile clip; the measured ink box decides, and one is the default. */
101
103
  export const MARK_SCALE = {
102
104
  kimi: 0.72,
103
105
  zdotai: 0.72,
@@ -113,20 +115,11 @@ export function luminance(hex) {
113
115
  });
114
116
  return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
115
117
  }
116
- /**
117
- * The glyph color that stays legible on a tile fill. The cut sits at 0.35, which keeps white ink
118
- * on a brand color: Meta blue, DeepSeek blue, Xiaomi orange, Mistral orange, and Kimi blue all
119
- * carry a white logo, which is how their own brand sheets print them. Only a light neutral takes
120
- * black: Kilo yellow, StepFun teal, and Lightbend orange.
121
- */
118
+ /** The ink legible on a tile fill: luminance above 0.35 takes black, else white. */
122
119
  export function contrastInk(hex) {
123
120
  return luminance(hex) > 0.35 ? "#000000" : "#ffffff";
124
121
  }
125
- /**
126
- * Marks that carry their own color or their own background. Everything else is opaque black, so
127
- * it draws as an alpha mask filled with the chosen ink instead of an image. A slug whose asset is
128
- * a filled tile must draw as an image, or the mask paints one flat block and the logo vanishes.
129
- */
122
+ /** Marks carrying their own color or background; a filled-tile asset must draw as an image, not a mask. */
130
123
  export const COLORED_MARKS = new Set([
131
124
  "cerebras",
132
125
  "inclusionai",
@@ -137,12 +130,18 @@ export const COLORED_MARKS = new Set([
137
130
  "magpie",
138
131
  "charmhyper",
139
132
  "stepfun",
140
- // The ICO and the two inline PNGs are dark tiles with light art drawn inside them. The Groq
141
- // favicon is one flat orange, so it draws as a mask instead: a masked glyph on a Groq-orange
142
- // tile is a logo, and the same art as an image on the same orange tile is a blank tile.
133
+ // The ICO and inline PNGs are dark tiles with light art; flat-orange Groq draws as a mask instead.
143
134
  "gmicloud",
144
135
  "stealth",
145
136
  "cognition",
137
+ // A full-bleed teal tile with its own white art, so a mask would flatten both into one ink.
138
+ "workbuddy",
139
+ // Each is a full-bleed brand tile with its own ink, so a mask would flatten both into one color.
140
+ "trae",
141
+ "kiro",
142
+ "meituan",
143
+ // Carries its own orange and its own white, so a mask would flatten both into one ink.
144
+ "amazonaws",
146
145
  ]);
147
146
  /** The URL a mark comes from, or null when the name has no hosted mark. */
148
147
  export function markUrl(slug) {
@@ -23,7 +23,7 @@ const GRIDS = {
23
23
  export const SERVING_CONCURRENCY = 32;
24
24
  /** EcoLogits release this port tracks, so a UI can name it instead of guessing. */
25
25
  export const ECOLOGITS_VERSION = '0.8.2';
26
- // Registry values verbatim from EcoLogits' model_repository; borrows cite the public param disclosure they stand in for (MoE active/total).
26
+ // Verbatim from EcoLogits' model_repository; borrows cite the param disclosure they replace (MoE active/total).
27
27
  const TABLE = [
28
28
  { match: 'opus', active: 625.0, total: 2000, provider: 'anthropic', source: 'registry' },
29
29
  { match: 'sonnet', active: 137.5, total: 440, provider: 'anthropic', source: 'registry' },
@@ -7,7 +7,7 @@ export const PLUGIN_NAME = '@krtclcdy/tersio';
7
7
  export function tersioSettingsFile() {
8
8
  return path.join(tersioHome(), 'settings.json');
9
9
  }
10
- // Older installs kept these as OMP plugin settings; still read so switching the store does not reset a user's defaults.
10
+ // Older installs kept these as OMP plugin settings; still read so switching stores does not reset a user's defaults.
11
11
  function legacyOmpSettings() {
12
12
  const configHome = process.env.XDG_CONFIG_HOME || path.join(homeDir(), '.config');
13
13
  for (const p of [
@@ -38,13 +38,15 @@ function readSettingsFile() {
38
38
  return null;
39
39
  }
40
40
  }
41
- // The stored settings; empty when the file is missing, corrupt, or empty.
41
+ // The stored settings; {} when the file is missing or corrupt.
42
42
  export function readPluginSettings() {
43
43
  return readSettingsFile() ?? legacyOmpSettings() ?? {};
44
44
  }
45
45
  const COMBO_LEVELS = new Set(['off', 'medium', 'balanced', 'max']);
46
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
+ // Opt-in only: no combo preset writes this, so it never reaches a user who did not ask for it.
49
+ const PSTACK_MODES = new Set(['off', 'on']);
48
50
  function readStringDefault(key, valid) {
49
51
  const raw = readPluginSettings()[key];
50
52
  return typeof raw === 'string' && valid.has(raw) ? raw : 'off';
@@ -62,14 +64,17 @@ export function readRtkDefault() {
62
64
  export function readPonytailDefault() {
63
65
  return readStringDefault('ponytailDefault', PONYTAIL_MODES);
64
66
  }
67
+ export function readPstackDefault() {
68
+ return readStringDefault('pstackDefault', PSTACK_MODES);
69
+ }
65
70
  export function isComboSetupComplete() {
66
71
  return readPluginSettings().comboSetupComplete === true;
67
72
  }
68
73
  const COMBO_MODE_DEFAULTS = {
69
- off: { cavemanDefault: 'off', rtkDefault: false, ponytailDefault: 'off' },
70
- medium: { cavemanDefault: 'lite', rtkDefault: true, ponytailDefault: 'lite' },
71
- balanced: { cavemanDefault: 'full', rtkDefault: true, ponytailDefault: 'full' },
72
- max: { cavemanDefault: 'ultra', rtkDefault: true, ponytailDefault: 'ultra' },
74
+ off: { cavemanDefault: 'off', rtkDefault: false, ponytailDefault: 'off', pstackDefault: 'off' },
75
+ medium: { cavemanDefault: 'lite', rtkDefault: true, ponytailDefault: 'lite', pstackDefault: 'on' },
76
+ balanced: { cavemanDefault: 'full', rtkDefault: true, ponytailDefault: 'full', pstackDefault: 'on' },
77
+ max: { cavemanDefault: 'ultra', rtkDefault: true, ponytailDefault: 'ultra', pstackDefault: 'on' },
73
78
  };
74
79
  export function saveComboSetup(level) {
75
80
  if (!(level in COMBO_MODE_DEFAULTS))
@@ -1,13 +1,13 @@
1
- // extensions/shared/pricing.ts — dynamic model pricing from LiteLLM. Exact id → provider-prefix strip → default, reported with known:false when unpriced.
1
+ // pricing.ts — model pricing from LiteLLM. Exact id → prefix strip → default, known:false if unpriced.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { tersioDataPath } from './utils.js';
5
5
  // Sonnet-class default for unpriced models; always paired with known:false.
6
6
  export const DEFAULT_PRICE = { input: 2, output: 10, cacheRead: 0.2, cacheWrite: 2.5 };
7
- /** Tiers firm enough to call a price. `tail` and `default` are estimates, so they are not. */
7
+ /** Tiers firm enough to call a price; `tail` and `default` are estimates. */
8
8
  export const PRICE_TIERS_FIRM = ['measured', 'override', 'exact', 'alias'];
9
9
  const LITELLM_URL = 'https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json';
10
- // Readers use the file even when stale, and only one refresh runs per process; `tersio update` still forces a fresh fetch.
10
+ // Readers use the file even when stale; one refresh per process. `tersio update` still forces a fresh fetch.
11
11
  const CACHE_TTL_MS = 7 * 24 * 3600 * 1000;
12
12
  let refreshInflight = null;
13
13
  export function pricesCachePath() {
@@ -16,11 +16,7 @@ export function pricesCachePath() {
16
16
  return override;
17
17
  return tersioDataPath('prices.json', 'tersio-prices.json');
18
18
  }
19
- /**
20
- * The operator's own prices, merged over the feed and keyed exactly as the transcript spells a
21
- * model. A `*` prefix matches any gateway suffix. This is where a model the feed keys under a
22
- * different provider gets a real price.
23
- */
19
+ /** Operator prices keyed as the transcript spells a model; a `*` prefix matches any gateway suffix. */
24
20
  export function pricesOverridePath() {
25
21
  const override = process.env.TERSIO_PRICES_OVERRIDE_FILE;
26
22
  if (override)
@@ -52,7 +48,6 @@ function loadOverrides(stamp = overrideStamp()) {
52
48
  const price = asPrice(row);
53
49
  if (!price)
54
50
  continue;
55
- // A `*` prefix matches any gateway prefix, so one entry covers every spelling of a model.
56
51
  if (id.startsWith('*'))
57
52
  tails.push([id.slice(1).toLowerCase(), price]);
58
53
  else
@@ -159,7 +154,7 @@ export function priceFor(model, live) {
159
154
  }
160
155
  return resolvePrice(model, table, overrideStamp());
161
156
  }
162
- // Recent rows repeat a handful of models; the memo turns thousands of lookups into one per model. Cleared whenever the table identity changes.
157
+ // Recent rows repeat a few models; the memo turns thousands of lookups into one per model. Cleared on table change.
163
158
  let memoTable;
164
159
  let memoStamp = 0;
165
160
  let memo = new Map();
@@ -193,21 +188,18 @@ function resolvePrice(model, table, stamp = overrideStamp()) {
193
188
  }
194
189
  }
195
190
  }
196
- // An alias goes through the same chain as any other id, so it still resolves when the feed keys
197
- // it under a provider prefix.
191
+ // An alias runs the same chain, so it still resolves when the feed keys it under a provider prefix.
198
192
  const alias = MODEL_ALIASES.find((a) => a.id === model.toLowerCase());
199
193
  const id = alias?.feed ?? model;
200
194
  if (table) {
201
195
  const entries = priceEntries(table);
202
- // 1. The id verbatim, then case-folded. Nothing is being guessed here.
196
+ // The id verbatim, then case-folded; nothing guessed.
203
197
  const hit = entries.find(([k]) => k === id) ?? entries.find(([k]) => k === id.toLowerCase());
204
198
  if (hit)
205
199
  return { price: hit[1], known: true, live: true, tier: alias ? 'alias' : 'exact' };
206
200
  const lower = id.toLowerCase();
207
201
  const tail = lower.split('/').pop() ?? lower;
208
- // The bare model name under any provider, which need not be the provider that billed the row.
209
- // Reported as tier `tail`, so a caller keeps it out of a firm total; `prices.local.json` fixes
210
- // a specific case.
202
+ // Bare name under any provider (need not be the billing one); tier `tail` keeps it out of a firm total.
211
203
  const bare = entries.find(([k]) => {
212
204
  const lk = k.toLowerCase();
213
205
  return lk === tail || lk.endsWith(`/${tail}`);
@@ -1,10 +1,10 @@
1
1
  // Savings read from rtk history, never estimated; modes bench in docs/BENCHMARK.md.
2
- import { execFileSync } from 'node:child_process';
3
2
  import fs from 'node:fs';
4
3
  import os from 'node:os';
5
4
  import path from 'node:path';
5
+ import { sqliteRead } from './sqlite.js';
6
6
  const EMPTY = { commands: 0, saved: 0, input: 0, avgPct: 0, totalMs: 0, byCommand: [] };
7
- // The table shows one clipped cell, so bound the command before it crosses the sqlite3 CLI and lands in every /data.json poll.
7
+ // The table shows one clipped cell, so bound the command before it reaches /data.json.
8
8
  const MAX_CMD_CHARS = 200;
9
9
  // A multi-line stored command would derail every row after it; flatten on read.
10
10
  const CMD_ONE_LINE = `replace(replace(replace(rtk_cmd, char(10), ' '), char(13), ' '), char(9), ' ')`;
@@ -19,12 +19,7 @@ export function rtkDbPath() {
19
19
  return path.join(xdg, 'rtk', 'history.db');
20
20
  }
21
21
  function query(db, sql) {
22
- const out = execFileSync('sqlite3', ['-separator', '\t', db, sql], {
23
- encoding: 'utf8',
24
- timeout: 5000,
25
- stdio: ['ignore', 'pipe', 'ignore'],
26
- });
27
- return out
22
+ return sqliteRead(db, sql)
28
23
  .split('\n')
29
24
  .filter((line) => line.trim())
30
25
  .map((line) => line.split('\t'));
@@ -33,7 +28,7 @@ export function readRtkGain(limit = 10, cutoffMs) {
33
28
  try {
34
29
  if (!fs.existsSync(rtkDbPath()))
35
30
  return EMPTY;
36
- // View-level cutoff only: rtk's own rows stay untouched. Timestamps that do not parse land on 0 and drop out of a filtered view.
31
+ // View-level cutoff only: rtk's rows stay untouched. Unparseable timestamps land on 0 and drop from filtered views.
37
32
  const where = cutoffMs && cutoffMs > 0
38
33
  ? `WHERE CAST(strftime('%s', timestamp) AS INTEGER) >= ${Math.floor(cutoffMs / 1000)}`
39
34
  : '';
@@ -1,17 +1,19 @@
1
1
  import { readPluginSettings } from './plugin-settings.js';
2
2
  const BRIDGE_KEY = Symbol.for('tersio/combo-session-state');
3
- // A verbatim sentence from omp's subagent prompt, so it can drift; read from the installed omp binary on 2026-10-01. pi has no built-in subagent prompt.
3
+ // A verbatim sentence from omp's subagent prompt, so it can drift; read from the installed omp binary on 2026-10-01.
4
4
  export const OMP_SUBAGENT_MARKER = 'Worker agent: delegated tasks.';
5
+ // Every level above off turns pstack on with the other three; off is the one level without it.
5
6
  export const COMBO_LEVELS = Object.freeze({
6
- off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off' }),
7
- medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite' }),
8
- balanced: Object.freeze({ level: 'balanced', caveman: 'full', rtk: 'on', ponytail: 'full' }),
9
- max: Object.freeze({ level: 'max', caveman: 'ultra', rtk: 'on', ponytail: 'ultra' }),
7
+ off: Object.freeze({ level: 'off', caveman: 'off', rtk: 'off', ponytail: 'off', pstack: 'off' }),
8
+ medium: Object.freeze({ level: 'medium', caveman: 'lite', rtk: 'on', ponytail: 'lite', pstack: 'on' }),
9
+ balanced: Object.freeze({ level: 'balanced', caveman: 'full', rtk: 'on', ponytail: 'full', pstack: 'on' }),
10
+ max: Object.freeze({ level: 'max', caveman: 'ultra', rtk: 'on', ponytail: 'ultra', pstack: 'on' }),
10
11
  });
11
12
  const MODE_VALUES = {
12
13
  caveman: new Set(['off', 'lite', 'full', 'ultra', 'megacave', 'megacave-lite', 'megacave-full', 'megacave-ultra', 'wenyan', 'wenyan-lite', 'wenyan-full', 'wenyan-ultra']),
13
14
  rtk: new Set(['off', 'on']),
14
15
  ponytail: new Set(['off', 'lite', 'full', 'ultra']),
16
+ pstack: new Set(['off', 'on']),
15
17
  };
16
18
  // Upstream renamed wenyan to megacave and kept the old spelling as an alias.
17
19
  const CAVEMAN_LEGACY_PREFIX = /^wenyan/;
@@ -19,6 +21,7 @@ const MODE_ENTRY_TYPES = {
19
21
  'caveman-mode': 'caveman',
20
22
  'rtk-mode': 'rtk',
21
23
  'ponytail-mode': 'ponytail',
24
+ 'pstack-mode': 'pstack',
22
25
  };
23
26
  /** True when the branch carries Tersio's own mode state; a subagent starts without it. */
24
27
  export function hasModeState(entries) {
@@ -39,7 +42,7 @@ export function normalizeMode(name, value) {
39
42
  export function deriveLevel(modes) {
40
43
  for (const level of Object.keys(COMBO_LEVELS)) {
41
44
  const preset = COMBO_LEVELS[level];
42
- if (preset.caveman === modes?.caveman && preset.rtk === modes?.rtk && preset.ponytail === modes?.ponytail)
45
+ if (preset.caveman === modes?.caveman && preset.rtk === modes?.rtk && preset.ponytail === modes?.ponytail && preset.pstack === modes?.pstack)
43
46
  return level;
44
47
  }
45
48
  return 'custom';
@@ -55,6 +58,7 @@ function normalizedState(modes, level = deriveLevel(modes)) {
55
58
  caveman: normalizeMode('caveman', modes?.caveman) || 'off',
56
59
  rtk: normalizeMode('rtk', modes?.rtk) || 'off',
57
60
  ponytail: normalizeMode('ponytail', modes?.ponytail) || 'off',
61
+ pstack: normalizeMode('pstack', modes?.pstack) || 'off',
58
62
  };
59
63
  return Object.freeze({ level: isKnownLevel(level) ? level : deriveLevel(state), ...state });
60
64
  }
@@ -98,7 +102,7 @@ export function normalizeComboLevel(value) {
98
102
  return level;
99
103
  return COMBO_ALIASES[level] ?? null;
100
104
  }
101
- // Last-wins scan for a custom session entry; skips entries whose value fails to parse so a corrupt write never shadows an older valid one.
105
+ // Last-wins scan for a custom session entry; skips unparseable values so a corrupt write never shadows a valid one.
102
106
  export function lastCustomValue(entries, customType, pick) {
103
107
  if (!Array.isArray(entries))
104
108
  return null;
@@ -131,7 +135,7 @@ export function setSharedComboMode(name, value) {
131
135
  if (!mode)
132
136
  return getSharedComboState();
133
137
  const modes = { ...getSharedComboState(), [name]: mode };
134
- // 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.
138
+ // Derive the level from the final quartet, so a redundant same-value write cannot strand a preset at 'custom'.
135
139
  return publish(normalizedState(modes));
136
140
  }
137
141
  export function setSharedComboModes(modes) {
@@ -139,6 +143,7 @@ export function setSharedComboModes(modes) {
139
143
  caveman: normalizeMode('caveman', modes.caveman) || 'off',
140
144
  rtk: normalizeMode('rtk', modes.rtk) || 'off',
141
145
  ponytail: normalizeMode('ponytail', modes.ponytail) || 'off',
146
+ pstack: normalizeMode('pstack', modes.pstack) || 'off',
142
147
  }));
143
148
  }
144
149
  export function reconcileSharedComboEntries(entries) {
@@ -162,12 +167,12 @@ export function reconcileSharedComboEntries(entries) {
162
167
  modes[name] = mode;
163
168
  }
164
169
  }
165
- // The level reflects the final triplet, never the write order, so a preset entry followed by the same values reconciles back to the preset.
170
+ // The level reflects the final quartet, not write order, so a preset followed by the same values reconciles back.
166
171
  return publish(normalizedState(modes));
167
172
  }
168
173
  // One-line session-wide mode summary for change confirmations.
169
174
  export function activeModesSummary(state) {
170
- return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}`;
175
+ return `caveman=${state.caveman.toUpperCase()}, rtk=${state.rtk.toUpperCase()}, ponytail=${state.ponytail.toUpperCase()}, pstack=${state.pstack.toUpperCase()}`;
171
176
  }
172
177
  // Keyed registration drops stale closures; hosts offer no teardown hook.
173
178
  export function setSharedComboListener(key, listener) {
@@ -0,0 +1,168 @@
1
+ // One place that runs SQL against a database file.
2
+ //
3
+ // Bun and Node 22.5+ carry SQLite in process, so the `sqlite3` binary is a fallback rather than a
4
+ // requirement: hosts without it kept silently reading nothing. Reads come back exactly as the sqlite3
5
+ // CLI printed them -- rows on their own lines, cells joined by tabs, NULL an empty cell, one trailing
6
+ // newline -- because every caller already parses that shape. The engine rungs take one statement per
7
+ // read, so a caller with several queries calls once per query.
8
+ //
9
+ // The write path leaves the journal mode alone. The store's own schema sets WAL so a reader (the
10
+ // dashboard) never blocks a writer, and changing the mode needs exclusive access: with a reader holding
11
+ // the file the pragma fails, which is a failure reported after the caller's rows have already committed.
12
+ import { execFileSync } from 'node:child_process';
13
+ import fs from 'node:fs';
14
+ import { createRequire } from 'node:module';
15
+ import os from 'node:os';
16
+ import path from 'node:path';
17
+ /** A read this large used to vanish behind a 1MB cap, which cleared on one busy session_message table. */
18
+ const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
19
+ const TIMEOUT = 30_000;
20
+ const requireOptional = createRequire(import.meta.url);
21
+ let cached;
22
+ let cachedFor;
23
+ /** Bun's SQLite, or Node's when this build carries one. Null when neither runtime has an engine. */
24
+ function engine() {
25
+ const asked = process.env.TERSIO_SQLITE ?? '';
26
+ if (cached !== undefined && cachedFor === asked)
27
+ return cached;
28
+ cachedFor = asked;
29
+ cached = null;
30
+ if (asked === 'cli')
31
+ return cached;
32
+ try {
33
+ const { Database } = requireOptional('bun:sqlite');
34
+ cached = {
35
+ kind: 'bun',
36
+ read: (db, sql) => { const d = new Database(db, { readonly: true }); try {
37
+ return d.query(sql).values();
38
+ }
39
+ finally {
40
+ d.close();
41
+ } },
42
+ exec: (db, sql) => { const d = new Database(db); try {
43
+ d.exec(sql);
44
+ }
45
+ finally {
46
+ d.close();
47
+ } },
48
+ };
49
+ return cached;
50
+ }
51
+ catch { /* not Bun, or no SQLite in this build */ }
52
+ try {
53
+ const { DatabaseSync } = requireOptional('node:sqlite');
54
+ // Rows arrive keyed by column name unless the statement returns arrays. A duplicate name collapses
55
+ // its key and shifts every later cell, so a Node that cannot return arrays is no engine at all.
56
+ const probe = new DatabaseSync(':memory:');
57
+ try {
58
+ if (typeof probe.prepare('SELECT 1').setReturnArrays !== 'function')
59
+ throw new Error('no positional rows');
60
+ }
61
+ finally {
62
+ probe.close();
63
+ }
64
+ cached = {
65
+ kind: 'node',
66
+ read: (db, sql) => {
67
+ const d = new DatabaseSync(db, { readOnly: true });
68
+ try {
69
+ const st = d.prepare(sql);
70
+ st.setReturnArrays?.(true);
71
+ return st.all();
72
+ }
73
+ finally {
74
+ d.close();
75
+ }
76
+ },
77
+ exec: (db, sql) => { const d = new DatabaseSync(db); try {
78
+ d.exec(sql);
79
+ }
80
+ finally {
81
+ d.close();
82
+ } },
83
+ };
84
+ return cached;
85
+ }
86
+ catch { /* Node older than 22.5, or one built without SQLite or positional rows */ }
87
+ return cached;
88
+ }
89
+ /** The `sqlite3` binary, resolved once from PATH so a missing one costs one scan, not one spawn per call. */
90
+ let binary;
91
+ function findBinary() {
92
+ if (binary !== undefined)
93
+ return binary;
94
+ binary = null;
95
+ for (const dir of (process.env.PATH ?? '').split(path.delimiter)) {
96
+ if (!dir)
97
+ continue;
98
+ for (const name of process.platform === 'win32' ? ['sqlite3.exe', 'sqlite3'] : ['sqlite3']) {
99
+ const at = path.join(dir, name);
100
+ try {
101
+ fs.accessSync(at, fs.constants.X_OK);
102
+ binary = at;
103
+ return binary;
104
+ }
105
+ catch { /* not here */ }
106
+ }
107
+ }
108
+ return binary;
109
+ }
110
+ /** `bun`, `node`, `cli`, or null when this host has no way to read SQLite at all. */
111
+ export function sqliteEngine() {
112
+ return engine()?.kind ?? (findBinary() === null ? null : 'cli');
113
+ }
114
+ /** Whether any path can run SQL: what the tests guard their skips on. */
115
+ export function sqliteAvailable() {
116
+ return sqliteEngine() !== null;
117
+ }
118
+ /** Rows as the CLI prints them, so a caller cannot tell which engine answered. */
119
+ function toTsv(rows) {
120
+ if (rows.length === 0)
121
+ return '';
122
+ return `${rows.map((row) => row.map((cell) => (cell === null || cell === undefined ? '' : String(cell))).join('\t')).join('\n')}\n`;
123
+ }
124
+ function cliRead(db, sql) {
125
+ const opts = { encoding: 'utf8', timeout: TIMEOUT, stdio: ['pipe', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER, input: sql };
126
+ try {
127
+ return execFileSync('sqlite3', ['-separator', '\t', '-readonly', db], opts);
128
+ }
129
+ catch { /* the side files may be gone; try the next rung */ }
130
+ if (!fs.existsSync(`${db}-wal`)) {
131
+ try {
132
+ return execFileSync('sqlite3', ['-separator', '\t', `file:${db}?immutable=1`], opts);
133
+ }
134
+ catch { /* fall through to the copy */ }
135
+ }
136
+ // A live database cannot be read without its write-ahead log, so copy the set and read the copy.
137
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'tersio-sqlite-'));
138
+ const target = path.join(dir, path.basename(db));
139
+ try {
140
+ fs.copyFileSync(db, target);
141
+ for (const suffix of ['-wal', '-shm']) {
142
+ if (fs.existsSync(`${db}${suffix}`))
143
+ fs.copyFileSync(`${db}${suffix}`, `${target}${suffix}`);
144
+ }
145
+ return execFileSync('sqlite3', ['-separator', '\t', '-readonly', target], opts);
146
+ }
147
+ finally {
148
+ fs.rmSync(dir, { recursive: true, force: true });
149
+ }
150
+ }
151
+ /** Run a read and return the tab-separated rows. Throws when the file is missing, as the callers expect. */
152
+ export function sqliteRead(db, sql) {
153
+ if (!fs.existsSync(db))
154
+ throw new Error(`missing database: ${db}`);
155
+ const e = engine();
156
+ if (e !== null)
157
+ return toTsv(e.read(db, sql));
158
+ return cliRead(db, sql);
159
+ }
160
+ /** Run a script, creating the file if needed. Stops at the first error, so a failed INSERT leaves no DELETEs. */
161
+ export function sqliteExec(db, sql) {
162
+ const e = engine();
163
+ if (e !== null) {
164
+ e.exec(db, sql);
165
+ return;
166
+ }
167
+ execFileSync('sqlite3', ['-bail', db, sql], { stdio: ['ignore', 'ignore', 'ignore'], timeout: TIMEOUT });
168
+ }
@@ -1,16 +1,16 @@
1
- // One status string every surface renders: host footer bar, CLI menu title, dashboard banner. Nothing else formats mode state.
1
+ // One status string every surface renders: host footer, CLI title, dashboard banner. Nothing else formats mode state.
2
2
  import { getSharedComboState } from './session-state.js';
3
- /** OFF collapses the three modes into one word; a preset or a mix names itself. */
3
+ /** OFF collapses every mode into one word; a preset or a mix names itself. */
4
4
  export function formatStatus(state) {
5
- const { level, caveman, rtk, ponytail } = state;
6
- if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off')
5
+ const { level, caveman, rtk, ponytail, pstack } = state;
6
+ if (level === 'off' && caveman === 'off' && rtk === 'off' && ponytail === 'off' && pstack === 'off')
7
7
  return '🧩 combo OFF';
8
8
  const label = level === 'custom' ? 'CUSTOM' : level.toUpperCase();
9
- return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()}`;
9
+ return `🧩 combo ${label}: 🪨caveman=${caveman.toUpperCase()} ⚡rtk=${rtk.toUpperCase()} 🦥ponytail=${ponytail.toUpperCase()} 🥔pstack=${pstack.toUpperCase()}`;
10
10
  }
11
- // The string upstream's skill tells the model to relay for `/caveman status`.
11
+ // The string upstream's skill tells the model to relay for `/caveman status`; `off` is a mode, so it reads as one.
12
12
  export function formatCavemanStatus(mode) {
13
- return `Caveman mode: ${mode && mode !== 'off' ? mode : 'unknown'}`;
13
+ return `Caveman mode: ${mode || 'unknown'}`;
14
14
  }
15
15
  // Stale ctx access throws, so never let it abort a mode restore.
16
16
  function statusUi(ctx) {
@@ -27,6 +27,7 @@ let remembered;
27
27
  let lastText;
28
28
  let lastUi;
29
29
  // Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
30
+ // One key only: the combo line already names the caveman mode, and a second key renders as a second footer line.
30
31
  export function announceStatus(ctx) {
31
32
  const state = getSharedComboState();
32
33
  const text = formatStatus(state);
@@ -40,5 +41,4 @@ export function announceStatus(ctx) {
40
41
  lastText = text;
41
42
  lastUi = ui;
42
43
  ui.setStatus?.('tersio', text);
43
- ui.setStatus?.('caveman', formatCavemanStatus(state.caveman));
44
44
  }
@@ -1,2 +1,2 @@
1
- // Shared OMP extension host types. Structural — the real `pi` object comes from the OMP runtime; these describe the surface this package touches.
1
+ // Shared OMP extension host types. Structural — the real `pi` object comes from the OMP runtime.
2
2
  export {};