From 8e80e5349e877eb1af9bb332f8bdaeee51034e9a Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:59:55 +0100 Subject: [PATCH 1/5] Add inject tag feature for snippets --- index.ts | 116 ++++++++++++++++++++++++++++++++++++++++--- src/expander.test.ts | 44 +++++++++++++++- src/expander.ts | 24 ++++++--- src/types.ts | 4 ++ 4 files changed, 174 insertions(+), 14 deletions(-) diff --git a/index.ts b/index.ts index ec7956a..a34960b 100644 --- a/index.ts +++ b/index.ts @@ -8,6 +8,7 @@ import { assembleMessage, expandHashtags } from "./src/expander.js"; import { loadSnippets } from "./src/loader.js"; import { logger } from "./src/logger.js"; import { executeShellCommands, type ShellContext } from "./src/shell.js"; +import type { ExpansionResult } from "./src/types.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -82,14 +83,19 @@ export const SnippetsPlugin: Plugin = async (ctx) => { // Create command handler const commandHandler = createCommandExecuteHandler(ctx.client, snippets, ctx.directory); + // Map to store active injections per session + const activeInjections = new Map(); + /** * Processes text parts for snippet expansion and shell command execution + * Returns collected inject blocks */ const processTextParts = async (parts: Array<{ type: string; text?: string }>) => { 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) { @@ -97,6 +103,7 @@ export const SnippetsPlugin: Plugin = async (ctx) => { const expandStart = performance.now(); const expansionResult = expandHashtags(part.text, snippets); part.text = assembleMessage(expansionResult); + allInjected.push(...expansionResult.inject); const expandTime = performance.now() - expandStart; expandTimeTotal += expandTime; @@ -118,8 +125,11 @@ export const SnippetsPlugin: Plugin = async (ctx) => { snippetExpandTimeMs: expandTimeTotal.toFixed(2), shellTimeMs: shellTimeTotal.toFixed(2), processedParts, + injectedCount: allInjected.length, }); } + + return allInjected; }; return { @@ -135,26 +145,120 @@ export const SnippetsPlugin: Plugin = async (ctx) => { // Handle /snippet command execution "command.execute.before": commandHandler, - "chat.message": async (_input, output) => { + "chat.message": async (input: any, output: any) => { // Only process user messages, never assistant messages 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: any) => "ignored" in part && part.ignored)) return; + + const injected = await processTextParts(output.parts); + + // Mark parts as processed to avoid re-processing in transform + output.parts.forEach((part: any) => { + if (part.type === "text") { + (part as any).snippetsProcessed = true; + } + }); + + const sessionID = input.sessionID; + + // CRITICAL: Clear old injections from this session FIRST + // This ensures injections only last for ONE user message cycle + if (activeInjections.has(sessionID)) { + logger.debug("Clearing previous injections for new user message", { + sessionID, + previousCount: activeInjections.get(sessionID)?.length || 0, + }); + activeInjections.delete(sessionID); + } + + // Store any NEW injections for this session + if (injected.length > 0) { + activeInjections.set(sessionID, injected); + logger.debug("Stored NEW inject blocks for session", { + sessionID, + count: injected.length, + }); + } }, // Process all messages including question tool responses - "experimental.chat.messages.transform": async (_input, output) => { + "experimental.chat.messages.transform": async (input: any, output: any) => { + // SessionID might be in different locations depending on OpenCode version + 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 if already processed in chat.message hook + if (message.parts.some((part: any) => (part as any).snippetsProcessed)) continue; // 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: any) => "ignored" in part && part.ignored)) continue; + + const injected = await processTextParts(message.parts); + + // Also collect injections found during transform (e.g. from tool outputs if they contain hashtags) + if (injected.length > 0 && sessionID) { + const existing = activeInjections.get(sessionID) || []; + // Deduplicate to avoid adding same injection multiple times in transform loop + const newInjections = injected.filter((inv) => !existing.includes(inv)); + if (newInjections.length > 0) { + activeInjections.set(sessionID, [...existing, ...newInjections]); + } + } } } + + // Inject active injections at the end of the message history + // WITHOUT synthetic flag so they're visible to the user + if (sessionID) { + const injections = activeInjections.get(sessionID); + logger.debug("Transform hook - checking for injections", { + sessionID, + hasInjections: !!injections, + injectionCount: injections?.length || 0, + totalSessions: activeInjections.size, + allSessionIDs: Array.from(activeInjections.keys()), + }); + if (injections && injections.length > 0) { + const beforeCount = output.messages.length; + for (const injectText of injections) { + output.messages.push({ + info: { + role: "user", + sessionID: sessionID, + } as any, + parts: [{ type: "text", text: injectText }], + }); + } + logger.debug("Injected ephemeral user messages", { + sessionID, + injectionCount: injections.length, + messagesBefore: beforeCount, + messagesAfter: output.messages.length, + }); + // DO NOT clear here - let session.idle handle it + } + } + }, + + // Clear active injections when the session goes idle + "session.idle": async ({ sessionID }: { sessionID: string }) => { + if (activeInjections.has(sessionID)) { + activeInjections.delete(sessionID); + logger.debug("Cleared active injections on session idle", { sessionID }); + } }, + // Process skill tool output to expand snippets in skill content + // Process skill tool output to expand snippets in skill content "tool.execute.after": async (input, output) => { // Only process the skill tool 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..309c6bc 100644 --- a/src/expander.ts +++ b/src/expander.ts @@ -10,7 +10,7 @@ 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 @@ -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/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[]; } From 175319a2c4a5bb2c3d1b891c919d24bc59bf56aa Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:12:10 +0100 Subject: [PATCH 2/5] refactor --- index.ts | 91 ++++++++++++++++------------------------ src/hook-types.ts | 46 ++++++++++++++++++++ src/injection-manager.ts | 86 +++++++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 54 deletions(-) create mode 100644 src/hook-types.ts create mode 100644 src/injection-manager.ts diff --git a/index.ts b/index.ts index a34960b..3268e3e 100644 --- a/index.ts +++ b/index.ts @@ -5,10 +5,17 @@ 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"; -import type { ExpansionResult } from "./src/types.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -83,14 +90,16 @@ export const SnippetsPlugin: Plugin = async (ctx) => { // Create command handler const commandHandler = createCommandExecuteHandler(ctx.client, snippets, ctx.directory); - // Map to store active injections per session - const activeInjections = new Map(); + // Injection manager handles lifecycle of inject blocks per session + const injectionManager = new InjectionManager(); /** - * Processes text parts for snippet expansion and shell command execution - * Returns collected inject blocks + * 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; @@ -104,24 +113,21 @@ export const SnippetsPlugin: Plugin = async (ctx) => { const expansionResult = expandHashtags(part.text, snippets); part.text = assembleMessage(expansionResult); allInjected.push(...expansionResult.inject); - const expandTime = performance.now() - expandStart; - expandTimeTotal += expandTime; + 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, @@ -145,45 +151,30 @@ export const SnippetsPlugin: Plugin = async (ctx) => { // Handle /snippet command execution "command.execute.before": commandHandler, - "chat.message": async (input: any, output: any) => { + "chat.message": async (input: ChatMessageInput, output: ChatMessageOutput) => { // Only process user messages, never assistant messages if (output.message.role !== "user") return; // Skip processing if any part is marked as ignored (e.g., command output) - if (output.parts.some((part: any) => "ignored" in part && part.ignored)) return; + if (output.parts.some((part) => part.ignored)) return; const injected = await processTextParts(output.parts); // Mark parts as processed to avoid re-processing in transform - output.parts.forEach((part: any) => { + output.parts.forEach((part) => { if (part.type === "text") { - (part as any).snippetsProcessed = true; + part.snippetsProcessed = true; } }); - const sessionID = input.sessionID; - - // CRITICAL: Clear old injections from this session FIRST - // This ensures injections only last for ONE user message cycle - if (activeInjections.has(sessionID)) { - logger.debug("Clearing previous injections for new user message", { - sessionID, - previousCount: activeInjections.get(sessionID)?.length || 0, - }); - activeInjections.delete(sessionID); - } - - // Store any NEW injections for this session - if (injected.length > 0) { - activeInjections.set(sessionID, injected); - logger.debug("Stored NEW inject blocks for session", { - sessionID, - count: injected.length, - }); - } + // Store new injections for this session (clears any previous injections automatically) + injectionManager.setInjections(input.sessionID, injected); }, // Process all messages including question tool responses - "experimental.chat.messages.transform": async (input: any, output: any) => { + "experimental.chat.messages.transform": async ( + input: TransformInput, + output: TransformOutput, + ) => { // SessionID might be in different locations depending on OpenCode version const sessionID = input.sessionID || input.session?.id || output.messages[0]?.info?.sessionID; @@ -198,20 +189,15 @@ export const SnippetsPlugin: Plugin = async (ctx) => { // Only process user messages if (message.info.role === "user") { // Skip if already processed in chat.message hook - if (message.parts.some((part: any) => (part as any).snippetsProcessed)) continue; + if (message.parts.some((part) => part.snippetsProcessed)) continue; // Skip processing if any part is marked as ignored (e.g., command output) - if (message.parts.some((part: any) => "ignored" in part && part.ignored)) continue; + if (message.parts.some((part) => part.ignored)) continue; const injected = await processTextParts(message.parts); // Also collect injections found during transform (e.g. from tool outputs if they contain hashtags) if (injected.length > 0 && sessionID) { - const existing = activeInjections.get(sessionID) || []; - // Deduplicate to avoid adding same injection multiple times in transform loop - const newInjections = injected.filter((inv) => !existing.includes(inv)); - if (newInjections.length > 0) { - activeInjections.set(sessionID, [...existing, ...newInjections]); - } + injectionManager.addInjections(sessionID, injected); } } } @@ -219,13 +205,13 @@ export const SnippetsPlugin: Plugin = async (ctx) => { // Inject active injections at the end of the message history // WITHOUT synthetic flag so they're visible to the user if (sessionID) { - const injections = activeInjections.get(sessionID); + const injections = injectionManager.getInjections(sessionID); logger.debug("Transform hook - checking for injections", { sessionID, hasInjections: !!injections, injectionCount: injections?.length || 0, - totalSessions: activeInjections.size, - allSessionIDs: Array.from(activeInjections.keys()), + totalSessions: injectionManager.size, + allSessionIDs: injectionManager.getAllSessionIDs(), }); if (injections && injections.length > 0) { const beforeCount = output.messages.length; @@ -234,7 +220,7 @@ export const SnippetsPlugin: Plugin = async (ctx) => { info: { role: "user", sessionID: sessionID, - } as any, + }, parts: [{ type: "text", text: injectText }], }); } @@ -250,11 +236,8 @@ export const SnippetsPlugin: Plugin = async (ctx) => { }, // Clear active injections when the session goes idle - "session.idle": async ({ sessionID }: { sessionID: string }) => { - if (activeInjections.has(sessionID)) { - activeInjections.delete(sessionID); - logger.debug("Cleared active injections on session idle", { sessionID }); - } + "session.idle": async (event: SessionIdleEvent) => { + injectionManager.clearSession(event.sessionID); }, // Process skill tool output to expand snippets in skill content 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..c239d5e --- /dev/null +++ b/src/injection-manager.ts @@ -0,0 +1,86 @@ +import { logger } from "./logger.js"; + +/** + * Manages injection lifecycle per session. + * Injections are ephemeral messages that last for exactly one user message cycle. + */ +export class InjectionManager { + private activeInjections = new Map(); + + /** + * Stores new injections for a session, clearing any previous injections. + * This ensures injections only last for ONE user message cycle. + */ + setInjections(sessionID: string, injections: string[]): void { + // Clear old injections first + if (this.activeInjections.has(sessionID)) { + logger.debug("Clearing previous injections for new user message", { + sessionID, + previousCount: this.activeInjections.get(sessionID)?.length || 0, + }); + this.activeInjections.delete(sessionID); + } + + // Store new injections if any + if (injections.length > 0) { + this.activeInjections.set(sessionID, injections); + logger.debug("Stored NEW inject blocks for session", { + sessionID, + count: injections.length, + }); + } + } + + /** + * Adds additional injections to an existing session, deduplicating to avoid duplicates. + * Used when transform hook discovers additional injections. + */ + 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); + } + + /** + * Checks if there are active injections for a session. + */ + has(sessionID: string): boolean { + return this.activeInjections.has(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 }); + } + } + + /** + * Gets the number of sessions with active injections. + */ + get size(): number { + return this.activeInjections.size; + } + + /** + * Gets all session IDs with active injections. + */ + getAllSessionIDs(): string[] { + return Array.from(this.activeInjections.keys()); + } +} From 72e5a16527603cbbb1498431249429942cd922ba Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:16:44 +0100 Subject: [PATCH 3/5] rm slop --- index.ts | 25 ------------------------- src/injection-manager.ts | 8 ++------ 2 files changed, 2 insertions(+), 31 deletions(-) diff --git a/index.ts b/index.ts index 3268e3e..4b7b04f 100644 --- a/index.ts +++ b/index.ts @@ -90,7 +90,6 @@ export const SnippetsPlugin: Plugin = async (ctx) => { // Create command handler const commandHandler = createCommandExecuteHandler(ctx.client, snippets, ctx.directory); - // Injection manager handles lifecycle of inject blocks per session const injectionManager = new InjectionManager(); /** @@ -108,14 +107,12 @@ export const SnippetsPlugin: Plugin = async (ctx) => { 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); 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, @@ -139,7 +136,6 @@ export const SnippetsPlugin: Plugin = async (ctx) => { }; return { - // Register /snippet command config: async (opencodeConfig) => { opencodeConfig.command ??= {}; opencodeConfig.command.snippet = { @@ -148,34 +144,27 @@ export const SnippetsPlugin: Plugin = async (ctx) => { }; }, - // Handle /snippet command execution "command.execute.before": commandHandler, "chat.message": async (input: ChatMessageInput, output: ChatMessageOutput) => { - // Only process user messages, never assistant messages if (output.message.role !== "user") return; - // Skip processing if any part is marked as ignored (e.g., command output) if (output.parts.some((part) => part.ignored)) return; const injected = await processTextParts(output.parts); - // Mark parts as processed to avoid re-processing in transform output.parts.forEach((part) => { if (part.type === "text") { part.snippetsProcessed = true; } }); - // Store new injections for this session (clears any previous injections automatically) injectionManager.setInjections(input.sessionID, injected); }, - // Process all messages including question tool responses "experimental.chat.messages.transform": async ( input: TransformInput, output: TransformOutput, ) => { - // SessionID might be in different locations depending on OpenCode version const sessionID = input.sessionID || input.session?.id || output.messages[0]?.info?.sessionID; logger.debug("Transform hook called", { @@ -186,24 +175,18 @@ export const SnippetsPlugin: Plugin = async (ctx) => { }); for (const message of output.messages) { - // Only process user messages if (message.info.role === "user") { - // Skip if already processed in chat.message hook if (message.parts.some((part) => part.snippetsProcessed)) continue; - // Skip processing if any part is marked as ignored (e.g., command output) if (message.parts.some((part) => part.ignored)) continue; const injected = await processTextParts(message.parts); - // Also collect injections found during transform (e.g. from tool outputs if they contain hashtags) if (injected.length > 0 && sessionID) { injectionManager.addInjections(sessionID, injected); } } } - // Inject active injections at the end of the message history - // WITHOUT synthetic flag so they're visible to the user if (sessionID) { const injections = injectionManager.getInjections(sessionID); logger.debug("Transform hook - checking for injections", { @@ -230,25 +213,17 @@ export const SnippetsPlugin: Plugin = async (ctx) => { messagesBefore: beforeCount, messagesAfter: output.messages.length, }); - // DO NOT clear here - let session.idle handle it } } }, - // Clear active injections when the session goes idle "session.idle": async (event: SessionIdleEvent) => { injectionManager.clearSession(event.sessionID); }, - // Process skill tool output to expand snippets in skill content - - // Process skill tool output to expand snippets in skill content "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/injection-manager.ts b/src/injection-manager.ts index c239d5e..68d0f42 100644 --- a/src/injection-manager.ts +++ b/src/injection-manager.ts @@ -9,10 +9,8 @@ export class InjectionManager { /** * Stores new injections for a session, clearing any previous injections. - * This ensures injections only last for ONE user message cycle. */ setInjections(sessionID: string, injections: string[]): void { - // Clear old injections first if (this.activeInjections.has(sessionID)) { logger.debug("Clearing previous injections for new user message", { sessionID, @@ -21,10 +19,9 @@ export class InjectionManager { this.activeInjections.delete(sessionID); } - // Store new injections if any if (injections.length > 0) { this.activeInjections.set(sessionID, injections); - logger.debug("Stored NEW inject blocks for session", { + logger.debug("Stored inject blocks for session", { sessionID, count: injections.length, }); @@ -32,8 +29,7 @@ export class InjectionManager { } /** - * Adds additional injections to an existing session, deduplicating to avoid duplicates. - * Used when transform hook discovers additional injections. + * Adds additional injections to an existing session without duplicates. */ addInjections(sessionID: string, newInjections: string[]): void { if (newInjections.length === 0) return; From 0747aaaace55c20caec82eb3bab88aaff94ce667 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 27 Jan 2026 01:39:07 +0100 Subject: [PATCH 4/5] simpler --- index.ts | 2 -- src/injection-manager.ts | 35 ++--------------------------------- 2 files changed, 2 insertions(+), 35 deletions(-) diff --git a/index.ts b/index.ts index 4b7b04f..3ca392e 100644 --- a/index.ts +++ b/index.ts @@ -193,8 +193,6 @@ export const SnippetsPlugin: Plugin = async (ctx) => { sessionID, hasInjections: !!injections, injectionCount: injections?.length || 0, - totalSessions: injectionManager.size, - allSessionIDs: injectionManager.getAllSessionIDs(), }); if (injections && injections.length > 0) { const beforeCount = output.messages.length; diff --git a/src/injection-manager.ts b/src/injection-manager.ts index 68d0f42..a1f4d16 100644 --- a/src/injection-manager.ts +++ b/src/injection-manager.ts @@ -11,20 +11,10 @@ export class InjectionManager { * Stores new injections for a session, clearing any previous injections. */ setInjections(sessionID: string, injections: string[]): void { - if (this.activeInjections.has(sessionID)) { - logger.debug("Clearing previous injections for new user message", { - sessionID, - previousCount: this.activeInjections.get(sessionID)?.length || 0, - }); - this.activeInjections.delete(sessionID); - } - if (injections.length > 0) { this.activeInjections.set(sessionID, injections); - logger.debug("Stored inject blocks for session", { - sessionID, - count: injections.length, - }); + } else { + this.activeInjections.delete(sessionID); } } @@ -49,13 +39,6 @@ export class InjectionManager { return this.activeInjections.get(sessionID); } - /** - * Checks if there are active injections for a session. - */ - has(sessionID: string): boolean { - return this.activeInjections.has(sessionID); - } - /** * Clears all injections for a session. */ @@ -65,18 +48,4 @@ export class InjectionManager { logger.debug("Cleared active injections on session idle", { sessionID }); } } - - /** - * Gets the number of sessions with active injections. - */ - get size(): number { - return this.activeInjections.size; - } - - /** - * Gets all session IDs with active injections. - */ - getAllSessionIDs(): string[] { - return Array.from(this.activeInjections.keys()); - } } From 3ead587750cdc1a28a8ab8b275597c99de158563 Mon Sep 17 00:00:00 2001 From: spoons-and-mirrors <212802214+spoons-and-mirrors@users.noreply.github.com> Date: Tue, 27 Jan 2026 01:50:58 +0100 Subject: [PATCH 5/5] comment --- src/expander.ts | 2 +- src/injection-manager.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/expander.ts b/src/expander.ts index 309c6bc..e1add09 100644 --- a/src/expander.ts +++ b/src/expander.ts @@ -13,7 +13,7 @@ const MAX_EXPANSION_COUNT = 15; 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 diff --git a/src/injection-manager.ts b/src/injection-manager.ts index a1f4d16..244b49e 100644 --- a/src/injection-manager.ts +++ b/src/injection-manager.ts @@ -2,7 +2,7 @@ import { logger } from "./logger.js"; /** * Manages injection lifecycle per session. - * Injections are ephemeral messages that last for exactly one user message cycle. + * Injections persist for the entire agentic loop until session idle. */ export class InjectionManager { private activeInjections = new Map();