diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d6487c1..43a41aa 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,9 +2,9 @@ name: Test on: push: - branches: [master] + branches: [main] pull_request: - branches: [master] + branches: [main] jobs: test: diff --git a/README.md b/README.md index 91aad27..7906106 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,86 @@ Transient hints auto-clear in two ways: 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. +## Configuration + +The plugin works out of the box with no configuration. All options below are optional — only add what you want to change. + +### Config files + +Configuration is loaded from two locations, merged in order (later overrides earlier): + +| Layer | Path | Purpose | +| ---------- | --------------------------------- | -------------------- | +| **Global** | `~/.config/opencode/btw.jsonc` | User-wide defaults | +| **Project** | `.opencode/btw.jsonc` (walks up) | Per-project overrides | + +The global config file is auto-created on first run with a `$schema` reference for IDE auto-completion. Files use JSONC format (JSON with comments and trailing commas). + +You can also use `XDG_CONFIG_HOME` to customize the global config location: `$XDG_CONFIG_HOME/opencode/btw.jsonc`. + +### Options + +```jsonc +{ + // JSON Schema for IDE auto-completion + "$schema": "https://raw.githubusercontent.com/kldzj/opencode-btw/main/btw.schema.json", + + // Default hint type: false = transient (auto-clears), true = pinned (persists) + "defaultPinned": false, + + // Auto-clear behavior for transient hints + "autoClear": { + "onIdle": true, // Clear when session goes idle + "onQuestionTool": true // Clear when the question tool fires + }, + + // Hint injection settings + "injection": { + "target": "both", // "both", "system", or "user" + "systemPromptPosition": "prepend", // "prepend" or "append" + "systemInstructions": null, // Custom framing text (null = built-in default) + "userMessagePrefix": "BTW, " // Prefix for single-hint user messages + }, + + // Enable debug mode (verbose toast logging) + "debug": false, + + // Default toast notification duration in milliseconds + "toastDuration": 3000 +} +``` + +### Examples + +**Pinned hints by default** — skip typing `pin` every time: + +```jsonc +{ "defaultPinned": true } +``` + +**System prompt only** — don't modify user messages: + +```jsonc +{ "injection": { "target": "system" } } +``` + +**Custom framing** — change how hints are presented to the model: + +```jsonc +{ + "injection": { + "systemInstructions": "The user has set the following preferences. Follow them strictly.", + "userMessagePrefix": "Note: " + } +} +``` + +**Disable auto-clear on idle** — only clear when the question tool fires: + +```jsonc +{ "autoClear": { "onIdle": false } } +``` + ## Use cases - **Error loops**: the model keeps making the same mistake — `/btw you're using the wrong API, check the docs for v2` diff --git a/btw.schema.json b/btw.schema.json new file mode 100644 index 0000000..5a9c5d6 --- /dev/null +++ b/btw.schema.json @@ -0,0 +1,75 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "opencode-btw Configuration", + "description": "Configuration for the opencode-btw hint injection plugin", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "type": "string", + "description": "JSON Schema reference for IDE auto-completion" + }, + "defaultPinned": { + "type": "boolean", + "default": false, + "description": "Whether newly added hints are pinned (persistent) by default. When false, hints are transient and auto-clear after the model turn." + }, + "autoClear": { + "type": "object", + "description": "Controls when transient hints are automatically cleared", + "additionalProperties": false, + "properties": { + "onIdle": { + "type": "boolean", + "default": true, + "description": "Auto-clear transient hints when the session goes idle (model finishes responding)" + }, + "onQuestionTool": { + "type": "boolean", + "default": true, + "description": "Auto-clear transient hints when the model uses the question tool" + } + } + }, + "injection": { + "type": "object", + "description": "Controls how and where hints are injected into the model's context", + "additionalProperties": false, + "properties": { + "target": { + "type": "string", + "enum": ["both", "system", "user"], + "default": "both", + "description": "Where to inject hints: 'both' injects into system prompt AND user message, 'system' only into system prompt, 'user' only into user message" + }, + "systemPromptPosition": { + "type": "string", + "enum": ["prepend", "append"], + "default": "prepend", + "description": "Whether to prepend or append the hint block in the system prompt" + }, + "systemInstructions": { + "type": ["string", "null"], + "default": null, + "description": "Custom framing text for the system prompt hint block. Set to null to use the built-in default. This replaces the '## Active User Preferences' section." + }, + "userMessagePrefix": { + "type": "string", + "default": "BTW, ", + "description": "Prefix for single-hint injection into user messages (e.g., 'BTW, ')" + } + } + }, + "debug": { + "type": "boolean", + "default": false, + "description": "Enable debug mode by default (verbose toast logging)" + }, + "toastDuration": { + "type": "number", + "default": 3000, + "minimum": 100, + "description": "Default toast notification duration in milliseconds" + } + } +} diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..bbe0015 --- /dev/null +++ b/bun.lock @@ -0,0 +1,24 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "opencode-btw", + "dependencies": { + "jsonc-parser": "^3.3.1", + }, + "peerDependencies": { + "@opencode-ai/plugin": "*", + }, + }, + }, + "packages": { + "@opencode-ai/plugin": ["@opencode-ai/plugin@1.3.0", "", { "dependencies": { "@opencode-ai/sdk": "1.3.0", "zod": "4.1.8" } }, "sha512-mR1Kdcpr3Iv+KS7cL2DRFB6QAcSoR6/DojmwuxYF/pMCahMtaCLiqZGQjoSNl12+gQ6RsIJJyUh/jX3JVlOx8A=="], + + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.3.0", "", {}, "sha512-5WyYEpcV6Zk9otXOMIrvZRbJm1yxt/c8EXSBn1p6Sw1yagz8HRljkoUTJFxzD0x2+/6vAZItr3OrXDZfE+oA2g=="], + + "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="], + + "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + } +} diff --git a/package.json b/package.json index ac080c1..a370402 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,8 @@ "files": [ "src/plugin.ts", "src/core.ts", + "src/config.ts", + "btw.schema.json", "LICENSE" ], "keywords": [ @@ -33,5 +35,8 @@ }, "publishConfig": { "access": "public" + }, + "dependencies": { + "jsonc-parser": "^3.3.1" } } diff --git a/src/config.test.ts b/src/config.test.ts new file mode 100644 index 0000000..cf5b34b --- /dev/null +++ b/src/config.test.ts @@ -0,0 +1,510 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { mkdirSync, writeFileSync, rmSync, existsSync, readFileSync } from "fs" +import { join } from "path" +import { tmpdir } from "os" + +import { + type BtwConfig, + type ConfigContext, + type ConfigWarning, + DEFAULT_CONFIG, + DEFAULT_SYSTEM_INSTRUCTIONS, + validateConfig, + globalConfigPath, + findProjectConfig, + getConfig, +} from "./config" + +// ─── Helpers ───────────────────────────────────────────────────────── + +function makeTempDir(): string { + const dir = join(tmpdir(), `btw-config-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(dir, { recursive: true }) + return dir +} + +function makeCtx(dir: string): ConfigContext { + return { + directory: dir, + client: { + tui: { + showToast: async () => {}, + }, + }, + } +} + +function writeJsonc(filePath: string, content: string): void { + mkdirSync(join(filePath, ".."), { recursive: true }) + writeFileSync(filePath, content) +} + +// ─── DEFAULT_CONFIG ────────────────────────────────────────────────── + +describe("DEFAULT_CONFIG", () => { + test("has expected default values", () => { + expect(DEFAULT_CONFIG.defaultPinned).toBe(false) + expect(DEFAULT_CONFIG.debug).toBe(false) + expect(DEFAULT_CONFIG.toastDuration).toBe(3000) + expect(DEFAULT_CONFIG.autoClear.onIdle).toBe(true) + expect(DEFAULT_CONFIG.autoClear.onQuestionTool).toBe(true) + expect(DEFAULT_CONFIG.injection.target).toBe("both") + expect(DEFAULT_CONFIG.injection.systemPromptPosition).toBe("prepend") + expect(DEFAULT_CONFIG.injection.systemInstructions).toBeNull() + expect(DEFAULT_CONFIG.injection.userMessagePrefix).toBe("BTW, ") + }) + + test("is marked as Readonly (TypeScript-level constraint)", () => { + // DEFAULT_CONFIG uses Readonly at the type level + // Verify it's a plain object with expected shape + expect(typeof DEFAULT_CONFIG).toBe("object") + expect(DEFAULT_CONFIG).not.toBeNull() + }) +}) + +// ─── DEFAULT_SYSTEM_INSTRUCTIONS ──────────────────────────────────── + +describe("DEFAULT_SYSTEM_INSTRUCTIONS", () => { + test("contains key behavioral directives", () => { + expect(DEFAULT_SYSTEM_INSTRUCTIONS).toContain("user preferences") + expect(DEFAULT_SYSTEM_INSTRUCTIONS).toContain("apply them naturally") + expect(DEFAULT_SYSTEM_INSTRUCTIONS).toContain("corrects your approach") + }) +}) + +// ─── validateConfig ────────────────────────────────────────────────── + +describe("validateConfig", () => { + test("returns no warnings for valid empty config", () => { + const warnings = validateConfig({}) + expect(warnings).toEqual([]) + }) + + test("returns no warnings for valid full config", () => { + const warnings = validateConfig({ + "$schema": "...", + defaultPinned: true, + debug: false, + toastDuration: 5000, + autoClear: { onIdle: false, onQuestionTool: true }, + injection: { + target: "system", + systemPromptPosition: "append", + systemInstructions: "custom", + userMessagePrefix: "Hey, ", + }, + }) + expect(warnings).toEqual([]) + }) + + // ── Unknown keys ── + + test("warns on unknown top-level keys", () => { + const warnings = validateConfig({ foo: "bar", baz: 123 }) + expect(warnings).toHaveLength(2) + expect(warnings[0]!.key).toBe("foo") + expect(warnings[1]!.key).toBe("baz") + }) + + test("warns on unknown autoClear keys", () => { + const warnings = validateConfig({ autoClear: { onIdle: true, unknown: false } }) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.key).toBe("autoClear.unknown") + }) + + test("warns on unknown injection keys", () => { + const warnings = validateConfig({ injection: { target: "both", extra: true } }) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.key).toBe("injection.extra") + }) + + // ── Type checks ── + + test("warns when defaultPinned is not boolean", () => { + const warnings = validateConfig({ defaultPinned: "yes" }) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.key).toBe("defaultPinned") + expect(warnings[0]!.message).toContain("Expected boolean") + }) + + test("warns when debug is not boolean", () => { + const warnings = validateConfig({ debug: 1 }) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.key).toBe("debug") + }) + + test("warns when toastDuration is not a number >= 100", () => { + const w1 = validateConfig({ toastDuration: "fast" }) + expect(w1).toHaveLength(1) + expect(w1[0]!.key).toBe("toastDuration") + + const w2 = validateConfig({ toastDuration: 0 }) + expect(w2).toHaveLength(1) + + const w3 = validateConfig({ toastDuration: -100 }) + expect(w3).toHaveLength(1) + + const w4 = validateConfig({ toastDuration: 50 }) + expect(w4).toHaveLength(1) + + // 100 is the minimum — should be valid + const w5 = validateConfig({ toastDuration: 100 }) + expect(w5).toHaveLength(0) + }) + + test("warns when autoClear is not an object", () => { + const warnings = validateConfig({ autoClear: "yes" }) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.key).toBe("autoClear") + expect(warnings[0]!.message).toBe("Expected an object") + }) + + test("warns when autoClear.onIdle is not boolean", () => { + const warnings = validateConfig({ autoClear: { onIdle: "yes" } }) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.key).toBe("autoClear.onIdle") + }) + + test("warns when injection is not an object", () => { + const warnings = validateConfig({ injection: [1, 2] }) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.key).toBe("injection") + expect(warnings[0]!.message).toBe("Expected an object") + }) + + test("warns on invalid injection.target", () => { + const warnings = validateConfig({ injection: { target: "nowhere" } }) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.key).toBe("injection.target") + expect(warnings[0]!.message).toContain('"both"') + }) + + test("warns on invalid injection.systemPromptPosition", () => { + const warnings = validateConfig({ injection: { systemPromptPosition: "middle" } }) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.key).toBe("injection.systemPromptPosition") + expect(warnings[0]!.message).toContain('"prepend"') + }) + + test("warns when injection.systemInstructions is wrong type", () => { + const warnings = validateConfig({ injection: { systemInstructions: 42 } }) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.key).toBe("injection.systemInstructions") + }) + + test("allows injection.systemInstructions to be null", () => { + const warnings = validateConfig({ injection: { systemInstructions: null } }) + expect(warnings).toEqual([]) + }) + + test("warns when injection.userMessagePrefix is wrong type", () => { + const warnings = validateConfig({ injection: { userMessagePrefix: 123 } }) + expect(warnings).toHaveLength(1) + expect(warnings[0]!.key).toBe("injection.userMessagePrefix") + }) +}) + +// ─── findProjectConfig ────────────────────────────────────────────── + +describe("findProjectConfig", () => { + let tempDir: string + + beforeEach(() => { + tempDir = makeTempDir() + }) + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }) + }) + + test("returns null when no project config exists", () => { + expect(findProjectConfig(tempDir)).toBeNull() + }) + + test("finds .opencode/btw.jsonc in the start directory", () => { + const configPath = join(tempDir, ".opencode", "btw.jsonc") + writeJsonc(configPath, "{}") + expect(findProjectConfig(tempDir)).toBe(configPath) + }) + + test("finds .opencode/btw.json in the start directory", () => { + const configPath = join(tempDir, ".opencode", "btw.json") + writeJsonc(configPath, "{}") + expect(findProjectConfig(tempDir)).toBe(configPath) + }) + + test("prefers btw.jsonc over btw.json", () => { + writeJsonc(join(tempDir, ".opencode", "btw.jsonc"), "{}") + writeJsonc(join(tempDir, ".opencode", "btw.json"), "{}") + expect(findProjectConfig(tempDir)).toBe(join(tempDir, ".opencode", "btw.jsonc")) + }) + + test("walks up the directory tree to find config", () => { + const subDir = join(tempDir, "a", "b", "c") + mkdirSync(subDir, { recursive: true }) + const configPath = join(tempDir, ".opencode", "btw.jsonc") + writeJsonc(configPath, "{}") + expect(findProjectConfig(subDir)).toBe(configPath) + }) +}) + +// ─── getConfig (integration) ──────────────────────────────────────── + +describe("getConfig", () => { + let tempDir: string + let origHome: string | undefined + let origXdg: string | undefined + + beforeEach(() => { + tempDir = makeTempDir() + origHome = process.env.HOME + origXdg = process.env.XDG_CONFIG_HOME + // Point HOME to temp dir so global config goes there + process.env.HOME = tempDir + delete process.env.XDG_CONFIG_HOME + }) + + afterEach(() => { + process.env.HOME = origHome + if (origXdg !== undefined) { + process.env.XDG_CONFIG_HOME = origXdg + } else { + delete process.env.XDG_CONFIG_HOME + } + rmSync(tempDir, { recursive: true, force: true }) + }) + + test("returns defaults when no config files exist", () => { + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + expect(config.defaultPinned).toBe(false) + expect(config.debug).toBe(false) + expect(config.toastDuration).toBe(3000) + expect(config.autoClear.onIdle).toBe(true) + expect(config.autoClear.onQuestionTool).toBe(true) + expect(config.injection.target).toBe("both") + expect(config.injection.systemPromptPosition).toBe("prepend") + expect(config.injection.systemInstructions).toBeNull() + expect(config.injection.userMessagePrefix).toBe("BTW, ") + }) + + test("auto-creates global config with $schema", () => { + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + getConfig(makeCtx(projectDir)) + + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + expect(existsSync(globalPath)).toBe(true) + const content = readFileSync(globalPath, "utf-8") + expect(content).toContain("$schema") + expect(content).toContain("opencode-btw") + }) + + test("does not overwrite existing global config", () => { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, '{\n "debug": true\n}\n') + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + expect(config.debug).toBe(true) + const content = readFileSync(globalPath, "utf-8") + expect(content).not.toContain("$schema") + }) + + test("merges global config with defaults", () => { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, JSON.stringify({ + defaultPinned: true, + toastDuration: 5000, + })) + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + expect(config.defaultPinned).toBe(true) + expect(config.toastDuration).toBe(5000) + // Other values should remain defaults + expect(config.debug).toBe(false) + expect(config.autoClear.onIdle).toBe(true) + }) + + test("project config overrides global config", () => { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, JSON.stringify({ + defaultPinned: true, + debug: true, + })) + + const projectDir = join(tempDir, "project") + const projectConfig = join(projectDir, ".opencode", "btw.jsonc") + writeJsonc(projectConfig, JSON.stringify({ + defaultPinned: false, // Override global + })) + + const config = getConfig(makeCtx(projectDir)) + expect(config.defaultPinned).toBe(false) // project override + expect(config.debug).toBe(true) // from global, not overridden + }) + + test("merges nested autoClear config", () => { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, JSON.stringify({ + autoClear: { onIdle: false }, + })) + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + expect(config.autoClear.onIdle).toBe(false) + expect(config.autoClear.onQuestionTool).toBe(true) // default preserved + }) + + test("merges nested injection config", () => { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, JSON.stringify({ + injection: { + target: "system", + userMessagePrefix: "FYI, ", + }, + })) + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + expect(config.injection.target).toBe("system") + expect(config.injection.userMessagePrefix).toBe("FYI, ") + expect(config.injection.systemPromptPosition).toBe("prepend") // default preserved + }) + + test("handles JSONC comments and trailing commas", () => { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, `{ + // This is a comment + "debug": true, + "toastDuration": 4000, // trailing comma ok +}`) + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + expect(config.debug).toBe(true) + expect(config.toastDuration).toBe(4000) + }) + + test("ignores invalid config files gracefully", () => { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, "this is not valid json {{{") + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + // Should fall back to all defaults + expect(config).toEqual(DEFAULT_CONFIG) + }) + + test("ignores non-object config files", () => { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, '"just a string"') + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + expect(config).toEqual(DEFAULT_CONFIG) + }) + + test("ignores invalid field values but keeps valid ones", () => { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, JSON.stringify({ + debug: true, // valid + toastDuration: -1, // invalid (< 100) — should be ignored + })) + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + expect(config.debug).toBe(true) + expect(config.toastDuration).toBe(3000) // default, invalid value ignored + }) + + test("respects XDG_CONFIG_HOME for global config", () => { + const xdgDir = join(tempDir, "custom-xdg") + process.env.XDG_CONFIG_HOME = xdgDir + + const globalPath = join(xdgDir, "opencode", "btw.jsonc") + writeJsonc(globalPath, JSON.stringify({ debug: true })) + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + expect(config.debug).toBe(true) + }) + + test("returns independent config objects (no shared references)", () => { + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + + const config1 = getConfig(makeCtx(projectDir)) + const config2 = getConfig(makeCtx(projectDir)) + + config1.autoClear.onIdle = false + expect(config2.autoClear.onIdle).toBe(true) // should not be affected + }) + + test("empty config files return defaults", () => { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, "") + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + expect(config).toEqual(DEFAULT_CONFIG) + }) + + test("handles injection target enum values correctly", () => { + for (const target of ["both", "system", "user"] as const) { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, JSON.stringify({ injection: { target } })) + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + expect(config.injection.target).toBe(target) + } + }) + + test("handles systemPromptPosition enum values correctly", () => { + for (const pos of ["prepend", "append"] as const) { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, JSON.stringify({ injection: { systemPromptPosition: pos } })) + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + expect(config.injection.systemPromptPosition).toBe(pos) + } + }) + + test("ignores invalid injection.target values", () => { + const globalPath = join(tempDir, ".config", "opencode", "btw.jsonc") + writeJsonc(globalPath, JSON.stringify({ injection: { target: "nowhere" } })) + + const projectDir = join(tempDir, "project") + mkdirSync(projectDir, { recursive: true }) + const config = getConfig(makeCtx(projectDir)) + + expect(config.injection.target).toBe("both") // default + }) +}) diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..daa12cd --- /dev/null +++ b/src/config.ts @@ -0,0 +1,402 @@ +import { parse } from "jsonc-parser" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs" +import { homedir } from "os" +import { dirname, join, resolve } from "path" + +// ─── Types ─────────────────────────────────────────────────────────── + +export type InjectionTarget = "both" | "system" | "user" +export type SystemPromptPosition = "prepend" | "append" + +export interface AutoClearConfig { + /** Auto-clear transient hints when the session goes idle */ + onIdle: boolean + /** Auto-clear transient hints when the question tool fires */ + onQuestionTool: boolean +} + +export interface InjectionConfig { + /** Where to inject hints: system prompt, user message, or both */ + target: InjectionTarget + /** System prompt injection position */ + systemPromptPosition: SystemPromptPosition + /** Custom system prompt framing text (replaces the default BTW_SYSTEM_INSTRUCTIONS) */ + systemInstructions: string | null + /** Prefix for user message hint injection (default: "BTW, ") */ + userMessagePrefix: string +} + +export interface BtwConfig { + /** Whether newly added hints are pinned by default */ + defaultPinned: boolean + /** Auto-clear behavior for transient hints */ + autoClear: AutoClearConfig + /** Hint injection behavior */ + injection: InjectionConfig + /** Enable debug mode by default */ + debug: boolean + /** Default toast notification duration in milliseconds */ + toastDuration: number +} + +// ─── Defaults ──────────────────────────────────────────────────────── + +export const DEFAULT_SYSTEM_INSTRUCTIONS = `## Active User Preferences + +This environment supports real-time user preferences via the /btw command. +When preferences are listed below, apply them naturally to your work: + +- Each preference reflects the user's current intent and working style +- Apply preferences consistently across all actions and responses +- If a preference corrects your approach (e.g. "use Edit instead of sed"), adjust accordingly +- If a preference asks a question, answer it in your response +- If a preference changes your current direction, adapt smoothly +- Preferences remain active until the user removes them +- If a preference specifies files or areas to focus on, prioritize those` + +export const DEFAULT_CONFIG: Readonly = { + defaultPinned: false, + autoClear: { + onIdle: true, + onQuestionTool: true, + }, + injection: { + target: "both", + systemPromptPosition: "prepend", + systemInstructions: null, + userMessagePrefix: "BTW, ", + }, + debug: false, + toastDuration: 3000, +} + +// ─── Schema reference ──────────────────────────────────────────────── + +const SCHEMA_URL = + "https://raw.githubusercontent.com/kldzj/opencode-btw/main/btw.schema.json" + +// ─── Config paths ──────────────────────────────────────────────────── + +function globalConfigDir(): string { + const xdg = process.env.XDG_CONFIG_HOME + return xdg ? join(xdg, "opencode") : join(process.env.HOME ?? homedir(), ".config", "opencode") +} + +export function globalConfigPath(): string { + return join(globalConfigDir(), "btw.jsonc") +} + +/** + * Walk up from `startDir` looking for `.opencode/btw.jsonc` or `.opencode/btw.json`. + * Returns the first match, or null if none found. + */ +export function findProjectConfig(startDir: string): string | null { + let dir = resolve(startDir) + let depth = 0 + + while (depth++ < 100) { + for (const name of ["btw.jsonc", "btw.json"]) { + const candidate = join(dir, ".opencode", name) + if (existsSync(candidate)) return candidate + } + const parent = dirname(dir) + if (parent === dir) break + dir = parent + } + return null +} + +// ─── File loading ──────────────────────────────────────────────────── + +interface LoadResult { + data: Record | null + parseError: string | null +} + +function loadConfigFile(filePath: string): LoadResult { + try { + if (!existsSync(filePath)) return { data: null, parseError: null } + + const content = readFileSync(filePath, "utf-8") + if (!content.trim()) return { data: null, parseError: null } + + const parsed = parse(content, undefined, { allowTrailingComma: true }) + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { data: null, parseError: `Expected a JSON object in ${filePath}` } + } + return { data: parsed as Record, parseError: null } + } catch (err) { + return { data: null, parseError: `Failed to read ${filePath}: ${err}` } + } +} + +// ─── Auto-create global config ────────────────────────────────────── + +function ensureGlobalConfig(): void { + const configPath = globalConfigPath() + if (existsSync(configPath)) return + + // Also check for .json variant + const jsonVariant = configPath.replace(/\.jsonc$/, ".json") + if (existsSync(jsonVariant)) return + + try { + mkdirSync(dirname(configPath), { recursive: true }) + writeFileSync( + configPath, + `{\n "$schema": "${SCHEMA_URL}"\n}\n`, + ) + } catch { + // Non-critical — user can create it manually + } +} + +// ─── Deep clone ────────────────────────────────────────────────────── + +function deepClone(obj: T): T { + return JSON.parse(JSON.stringify(obj)) +} + +// ─── Merge ─────────────────────────────────────────────────────────── + +function mergeAutoClear(base: AutoClearConfig, override: Record): AutoClearConfig { + return { + onIdle: typeof override.onIdle === "boolean" ? override.onIdle : base.onIdle, + onQuestionTool: + typeof override.onQuestionTool === "boolean" ? override.onQuestionTool : base.onQuestionTool, + } +} + +function mergeInjection(base: InjectionConfig, override: Record): InjectionConfig { + return { + target: isInjectionTarget(override.target) ? override.target : base.target, + systemPromptPosition: isSystemPromptPosition(override.systemPromptPosition) + ? override.systemPromptPosition + : base.systemPromptPosition, + systemInstructions: + override.systemInstructions === null + ? null + : typeof override.systemInstructions === "string" + ? override.systemInstructions + : base.systemInstructions, + userMessagePrefix: + typeof override.userMessagePrefix === "string" + ? override.userMessagePrefix + : base.userMessagePrefix, + } +} + +function mergeLayer(base: BtwConfig, override: Record): BtwConfig { + return { + defaultPinned: + typeof override.defaultPinned === "boolean" ? override.defaultPinned : base.defaultPinned, + autoClear: + override.autoClear && typeof override.autoClear === "object" && !Array.isArray(override.autoClear) + ? mergeAutoClear(base.autoClear, override.autoClear as Record) + : base.autoClear, + injection: + override.injection && typeof override.injection === "object" && !Array.isArray(override.injection) + ? mergeInjection(base.injection, override.injection as Record) + : base.injection, + debug: typeof override.debug === "boolean" ? override.debug : base.debug, + toastDuration: + typeof override.toastDuration === "number" && override.toastDuration >= 100 + ? override.toastDuration + : base.toastDuration, + } +} + +// ─── Type guards ───────────────────────────────────────────────────── + +function isInjectionTarget(v: unknown): v is InjectionTarget { + return v === "both" || v === "system" || v === "user" +} + +function isSystemPromptPosition(v: unknown): v is SystemPromptPosition { + return v === "prepend" || v === "append" +} + +// ─── Validation ────────────────────────────────────────────────────── + +const VALID_TOP_KEYS = new Set([ + "$schema", + "defaultPinned", + "autoClear", + "injection", + "debug", + "toastDuration", +]) + +const VALID_AUTO_CLEAR_KEYS = new Set(["onIdle", "onQuestionTool"]) + +const VALID_INJECTION_KEYS = new Set([ + "target", + "systemPromptPosition", + "systemInstructions", + "userMessagePrefix", +]) + +export interface ConfigWarning { + key: string + message: string +} + +export function validateConfig(data: Record): ConfigWarning[] { + const warnings: ConfigWarning[] = [] + + // Check unknown top-level keys + for (const key of Object.keys(data)) { + if (!VALID_TOP_KEYS.has(key)) { + warnings.push({ key, message: `Unknown config key "${key}"` }) + } + } + + // Type checks + if ("defaultPinned" in data && typeof data.defaultPinned !== "boolean") { + warnings.push({ key: "defaultPinned", message: `Expected boolean, got ${typeof data.defaultPinned}` }) + } + + if ("debug" in data && typeof data.debug !== "boolean") { + warnings.push({ key: "debug", message: `Expected boolean, got ${typeof data.debug}` }) + } + + if ("toastDuration" in data) { + if (typeof data.toastDuration !== "number" || data.toastDuration < 100) { + warnings.push({ + key: "toastDuration", + message: `Expected number >= 100, got ${JSON.stringify(data.toastDuration)}`, + }) + } + } + + // autoClear validation + if ("autoClear" in data) { + const ac = data.autoClear + if (ac && typeof ac === "object" && !Array.isArray(ac)) { + const acObj = ac as Record + for (const key of Object.keys(acObj)) { + if (!VALID_AUTO_CLEAR_KEYS.has(key)) { + warnings.push({ key: `autoClear.${key}`, message: `Unknown config key "autoClear.${key}"` }) + } + } + if ("onIdle" in acObj && typeof acObj.onIdle !== "boolean") { + warnings.push({ key: "autoClear.onIdle", message: `Expected boolean, got ${typeof acObj.onIdle}` }) + } + if ("onQuestionTool" in acObj && typeof acObj.onQuestionTool !== "boolean") { + warnings.push({ + key: "autoClear.onQuestionTool", + message: `Expected boolean, got ${typeof acObj.onQuestionTool}`, + }) + } + } else { + warnings.push({ key: "autoClear", message: "Expected an object" }) + } + } + + // injection validation + if ("injection" in data) { + const inj = data.injection + if (inj && typeof inj === "object" && !Array.isArray(inj)) { + const injObj = inj as Record + for (const key of Object.keys(injObj)) { + if (!VALID_INJECTION_KEYS.has(key)) { + warnings.push({ key: `injection.${key}`, message: `Unknown config key "injection.${key}"` }) + } + } + if ("target" in injObj && !isInjectionTarget(injObj.target)) { + warnings.push({ + key: "injection.target", + message: `Expected "both", "system", or "user", got ${JSON.stringify(injObj.target)}`, + }) + } + if ("systemPromptPosition" in injObj && !isSystemPromptPosition(injObj.systemPromptPosition)) { + warnings.push({ + key: "injection.systemPromptPosition", + message: `Expected "prepend" or "append", got ${JSON.stringify(injObj.systemPromptPosition)}`, + }) + } + if ( + "systemInstructions" in injObj && + injObj.systemInstructions !== null && + typeof injObj.systemInstructions !== "string" + ) { + warnings.push({ + key: "injection.systemInstructions", + message: `Expected string or null, got ${typeof injObj.systemInstructions}`, + }) + } + if ("userMessagePrefix" in injObj && typeof injObj.userMessagePrefix !== "string") { + warnings.push({ + key: "injection.userMessagePrefix", + message: `Expected string, got ${typeof injObj.userMessagePrefix}`, + }) + } + } else { + warnings.push({ key: "injection", message: "Expected an object" }) + } + } + + return warnings +} + +// ─── Main entry point ──────────────────────────────────────────────── + +export interface ConfigContext { + directory: string + client: { + tui: { + showToast: (opts: { body: { message: string; variant: string; duration: number } }) => Promise + } + } +} + +export function getConfig(ctx: ConfigContext): BtwConfig { + let config = deepClone(DEFAULT_CONFIG) as BtwConfig + + ensureGlobalConfig() + + const layers: Array<{ path: string | null; name: string }> = [ + { path: globalConfigPath(), name: "global" }, + { path: findProjectConfig(ctx.directory), name: "project" }, + ] + + // Also check .json variants for global + const globalJson = globalConfigPath().replace(/\.jsonc$/, ".json") + if (!existsSync(globalConfigPath()) && existsSync(globalJson)) { + layers[0] = { path: globalJson, name: "global" } + } + + for (const layer of layers) { + if (!layer.path) continue + const result = loadConfigFile(layer.path) + + if (result.parseError) { + scheduleWarning(ctx, `[btw] Config parse error in ${layer.name} config: ${result.parseError}`) + continue + } + + if (!result.data) continue + + const warnings = validateConfig(result.data) + for (const w of warnings) { + scheduleWarning(ctx, `[btw] ${layer.name} config: ${w.message}`) + } + + config = mergeLayer(config, result.data) + } + + return config +} + +// ─── Scheduled warnings ───────────────────────────────────────────── + +function scheduleWarning(ctx: ConfigContext, message: string): void { + // Delay warnings so they don't fire during plugin initialization + setTimeout(async () => { + try { + await ctx.client.tui.showToast({ + body: { message, variant: "info", duration: 7000 }, + }) + } catch {} + }, 5000) +} diff --git a/src/core.test.ts b/src/core.test.ts index 793ee75..e6f188c 100644 --- a/src/core.test.ts +++ b/src/core.test.ts @@ -7,7 +7,6 @@ import { type HintEntry, BTW_HANDLED, BTW_HELP, - BTW_SYSTEM_INSTRUCTIONS, cancelCommand, projectHash, btwDir, @@ -27,6 +26,7 @@ import { isDebugEnabled, toggleDebug, } from "./core" +import { DEFAULT_SYSTEM_INSTRUCTIONS, type BtwConfig, DEFAULT_CONFIG } from "./config" // ─── Pure Functions ────────────────────────────────────────────────── @@ -67,6 +67,12 @@ describe("hintPath", () => { const path = hintPath("/some/dir", "session-123") expect(path).toBe("/some/dir/session-123.json") }) + + test("sanitizes sessionID to prevent path traversal", () => { + const path = hintPath("/some/dir", "../../etc/passwd") + expect(path).toBe("/some/dir/______etc_passwd.json") + expect(path).not.toContain("..") + }) }) // ─── Command Parsing ───────────────────────────────────────────────── @@ -189,11 +195,10 @@ describe("parseCommand", () => { }) }) - test("treats clear with non-numeric arg as a set action", () => { + test("treats 'clear all' as clear command", () => { expect(parseCommand("clear all")).toEqual({ - action: "set", - text: "clear all", - pinned: false, + action: "clear", + which: "all", }) }) @@ -223,7 +228,7 @@ describe("buildSystemBlock", () => { test("includes system instructions for single hint", () => { const block = buildSystemBlock([{ text: "test hint", pinned: false }]) - expect(block).toContain(BTW_SYSTEM_INSTRUCTIONS) + expect(block).toContain(DEFAULT_SYSTEM_INSTRUCTIONS) }) test("formats single hint as plain text under preferences header", () => { @@ -253,11 +258,11 @@ describe("buildSystemBlock", () => { }) }) -describe("BTW_SYSTEM_INSTRUCTIONS", () => { +describe("DEFAULT_SYSTEM_INSTRUCTIONS", () => { test("contains key behavioral directives", () => { - expect(BTW_SYSTEM_INSTRUCTIONS).toContain("user preferences") - expect(BTW_SYSTEM_INSTRUCTIONS).toContain("apply them naturally") - expect(BTW_SYSTEM_INSTRUCTIONS).toContain("corrects your approach") + expect(DEFAULT_SYSTEM_INSTRUCTIONS).toContain("user preferences") + expect(DEFAULT_SYSTEM_INSTRUCTIONS).toContain("apply them naturally") + expect(DEFAULT_SYSTEM_INSTRUCTIONS).toContain("corrects your approach") }) }) @@ -729,3 +734,99 @@ describe("isDebugEnabled / toggleDebug", () => { } }) }) + +// ─── Config-Aware Behavior ────────────────────────────────────────── + +describe("parseCommand with config", () => { + test("uses defaultPinned from config when adding hints", () => { + const config = { ...DEFAULT_CONFIG, defaultPinned: true } + const result = parseCommand("my hint", config) + expect(result).toEqual({ action: "set", text: "my hint", pinned: true }) + }) + + test("defaults to transient when no config provided", () => { + const result = parseCommand("my hint") + expect(result).toEqual({ action: "set", text: "my hint", pinned: false }) + }) + + test("explicit pin overrides defaultPinned=false", () => { + const config = { ...DEFAULT_CONFIG, defaultPinned: false } + const result = parseCommand("pin my hint", config) + expect(result).toEqual({ action: "set", text: "my hint", pinned: true }) + }) + + test("explicit pin still works with defaultPinned=true", () => { + const config = { ...DEFAULT_CONFIG, defaultPinned: true } + const result = parseCommand("pin my hint", config) + expect(result).toEqual({ action: "set", text: "my hint", pinned: true }) + }) +}) + +describe("buildSystemBlock with config", () => { + test("uses custom systemInstructions from config", () => { + const config: BtwConfig = { + ...DEFAULT_CONFIG, + injection: { ...DEFAULT_CONFIG.injection, systemInstructions: "Custom instructions here" }, + } + const block = buildSystemBlock([{ text: "hint", pinned: false }], config) + expect(block).toContain("Custom instructions here") + expect(block).not.toContain("Active User Preferences") + }) + + test("uses default instructions when systemInstructions is null", () => { + const config: BtwConfig = { + ...DEFAULT_CONFIG, + injection: { ...DEFAULT_CONFIG.injection, systemInstructions: null }, + } + const block = buildSystemBlock([{ text: "hint", pinned: false }], config) + expect(block).toContain("Active User Preferences") + }) + + test("uses default instructions when no config provided", () => { + const block = buildSystemBlock([{ text: "hint", pinned: false }]) + expect(block).toContain("Active User Preferences") + }) +}) + +describe("buildUserHint with config", () => { + test("uses custom userMessagePrefix from config", () => { + const config: BtwConfig = { + ...DEFAULT_CONFIG, + injection: { ...DEFAULT_CONFIG.injection, userMessagePrefix: "Hey, " }, + } + const result = buildUserHint([{ text: "use emojis", pinned: false }], config) + expect(result).toBe("Hey, use emojis") + }) + + test("uses default prefix when no config provided", () => { + const result = buildUserHint([{ text: "use emojis", pinned: false }]) + expect(result).toBe("BTW, use emojis") + }) + + test("multiple hints use config prefix for header", () => { + const config: BtwConfig = { + ...DEFAULT_CONFIG, + injection: { ...DEFAULT_CONFIG.injection, userMessagePrefix: "Hey, " }, + } + const result = buildUserHint( + [{ text: "a", pinned: false }, { text: "b", pinned: false }], + config, + ) + expect(result).toContain("Hey:") + expect(result).toContain("1. a") + expect(result).toContain("2. b") + }) +}) + +describe("isDebugEnabled with config", () => { + test("returns true when config.debug is true", () => { + const config = { ...DEFAULT_CONFIG, debug: true } + expect(isDebugEnabled(config)).toBe(true) + }) + + test("config.debug false overrides marker file", () => { + const config = { ...DEFAULT_CONFIG, debug: false } + // Config explicitly set to false takes priority over marker file + expect(isDebugEnabled(config)).toBe(false) + }) +}) diff --git a/src/core.ts b/src/core.ts index 5bf99f5..db4a809 100644 --- a/src/core.ts +++ b/src/core.ts @@ -1,5 +1,6 @@ import { createHash } from "crypto" import { existsSync, mkdirSync, unlinkSync } from "fs" +import { DEFAULT_SYSTEM_INSTRUCTIONS, type BtwConfig } from "./config" export interface HintEntry { text: string @@ -36,19 +37,6 @@ export const BTW_HELP = `[btw] Usage: /btw debug Toggle debug mode (verbose toast logging) /btw help Show this help message` -export const BTW_SYSTEM_INSTRUCTIONS = `## Active User Preferences - -This environment supports real-time user preferences via the /btw command. -When preferences are listed below, apply them naturally to your work: - -- Each preference reflects the user's current intent and working style -- Apply preferences consistently across all actions and responses -- If a preference corrects your approach (e.g. "use Edit instead of sed"), adjust accordingly -- If a preference asks a question, answer it in your response -- If a preference changes your current direction, adapt smoothly -- Preferences remain active until the user removes them -- If a preference specifies files or areas to focus on, prioritize those` - export function projectHash(directory: string): string { return createHash("md5").update(directory).digest("hex").slice(0, 12) } @@ -58,7 +46,8 @@ export function btwDir(directory: string): string { } export function hintPath(dir: string, sessionID: string): string { - return `${dir}/${sessionID}.json` + const safe = sessionID.replace(/[^a-zA-Z0-9_-]/g, "_") + return `${dir}/${safe}.json` } export function ensureDir(dir: string): void { @@ -73,7 +62,9 @@ export function debugMarkerPath(): string { return `${process.env.HOME}/.cache/opencode/btw/.debug` } -export function isDebugEnabled(): boolean { +export function isDebugEnabled(config?: BtwConfig): boolean { + // When config explicitly sets debug, it takes priority + if (config && "debug" in config) return config.debug try { return existsSync(debugMarkerPath()) } catch { @@ -166,10 +157,10 @@ export async function removeAt( return removed } -export function parseCommand(rawArgs: string): ParsedCommand { +export function parseCommand(rawArgs: string, config?: BtwConfig): ParsedCommand { const args = rawArgs.trim() - if (args === "clear" || args === "reset") { + if (args === "clear" || args === "reset" || args === "clear all") { return { action: "clear", which: "all" } } @@ -206,19 +197,25 @@ export function parseCommand(rawArgs: string): ParsedCommand { return { action: "set", text, pinned: true } } - return { action: "set", text: args, pinned: false } + // Use config.defaultPinned to determine default hint type + const defaultPinned = config?.defaultPinned ?? false + return { action: "set", text: args, pinned: defaultPinned } } -export function buildSystemBlock(hints: HintEntry[]): string { +export function buildSystemBlock(hints: HintEntry[], config?: BtwConfig): string { if (hints.length === 0) return "" const hintList = hints .map((h, i) => hints.length === 1 ? h.text : `${i + 1}. ${h.text}`) .join("\n") - return [BTW_SYSTEM_INSTRUCTIONS, "", "### Current Preferences", hintList].join("\n") + const instructions = config?.injection?.systemInstructions ?? DEFAULT_SYSTEM_INSTRUCTIONS + return [instructions, "", "### Current Preferences", hintList].join("\n") } -export function buildUserHint(hints: HintEntry[]): string { +export function buildUserHint(hints: HintEntry[], config?: BtwConfig): string { if (hints.length === 0) return "" - if (hints.length === 1) return `BTW, ${hints[0].text}` - return `BTW:\n${hints.map((h, i) => `${i + 1}. ${h.text}`).join("\n")}` + const prefix = config?.injection?.userMessagePrefix ?? "BTW, " + if (hints.length === 1) return `${prefix}${hints[0].text}` + // Derive multi-hint header from prefix: "BTW, " → "BTW:", "Hey, " → "Hey:" + const label = prefix.replace(/[,:\s]+$/, "") + return `${label}:\n${hints.map((h, i) => `${i + 1}. ${h.text}`).join("\n")}` } diff --git a/src/plugin.ts b/src/plugin.ts index 68c6e96..7ed7a40 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -18,6 +18,7 @@ import { removeTransient, toggleDebug, } from "./core" +import { type BtwConfig, getConfig } from "./config" // btw — inject hints into the model's context without sending a new message. // @@ -25,32 +26,34 @@ import { // Debug mode marker: ~/.cache/opencode/btw/.debug export const BtwPlugin: Plugin = async ({ directory, client }) => { + const config = getConfig({ directory, client }) + const dir = btwDir(directory) ensureDir(dir) const hint = (sessionID: string) => hintPath(dir, sessionID) - const notify = async (msg: string, duration = 3000) => { + const notify = async (msg: string, duration?: number) => { try { await client.tui.showToast({ body: { message: msg, variant: "info" as const, - duration, + duration: duration ?? config.toastDuration, }, }) } catch {} } const debugLog = async (msg: string) => { - if (!isDebugEnabled()) return + if (!isDebugEnabled(config)) return await notify(`[btw/debug] ${msg}`, 2000) } return { - config: (config) => { - ;(config as any).command = (config as any).command ?? {} - ;(config as any).command["btw"] = { + config: (cfg) => { + ;(cfg as any).command = (cfg as any).command ?? {} + ;(cfg as any).command["btw"] = { description: "Inject a hint into the model's context (stacks; transient by default, use 'pin' to persist)", template: "$ARGUMENTS", @@ -62,6 +65,7 @@ export const BtwPlugin: Plugin = async ({ directory, client }) => { // their purpose — the model saw them, processed them, and asked the user // a question. Clear transient hints so the next LLM call (processing the // user's answer) doesn't carry stale one-shot nudges. + if (!config.autoClear.onQuestionTool) return if (input.tool !== "question") return const sessionID = (input as any).sessionID if (typeof sessionID !== "string") return @@ -76,6 +80,7 @@ export const BtwPlugin: Plugin = async ({ directory, client }) => { event: async ({ event }) => { // Fallback: auto-clear transient hints when the model finishes if (event.type === "session.idle") { + if (!config.autoClear.onIdle) return const sessionID = (event as any).properties?.sessionID if (typeof sessionID !== "string") return @@ -99,7 +104,7 @@ export const BtwPlugin: Plugin = async ({ directory, client }) => { if (input.command !== "btw") return const sessionID = input.sessionID - const parsed = parseCommand(input.arguments ?? "") + const parsed = parseCommand(input.arguments ?? "", config) switch (parsed.action) { case "clear": @@ -165,6 +170,9 @@ export const BtwPlugin: Plugin = async ({ directory, client }) => { }, "experimental.chat.messages.transform": async (_input, output) => { + // Skip user message injection if target is "system" only + if (config.injection.target === "system") return + // Find the last user message to append hint text const messages = (output as Record)?.messages if (!Array.isArray(messages) || messages.length === 0) return @@ -181,7 +189,7 @@ export const BtwPlugin: Plugin = async ({ directory, client }) => { const hints = await readHints(hint(sessionID)) if (hints.length === 0) return - const hintText = buildUserHint(hints) + const hintText = buildUserHint(hints, config) lastUser.parts.push({ id: `btw-${Date.now()}`, sessionID, @@ -195,13 +203,21 @@ export const BtwPlugin: Plugin = async ({ directory, client }) => { }, "experimental.chat.system.transform": async (input, output) => { + // Skip system prompt injection if target is "user" only + if (config.injection.target === "user") return + const sessionID = (input as Record)?.sessionID if (typeof sessionID !== "string" || !sessionID) return try { const hints = await readHints(hint(sessionID)) if (hints.length > 0) { - output.system.unshift(buildSystemBlock(hints)) + const block = buildSystemBlock(hints, config) + if (config.injection.systemPromptPosition === "append") { + output.system.push(block) + } else { + output.system.unshift(block) + } await debugLog(`transform: applied ${hints.length} preference(s)`) } } catch {}