opencode-adaptive-snip 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.
@@ -0,0 +1,23 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ import type { AnalyzeConfig, CommandPair, LLMAnalysisResponse } from "./types.js";
3
+ import { SnipRuleStore } from "./store.js";
4
+ type OpenCodeClient = PluginInput["client"];
5
+ export declare class LearnAnalyzer {
6
+ private config;
7
+ private store;
8
+ private client;
9
+ private buffer;
10
+ private lastAnalysisAt;
11
+ private inFlight;
12
+ constructor(config: AnalyzeConfig, store: SnipRuleStore, client: OpenCodeClient);
13
+ addPair(pair: CommandPair): void;
14
+ shouldAnalyze(): boolean;
15
+ analyze(): Promise<LLMAnalysisResponse>;
16
+ private runAnalysis;
17
+ private buildClassifierSystemPrompt;
18
+ private buildRuleCrafterSystemPrompt;
19
+ private withSession;
20
+ buildUserPrompt(pairs?: CommandPair[]): string;
21
+ get bufferSize(): number;
22
+ }
23
+ export {};
@@ -0,0 +1,333 @@
1
+ import { ANALYSIS_JSON_SCHEMA, CLASSIFIER_JSON_SCHEMA } from "./types.js";
2
+ import { SnipRuleStore } from "./store.js";
3
+ const SENSITIVE_COMMAND_RE = /^(?:cat|less|more|head|tail|sed|awk|env|printenv|git\s+(?:show|grep)|git\s+diff(?!\s+--stat\b))\b/;
4
+ function redactSensitiveText(text) {
5
+ return text
6
+ .replace(/\b(Bearer\s+)[A-Za-z0-9._~+/-]+=*/gi, "$1[REDACTED]")
7
+ .replace(/\bsk-[A-Za-z0-9_-]{16,}\b/g, "sk-[REDACTED]")
8
+ .replace(/\b(api[_-]?key|token|secret|password|authorization)(\s*[:=]\s*)([^\s"'`]+)/gi, "$1$2[REDACTED]");
9
+ }
10
+ function shouldCollectPair(pair) {
11
+ return !SENSITIVE_COMMAND_RE.test(pair.command.trimStart());
12
+ }
13
+ export class LearnAnalyzer {
14
+ config;
15
+ store;
16
+ client;
17
+ buffer = [];
18
+ lastAnalysisAt = 0;
19
+ inFlight = null;
20
+ constructor(config, store, client) {
21
+ this.config = config;
22
+ this.store = store;
23
+ this.client = client;
24
+ }
25
+ addPair(pair) {
26
+ if (!shouldCollectPair(pair))
27
+ return;
28
+ this.buffer.push(pair);
29
+ }
30
+ shouldAnalyze() {
31
+ return (!this.inFlight &&
32
+ this.buffer.length >= this.config.batchSize &&
33
+ Date.now() - this.lastAnalysisAt >=
34
+ this.config.cooldownMinutes * 60 * 1000);
35
+ }
36
+ async analyze() {
37
+ if (this.inFlight)
38
+ return this.inFlight;
39
+ this.inFlight = this.runAnalysis().finally(() => {
40
+ this.inFlight = null;
41
+ });
42
+ return this.inFlight;
43
+ }
44
+ async runAnalysis() {
45
+ if (this.buffer.length === 0) {
46
+ return { rules: [] };
47
+ }
48
+ const pairs = this.buffer.slice(0, this.config.batchSize);
49
+ const classifierResult = await this.withSession("snip-classifier", async (sessionId) => {
50
+ const body = {
51
+ system: this.buildClassifierSystemPrompt(),
52
+ parts: [{ type: "text", text: this.buildUserPrompt(pairs) }],
53
+ format: { type: "json_schema", schema: CLASSIFIER_JSON_SCHEMA },
54
+ };
55
+ if (this.config.llmModel) {
56
+ body.model = this.config.llmModel;
57
+ }
58
+ const result = await this.client.session.prompt({
59
+ path: { id: sessionId },
60
+ body: body,
61
+ });
62
+ if (!result.data) {
63
+ throw new Error("Classifier prompt returned no data");
64
+ }
65
+ const textPart = result.data.parts?.find((p) => p.type === "text");
66
+ if (!textPart?.text) {
67
+ throw new Error("No text part in classifier response");
68
+ }
69
+ const parsed = JSON.parse(textPart.text);
70
+ if (!parsed || !Array.isArray(parsed.decisions)) {
71
+ throw new Error("Classifier returned invalid JSON");
72
+ }
73
+ return parsed;
74
+ });
75
+ if (!classifierResult.ok) {
76
+ this.lastAnalysisAt = Date.now();
77
+ return { rules: [] };
78
+ }
79
+ const yesCommands = new Set(classifierResult.value.decisions.filter((d) => d.shouldSnip).map((d) => d.command));
80
+ if (yesCommands.size === 0) {
81
+ this.buffer = this.buffer.slice(pairs.length);
82
+ this.lastAnalysisAt = Date.now();
83
+ return { rules: [] };
84
+ }
85
+ const yesPairs = pairs.filter((p) => yesCommands.has(p.command));
86
+ const crafterResult = await this.withSession("snip-rule-crafter", async (sessionId) => {
87
+ const body = {
88
+ system: this.buildRuleCrafterSystemPrompt(),
89
+ parts: [{ type: "text", text: this.buildUserPrompt(yesPairs) }],
90
+ format: { type: "json_schema", schema: ANALYSIS_JSON_SCHEMA },
91
+ };
92
+ if (this.config.llmModel) {
93
+ body.model = this.config.llmModel;
94
+ }
95
+ const result = await this.client.session.prompt({
96
+ path: { id: sessionId },
97
+ body: body,
98
+ });
99
+ if (!result.data) {
100
+ throw new Error("Rule crafter prompt returned no data");
101
+ }
102
+ const textPart = result.data.parts?.find((p) => p.type === "text");
103
+ if (!textPart?.text) {
104
+ throw new Error("No text part in rule crafter response");
105
+ }
106
+ const parsed = JSON.parse(textPart.text);
107
+ if (!parsed || !Array.isArray(parsed.rules)) {
108
+ throw new Error("Rule crafter returned invalid JSON");
109
+ }
110
+ return parsed;
111
+ });
112
+ if (!crafterResult.ok) {
113
+ this.lastAnalysisAt = Date.now();
114
+ return { rules: [] };
115
+ }
116
+ if (this.config.autoLearn) {
117
+ await this.store.mergeLearned(crafterResult.value.rules, this.config.minConfidence, this.config.maxRules);
118
+ }
119
+ this.buffer = this.buffer.slice(pairs.length);
120
+ this.lastAnalysisAt = Date.now();
121
+ return crafterResult.value;
122
+ }
123
+ buildClassifierSystemPrompt() {
124
+ return `[ROLE]
125
+ You are a shell command classifier. Your only task is to decide whether each
126
+ command-output pair would benefit from "snip" preprocessing — a tool that
127
+ filters and summarizes shell command output to reduce token usage for LLMs.
128
+
129
+ [TASK]
130
+ For each command-output pair below, classify it as:
131
+ - shouldSnip=true: Output is verbose, repetitive, or large enough to warrant snip
132
+ - shouldSnip=false: Output is short, essential, or already concise
133
+
134
+ [CONSTRAINTS]
135
+
136
+ SHOULD SNIP (return true) when output is:
137
+ 1. Very long (> 1000 lines or > 50000 characters)
138
+ 2. Highly repetitive (test results, build logs, linter warnings)
139
+ 3. Contains progress bars, ANSI codes, spinners, verbose debug output
140
+ 4. Would consume significant LLM context (> 10000 tokens)
141
+
142
+ MUST NOT SNIP (return false) when output is:
143
+ 1. Short and informative (< 500 characters)
144
+ 2. Essential for LLM task (file contents, git diffs, error stacks)
145
+ 3. Already concise, well-structured, low-token
146
+ 4. Shell builtins (cd, export, echo, pwd, alias, source, type, unset, read, ulimit)
147
+
148
+ [OUTPUT FORMAT]
149
+ Return ONLY valid JSON. Start with {, end with }. No markdown fences.
150
+ {
151
+ "decisions": [
152
+ { "command": "...", "shouldSnip": true, "reason": "..." }
153
+ ]
154
+ }
155
+
156
+ [FEW-SHOT EXAMPLES]
157
+
158
+ Example 1 — Short informative output (NO snip):
159
+ Input:
160
+ ---
161
+ Command: git diff --stat
162
+ Output (first 5000 chars):
163
+ src/auth.ts | 12 +++++++++---
164
+ src/config.ts | 5 +++--
165
+ 2 files changed, 14 insertions(+), 5 deletions(-)
166
+ Duration: 300ms
167
+ ---
168
+ Expected:
169
+ {"decisions":[{"command":"git diff --stat","shouldSnip":false,"reason":"Short diff stat output, ~100 chars, essential for understanding changes"}]}
170
+
171
+ Example 2 — Verbose test output (SNIP):
172
+ Input:
173
+ ---
174
+ Command: npm test
175
+ Output (first 5000 chars):
176
+ > myapp@1.0.0 test
177
+ > jest --verbose
178
+
179
+ PASS src/auth.test.ts
180
+ ✓ should login (45ms)
181
+ ✓ should logout (12ms)
182
+ ✓ should reject invalid token (8ms)
183
+ ... (1200 more lines of test output)
184
+ Duration: 45000ms
185
+ ---
186
+ Expected:
187
+ {"decisions":[{"command":"npm test","shouldSnip":true,"reason":"1200+ lines of test output, highly repetitive, 45s duration"}]}
188
+
189
+ [INSTRUCTION]
190
+ Analyze each command-output pair. Be conservative — if unsure, return false.
191
+ Return exactly one decision per input pair. Start your response with {"decisions":`;
192
+ }
193
+ buildRuleCrafterSystemPrompt() {
194
+ return `[ROLE]
195
+ You are a regex pattern crafter specializing in shell command matching.
196
+ Given commands that have been classified as needing "snip" preprocessing,
197
+ create precise regex patterns and snip prefix configurations.
198
+
199
+ [TASK]
200
+ For each command that needs snip, create:
201
+ 1. A regex pattern that matches the command (and similar variants)
202
+ 2. A snip prefix with appropriate timeout
203
+ 3. A confidence score
204
+
205
+ [SNIP PREFIX RULES]
206
+ - Default for most commands: "snip"
207
+ - Slow commands (tests, builds, compilations): "snip --timeout 120"
208
+ - Very slow commands (cargo, mvn, docker build): "snip --timeout 180"
209
+ - Git commands: "snip" (no timeout needed)
210
+
211
+ [REGEX PATTERN RULES]
212
+ - ALWAYS anchor with ^ (start of string)
213
+ - Match command name and common subcommands with alternation: ^cmd (sub1|sub2)
214
+ - Use simple groups, avoid complex lookaheads/backreferences
215
+ - Do NOT overfit to exact arguments (^(npm test).* is WRONG)
216
+ - Escape special chars: \\+ \\| \\. \\* \\? \\[ \\] \\( \\)
217
+ - CORRECT: ^go (test|build|vet|lint)
218
+ - CORRECT: ^npm (install|test|run build)
219
+ - CORRECT: ^cargo (test|build|clippy)
220
+ - INCORRECT: ^go test .* (overfit, matches exact args)
221
+ - INCORRECT: go test (missing ^ anchor)
222
+ - INCORRECT: ^npm test -v (overfit to flags)
223
+
224
+ [CONFIDENCE]
225
+ - 0.90-1.00: Output is clearly, consistently, and severely excessive
226
+ - 0.70-0.89: Likely excessive with some ambiguity
227
+ - 0.50-0.69: Possibly useful but uncertain — include if evidence supports it
228
+ - BELOW 0.50: MUST NOT output. Omit from rules array entirely.
229
+
230
+ [OUTPUT FORMAT]
231
+ Return ONLY valid JSON. Start with {, end with }. No markdown fences.
232
+ {
233
+ "rules": [
234
+ {
235
+ "pattern": "regex pattern here",
236
+ "snip": "snip prefix here",
237
+ "confidence": 0.95,
238
+ "description": "why this rule was created"
239
+ }
240
+ ]
241
+ }
242
+
243
+ [FEW-SHOT EXAMPLES]
244
+
245
+ Example 1 — Verbose test runner:
246
+ Input:
247
+ ---
248
+ Command: npm test
249
+ Output (first 5000 chars):
250
+ > myapp@1.0.0 test
251
+ > jest --verbose
252
+
253
+ PASS src/auth.test.ts
254
+ ✓ should login (45ms)
255
+ ✓ should logout (12ms)
256
+ ✓ should reject invalid token (8ms)
257
+ ... (1200 more lines of test output)
258
+ Duration: 45000ms
259
+ ---
260
+ Expected:
261
+ {"rules":[{"pattern":"^npm test","snip":"snip --timeout 120","confidence":0.95,"description":"npm test produces 1200+ lines of test runner output, duration 45s"}]}
262
+
263
+ Example 2 — Build system:
264
+ Input:
265
+ ---
266
+ Command: cargo build
267
+ Output (first 5000 chars):
268
+ Compiling serde v1.0.210
269
+ Compiling tokio v1.42.0
270
+ Compiling myapp v0.1.0 (/home/user/myapp)
271
+ ... (800 more lines of compilation output)
272
+ warning: unused import: \`std::mem\`
273
+ --> src/main.rs:3:5
274
+ |
275
+ 3 | use std::mem;
276
+ | ^^^^^^^^
277
+ |
278
+ = note: \`#[warn(unused_imports)]\` on by default
279
+ Duration: 120000ms
280
+ ---
281
+ Expected:
282
+ {"rules":[{"pattern":"^cargo build","snip":"snip --timeout 180","confidence":0.98,"description":"cargo build produces 800+ lines of compilation output with warnings, duration 120s"}]}
283
+
284
+ [INSTRUCTION]
285
+ For each command, craft ONE precise regex rule. Be conservative — if unsure about confidence, use lower value.
286
+ Start your response with {"rules":`;
287
+ }
288
+ async withSession(title, fn) {
289
+ let session = null;
290
+ try {
291
+ session = await this.client.session.create({
292
+ body: { title },
293
+ });
294
+ if (!session.data?.id) {
295
+ console.warn(`[adaptive-snip] ${title} session creation returned no ID`);
296
+ return { ok: false };
297
+ }
298
+ return { ok: true, value: await fn(session.data.id) };
299
+ }
300
+ catch (e) {
301
+ console.warn(`[adaptive-snip] ${title} session failed:`, e);
302
+ return { ok: false };
303
+ }
304
+ finally {
305
+ if (session?.data?.id) {
306
+ try {
307
+ await this.client.session.delete({ path: { id: session.data.id } });
308
+ }
309
+ catch (e) {
310
+ console.warn(`[adaptive-snip] Failed to cleanup ${title} session:`, e);
311
+ }
312
+ }
313
+ }
314
+ }
315
+ buildUserPrompt(pairs) {
316
+ const limit = this.config.batchSize;
317
+ const source = pairs ?? this.buffer;
318
+ const selected = source.slice(0, limit);
319
+ let prompt = "";
320
+ for (const pair of selected) {
321
+ const output = pair.output.length > 5000
322
+ ? pair.output.slice(0, 5000)
323
+ : pair.output;
324
+ const command = redactSensitiveText(pair.command);
325
+ const truncatedOutput = redactSensitiveText(output);
326
+ prompt += `---\nCommand: ${command}\nOutput (first 5000 chars):\n${truncatedOutput}\nDuration: ${pair.duration}ms\n---\n\n`;
327
+ }
328
+ return prompt;
329
+ }
330
+ get bufferSize() {
331
+ return this.buffer.length;
332
+ }
333
+ }
@@ -0,0 +1,2 @@
1
+ import type { AdaptiveSnipOptions, NormalizedAdaptiveSnipOptions } from "./types.js";
2
+ export declare function normalizeConfig(options?: Partial<AdaptiveSnipOptions>, projectDir?: string): Promise<NormalizedAdaptiveSnipOptions>;
package/dist/config.js ADDED
@@ -0,0 +1,81 @@
1
+ import { join, relative, resolve } from "node:path";
2
+ const DEFAULT_ANALYZE = {
3
+ enabled: true,
4
+ autoLearn: true,
5
+ batchSize: 20,
6
+ minConfidence: 0.7,
7
+ maxRules: 50,
8
+ cooldownMinutes: 60,
9
+ };
10
+ const DISABLED_ANALYZE = {
11
+ enabled: false,
12
+ autoLearn: false,
13
+ batchSize: 20,
14
+ minConfidence: 0.7,
15
+ maxRules: 50,
16
+ cooldownMinutes: 60,
17
+ };
18
+ const DEFAULT_RULE_FILE = ".opencode/snip-rules.json";
19
+ /** Auto-discovered config file name in project root */
20
+ const DEFAULT_CONFIG_FILE = "adaptive-snip.json";
21
+ const DEFAULT_OPTIONS_PARTIAL = {
22
+ analyze: undefined,
23
+ rules: [],
24
+ fallback: undefined,
25
+ ruleFile: DEFAULT_RULE_FILE,
26
+ };
27
+ function normalizeAnalyze(analyze) {
28
+ if (analyze === true)
29
+ return { ...DEFAULT_ANALYZE };
30
+ if (analyze === false)
31
+ return { ...DISABLED_ANALYZE };
32
+ if (analyze === undefined)
33
+ return { ...DISABLED_ANALYZE };
34
+ const merged = { ...DEFAULT_ANALYZE, ...analyze };
35
+ if (merged.autoLearn && !merged.enabled) {
36
+ console.warn("[adaptive-snip] analyze.autoLearn=true but analyze.enabled=false; forcing autoLearn=false");
37
+ merged.autoLearn = false;
38
+ }
39
+ return merged;
40
+ }
41
+ async function loadConfigFile(projectDir) {
42
+ const fullPath = join(projectDir, DEFAULT_CONFIG_FILE);
43
+ try {
44
+ const file = Bun.file(fullPath);
45
+ return await file.json();
46
+ }
47
+ catch (e) {
48
+ const err = e;
49
+ if (err?.code === "ENOENT")
50
+ return {};
51
+ console.warn(`[adaptive-snip] Failed to load ${DEFAULT_CONFIG_FILE}: ${e}`);
52
+ return {};
53
+ }
54
+ }
55
+ function resolveRuleFile(ruleFile, projectDir) {
56
+ if (!projectDir)
57
+ return ruleFile;
58
+ const root = resolve(projectDir);
59
+ const fullPath = resolve(root, ruleFile);
60
+ const rel = relative(root, fullPath);
61
+ if (rel === "" || (!rel.startsWith("..") && !rel.startsWith("/") && rel !== "..")) {
62
+ return ruleFile;
63
+ }
64
+ console.warn(`[adaptive-snip] Ignoring ruleFile outside project directory: ${ruleFile}`);
65
+ return DEFAULT_RULE_FILE;
66
+ }
67
+ export async function normalizeConfig(options, projectDir) {
68
+ const fileConfig = projectDir
69
+ ? await loadConfigFile(projectDir)
70
+ : {};
71
+ const merged = {
72
+ ...DEFAULT_OPTIONS_PARTIAL,
73
+ ...fileConfig,
74
+ ...(options ?? {}),
75
+ };
76
+ const analyze = normalizeAnalyze(merged.analyze);
77
+ const rules = [...(merged.rules ?? [])];
78
+ const fallback = merged.fallback === null ? null : (merged.fallback ?? undefined);
79
+ const ruleFile = resolveRuleFile(merged.ruleFile ?? DEFAULT_RULE_FILE, projectDir);
80
+ return { analyze, rules, fallback, ruleFile };
81
+ }
@@ -0,0 +1,3 @@
1
+ import type { Hooks } from "@opencode-ai/plugin";
2
+ import { LearnAnalyzer } from "./analyze.js";
3
+ export declare function createEventListener(analyzer: LearnAnalyzer | null): NonNullable<Hooks["event"]>;
package/dist/events.js ADDED
@@ -0,0 +1,57 @@
1
+ import { LearnAnalyzer } from "./analyze.js";
2
+ export function createEventListener(analyzer) {
3
+ const pending = new Map();
4
+ const STALE_MS = 10 * 60 * 1000;
5
+ function cleanupStale() {
6
+ const now = Date.now();
7
+ for (const [callID, entry] of pending) {
8
+ if (now - entry.startedAt > STALE_MS) {
9
+ pending.delete(callID);
10
+ }
11
+ }
12
+ }
13
+ return ({ event }) => {
14
+ if (!analyzer)
15
+ return Promise.resolve();
16
+ if (event.type === "session.next.shell.started") {
17
+ const started = event.properties;
18
+ pending.set(started.callID, {
19
+ command: started.command,
20
+ startedAt: Date.now(),
21
+ });
22
+ cleanupStale();
23
+ return Promise.resolve();
24
+ }
25
+ if (event.type === "session.next.shell.ended") {
26
+ const ended = event.properties;
27
+ const started = pending.get(ended.callID);
28
+ if (!started) {
29
+ console.warn("[adaptive-snip] Unpaired shell.ended event for callID:", ended.callID);
30
+ return Promise.resolve();
31
+ }
32
+ pending.delete(ended.callID);
33
+ const output = typeof ended.output === "string"
34
+ ? ended.output
35
+ : JSON.stringify(ended.output);
36
+ const truncatedOutput = output.length > 5000
37
+ ? output.slice(0, 5000) + "... [truncated]"
38
+ : output;
39
+ const endedAt = Date.now();
40
+ const pair = {
41
+ callID: ended.callID,
42
+ command: started.command,
43
+ output: truncatedOutput,
44
+ startedAt: started.startedAt,
45
+ endedAt,
46
+ duration: endedAt - started.startedAt,
47
+ };
48
+ analyzer.addPair(pair);
49
+ if (analyzer.shouldAnalyze()) {
50
+ analyzer.analyze().catch((e) => {
51
+ console.warn("[adaptive-snip] Analyzer.analyze() failed:", e);
52
+ });
53
+ }
54
+ }
55
+ return Promise.resolve();
56
+ };
57
+ }
@@ -0,0 +1,6 @@
1
+ import type { Hooks, PluginInput } from "./types.js";
2
+ export declare function adaptiveSnip(input: PluginInput, options?: Record<string, unknown>): Promise<Hooks>;
3
+ declare const _default: {
4
+ server: typeof adaptiveSnip;
5
+ };
6
+ export default _default;
package/dist/index.js ADDED
@@ -0,0 +1,20 @@
1
+ import { normalizeConfig } from "./config.js";
2
+ import { SnipRuleStore } from "./store.js";
3
+ import { LearnAnalyzer } from "./analyze.js";
4
+ import { createSnipHook } from "./snip.js";
5
+ import { createEventListener } from "./events.js";
6
+ export async function adaptiveSnip(input, options) {
7
+ const opts = await normalizeConfig(options, input.directory);
8
+ const store = new SnipRuleStore(input.directory, opts.ruleFile);
9
+ let analyzer = null;
10
+ if (opts.analyze.enabled) {
11
+ analyzer = new LearnAnalyzer(opts.analyze, store, input.client);
12
+ }
13
+ return Promise.resolve({
14
+ event: createEventListener(analyzer),
15
+ "tool.execute.before": createSnipHook(opts, store),
16
+ });
17
+ }
18
+ export default {
19
+ server: adaptiveSnip,
20
+ };
@@ -0,0 +1,4 @@
1
+ import type { CompiledSnipRule, SnipFallback, SnipRule } from "./types.js";
2
+ export declare function compileRules(rules: SnipRule[]): CompiledSnipRule[];
3
+ export declare function matchRule(cmd: string, compiled: CompiledSnipRule[]): CompiledSnipRule | null;
4
+ export declare function resolveSnip(cmd: string, compiled: CompiledSnipRule[], fallback?: SnipFallback | null): string | null;
package/dist/rules.js ADDED
@@ -0,0 +1,39 @@
1
+ function sanitizeFlags(flags) {
2
+ return [...new Set((flags ?? "").replace(/[gy]/g, "").split(""))].join("");
3
+ }
4
+ export function compileRules(rules) {
5
+ const compiled = [];
6
+ for (const rule of rules) {
7
+ try {
8
+ const regex = new RegExp(rule.pattern, sanitizeFlags(rule.flags));
9
+ compiled.push({
10
+ regex,
11
+ snip: rule.snip,
12
+ source: rule.source,
13
+ confidence: rule.confidence,
14
+ });
15
+ }
16
+ catch {
17
+ console.warn(`[adaptive-snip] Invalid regex in rule: ${rule.pattern}`);
18
+ }
19
+ }
20
+ return compiled;
21
+ }
22
+ export function matchRule(cmd, compiled) {
23
+ for (const rule of compiled) {
24
+ rule.regex.lastIndex = 0;
25
+ if (rule.regex.test(cmd)) {
26
+ rule.regex.lastIndex = 0;
27
+ return rule;
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+ export function resolveSnip(cmd, compiled, fallback) {
33
+ const match = matchRule(cmd, compiled);
34
+ if (match)
35
+ return match.snip;
36
+ if (fallback)
37
+ return fallback.prefix;
38
+ return null;
39
+ }
package/dist/snip.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ import type { AdaptiveSnipOptions } from "./types.js";
2
+ import { SnipRuleStore } from "./store.js";
3
+ import type { Hooks } from "@opencode-ai/plugin";
4
+ export declare function createSnipHook(opts: AdaptiveSnipOptions, store: SnipRuleStore): NonNullable<Hooks["tool.execute.before"]>;
package/dist/snip.js ADDED
@@ -0,0 +1,57 @@
1
+ import { isAlreadyPrefixed, isOperatorSegment, parseCommand, splitOperators } from "./utils.js";
2
+ import { compileRules, resolveSnip } from "./rules.js";
3
+ import { SnipRuleStore } from "./store.js";
4
+ export function createSnipHook(opts, store) {
5
+ return async ({ tool }, output) => {
6
+ if (tool !== "bash")
7
+ return;
8
+ const cmd = output.args?.command;
9
+ if (!cmd || typeof cmd !== "string")
10
+ return;
11
+ const parsed = parseCommand(cmd);
12
+ const learnedRules = await store.getCompiledsnipRules();
13
+ const configRules = compileRules(opts.rules ?? []);
14
+ const allRules = [...configRules, ...learnedRules];
15
+ function prefixSegment(segment) {
16
+ const leading = segment.match(/^\s*/)?.[0] ?? "";
17
+ const trailing = segment.match(/\s*$/)?.[0] ?? "";
18
+ const core = segment.slice(leading.length, segment.length - trailing.length);
19
+ if (!core)
20
+ return segment;
21
+ const segmentParsed = parseCommand(core);
22
+ if (!segmentParsed.bareCommand.trim())
23
+ return segment;
24
+ if (segmentParsed.isBuiltin)
25
+ return segment;
26
+ const prefix = resolveSnip(segmentParsed.bareCommand, allRules, opts.fallback);
27
+ if (!prefix)
28
+ return segment;
29
+ if (isAlreadyPrefixed(segmentParsed.bareCommand, prefix))
30
+ return segment;
31
+ if (segmentParsed.hasPipe && segmentParsed.pipeIndex !== undefined) {
32
+ const beforePipe = segmentParsed.bareCommand
33
+ .slice(0, segmentParsed.pipeIndex)
34
+ .trimEnd();
35
+ const afterPipe = segmentParsed.bareCommand.slice(segmentParsed.pipeIndex);
36
+ return `${leading}${segmentParsed.envPrefix}${prefix} ${beforePipe} ${afterPipe}${trailing}`;
37
+ }
38
+ return `${leading}${segmentParsed.envPrefix}${prefix} ${segmentParsed.bareCommand}${trailing}`;
39
+ }
40
+ if (!parsed.bareCommand.trim())
41
+ return;
42
+ if (parsed.isBuiltin)
43
+ return;
44
+ const segments = splitOperators(cmd);
45
+ if (segments.length === 1) {
46
+ const next = prefixSegment(cmd);
47
+ if (next !== cmd)
48
+ output.args.command = next;
49
+ return;
50
+ }
51
+ const prefixed = segments
52
+ .map((segment) => isOperatorSegment(segment) ? segment : prefixSegment(segment))
53
+ .join("");
54
+ if (prefixed !== cmd)
55
+ output.args.command = prefixed;
56
+ };
57
+ }
@@ -0,0 +1,12 @@
1
+ import type { CompiledSnipRule, LLMRuleSuggestion, RuleStore } from "./types.js";
2
+ export declare function resolveProjectPath(projectDir: string, filePath: string): string;
3
+ export declare function isSafeSnipPrefix(prefix: string): boolean;
4
+ export declare class SnipRuleStore {
5
+ private projectDir;
6
+ private filePath;
7
+ constructor(projectDir: string, filePath: string);
8
+ load(): Promise<RuleStore>;
9
+ save(store: RuleStore): Promise<void>;
10
+ getCompiledsnipRules(): Promise<CompiledSnipRule[]>;
11
+ mergeLearned(suggestions: LLMRuleSuggestion[], minConfidence: number, maxRules: number): Promise<RuleStore>;
12
+ }