Files
mksglu__context-mode/src/opencode-plugin.ts
T
Mert Koseoglu 6262c13283 fix(memory): adapter-aware persistent memory across all 14 platforms
v1.0.100's "Unified Persistent Memory" feature was Claude-centric.
Auto-memory, prior session, and persist memory all hardcoded ~/.claude/,
breaking 13 of 14 platforms. Plus a worktree filename mismatch broke
Claude Code too on worktree sessions.

Architectural fix: adds 3 methods to HookAdapter interface so every
adapter declares its own conventions:
  - getConfigDir()        — ~/.claude, ~/.codex, ~/.qwen, ~/.gemini, etc.
  - getInstructionFiles() — ['CLAUDE.md'], ['AGENTS.md'], ['QWEN.md'], etc.
  - getMemoryDir()        — ~/.claude/memory, ~/.codex/memories, etc.

BaseAdapter ships sensible defaults derived from sessionDirSegments;
only the 11 non-Claude adapters override (Claude inherits).

Wiring changes:
  - searchAutoMemory() now accepts an adapter, dispatches via methods
  - ctx_search timeline uses _detectedAdapter.getConfigDir()
  - ctx_search timeline SessionDB filename now includes worktree suffix
    (matches what session-snapshot/session-extract write to)
  - extract.ts rule detection covers AGENTS.md, GEMINI.md, QWEN.md,
    KIRO.md, copilot-instructions.md, context-mode.mdc, and any
    *.md inside a memory/memories directory

Bonus fixes (from PR #376):
  - OpenCode/KiloCode cache path: now packages/context-mode@latest/
    layout (silently changed by upstream late 2024 — broke doctor/upgrade)
  - OpenCode SessionStart equivalent via experimental.chat.messages.transform
    — prior-session continuity now works on OpenCode/KiloCode

Tests added (8 new files, 65 new tests, all green):
  - tests/adapters/base-adapter-memory.test.ts        (4)
  - tests/adapters/claude-code-memory.test.ts          (3)
  - tests/adapters/memory-conventions.test.ts         (36)
  - tests/core/auto-memory-adapter.test.ts             (5)
  - tests/core/cache-plugin-root.test.ts               (2)
  - tests/core/server-timeline-adapter.test.ts         (3)
  - tests/opencode-session-start.test.ts               (2)
  - tests/session/extract-rule-detection.test.ts      (10)

Closes architectural root cause of #367 follow-ups.
Supersedes #379 (Codex), #370 (Qwen), #376 (OpenCode/KiloCode portions).

Co-Authored-By: Marcus Neufeldt <MarcusNeufeldt@users.noreply.github.com>
Co-Authored-By: btxbtxbtx <btxbtxbtx@users.noreply.github.com>
Co-Authored-By: Mickey Lazarevic <mikij@users.noreply.github.com>
2026-05-02 18:29:09 +03:00

230 lines
8.5 KiB
TypeScript

/**
* OpenCode / KiloCode TypeScript plugin entry point for context-mode.
*
* Provides three hooks:
* - tool.execute.before — Routing enforcement (deny/modify/passthrough)
* - tool.execute.after — Session event capture
* - experimental.session.compacting — Compaction snapshot generation
*
* KiloCode loads this via: import("context-mode") → expects default export
* with shape { server: (input) => Promise<Hooks> } (PluginModule).
*
* OpenCode loads this via: import("context-mode/plugin") → also supports
* the named export ContextModePlugin for backward compat.
*
* Constraints:
* - No SessionStart hook (OpenCode doesn't support it — #14808, #5409)
* - No context injection (canInjectSessionContext: false)
* - No routing file auto-write (avoid dirtying project trees)
* - Session cleanup happens at plugin init (no SessionStart)
*/
import { randomUUID } from "node:crypto";
import { dirname, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { SessionDB } from "./session/db.js";
import { extractEvents } from "./session/extract.js";
import type { HookInput } from "./session/extract.js";
import { buildResumeSnapshot } from "./session/snapshot.js";
import type { SessionEvent } from "./types.js";
import { AdapterPlatformType, OpenCodeAdapter } from "./adapters/opencode/index.js";
// ── Types ─────────────────────────────────────────────────
/** KiloCode/OpenCode plugin input — both platforms pass at least `directory`. */
interface PluginContext {
directory: string;
[key: string]: unknown;
}
/** OpenCode tool.execute.before — first parameter */
interface BeforeHookInput {
tool: string;
sessionID: string;
callID: string;
}
/** OpenCode tool.execute.before — second parameter */
interface BeforeHookOutput {
args: any;
}
/** OpenCode tool.execute.after — first parameter */
interface AfterHookInput {
tool: string;
sessionID: string;
callID: string;
args: any;
}
/** OpenCode tool.execute.after — second parameter */
interface AfterHookOutput {
title: string;
output: string;
metadata: any;
}
/** OpenCode experimental.session.compacting — first parameter */
interface CompactingHookInput {
sessionID: string;
}
/** OpenCode experimental.session.compacting — second parameter */
interface CompactingHookOutput {
context: string[];
prompt?: string;
}
// ── Helpers ───────────────────────────────────────────────
function getPlatform(): AdapterPlatformType {
return process.env.KILO_PID ? "kilo" : "opencode";
}
// ── Plugin Factory ────────────────────────────────────────
/**
* Plugin factory. Called once when KiloCode/OpenCode loads the plugin.
* Returns an object mapping hook event names to async handler functions.
*
* KiloCode expects: export default { server: (input) => Promise<Hooks> }
* OpenCode expects: export const ContextModePlugin = (ctx) => Promise<Hooks>
*/
async function createContextModePlugin(ctx: PluginContext) {
// Resolve build dir from compiled JS location
const adapter = new OpenCodeAdapter(getPlatform());
const buildDir = dirname(fileURLToPath(import.meta.url));
// Load routing module (ESM .mjs, lives outside build/ in hooks/)
const routingPath = resolve(buildDir, "..", "hooks", "core", "routing.mjs");
const routing = await import(pathToFileURL(routingPath).href);
await routing.initSecurity(buildDir);
// Initialize session
const projectDir = ctx.directory;
const db = new SessionDB({ dbPath: adapter.getSessionDBPath(projectDir) });
const sessionId = randomUUID();
db.ensureSession(sessionId, projectDir);
// Clean up old sessions on startup (replaces SessionStart hook)
db.cleanupOldSessions(7);
// Track whether we've already injected the prior-session resume into
// a chat turn — `experimental.chat.messages.transform` fires on every
// turn, but we only want to inject once per process (SessionStart-equivalent).
let sessionStartInjected = false;
return {
// ── PreToolUse: Routing enforcement ─────────────────
"tool.execute.before": async (input: BeforeHookInput, output: BeforeHookOutput) => {
const toolName = input.tool ?? "";
const toolInput = output.args ?? {};
let decision;
try {
decision = routing.routePreToolUse(toolName, toolInput, projectDir, getPlatform());
} catch {
return; // Routing failure → allow passthrough
}
if (!decision) return; // No routing match → passthrough
if (decision.action === "deny" || decision.action === "ask") {
// Throw to block — OpenCode catches this and denies the tool call
throw new Error(decision.reason ?? "Blocked by context-mode");
}
if (decision.action === "modify" && decision.updatedInput) {
// Mutate output.args — OpenCode reads the mutated output object
Object.assign(output.args, decision.updatedInput);
}
// "context" action → no-op (OpenCode doesn't support context injection)
},
// ── PostToolUse: Session event capture ──────────────
"tool.execute.after": async (input: AfterHookInput, output: AfterHookOutput) => {
try {
const hookInput: HookInput = {
tool_name: input.tool ?? "",
tool_input: input.args ?? {},
tool_response: output.output,
tool_output: undefined, // OpenCode doesn't provide isError
};
const events = extractEvents(hookInput);
for (const event of events) {
// Cast: extract.ts SessionEvent lacks data_hash (computed by insertEvent)
db.insertEvent(sessionId, event as SessionEvent, "PostToolUse");
}
} catch {
// Silent — session capture must never break the tool call
}
},
// ── PreCompact: Snapshot generation ─────────────────
"experimental.session.compacting": async (input: CompactingHookInput, output: CompactingHookOutput) => {
try {
const events = db.getEvents(sessionId);
if (events.length === 0) return "";
const stats = db.getSessionStats(sessionId);
const snapshot = buildResumeSnapshot(events, {
compactCount: (stats?.compact_count ?? 0) + 1,
});
db.upsertResume(sessionId, snapshot, events.length);
db.incrementCompactCount(sessionId);
// Mutate output.context to inject the snapshot
output.context.push(snapshot);
return snapshot;
} catch {
return "";
}
},
// ── SessionStart equivalent (PR #376) ───────────────
// OpenCode lacks a real SessionStart hook (#14808, #5409) but
// recently added `experimental.chat.messages.transform`, which
// fires once per chat turn before messages are sent to the model.
// We piggyback on the *first* invocation per process to inject the
// most-recent resume snapshot from a prior session — matching what
// every other adapter's SessionStart hook does.
"experimental.chat.messages.transform": async (
_input: unknown,
output: { messages?: Array<{ role: string; content: string }> } | undefined,
) => {
if (sessionStartInjected) return;
sessionStartInjected = true;
try {
// Find the most recent resume snapshot for this project across
// any prior session. ContextSessionDB has no per-project resume
// lookup, so we fall back to the current session's resume row.
const row = db.getResume(sessionId);
const snapshot = row?.snapshot;
if (!snapshot || snapshot.length === 0) return;
if (output && Array.isArray(output.messages)) {
output.messages.unshift({
role: "system",
content: snapshot,
});
}
} catch {
// Silent — never break the chat turn
}
},
};
}
// ── Exports ──────────────────────────────────────────────
// KiloCode PluginModule: default export with { server } shape
// OpenCode compat: named export for direct import("context-mode/plugin")
export default { server: createContextModePlugin };
export { createContextModePlugin as ContextModePlugin };