diff --git a/index.ts b/index.ts index ec7956a..3ca392e 100644 --- a/index.ts +++ b/index.ts @@ -5,6 +5,14 @@ import type { Plugin } from "@opencode-ai/plugin"; import { createCommandExecuteHandler } from "./src/commands.js"; import { loadConfig } from "./src/config.js"; import { assembleMessage, expandHashtags } from "./src/expander.js"; +import type { + ChatMessageInput, + ChatMessageOutput, + SessionIdleEvent, + TransformInput, + TransformOutput, +} from "./src/hook-types.js"; +import { InjectionManager } from "./src/injection-manager.js"; import { loadSnippets } from "./src/loader.js"; import { logger } from "./src/logger.js"; import { executeShellCommands, type ShellContext } from "./src/shell.js"; @@ -82,48 +90,52 @@ export const SnippetsPlugin: Plugin = async (ctx) => { // Create command handler const commandHandler = createCommandExecuteHandler(ctx.client, snippets, ctx.directory); + const injectionManager = new InjectionManager(); + /** - * Processes text parts for snippet expansion and shell command execution + * Processes text parts for snippet expansion and shell command execution. + * Returns collected inject blocks from expanded snippets. */ - const processTextParts = async (parts: Array<{ type: string; text?: string }>) => { + const processTextParts = async ( + parts: Array<{ type: string; text?: string }>, + ): Promise => { const messageStart = performance.now(); let expandTimeTotal = 0; let shellTimeTotal = 0; let processedParts = 0; + const allInjected: string[] = []; for (const part of parts) { if (part.type === "text" && part.text) { - // 1. Expand hashtags recursively with loop detection const expandStart = performance.now(); const expansionResult = expandHashtags(part.text, snippets); part.text = assembleMessage(expansionResult); - const expandTime = performance.now() - expandStart; - expandTimeTotal += expandTime; + allInjected.push(...expansionResult.inject); + expandTimeTotal += performance.now() - expandStart; - // 2. Execute shell commands: !`command` const shellStart = performance.now(); part.text = await executeShellCommands(part.text, ctx as unknown as ShellContext, { hideCommandInOutput: config.hideCommandInOutput, }); - const shellTime = performance.now() - shellStart; - shellTimeTotal += shellTime; + shellTimeTotal += performance.now() - shellStart; processedParts += 1; } } - const totalTime = performance.now() - messageStart; if (processedParts > 0) { logger.debug("Text parts processing complete", { - totalTimeMs: totalTime.toFixed(2), + totalTimeMs: (performance.now() - messageStart).toFixed(2), snippetExpandTimeMs: expandTimeTotal.toFixed(2), shellTimeMs: shellTimeTotal.toFixed(2), processedParts, + injectedCount: allInjected.length, }); } + + return allInjected; }; return { - // Register /snippet command config: async (opencodeConfig) => { opencodeConfig.command ??= {}; opencodeConfig.command.snippet = { @@ -132,36 +144,84 @@ export const SnippetsPlugin: Plugin = async (ctx) => { }; }, - // Handle /snippet command execution "command.execute.before": commandHandler, - "chat.message": async (_input, output) => { - // Only process user messages, never assistant messages + "chat.message": async (input: ChatMessageInput, output: ChatMessageOutput) => { if (output.message.role !== "user") return; - // Skip processing if any part is marked as ignored (e.g., command output) - if (output.parts.some((part) => "ignored" in part && part.ignored)) return; - await processTextParts(output.parts); + if (output.parts.some((part) => part.ignored)) return; + + const injected = await processTextParts(output.parts); + + output.parts.forEach((part) => { + if (part.type === "text") { + part.snippetsProcessed = true; + } + }); + + injectionManager.setInjections(input.sessionID, injected); }, - // Process all messages including question tool responses - "experimental.chat.messages.transform": async (_input, output) => { + "experimental.chat.messages.transform": async ( + input: TransformInput, + output: TransformOutput, + ) => { + const sessionID = input.sessionID || input.session?.id || output.messages[0]?.info?.sessionID; + + logger.debug("Transform hook called", { + inputSessionID: input.sessionID, + extractedSessionID: sessionID, + messageCount: output.messages.length, + hasSessionID: !!sessionID, + }); + for (const message of output.messages) { - // Only process user messages if (message.info.role === "user") { - // Skip processing if any part is marked as ignored (e.g., command output) - if (message.parts.some((part) => "ignored" in part && part.ignored)) continue; - await processTextParts(message.parts); + if (message.parts.some((part) => part.snippetsProcessed)) continue; + if (message.parts.some((part) => part.ignored)) continue; + + const injected = await processTextParts(message.parts); + + if (injected.length > 0 && sessionID) { + injectionManager.addInjections(sessionID, injected); + } + } + } + + if (sessionID) { + const injections = injectionManager.getInjections(sessionID); + logger.debug("Transform hook - checking for injections", { + sessionID, + hasInjections: !!injections, + injectionCount: injections?.length || 0, + }); + if (injections && injections.length > 0) { + const beforeCount = output.messages.length; + for (const injectText of injections) { + output.messages.push({ + info: { + role: "user", + sessionID: sessionID, + }, + parts: [{ type: "text", text: injectText }], + }); + } + logger.debug("Injected ephemeral user messages", { + sessionID, + injectionCount: injections.length, + messagesBefore: beforeCount, + messagesAfter: output.messages.length, + }); } } }, - // Process skill tool output to expand snippets in skill content + "session.idle": async (event: SessionIdleEvent) => { + injectionManager.clearSession(event.sessionID); + }, + "tool.execute.after": async (input, output) => { - // Only process the skill tool if (input.tool !== "skill") return; - // The skill tool returns markdown content in its output - // Expand hashtags in the skill content if (typeof output.output === "string" && output.output.trim()) { const expansionResult = expandHashtags(output.output, snippets); output.output = assembleMessage(expansionResult); diff --git a/src/expander.test.ts b/src/expander.test.ts index eb41d97..75f6496 100644 --- a/src/expander.test.ts +++ b/src/expander.test.ts @@ -1,3 +1,4 @@ +import { describe, expect, it } from "bun:test"; import { assembleMessage, expandHashtags, parseSnippetBlocks } from "../src/expander.js"; import type { SnippetInfo, SnippetRegistry } from "../src/types.js"; @@ -118,7 +119,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => { }); describe("Loop detection - Direct cycles", () => { - it("should detect and prevent simple self-reference", { timeout: 100 }, () => { + it("should detect and prevent simple self-reference", () => { const registry = createRegistry([["self", "I reference #self"]]); const result = expandHashtags("#self", registry); @@ -126,7 +127,7 @@ describe("expandHashtags - Recursive Includes and Loop Detection", () => { // Loop detected after 15 expansions, #self left as-is const expected = `${"I reference ".repeat(15)}#self`; expect(result.text).toBe(expected); - }); + }, 100); it("should detect and prevent two-way circular reference", () => { const registry = createRegistry([ @@ -476,6 +477,7 @@ describe("parseSnippetBlocks", () => { inline: "Just some content", prepend: [], append: [], + inject: [], }); }); @@ -486,6 +488,7 @@ describe("parseSnippetBlocks", () => { inline: "Inline text", prepend: [], append: ["Append content"], + inject: [], }); }); @@ -496,6 +499,7 @@ describe("parseSnippetBlocks", () => { inline: "Inline text", prepend: ["Prepend content"], append: [], + inject: [], }); }); @@ -514,6 +518,7 @@ After content inline: "Inline text", prepend: ["Before content"], append: ["After content"], + inject: [], }); }); @@ -532,6 +537,18 @@ Second append inline: "Inline", prepend: [], append: ["First append", "Second append"], + inject: [], + }); + }); + + it("should extract inject blocks", () => { + const result = parseSnippetBlocks("Inline\n\nInject content\n"); + + expect(result).toEqual({ + inline: "Inline", + prepend: [], + append: [], + inject: ["Inject content"], }); }); }); @@ -548,6 +565,7 @@ Only append content inline: "", prepend: [], append: ["Only append content"], + inject: [], }); }); @@ -560,6 +578,7 @@ Only append content inline: "Inline", prepend: [], append: ["Unclosed append content"], + inject: [], }); }); @@ -604,6 +623,7 @@ Only append content inline: "", prepend: [], append: ["Content"], + inject: [], }); }); @@ -616,6 +636,7 @@ Only append content inline: "Inline", prepend: [], append: [], + inject: [], }); }); }); @@ -647,6 +668,7 @@ describe("assembleMessage", () => { text: "Main content", prepend: [], append: [], + inject: [], }); expect(result).toBe("Main content"); @@ -657,6 +679,7 @@ describe("assembleMessage", () => { text: "Main content", prepend: [], append: ["Appended section"], + inject: [], }); expect(result).toBe("Main content\n\nAppended section"); @@ -667,6 +690,7 @@ describe("assembleMessage", () => { text: "Main content", prepend: ["Prepended section"], append: [], + inject: [], }); expect(result).toBe("Prepended section\n\nMain content"); @@ -677,6 +701,7 @@ describe("assembleMessage", () => { text: "Main content", prepend: ["Before"], append: ["After"], + inject: [], }); expect(result).toBe("Before\n\nMain content\n\nAfter"); @@ -687,6 +712,7 @@ describe("assembleMessage", () => { text: "Main", prepend: ["First", "Second"], append: [], + inject: [], }); expect(result).toBe("First\n\nSecond\n\nMain"); @@ -697,6 +723,7 @@ describe("assembleMessage", () => { text: "Main", prepend: [], append: ["First", "Second"], + inject: [], }); expect(result).toBe("Main\n\nFirst\n\nSecond"); @@ -707,6 +734,7 @@ describe("assembleMessage", () => { text: "", prepend: ["Before"], append: ["After"], + inject: [], }); expect(result).toBe("Before\n\nAfter"); @@ -717,6 +745,7 @@ describe("assembleMessage", () => { text: " ", prepend: ["Before"], append: ["After"], + inject: [], }); expect(result).toBe("Before\n\nAfter"); @@ -843,4 +872,15 @@ describe("Prepend/Append integration with expandHashtags", () => { expect(assembled).toBe("Plain content and Block inline\n\nBlock append"); }); + + it("should collect inject blocks and not include them in assembled message", () => { + const registry = createRegistry([["inj", "Inline\n\nInjected message\n"]]); + + const result = expandHashtags("Use #inj", registry); + const assembled = assembleMessage(result); + + expect(result.text).toBe("Use Inline"); + expect(result.inject).toEqual(["Injected message"]); + expect(assembled).toBe("Use Inline"); // assembled message should NOT contain injected content + }); }); diff --git a/src/expander.ts b/src/expander.ts index 6d01670..e1add09 100644 --- a/src/expander.ts +++ b/src/expander.ts @@ -10,10 +10,10 @@ const MAX_EXPANSION_COUNT = 15; /** * Tag types for parsing */ -type BlockType = "prepend" | "append"; +type BlockType = "prepend" | "append" | "inject"; /** - * Parses snippet content to extract inline text and prepend/append blocks + * Parses snippet content to extract inline text and prepend/append/inject blocks * * Uses a lenient stack-based parser: * - Unclosed tags → treat rest of content as block @@ -21,15 +21,16 @@ type BlockType = "prepend" | "append"; * - Multiple blocks → collected in document order * * @param content - The raw snippet content to parse - * @returns Parsed content with inline, prepend, and append parts, or null on error + * @returns Parsed content with inline, prepend, append, and inject parts, or null on error */ export function parseSnippetBlocks(content: string): ParsedSnippetContent | null { const prepend: string[] = []; const append: string[] = []; + const inject: string[] = []; let inline = ""; // Regex to find opening and closing tags - const tagPattern = /<(\/?)(?prepend|append)>/gi; + const tagPattern = /<(\/?)(?prepend|append|inject)>/gi; let lastIndex = 0; let currentBlock: { type: BlockType; startIndex: number; contentStart: number } | null = null; @@ -58,8 +59,10 @@ export function parseSnippetBlocks(content: string): ParsedSnippetContent | null if (blockContent) { if (currentBlock.type === "prepend") { prepend.push(blockContent); - } else { + } else if (currentBlock.type === "append") { append.push(blockContent); + } else { + inject.push(blockContent); } } lastIndex = tagEnd; @@ -85,8 +88,10 @@ export function parseSnippetBlocks(content: string): ParsedSnippetContent | null if (blockContent) { if (currentBlock.type === "prepend") { prepend.push(blockContent); - } else { + } else if (currentBlock.type === "append") { append.push(blockContent); + } else { + inject.push(blockContent); } } } else { @@ -98,6 +103,7 @@ export function parseSnippetBlocks(content: string): ParsedSnippetContent | null inline: inline.trim(), prepend, append, + inject, }; } @@ -119,6 +125,7 @@ export function expandHashtags( ): ExpansionResult { const collectedPrepend: string[] = []; const collectedAppend: string[] = []; + const collectedInject: string[] = []; let expanded = text; let hasChanges = true; @@ -134,6 +141,7 @@ export function expandHashtags( // We need to collect blocks during replacement, so we track them here const roundPrepend: string[] = []; const roundAppend: string[] = []; + const roundInject: string[] = []; expanded = expanded.replace(PATTERNS.HASHTAG, (match, name) => { const key = name.toLowerCase(); @@ -165,9 +173,10 @@ export function expandHashtags( return match; } - // Collect prepend/append blocks + // Collect prepend/append/inject blocks roundPrepend.push(...parsed.prepend); roundAppend.push(...parsed.append); + roundInject.push(...parsed.inject); // Recursively expand any hashtags in the inline content const nestedResult = expandHashtags(parsed.inline, registry, expansionCounts); @@ -175,6 +184,7 @@ export function expandHashtags( // Collect blocks from nested expansion roundPrepend.push(...nestedResult.prepend); roundAppend.push(...nestedResult.append); + roundInject.push(...nestedResult.inject); return nestedResult.text; }); @@ -182,6 +192,7 @@ export function expandHashtags( // Add this round's blocks to collected blocks collectedPrepend.push(...roundPrepend); collectedAppend.push(...roundAppend); + collectedInject.push(...roundInject); // Only continue if the text actually changed AND no loop was detected hasChanges = expanded !== previous && !loopDetected; @@ -191,6 +202,7 @@ export function expandHashtags( text: expanded, prepend: collectedPrepend, append: collectedAppend, + inject: collectedInject, }; } diff --git a/src/hook-types.ts b/src/hook-types.ts new file mode 100644 index 0000000..0acc149 --- /dev/null +++ b/src/hook-types.ts @@ -0,0 +1,46 @@ +/** + * Plugin hook types - minimal definitions for type safety + */ + +export interface MessagePart { + type: string; + text?: string; + ignored?: boolean; + snippetsProcessed?: boolean; +} + +export interface ChatMessageInput { + sessionID: string; +} + +export interface ChatMessageOutput { + message: { + role: string; + }; + parts: MessagePart[]; +} + +export interface TransformMessageInfo { + role: string; + sessionID?: string; +} + +export interface TransformMessage { + info: TransformMessageInfo; + parts: MessagePart[]; +} + +export interface TransformInput { + sessionID?: string; + session?: { + id?: string; + }; +} + +export interface TransformOutput { + messages: TransformMessage[]; +} + +export interface SessionIdleEvent { + sessionID: string; +} diff --git a/src/injection-manager.ts b/src/injection-manager.ts new file mode 100644 index 0000000..244b49e --- /dev/null +++ b/src/injection-manager.ts @@ -0,0 +1,51 @@ +import { logger } from "./logger.js"; + +/** + * Manages injection lifecycle per session. + * Injections persist for the entire agentic loop until session idle. + */ +export class InjectionManager { + private activeInjections = new Map(); + + /** + * Stores new injections for a session, clearing any previous injections. + */ + setInjections(sessionID: string, injections: string[]): void { + if (injections.length > 0) { + this.activeInjections.set(sessionID, injections); + } else { + this.activeInjections.delete(sessionID); + } + } + + /** + * Adds additional injections to an existing session without duplicates. + */ + addInjections(sessionID: string, newInjections: string[]): void { + if (newInjections.length === 0) return; + + const existing = this.activeInjections.get(sessionID) || []; + const uniqueInjections = newInjections.filter((inj) => !existing.includes(inj)); + + if (uniqueInjections.length > 0) { + this.activeInjections.set(sessionID, [...existing, ...uniqueInjections]); + } + } + + /** + * Gets active injections for a session without removing them. + */ + getInjections(sessionID: string): string[] | undefined { + return this.activeInjections.get(sessionID); + } + + /** + * Clears all injections for a session. + */ + clearSession(sessionID: string): void { + if (this.activeInjections.has(sessionID)) { + this.activeInjections.delete(sessionID); + logger.debug("Cleared active injections on session idle", { sessionID }); + } + } +} diff --git a/src/types.ts b/src/types.ts index e272bf2..d015620 100644 --- a/src/types.ts +++ b/src/types.ts @@ -56,6 +56,8 @@ export interface ParsedSnippetContent { prepend: string[]; /** block contents in document order */ append: string[]; + /** block contents in document order */ + inject: string[]; } /** @@ -68,4 +70,6 @@ export interface ExpansionResult { prepend: string[]; /** Collected append blocks from all expanded snippets */ append: string[]; + /** Collected inject blocks from all expanded snippets */ + inject: string[]; }