mirror of
https://github.com/mksglu/context-mode.git
synced 2026-09-19 03:27:16 +08:00
6262c13283
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>
230 lines
8.5 KiB
TypeScript
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 };
|