Files
context-mode/src/adapters/omp/plugin.ts
T
Mert Koseoglu 019562f681 fix(pi,omp): route SessionDB through resolveSessionDbPath so MCP server reads same file (#645)
The Pi extension and the OMP plugin opened their SessionDB at a
hardcoded `<sessionDir>/context-mode.db` literal. The MCP server
(`src/server.ts` ctx_stats and ctx_search(sort: "timeline"))
reads from the canonical `<16-hex-hash>.db` produced by
`resolveSessionDbPath`. The two paths are never the same file for
any real projectDir, so both MCP tools silently degraded for every
Pi and OMP user: `ctx_stats` reported zero history and timeline
sort was dropped.

Root cause: the C2 narrowing refactor (d923716) migrated only
opencode/plugin.ts to the canonical helper. Pi (introduced in
c805410, predating resolveSessionDbPath) and OMP (copy-pasted
from Pi in 2ddae39) were left behind.

Fix: route `getDBPath`/`getOrCreateDB` through
`resolveSessionDbPath({ projectDir, sessionsDir: getSessionDir() })`
in both adapters, mirroring opencode's pattern at
`src/adapters/opencode/plugin.ts:307-309`. Defensive re-keying of
the singleton on resolved path also removes a stale-singleton
foot-gun visible in tests.

Tests:
- Add one new regression test per adapter pinning the canonical
  contract (`tests/pi-extension.test.ts`, `tests/adapters/omp-plugin.test.ts`).
- Migrate four pre-existing tests that incidentally pinned the
  buggy literal as the contract.
- `registerOmpPlugin` now sets PI_PROJECT_DIR (the env the
  production plugin actually reads) alongside the legacy
  OMP_PROJECT_DIR so test and production resolvers agree on the hash.

Side effect: existing on-disk `context-mode.db` literals from
prior installs become orphans (reporter flagged this; manual
cleanup or a separate literal-to-canonical migration helper).
No worsening — those files were already invisible to the MCP
server.

Full suite: 3408/3408 passed, 30 skipped, 0 failed across 147
test files. `npx tsc --noEmit` clean.
2026-05-20 10:49:42 +03:00

284 lines
11 KiB
TypeScript

/**
* Oh My Pi (OMP) plugin entry point for context-mode.
*
* Mirrors the Pi extension shape (`src/adapters/pi/extension.ts`) for
* the four OMP hook events that materially protect the context window
* and persist session continuity:
*
* - session_start — initialize the session row in our DB
* - tool_call — hard-block curl/wget/inline-HTTP in bash
* - tool_result — extract structured events into the session DB
* - session_before_compact — persist a resume snapshot before compaction
*
* Loaded by OMP via the `omp` (or `pi`) field in package.json — see
* upstream loader at refs/platforms/oh-my-pi/packages/coding-agent/src/
* extensibility/plugins/loader.ts:75:
* `const manifest: PluginManifest | undefined = pluginPkg.omp || pluginPkg.pi;`
* Hook factory contract from refs/.../extensibility/hooks/types.ts:809:
* `export type HookFactory = (pi: HookAPI) => void;`
*
* OMP differs from Pi in two ways that justify a dedicated plugin file:
* 1. Storage roots at ~/.omp/context-mode/ via OMPAdapter, not ~/.pi/
* 2. OMP has native MCP support (mcp.json), so no MCP bridge is needed
* — the bridge that Pi's extension ships (mcp-bridge.ts) is dead weight
* under OMP and is intentionally omitted here.
*/
import { createHash } from "node:crypto";
import { mkdirSync } from "node:fs";
import { join } from "node:path";
import { resolveSessionDbPath, 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 { OMPAdapter } from "./index.js";
// ── Tool-name normalization ─────────────────────────────
// OMP uses lowercase tool names (refs/.../hooks/types.ts:451 example
// `toolName: "bash"`). Shared event extractors expect PascalCase
// (Claude Code convention). Map the common ones.
const OMP_TOOL_MAP: Record<string, string> = {
bash: "Bash",
edit: "Edit",
read: "Read",
write: "Write",
list: "Glob",
view: "Read",
};
// ── Routing patterns ─────────────────────────────────────
// Inline HTTP client patterns to hard-block in bash. Identical to the
// Pi extension list (src/adapters/pi/extension.ts:42). One unrouted
// curl can dump 56 KB into context.
const BLOCKED_BASH_PATTERNS: RegExp[] = [
/\bcurl\s/,
/\bwget\s/,
/\bfetch\s*\(/,
/\brequests\.get\s*\(/,
/\brequests\.post\s*\(/,
/\bhttp\.get\s*\(/,
/\bhttp\.request\s*\(/,
/\burllib\.request/,
/\bInvoke-WebRequest\b/,
];
// ── Module-level singletons ──────────────────────────────
// Same shape as Pi: one DB per process, session ID rebound on each
// session_start so multi-session reuse within a long-lived plugin
// process keeps event attribution correct.
let _db: SessionDB | null = null;
let _dbPath = "";
let _sessionId = "";
const _ompAdapter = new OMPAdapter();
function getSessionDir(): string {
const dir = _ompAdapter.getSessionDir();
mkdirSync(dir, { recursive: true });
return dir;
}
// Issue #645 — route through the canonical per-project resolver the MCP
// server uses (src/server.ts ctx_stats / ctx_search timeline). The
// previous shared `context-mode.db` literal was a different file from
// the `<canonical-hash>.db` the server reads, so every OMP user's
// `ctx_stats` reported zero history and `ctx_search(sort: "timeline")`
// silently dropped the sort. Mirrors the matching Pi fix and the
// opencode plugin pattern (src/adapters/opencode/plugin.ts:307).
function getDBPath(projectDir: string): string {
return resolveSessionDbPath({ projectDir, sessionsDir: getSessionDir() });
}
function getOrCreateDB(projectDir: string): SessionDB {
// Reopen the singleton if the resolved DB path changes. See the
// matching Pi extension comment — defensive re-keying on projectDir
// hash keeps tests deterministic and stops a stale singleton from
// pointing at an earlier projectDir's `<hash>.db`. (#645)
const dbPath = getDBPath(projectDir);
if (!_db || _dbPath !== dbPath) {
if (_db) {
try { _db.close(); } catch { /* best effort */ }
}
_db = new SessionDB({ dbPath });
_dbPath = dbPath;
}
return _db;
}
/**
* Derive a stable session ID from OMP's session manager when available,
* otherwise fall back to a wall-clock token. Mirrors the Pi extension
* derivation (src/adapters/pi/extension.ts:142) — the OMP `ctx` object
* exposes `sessionManager.getSessionFile()` per refs/.../hooks/types.ts.
*/
function deriveSessionId(ctx: Record<string, unknown> | undefined): string {
try {
const sessionManager = (ctx as { sessionManager?: { getSessionFile?: () => string } } | undefined)
?.sessionManager;
const sessionFile = sessionManager?.getSessionFile?.();
if (sessionFile && typeof sessionFile === "string") {
return createHash("sha256").update(sessionFile).digest("hex").slice(0, 16);
}
} catch {
// best effort
}
return `omp-${Date.now()}`;
}
// ── Test-only state reset (NOT exported via plugin entry) ───────────
// The plugin's default export is the OMP factory; this helper is only
// imported by tests to clear singletons between cases.
export function _resetOmpPluginStateForTests(): void {
if (_db) {
try { _db.close(); } catch { /* best effort */ }
}
_db = null;
_dbPath = "";
_sessionId = "";
}
/**
* Return the current session ID picked by the most recent session_start
* handler. Test-only — production code reads `_sessionId` directly via
* the closure. The shared SQLite DB at `~/.omp/context-mode/` survives
* between tests, so `getLatestSessionId()` cannot disambiguate which
* row belongs to "this" test when multiple tests insert in the same
* second; tests use this getter instead.
*/
export function _getOmpPluginSessionIdForTests(): string {
return _sessionId;
}
// ── HookAPI shape (local declaration; type erased at runtime) ──────
// We deliberately do NOT take a hard dependency on
// @oh-my-pi/pi-coding-agent. The runtime shape below mirrors the
// upstream HookAPI signature at refs/.../hooks/types.ts:695.
type ToolCallEvent = { toolName: string; toolCallId?: string; input?: Record<string, unknown> };
type ToolResultEvent = {
toolName: string;
toolCallId?: string;
input?: Record<string, unknown>;
content?: Array<{ type: string; text?: string }>;
isError?: boolean;
};
type ToolCallEventResult = { block?: boolean; reason?: string };
type HookEventCtx = Record<string, unknown> | undefined;
type HookHandler<E, R = void> = (event: E, ctx: HookEventCtx) => R | undefined | Promise<R | undefined>;
export interface MinimalHookAPI {
on(event: "session_start", handler: HookHandler<{ type: "session_start" }>): void;
on(event: "session_before_compact", handler: HookHandler<{ type: "session_before_compact" }>): void;
on(event: "tool_call", handler: HookHandler<ToolCallEvent, ToolCallEventResult>): void;
on(event: "tool_result", handler: HookHandler<ToolResultEvent>): void;
on(event: string, handler: (...args: unknown[]) => unknown): void;
}
// ── Plugin entry point ───────────────────────────────────
/**
* OMP plugin default export. Called once by the OMP runtime per
* upstream `extensibility/plugins/loader.ts` after `omp plugin install
* context-mode`. Subsequent `pi.on(...)` registrations route the four
* lifecycle events to our SessionDB-backed handlers below.
*/
export default function ompPlugin(pi: MinimalHookAPI): void {
// OMP upstream uses PI_-prefixed env vars only (verified against
// can1357/oh-my-pi v3.20.1 — see `packages/utils/src/dirs.ts`). The
// earlier `OMP_PROJECT_DIR` read was an EM mistake — no upstream code
// ever sets it. Drop it; fall through PI_PROJECT_DIR → cwd().
const projectDir = process.env.PI_PROJECT_DIR || process.cwd();
const db = getOrCreateDB(projectDir);
// ── 1. session_start — initialize session row ─────────
pi.on("session_start", (_event, ctx) => {
try {
_sessionId = deriveSessionId(ctx);
db.ensureSession(_sessionId, projectDir);
db.cleanupOldSessions(7);
} catch {
// best effort — never break session start
if (!_sessionId) {
_sessionId = `omp-${Date.now()}`;
}
}
return undefined;
});
// ── 2. tool_call — pre-tool-call hard-block ───────────
// Returning `{block: true, reason}` per
// refs/.../hooks/types.ts:566 (ToolCallEventResult) terminates the
// tool call with the reason surfaced to the LLM.
pi.on("tool_call", (event) => {
try {
const toolName = String(event?.toolName ?? "").toLowerCase();
if (toolName !== "bash") return undefined;
const command = String((event?.input as { command?: unknown } | undefined)?.command ?? "");
if (!command) return undefined;
const isBlocked = BLOCKED_BASH_PATTERNS.some((p) => p.test(command));
if (isBlocked) {
return {
block: true,
reason:
"Use context-mode MCP tools (ctx_execute, ctx_fetch_and_index) instead of inline HTTP. " +
"curl/wget/fetch dump raw HTTP into the context window.",
};
}
} catch {
// routing failure → allow passthrough
}
return undefined;
});
// ── 3. tool_result — post-tool-call event capture ─────
// OMP `tool_result` payload (refs/.../hooks/types.ts:461 onward) is
// `{toolName, toolCallId, input, content[], isError}`. We adapt to
// the Claude Code-shaped HookInput consumed by extractEvents.
pi.on("tool_result", (event) => {
try {
if (!_sessionId) return undefined;
const rawToolName = String(event?.toolName ?? "");
const mappedToolName = OMP_TOOL_MAP[rawToolName.toLowerCase()] ?? rawToolName;
const content = Array.isArray(event?.content) ? event.content : [];
const textParts = content
.filter((c): c is { type: string; text: string } => c?.type === "text" && typeof c.text === "string")
.map((c) => c.text);
const resultStr = textParts.join("\n");
const hookInput: HookInput = {
tool_name: mappedToolName,
tool_input: (event?.input as Record<string, unknown>) ?? {},
tool_response: resultStr,
tool_output: event?.isError ? { isError: true } : undefined,
};
const events = extractEvents(hookInput);
for (const ev of events) {
db.insertEvent(_sessionId, ev as SessionEvent, "PostToolUse");
}
} catch {
// best effort
}
return undefined;
});
// ── 4. session_before_compact — resume snapshot ───────
pi.on("session_before_compact", () => {
try {
if (!_sessionId) return undefined;
const events = db.getEvents(_sessionId);
const snapshot = buildResumeSnapshot(events);
db.upsertResume(_sessionId, snapshot, events.length);
db.incrementCompactCount(_sessionId);
} catch {
// best effort
}
return undefined;
});
}