@dohmmit/rtk 0.1.0
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/LICENSE +21 -0
- package/README.md +51 -0
- package/dist/config.d.ts +29 -0
- package/dist/config.js +85 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +231 -0
- package/dist/stats.d.ts +38 -0
- package/dist/stats.js +76 -0
- package/package.json +34 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# @dohmmit/rtk
|
|
2
|
+
|
|
3
|
+
OpenCode plugin that integrates [rtk](https://github.com/rtk-ai/rtk) to transparently rewrite bash commands for 60-90% token savings.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @dohmmit/rtk
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Then add to your `opencode.json`:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"plugin": ["@dohmmit/rtk"]
|
|
16
|
+
}
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
**Prerequisite:** [rtk](https://github.com/rtk-ai/rtk) must be installed (>= 0.23.0).
|
|
20
|
+
|
|
21
|
+
## Configuration
|
|
22
|
+
|
|
23
|
+
Config file at `~/.config/opencode/rtk-wrapper-config.json` (auto-created on first load):
|
|
24
|
+
|
|
25
|
+
```json
|
|
26
|
+
{
|
|
27
|
+
"version": 1,
|
|
28
|
+
"excludeCommands": ["rtk", "cd"],
|
|
29
|
+
"settings": {
|
|
30
|
+
"ultraCompact": false
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
- **excludeCommands:** Commands that pass through unrewritten. Default: `["rtk", "cd"]`
|
|
36
|
+
- **settings.ultraCompact:** Append `--ultra-compact` to all rewritten commands
|
|
37
|
+
|
|
38
|
+
## Example
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
# Without rtk plugin: raw git output (50+ lines)
|
|
42
|
+
git status
|
|
43
|
+
|
|
44
|
+
# With rtk plugin: automatically becomes
|
|
45
|
+
rtk git status
|
|
46
|
+
# Output: "3 modified, 1 untracked" (one line)
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## License
|
|
50
|
+
|
|
51
|
+
MIT
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Config shape contract per D-01. Changing this is a file-format migration. */
|
|
2
|
+
export interface RtkConfig {
|
|
3
|
+
version: number;
|
|
4
|
+
excludeCommands: string[];
|
|
5
|
+
settings: Record<string, unknown>;
|
|
6
|
+
}
|
|
7
|
+
/** D-02: Sensible defaults — zero-config for 90% of users. */
|
|
8
|
+
export declare const DEFAULTS: RtkConfig;
|
|
9
|
+
/** Cross-platform config path via Node.js built-ins (PLAT-02). */
|
|
10
|
+
export declare function getConfigPath(): string;
|
|
11
|
+
/**
|
|
12
|
+
* Validate and sanitize parsed JSON into an RtkConfig.
|
|
13
|
+
* Forward-compat: ignores unknown top-level keys.
|
|
14
|
+
* open question #1 resolution: trims excludeCommands entries.
|
|
15
|
+
*/
|
|
16
|
+
export declare function validateConfig(raw: unknown, _configPath: string): RtkConfig;
|
|
17
|
+
/**
|
|
18
|
+
* Load the config file or create it with sensible defaults.
|
|
19
|
+
*
|
|
20
|
+
* CONF-04: Resolves ~/.config/opencode/rtk-wrapper-config.json via
|
|
21
|
+
* os.homedir() + path.join(). Auto-creates parent directories and file
|
|
22
|
+
* on first access (QUAL-02 — zero-config works out of the box).
|
|
23
|
+
*
|
|
24
|
+
* D-03: On ANY error (malformed JSON, permissions, etc.), log a single
|
|
25
|
+
* warning, overwrite with fresh defaults, and continue. No partial-salvage.
|
|
26
|
+
*
|
|
27
|
+
* PLAT-02: Uses only Node.js built-ins — no bash, jq, or shell calls.
|
|
28
|
+
*/
|
|
29
|
+
export declare function loadOrCreateConfig(): RtkConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
/** D-02: Sensible defaults — zero-config for 90% of users. */
|
|
5
|
+
export const DEFAULTS = {
|
|
6
|
+
version: 1,
|
|
7
|
+
excludeCommands: ["rtk", "cd"],
|
|
8
|
+
settings: {},
|
|
9
|
+
};
|
|
10
|
+
/** Cross-platform config path via Node.js built-ins (PLAT-02). */
|
|
11
|
+
export function getConfigPath() {
|
|
12
|
+
return join(homedir(), ".config", "opencode", "rtk-wrapper-config.json");
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Validate and sanitize parsed JSON into an RtkConfig.
|
|
16
|
+
* Forward-compat: ignores unknown top-level keys.
|
|
17
|
+
* open question #1 resolution: trims excludeCommands entries.
|
|
18
|
+
*/
|
|
19
|
+
export function validateConfig(raw, _configPath) {
|
|
20
|
+
if (!raw || typeof raw !== "object") {
|
|
21
|
+
throw new SyntaxError("Config must be a JSON object");
|
|
22
|
+
}
|
|
23
|
+
const obj = raw;
|
|
24
|
+
const version = typeof obj.version === "number" ? obj.version : 1;
|
|
25
|
+
const excludeCommands = [];
|
|
26
|
+
if (Array.isArray(obj.excludeCommands)) {
|
|
27
|
+
for (const item of obj.excludeCommands) {
|
|
28
|
+
if (typeof item === "string") {
|
|
29
|
+
const trimmed = item.trim();
|
|
30
|
+
if (trimmed.length > 0) {
|
|
31
|
+
excludeCommands.push(trimmed);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const settings = obj.settings && typeof obj.settings === "object"
|
|
37
|
+
? { ...obj.settings }
|
|
38
|
+
: {};
|
|
39
|
+
return { version, excludeCommands, settings };
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Load the config file or create it with sensible defaults.
|
|
43
|
+
*
|
|
44
|
+
* CONF-04: Resolves ~/.config/opencode/rtk-wrapper-config.json via
|
|
45
|
+
* os.homedir() + path.join(). Auto-creates parent directories and file
|
|
46
|
+
* on first access (QUAL-02 — zero-config works out of the box).
|
|
47
|
+
*
|
|
48
|
+
* D-03: On ANY error (malformed JSON, permissions, etc.), log a single
|
|
49
|
+
* warning, overwrite with fresh defaults, and continue. No partial-salvage.
|
|
50
|
+
*
|
|
51
|
+
* PLAT-02: Uses only Node.js built-ins — no bash, jq, or shell calls.
|
|
52
|
+
*/
|
|
53
|
+
export function loadOrCreateConfig() {
|
|
54
|
+
const configPath = getConfigPath();
|
|
55
|
+
if (!existsSync(configPath)) {
|
|
56
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
57
|
+
writeFileSync(configPath, JSON.stringify(DEFAULTS, null, 2), "utf-8");
|
|
58
|
+
return { ...DEFAULTS };
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
62
|
+
const parsed = JSON.parse(raw);
|
|
63
|
+
return validateConfig(parsed, configPath);
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
// Distinguish parse failures from I/O failures in the log message
|
|
67
|
+
// (RESEARCH.md Pitfall 1: catch both SyntaxError and EACCES/EISDIR).
|
|
68
|
+
if (err instanceof SyntaxError) {
|
|
69
|
+
console.warn(`[rtk] Malformed config at ${configPath}: ${err.message}. ` +
|
|
70
|
+
"Recreating with defaults.");
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
console.warn(`[rtk] Cannot read config at ${configPath}: ` +
|
|
74
|
+
`${err.message}. Using defaults.`);
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
78
|
+
writeFileSync(configPath, JSON.stringify(DEFAULTS, null, 2), "utf-8");
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Recovery failed — still return defaults (e.g., config path is a directory)
|
|
82
|
+
}
|
|
83
|
+
return { ...DEFAULTS };
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { tool } from "@opencode-ai/plugin";
|
|
2
|
+
import { loadOrCreateConfig } from "./config.js";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { StatsTracker, formatSessionStats, formatCompactionSummary } from "./stats.js";
|
|
6
|
+
export const RtkPlugin = async ({ $, client }) => {
|
|
7
|
+
// Load config BEFORE init-time rtk check (D-04).
|
|
8
|
+
// Auto-creates at ~/.config/opencode/rtk-wrapper-config.json on first load.
|
|
9
|
+
const config = loadOrCreateConfig();
|
|
10
|
+
const excludeSet = new Set(config.excludeCommands);
|
|
11
|
+
// Init-time rtk availability and version check.
|
|
12
|
+
// Uses `rtk rewrite --help` rather than `rtk --version` — this verifies
|
|
13
|
+
// both that rtk is installed AND that it supports the `rewrite` subcommand
|
|
14
|
+
// (requires rtk >= 0.23.0). If rtk is absent, too old, or the subprocess
|
|
15
|
+
// fails, return {} (empty hooks) — the plugin is a no-op and other plugins
|
|
16
|
+
// continue to load.
|
|
17
|
+
const rtkCheck = await $ `rtk rewrite --help`.quiet().nothrow();
|
|
18
|
+
if (rtkCheck.exitCode !== 0) {
|
|
19
|
+
const configPath = join(homedir(), ".config", "opencode", "rtk-wrapper-config.json");
|
|
20
|
+
console.warn("[rtk] rtk binary not found or too old (requires rtk >= 0.23.0 for rewrite support). " +
|
|
21
|
+
"Commands will pass through unchanged. " +
|
|
22
|
+
`Config: ${configPath} — add excluded commands here. ` +
|
|
23
|
+
"Install: cargo install rtk or visit https://github.com/rtk-ai/rtk");
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
// Per-command result cache — prevents redundant subprocess spawns for
|
|
27
|
+
// repeated commands. QUAL-01: <1ms on cache hit.
|
|
28
|
+
// Scoped inside the plugin function so the cache is only allocated when
|
|
29
|
+
// rtk is confirmed available.
|
|
30
|
+
const cache = new Map();
|
|
31
|
+
// D-01: Stats tracker — closure-scoped, only allocated when rtk is
|
|
32
|
+
// confirmed available. Tracks per-command rewrite counts and cumulative
|
|
33
|
+
// token savings.
|
|
34
|
+
const stats = new StatsTracker();
|
|
35
|
+
// D-03: Gain pre-snapshot map — stores rtk gain total_saved before
|
|
36
|
+
// command execution, keyed by input.callID. Cleaned up after every
|
|
37
|
+
// after-hook lookup to prevent unbounded growth.
|
|
38
|
+
const gainPreSnapshots = new Map();
|
|
39
|
+
// D-05: Gain failure counter — incremented when rtk gain subprocess
|
|
40
|
+
// fails (non-zero exit or exception). Tracked for observability but
|
|
41
|
+
// never blocks execution.
|
|
42
|
+
const gainFailures = { count: 0 };
|
|
43
|
+
// D-04: Debug-level log helper. Uses client.app.log() when client is
|
|
44
|
+
// available; falls back to console.debug() otherwise.
|
|
45
|
+
const logDebug = (msg) => {
|
|
46
|
+
if (client) {
|
|
47
|
+
client.app.log({
|
|
48
|
+
body: { service: "rtk", level: "debug", message: msg },
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
console.debug(msg);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
return {
|
|
56
|
+
"tool.execute.before": async (input, output) => {
|
|
57
|
+
// Guard 1 — tool type. Only intercept bash/shell tool calls.
|
|
58
|
+
// All other tool types (read, write, grep, glob, edit, etc.)
|
|
59
|
+
// pass through unchanged.
|
|
60
|
+
const tool = String(input?.tool ?? "").toLowerCase();
|
|
61
|
+
if (tool !== "bash" && tool !== "shell") {
|
|
62
|
+
// FEED-01: Debug-level passthrough log for non-bash tools
|
|
63
|
+
logDebug(`[rtk] passthrough: '${tool}' (non-bash tool)`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
// Guard 2 — args existence. Defensive: output.args should always
|
|
67
|
+
// exist for bash tool calls, but guard against undefined/null/non-object.
|
|
68
|
+
const args = output?.args;
|
|
69
|
+
if (!args || typeof args !== "object")
|
|
70
|
+
return;
|
|
71
|
+
// Guard 3 — command presence. Extract the command string.
|
|
72
|
+
// Empty/absent commands pass through — nothing to rewrite.
|
|
73
|
+
const command = args.command;
|
|
74
|
+
if (typeof command !== "string" || !command) {
|
|
75
|
+
// FEED-01: Debug-level passthrough log for empty commands
|
|
76
|
+
logDebug("[rtk] passthrough: empty command");
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
// Guard 4 — config exclusion list. Commands matching the user's
|
|
80
|
+
// excludeCommands skip rewriting entirely (no cache lookup, no
|
|
81
|
+
// subprocess spawn). D-02: defaults are ["rtk", "cd"]. rtk prevents
|
|
82
|
+
// double-prefixing at config level (cheaper than reaching subprocess).
|
|
83
|
+
// cd is a pure shell builtin that rtk has no filter for.
|
|
84
|
+
if (excludeSet.has(command)) {
|
|
85
|
+
// FEED-01: Debug-level passthrough log for excluded commands
|
|
86
|
+
logDebug(`[rtk] passthrough: '${command}' (excluded)`);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Cache hit — use cached result, skip subprocess spawn.
|
|
90
|
+
// Only successful rewrites are cached (cache.set is after the
|
|
91
|
+
// mutation guard below), so a cache hit means a known rewrite.
|
|
92
|
+
const cached = cache.get(command);
|
|
93
|
+
if (cached !== undefined) {
|
|
94
|
+
;
|
|
95
|
+
args.command = cached;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
// Call rtk rewrite as a subprocess. Use Bun's $ shell API
|
|
99
|
+
// with .quiet() (suppress stdout passthrough) and .nothrow()
|
|
100
|
+
// (never throw on non-zero exit). Wrap in try/catch for any
|
|
101
|
+
// unexpected failures — pass command through unchanged.
|
|
102
|
+
// The catch block is deliberately silent — logging in the hot
|
|
103
|
+
// path is noise. The init-time detection above already warns
|
|
104
|
+
// once at startup.
|
|
105
|
+
try {
|
|
106
|
+
const result = await $ `rtk rewrite ${command}`.quiet().nothrow();
|
|
107
|
+
const rewritten = String(result.stdout).trim();
|
|
108
|
+
// Exit code non-zero OR empty output means no rewrite
|
|
109
|
+
// available — pass command through unchanged.
|
|
110
|
+
// Empty output guard: never set args.command to an empty
|
|
111
|
+
// string (PITFALLS.md security section: undefined behavior).
|
|
112
|
+
if (result.exitCode !== 0 || !rewritten || rewritten.length === 0)
|
|
113
|
+
return;
|
|
114
|
+
// PITFALLS.md Pitfall 4: mutate args.command in-place only.
|
|
115
|
+
// Never replace the entire args object — that would drop
|
|
116
|
+
// timeout, workdir, and description fields.
|
|
117
|
+
// PITFALLS.md Pitfall 2: compare before mutating — the
|
|
118
|
+
// rewritten !== command guard prevents double-wrapping.
|
|
119
|
+
if (rewritten !== command) {
|
|
120
|
+
// D-08: Ultra-compact mode (CONF-03).
|
|
121
|
+
// Append BEFORE cache.set() and BEFORE args.command mutation
|
|
122
|
+
// (Pitfall 5: cache MUST store the full rewritten command
|
|
123
|
+
// including the ultra-compact modifier).
|
|
124
|
+
const ultraCompact = Boolean(config.settings.ultraCompact);
|
|
125
|
+
const finalCommand = ultraCompact
|
|
126
|
+
? rewritten + " --ultra-compact"
|
|
127
|
+
: rewritten;
|
|
128
|
+
args.command = finalCommand;
|
|
129
|
+
// Cache ONLY on successful rewrite. If rtk rewrite returned
|
|
130
|
+
// empty or exit code 1, do not cache (future calls may
|
|
131
|
+
// succeed if rtk is updated or the command context changes).
|
|
132
|
+
cache.set(command, finalCommand);
|
|
133
|
+
// FEED-02 / D-03: Capture pre-execution gain snapshot.
|
|
134
|
+
// Uses rtk gain --format json (NOT --bytes — RESEARCH.md Pitfall 1).
|
|
135
|
+
// Stored keyed by input.callID for delta computation in after hook.
|
|
136
|
+
try {
|
|
137
|
+
const preResult = await $ `rtk gain --format json`.quiet().nothrow();
|
|
138
|
+
if (preResult.exitCode === 0) {
|
|
139
|
+
const pre = JSON.parse(String(preResult.stdout)).summary.total_saved;
|
|
140
|
+
gainPreSnapshots.set(input.callID, pre);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
// Gain subprocess failure → no pre-snapshot stored.
|
|
145
|
+
// Delta will be 0 for this call. Never block tool execution.
|
|
146
|
+
}
|
|
147
|
+
// FEED-01: Structured logging at info level.
|
|
148
|
+
// D-04: null-guarded — if client is absent, silently skip.
|
|
149
|
+
// info-level has no console fallback.
|
|
150
|
+
client?.app.log({
|
|
151
|
+
body: {
|
|
152
|
+
service: "rtk",
|
|
153
|
+
level: "info",
|
|
154
|
+
message: `[rtk] rewrote '${command}' → '${finalCommand}'`,
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// rtk rewrite subprocess failed (crash, timeout, missing
|
|
161
|
+
// binary). Pass command through unchanged — never block
|
|
162
|
+
// tool execution because of a failed rewrite. No logging
|
|
163
|
+
// in hot path — the init-time check already warned at startup.
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
// D-03: tool.execute.after — full pre/post gain delta pipeline.
|
|
167
|
+
// Replaces the tracer's simple stats.record(command, 0) with real
|
|
168
|
+
// gain measurements from rtk gain --format json snapshots.
|
|
169
|
+
// FEED-02: Accumulates accurate token savings per command.
|
|
170
|
+
"tool.execute.after": async (input, _output) => {
|
|
171
|
+
const tool = String(input?.tool ?? "").toLowerCase();
|
|
172
|
+
if (tool !== "bash" && tool !== "shell")
|
|
173
|
+
return;
|
|
174
|
+
const args = input?.args;
|
|
175
|
+
if (!args || typeof args !== "object")
|
|
176
|
+
return;
|
|
177
|
+
const command = args.command;
|
|
178
|
+
if (typeof command !== "string" || !command)
|
|
179
|
+
return;
|
|
180
|
+
// Look up pre-snapshot and clean up regardless of outcome
|
|
181
|
+
const preTotal = gainPreSnapshots.get(input.callID);
|
|
182
|
+
gainPreSnapshots.delete(input.callID);
|
|
183
|
+
let delta = 0;
|
|
184
|
+
try {
|
|
185
|
+
const postResult = await $ `rtk gain --format json`.quiet().nothrow();
|
|
186
|
+
if (postResult.exitCode === 0) {
|
|
187
|
+
const post = JSON.parse(String(postResult.stdout)).summary.total_saved;
|
|
188
|
+
if (preTotal !== undefined) {
|
|
189
|
+
// D-03 Pitfall 3: clamp negative deltas to 0
|
|
190
|
+
// (pre/post mismatch where post < pre — e.g., gain reset)
|
|
191
|
+
delta = Math.max(0, post - preTotal);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
else {
|
|
195
|
+
gainFailures.count++;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
gainFailures.count++;
|
|
200
|
+
}
|
|
201
|
+
// Extract the base command (first word) for aggregation.
|
|
202
|
+
// "rtk git status" → "rtk", "git status" → "git"
|
|
203
|
+
const baseCommand = command.split(" ")[0];
|
|
204
|
+
if (preTotal !== undefined) {
|
|
205
|
+
// Rewrite was attempted — record with real delta
|
|
206
|
+
stats.record(baseCommand, delta);
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
// FEED-03: rtk_gain custom LLM tool (D-06, D-07).
|
|
210
|
+
// Stateless — reads from closure-scoped stats, no side effects.
|
|
211
|
+
tool: {
|
|
212
|
+
rtk_gain: tool({
|
|
213
|
+
description: "Show cumulative rtk token savings for the current session. " +
|
|
214
|
+
"Returns a formatted table of per-command rewrite counts and tokens saved.",
|
|
215
|
+
args: {},
|
|
216
|
+
async execute(_args, _ctx) {
|
|
217
|
+
return formatSessionStats(stats);
|
|
218
|
+
},
|
|
219
|
+
}),
|
|
220
|
+
},
|
|
221
|
+
// FEED-04: Session compaction savings summary (D-09).
|
|
222
|
+
// If the hook is never called at runtime (API may not fire in all
|
|
223
|
+
// OpenCode versions), rtk_gain remains the primary interface.
|
|
224
|
+
"experimental.session.compacting": async (_input, output) => {
|
|
225
|
+
const summary = formatCompactionSummary(stats);
|
|
226
|
+
if (summary) {
|
|
227
|
+
output.context.push(summary);
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
};
|
|
231
|
+
};
|
package/dist/stats.d.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closure-scoped stats tracking for the rtk plugin.
|
|
3
|
+
*
|
|
4
|
+
* D-01: Allocated inside the plugin function closure, only when rtk is
|
|
5
|
+
* confirmed available at init. Never exposed to module-level globals.
|
|
6
|
+
*
|
|
7
|
+
* D-02: Track two metrics per command — `count` (times rewritten) and
|
|
8
|
+
* `tokensSaved` (cumulative rtk gain delta). Both are positive integers.
|
|
9
|
+
*/
|
|
10
|
+
export declare class StatsTracker {
|
|
11
|
+
private data;
|
|
12
|
+
/**
|
|
13
|
+
* Record a command rewrite.
|
|
14
|
+
* Multiple calls for the same command aggregate count and tokensSaved.
|
|
15
|
+
*/
|
|
16
|
+
record(command: string, tokensSaved: number): void;
|
|
17
|
+
/** Yield all tracked entries for read-only iteration. */
|
|
18
|
+
entries(): IterableIterator<[string, {
|
|
19
|
+
count: number;
|
|
20
|
+
tokensSaved: number;
|
|
21
|
+
}]>;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* D-07: Format session stats as a Markdown table.
|
|
25
|
+
*
|
|
26
|
+
* Columns: Command, Rewrites, Tokens Saved.
|
|
27
|
+
* Rows sorted by tokensSaved descending. Total row at bottom.
|
|
28
|
+
*
|
|
29
|
+
* Returns "No commands rewritten this session." if stats is empty.
|
|
30
|
+
*/
|
|
31
|
+
export declare function formatSessionStats(stats: StatsTracker): string;
|
|
32
|
+
/**
|
|
33
|
+
* One-line session compaction summary.
|
|
34
|
+
*
|
|
35
|
+
* Format: "[rtk] This session: N commands rewritten, N tokens saved (~N bytes)"
|
|
36
|
+
* Returns "" if no rewrites recorded.
|
|
37
|
+
*/
|
|
38
|
+
export declare function formatCompactionSummary(stats: StatsTracker): string;
|
package/dist/stats.js
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Closure-scoped stats tracking for the rtk plugin.
|
|
3
|
+
*
|
|
4
|
+
* D-01: Allocated inside the plugin function closure, only when rtk is
|
|
5
|
+
* confirmed available at init. Never exposed to module-level globals.
|
|
6
|
+
*
|
|
7
|
+
* D-02: Track two metrics per command — `count` (times rewritten) and
|
|
8
|
+
* `tokensSaved` (cumulative rtk gain delta). Both are positive integers.
|
|
9
|
+
*/
|
|
10
|
+
export class StatsTracker {
|
|
11
|
+
data = new Map();
|
|
12
|
+
/**
|
|
13
|
+
* Record a command rewrite.
|
|
14
|
+
* Multiple calls for the same command aggregate count and tokensSaved.
|
|
15
|
+
*/
|
|
16
|
+
record(command, tokensSaved) {
|
|
17
|
+
const existing = this.data.get(command);
|
|
18
|
+
if (existing) {
|
|
19
|
+
existing.count++;
|
|
20
|
+
existing.tokensSaved += tokensSaved;
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
this.data.set(command, { count: 1, tokensSaved });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/** Yield all tracked entries for read-only iteration. */
|
|
27
|
+
entries() {
|
|
28
|
+
return this.data.entries();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* D-07: Format session stats as a Markdown table.
|
|
33
|
+
*
|
|
34
|
+
* Columns: Command, Rewrites, Tokens Saved.
|
|
35
|
+
* Rows sorted by tokensSaved descending. Total row at bottom.
|
|
36
|
+
*
|
|
37
|
+
* Returns "No commands rewritten this session." if stats is empty.
|
|
38
|
+
*/
|
|
39
|
+
export function formatSessionStats(stats) {
|
|
40
|
+
const entries = Array.from(stats.entries())
|
|
41
|
+
.filter(([_, v]) => v.count > 0)
|
|
42
|
+
.sort((a, b) => b[1].tokensSaved - a[1].tokensSaved);
|
|
43
|
+
if (entries.length === 0)
|
|
44
|
+
return "No commands rewritten this session.";
|
|
45
|
+
let totalRewrites = 0;
|
|
46
|
+
let totalTokens = 0;
|
|
47
|
+
const rows = entries.map(([cmd, { count, tokensSaved }]) => {
|
|
48
|
+
totalRewrites += count;
|
|
49
|
+
totalTokens += tokensSaved;
|
|
50
|
+
return `| ${cmd} | ${count.toLocaleString()} | ${tokensSaved.toLocaleString()} |`;
|
|
51
|
+
});
|
|
52
|
+
return [
|
|
53
|
+
"| Command | Rewrites | Tokens Saved |",
|
|
54
|
+
"|---------|---------|-------------|",
|
|
55
|
+
...rows,
|
|
56
|
+
`| **Total** | **${totalRewrites.toLocaleString()}** | **${totalTokens.toLocaleString()}** |`,
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* One-line session compaction summary.
|
|
61
|
+
*
|
|
62
|
+
* Format: "[rtk] This session: N commands rewritten, N tokens saved (~N bytes)"
|
|
63
|
+
* Returns "" if no rewrites recorded.
|
|
64
|
+
*/
|
|
65
|
+
export function formatCompactionSummary(stats) {
|
|
66
|
+
let totalRewrites = 0;
|
|
67
|
+
let totalTokens = 0;
|
|
68
|
+
for (const [, { count, tokensSaved }] of stats.entries()) {
|
|
69
|
+
totalRewrites += count;
|
|
70
|
+
totalTokens += tokensSaved;
|
|
71
|
+
}
|
|
72
|
+
if (totalRewrites === 0)
|
|
73
|
+
return "";
|
|
74
|
+
return (`[rtk] This session: ${totalRewrites.toLocaleString()} commands rewritten, ` +
|
|
75
|
+
`${totalTokens.toLocaleString()} tokens saved (~${Math.round(totalTokens / 4).toLocaleString()} bytes)`);
|
|
76
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@dohmmit/rtk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin that integrates rtk to transparently rewrite bash commands for token savings",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"import": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist"
|
|
16
|
+
],
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/user/opencode-rtk.git"
|
|
21
|
+
},
|
|
22
|
+
"keywords": ["opencode", "rtk", "plugin", "tokens"],
|
|
23
|
+
"scripts": {
|
|
24
|
+
"build": "tsc",
|
|
25
|
+
"typecheck": "tsc --noEmit",
|
|
26
|
+
"test": "node --experimental-strip-types --test tests/*.test.ts",
|
|
27
|
+
"prepublishOnly": "npm test"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@opencode-ai/plugin": "^1.18.4",
|
|
31
|
+
"@types/node": "^26.1.1",
|
|
32
|
+
"typescript": "^5.9.3"
|
|
33
|
+
}
|
|
34
|
+
}
|