opencode-btw 0.1.0 → 0.2.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/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Hint injection plugin for [OpenCode](https://opencode.ai) — nudge the model mid-task without interrupting its flow.
4
4
 
5
- When the model is stuck in a loop or heading in the wrong direction, `/btw` lets you inject a hint into its context without sending a new message. The hint persists in the system prompt until you clear it and is picked up on the next LLM call, including during tool loops.
5
+ When the model is stuck in a loop or heading in the wrong direction, `/btw` lets you inject a hint into its context without sending a new message. The hint is picked up on the next LLM call, including during tool loops.
6
6
 
7
7
  ## Install
8
8
 
@@ -19,30 +19,54 @@ Restart OpenCode after adding the plugin.
19
19
  ## Usage
20
20
 
21
21
  ```
22
- /btw use the Edit tool instead of sed
23
- /btw focus on error handling, you keep missing edge cases
24
- /btw clear
25
- /btw # show current hint
22
+ /btw use the Edit tool instead of sed # add transient hint (auto-clears)
23
+ /btw pin always use pnpm, not npm # add persistent hint (manual clear)
24
+ /btw clear # remove all hints
25
+ /btw clear last # remove the most recently added hint
26
+ /btw # show all active hints
26
27
  ```
27
28
 
28
- A confirmation message appears in the chat after each command. The model does not see this confirmation — only the hint itself (via the system prompt).
29
+ A confirmation message appears in the chat after each command. The model does not see this confirmation — only the hints themselves (via the system prompt).
30
+
31
+ ### Stacking hints
32
+
33
+ Hints stack — each `/btw` adds to the list rather than replacing. This lets you layer corrections:
34
+
35
+ ```
36
+ /btw pin always use TypeScript # persistent base hint
37
+ /btw fix the bug in auth.ts first # transient nudge on top
38
+ ```
39
+
40
+ After the model finishes its turn, the transient hint auto-clears while the pinned one remains.
41
+
42
+ ### Transient vs. pinned hints
43
+
44
+ - **`/btw <hint>`** — auto-clears after the model finishes its current turn (including all tool calls). Use for one-off corrections and nudges.
45
+ - **`/btw pin <hint>`** — persists until you run `/btw clear`. Use for session-wide preferences like "always use pnpm" or "focus on the auth module".
29
46
 
30
47
  ## How it works
31
48
 
32
49
  1. `/btw <hint>` saves the hint to a file on disk and cancels the command before an LLM call is made
33
- 2. On every subsequent LLM call, the `experimental.chat.system.transform` hook reads the hint and appends it to the system prompt
34
- 3. `/btw clear` removes the hint file
50
+ 2. On every subsequent LLM call, the `experimental.chat.system.transform` hook reads all hints and appends them to the system prompt
51
+ 3. When the model's turn finishes (`session.idle` event), transient hints are automatically removed while pinned hints stay
52
+ 4. `/btw clear` removes all hints, `/btw clear last` removes the most recent one
35
53
 
36
- Hints are **session-scoped** (each session has its own) and **project-scoped** (stored under a hash of the project directory). All data lives in `~/.cache/opencode/btw/`.
54
+ Hints are **session-scoped** (each session has its own) and **project-scoped** (stored under a hash of the project directory). All data lives in `~/.cache/opencode/btw/`. Hint files are cleaned up automatically when sessions are deleted.
37
55
 
38
56
  ## Use cases
39
57
 
40
58
  - **Error loops**: the model keeps making the same mistake — `/btw you're using the wrong API, check the docs for v2`
41
- - **Tool preference**: `/btw use Edit instead of sed, use Grep instead of grep`
59
+ - **Tool preference**: `/btw pin use Edit instead of sed, use Grep instead of grep`
42
60
  - **Scope nudge**: `/btw focus only on the auth module, don't touch other files`
43
61
  - **Strategy shift**: `/btw try a completely different approach, the current one won't work`
44
62
  - **Direct questions**: `/btw what file are you currently editing?`
45
63
 
64
+ ## Development
65
+
66
+ ```bash
67
+ bun test # run test suite
68
+ ```
69
+
46
70
  ## License
47
71
 
48
72
  MIT
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "opencode-btw",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Hint injection plugin for OpenCode — nudge the model mid-task without interrupting its flow",
5
5
  "main": "src/plugin.ts",
6
+ "scripts": {
7
+ "test": "bun test"
8
+ },
6
9
  "files": [
7
- "src",
10
+ "src/plugin.ts",
11
+ "src/core.ts",
8
12
  "LICENSE"
9
13
  ],
10
14
  "keywords": [
package/src/core.ts ADDED
@@ -0,0 +1,143 @@
1
+ import { createHash } from "crypto"
2
+ import { mkdirSync, unlinkSync } from "fs"
3
+
4
+ export interface HintEntry {
5
+ text: string
6
+ pinned: boolean
7
+ }
8
+
9
+ export interface HintFile {
10
+ hints: HintEntry[]
11
+ }
12
+
13
+ export type ParsedCommand =
14
+ | { action: "clear"; which: "all" | "last" }
15
+ | { action: "status" }
16
+ | { action: "set"; text: string; pinned: boolean }
17
+ | { action: "error"; message: string }
18
+
19
+ export const BTW_HANDLED = "__BTW_HANDLED__"
20
+
21
+ export const BTW_SYSTEM_INSTRUCTIONS = `<btw-hint-system>
22
+ The user may inject real-time hints via the /btw command. These hints appear below as <btw-active-hint> blocks.
23
+ When a hint is present:
24
+ - Treat it as a direct, high-priority instruction from the user — equivalent to them telling you something face-to-face
25
+ - Apply it IMMEDIATELY to your current and future actions — do not wait for a "good moment"
26
+ - If the hint is a behavioral correction (e.g. "use Edit instead of sed"), adjust silently without calling attention to the change
27
+ - If the hint is a direct request or question (e.g. "explain what you're doing"), respond to it naturally
28
+ - If the hint contradicts your current approach, change course
29
+ - The hint persists until the user clears it — apply it to every action, not just the next one
30
+ - If the hint says to focus on specific files/areas, prioritize those and deprioritize others
31
+ </btw-hint-system>`
32
+
33
+ export function projectHash(directory: string): string {
34
+ return createHash("md5").update(directory).digest("hex").slice(0, 12)
35
+ }
36
+
37
+ export function btwDir(directory: string): string {
38
+ return `${process.env.HOME}/.cache/opencode/btw/${projectHash(directory)}`
39
+ }
40
+
41
+ export function hintPath(dir: string, sessionID: string): string {
42
+ return `${dir}/${sessionID}.json`
43
+ }
44
+
45
+ export function ensureDir(dir: string): void {
46
+ try {
47
+ mkdirSync(dir, { recursive: true })
48
+ } catch {}
49
+ }
50
+
51
+ export async function readHints(filePath: string): Promise<HintEntry[]> {
52
+ try {
53
+ const file = Bun.file(filePath)
54
+ if (await file.exists()) {
55
+ const data = await file.json()
56
+ // Handle new array format
57
+ if (Array.isArray(data?.hints) && data.hints.length > 0) {
58
+ return data.hints as HintEntry[]
59
+ }
60
+ // Handle legacy single-hint format
61
+ if (data?.text) {
62
+ return [{ text: data.text, pinned: data.pinned ?? false }]
63
+ }
64
+ }
65
+ } catch {}
66
+ return []
67
+ }
68
+
69
+ export async function writeHints(
70
+ filePath: string,
71
+ hints: HintEntry[],
72
+ ): Promise<void> {
73
+ if (hints.length === 0) {
74
+ await clearHints(filePath)
75
+ return
76
+ }
77
+ await Bun.write(filePath, JSON.stringify({ hints } satisfies HintFile))
78
+ }
79
+
80
+ export async function addHint(
81
+ filePath: string,
82
+ entry: HintEntry,
83
+ ): Promise<void> {
84
+ const existing = await readHints(filePath)
85
+ existing.push(entry)
86
+ await writeHints(filePath, existing)
87
+ }
88
+
89
+ export async function clearHints(filePath: string): Promise<void> {
90
+ try {
91
+ unlinkSync(filePath)
92
+ } catch {}
93
+ }
94
+
95
+ export async function removeTransient(filePath: string): Promise<boolean> {
96
+ const hints = await readHints(filePath)
97
+ const pinned = hints.filter((h) => h.pinned)
98
+ if (pinned.length === hints.length) return false // nothing removed
99
+ await writeHints(filePath, pinned)
100
+ return true
101
+ }
102
+
103
+ export async function removeLast(filePath: string): Promise<HintEntry | null> {
104
+ const hints = await readHints(filePath)
105
+ if (hints.length === 0) return null
106
+ const removed = hints.pop()!
107
+ await writeHints(filePath, hints)
108
+ return removed
109
+ }
110
+
111
+ export function parseCommand(rawArgs: string): ParsedCommand {
112
+ const args = rawArgs.trim()
113
+
114
+ if (args === "clear" || args === "reset") {
115
+ return { action: "clear", which: "all" }
116
+ }
117
+
118
+ if (args === "clear last") {
119
+ return { action: "clear", which: "last" }
120
+ }
121
+
122
+ if (!args) {
123
+ return { action: "status" }
124
+ }
125
+
126
+ if (args === "pin" || args.startsWith("pin ")) {
127
+ const text = args.slice(3).trim()
128
+ if (!text) {
129
+ return { action: "error", message: "Usage: /btw pin <hint>" }
130
+ }
131
+ return { action: "set", text, pinned: true }
132
+ }
133
+
134
+ return { action: "set", text: args, pinned: false }
135
+ }
136
+
137
+ export function buildSystemBlock(hints: HintEntry[]): string {
138
+ if (hints.length === 0) return ""
139
+ const hintBlocks = hints
140
+ .map((h) => `<btw-active-hint>\n${h.text}\n</btw-active-hint>`)
141
+ .join("\n\n")
142
+ return [BTW_SYSTEM_INSTRUCTIONS, "", hintBlocks].join("\n")
143
+ }
package/src/plugin.ts CHANGED
@@ -1,69 +1,39 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
- import { createHash } from "crypto"
3
- import { mkdirSync } from "fs"
2
+
3
+ import {
4
+ BTW_HANDLED,
5
+ addHint,
6
+ buildSystemBlock,
7
+ btwDir,
8
+ clearHints,
9
+ ensureDir,
10
+ hintPath,
11
+ parseCommand,
12
+ readHints,
13
+ removeLast,
14
+ removeTransient,
15
+ } from "./core"
4
16
 
5
17
  // btw — inject hints into the model's context without sending a new message.
6
18
  //
7
- // Uses two patterns:
8
- // 1. Sentinel error throw from command.execute.before — cancels the command
9
- // before prompt() is called. No LLM request is made.
10
- // 2. experimental.chat.system.transform — appends the hint to the system
11
- // prompt on every LLM call (including tool-loop iterations).
19
+ // Uses three hooks:
20
+ // 1. command.execute.before — intercepts /btw, saves hint, throws sentinel
21
+ // to cancel the LLM call entirely.
22
+ // 2. experimental.chat.system.transform — appends hints to the system prompt
23
+ // on every LLM call (including tool-loop iterations).
24
+ // 3. event — listens for session.idle to auto-clear transient hints, and
25
+ // session.deleted to clean up hint files.
12
26
  //
13
27
  // File layout:
14
28
  // ~/.cache/opencode/btw/<project-hash>/
15
- // <sessionID>.txt # hint content
16
-
17
- // System prompt instructions — explains the btw system
18
- const BTW_SYSTEM_INSTRUCTIONS = `<btw-hint-system>
19
- The user may inject real-time hints via the /btw command. These hints appear below as <btw-active-hint> blocks.
20
- When a hint is present:
21
- - Treat it as a direct, high-priority instruction from the user — equivalent to them telling you something face-to-face
22
- - Apply it IMMEDIATELY to your current and future actions — do not wait for a "good moment"
23
- - If the hint is a behavioral correction (e.g. "use Edit instead of sed"), adjust silently without calling attention to the change
24
- - If the hint is a direct request or question (e.g. "explain what you're doing"), respond to it naturally
25
- - If the hint contradicts your current approach, change course
26
- - The hint persists until the user clears it — apply it to every action, not just the next one
27
- - If the hint says to focus on specific files/areas, prioritize those and deprioritize others
28
- </btw-hint-system>`
29
-
30
- function projectHash(directory: string): string {
31
- return createHash("md5").update(directory).digest("hex").slice(0, 12)
32
- }
33
-
34
- function btwDir(directory: string): string {
35
- return `${process.env.HOME}/.cache/opencode/btw/${projectHash(directory)}`
36
- }
37
-
38
- // Sentinel error — thrown to prevent OpenCode from calling prompt() after the
39
- // command hook. Prevents an LLM call from being made for /btw commands.
40
- const BTW_HANDLED = "__BTW_HANDLED__"
29
+ // <sessionID>.json # { hints: [{ text, pinned }] }
41
30
 
42
31
  export const BtwPlugin: Plugin = async ({ directory, client }) => {
43
32
  const dir = btwDir(directory)
33
+ ensureDir(dir)
44
34
 
45
- try {
46
- mkdirSync(dir, { recursive: true })
47
- } catch {}
48
-
49
- const hintPath = (sessionID: string) => `${dir}/${sessionID}.txt`
35
+ const hint = (sessionID: string) => hintPath(dir, sessionID)
50
36
 
51
- const readHint = async (sessionID: string): Promise<string> => {
52
- try {
53
- const file = Bun.file(hintPath(sessionID))
54
- if (await file.exists()) {
55
- return (await file.text()).trim()
56
- }
57
- } catch {}
58
- return ""
59
- }
60
-
61
- const writeHint = async (sessionID: string, hint: string) => {
62
- await Bun.write(hintPath(sessionID), hint)
63
- }
64
-
65
- // Send a no-op message that appears in the chat UI but is invisible to the
66
- // LLM (noReply prevents inference, ignored: true hides it from message transforms).
67
37
  const sendVisibleMessage = async (sessionID: string, text: string) => {
68
38
  try {
69
39
  await client.session.prompt({
@@ -73,73 +43,109 @@ export const BtwPlugin: Plugin = async ({ directory, client }) => {
73
43
  parts: [{ type: "text" as const, text, ignored: true }],
74
44
  },
75
45
  })
76
- } catch {
77
- // Prompt API unavailable — silently skip
78
- }
46
+ } catch {}
79
47
  }
80
48
 
81
49
  return {
82
- // Register /btw as a command so it appears in /help and autocomplete.
83
- // The template is minimal — command.execute.before intercepts before it's used.
84
50
  config: (config) => {
85
51
  ;(config as any).command = (config as any).command ?? {}
86
52
  ;(config as any).command["btw"] = {
87
53
  description:
88
- "Inject a hint into the model's context (persists in system prompt until cleared)",
54
+ "Inject a hint into the model's context (stacks; transient by default, use 'pin' to persist)",
89
55
  template: "$ARGUMENTS",
90
56
  }
91
57
  },
92
58
 
93
- // Intercept /btw: save hint, send visible message, throw sentinel to prevent LLM call.
59
+ event: async ({ event }) => {
60
+ // Auto-clear transient hints when the model finishes a complete turn
61
+ if (event.type === "session.idle") {
62
+ const sessionID = (event as any).properties?.sessionID
63
+ if (typeof sessionID !== "string") return
64
+
65
+ const removed = await removeTransient(hint(sessionID))
66
+ if (removed) {
67
+ await sendVisibleMessage(sessionID, "[btw] Transient hints auto-cleared")
68
+ }
69
+ }
70
+
71
+ // Clean up hint files when sessions are deleted
72
+ if (event.type === "session.deleted") {
73
+ const sessionID = (event as any).properties?.sessionID
74
+ if (typeof sessionID === "string") {
75
+ await clearHints(hint(sessionID))
76
+ }
77
+ }
78
+ },
79
+
94
80
  "command.execute.before": async (input, _output) => {
95
81
  if (input.command !== "btw") return
96
82
 
97
- const args = (input.arguments ?? "").trim()
98
83
  const sessionID = input.sessionID
84
+ const parsed = parseCommand(input.arguments ?? "")
85
+
86
+ switch (parsed.action) {
87
+ case "clear":
88
+ if (parsed.which === "last") {
89
+ const removed = await removeLast(hint(sessionID))
90
+ if (removed) {
91
+ await sendVisibleMessage(
92
+ sessionID,
93
+ `[btw] Removed last hint: "${removed.text}"`,
94
+ )
95
+ } else {
96
+ await sendVisibleMessage(sessionID, "[btw] No hints to remove")
97
+ }
98
+ } else {
99
+ await clearHints(hint(sessionID))
100
+ await sendVisibleMessage(sessionID, "[btw] All hints cleared")
101
+ }
102
+ throw new Error(BTW_HANDLED)
103
+
104
+ case "status": {
105
+ const hints = await readHints(hint(sessionID))
106
+ if (hints.length === 0) {
107
+ await sendVisibleMessage(sessionID, "[btw] No hints set")
108
+ } else {
109
+ const lines = hints.map((h, i) => {
110
+ const label = h.pinned ? "pinned" : "transient"
111
+ return ` ${i + 1}. [${label}] "${h.text}"`
112
+ })
113
+ await sendVisibleMessage(
114
+ sessionID,
115
+ `[btw] Active hints:\n${lines.join("\n")}`,
116
+ )
117
+ }
118
+ throw new Error(BTW_HANDLED)
119
+ }
99
120
 
100
- if (args === "clear" || args === "reset") {
101
- await writeHint(sessionID, "")
102
- await sendVisibleMessage(sessionID, "[btw] Hint cleared")
103
- throw new Error(BTW_HANDLED)
104
- }
105
-
106
- if (!args) {
107
- const hint = await readHint(sessionID)
108
- await sendVisibleMessage(
109
- sessionID,
110
- hint ? `[btw] Current hint: "${hint}"` : "[btw] No hint set",
111
- )
112
- throw new Error(BTW_HANDLED)
121
+ case "error":
122
+ await sendVisibleMessage(sessionID, `[btw] ${parsed.message}`)
123
+ throw new Error(BTW_HANDLED)
124
+
125
+ case "set":
126
+ await addHint(hint(sessionID), {
127
+ text: parsed.text,
128
+ pinned: parsed.pinned,
129
+ })
130
+ const verb = parsed.pinned ? "Pinned hint" : "Hint"
131
+ await sendVisibleMessage(
132
+ sessionID,
133
+ `[btw] ${verb} added: "${parsed.text}"`,
134
+ )
135
+ throw new Error(BTW_HANDLED)
113
136
  }
114
-
115
- // Set the hint
116
- await writeHint(sessionID, args)
117
- await sendVisibleMessage(sessionID, `[btw] Hint set: "${args}"`)
118
- throw new Error(BTW_HANDLED)
119
137
  },
120
138
 
121
- // Inject hint into system prompt on every LLM call (including tool loops).
122
139
  "experimental.chat.system.transform": async (input, output) => {
123
140
  const sessionID = (input as Record<string, unknown>)?.sessionID
124
141
  if (typeof sessionID !== "string" || !sessionID) return
125
142
 
126
143
  try {
127
- const hint = await readHint(sessionID)
128
-
129
- if (hint) {
130
- output.system.push(
131
- [
132
- BTW_SYSTEM_INSTRUCTIONS,
133
- "",
134
- "<btw-active-hint>",
135
- hint,
136
- "</btw-active-hint>",
137
- ].join("\n"),
138
- )
144
+ const hints = await readHints(hint(sessionID))
145
+ if (hints.length > 0) {
146
+ output.system.push(buildSystemBlock(hints))
139
147
  }
140
- } catch {
141
- // File read failed — no hint to inject
142
- }
148
+ } catch {}
143
149
  },
144
150
  }
145
151
  }