-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
Expand file tree
/
Copy pathplugin.ts
More file actions
283 lines (257 loc) · 11.2 KB
/
plugin.ts
File metadata and controls
283 lines (257 loc) · 11.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
/**
* 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;
});
}