Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 87 additions & 27 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<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) {
// 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 = {
Expand All @@ -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", {
Copy link
Copy Markdown
Owner

@JosXa JosXa Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahhh, this was the wording that finally clued me in on what this was about. Real messages kept at the top of the conversation.
Still not sure this is in scope of "snippets" :D But we can try

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);
Expand Down
44 changes: 42 additions & 2 deletions src/expander.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -118,15 +119,15 @@ 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);

// 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([
Expand Down Expand Up @@ -476,6 +477,7 @@ describe("parseSnippetBlocks", () => {
inline: "Just some content",
prepend: [],
append: [],
inject: [],
});
});

Expand All @@ -486,6 +488,7 @@ describe("parseSnippetBlocks", () => {
inline: "Inline text",
prepend: [],
append: ["Append content"],
inject: [],
});
});

Expand All @@ -496,6 +499,7 @@ describe("parseSnippetBlocks", () => {
inline: "Inline text",
prepend: ["Prepend content"],
append: [],
inject: [],
});
});

Expand All @@ -514,6 +518,7 @@ After content
inline: "Inline text",
prepend: ["Before content"],
append: ["After content"],
inject: [],
});
});

Expand All @@ -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<inject>\nInject content\n</inject>");

expect(result).toEqual({
inline: "Inline",
prepend: [],
append: [],
inject: ["Inject content"],
});
});
});
Expand All @@ -548,6 +565,7 @@ Only append content
inline: "",
prepend: [],
append: ["Only append content"],
inject: [],
});
});

Expand All @@ -560,6 +578,7 @@ Only append content
inline: "Inline",
prepend: [],
append: ["Unclosed append content"],
inject: [],
});
});

Expand Down Expand Up @@ -604,6 +623,7 @@ Only append content
inline: "",
prepend: [],
append: ["Content"],
inject: [],
});
});

Expand All @@ -616,6 +636,7 @@ Only append content
inline: "Inline",
prepend: [],
append: [],
inject: [],
});
});
});
Expand Down Expand Up @@ -647,6 +668,7 @@ describe("assembleMessage", () => {
text: "Main content",
prepend: [],
append: [],
inject: [],
});

expect(result).toBe("Main content");
Expand All @@ -657,6 +679,7 @@ describe("assembleMessage", () => {
text: "Main content",
prepend: [],
append: ["Appended section"],
inject: [],
});

expect(result).toBe("Main content\n\nAppended section");
Expand All @@ -667,6 +690,7 @@ describe("assembleMessage", () => {
text: "Main content",
prepend: ["Prepended section"],
append: [],
inject: [],
});

expect(result).toBe("Prepended section\n\nMain content");
Expand All @@ -677,6 +701,7 @@ describe("assembleMessage", () => {
text: "Main content",
prepend: ["Before"],
append: ["After"],
inject: [],
});

expect(result).toBe("Before\n\nMain content\n\nAfter");
Expand All @@ -687,6 +712,7 @@ describe("assembleMessage", () => {
text: "Main",
prepend: ["First", "Second"],
append: [],
inject: [],
});

expect(result).toBe("First\n\nSecond\n\nMain");
Expand All @@ -697,6 +723,7 @@ describe("assembleMessage", () => {
text: "Main",
prepend: [],
append: ["First", "Second"],
inject: [],
});

expect(result).toBe("Main\n\nFirst\n\nSecond");
Expand All @@ -707,6 +734,7 @@ describe("assembleMessage", () => {
text: "",
prepend: ["Before"],
append: ["After"],
inject: [],
});

expect(result).toBe("Before\n\nAfter");
Expand All @@ -717,6 +745,7 @@ describe("assembleMessage", () => {
text: " ",
prepend: ["Before"],
append: ["After"],
inject: [],
});

expect(result).toBe("Before\n\nAfter");
Expand Down Expand Up @@ -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<inject>\nInjected message\n</inject>"]]);

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
});
});
Loading