@krtclcdy/tersio 2.25.5 → 2.25.7

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 (231) hide show
  1. package/README.md +42 -38
  2. package/dashboard/dist/index.html +15 -15
  3. package/dist/bench/code-out/pfull-0/debounce.js +13 -0
  4. package/dist/bench/code-out/pfull-0/deepClone.js +18 -0
  5. package/dist/bench/code-out/pfull-0/groupBy.js +15 -0
  6. package/dist/bench/code-out/pfull-1/debounce.js +13 -0
  7. package/dist/bench/code-out/pfull-1/deepClone.js +17 -0
  8. package/dist/bench/code-out/pfull-1/groupBy.js +14 -0
  9. package/dist/bench/code-out/pfull-2/debounce.js +13 -0
  10. package/dist/bench/code-out/pfull-2/deepClone.js +18 -0
  11. package/dist/bench/code-out/pfull-2/groupBy.js +15 -0
  12. package/dist/bench/code-out/pfull-3/debounce.js +12 -0
  13. package/dist/bench/code-out/pfull-3/deepClone.js +17 -0
  14. package/dist/bench/code-out/pfull-3/groupBy.js +7 -0
  15. package/dist/bench/code-out/pfull-4/debounce.js +10 -0
  16. package/dist/bench/code-out/pfull-4/deepClone.js +17 -0
  17. package/dist/bench/code-out/pfull-4/groupBy.js +14 -0
  18. package/dist/bench/code-out/plite-0/debounce.js +13 -0
  19. package/dist/bench/code-out/plite-0/deepClone.js +18 -0
  20. package/dist/bench/code-out/plite-0/groupBy.js +15 -0
  21. package/dist/bench/code-out/plite-1/debounce.js +13 -0
  22. package/dist/bench/code-out/plite-1/deepClone.js +17 -0
  23. package/dist/bench/code-out/plite-1/groupBy.js +11 -0
  24. package/dist/bench/code-out/plite-2/debounce.js +12 -0
  25. package/dist/bench/code-out/plite-2/deepClone.js +17 -0
  26. package/dist/bench/code-out/plite-2/groupBy.js +14 -0
  27. package/dist/bench/code-out/plite-3/debounce.js +12 -0
  28. package/dist/bench/code-out/plite-3/deepClone.js +17 -0
  29. package/dist/bench/code-out/plite-3/groupBy.js +14 -0
  30. package/dist/bench/code-out/plite-4/debounce.js +12 -0
  31. package/dist/bench/code-out/plite-4/deepClone.js +17 -0
  32. package/dist/bench/code-out/plite-4/groupBy.js +14 -0
  33. package/dist/bench/code-out/pultra-0/debounce.js +12 -0
  34. package/dist/bench/code-out/pultra-0/deepClone.js +20 -0
  35. package/dist/bench/code-out/pultra-0/groupBy.js +14 -0
  36. package/dist/bench/code-out/pultra-1/debounce.js +10 -0
  37. package/dist/bench/code-out/pultra-1/deepClone.js +17 -0
  38. package/dist/bench/code-out/pultra-1/groupBy.js +7 -0
  39. package/dist/bench/code-out/pultra-2/debounce.js +13 -0
  40. package/dist/bench/code-out/pultra-2/deepClone.js +18 -0
  41. package/dist/bench/code-out/pultra-2/groupBy.js +15 -0
  42. package/dist/bench/code-out/pultra-3/debounce.js +13 -0
  43. package/dist/bench/code-out/pultra-3/deepClone.js +18 -0
  44. package/dist/bench/code-out/pultra-3/groupBy.js +15 -0
  45. package/dist/bench/code-out/pultra-4/debounce.js +13 -0
  46. package/dist/bench/code-out/pultra-4/deepClone.js +18 -0
  47. package/dist/bench/code-out/pultra-4/groupBy.js +15 -0
  48. package/dist/bench/code-out/run-0/debounce.js +12 -0
  49. package/dist/bench/code-out/run-0/deepClone.js +17 -0
  50. package/dist/bench/code-out/run-0/groupBy.js +14 -0
  51. package/dist/bench/code-out/run-1/debounce.js +13 -0
  52. package/dist/bench/code-out/run-1/deepClone.js +15 -0
  53. package/dist/bench/code-out/run-1/groupBy.js +13 -0
  54. package/dist/bench/code-out/run-2/debounce.js +12 -0
  55. package/dist/bench/code-out/run-2/deepClone.js +18 -0
  56. package/dist/bench/code-out/run-2/groupBy.js +13 -0
  57. package/dist/bench/code-out/run-3/debounce.js +12 -0
  58. package/dist/bench/code-out/run-3/deepClone.js +18 -0
  59. package/dist/bench/code-out/run-3/groupBy.js +14 -0
  60. package/dist/bench/code-out/run-4/debounce.js +12 -0
  61. package/dist/bench/code-out/run-4/deepClone.js +16 -0
  62. package/dist/bench/code-out/run-4/groupBy.js +13 -0
  63. package/dist/cli/backups.js +7 -14
  64. package/dist/cli/banner.js +3 -3
  65. package/dist/cli/common.js +29 -13
  66. package/dist/cli/currency.js +1 -1
  67. package/dist/cli/dashboard.js +66 -35
  68. package/dist/cli/doctor-fix.js +204 -19
  69. package/dist/cli/doctor.js +263 -40
  70. package/dist/cli/hosts.js +25 -6
  71. package/dist/cli/install.js +150 -49
  72. package/dist/cli/interactive.js +3 -3
  73. package/dist/cli/manifest.js +109 -9
  74. package/dist/cli/profile.js +13 -8
  75. package/dist/cli/rtk-wiring.js +1 -1
  76. package/dist/cli/settings.js +20 -5
  77. package/dist/cli/uninstall.js +4 -4
  78. package/dist/cli/update.js +6 -7
  79. package/dist/cli/usage.js +6 -9
  80. package/dist/extensions/caveman/index.js +10 -4
  81. package/dist/extensions/caveman/rule-lite.md +77 -0
  82. package/dist/extensions/caveman/rule-megacave-lite.md +59 -0
  83. package/dist/extensions/caveman/rule-megacave-ultra.md +59 -0
  84. package/dist/extensions/caveman/rule-megacave.md +5 -3
  85. package/dist/extensions/caveman/rule-ultra.md +4 -4
  86. package/dist/extensions/caveman/rule.md +55 -45
  87. package/dist/extensions/combo/index.js +85 -25
  88. package/dist/extensions/commands/index.js +1 -1
  89. package/dist/extensions/pstack/index.js +121 -0
  90. package/dist/extensions/pstack/rule.md +141 -0
  91. package/dist/extensions/rtk/filter.js +0 -1
  92. package/dist/extensions/rtk/session.js +20 -12
  93. package/dist/extensions/server.js +90 -6
  94. package/dist/extensions/shared/agent-stats.js +214 -278
  95. package/dist/extensions/shared/brand-marks.js +33 -34
  96. package/dist/extensions/shared/carbon.js +1 -1
  97. package/dist/extensions/shared/plugin-settings.js +11 -6
  98. package/dist/extensions/shared/pricing.js +8 -16
  99. package/dist/extensions/shared/rtk-eligible.js +84 -0
  100. package/dist/extensions/shared/rtk-gain.js +5 -10
  101. package/dist/extensions/shared/session-state.js +15 -10
  102. package/dist/extensions/shared/sqlite.js +162 -0
  103. package/dist/extensions/shared/status.js +8 -9
  104. package/dist/extensions/shared/types.js +1 -1
  105. package/dist/extensions/shared/usage-ledger.js +155 -61
  106. package/dist/extensions/shared/usage-store.js +111 -52
  107. package/dist/extensions/shared/utils.js +5 -5
  108. package/dist/extensions/timing/index.js +31 -0
  109. package/dist/extensions/updater/index.js +3 -6
  110. package/dist/tersio.js +8 -3
  111. package/extensions/caveman/index.ts +10 -4
  112. package/extensions/caveman/rule-lite.md +77 -0
  113. package/extensions/caveman/rule-megacave-lite.md +59 -0
  114. package/extensions/caveman/rule-megacave-ultra.md +59 -0
  115. package/extensions/caveman/rule-megacave.md +5 -3
  116. package/extensions/caveman/rule-ultra.md +4 -4
  117. package/extensions/caveman/rule.md +55 -45
  118. package/extensions/combo/index.ts +86 -23
  119. package/extensions/commands/index.ts +1 -1
  120. package/extensions/pstack/index.ts +130 -0
  121. package/extensions/pstack/playbooks/authoring-a-skill.md +12 -0
  122. package/extensions/pstack/playbooks/autonomous-run.md +13 -0
  123. package/extensions/pstack/playbooks/autopilot-full.md +13 -0
  124. package/extensions/pstack/playbooks/autopilot-stack.md +16 -0
  125. package/extensions/pstack/playbooks/babysit.md +23 -0
  126. package/extensions/pstack/playbooks/bug-fix.md +15 -0
  127. package/extensions/pstack/playbooks/eval.md +25 -0
  128. package/extensions/pstack/playbooks/feature.md +21 -0
  129. package/extensions/pstack/playbooks/hillclimb.md +21 -0
  130. package/extensions/pstack/playbooks/investigation.md +14 -0
  131. package/extensions/pstack/playbooks/multi-phase-plan.md +155 -0
  132. package/extensions/pstack/playbooks/opening-a-pr.md +34 -0
  133. package/extensions/pstack/playbooks/orchestrate.md +111 -0
  134. package/extensions/pstack/playbooks/pause-safely.md +10 -0
  135. package/extensions/pstack/playbooks/perf-issue.md +25 -0
  136. package/extensions/pstack/playbooks/prototype.md +14 -0
  137. package/extensions/pstack/playbooks/refactoring.md +16 -0
  138. package/extensions/pstack/playbooks/runtime-forensics.md +11 -0
  139. package/extensions/pstack/playbooks/session-pickup.md +11 -0
  140. package/extensions/pstack/playbooks/shipping.md +17 -0
  141. package/extensions/pstack/playbooks/trace-forensics.md +14 -0
  142. package/extensions/pstack/playbooks/visual-parity.md +11 -0
  143. package/extensions/pstack/playbooks/worktree-cleanup.md +12 -0
  144. package/extensions/pstack/principles/attack-the-premise.md +22 -0
  145. package/extensions/pstack/principles/boundary-discipline.md +33 -0
  146. package/extensions/pstack/principles/build-the-lever.md +22 -0
  147. package/extensions/pstack/principles/encode-lessons-in-structure.md +10 -0
  148. package/extensions/pstack/principles/exhaust-the-design-space.md +8 -0
  149. package/extensions/pstack/principles/experience-first.md +18 -0
  150. package/extensions/pstack/principles/explain-the-number.md +16 -0
  151. package/extensions/pstack/principles/fix-root-causes.md +23 -0
  152. package/extensions/pstack/principles/foundational-thinking.md +20 -0
  153. package/extensions/pstack/principles/guard-the-context-window.md +11 -0
  154. package/extensions/pstack/principles/laziness-protocol.md +9 -0
  155. package/extensions/pstack/principles/make-operations-idempotent.md +23 -0
  156. package/extensions/pstack/principles/migrate-callers-then-delete-legacy-apis.md +21 -0
  157. package/extensions/pstack/principles/minimize-reader-load.md +17 -0
  158. package/extensions/pstack/principles/model-the-domain.md +23 -0
  159. package/extensions/pstack/principles/never-block-on-the-human.md +14 -0
  160. package/extensions/pstack/principles/outcome-oriented-execution.md +20 -0
  161. package/extensions/pstack/principles/prove-it-works.md +21 -0
  162. package/extensions/pstack/principles/redesign-from-first-principles.md +15 -0
  163. package/extensions/pstack/principles/separate-before-serializing-shared-state.md +15 -0
  164. package/extensions/pstack/principles/sequence-verifiable-units.md +16 -0
  165. package/extensions/pstack/principles/subtract-before-you-add.md +17 -0
  166. package/extensions/pstack/principles/test-behavior-not-implementation.md +20 -0
  167. package/extensions/pstack/principles/type-system-discipline.md +17 -0
  168. package/extensions/pstack/references/bugbot-triage.md +142 -0
  169. package/extensions/pstack/rule.md +141 -0
  170. package/extensions/pstack/skills/architect/SKILL.md +78 -0
  171. package/extensions/pstack/skills/architect/references/design-red-flags.md +57 -0
  172. package/extensions/pstack/skills/architect/references/rationale-template.md +35 -0
  173. package/extensions/pstack/skills/architect/references/runner-prompt.md +20 -0
  174. package/extensions/pstack/skills/arena/SKILL.md +47 -0
  175. package/extensions/pstack/skills/benchmark-checklist/SKILL.md +38 -0
  176. package/extensions/pstack/skills/blast-radius/SKILL.md +49 -0
  177. package/extensions/pstack/skills/bro/SKILL.md +6 -0
  178. package/extensions/pstack/skills/correct/SKILL.md +31 -0
  179. package/extensions/pstack/skills/figure-it-out/SKILL.md +51 -0
  180. package/extensions/pstack/skills/how/SKILL.md +35 -0
  181. package/extensions/pstack/skills/how/references/explainer-prompt.md +55 -0
  182. package/extensions/pstack/skills/how/references/explorer-prompt.md +35 -0
  183. package/extensions/pstack/skills/interrogate/SKILL.md +73 -0
  184. package/extensions/pstack/skills/interrogate/references/code-quality-review.md +47 -0
  185. package/extensions/pstack/skills/interrogate/references/lead-judgment.md +58 -0
  186. package/extensions/pstack/skills/interrogate/references/reviewer-prompt.md +62 -0
  187. package/extensions/pstack/skills/interrogate/references/rubric.md +77 -0
  188. package/extensions/pstack/skills/no-comments/SKILL.md +23 -0
  189. package/extensions/pstack/skills/reflect/SKILL.md +66 -0
  190. package/extensions/pstack/skills/reflect/references/divergent-reviewer.md +38 -0
  191. package/extensions/pstack/skills/reflect/references/judgment-reviewer.md +37 -0
  192. package/extensions/pstack/skills/reflect/references/synthesizer.md +56 -0
  193. package/extensions/pstack/skills/reflect/references/tooling-reviewer.md +48 -0
  194. package/extensions/pstack/skills/show-me-your-work/SKILL.md +81 -0
  195. package/extensions/pstack/skills/swarm/SKILL.md +45 -0
  196. package/extensions/pstack/skills/tdd/SKILL.md +41 -0
  197. package/extensions/pstack/skills/technical-writing/SKILL.md +105 -0
  198. package/extensions/pstack/skills/unslop/SKILL.md +61 -0
  199. package/extensions/pstack/skills/why/SKILL.md +97 -0
  200. package/extensions/pstack/skills/why/references/epistemics.md +71 -0
  201. package/extensions/pstack/skills/why/references/investigator-prompt.md +84 -0
  202. package/extensions/pstack/skills/why/references/source-playbook.md +17 -0
  203. package/extensions/pstack/skills/why/references/sources/code-archaeology.md +51 -0
  204. package/extensions/pstack/skills/why/references/sources/databricks.md +70 -0
  205. package/extensions/pstack/skills/why/references/sources/datadog.md +97 -0
  206. package/extensions/pstack/skills/why/references/sources/incident-postmortem.md +5 -0
  207. package/extensions/pstack/skills/why/references/sources/linear.md +48 -0
  208. package/extensions/pstack/skills/why/references/sources/notion.md +55 -0
  209. package/extensions/pstack/skills/why/references/sources/sentry.md +66 -0
  210. package/extensions/pstack/skills/why/references/sources/slack.md +17 -0
  211. package/extensions/pstack/skills/why/references/synthesizer-prompt.md +129 -0
  212. package/extensions/rtk/filter.ts +0 -1
  213. package/extensions/rtk/session.ts +19 -11
  214. package/extensions/server.ts +72 -7
  215. package/extensions/shared/agent-stats.ts +226 -278
  216. package/extensions/shared/brand-marks.ts +34 -34
  217. package/extensions/shared/carbon.ts +1 -1
  218. package/extensions/shared/plugin-settings.ts +12 -6
  219. package/extensions/shared/pricing.ts +10 -26
  220. package/extensions/shared/rtk-eligible.ts +83 -0
  221. package/extensions/shared/rtk-gain.ts +5 -10
  222. package/extensions/shared/session-state.ts +18 -13
  223. package/extensions/shared/sqlite.ts +162 -0
  224. package/extensions/shared/status.ts +9 -9
  225. package/extensions/shared/types.ts +2 -1
  226. package/extensions/shared/usage-ledger.ts +157 -66
  227. package/extensions/shared/usage-store.ts +120 -52
  228. package/extensions/shared/utils.ts +5 -5
  229. package/extensions/timing/index.ts +44 -0
  230. package/extensions/updater/index.ts +3 -8
  231. package/package.json +21 -9
@@ -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) {
@@ -26,7 +26,7 @@ function statusUi(ctx) {
26
26
  let remembered;
27
27
  let lastText;
28
28
  let lastUi;
29
- // Paint the host footer bar; same text on the same UI is skipped, a new ctx repaints it.
29
+ // Paint the host footer bar; same text on the same UI is skipped. One key only: the combo line already names the caveman mode, and a second key renders as a second footer line.
30
30
  export function announceStatus(ctx) {
31
31
  const state = getSharedComboState();
32
32
  const text = formatStatus(state);
@@ -40,5 +40,4 @@ export function announceStatus(ctx) {
40
40
  lastText = text;
41
41
  lastUi = ui;
42
42
  ui.setStatus?.('tersio', text);
43
- ui.setStatus?.('caveman', formatCavemanStatus(state.caveman));
44
43
  }
@@ -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 {};
@@ -1,8 +1,10 @@
1
- // Shared usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and the Dashboard. Best-effort; corrupt lines are skipped on read.
1
+ // Usage ledger: append-only JSON lines, read by the CLI, `/tersio`, and the Dashboard. Corrupt lines are skipped.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from './utils.js';
5
4
  import { execFileSync } from 'node:child_process';
5
+ import { homeDir, isPiProcess, piAgentDir, resolveRtkBinary, tersioDataPath } from './utils.js';
6
+ import { eligibleHeads } from './rtk-eligible.js';
7
+ import { sqliteRead } from './sqlite.js';
6
8
  export function ledgerPath() {
7
9
  const override = process.env.TERSIO_USAGE_FILE;
8
10
  if (override)
@@ -50,7 +52,7 @@ function stampMs(ts) {
50
52
  export function durOf(v) {
51
53
  return typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined;
52
54
  }
53
- // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded, which is deliberately not 0.
55
+ // Measured cost: usage.cost.total, or a bare number in old fixtures. Anything else means unrecorded, not 0.
54
56
  export function costOf(usage) {
55
57
  const c = usage.cost;
56
58
  if (typeof c === 'number')
@@ -87,7 +89,7 @@ export function sessionsDirs() {
87
89
  const override = process.env.TERSIO_SESSIONS_DIR;
88
90
  if (override)
89
91
  return [override];
90
- // Both hosts, not just the running one: a session on pi is missing from the dashboard entirely if we stop at the first directory that exists.
92
+ // Both hosts, not just the running one: a pi session is missing if we stop at the first existing directory.
91
93
  const dirs = isPiProcess()
92
94
  ? [path.join(piAgentDir(), 'sessions'), path.join(homeDir(), '.omp', 'agent', 'sessions')]
93
95
  : [path.join(homeDir(), '.omp', 'agent', 'sessions'), path.join(piAgentDir(), 'sessions')];
@@ -129,22 +131,20 @@ export function opencodeDbPath() {
129
131
  catch { /* fall through to the platform default */ }
130
132
  return path.join(homeDir(), 'Library', 'Application Support', 'opencode', 'opencode.db');
131
133
  }
132
- // Two on-disk shapes: legacy `message` (role/modelID/providerID live inside `data`),
133
- // current `session_message` (role in the `type` column, model under data.model).
134
- // Scalars only: the `data` blob holds newlines and tabs, which the sqlite3 CLI would split.
134
+ // `message` vs `session_message`: read scalars only, since the blob's newlines break the row-per-line output.
135
135
  const OC_DB_ORDER = ' ORDER BY time_created;';
136
- const OC_DB_COLUMNS = 'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.completed\'),' +
136
+ const OC_DB_COLUMNS = 'json_extract(data,\'$.time.created\'), json_extract(data,\'$.time.streamed\'), json_extract(data,\'$.time.completed\'),' +
137
137
  ' json_extract(data,\'$.tokens.input\'), json_extract(data,\'$.tokens.output\'),' +
138
138
  ' json_extract(data,\'$.tokens.cache.read\'), json_extract(data,\'$.tokens.cache.write\'), json_extract(data,\'$.cost\')';
139
- const ocDbLegacy = (since) => `SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}` +
139
+ const OC_DB_TOOLS = '(SELECT group_concat(json_extract(j.value,\'$.name\')) FROM json_each(data,\'$.content\') j' +
140
+ ' WHERE json_extract(j.value,\'$.type\')=\'tool\')';
141
+ const ocDbLegacy = (since) => `SELECT id, time_created, json_extract(data,'$.role'), json_extract(data,'$.providerID'), json_extract(data,'$.modelID'), ${OC_DB_COLUMNS}, ${OC_DB_TOOLS}` +
140
142
  ` FROM message WHERE json_extract(data,'$.role')='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
141
- const ocDbCurrent = (since) => `SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}` +
143
+ const ocDbCurrent = (since) => `SELECT id, time_created, 'assistant', json_extract(data,'$.model.providerID'), json_extract(data,'$.model.id'), ${OC_DB_COLUMNS}, ${OC_DB_TOOLS}` +
142
144
  ` FROM session_message WHERE type='assistant' AND time_created > ${since}${OC_DB_ORDER}`;
143
145
  // A re-scan window covers tokens that land after the row was created, so a late fill is not lost.
144
146
  export const OPENCODE_DB_OVERLAP_MS = 3_600_000;
145
- // execFileSync caps output at 1MB; a busy table clears it in one SELECT.
146
- export const SQLITE_READ_BUFFER = 50 * 1024 * 1024;
147
- // Empty when sqlite3 is absent or the db is unreadable; that is a sync skip, not a failure.
147
+ // Empty when no SQLite is available or the db is unreadable; that is a sync skip, not a failure.
148
148
  export function readOpencodeDbRows(db, sinceMs) {
149
149
  if (!Number.isFinite(sinceMs))
150
150
  return [];
@@ -153,7 +153,7 @@ export function readOpencodeDbRows(db, sinceMs) {
153
153
  return [];
154
154
  const since = Math.max(0, Math.floor(sinceMs));
155
155
  // Separate calls: a db holding only one table must not cancel the other.
156
- const run = (sql) => execFileSync('sqlite3', ['-separator', '\t', '-readonly', db, sql], { encoding: 'utf8', timeout: 30000, stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: SQLITE_READ_BUFFER });
156
+ const run = (sql) => sqliteRead(db, sql);
157
157
  let out = '';
158
158
  try {
159
159
  out += run(ocDbLegacy(since));
@@ -167,17 +167,18 @@ export function readOpencodeDbRows(db, sinceMs) {
167
167
  for (const line of out.split('\n')) {
168
168
  if (!line.trim())
169
169
  continue;
170
- const [id, created, role, providerID, modelID, tCreated, tCompleted, input, output, cacheRead, cacheWrite, cost] = line.split('\t');
170
+ const [id, created, role, providerID, modelID, tCreated, tStreamed, tCompleted, input, output, cacheRead, cacheWrite, cost, tools] = line.split('\t');
171
171
  const n = (v) => (v !== undefined && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined);
172
172
  rows.push({
173
173
  id,
174
174
  created: Number(created),
175
+ tools: tools ?? '',
175
176
  row: {
176
177
  role,
177
178
  modelID,
178
179
  providerID,
179
180
  tokens: { input: n(input), output: n(output), cache: { read: n(cacheRead), write: n(cacheWrite) } },
180
- time: { created: n(tCreated), completed: n(tCompleted) },
181
+ time: { created: n(tCreated), streamed: n(tStreamed), completed: n(tCompleted) },
181
182
  cost: n(cost),
182
183
  },
183
184
  });
@@ -196,6 +197,60 @@ export function dayKey(ts) {
196
197
  const p = (n) => String(n).padStart(2, '0');
197
198
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
198
199
  }
200
+ // OpenCode tool parts carry the command in the message blob; sqliteRead is line-based, so newlines are flattened in SQL.
201
+ const OC_BASH_CMD = "replace(replace(json_extract(j.value,'$.state.input.command'), char(10), ' '), char(13), ' ')";
202
+ const OC_BASH_TOOL = "json_extract(j.value,'$.type')='tool' AND (json_extract(j.value,'$.name')='bash' OR json_extract(j.value,'$.name')='shell')";
203
+ const ocBashLegacy = `SELECT message.id, json_extract(message.data,'$.sessionID'), ${OC_BASH_CMD} FROM message, json_each(message.data,'$.content') j WHERE json_extract(message.data,'$.role')='assistant' AND ${OC_BASH_TOOL}`;
204
+ const ocBashCurrent = `SELECT session_message.id, session_message.session_id, ${OC_BASH_CMD} FROM session_message, json_each(session_message.data,'$.content') j WHERE session_message.type='assistant' AND ${OC_BASH_TOOL}`;
205
+ /** Every executed bash command in an OpenCode db, v2 first and the legacy table second. */
206
+ export function readOpencodeBashRows(db) {
207
+ const rows = [];
208
+ try {
209
+ if (!fs.existsSync(db))
210
+ return rows;
211
+ let out = '';
212
+ try {
213
+ out += sqliteRead(db, ocBashCurrent);
214
+ }
215
+ catch { /* no v2 table */ }
216
+ try {
217
+ out += sqliteRead(db, ocBashLegacy);
218
+ }
219
+ catch { /* no legacy table */ }
220
+ for (const line of out.split('\n')) {
221
+ if (!line.trim())
222
+ continue;
223
+ const [id, sessionId, command] = line.split('\t');
224
+ if (typeof command !== 'string' || !command.trim())
225
+ continue;
226
+ rows.push({ id: id ?? '', sessionId: sessionId ?? '', command });
227
+ }
228
+ }
229
+ catch { /* an unreadable db is a skip, not a failure */ }
230
+ return rows;
231
+ }
232
+ // Pre-v2 OpenCode stores one message per JSON file; the same tool-part shape holds the command.
233
+ function opencodeFileBash(file) {
234
+ try {
235
+ const message = JSON.parse(fs.readFileSync(file, 'utf8'));
236
+ const parts = Array.isArray(message.content) ? message.content : [];
237
+ const commands = [];
238
+ for (const part of parts) {
239
+ const p = part;
240
+ if (p?.type !== 'tool')
241
+ continue;
242
+ if (p.name !== 'bash' && p.name !== 'shell')
243
+ continue;
244
+ const command = p.state?.input?.command;
245
+ if (typeof command === 'string' && command)
246
+ commands.push(command);
247
+ }
248
+ return { commands, sessionId: typeof message.sessionID === 'string' ? message.sessionID : '' };
249
+ }
250
+ catch {
251
+ return { commands: [], sessionId: '' };
252
+ }
253
+ }
199
254
  export function walkJsonl(dir, out, cap, ext = '.jsonl') {
200
255
  let entries;
201
256
  try {
@@ -217,15 +272,10 @@ export function walkJsonl(dir, out, cap, ext = '.jsonl') {
217
272
  // Recent rows are a newest-first window over lifetime history; aggregates keep everything.
218
273
  export const RECENT_LIMIT = 2000;
219
274
  const FREE_SUFFIX = /(?::free|-free)$/i;
220
- const RTK_ELIGIBLE_HEADS = new Set([
221
- 'rtk', 'git', 'grep', 'rg', 'find', 'cat', 'ls', 'tree', 'diff', 'log',
222
- 'npm', 'npx', 'pnpm', 'bun', 'bunx', 'cargo', 'go', 'python', 'pytest',
223
- 'ruff', 'mypy', 'docker', 'kubectl', 'psql', 'aws', 'gh', 'glab', 'wc',
224
- ]);
225
275
  export function newSessionAccum() {
226
276
  return { byModel: {}, byDay: {}, byDayModel: {}, byTool: {}, byModelMessages: {}, byHost: {}, byAgent: {}, byAgentMessages: {}, byModelMeasured: {}, byModelCostMessages: {}, totals: zeroBreakdown(), messages: 0, costMeasured: 0, recent: [], recentFloor: Number.POSITIVE_INFINITY };
227
277
  }
228
- export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id, agent) {
278
+ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames, host, id, agent, ttftMs) {
229
279
  const watermark = readResetWatermark();
230
280
  const ms = ts === undefined ? NaN : typeof ts === 'number' ? ts : Date.parse(ts);
231
281
  if (watermark > 0 && (!Number.isFinite(ms) || ms < watermark))
@@ -250,11 +300,9 @@ export function ingestSessionRow(accum, model, usage, ts, durMs, run, toolNames,
250
300
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? Math.floor(v) : 0);
251
301
  if (Number.isFinite(ms)) {
252
302
  const outcome = run ?? { st: 'completed' };
253
- // The store holds every message ever recorded, but only the newest few are ever read. Skip
254
- // anything older than the floor, and re-read the floor when the list is full, so a 50k-row
255
- // store costs 2000 rows of memory instead of 50k.
303
+ // Only the newest rows are read: skip below the floor, and a 50k-row store costs 2000 rows of memory.
256
304
  if (accum.recent.length < RECENT_LIMIT || ms >= accum.recentFloor) {
257
- accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), h: host, cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note, id });
305
+ accum.recent.push({ m: key, i: num(usage.input), o: num(usage.output), t: ms, d: durOf(durMs), tf: durOf(ttftMs), h: host, cr: num(usage.cacheRead), cw: num(usage.cacheWrite), usd: costOf(usage), st: outcome.st, code: outcome.code, note: outcome.note, id });
258
306
  if (accum.recent.length > RECENT_LIMIT) {
259
307
  let drop = 0;
260
308
  for (let k = 1; k < accum.recent.length; k += 1)
@@ -326,7 +374,7 @@ export function classifySessionLine(row) {
326
374
  const command = part.arguments?.command;
327
375
  tools.push(`bash:${leadBinary(command)}`);
328
376
  }
329
- return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, run: statusOf(msg), tools };
377
+ return { kind: 'assistant_row', model, usage: msg.usage, durMs: typeof msg.duration === 'number' ? msg.duration : computedDur, ttftMs: typeof msg.ttft === 'number' ? msg.ttft : undefined, run: statusOf(msg), tools };
330
378
  }
331
379
  // True when at least one token bucket holds a positive finite count.
332
380
  export function hasPositiveUsage(usage) {
@@ -337,6 +385,25 @@ export function hasPositiveUsage(usage) {
337
385
  }
338
386
  return false;
339
387
  }
388
+ /** Tool names an OpenCode message called; v2 passes SQL's comma-joined string as the blob holds newlines. */
389
+ export function opencodeToolNames(message, joined) {
390
+ if (joined !== undefined)
391
+ return joined === '' ? [] : joined.split(',');
392
+ const parts = message?.content;
393
+ if (!Array.isArray(parts))
394
+ return [];
395
+ const names = [];
396
+ for (const part of parts) {
397
+ if (!part || typeof part !== 'object')
398
+ continue;
399
+ if (part.type !== 'tool')
400
+ continue;
401
+ const name = part.name;
402
+ if (typeof name === 'string' && name)
403
+ names.push(name);
404
+ }
405
+ return names;
406
+ }
340
407
  // One OpenCode message file; assistant rows carry the token buckets.
341
408
  export function classifyOpencodeMessage(obj) {
342
409
  if (!obj || typeof obj !== 'object' || obj.role !== 'assistant')
@@ -358,14 +425,16 @@ export function classifyOpencodeMessage(obj) {
358
425
  const num = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
359
426
  const created = num(obj.time?.created);
360
427
  const completed = num(obj.time?.completed);
428
+ // `streamed` is when the first token arrived, so it is the host's own first-token latency.
429
+ const streamed = num(obj.time?.streamed);
361
430
  return {
362
431
  model: `opencode/${provider}/${model}`,
363
432
  usage,
364
433
  ms: created,
365
434
  durMs: created !== undefined && completed !== undefined && completed > created ? completed - created : undefined,
435
+ ttftMs: created !== undefined && streamed !== undefined && streamed > created ? streamed - created : undefined,
366
436
  };
367
437
  }
368
- // One OpenCode message file into the shared accum. The msg id is the file name.
369
438
  export function processOpencodeFile(accum, text, file) {
370
439
  let obj;
371
440
  try {
@@ -374,18 +443,19 @@ export function processOpencodeFile(accum, text, file) {
374
443
  catch {
375
444
  return;
376
445
  }
377
- const parsed = classifyOpencodeMessage(obj);
446
+ const message = obj;
447
+ const parsed = classifyOpencodeMessage(message);
378
448
  if (!parsed)
379
449
  return;
380
450
  const id = file ? path.basename(file).replace(/\.[^.]+$/, '') || undefined : undefined;
381
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', id, 'main');
451
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, opencodeToolNames(message), 'opencode', id, 'main', parsed.ttftMs);
382
452
  }
383
453
  // One v2 message row into the shared accum. The msg id is the row primary key.
384
454
  export function ingestOpencodeDbRow(accum, row) {
385
455
  const parsed = classifyOpencodeMessage(row.row);
386
456
  if (!parsed)
387
457
  return;
388
- ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, undefined, 'opencode', row.id, 'main');
458
+ ingestSessionRow(accum, parsed.model, parsed.usage, parsed.ms, parsed.durMs, { st: 'completed' }, opencodeToolNames(row.row, row.tools), 'opencode', row.id, 'main', parsed.ttftMs);
389
459
  }
390
460
  // Fold `:free`/`-free` suffixes and case variants into one chart key.
391
461
  export function canonicalModelId(model) {
@@ -396,8 +466,7 @@ export function messageRowId(row) {
396
466
  const id = row.id;
397
467
  return typeof id === 'string' && id ? id : undefined;
398
468
  }
399
- // Vendor names mirror the dashboard map; CLI needs names only. Family first:
400
- // gateway segments shadow it (minimaxai holds xai, gemma rides nvidia).
469
+ // Vendor names mirror the dashboard map; family first, so gateway segments shadow it (minimaxai holds xai).
401
470
  const VENDOR_RES = [
402
471
  [/inclusionai|ling-/i, 'InclusionAI'],
403
472
  [/minimax/i, 'MiniMax'],
@@ -533,9 +602,7 @@ export function hostOfSessionFile(file) {
533
602
  return 'opencode';
534
603
  return 'pi';
535
604
  }
536
- // Which agent ran a session file, from its own `session` row: a transcript with a parent
537
- // session is a subagent's, and everything else is the main agent. The row is the host's own
538
- // record, so a mirrored transcript carries the same answer as the stats database.
605
+ // A transcript with a parent session ran as a subagent; the host's own row, so a mirror answers the same.
539
606
  export function agentOfSessionText(text) {
540
607
  if (!text.includes('parentSession'))
541
608
  return 'main';
@@ -552,7 +619,17 @@ export function agentOfSessionText(text) {
552
619
  return 'main';
553
620
  }
554
621
  const adoptionFileCache = new Map();
555
- function parseAdoptionFile(file) {
622
+ function countBashCommands(commands, eligible, counts) {
623
+ for (const command of commands) {
624
+ counts.bashCalls += 1;
625
+ const head = leadBinary(command);
626
+ if (eligible.has(head))
627
+ counts.eligibleCalls += 1;
628
+ if (head === 'rtk')
629
+ counts.rtkCalls += 1;
630
+ }
631
+ }
632
+ function parseAdoptionFile(file, eligible) {
556
633
  const counts = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
557
634
  let text;
558
635
  try {
@@ -561,7 +638,7 @@ function parseAdoptionFile(file) {
561
638
  catch {
562
639
  return { counts, sessions: 0 };
563
640
  }
564
- let sawBash = false;
641
+ const commands = [];
565
642
  for (const line of text.split('\n')) {
566
643
  if (!line.includes('tool_execution_start'))
567
644
  continue;
@@ -570,24 +647,22 @@ function parseAdoptionFile(file) {
570
647
  if (row.type !== 'custom' || row.customType !== 'tool_execution_start' || row.data?.toolName !== 'bash')
571
648
  continue;
572
649
  const command = row.data.args?.command;
573
- if (typeof command !== 'string')
574
- continue;
575
- sawBash = true;
576
- counts.bashCalls += 1;
577
- const head = leadBinary(command);
578
- if (RTK_ELIGIBLE_HEADS.has(head))
579
- counts.eligibleCalls += 1;
580
- if (head === 'rtk')
581
- counts.rtkCalls += 1;
650
+ if (typeof command === 'string')
651
+ commands.push(command);
582
652
  }
583
653
  catch { /* skip corrupt line */ }
584
654
  }
585
- return { counts, sessions: sawBash ? 1 : 0 };
655
+ countBashCommands(commands, eligible, counts);
656
+ return { counts, sessions: commands.length > 0 ? 1 : 0 };
586
657
  }
587
658
  export function clearRtkAdoptionCache() {
588
659
  adoptionFileCache.clear();
589
660
  }
590
661
  export function readRtkAdoption() {
662
+ // The eligible set comes from rtk's rewrite contract, cached per binary version.
663
+ const eligible = eligibleHeads(resolveRtkBinary());
664
+ // A cached count is only valid for the head set that produced it.
665
+ const headsKey = [...eligible].sort().join(',');
591
666
  const files = [];
592
667
  for (const dir of sessionsDirs())
593
668
  walkJsonl(dir, files, 2000);
@@ -609,9 +684,9 @@ export function readRtkAdoption() {
609
684
  continue;
610
685
  }
611
686
  let entry = adoptionFileCache.get(file);
612
- if (!entry || entry.size !== stat.size || entry.mtimeMs !== stat.mtimeMs) {
613
- const parsed = parseAdoptionFile(file);
614
- entry = { size: stat.size, mtimeMs: stat.mtimeMs, counts: parsed.counts };
687
+ if (!entry || entry.size !== stat.size || entry.mtimeMs !== stat.mtimeMs || entry.heads !== headsKey) {
688
+ const parsed = parseAdoptionFile(file, eligible);
689
+ entry = { size: stat.size, mtimeMs: stat.mtimeMs, heads: headsKey, counts: parsed.counts };
615
690
  adoptionFileCache.set(file, entry);
616
691
  sessions += parsed.sessions;
617
692
  }
@@ -622,6 +697,30 @@ export function readRtkAdoption() {
622
697
  eligibleCalls += entry.counts.eligibleCalls;
623
698
  rtkCalls += entry.counts.rtkCalls;
624
699
  }
700
+ // OpenCode keeps its own store: a v2 sqlite db, or one JSON file per message before v2.
701
+ const opencode = { bashCalls: 0, eligibleCalls: 0, rtkCalls: 0 };
702
+ const opencodeSessions = new Set();
703
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DB !== undefined) {
704
+ for (const row of readOpencodeBashRows(opencodeDbPath())) {
705
+ countBashCommands([row.command], eligible, opencode);
706
+ if (row.sessionId)
707
+ opencodeSessions.add(row.sessionId);
708
+ }
709
+ }
710
+ const ocFiles = [];
711
+ if (process.env.TERSIO_SESSIONS_DIR === undefined || process.env.TERSIO_OPENCODE_DIR !== undefined) {
712
+ walkJsonl(opencodeSessionsDir(), ocFiles, 5000, '.json');
713
+ for (const file of ocFiles) {
714
+ const parsed = opencodeFileBash(file);
715
+ countBashCommands(parsed.commands, eligible, opencode);
716
+ if (parsed.sessionId)
717
+ opencodeSessions.add(parsed.sessionId);
718
+ }
719
+ }
720
+ bashCalls += opencode.bashCalls;
721
+ eligibleCalls += opencode.eligibleCalls;
722
+ rtkCalls += opencode.rtkCalls;
723
+ sessions += opencodeSessions.size;
625
724
  const missedCalls = Math.max(0, eligibleCalls - rtkCalls);
626
725
  return { sessions, bashCalls, eligibleCalls, rtkCalls, missedCalls, adoptionPct: eligibleCalls ? (rtkCalls / eligibleCalls) * 100 : 0 };
627
726
  }
@@ -652,7 +751,7 @@ export function processSessionText(accum, state, text, host, agent) {
652
751
  }
653
752
  if (parsed.kind !== 'assistant_row' || !parsed.model || !parsed.usage)
654
753
  continue;
655
- ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid, agent);
754
+ ingestSessionRow(accum, parsed.model, parsed.usage, row.timestamp, parsed.durMs, parsed.run, parsed.tools, host, rid, agent, parsed.ttftMs);
656
755
  }
657
756
  catch { /* skip corrupt lines */ }
658
757
  }
@@ -696,16 +795,12 @@ export function leadBinary(command) {
696
795
  }
697
796
  return 'bash';
698
797
  }
699
- // --- Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table) ---
798
+ // Pricing lives in ./pricing.ts (live LiteLLM cache + fallback table).
700
799
  import { DEFAULT_PRICE, PRICE_TIERS_FIRM, canonicalPriceId, priceFor, pricesOverridePath, refreshPricesIfStale } from './pricing.js';
701
800
  import { co2GramsFor, energyWhFor } from './carbon.js';
702
801
  export { DEFAULT_PRICE, PRICE_TIERS_FIRM, priceFor, pricesOverridePath, refreshPricesIfStale };
703
802
  export { co2GramsFor, energyWhFor };
704
- /**
705
- * Price one model's tokens. `measured` is the cost the host reported for those tokens; it outranks
706
- * every rate card, because it is the only figure from the party that billed. Pass undefined rather
707
- * than 0 for a cost nobody reported: `costOf` keeps the two apart.
708
- */
803
+ /** Price one model's tokens; `measured` outranks every rate card, so pass undefined when unreported. */
709
804
  export function usdCost(t, model, measured) {
710
805
  const m = 1 / 1_000_000;
711
806
  if (measured !== undefined) {
@@ -727,8 +822,7 @@ export function usdCost(t, model, measured) {
727
822
  };
728
823
  return {
729
824
  usd: buckets.input + buckets.output + buckets.cacheRead + buckets.cacheWrite,
730
- // A tier the caller cannot stand behind is not a price. `tail` and `default` are estimates,
731
- // and a report that sums them beside measured cost reads as a bill it is not.
825
+ // `tail`/`default` are estimates; summing them beside measured cost reads as a bill it is not.
732
826
  priced: PRICE_TIERS_FIRM.includes(hit.tier),
733
827
  tier: hit.tier,
734
828
  buckets,
@@ -759,7 +853,7 @@ export function readUsage() {
759
853
  }
760
854
  return rows;
761
855
  }
762
- // Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database are never touched.
856
+ // Delete the ledger file and return the rows cleared. Tersio-owned data only: transcripts and the RTK database stay.
763
857
  export function clearUsageLedger() {
764
858
  const rows = readUsage().length;
765
859
  try {