diff --git a/.gitignore b/.gitignore index b237e843d..df143050b 100644 --- a/.gitignore +++ b/.gitignore @@ -112,3 +112,4 @@ packages/plugin/scripts/experiments/issue-195-repro.mjs packages/plugin/scripts/experiments/visual-memory/render-trimmed-memories.ts packages/plugin/scripts/experiments/visual-memory/trials/REPORT.md packages/plugin/scripts/mural-test-output/ +AgentLogs/ diff --git a/CONFIGURATION.md b/CONFIGURATION.md index 93376a89c..4c0a8f8bd 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -1,10 +1,10 @@ # Configuration Reference -All settings are flat top-level keys in `magic-context.jsonc`. The schema is **shared between the OpenCode plugin and the Pi extension** — every setting documented here applies to both unless explicitly marked **Pi only** or **OpenCode only**. +All settings are flat top-level keys in `magic-context.jsonc`. The schema is shared by the OpenCode plugin and the Pi-compatible extension used on both Pi and OMP. ### Configuration locations -Magic Context reads config from **one shared CortexKit location**, the same for both harnesses (project overrides user): +Magic Context reads config from one shared CortexKit location across OpenCode, Pi, and OMP (project overrides user): | Path | Scope | |---|---| @@ -19,12 +19,12 @@ Project config always merges on top of user config. The unified setup wizard (`n Both plugins write to the same SQLite database at `~/.local/share/cortexkit/magic-context/context.db`. Tables are scoped by: -- `harness` column (`'opencode'` or `'pi'`) for **session-scoped** data — tags, compartments, session facts, notes +- `harness` column (`'opencode'` or `'pi'`) for **session-scoped** data — OMP intentionally uses the Pi-compatible `'pi'` discriminator - `project_path` (resolved git root) for **project-scoped** data — memories, embeddings, dreamer runs, key-file pins, smart notes -So memories you write in OpenCode appear in Pi sessions for the same project (and vice versa), while per-session compartments and tags stay correctly attributed to their originating harness. +Project memories therefore flow across OpenCode, Pi, and OMP, while per-session state remains scoped to the OpenCode or Pi-compatible runtime. -For semantic search to work cross-harness, both plugins resolve embedding config per project identity on every retrieval path. OpenCode and Pi can run in the same process against different projects without sharing one process-global embedding provider. For one project, keep the effective `embedding` block consistent across the OpenCode and Pi config stack; Magic Context tags stored vectors with the resolved model identity and clears stale vectors for that project when the provider/model changes. +For semantic search to work cross-harness, every host resolves embedding config per project identity on each retrieval path. Keep the effective `embedding` block consistent across OpenCode, Pi, and OMP for the same project. ### JSON Schema @@ -43,19 +43,22 @@ Both setup wizards add this automatically. If something isn't working, run the unified doctor to auto-detect installed harnesses and fix common issues: ```bash -# Auto-detect installed harnesses; if both, picks the first or asks +# Auto-detect installed harnesses; if multiple are present, pick or prompt npx @cortexkit/magic-context@latest doctor # Target a specific harness explicitly npx @cortexkit/magic-context@latest doctor --harness opencode npx @cortexkit/magic-context@latest doctor --harness pi +npx @cortexkit/magic-context@latest doctor --harness omp ``` The OpenCode doctor checks: installation, CLI version vs npm latest, plugin registration (preserves local dev paths), `magic-context.jsonc` parses + loads through the schema, conflicts (compaction, DCP, OMO hooks), TUI sidebar configuration, embedding endpoint, shared-DB existence + `PRAGMA integrity_check` + row counts, plugin npm cache, and historian debug dumps. The Pi doctor checks: Pi binary + version (requires `>= 0.71.0`), CLI version vs npm latest, settings registration, config validity, embedding endpoint reachability, shared-DB integrity, stale Pi extension caches, and historian debug dumps. -Both report `PASS X / WARN Y / FAIL Z` summary counts. Use `--force` to auto-fix what doctor can (clears stale plugin cache, repairs config) and `--issue` to produce a sanitized issue report. +The OMP doctor checks the OMP version, effective plugin enable state, `PI_CODING_AGENT_DIR`/profile/XDG path agreement, native compaction and automatic-memory conflicts, config validity, and shared DB integrity. `--force` installs/enables the plugin and repairs conflicting OMP settings. + +All doctors report `PASS X / WARN Y / FAIL Z` summary counts. Use `--force` for safe repairs and `--issue` to produce a sanitized issue report. ### SQLite backend diff --git a/README.md b/README.md index 3292423a7..de62e31aa 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ Magic Context gives them one. It is the **hippocampus** for coding agents, the p - **Capture.** As the historian compresses your history, it lifts the durable knowledge (decisions, constraints, conventions) into project memory. You get a memory system for free, from work you are already doing. - **Consolidate.** Overnight, dreamer agents do what sleep does for you: verify memories against the codebase, curate duplicates and stale entries, and promote what recurs. -- **Recall.** The right memories surface automatically every turn, and the agent can search across memories, past conversations, and git history on demand. Across sessions, and across OpenCode and Pi. +- **Recall.** The right memories surface automatically every turn, and the agent can search across memories, past conversations, and git history on demand. Across sessions, and across OpenCode, Pi, and OMP. Two promises: your agent **never stops to manage its context** (no compaction pauses, no broken flow) and it **never forgets**. @@ -96,7 +96,7 @@ irm https://raw.githubusercontent.com/cortexkit/magic-context/master/scripts/ins npx @cortexkit/magic-context@latest setup ``` -The wizard auto-detects which harnesses you have (OpenCode, Pi, or both), adds the plugin, disables built-in compaction, helps you pick models for the historian, dreamer, and sidekick, and resolves conflicts with other context-management plugins. Target a specific harness with `--harness opencode` or `--harness pi`. +The wizard auto-detects which harnesses you have (OpenCode, Pi, OMP, or any combination), adds the plugin, disables built-in compaction, helps you pick models for the historian, dreamer, and sidekick, and resolves conflicts with other context-management plugins. Target one with `--harness opencode`, `--harness pi`, or `--harness omp`. > **Why disable built-in compaction?** Magic Context manages context itself. The host's compaction would interfere with its cache-aware deferred operations and double-compress. @@ -111,9 +111,11 @@ The wizard auto-detects which harnesses you have (OpenCode, Pi, or both), adds t **Pi:** `npx @cortexkit/magic-context@latest setup --harness pi` (requires Pi `>= 0.74.0`). The Pi extension shares the same database as OpenCode; project memories and embeddings pool across both. -**Troubleshooting:** `npx @cortexkit/magic-context@latest doctor` auto-detects your harnesses, checks for conflicts (compaction, OMO hooks, DCP), verifies the plugin and TUI sidebar, runs an integrity check on the database, and fixes what it can. Add `--issue` to file a ready-to-submit bug report. +**Oh My Pi (OMP):** `npx @cortexkit/magic-context@latest setup --harness omp` (requires OMP `>= 17.1.7`). Setup installs the Pi-compatible extension through `omp plugin`, disables OMP native compaction and automatic memory, and honors OMP profiles, `PI_CODING_AGENT_DIR`, and initialized XDG layouts. -Works the same on a brand-new or a long-running project: install, restart the harness, and Magic Context captures context from that point forward. It does not backfill OpenCode or Pi sessions from before it was installed. +**Troubleshooting:** `npx @cortexkit/magic-context@latest doctor` auto-detects your harnesses, checks host-specific conflicts, verifies plugin registration and database integrity, and fixes what it can. Add `--issue` to file a ready-to-submit bug report. + +Works the same on a brand-new or a long-running project: install, restart the harness, and Magic Context captures context from that point forward. It does not backfill OpenCode, Pi, or OMP sessions from before it was installed.
Compatibility with other context-management plugins @@ -123,6 +125,8 @@ Works the same on a brand-new or a long-running project: install, restart the ha Magic Context owns context management end to end, so it **disables itself** if another plugin is already doing that job. Running two context managers at once would double-compress your history and thrash the prompt cache. On startup it checks for the following; setup and `doctor` help you resolve each, and until they're resolved Magic Context stays off (fail-safe) and tells you why: - **OpenCode built-in compaction** (`compaction.auto` / `compaction.prune`) — Magic Context replaces it. Setup turns it off. +- **OMP native compaction** (`compaction.enabled`) — Magic Context replaces it. OMP setup turns it off transactionally. +- **OMP automatic memory** (`memory.backend`) — a second memory injector duplicates recall and retention. OMP setup sets it to `off`; existing data is not deleted. - **DCP** (`opencode-dcp`) — a separate context-pruning plugin. The two cannot run together; remove it from your `plugin` list. - **oh-my-opencode (OMO)** — setup offers to disable the three hooks that overlap: - `preemptive-compaction` — triggers compaction that conflicts with the historian. @@ -215,7 +219,7 @@ Because it runs during idle time, the dreamer pairs well with local models, even - **`ctx_expand`**: pull a compressed history range back to the original `U:`/`A:` transcript when the agent needs the exact details. - **`ctx_note`**: a scratchpad for deferred intentions. Notes resurface at natural boundaries (after commits, after historian runs, when todos finish). **Smart notes** carry an open-ended condition the dreamer watches for. -Recall works **across sessions** (a new session inherits everything) and **across harnesses** (write a memory in OpenCode, retrieve it in Pi). +Recall works **across sessions** (a new session inherits everything) and **across harnesses** (write a memory in OpenCode, retrieve it in Pi or OMP). > **Auto search hints** *(on by default)* run a background `ctx_search` each turn and whisper a "vague recall" when something relevant exists — like almost remembering a note you took. It appends only compact fragments, never full content; set `memory.auto_search.enabled: false` to turn it off. **Git commit indexing** *(opt-in)* makes your project history semantically searchable as an additional `ctx_search` source — enable with `memory.git_commit_indexing.enabled: true`. diff --git a/packages/cli/package.json b/packages/cli/package.json index add169378..0ee80e533 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,12 +1,14 @@ { "name": "@cortexkit/magic-context", "version": "0.33.0", - "description": "Unified CLI for Magic Context — setup, doctor, and migration across OpenCode and Pi", + "description": "Unified CLI for Magic Context — setup, doctor, and migration across OpenCode, Pi, and OMP", "keywords": [ "opencode", "opencode-plugin", "pi", "pi-extension", + "omp", + "oh-my-pi", "magic-context", "cli", "setup", diff --git a/packages/cli/src/adapters/index.ts b/packages/cli/src/adapters/index.ts index 069c41aa8..b4b45b033 100644 --- a/packages/cli/src/adapters/index.ts +++ b/packages/cli/src/adapters/index.ts @@ -1,11 +1,12 @@ +import { OmpAdapter } from "./omp"; import { OpenCodeAdapter } from "./opencode"; import { PiAdapter } from "./pi"; import type { HarnessAdapter, HarnessKind } from "./types"; export type { HarnessAdapter, HarnessKind } from "./types"; -export { OpenCodeAdapter, PiAdapter }; +export { OmpAdapter, OpenCodeAdapter, PiAdapter }; -const ALL: HarnessAdapter[] = [new OpenCodeAdapter(), new PiAdapter()]; +const ALL: HarnessAdapter[] = [new OpenCodeAdapter(), new PiAdapter(), new OmpAdapter()]; /** Look up an adapter by kind. Throws on unknown kind. */ export function getAdapter(kind: HarnessKind): HarnessAdapter { diff --git a/packages/cli/src/adapters/omp.test.ts b/packages/cli/src/adapters/omp.test.ts new file mode 100644 index 000000000..49b966ad5 --- /dev/null +++ b/packages/cli/src/adapters/omp.test.ts @@ -0,0 +1,48 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { OmpAdapter } from "./omp"; + +const original = { + HOME: process.env.HOME, + PATH: process.env.PATH, + PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR, + XDG_DATA_HOME: process.env.XDG_DATA_HOME, +}; +const roots: string[] = []; + +afterEach(() => { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("OmpAdapter", () => { + it("detects an enabled Magic Context plugin from omp plugin list", () => { + const root = mkdtempSync(join(tmpdir(), "mc-omp-adapter-")); + roots.push(root); + const bin = join(root, "bin"); + mkdirSync(bin, { recursive: true }); + const omp = join(bin, "omp"); + writeFileSync( + omp, + `#!/bin/sh +if [ "$1 $2 $3" = "plugin list --json" ]; then + printf '%s' '{"npm":[{"name":"@cortexkit/pi-magic-context","version":"0.33.0","enabled":true}],"marketplace":[]}' +fi +`, + { mode: 0o755 }, + ); + process.env.PATH = bin; + process.env.HOME = root; + delete process.env.XDG_DATA_HOME; + + const adapter = new OmpAdapter(); + expect(adapter.isInstalled()).toBe(true); + expect(adapter.hasPluginEntry()).toBe(true); + expect(adapter.getInstalledPluginVersion()).toBe("0.33.0"); + }); +}); diff --git a/packages/cli/src/adapters/omp.ts b/packages/cli/src/adapters/omp.ts new file mode 100644 index 000000000..823c464e5 --- /dev/null +++ b/packages/cli/src/adapters/omp.ts @@ -0,0 +1,193 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + detectOmpBinary, + listOmpPlugins, + OMP_PLUGIN_PACKAGE, + runOmpCommand, +} from "../lib/omp-helpers"; +import { + dirSizeBytes, + getMagicContextLogPath, + getOmpAgentDir, + getOmpPluginsDir, + getOmpPluginsLockPath, + getOmpUserConfigPath, +} from "../lib/paths"; +import type { + HarnessAdapter, + HarnessConfigPaths, + PluginCacheInfo, + PluginEntryResult, +} from "./types"; + +export class OmpAdapter implements HarnessAdapter { + readonly kind = "omp" as const; + readonly displayName = "Oh My Pi (OMP)"; + readonly pluginPackageName = OMP_PLUGIN_PACKAGE; + + isInstalled(): boolean { + return detectOmpBinary() !== null; + } + + hasPluginEntry(): boolean { + const omp = detectOmpBinary(); + if (!omp) return false; + return ( + listOmpPlugins(omp.path)?.some( + (plugin) => plugin.name === OMP_PLUGIN_PACKAGE && plugin.enabled, + ) ?? false + ); + } + + getConfigPaths(): HarnessConfigPaths { + return { + configDir: getOmpAgentDir(), + pluginConfigPath: getOmpPluginsLockPath(), + magicContextConfigPath: getOmpUserConfigPath(), + secondaryConfigPath: null, + }; + } + + async ensurePluginEntry(): Promise { + const configPath = getOmpPluginsLockPath(); + const omp = detectOmpBinary(); + if (!omp) return this.errorResult(configPath, "OMP binary not found"); + const plugins = listOmpPlugins(omp.path); + if (plugins === null) { + return this.errorResult(configPath, "`omp plugin list --json` failed"); + } + const installed = plugins.find((plugin) => plugin.name === OMP_PLUGIN_PACKAGE); + if (installed?.enabled) { + return { + ok: true, + action: "already_present", + message: `${OMP_PLUGIN_PACKAGE} is already enabled in OMP.`, + configPath, + }; + } + const originalRuntimeEnabled = this.readRuntimeEnabled(configPath); + + const args = installed + ? ["plugin", "enable", OMP_PLUGIN_PACKAGE] + : ["plugin", "install", OMP_PLUGIN_PACKAGE]; + const result = runOmpCommand(omp.path, args, 120_000); + if (!result.ok) { + return this.errorResult( + configPath, + result.stderr || result.stdout || `omp ${args.join(" ")} failed`, + ); + } + const enabledAfter = listOmpPlugins(omp.path)?.some( + (plugin) => plugin.name === OMP_PLUGIN_PACKAGE && plugin.enabled, + ); + if (!enabledAfter) { + // A project override can keep the plugin disabled even when the + // global install/enable command exits 0. New installs are removed. + // Existing installs restore the exact lockfile enable state; never + // infer global state from the project-effective plugin list. + if (!installed) { + runOmpCommand(omp.path, ["plugin", "uninstall", OMP_PLUGIN_PACKAGE], 120_000); + } else if (originalRuntimeEnabled !== undefined) { + runOmpCommand( + omp.path, + ["plugin", originalRuntimeEnabled ? "enable" : "disable", OMP_PLUGIN_PACKAGE], + 120_000, + ); + } + return this.errorResult( + configPath, + `${OMP_PLUGIN_PACKAGE} is still disabled in the current project after \`omp ${args.join(" ")}\``, + ); + } + return { + ok: true, + action: installed ? "updated" : "added", + message: installed + ? `Enabled ${OMP_PLUGIN_PACKAGE} in OMP.` + : `Installed ${OMP_PLUGIN_PACKAGE} in OMP.`, + configPath, + }; + } + + async removePluginEntry(): Promise { + const configPath = getOmpPluginsLockPath(); + const omp = detectOmpBinary(); + if (!omp) return this.errorResult(configPath, "OMP binary not found"); + const plugins = listOmpPlugins(omp.path); + if (plugins === null) { + return this.errorResult(configPath, "`omp plugin list --json` failed"); + } + const installed = plugins.some((plugin) => plugin.name === OMP_PLUGIN_PACKAGE); + if (!installed) { + return { + ok: true, + action: "already_present", + message: `${OMP_PLUGIN_PACKAGE} is not installed in OMP.`, + configPath, + }; + } + const result = runOmpCommand( + omp.path, + ["plugin", "uninstall", OMP_PLUGIN_PACKAGE], + 120_000, + ); + if (!result.ok) { + return this.errorResult( + configPath, + result.stderr || result.stdout || "OMP plugin uninstall failed", + ); + } + return { + ok: true, + action: "updated", + message: `Uninstalled ${OMP_PLUGIN_PACKAGE} from OMP.`, + configPath, + }; + } + + getInstallHint(): string { + return "Install OMP: https://omp.sh (npm: @oh-my-pi/pi-coding-agent)"; + } + + getPluginCacheInfo(): PluginCacheInfo { + const path = join(getOmpPluginsDir(), "cache"); + return { path, exists: existsSync(path), sizeBytes: dirSizeBytes(path) }; + } + + getLogPath(): string { + // OMP executes the Pi-compatible runtime, which intentionally keeps the + // existing `pi` DB/log discriminator for cross-host session semantics. + return getMagicContextLogPath("pi"); + } + + getInstalledPluginVersion(): string | null { + const omp = detectOmpBinary(); + if (!omp) return null; + return ( + listOmpPlugins(omp.path)?.find((plugin) => plugin.name === OMP_PLUGIN_PACKAGE) + ?.version ?? null + ); + } + + private readRuntimeEnabled(configPath: string): boolean | undefined { + try { + const lock = JSON.parse(readFileSync(configPath, "utf-8")) as { + plugins?: Record; + }; + const enabled = lock.plugins?.[OMP_PLUGIN_PACKAGE]?.enabled; + return typeof enabled === "boolean" ? enabled : undefined; + } catch { + return undefined; + } + } + + private errorResult(configPath: string, message: string): PluginEntryResult { + return { + ok: false, + action: "error", + message: `Failed to configure OMP: ${message}`, + configPath, + }; + } +} diff --git a/packages/cli/src/adapters/types.ts b/packages/cli/src/adapters/types.ts index 4ea2c4636..b70233630 100644 --- a/packages/cli/src/adapters/types.ts +++ b/packages/cli/src/adapters/types.ts @@ -1,6 +1,6 @@ /** * HarnessAdapter — abstracts what the unified Magic Context CLI needs to know - * about a specific agent harness (OpenCode, Pi). + * about a specific agent harness (OpenCode, Pi, or Oh My Pi). * * Each adapter covers: * 1. *Detection* — is the harness installed? is the plugin registered with it? @@ -12,7 +12,7 @@ * structures; async work lives in the command layer. */ -export type HarnessKind = "opencode" | "pi"; +export type HarnessKind = "opencode" | "pi" | "omp"; export interface HarnessConfigPaths { /** Primary config dir (e.g. `~/.config/opencode`, `~/.pi/agent`). */ diff --git a/packages/cli/src/commands/doctor-omp.test.ts b/packages/cli/src/commands/doctor-omp.test.ts new file mode 100644 index 000000000..26c8e23e9 --- /dev/null +++ b/packages/cli/src/commands/doctor-omp.test.ts @@ -0,0 +1,104 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { PromptIO, PromptSpinner, SelectOption } from "../lib/prompts"; +import { runDoctor } from "./doctor-omp"; + +class MockPrompts implements PromptIO { + readonly messages: string[] = []; + readonly log = { + info: (message: string) => this.messages.push(`info:${message}`), + success: (message: string) => this.messages.push(`success:${message}`), + warn: (message: string) => this.messages.push(`warn:${message}`), + error: (message: string) => this.messages.push(`error:${message}`), + message: (message: string) => this.messages.push(`message:${message}`), + step: (message: string) => this.messages.push(`step:${message}`), + }; + intro(message: string): void { + this.messages.push(`intro:${message}`); + } + outro(): void {} + note(): void {} + spinner(): PromptSpinner { + return { start: () => {}, stop: () => {}, message: () => {} }; + } + async confirm(): Promise { + return false; + } + async text(): Promise { + return "test"; + } + async selectOne(_message: string, options: SelectOption[]): Promise { + return options[0]?.value ?? ""; + } + async selectMany(_message: string, options: SelectOption[]): Promise { + return options.map((option) => option.value); + } + async selectAutocomplete(_message: string, options: SelectOption[]): Promise { + return options[0]?.value ?? ""; + } +} + +const roots: string[] = []; +const original = { + HOME: process.env.HOME, + XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME, + XDG_DATA_HOME: process.env.XDG_DATA_HOME, + PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR, +}; + +afterEach(() => { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("OMP doctor", () => { + it("accepts a healthy OMP installation", async () => { + const root = mkdtempSync(join(tmpdir(), "mc-omp-doctor-")); + roots.push(root); + const agentDir = join(root, ".omp", "agent"); + const pluginDir = join(root, "plugin"); + const configDir = join(root, ".config", "cortexkit"); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(pluginDir, { recursive: true }); + mkdirSync(configDir, { recursive: true }); + writeFileSync( + join(pluginDir, "package.json"), + JSON.stringify({ omp: { extensions: ["./dist/index.js"] } }), + ); + writeFileSync(join(configDir, "magic-context.jsonc"), "{}\n"); + process.env.HOME = root; + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.XDG_CONFIG_HOME = join(root, ".config"); + delete process.env.XDG_DATA_HOME; + const prompts = new MockPrompts(); + + const code = await runDoctor({ + cwd: root, + prompts, + deps: { + detectOmpBinary: () => ({ path: "/fake/omp", source: "path" }), + getOmpVersion: () => "17.1.7", + listOmpPlugins: () => [ + { + name: "@cortexkit/pi-magic-context", + version: "0.33.0", + enabled: true, + path: pluginDir, + }, + ], + getOmpSetting: ((_path: string, key: string) => + key === "compaction.enabled" ? false : "off") as never, + runOmpCommand: () => ({ ok: true, stdout: agentDir, stderr: "" }), + }, + }); + + expect(code).toBe(0); + expect(prompts.messages.join("\n")).toContain("OMP 17.1.7 detected"); + expect(prompts.messages.join("\n")).toContain("FAIL 0"); + }); +}); diff --git a/packages/cli/src/commands/doctor-omp.ts b/packages/cli/src/commands/doctor-omp.ts new file mode 100644 index 000000000..99d53da86 --- /dev/null +++ b/packages/cli/src/commands/doctor-omp.ts @@ -0,0 +1,411 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { MagicContextConfigSchema } from "@magic-context/core/config/schema/magic-context"; +import type { ContextDatabase } from "@magic-context/core/features/magic-context/storage"; +import { getMagicContextStorageDir } from "@magic-context/core/shared/data-path"; +import { loadPiConfig } from "@magic-context/pi-core/config"; +import { parse as parseJsonc, stringify as stringifyJsonc } from "comment-json"; +import { OmpAdapter } from "../adapters/omp"; +import { writeFileAtomic } from "../lib/atomic-write"; +import { migrateConfigLocationsForCli } from "../lib/config-location-migration"; +import { openExistingContextDatabase } from "../lib/database-access"; +import { + detectOmpBinary, + getOmpSetting, + getOmpVersion, + listOmpPlugins, + OMP_PLUGIN_PACKAGE, + type OmpBinaryInfo, + runOmpCommand, +} from "../lib/omp-helpers"; +import { + getMagicContextLogPath, + getOmpAgentDir, + getOmpConfigPath, + getOmpPluginsLockPath, + getOmpSessionsRoot, + getOmpUserConfigPath, +} from "../lib/paths"; +import { type PromptIO, promptIO } from "../lib/prompts"; +import { sanitizeDiagnosticText } from "../lib/redaction"; + +const MIN_OMP_VERSION = "17.1.7"; +type Status = "pass" | "warn" | "fail" | "info"; +interface CheckResult { + status: Status; + message: string; +} +interface RepairPlan { + installPlugin: boolean; + disableCompaction: boolean; + disableMemory: boolean; + writeUserConfig: boolean; +} +interface HealthReport { + results: CheckResult[]; + repairPlan: RepairPlan; + pass: number; + warn: number; + fail: number; +} +interface DoctorDeps { + prompts: PromptIO; + detectOmpBinary: () => OmpBinaryInfo | null; + getOmpVersion: typeof getOmpVersion; + getOmpSetting: typeof getOmpSetting; + listOmpPlugins: typeof listOmpPlugins; + runOmpCommand: typeof runOmpCommand; + openExistingContextDatabase: typeof openExistingContextDatabase; + now: () => Date; + execFileSync: typeof execFileSync; + spawnSync: typeof spawnSync; +} + +export interface RunOmpDoctorOptions { + force?: boolean; + issue?: boolean; + cwd?: string; + prompts?: PromptIO; + deps?: Partial; +} + +const DEFAULT_DEPS: DoctorDeps = { + prompts: promptIO, + detectOmpBinary, + getOmpVersion, + getOmpSetting, + listOmpPlugins, + runOmpCommand, + openExistingContextDatabase, + now: () => new Date(), + execFileSync, + spawnSync, +}; + +function parseSemver(value: string | null): [number, number, number] | null { + const match = value?.match(/(\d+)\.(\d+)\.(\d+)/); + return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null; +} + +function isOlderThan(value: string | null, minimum: string): boolean { + const left = parseSemver(value); + const right = parseSemver(minimum); + if (!left || !right) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return left[index] < right[index]; + } + return false; +} + +function add(results: CheckResult[], status: Status, message: string): void { + results.push({ status, message }); +} + +function printResult(prompts: PromptIO, result: CheckResult): void { + const line = `${result.status.toUpperCase()} ${result.message}`; + if (result.status === "pass") prompts.log.success(line); + else if (result.status === "warn") prompts.log.warn(line); + else if (result.status === "info") prompts.log.info(line); + else prompts.log.error(line); +} + +function selfVersion(): string { + const req = createRequire(import.meta.url); + for (const path of ["../../package.json", "../package.json"]) { + try { + const value = req(path) as { version?: unknown }; + if (typeof value.version === "string") return value.version; + } catch { + // Try the source/published alternate layout. + } + } + return "unknown"; +} + +function readConfig(path: string): { error?: string } { + if (!existsSync(path)) return {}; + try { + const parsed = parseJsonc(readFileSync(path, "utf-8")); + return parsed && typeof parsed === "object" ? {} : { error: "top level is not an object" }; + } catch (error) { + return { error: error instanceof Error ? error.message : String(error) }; + } +} + +function pluginDeclaresOmp(path: string | undefined): boolean | null { + if (!path) return null; + try { + const pkg = JSON.parse(readFileSync(join(path, "package.json"), "utf-8")) as { + omp?: { extensions?: unknown }; + pi?: { extensions?: unknown }; + }; + return Array.isArray(pkg.omp?.extensions) || Array.isArray(pkg.pi?.extensions); + } catch { + return null; + } +} + +async function runHealthChecks(options: { + cwd: string; + prompts: PromptIO; + deps: DoctorDeps; + quiet?: boolean; +}): Promise { + const results: CheckResult[] = []; + const repairPlan: RepairPlan = { + installPlugin: false, + disableCompaction: false, + disableMemory: false, + writeUserConfig: false, + }; + const omp = options.deps.detectOmpBinary(); + if (!omp) { + add(results, "fail", "OMP binary not found on PATH or in standard user bin directories"); + } else { + const version = options.deps.getOmpVersion(omp.path); + if (!version) add(results, "fail", `OMP at ${omp.path} could not report its version`); + else if (isOlderThan(version, MIN_OMP_VERSION)) { + add(results, "fail", `OMP ${version} is older than tested minimum ${MIN_OMP_VERSION}`); + } else add(results, "pass", `OMP ${version} detected at ${omp.path}`); + + const plugins = options.deps.listOmpPlugins(omp.path); + if (!plugins) { + add(results, "fail", "`omp plugin list --json` failed or returned invalid JSON"); + } else { + const plugin = plugins.find((entry) => entry.name === OMP_PLUGIN_PACKAGE); + if (!plugin) { + add(results, "fail", `${OMP_PLUGIN_PACKAGE} is not installed in OMP`); + repairPlan.installPlugin = true; + } else if (!plugin.enabled) { + add(results, "fail", `${OMP_PLUGIN_PACKAGE} is installed but disabled in OMP`); + repairPlan.installPlugin = true; + } else { + add(results, "pass", `${OMP_PLUGIN_PACKAGE} ${plugin.version} is enabled`); + const manifest = pluginDeclaresOmp(plugin.path); + if (manifest === true) + add(results, "pass", "Plugin exposes an OMP/Pi extension manifest"); + else if (manifest === false) + add(results, "fail", "Installed plugin has no OMP/Pi extension manifest"); + else add(results, "warn", "Could not inspect the installed plugin manifest"); + } + } + + const compaction = options.deps.getOmpSetting(omp.path, "compaction.enabled"); + if (compaction === false) add(results, "pass", "OMP native compaction is disabled"); + else if (compaction === true) { + add( + results, + "fail", + "OMP native compaction is enabled and conflicts with Magic Context", + ); + repairPlan.disableCompaction = true; + } else add(results, "fail", "Could not read OMP compaction.enabled"); + + const memory = options.deps.getOmpSetting(omp.path, "memory.backend"); + if (memory === "off") add(results, "pass", "OMP automatic memory backend is disabled"); + else if (typeof memory === "string") { + add( + results, + "fail", + `OMP memory.backend=${memory} duplicates Magic Context memory injection`, + ); + repairPlan.disableMemory = true; + } else add(results, "fail", "Could not read OMP memory.backend"); + + const reportedAgentDir = options.deps.runOmpCommand(omp.path, ["config", "path"], 10_000); + if (!reportedAgentDir.ok) + add(results, "warn", "Could not verify OMP active agent directory"); + else if (reportedAgentDir.stdout === getOmpAgentDir()) { + add(results, "pass", `OMP agent directory resolved to ${getOmpAgentDir()}`); + } else { + add( + results, + "fail", + `OMP reports agent directory ${reportedAgentDir.stdout}, but Magic Context resolved ${getOmpAgentDir()}`, + ); + } + } + + const userConfigPath = getOmpUserConfigPath(); + if (!existsSync(userConfigPath)) { + add(results, "warn", `No Magic Context user config at ${userConfigPath}`); + repairPlan.writeUserConfig = true; + } else { + const parsed = readConfig(userConfigPath); + if (parsed.error) add(results, "fail", `Invalid Magic Context config: ${parsed.error}`); + else add(results, "pass", `Magic Context config parses: ${userConfigPath}`); + } + const loaded = loadPiConfig({ cwd: options.cwd }); + if (loaded.warnings.length === 0) + add(results, "pass", "Magic Context runtime config loads successfully"); + else for (const warning of loaded.warnings.slice(0, 5)) add(results, "warn", warning); + + const dbPath = join(getMagicContextStorageDir(), "context.db"); + if (!existsSync(dbPath)) add(results, "info", `Shared context DB will be created at ${dbPath}`); + else { + let db: ContextDatabase | null = null; + try { + db = options.deps.openExistingContextDatabase(dbPath, { readonly: true }); + const integrity = db?.prepare("PRAGMA integrity_check").get() as + | { integrity_check?: unknown } + | undefined; + if (integrity?.integrity_check === "ok") + add(results, "pass", "SQLite integrity_check: ok"); + else + add( + results, + "fail", + `SQLite integrity_check: ${String(integrity?.integrity_check)}`, + ); + } catch (error) { + add(results, "fail", `Could not inspect shared DB: ${String(error)}`); + } finally { + db?.close(); + } + } + + add(results, "info", `OMP config: ${getOmpConfigPath()}`); + add(results, "info", `OMP plugin lock: ${getOmpPluginsLockPath()}`); + add(results, "info", `OMP sessions: ${getOmpSessionsRoot()}`); + const logPath = getMagicContextLogPath("pi"); + add( + results, + "info", + `Pi-compatible runtime log: ${logPath}${existsSync(logPath) ? "" : " (not created yet)"}`, + ); + + if (!options.quiet) for (const result of results) printResult(options.prompts, result); + return { + results, + repairPlan, + pass: results.filter((result) => result.status === "pass").length, + warn: results.filter((result) => result.status === "warn").length, + fail: results.filter((result) => result.status === "fail").length, + }; +} + +function writeDefaultConfig(path: string): void { + mkdirSync(dirname(path), { recursive: true }); + const config = { + $schema: + "https://raw.githubusercontent.com/cortexkit/magic-context/master/assets/magic-context.schema.json", + ...MagicContextConfigSchema.parse({}), + }; + writeFileAtomic(path, `${stringifyJsonc(config, null, 2)}\n`); +} + +async function repair(plan: RepairPlan, deps: DoctorDeps, prompts: PromptIO): Promise { + const omp = deps.detectOmpBinary(); + if (!omp) return 0; + let fixed = 0; + if (plan.installPlugin) { + const result = await new OmpAdapter().ensurePluginEntry(); + if (result.ok) { + prompts.log.success(result.message); + fixed += 1; + } else prompts.log.error(result.message); + } + for (const [enabled, key, value] of [ + [plan.disableCompaction, "compaction.enabled", "false"], + [plan.disableMemory, "memory.backend", "off"], + ] as const) { + if (!enabled) continue; + const result = deps.runOmpCommand(omp.path, ["config", "set", key, value], 10_000); + if (result.ok) { + prompts.log.success(`Set OMP ${key}=${value}`); + fixed += 1; + } else prompts.log.error(result.stderr || `Could not set OMP ${key}`); + } + if (plan.writeUserConfig && !existsSync(getOmpUserConfigPath())) { + writeDefaultConfig(getOmpUserConfigPath()); + prompts.log.success(`Wrote default Magic Context config to ${getOmpUserConfigPath()}`); + fixed += 1; + } + return fixed; +} + +function timestamp(date: Date): string { + return date + .toISOString() + .replace(/[-:]/g, "") + .replace(/\.\d{3}Z$/, "Z"); +} + +async function runIssueFlow(options: { + cwd: string; + prompts: PromptIO; + deps: DoctorDeps; +}): Promise { + const title = await options.prompts.text("Issue title", { + placeholder: "Short summary of the OMP problem", + validate: (value) => (value.trim() ? undefined : "Title is required"), + }); + const description = await options.prompts.text("Issue description", { + placeholder: "What happened, expected behavior, and reproduction steps", + validate: (value) => (value.trim() ? undefined : "Description is required"), + }); + const report = await runHealthChecks({ ...options, quiet: true }); + const body = [ + "## Description", + sanitizeDiagnosticText(description), + "", + "## OMP diagnostics", + `- Magic Context CLI: ${selfVersion()}`, + ...report.results.map( + (result) => + `- ${result.status.toUpperCase()}: ${sanitizeDiagnosticText(result.message)}`, + ), + ].join("\n"); + const path = join(options.cwd, `magic-context-omp-issue-${timestamp(options.deps.now())}.md`); + writeFileAtomic(path, `${body}\n`); + options.prompts.log.success(`Sanitized report written to ${path}`); + try { + options.deps.execFileSync("gh", ["auth", "status"], { stdio: "ignore" }); + if (await options.prompts.confirm("Submit this issue on GitHub now?", false)) { + const result = options.deps.spawnSync( + "gh", + [ + "issue", + "create", + "-R", + "cortexkit/magic-context", + "--title", + `[omp] ${title}`, + "--body-file", + path, + ], + { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] }, + ); + if (result.status === 0) options.prompts.log.success(String(result.stdout).trim()); + else options.prompts.log.warn(String(result.stderr).trim()); + } + } catch { + options.prompts.log.info("gh CLI unavailable; submit the generated report manually"); + } + return 0; +} + +export async function runDoctor(options: RunOmpDoctorOptions = {}): Promise { + const deps: DoctorDeps = { + ...DEFAULT_DEPS, + prompts: options.prompts ?? DEFAULT_DEPS.prompts, + ...options.deps, + }; + const prompts = options.prompts ?? deps.prompts; + const cwd = options.cwd ?? process.cwd(); + migrateConfigLocationsForCli(cwd, prompts.log); + if (options.issue) return runIssueFlow({ cwd, prompts, deps }); + + prompts.intro("Magic Context for Oh My Pi (OMP) Doctor"); + const first = await runHealthChecks({ cwd, prompts, deps }); + prompts.log.message(`Summary: PASS ${first.pass} / WARN ${first.warn} / FAIL ${first.fail}`); + if (!options.force || first.fail === 0) return first.fail === 0 ? 0 : 1; + + const fixed = await repair(first.repairPlan, deps, prompts); + prompts.log.info(`Applied ${fixed} repair(s); re-checking`); + const second = await runHealthChecks({ cwd, prompts, deps }); + prompts.log.message(`Summary: PASS ${second.pass} / WARN ${second.warn} / FAIL ${second.fail}`); + return second.fail === 0 ? 0 : 1; +} diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index 45c8cd2c9..313eb3d97 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -25,6 +25,7 @@ import { runV22BackfillCommands, type V22BackfillCommandArgs, } from "../lib/v22-backfill-commands"; +import { runDoctor as runOmpDoctor } from "./doctor-omp"; import { runDoctor as runOpenCodeDoctor } from "./doctor-opencode"; import { doctor as runPiDoctor } from "./doctor-pi"; @@ -104,6 +105,11 @@ async function dispatchDoctor(adapter: HarnessAdapter, options: RunDoctorOptions if (options.issue) piArgs.push("--issue"); return runPiDoctor(piArgs); } + case "omp": + return runOmpDoctor({ + force: options.force, + issue: options.issue, + }); } } diff --git a/packages/cli/src/commands/migrate.test.ts b/packages/cli/src/commands/migrate.test.ts index e255b5c9f..12fbb6954 100644 --- a/packages/cli/src/commands/migrate.test.ts +++ b/packages/cli/src/commands/migrate.test.ts @@ -781,6 +781,12 @@ describe("migrate CLI parsing", () => { ).toEqual({ from: "opencode", to: "pi", session: "ses_x", maxMessages: 5, dryRun: true }); }); + it("accepts OMP as a Pi-compatible migration target", () => { + expect( + parseMigrateArgs(["--from", "opencode", "--to", "omp", "--session", "ses_x"]), + ).toEqual({ from: "opencode", to: "omp", session: "ses_x" }); + }); + it("rejects unsupported migration directions clearly", async () => { const originalError = console.error; const errors: string[] = []; diff --git a/packages/cli/src/commands/migrate.ts b/packages/cli/src/commands/migrate.ts index 41ae064a7..8aec8f894 100644 --- a/packages/cli/src/commands/migrate.ts +++ b/packages/cli/src/commands/migrate.ts @@ -11,7 +11,7 @@ import { openExistingDatabase, } from "../lib/database-access"; import { getOpenCodeDatabasePath, projectPathToPiSessionSlug } from "../lib/migration-paths"; -import { getPiSessionsRoot } from "../lib/paths"; +import { getOmpSessionsRoot, getPiSessionsRoot } from "../lib/paths"; export interface MigrateOpenCodeSessionToPiOptions { /** @@ -908,15 +908,15 @@ function ensureValidOptions( ): asserts opts is Required> & MigrateCliOptions { if (!opts.from) throw new Error("Missing required flag: --from "); - if (!opts.to) throw new Error("Missing required flag: --to "); - if (opts.from !== "opencode" || opts.to !== "pi") { - if (opts.from === "pi" && opts.to === "opencode") { + if (!opts.to) throw new Error("Missing required flag: --to "); + if (opts.from !== "opencode" || (opts.to !== "pi" && opts.to !== "omp")) { + if ((opts.from === "pi" || opts.from === "omp") && opts.to === "opencode") { throw new Error( - "Migration pi → opencode is not yet supported (V1 supports only opencode → pi)", + `Migration ${opts.from} → opencode is not yet supported (supported: opencode → pi|omp)`, ); } throw new Error( - `Unsupported migration: ${opts.from} → ${opts.to} (V1 supports only opencode → pi)`, + `Unsupported migration: ${opts.from} → ${opts.to} (supported: opencode → pi|omp)`, ); } if (!opts.session) throw new Error("Missing required flag: --session "); @@ -1101,16 +1101,17 @@ export function printMigrateHelp(): void { Magic Context doctor migrate ───────────────────────────── - Copy OpenCode session message content into a new Pi JSONL session, + Copy OpenCode session message content into a new Pi-compatible JSONL session, PLUS the source session's Magic Context state (compartments + facts) - into the shared cortexkit database under the new Pi session id. + into the shared cortexkit database under the new session id. - Supported pairs (V1): + Supported pairs: --from opencode --to pi + --from opencode --to omp Usage: - npx @cortexkit/opencode-magic-context@latest doctor migrate \\ - --from opencode --to pi --session ses_xxx [--max-messages N] [--dry-run] + npx @cortexkit/magic-context@latest doctor migrate \\ + --from opencode --to --session ses_xxx [--max-messages N] [--dry-run] Fidelity: - text, reasoning text, tool calls, and tool results are preserved @@ -1129,15 +1130,17 @@ export async function runMigrateCli(args: string[]): Promise { try { const parsed = parseMigrateArgs(args); ensureValidOptions(parsed); + const target = parsed.to === "omp" ? "OMP" : "Pi"; const result = migrateOpenCodeSessionToPi({ sessionId: parsed.session, maxMessages: parsed.maxMessages, dryRun: parsed.dryRun, + piSessionsRoot: parsed.to === "omp" ? getOmpSessionsRoot() : undefined, }); const action = result.dryRun ? "Would write" : "Wrote"; - console.log(`${action} Pi session JSONL:`); + console.log(`${action} ${target} session JSONL:`); console.log(` path: ${result.outputPath}`); - console.log(` pi session id: ${result.piSessionId}`); + console.log(` pi-compatible session id: ${result.piSessionId}`); console.log(` source messages: ${result.sourceMessageCount}`); console.log(` migrated entries: ${result.messageCount}`); console.log(` bytes: ${result.byteCount}`); @@ -1161,7 +1164,7 @@ export async function runMigrateCli(args: string[]): Promise { ); } if (!result.dryRun) { - console.log("Pi may need to be restarted to pick up the new session file."); + console.log(`${target} may need to be restarted to pick up the new session file.`); if (result.cortexkitSchemaVersionBefore !== undefined) { console.log( "If OpenCode or another harness is running, restart it before creating new sessions so it reloads the same schema fence.", diff --git a/packages/cli/src/commands/setup-omp.test.ts b/packages/cli/src/commands/setup-omp.test.ts new file mode 100644 index 000000000..f4bc35e63 --- /dev/null +++ b/packages/cli/src/commands/setup-omp.test.ts @@ -0,0 +1,127 @@ +import { afterEach, describe, expect, it } from "bun:test"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import type { PromptIO, PromptSpinner, SelectOption } from "../lib/prompts"; +import { __test } from "./setup-omp"; + +class MockPrompts implements PromptIO { + readonly messages: string[] = []; + constructor(private readonly confirms: boolean[]) {} + readonly log = { + info: (message: string) => this.messages.push(`info:${message}`), + success: (message: string) => this.messages.push(`success:${message}`), + warn: (message: string) => this.messages.push(`warn:${message}`), + error: (message: string) => this.messages.push(`error:${message}`), + message: (message: string) => this.messages.push(`message:${message}`), + step: (message: string) => this.messages.push(`step:${message}`), + }; + intro(): void {} + outro(): void {} + note(): void {} + spinner(): PromptSpinner { + return { start: () => {}, stop: () => {}, message: () => {} }; + } + async confirm(): Promise { + const value = this.confirms.shift(); + if (value === undefined) throw new Error("missing confirm response"); + return value; + } + async text(): Promise { + return ""; + } + async selectOne(_message: string, options: SelectOption[]): Promise { + return options[0]?.value ?? ""; + } + async selectMany(_message: string, options: SelectOption[]): Promise { + return options.map((option) => option.value); + } + async selectAutocomplete(_message: string, options: SelectOption[]): Promise { + return options[0]?.value ?? ""; + } +} + +const roots: string[] = []; +const original = { + PATH: process.env.PATH, + HOME: process.env.HOME, +}; + +afterEach(() => { + for (const [key, value] of Object.entries(original)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +function makeFakeOmp(): { binary: string; state: string } { + const root = mkdtempSync(join(tmpdir(), "mc-omp-setup-")); + roots.push(root); + const state = join(root, "state.json"); + const binary = join(root, "omp"); + writeFileSync(state, JSON.stringify({ compaction: true, memory: "mnemopi" })); + writeFileSync( + binary, + `#!/usr/bin/node +const fs = require("fs"); +const statePath = ${JSON.stringify(state)}; +const state = JSON.parse(fs.readFileSync(statePath, "utf8")); +const args = process.argv.slice(2); +if (args[0] === "config" && args[1] === "get") { + const value = args[2] === "compaction.enabled" ? state.compaction : state.memory; + process.stdout.write(JSON.stringify({ value })); +} else if (args[0] === "config" && args[1] === "set") { + if (args[2] === "compaction.enabled") state.compaction = args[3] === "true"; + else state.memory = args[3]; + fs.writeFileSync(statePath, JSON.stringify(state)); +} else if (args[0] === "plugin" && args[1] === "list") { + process.stdout.write(JSON.stringify({ npm: [], marketplace: [] })); +} +`, + ); + chmodSync(binary, 0o755); + process.env.PATH = root; + process.env.HOME = root; + return { binary, state }; +} + +describe("OMP setup transaction", () => { + it("restores native settings when a later setup step rolls back", async () => { + const { binary, state } = makeFakeOmp(); + const prompts = new MockPrompts([true, true]); + const rollback = await __test.OMP_HOST.beforeWrite?.({ + binaryPath: binary, + prompts, + dryRun: false, + configureHost: true, + }); + expect(typeof rollback).toBe("function"); + expect(JSON.parse(readFileSync(state, "utf-8"))).toEqual({ + compaction: false, + memory: "off", + }); + + if (typeof rollback === "function") await rollback(); + expect(JSON.parse(readFileSync(state, "utf-8"))).toEqual({ + compaction: true, + memory: "mnemopi", + }); + }); + + it("does not change native settings when registration is skipped and plugin is absent", async () => { + const { binary, state } = makeFakeOmp(); + const prompts = new MockPrompts([]); + const rollback = await __test.OMP_HOST.beforeWrite?.({ + binaryPath: binary, + prompts, + dryRun: false, + configureHost: false, + }); + expect(typeof rollback).toBe("function"); + expect(JSON.parse(readFileSync(state, "utf-8"))).toEqual({ + compaction: true, + memory: "mnemopi", + }); + }); +}); diff --git a/packages/cli/src/commands/setup-omp.ts b/packages/cli/src/commands/setup-omp.ts new file mode 100644 index 000000000..ac450dfcb --- /dev/null +++ b/packages/cli/src/commands/setup-omp.ts @@ -0,0 +1,136 @@ +import { OmpAdapter } from "../adapters/omp"; +import { + detectOmpBinary, + getOmpAvailableModels, + getOmpSetting, + getOmpVersion, + OMP_PLUGIN_PACKAGE, + runOmpCommand, +} from "../lib/omp-helpers"; +import { getOmpAgentDir, getOmpPluginsLockPath, getOmpUserConfigPath } from "../lib/paths"; +import { + type PiCompatibleSetupHost, + type RunSetupOptions, + runSetup as runPiCompatibleSetup, + type SetupEnvironment, +} from "./setup-pi"; + +const OMP_ENV: SetupEnvironment = { + detectPiBinary: detectOmpBinary, + getPiVersion: getOmpVersion, + getAvailableModels: getOmpAvailableModels, + paths: { + getPiAgentConfigDir: getOmpAgentDir, + getPiUserConfigPath: getOmpUserConfigPath, + getPiUserExtensionsPath: getOmpPluginsLockPath, + }, +}; + +const OMP_HOST: PiCompatibleSetupHost = { + displayName: "Oh My Pi (OMP)", + cliName: "omp", + packageSource: OMP_PLUGIN_PACKAGE, + installCommand: `omp plugin install ${OMP_PLUGIN_PACKAGE}`, + minimumVersion: "17.1.7", + versionWarning: (version, minimum) => + `OMP ${version} is older than the tested minimum ${minimum}. ` + + "Upgrade with `omp update` before enabling Magic Context.", + ensurePluginEntry: async () => new OmpAdapter().ensurePluginEntry(), + beforeWrite: async ({ binaryPath, prompts, dryRun, configureHost }) => { + if (!configureHost && !new OmpAdapter().hasPluginEntry()) { + return async () => {}; + } + const compaction = getOmpSetting(binaryPath, "compaction.enabled"); + const memoryBackend = getOmpSetting(binaryPath, "memory.backend"); + if (compaction === null || memoryBackend === null) { + prompts.log.error( + "Could not read OMP compaction/memory settings; refusing to install two context managers blindly.", + ); + return false; + } + + const changes: Array<{ + key: "compaction.enabled" | "memory.backend"; + from: string; + to: string; + }> = []; + if (compaction === true) { + const disable = await prompts.confirm( + "Disable OMP native compaction? Magic Context must own context management end to end.", + true, + ); + if (!disable) { + prompts.log.error("OMP native compaction conflicts with Magic Context."); + return false; + } + changes.push({ key: "compaction.enabled", from: "true", to: "false" }); + } + if (memoryBackend !== "off") { + const disable = await prompts.confirm( + `Disable OMP memory backend "${memoryBackend}"? Running two automatic memory injectors duplicates context and writes.`, + true, + ); + if (!disable) { + prompts.log.error( + "OMP automatic memory conflicts with Magic Context memory injection.", + ); + return false; + } + changes.push({ key: "memory.backend", from: memoryBackend, to: "off" }); + } + + if (dryRun) { + for (const change of changes) { + prompts.log.message( + `[dry-run] would run \`omp config set ${change.key} ${change.to}\``, + ); + } + return async () => {}; + } + + const applied: typeof changes = []; + const rollback = async () => { + for (const change of [...applied].reverse()) { + const result = runOmpCommand( + binaryPath, + ["config", "set", change.key, change.from], + 10_000, + ); + if (result.ok) prompts.log.info(`Restored OMP ${change.key}=${change.from}`); + else prompts.log.error(result.stderr || `Could not restore OMP ${change.key}`); + } + }; + for (const change of changes) { + const result = runOmpCommand( + binaryPath, + ["config", "set", change.key, change.to], + 10_000, + ); + if (!result.ok) { + prompts.log.error(result.stderr || `Could not set OMP ${change.key}`); + await rollback(); + return false; + } + applied.push(change); + prompts.log.success(`Set OMP ${change.key}=${change.to}`); + } + return rollback; + }, + rollbackPluginEntry: async (registration) => { + if (registration.action === "already_present") return; + const omp = detectOmpBinary(); + if (!omp) return; + const action = registration.action === "added" ? "uninstall" : "disable"; + runOmpCommand(omp.path, ["plugin", action, OMP_PLUGIN_PACKAGE], 120_000); + }, +}; + +export async function runSetup(options: RunSetupOptions = {}): Promise { + return runPiCompatibleSetup({ + ...options, + env: options.env ?? OMP_ENV, + host: options.host ?? OMP_HOST, + }); +} + +export const __test = { OMP_HOST }; diff --git a/packages/cli/src/commands/setup-pi.ts b/packages/cli/src/commands/setup-pi.ts index 4fd3ccc94..86b51b744 100644 --- a/packages/cli/src/commands/setup-pi.ts +++ b/packages/cli/src/commands/setup-pi.ts @@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { piModelRefToCanonical } from "@magic-context/core/shared/harness-provider-map"; import { stringify as stringifyJsonc } from "comment-json"; +import type { PluginEntryResult } from "../adapters/types"; import { writeFileAtomic } from "../lib/atomic-write"; import { hasUserConfigLocationMigrationRefusal, @@ -40,9 +41,29 @@ export interface SetupEnvironment { }; } +export type SetupRollback = () => Promise; + +export interface PiCompatibleSetupHost { + displayName: string; + cliName: string; + packageSource: string; + installCommand: string; + minimumVersion?: string; + versionWarning?: (version: string, minimum: string) => string; + ensurePluginEntry: (settingsPath: string) => Promise; + beforeWrite?: (options: { + binaryPath: string; + prompts: PromptIO; + dryRun: boolean; + configureHost: boolean; + }) => Promise; + rollbackPluginEntry?: (registration: PluginEntryResult) => Promise; +} + export interface RunSetupOptions { prompts?: PromptIO; env?: SetupEnvironment; + host?: PiCompatibleSetupHost; /** * When true, run the full interactive wizard (detection, model fetch, * type-ahead picker, all prompts) but write NO files and register NO @@ -63,6 +84,31 @@ const DEFAULT_ENV: SetupEnvironment = { }, }; +const DEFAULT_HOST: PiCompatibleSetupHost = { + displayName: "Pi", + cliName: "pi", + packageSource: PI_PACKAGE_SOURCE, + installCommand: "pi install npm:@cortexkit/pi-magic-context", + minimumVersion: "0.74.0", + versionWarning: (version, minimum) => + `Pi ${version} is older than the required ${minimum}.\n` + + `Pi 0.74.0 renamed the npm package from \`@mariozechner/pi-coding-agent\` ` + + `to \`@earendil-works/pi-coding-agent\`. Magic Context's peer dependency ` + + `targets the new scope, so older Pi installs cannot load this extension.\n` + + `Run \`pi update --self\` (or \`npm install -g @earendil-works/pi-coding-agent@latest\`) before continuing.`, + ensurePluginEntry: async (settingsPath) => { + const added = writePiSettingsPackage(settingsPath); + return { + ok: true, + action: added ? "added" : "already_present", + message: added + ? `Added ${PI_PACKAGE_SOURCE} to ${settingsPath}` + : `Magic Context package already present in ${settingsPath}`, + configPath: settingsPath, + }; + }, +}; + function ensureDir(path: string): void { if (!existsSync(path)) mkdirSync(path, { recursive: true }); } @@ -212,9 +258,10 @@ async function chooseEmbedding(prompts: PromptIO): Promise { export async function runSetup(options: RunSetupOptions = {}): Promise { const prompts = options.prompts ?? (await getDefaultPrompts()); const env = options.env ?? DEFAULT_ENV; + const host = options.host ?? DEFAULT_HOST; const dryRun = options.dryRun === true; - prompts.intro("Magic Context for Pi — Setup"); + prompts.intro(`Magic Context for ${host.displayName} — Setup`); if (dryRun) { prompts.log.warn("Dry run — no files will be written and no package will be registered."); prompts.log.message( @@ -231,45 +278,40 @@ export async function runSetup(options: RunSetupOptions = {}): Promise { } const spinner = prompts.spinner(); - spinner.start("Checking Pi installation"); - const pi = env.detectPiBinary(); - if (!pi) { - spinner.stop("Pi not found"); - prompts.log.warn("Could not find `pi` on PATH or at ~/.pi/bin/pi."); - prompts.log.message( - "Install Pi first, then rerun setup. If Pi is installed in a custom location, add it to PATH.", - ); - prompts.outro("Setup stopped — install Pi and try again"); + spinner.start(`Checking ${host.displayName} installation`); + const binary = env.detectPiBinary(); + if (!binary) { + spinner.stop(`${host.displayName} not found`); + prompts.log.warn(`Could not find \`${host.cliName}\` on PATH.`); + prompts.log.message(`Install ${host.displayName} first, then rerun setup.`); + prompts.outro(`Setup stopped — install ${host.displayName} and try again`); return 1; } - const version = env.getPiVersion(pi.path); - spinner.stop(version ? `Pi ${version} detected at ${pi.path}` : `Pi detected at ${pi.path}`); + const version = env.getPiVersion(binary.path); + spinner.stop( + version + ? `${host.displayName} ${version} detected at ${binary.path}` + : `${host.displayName} detected at ${binary.path}`, + ); - // Pi 0.74.0 moved to the `@earendil-works/pi-coding-agent` package scope. - // Magic Context's peerDependency targets that scope, so older Pi versions - // (on `@mariozechner/pi-coding-agent`) cannot load this extension. - const MIN_PI_VERSION = "0.74.0"; - if (version && comparePiVersion(version, MIN_PI_VERSION) < 0) { + if (version && host.minimumVersion && comparePiVersion(version, host.minimumVersion) < 0) { prompts.log.warn( - `Pi ${version} is older than the required ${MIN_PI_VERSION}.\n` + - `Pi 0.74.0 renamed the npm package from \`@mariozechner/pi-coding-agent\` ` + - `to \`@earendil-works/pi-coding-agent\`. Magic Context's peer dependency ` + - `targets the new scope, so older Pi installs cannot load this extension.\n` + - `Run \`pi update --self\` (or \`npm install -g @earendil-works/pi-coding-agent@latest\`) before continuing.`, + host.versionWarning?.(version, host.minimumVersion) ?? + `${host.displayName} ${version} is older than required ${host.minimumVersion}.`, ); const proceed = await prompts.confirm( - "Continue with setup anyway? (subagents will fail at runtime)", + "Continue with setup anyway? (subagents may fail at runtime)", false, ); if (!proceed) { - prompts.outro("Setup cancelled — upgrade Pi and try again."); + prompts.outro(`Setup cancelled — upgrade ${host.displayName} and try again.`); return 0; } } - spinner.start("Fetching available Pi models"); - const allModels = env.getAvailableModels(pi.path); + spinner.start(`Fetching available ${host.displayName} models`); + const allModels = env.getAvailableModels(binary.path); spinner.stop(`Found ${allModels.length} model choices`); const settingsPath = env.paths.getPiUserExtensionsPath(); @@ -284,14 +326,17 @@ export async function runSetup(options: RunSetupOptions = {}): Promise { return 1; } } - const configurePi = await prompts.confirm("Configure Pi to load Magic Context?", true); - if (configurePi) { - if (dryRun) { - prompts.log.message(`[dry-run] would add ${PI_PACKAGE_SOURCE} to ${settingsPath}`); - } - } else { + const configureHost = await prompts.confirm( + `Configure ${host.displayName} to load Magic Context?`, + true, + ); + if (configureHost && dryRun) { + prompts.log.message( + `[dry-run] would register ${host.packageSource} via \`${host.installCommand}\``, + ); + } else if (!configureHost) { prompts.log.warn( - "Skipped Pi package registration; install manually with `pi install npm:@cortexkit/pi-magic-context`.", + `Skipped ${host.displayName} package registration; install manually with \`${host.installCommand}\`.`, ); } @@ -340,38 +385,55 @@ export async function runSetup(options: RunSetupOptions = {}): Promise { : undefined; const embedding = await chooseEmbedding(prompts); - if (dryRun) { - prompts.log.message(`[dry-run] would write Magic Context config to ${configPath}`); - } else { - if (configurePi) { - const packageAdded = writePiSettingsPackage(settingsPath); - prompts.log.success( - packageAdded - ? `Added ${PI_PACKAGE_SOURCE} to ${settingsPath}` - : `Magic Context package already present in ${settingsPath}`, - ); - prompts.log.message( - "This mirrors `pi install npm:@cortexkit/pi-magic-context` without running installs during setup verification.", - ); + const rollbackHost = + (await host.beforeWrite?.({ + binaryPath: binary.path, + prompts, + dryRun, + configureHost, + })) ?? (async () => {}); + if (rollbackHost === false) { + prompts.outro(`Setup stopped — could not configure ${host.displayName}.`); + return 1; + } + + let registration: PluginEntryResult | undefined; + try { + if (dryRun) { + prompts.log.message(`[dry-run] would write Magic Context config to ${configPath}`); + } else { + if (configureHost) { + registration = await host.ensurePluginEntry(settingsPath); + if (!registration.ok) throw new Error(registration.message); + prompts.log.success(registration.message); + } + writeMagicContextConfig(configPath, { + historianModel, + historianThinkingLevel, + dreamerEnabled, + dreamerModel, + dreamerTasks, + sidekickEnabled, + sidekickModel, + embedding, + }); + prompts.log.success(`Config written to ${configPath}`); + } + } catch (error) { + if (registration?.ok && host.rollbackPluginEntry) { + await host.rollbackPluginEntry(registration); } - writeMagicContextConfig(configPath, { - historianModel, - historianThinkingLevel, - dreamerEnabled, - dreamerModel, - dreamerTasks, - sidekickEnabled, - sidekickModel, - embedding, - }); - prompts.log.success(`Config written to ${configPath}`); + await rollbackHost(); + prompts.log.error(error instanceof Error ? error.message : String(error)); + prompts.outro(`Setup stopped — rolled back ${host.displayName} changes.`); + return 1; } const thinkingLevelSuffix = historianThinkingLevel ? ` (thinking: ${historianThinkingLevel})` : ""; const summary = [ - `Pi settings: ${configurePi ? settingsPath : "skipped"}`, + `${host.displayName} plugin: ${configureHost ? settingsPath : "skipped"}`, `Magic Context config: ${configPath}`, `Historian: ${historianModel}${thinkingLevelSuffix}`, `Dreamer: ${dreamerEnabled ? dreamerModel : "disabled"}`, @@ -381,7 +443,9 @@ export async function runSetup(options: RunSetupOptions = {}): Promise { prompts.note(summary, dryRun ? "Configuration (dry run — not written)" : "Configuration"); prompts.outro( - dryRun ? "Dry run complete — nothing was written." : "Start a Pi session and try /ctx-aug", + dryRun + ? "Dry run complete — nothing was written." + : `Start a ${host.displayName} session and try /ctx-aug`, ); return 0; } diff --git a/packages/cli/src/commands/setup.ts b/packages/cli/src/commands/setup.ts index 004dff336..2196e74d5 100644 --- a/packages/cli/src/commands/setup.ts +++ b/packages/cli/src/commands/setup.ts @@ -3,16 +3,14 @@ * * Resolves the harness target via `--harness` flag or auto-detection * (`resolveAdaptersForCommand`), then dispatches to the per-harness - * setup wizard. We deliberately reuse the existing per-harness - * setup flows (`setup-opencode.ts` and `setup-pi.ts`) instead of - * collapsing them into a generic flow because each harness has - * meaningfully different prompts (OpenCode picks historian models + - * checks for DCP/OMO conflicts; Pi prompts for Pi version compat - * + thinking_level for Copilot models). + * setup flows instead of collapsing them into one generic flow: OpenCode has + * DCP/OMO conflicts, while Pi and OMP share the extension runtime but differ in + * installation and native context/memory conflict handling. */ import type { HarnessAdapter } from "../adapters/types"; import { resolveAdaptersForCommand } from "../lib/harness-select"; import { intro, log, note, outro } from "../lib/prompts"; +import { runSetup as runOmpSetup } from "./setup-omp"; import { runSetup as runOpenCodeSetup } from "./setup-opencode"; import { runSetup as runPiSetup } from "./setup-pi"; @@ -68,6 +66,8 @@ async function dispatchSetup(adapter: HarnessAdapter, dryRun: boolean): Promise< return runOpenCodeSetup(dryRun); case "pi": return runPiSetup({ dryRun }); + case "omp": + return runOmpSetup({ dryRun }); } } @@ -91,4 +91,13 @@ function printNextSteps(adapter: HarnessAdapter): void { "Next steps", ); } + if (adapter.kind === "omp") { + note( + [ + "Restart OMP (or run /reload-plugins) so Magic Context registers.", + "Verify with: npx @cortexkit/magic-context@latest doctor --harness omp", + ].join("\n"), + "Next steps", + ); + } } diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index a148dbfd3..adf70440a 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,17 +3,17 @@ * @cortexkit/magic-context — unified CLI for Magic Context. * * Subcommands: - * setup Interactive setup wizard for OpenCode and/or Pi. + * setup Interactive setup wizard for OpenCode, Pi, or OMP. * doctor Health-check + auto-fix for installed harnesses. * --force Force-clear plugin cache. * --issue Bundle a sanitized issue report and submit/open. * --clear Interactive picker to clear plugin caches. - * doctor migrate Migrate OpenCode session content to Pi JSONL. + * doctor migrate Migrate OpenCode session content to Pi/OMP JSONL. * doctor migrate-session Re-home an OpenCode session to another directory/project. * doctor merge-identity Merge all project-scoped rows between identities. * * Common flags: - * --harness opencode|pi Target one harness (default: auto-detect / prompt) + * --harness opencode|pi|omp Target one harness (default: auto-detect / prompt) * --version, -v Print CLI version and exit * --help, -h Print help and exit */ @@ -65,7 +65,7 @@ function printUsage(): void { console.log( " doctor drain-authority Drain module memory/note authority back to TypeScript", ); - console.log(" doctor migrate Migrate OpenCode session to Pi JSONL"); + console.log(" doctor migrate Migrate OpenCode session to Pi or OMP JSONL"); console.log(" doctor migrate-session Re-home an OpenCode session to another directory"); console.log( " doctor merge-identity Merge project rows (--from ID --to ID [--dry-run] [--yes])", @@ -74,6 +74,7 @@ function printUsage(): void { console.log(" Harness selection:"); console.log(" --harness opencode Target OpenCode only"); console.log(" --harness pi Target Pi only"); + console.log(" --harness omp Target Oh My Pi (OMP) only"); console.log(" (default: auto-detect, prompt if multiple installed)"); console.log(""); console.log(" Usage:"); @@ -82,7 +83,7 @@ function printUsage(): void { console.log(" npx @cortexkit/magic-context@latest doctor"); console.log(" npx @cortexkit/magic-context@latest doctor --issue"); console.log(" npx @cortexkit/magic-context@latest doctor migrate \\"); - console.log(" --from opencode --to pi --session ses_xxx --dry-run"); + console.log(" --from opencode --to --session ses_xxx --dry-run"); console.log(""); } diff --git a/packages/cli/src/lib/harness-select.ts b/packages/cli/src/lib/harness-select.ts index c36b0b2f7..e35c445d3 100644 --- a/packages/cli/src/lib/harness-select.ts +++ b/packages/cli/src/lib/harness-select.ts @@ -2,7 +2,7 @@ * Harness selection logic for the unified Magic Context CLI. * * Resolves which adapter(s) a command should target based on: - * 1. `--harness opencode|pi` flag (hard override, no prompts) + * 1. `--harness opencode|pi|omp` flag (hard override, no prompts) * 2. Auto-detect installed harnesses, prompting only when ambiguous * * Mirrors AFT's selection model — battle-tested cross-harness UX. @@ -21,7 +21,9 @@ function parseHarnessFlag(argv: string[]): HarnessFlagResult { if (idx === -1) return { kind: "absent" }; const value = argv[idx + 1]; if (!value || value.startsWith("--")) return { kind: "invalid", value: null }; - if (value === "opencode" || value === "pi") return { kind: "valid", harness: value }; + if (value === "opencode" || value === "pi" || value === "omp") { + return { kind: "valid", harness: value }; + } return { kind: "invalid", value }; } @@ -36,7 +38,7 @@ export interface ResolveOptions { * Resolve which adapter(s) to act on for the given command invocation. * * Decision tree: - * - `--harness opencode|pi` → return that single adapter (hard override) + * - `--harness opencode|pi|omp` → return that single adapter (hard override) * - 0 installed → prompt user to pick one (gives install hints) * - 1 installed → use it silently * - 2+ installed: @@ -52,15 +54,15 @@ export async function resolveAdaptersForCommand( if (flag.kind === "invalid") { throw new Error( flag.value === null - ? "Missing value for --harness (expected opencode or pi)" - : `Invalid --harness value: ${flag.value} (expected opencode or pi)`, + ? "Missing value for --harness (expected opencode, pi, or omp)" + : `Invalid --harness value: ${flag.value} (expected opencode, pi, or omp)`, ); } const installed = getInstalledAdapters(); if (installed.length === 0) { - log.warn("No supported harness was detected on PATH (opencode, pi)."); + log.warn("No supported harness was detected on PATH (opencode, pi, omp)."); const pick = await selectOne(`Which harness do you want to ${options.verb}?`, [ { label: "OpenCode", @@ -72,6 +74,11 @@ export async function resolveAdaptersForCommand( value: "pi", hint: "@cortexkit/pi-magic-context", }, + { + label: "Oh My Pi (OMP)", + value: "omp", + hint: "@cortexkit/pi-magic-context", + }, ]); return [getAdapter(pick as HarnessKind)]; } diff --git a/packages/cli/src/lib/omp-helpers.test.ts b/packages/cli/src/lib/omp-helpers.test.ts new file mode 100644 index 000000000..000002587 --- /dev/null +++ b/packages/cli/src/lib/omp-helpers.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "bun:test"; +import { parseOmpModelsOutput } from "./omp-helpers"; + +describe("OMP model discovery", () => { + it("parses model selectors without flattening scoped or nested IDs", () => { + const output = JSON.stringify({ + models: [ + { + provider: "anthropic", + id: "claude-opus", + selector: "anthropic/claude-opus", + }, + { + provider: "modal", + id: "@modal/qwen/model-v1", + selector: "modal/@modal/qwen/model-v1", + }, + { provider: "openai", id: "fallback/model" }, + ], + }); + + expect(parseOmpModelsOutput(output)).toEqual([ + "anthropic/claude-opus", + "modal/@modal/qwen/model-v1", + "openai/fallback/model", + ]); + }); +}); diff --git a/packages/cli/src/lib/omp-helpers.ts b/packages/cli/src/lib/omp-helpers.ts new file mode 100644 index 000000000..473d1724f --- /dev/null +++ b/packages/cli/src/lib/omp-helpers.ts @@ -0,0 +1,143 @@ +import { spawnSync } from "node:child_process"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { findOnPath, isExecutableFile } from "./find-on-path"; +import { getPiCommandInvocation } from "./pi-helpers"; + +export interface OmpBinaryInfo { + path: string; + source: "path" | "home"; +} + +export interface OmpCommandResult { + ok: boolean; + stdout: string; + stderr: string; +} + +export interface OmpPluginInfo { + name: string; + version: string; + enabled: boolean; + path?: string; +} + +export const OMP_PLUGIN_PACKAGE = "@cortexkit/pi-magic-context"; + +export function detectOmpBinary(): OmpBinaryInfo | null { + const fromPath = findOnPath("omp"); + if (fromPath) return { path: fromPath, source: "path" }; + + const home = process.env.HOME?.trim() || homedir(); + const candidates = + process.platform === "win32" + ? [join(home, ".bun", "bin", "omp.exe"), join(home, ".bun", "bin", "omp.cmd")] + : [join(home, ".bun", "bin", "omp"), join(home, ".local", "bin", "omp")]; + const candidate = candidates.find((path) => isExecutableFile(path)); + return candidate ? { path: candidate, source: "home" } : null; +} + +export function runOmpCommand(ompPath: string, args: string[], timeout = 30_000): OmpCommandResult { + try { + const invocation = getPiCommandInvocation(ompPath, args); + const result = spawnSync(invocation.command, invocation.args, { + encoding: "utf-8", + timeout, + stdio: ["ignore", "pipe", "pipe"], + }); + return { + ok: result.status === 0 && !result.error, + stdout: result.stdout?.trim() ?? "", + stderr: result.stderr?.trim() ?? result.error?.message ?? "", + }; + } catch (error) { + return { + ok: false, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } +} + +export function getOmpVersion(ompPath: string): string | null { + const result = runOmpCommand(ompPath, ["--version"], 10_000); + if (!result.ok) return null; + const match = (result.stdout || result.stderr).match( + /(?:omp\/)?(\d+\.\d+\.\d+(?:[-+][\w.-]+)?)/, + ); + return match?.[1] ?? null; +} + +export function parseOmpModelsOutput(output: string): string[] { + try { + const parsed = JSON.parse(output) as { models?: unknown }; + if (!Array.isArray(parsed.models)) return []; + const models = new Set(); + for (const entry of parsed.models) { + if (!entry || typeof entry !== "object") continue; + const value = entry as Record; + if (typeof value.selector === "string" && value.selector.length > 0) { + models.add(value.selector); + continue; + } + if ( + typeof value.provider === "string" && + value.provider.length > 0 && + typeof value.id === "string" && + value.id.length > 0 + ) { + models.add(`${value.provider}/${value.id}`); + } + } + return [...models]; + } catch { + return []; + } +} + +export function getOmpAvailableModels(ompPath: string): string[] { + const result = runOmpCommand(ompPath, ["models", "--json"], 30_000); + return result.ok ? parseOmpModelsOutput(result.stdout) : []; +} + +export function listOmpPlugins(ompPath: string): OmpPluginInfo[] | null { + const result = runOmpCommand(ompPath, ["plugin", "list", "--json"], 30_000); + if (!result.ok) return null; + try { + const parsed = JSON.parse(result.stdout) as { npm?: unknown }; + if (!Array.isArray(parsed.npm)) return []; + return parsed.npm.flatMap((entry): OmpPluginInfo[] => { + if (!entry || typeof entry !== "object") return []; + const value = entry as Record; + if (typeof value.name !== "string" || typeof value.version !== "string") return []; + return [ + { + name: value.name, + version: value.version, + enabled: value.enabled !== false, + ...(typeof value.path === "string" ? { path: value.path } : {}), + }, + ]; + }); + } catch { + return null; + } +} + +export function getOmpSetting(ompPath: string, key: "compaction.enabled"): boolean | null; +export function getOmpSetting(ompPath: string, key: "memory.backend"): string | null; +export function getOmpSetting( + ompPath: string, + key: "compaction.enabled" | "memory.backend", +): boolean | string | null { + const result = runOmpCommand(ompPath, ["config", "get", key, "--json"], 10_000); + if (!result.ok) return null; + try { + const parsed = JSON.parse(result.stdout) as { value?: unknown }; + return typeof parsed.value === "boolean" || typeof parsed.value === "string" + ? parsed.value + : null; + } catch { + return null; + } +} diff --git a/packages/cli/src/lib/paths-omp.test.ts b/packages/cli/src/lib/paths-omp.test.ts new file mode 100644 index 000000000..7a0ae8c15 --- /dev/null +++ b/packages/cli/src/lib/paths-omp.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { resolveOmpPaths } from "./paths"; + +const KEYS = [ + "HOME", + "PI_CONFIG_DIR", + "PI_CODING_AGENT_DIR", + "OMP_PROFILE", + "PI_PROFILE", + "XDG_DATA_HOME", +] as const; +const original = new Map(); +let root: string; + +beforeEach(() => { + for (const key of KEYS) original.set(key, process.env[key]); + root = mkdtempSync(join(tmpdir(), "mc-omp-paths-")); + process.env.HOME = root; + for (const key of KEYS.slice(1)) delete process.env[key]; +}); + +afterEach(() => { + for (const key of KEYS) { + const value = original.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + rmSync(root, { recursive: true, force: true }); +}); + +describe("OMP path compatibility", () => { + it("resolves the default layout", () => { + expect(resolveOmpPaths()).toEqual({ + configRoot: join(root, ".omp"), + agentDir: join(root, ".omp", "agent"), + dataRoot: join(root, ".omp"), + dataAgentRoot: join(root, ".omp", "agent"), + pluginsDir: join(root, ".omp", "plugins"), + sessionsRoot: join(root, ".omp", "agent", "sessions"), + }); + }); + + it("honors a custom default-profile agent dir without moving plugins", () => { + process.env.PI_CODING_AGENT_DIR = join(root, "custom-agent"); + const paths = resolveOmpPaths(); + expect(paths.agentDir).toBe(join(root, "custom-agent")); + expect(paths.sessionsRoot).toBe(join(root, "custom-agent", "sessions")); + expect(paths.pluginsDir).toBe(join(root, ".omp", "plugins")); + }); + + it("lets a named profile override PI_CODING_AGENT_DIR", () => { + process.env.OMP_PROFILE = "work"; + process.env.PI_CODING_AGENT_DIR = join(root, "stale-agent"); + const paths = resolveOmpPaths(); + expect(paths.agentDir).toBe(join(root, ".omp", "profiles", "work", "agent")); + expect(paths.pluginsDir).toBe(join(root, ".omp", "profiles", "work", "plugins")); + }); + + it("honors PI_CONFIG_DIR for non-XDG config state", () => { + process.env.PI_CONFIG_DIR = ".custom-omp"; + const paths = resolveOmpPaths(); + expect(paths.configRoot).toBe(join(root, ".custom-omp")); + expect(paths.agentDir).toBe(join(root, ".custom-omp", "agent")); + }); + + it("uses an initialized XDG data root and flattens agent sessions", () => { + const xdg = join(root, "xdg-data"); + mkdirSync(join(xdg, "omp"), { recursive: true }); + process.env.XDG_DATA_HOME = xdg; + const paths = resolveOmpPaths(); + if (process.platform === "linux" || process.platform === "darwin") { + expect(paths.pluginsDir).toBe(join(xdg, "omp", "plugins")); + expect(paths.sessionsRoot).toBe(join(xdg, "omp", "sessions")); + } else { + expect(paths.pluginsDir).toBe(join(root, ".omp", "plugins")); + } + }); + + it("requires the profile-specific XDG root before moving a profile", () => { + const xdg = join(root, "xdg-data"); + mkdirSync(join(xdg, "omp"), { recursive: true }); + process.env.XDG_DATA_HOME = xdg; + process.env.OMP_PROFILE = "work"; + expect(resolveOmpPaths().pluginsDir).toBe( + join(root, ".omp", "profiles", "work", "plugins"), + ); + + mkdirSync(join(xdg, "omp", "profiles", "work"), { recursive: true }); + const paths = resolveOmpPaths(); + if (process.platform === "linux" || process.platform === "darwin") { + expect(paths.pluginsDir).toBe(join(xdg, "omp", "profiles", "work", "plugins")); + expect(paths.sessionsRoot).toBe(join(xdg, "omp", "profiles", "work", "sessions")); + } + }); +}); diff --git a/packages/cli/src/lib/paths.ts b/packages/cli/src/lib/paths.ts index 201dd996d..0440705f9 100644 --- a/packages/cli/src/lib/paths.ts +++ b/packages/cli/src/lib/paths.ts @@ -1,6 +1,6 @@ import { existsSync, readdirSync, statSync } from "node:fs"; import { homedir } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { resolveCortexKitUserConfigPath } from "@magic-context/core/config/migrate-config-location"; import { getMagicContextHistorianDir as getMagicContextHistorianDirCore, @@ -150,6 +150,92 @@ export function getPiUserExtensionsPath(): string { return join(getPiAgentConfigDir(), "settings.json"); } +// ============================================================================ +// OMP paths +// ============================================================================ + +export interface OmpPaths { + configRoot: string; + agentDir: string; + dataRoot: string; + dataAgentRoot: string; + pluginsDir: string; + sessionsRoot: string; +} + +/** Mirror OMP's profile/custom-dir/XDG resolution without importing the host. */ +export function resolveOmpPaths(): OmpPaths { + const rawProfile = process.env.OMP_PROFILE ?? process.env.PI_PROFILE; + const normalizedProfile = rawProfile?.trim(); + const profile = + normalizedProfile && + normalizedProfile !== "default" && + /^[a-z0-9][a-z0-9._-]{0,63}$/.test(normalizedProfile) + ? normalizedProfile + : undefined; + const configDirName = process.env.PI_CONFIG_DIR?.trim() || ".omp"; + const baseConfigRoot = join(envFirstHomeDir(), configDirName); + const configRoot = profile ? join(baseConfigRoot, "profiles", profile) : baseConfigRoot; + const defaultAgentDir = join(configRoot, "agent"); + // Named profiles deliberately ignore PI_CODING_AGENT_DIR. OMP sets the env + // to the derived profile path for children, but the profile remains the + // authority so a stale/custom override cannot escape its root. + const override = profile ? undefined : process.env.PI_CODING_AGENT_DIR?.trim(); + const agentDir = override ? resolve(override) : defaultAgentDir; + const canUseXdg = + (process.platform === "linux" || process.platform === "darwin") && + agentDir === defaultAgentDir; + let dataRoot = configRoot; + if (canUseXdg) { + const xdgDataHome = process.env.XDG_DATA_HOME?.trim(); + if (xdgDataHome) { + const appRoot = join(xdgDataHome, "omp"); + const candidate = profile ? join(appRoot, "profiles", profile) : appRoot; + if (existsSync(candidate)) dataRoot = candidate; + } + } + // OMP flattens the `agent/` prefix when XDG data is active. + const dataAgentRoot = dataRoot === configRoot ? agentDir : dataRoot; + return { + configRoot, + agentDir, + dataRoot, + dataAgentRoot, + pluginsDir: join(dataRoot, "plugins"), + sessionsRoot: join(dataAgentRoot, "sessions"), + }; +} + +/** OMP's active agent dir, including named profiles and explicit overrides. */ +export function getOmpAgentDir(): string { + return resolveOmpPaths().agentDir; +} + +/** OMP session JSONL root, including its XDG data layout. */ +export function getOmpSessionsRoot(): string { + return resolveOmpPaths().sessionsRoot; +} + +/** OMP's global YAML settings file. */ +export function getOmpConfigPath(): string { + return join(resolveOmpPaths().agentDir, "config.yml"); +} + +/** OMP's npm/link plugin root, including profile and XDG data layout. */ +export function getOmpPluginsDir(): string { + return resolveOmpPaths().pluginsDir; +} + +/** OMP's plugin runtime lock file. */ +export function getOmpPluginsLockPath(): string { + return join(resolveOmpPaths().pluginsDir, "omp-plugins.lock.json"); +} + +/** Shared Magic Context config used by OMP's Pi-compatible extension. */ +export function getOmpUserConfigPath(): string { + return resolveCortexKitUserConfigPath(); +} + // ============================================================================ // Plugin / shared paths // ============================================================================ diff --git a/packages/cli/src/lib/v22-backfill-commands.ts b/packages/cli/src/lib/v22-backfill-commands.ts index 23a41abde..3bef00b1c 100644 --- a/packages/cli/src/lib/v22-backfill-commands.ts +++ b/packages/cli/src/lib/v22-backfill-commands.ts @@ -101,7 +101,7 @@ export async function runV22BackfillCommands( harness.log.info(`Magic Context schema: v${schemaVersionBefore} → v${schemaVersionAfter}`); if (!readonly) { harness.log.warn( - "If OpenCode or Pi is running, restart it before creating new sessions so every process reloads the same schema fence.", + "If OpenCode, Pi, or OMP is running, restart it before creating new sessions so every process reloads the same schema fence.", ); } return { handled: true, exitCode: 0 }; diff --git a/packages/dashboard/src-tauri/src/commands.rs b/packages/dashboard/src-tauri/src/commands.rs index 1459c22bf..74b748696 100644 --- a/packages/dashboard/src-tauri/src/commands.rs +++ b/packages/dashboard/src-tauri/src/commands.rs @@ -1036,6 +1036,86 @@ pub fn parse_pi_models_output(text: &str) -> Vec { models.into_iter().collect() } +/// Parse `omp models --json`, preserving selectors with scoped or nested model IDs. +pub fn parse_omp_models_output(text: &str) -> Vec { + let Ok(value) = serde_json::from_str::(text) else { + return Vec::new(); + }; + let Some(entries) = value.get("models").and_then(serde_json::Value::as_array) else { + return Vec::new(); + }; + + let mut models = std::collections::BTreeSet::new(); + for entry in entries { + if let Some(selector) = entry.get("selector").and_then(serde_json::Value::as_str) { + if !selector.is_empty() { + models.insert(selector.to_string()); + continue; + } + } + if let (Some(provider), Some(id)) = ( + entry.get("provider").and_then(serde_json::Value::as_str), + entry.get("id").and_then(serde_json::Value::as_str), + ) { + if !provider.is_empty() && !id.is_empty() { + models.insert(format!("{provider}/{id}")); + } + } + } + models.into_iter().collect() +} + +async fn get_available_omp_models() -> Vec { + let candidates = if cfg!(target_os = "windows") { + let appdata = std::env::var("APPDATA").unwrap_or_default(); + let mut list = Vec::new(); + if !appdata.is_empty() { + list.push(format!("{}\\npm\\omp.cmd", appdata)); + list.push(format!("{}\\npm\\omp.exe", appdata)); + } + list.push("omp".to_string()); + list.push("omp.exe".to_string()); + list + } else { + let home = std::env::var("HOME").unwrap_or_default(); + vec![ + format!("{}/.bun/bin/omp", home), + "omp".to_string(), + format!("{}/.local/bin/omp", home), + "/usr/local/bin/omp".to_string(), + "/opt/homebrew/bin/omp".to_string(), + format!("{}/.local/share/mise/shims/omp", home), + format!("{}/.asdf/shims/omp", home), + format!("{}/.volta/bin/omp", home), + ] + }; + + for bin in &candidates { + if let Some(text) = run_bounded_binary(bin, &["models", "--json"]).await { + let models = parse_omp_models_output(&text); + if !models.is_empty() { + return models; + } + } + } + + if cfg!(target_os = "windows") { + if let Some(bin) = resolve_via_where("omp").await { + if let Some(text) = run_bounded_binary(&bin, &["models", "--json"]).await { + let models = parse_omp_models_output(&text); + if !models.is_empty() { + return models; + } + } + } + } + + if let Some(text) = run_via_login_shell("omp models --json".to_string()).await { + return parse_omp_models_output(&text); + } + Vec::new() +} + #[tauri::command] pub async fn get_available_pi_models() -> Vec { // GUI apps on macOS don't inherit shell PATH; try common locations. @@ -1108,6 +1188,10 @@ pub async fn get_available_pi_models() -> Vec { } } + let omp_models = get_available_omp_models().await; + if !omp_models.is_empty() { + return omp_models; + } // Last resort: resolve `pi` through the user's login shell. This is the // universal fallback for version managers (mise/nvm/fnm) that install into // per-version dirs the candidates above can't enumerate — the login shell @@ -1318,9 +1402,9 @@ pub fn get_db_health(state: State<'_, AppState>) -> db::DbHealth { #[cfg(test)] mod tests { use super::{ - opencode_desktop_detected_for_env, parse_pi_models_output, pick_first_line, - prepare_embedding_probe_options, run_bounded_binary, windows_opencode_candidates, - DesktopPlatform, OpencodeDesktopEnv, OPENCODE_DESKTOP_APP_IDS, + opencode_desktop_detected_for_env, parse_omp_models_output, parse_pi_models_output, + pick_first_line, prepare_embedding_probe_options, run_bounded_binary, + windows_opencode_candidates, DesktopPlatform, OpencodeDesktopEnv, OPENCODE_DESKTOP_APP_IDS, }; use crate::embedding_probe::EmbeddingProbeOutcome; use std::path::{Path, PathBuf}; @@ -1576,6 +1660,19 @@ mod tests { assert_eq!(result, vec!["anthropic/claude-sonnet-4-5"]); } + #[test] + fn test_parse_omp_models_output_preserves_selectors() { + let input = r#"{"models":[{"provider":"anthropic","id":"claude-opus-4-5","selector":"anthropic/claude-opus-4-5"},{"provider":"modal","id":"@modal/qwen/model-v1","selector":"modal/@modal/qwen/model-v1"},{"provider":"openai","id":"fallback/model"}]}"#; + assert_eq!( + parse_omp_models_output(input), + vec![ + "anthropic/claude-opus-4-5", + "modal/@modal/qwen/model-v1", + "openai/fallback/model", + ] + ); + } + #[test] fn test_pick_first_line() { assert_eq!(pick_first_line(""), None); diff --git a/packages/dashboard/src-tauri/src/pi_sessions.rs b/packages/dashboard/src-tauri/src/pi_sessions.rs index 780937dc5..e4f2dc5fa 100644 --- a/packages/dashboard/src-tauri/src/pi_sessions.rs +++ b/packages/dashboard/src-tauri/src/pi_sessions.rs @@ -90,18 +90,137 @@ pub fn pi_sessions_root() -> Option { Some(dirs::home_dir()?.join(".pi/agent/sessions")) } -pub fn scan_pi_session_dir() -> Vec { - let Some(root) = pi_sessions_root() else { - return Vec::new(); +fn normalize_omp_profile(value: Option) -> Option { + let value = value?; + let trimmed = value.to_string_lossy().trim().to_string(); + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") { + None + } else { + Some(trimmed.into()) + } +} + +fn resolve_omp_profile( + omp_profile: Option, + pi_profile: Option, +) -> Option { + normalize_omp_profile(match omp_profile { + Some(value) => Some(value), + None => pi_profile, + }) +} + +fn active_omp_profile() -> Option { + resolve_omp_profile( + std::env::var_os("OMP_PROFILE"), + std::env::var_os("PI_PROFILE"), + ) +} + +fn append_omp_profile_roots(roots: &mut Vec, profiles_dir: &Path, xdg: bool) { + let Ok(entries) = fs::read_dir(profiles_dir) else { + return; }; - scan_pi_session_dir_at(&root) + let mut discovered = entries + .flatten() + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .map(|entry| { + if xdg { + entry.path().join("sessions") + } else { + entry.path().join("agent/sessions") + } + }) + .collect::>(); + discovered.sort(); + roots.extend(discovered); } -pub fn scan_pi_cache_session_dir() -> Vec { - let Some(root) = pi_sessions_root() else { +fn pi_compatible_session_roots() -> Vec { + if let Ok(root) = test_root().read() { + if let Some(path) = root.clone() { + return vec![path]; + } + } + + let Some(home) = dirs::home_dir() else { return Vec::new(); }; - scan_pi_cache_session_dir_at(&root) + let mut roots = vec![home.join(".pi/agent/sessions")]; + + if let Some(agent_dir) = std::env::var_os("PI_CODING_AGENT_DIR").filter(|v| !v.is_empty()) { + roots.push(PathBuf::from(agent_dir).join("sessions")); + } + + let config_dir = std::env::var_os("PI_CONFIG_DIR") + .filter(|v| !v.is_empty()) + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".omp")); + let profile = active_omp_profile(); + let profile_suffix = profile + .as_ref() + .map(|name| PathBuf::from("profiles").join(name)); + append_omp_profile_roots(&mut roots, &home.join(&config_dir).join("profiles"), false); + + let mut omp_agent = home.join(&config_dir); + if let Some(suffix) = &profile_suffix { + omp_agent.push(suffix); + } + roots.push(omp_agent.join("agent/sessions")); + + if let Some(xdg_data) = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) { + let app_root = PathBuf::from(xdg_data).join("omp"); + append_omp_profile_roots(&mut roots, &app_root.join("profiles"), true); + let mut xdg_root = app_root; + if let Some(suffix) = profile_suffix { + xdg_root.push(suffix); + } + if xdg_root.exists() { + roots.push(xdg_root.join("sessions")); + } + } + + let mut seen = HashSet::new(); + roots.retain(|path| seen.insert(path.clone())); + roots +} + +fn deduplicate_pi_sessions(mut sessions: Vec) -> Vec { + sessions.sort_by(|left, right| { + right + .modified + .cmp(&left.modified) + .then_with(|| left.jsonl_path.cmp(&right.jsonl_path)) + }); + let mut seen_paths = HashSet::new(); + let mut seen_ids = HashSet::new(); + sessions.retain(|session| { + let canonical_path = + fs::canonicalize(&session.jsonl_path).unwrap_or_else(|_| session.jsonl_path.clone()); + if !seen_paths.insert(canonical_path) { + return false; + } + session.session_id.is_empty() || seen_ids.insert(session.session_id.clone()) + }); + sessions +} + +pub fn scan_pi_session_dir() -> Vec { + deduplicate_pi_sessions( + pi_compatible_session_roots() + .into_iter() + .flat_map(|root| scan_pi_session_dir_at(&root)) + .collect(), + ) +} + +pub fn scan_pi_cache_session_dir() -> Vec { + deduplicate_pi_sessions( + pi_compatible_session_roots() + .into_iter() + .flat_map(|root| scan_pi_cache_session_dir_at(&root)) + .collect(), + ) } fn scan_pi_cache_session_dir_at(root: &Path) -> Vec { @@ -603,6 +722,39 @@ mod tests { path } + #[test] + fn discovers_named_omp_profile_session_roots() { + let dir = tempfile::tempdir().unwrap(); + fs::create_dir_all(dir.path().join("profiles/work/agent/sessions")).unwrap(); + fs::create_dir_all(dir.path().join("profiles/personal/agent/sessions")).unwrap(); + fs::write(dir.path().join("profiles/not-a-directory"), "").unwrap(); + let mut roots = Vec::new(); + append_omp_profile_roots(&mut roots, &dir.path().join("profiles"), false); + roots.sort(); + assert_eq!( + roots, + vec![ + dir.path().join("profiles/personal/agent/sessions"), + dir.path().join("profiles/work/agent/sessions"), + ] + ); + } + + #[test] + fn omp_profile_empty_and_default_select_the_default_profile() { + assert_eq!(normalize_omp_profile(Some("".into())), None); + assert_eq!(normalize_omp_profile(Some(" ".into())), None); + assert_eq!(normalize_omp_profile(Some("default".into())), None); + assert_eq!( + normalize_omp_profile(Some(" work ".into())), + Some("work".into()) + ); + assert_eq!( + resolve_omp_profile(Some("".into()), Some("work".into())), + None + ); + } + #[test] fn round_trip_small_pi_jsonl_fixture() { clear_caches_for_tests(); @@ -647,6 +799,28 @@ mod tests { assert!(scan_pi_cache_session_dir_at(dir.path()).is_empty()); } + #[test] + fn multi_root_discovery_deduplicates_logical_sessions() { + clear_caches_for_tests(); + let first = tempfile::tempdir().unwrap(); + let second = tempfile::tempdir().unwrap(); + let content = r#"{"type":"session","id":"same-session","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/tmp/proj"}"#; + fixture_path(&first, content); + fixture_path(&second, content); + + let mut first_sessions = scan_pi_session_dir_at(first.path()); + first_sessions[0].modified = 100; + let mut second_sessions = scan_pi_session_dir_at(second.path()); + second_sessions[0].modified = 200; + let newest_path = second_sessions[0].jsonl_path.clone(); + let sessions = + deduplicate_pi_sessions(first_sessions.into_iter().chain(second_sessions).collect()); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].session_id, "same-session"); + assert_eq!(sessions[0].jsonl_path, newest_path); + } + #[test] fn compaction_entry_handling() { clear_caches_for_tests(); diff --git a/packages/docs/src/content/docs/concepts/memory.md b/packages/docs/src/content/docs/concepts/memory.md index 1ddaf622c..e86d3f6e1 100644 --- a/packages/docs/src/content/docs/concepts/memory.md +++ b/packages/docs/src/content/docs/concepts/memory.md @@ -67,7 +67,7 @@ The desktop app and dashboard provide a memory browser where you can search, fil ## Project-scoped and shared -Memories are scoped to a **project identity** derived from the repository, not a filesystem path. This means memories follow a project across worktrees, clones, and forks. The same SQLite database is shared between OpenCode and Pi — write a memory in one harness, retrieve it in the other. +Memories are scoped to a **project identity** derived from the repository, not a filesystem path. This means memories follow a project across worktrees, clones, and forks. The same SQLite database is shared by OpenCode, Pi, and OMP — write a memory in one harness, retrieve it in another. ## How it connects diff --git a/packages/docs/src/content/docs/concepts/mural.md b/packages/docs/src/content/docs/concepts/mural.md index 42bf81eca..f1f872189 100644 --- a/packages/docs/src/content/docs/concepts/mural.md +++ b/packages/docs/src/content/docs/concepts/mural.md @@ -69,7 +69,7 @@ The mural does not render from a half-empty cue pool. Rendering is skipped until - **Vision model required** for the image part. Non-vision models get the same text baseline as with the feature off (no mural marker, no image). - **Experimental** — opt-in; defaults off. -- Works on **OpenCode and Pi** with the same config and shared store. The image envelope differs by harness (file part vs native image content); the PNG and the fold/replay rules match. +- Works on **OpenCode, Pi, and OMP** with the same config and shared store. The image envelope differs between OpenCode and the Pi-compatible hosts (file part vs native image content); the PNG and fold/replay rules match. ## How it connects diff --git a/packages/docs/src/content/docs/getting-started/installation.md b/packages/docs/src/content/docs/getting-started/installation.md index 6dc1b9970..3abc76d10 100644 --- a/packages/docs/src/content/docs/getting-started/installation.md +++ b/packages/docs/src/content/docs/getting-started/installation.md @@ -1,6 +1,6 @@ --- title: Installation -description: How to install Magic Context on OpenCode or Pi using the interactive setup wizard, and how to verify your install. +description: How to install Magic Context on OpenCode, Pi, or Oh My Pi using the interactive setup wizard, and how to verify your install. --- import { Tabs, TabItem } from '@astrojs/starlight/components'; @@ -12,6 +12,7 @@ The setup wizard detects which harnesses you have installed, configures the plug - **Node.js >= 24** for the CLI - **OpenCode** (current version) — for OpenCode installs - **Pi >= 0.74.0** — for Pi installs +- **OMP >= 17.1.7** — for Oh My Pi installs ## Run setup @@ -19,18 +20,19 @@ The setup wizard detects which harnesses you have installed, configures the plug npx @cortexkit/magic-context@latest setup ``` -The wizard auto-detects whether you have OpenCode, Pi, or both installed. It then: +The wizard auto-detects OpenCode, Pi, and OMP. It then: -1. Registers the plugin in your harness config -2. Disables built-in compaction (OpenCode only — Magic Context replaces it) -3. Prompts you to pick models for the historian and dreamer agents -4. Resolves any conflicts with other context-management plugins -5. Writes a `magic-context.jsonc` config file with sensible defaults +1. Registers the plugin using the harness's native package manager +2. Disables built-in compaction where Magic Context takes ownership +3. Disables OMP automatic memory to prevent duplicate recall/retention +4. Prompts you to pick models for the historian and dreamer agents +5. Resolves conflicts with other context-management plugins +6. Writes a shared `magic-context.jsonc` with sensible defaults -To target one harness explicitly, pass `--harness opencode` or `--harness pi`. +Target one harness explicitly with `--harness opencode`, `--harness pi`, or `--harness omp`. :::note -**Why is compaction disabled?** Magic Context manages context itself. OpenCode's built-in compaction would interfere with the historian and double-compress your history. Setup turns it off automatically. See [compatibility](/help/compatibility/) for details on other plugins that conflict. +**Why is compaction disabled?** Magic Context manages context itself. Host compaction would interfere with the historian and double-compress history. Setup turns it off automatically. OMP's automatic memory backend is also disabled because two memory injectors would duplicate context and writes. Existing OMP memory data is not deleted. ::: ## What gets configured @@ -47,7 +49,7 @@ Setup adds the plugin to your `opencode.json` and turns off compaction: } ``` -It also creates a `magic-context.jsonc` config file in the shared CortexKit location (the same for both harnesses; project overrides user): +It also creates a `magic-context.jsonc` config file in the shared CortexKit location (the same across all harnesses; project overrides user): | Path | Scope | |---|---| @@ -57,7 +59,7 @@ It also creates a `magic-context.jsonc` config file in the shared CortexKit loca -Setup adds the extension to Pi's settings and creates a `magic-context.jsonc` config file in the shared CortexKit location (the same for both harnesses; project overrides user): +Setup adds the extension to Pi's settings and creates a `magic-context.jsonc` config file in the shared CortexKit location (the same across all harnesses; project overrides user): | Path | Scope | |---|---| @@ -68,12 +70,26 @@ Setup adds the extension to Pi's settings and creates a `magic-context.jsonc` co Pi setup prompts for `thinking_level` if you pick a `github-copilot/*` reasoning model — Copilot requires it and rejects the default value Pi would send otherwise. The wizard handles this for you. ::: + + + +Setup installs `@cortexkit/pi-magic-context` through `omp plugin`, verifies it is effectively enabled for the current project, and writes the same shared CortexKit config used by OpenCode and Pi. It honors named OMP profiles, `PI_CODING_AGENT_DIR`, `PI_CONFIG_DIR`, and initialized XDG layouts. + +OMP setup transactionally applies: + +```bash +omp config set compaction.enabled false +omp config set memory.backend off +``` + +If plugin registration or config writing fails, the prior OMP settings are restored. + ## Verify the install -After setup, restart your harness (reload OpenCode or restart Pi) so the plugin loads. +After setup, restart your harness (or run `/reload-plugins` in OMP) so the plugin loads. @@ -85,6 +101,11 @@ Run `/ctx-status` in the OpenCode TUI or Desktop. You should see a status view w Run `/ctx-status` in Pi. You should see a status line with context usage and Magic Context state. Pi also shows a status line in the footer when the plugin is active. + + + +Run `/ctx-status` in OMP. The Magic Context footer/status should be active, and `doctor --harness omp` should report native compaction and automatic memory as disabled. + diff --git a/packages/docs/src/content/docs/getting-started/introduction.md b/packages/docs/src/content/docs/getting-started/introduction.md index 30595813b..36e73bf6c 100644 --- a/packages/docs/src/content/docs/getting-started/introduction.md +++ b/packages/docs/src/content/docs/getting-started/introduction.md @@ -5,13 +5,13 @@ description: What Magic Context is, why it exists, and what changes for your cod Every coding agent session starts from zero. No memory of last week's decisions, no recall of why you chose a particular architecture, no awareness of what happened before the current context window. When the window fills up, the agent pauses for compaction — a lossy operation that discards detail and breaks your flow. Then you start another session and it starts from zero again. -Magic Context is a plugin for [OpenCode](https://opencode.ai) and [Pi](https://github.com/earendil-works/pi-mono) that ends this cycle. It gives your agent persistent memory and a context window that manages itself — silently, in the background, without ever stopping the session. +Magic Context supports [OpenCode](https://opencode.ai), [Pi](https://github.com/earendil-works/pi-mono), and [Oh My Pi](https://github.com/can1357/oh-my-pi). It gives your agent persistent memory and a context window that manages itself — silently, in the background, without ever stopping the session. ## What changes **Sessions can run for weeks.** A background historian agent reads the older part of your conversation, compresses it into tiered compartments, and promotes durable knowledge (decisions, constraints, conventions) into project memory — all while you keep working. The raw history is never thrown away; the agent can expand any compartment back to the original transcript on demand via `ctx_expand`. -**The next session already knows your project.** Project memories persist across sessions and across both harnesses. When you start a new OpenCode or Pi session on the same project, those memories are injected automatically. Your agent remembers what was decided, and why. +**The next session already knows your project.** Project memories persist across sessions and all three harnesses. Start a new OpenCode, Pi, or OMP session on the same project and those memories are injected automatically. Your agent remembers what was decided, and why. **Full recall is always available.** The agent can search across memories, compressed session history, and indexed git commits in one call with `ctx_search`. Nothing is lost — it is compressed and indexed, not discarded. @@ -19,7 +19,7 @@ Magic Context is a plugin for [OpenCode](https://opencode.ai) and [Pi](https://g ## Who it is for -Magic Context is designed for developers using **OpenCode** (CLI, TUI, or Desktop) or **Pi** (>= 0.74.0). If you run long or recurring sessions on a project — debugging a codebase for weeks, building a feature over many sessions — it is especially useful. +Magic Context is designed for developers using **OpenCode** (CLI, TUI, or Desktop), **Pi** (>= 0.74.0), or **OMP** (>= 17.1.7). If you run long or recurring sessions on a project — debugging a codebase for weeks, building a feature over many sessions — it is especially useful. ## What it is not diff --git a/packages/docs/src/content/docs/getting-started/migrating-between-harnesses.md b/packages/docs/src/content/docs/getting-started/migrating-between-harnesses.md index 0c69b1b61..3f9b95a73 100644 --- a/packages/docs/src/content/docs/getting-started/migrating-between-harnesses.md +++ b/packages/docs/src/content/docs/getting-started/migrating-between-harnesses.md @@ -1,19 +1,19 @@ --- title: Migrating between harnesses -description: How to move an existing OpenCode session to Pi, carrying messages and Magic Context state with it. +description: How to move an existing OpenCode session to Pi or OMP, carrying messages and Magic Context state with it. --- -If you have an active OpenCode session and want to continue it in Pi, the `doctor migrate` command converts it into a Pi session file — including the session's messages, compartments, and session facts. +If you have an active OpenCode session and want to continue it in Pi or OMP, the `doctor migrate` command converts it into a Pi-compatible session file — including the session's messages, compartments, and session facts. :::note -Migration currently supports **OpenCode → Pi** only. Pi → OpenCode is not yet supported. +Migration currently supports **OpenCode → Pi/OMP** only. Reverse migration is not yet supported. ::: ## When you'd want this - You want to switch primary coding environments mid-project without losing session context -- You want to try Pi on a session that has built up significant history in OpenCode -- You're migrating your workflow from OpenCode to Pi and want to bring existing sessions along +- You want to try Pi or OMP on a session that has built up significant history in OpenCode +- You're migrating your workflow from OpenCode to a Pi-compatible host and want to bring existing sessions along ## What carries over @@ -27,7 +27,7 @@ Migration currently supports **OpenCode → Pi** only. Pi → OpenCode is not ye | File attachments | ⚠️ Replaced with `` markers | | Reasoning signatures | ❌ Stripped | -Project memories are **always shared** between OpenCode and Pi sessions for the same project — they live in the shared database and do not need to be migrated. The migration is only for session-level state (messages and compartments). +Project memories are **always shared** among OpenCode, Pi, and OMP sessions for the same project — they live in the shared database and do not need migration. Migration only copies session-level state (messages and compartments). ## Run the migration @@ -40,6 +40,13 @@ npx @cortexkit/magic-context@latest doctor migrate \ --from opencode --to pi --session ``` +Use `--to omp` instead to write into OMP's active profile/XDG session directory: + +```bash +npx @cortexkit/magic-context@latest doctor migrate \ + --from opencode --to omp --session +``` + To preview what the migration would do without writing any files: ```bash diff --git a/packages/docs/src/content/docs/help/compatibility.md b/packages/docs/src/content/docs/help/compatibility.md index 6e978f45f..c47734eca 100644 --- a/packages/docs/src/content/docs/help/compatibility.md +++ b/packages/docs/src/content/docs/help/compatibility.md @@ -25,6 +25,22 @@ This fail-safe behavior is intentional. Magic Context staying off is better than Run `doctor` or `doctor --force` if you ever re-enable these by accident; doctor will detect and offer to fix them. +### OMP native compaction + +**Key:** `compaction.enabled` in OMP's `config.yml` + +**Why it conflicts:** OMP's native compaction and Magic Context both own the same history boundary. Running both can create repeated cancellation attempts or competing summaries. + +**Resolution:** `setup --harness omp` asks once, then sets the key to `false` transactionally. `doctor --harness omp` detects drift; `doctor --harness omp --force` repairs it. + +### OMP automatic memory + +**Key:** `memory.backend` in OMP's `config.yml` + +**Why it conflicts:** OMP local/Mnemopi/Hindsight backends inject and retain memory independently. Enabling one alongside Magic Context duplicates recall context and background writes. + +**Resolution:** OMP setup sets the backend to `off`. This disables future OMP memory injection/retention but does not delete existing OMP memory data. + ### DCP (`opencode-dcp`) **Plugin name:** `opencode-dcp` @@ -51,8 +67,9 @@ When a conflict is active: - **OpenCode TUI/Desktop:** The sidebar shows "Magic Context disabled" with a brief reason. Run `/ctx-status` for the full conflict list. - **Pi:** The footer shows a disabled state. Run `/ctx-status` for details. +- **OMP:** The Pi-compatible extension uses the same Magic Context footer and `/ctx-status` command. -Both surfaces tell you exactly which conflict was found. Once you resolve it and restart the harness, Magic Context re-enables itself automatically — no other action needed. +All three surfaces tell you exactly which conflict was found. Once you resolve it and restart the harness, Magic Context re-enables itself automatically — no other action needed. ## Resolving conflicts diff --git a/packages/docs/src/content/docs/help/faq.md b/packages/docs/src/content/docs/help/faq.md index edf551303..1a7f582e1 100644 --- a/packages/docs/src/content/docs/help/faq.md +++ b/packages/docs/src/content/docs/help/faq.md @@ -41,7 +41,7 @@ All Magic Context state lives in one place: ~/.local/share/cortexkit/magic-context/context.db ``` -On Windows, this resolves to the XDG-equivalent path. The database is shared between OpenCode and Pi — memories and compartments are scoped by harness and project, not by which terminal you're using. +On Windows, this resolves to the XDG-equivalent path. The database is shared by OpenCode, Pi, and OMP — memories and compartments are scoped by harness and project, not by which terminal you use. The local embedding model cache (if using `embedding.provider: "local"`) is stored at: @@ -88,16 +88,16 @@ Not currently. The database is local to one machine. Memories, compartments, and If you work across machines, you can manually copy `~/.local/share/cortexkit/magic-context/context.db` between them — they share the same schema and project identity (git root hash), so memories written on one machine will appear on the other after a copy. There is no automatic sync. -## Can I move a session from OpenCode to Pi? +## Can I move a session from OpenCode to Pi or OMP? -Yes. `doctor migrate` converts an existing OpenCode session into a Pi session file, carrying messages, compartments, and session facts with it. Project memories are already shared (same database, no migration needed). +Yes. `doctor migrate` converts an existing OpenCode session into a Pi-compatible session file, carrying messages, compartments, and session facts with it. Project memories are already shared (same database, no migration needed). ```bash npx @cortexkit/magic-context@latest doctor migrate \ --from opencode --to pi --session ``` -Add `--dry-run` to preview without writing, or `--max-messages N` to migrate only the most recent N messages. See [Migrating between harnesses](/getting-started/migrating-between-harnesses/) for the full walkthrough. Migration currently supports OpenCode → Pi only. +Use `--to omp` for OMP. Add `--dry-run` to preview without writing, or `--max-messages N` to migrate only the most recent N messages. See [Migrating between harnesses](/getting-started/migrating-between-harnesses/) for the full walkthrough. Reverse migration is not yet supported. ## The dashboard shows no models / I'm on OpenCode Desktop only diff --git a/packages/docs/src/content/docs/help/troubleshooting.md b/packages/docs/src/content/docs/help/troubleshooting.md index b8cd96ac2..9b48b1475 100644 --- a/packages/docs/src/content/docs/help/troubleshooting.md +++ b/packages/docs/src/content/docs/help/troubleshooting.md @@ -15,11 +15,11 @@ Add `--force` to auto-fix what it can. Add `--issue` to generate a sanitized bug ## Plugin not loading -**Symptom:** The TUI sidebar doesn't appear in OpenCode, `/ctx-status` returns an unknown command, or the Pi footer shows no Magic Context status. +**Symptom:** The TUI sidebar doesn't appear in OpenCode, `/ctx-status` returns an unknown command, or the Pi/OMP footer shows no Magic Context status. **Fix — try in order:** -1. **Restart the harness.** After install, you must restart OpenCode or Pi for the plugin to register. Simply quitting and reopening is enough. +1. **Restart the harness.** After installation, restart OpenCode or Pi; in OMP, restart or run `/reload-plugins`. 2. **Run doctor.** Doctor verifies the plugin entry in your config, checks for version mismatches, and confirms the plugin package is present: ```bash diff --git a/packages/docs/src/content/docs/index.mdx b/packages/docs/src/content/docs/index.mdx index b491b9464..957a76739 100644 --- a/packages/docs/src/content/docs/index.mdx +++ b/packages/docs/src/content/docs/index.mdx @@ -1,6 +1,6 @@ --- title: Magic Context -description: Persistent memory and self-managing context for OpenCode and Pi coding agents. +description: Persistent memory and self-managing context for OpenCode, Pi, and Oh My Pi coding agents. template: splash hero: tagline: Your coding agent, with a memory. Sessions that last weeks, context that manages itself. diff --git a/packages/docs/src/content/docs/reference/configuration.md b/packages/docs/src/content/docs/reference/configuration.md index 633147e32..99fb603a8 100644 --- a/packages/docs/src/content/docs/reference/configuration.md +++ b/packages/docs/src/content/docs/reference/configuration.md @@ -7,7 +7,7 @@ description: Every magic-context.jsonc key, with types, defaults, and where to p packages/plugin/src/config/schema/magic-context.ts; regenerate with `bun packages/plugin/scripts/build-config-docs.ts`. --> -Magic Context reads `magic-context.jsonc` (or `.json`) from one shared CortexKit location, the same for both harnesses. Project config overrides user config, key by key. +Magic Context reads `magic-context.jsonc` (or `.json`) from one shared CortexKit location across OpenCode, Pi, and OMP. Project config overrides user config, key by key. - **Project** — `/.cortexkit/magic-context.jsonc` - **User-wide** — `~/.config/cortexkit/magic-context.jsonc` diff --git a/packages/docs/src/content/docs/reference/dashboard.md b/packages/docs/src/content/docs/reference/dashboard.md index 00f7692c6..aadce8b40 100644 --- a/packages/docs/src/content/docs/reference/dashboard.md +++ b/packages/docs/src/content/docs/reference/dashboard.md @@ -68,7 +68,7 @@ The landing view is a grid of **project cards** sorted by last activity, each sh ### Sessions -Browse the project's OpenCode and Pi sessions: compartments, facts, notes, smart notes, token breakdown, and subagent stats. +Browse the project's OpenCode and Pi-compatible sessions (including OMP): compartments, facts, notes, smart notes, token breakdown, and subagent stats. - Filter by harness and search; hide subagent sessions. - Open a session and switch tabs: messages, **compartments**, facts, notes, historian runs, tokens. @@ -137,11 +137,11 @@ Collection is driven by the **`review-user-memories`** dreamer task: schedule it ## Config -Visual editor for Magic Context JSONC. Because both harnesses now read one shared CortexKit config, this is a single **User Config** surface plus **per-project** overrides (no separate OpenCode/Pi tabs). +Visual editor for Magic Context JSONC. Because every harness reads one shared CortexKit config, this is a single **User Config** surface plus **per-project** overrides (no separate harness tabs). - Toggle and edit fields that mirror `magic-context.schema.json` (the editor links the schema URL and parses JSONC including comments and trailing commas; saves abort on a parse error so comments and sibling keys are preserved). - **Save** writes real files on disk via the Tauri backend, not a preview buffer. -- Model pickers merge OpenCode and Pi model lists (with provider prefixes normalized), and you can always type a model id directly even when a discovered list is present — discovery is never exhaustive. +- Model pickers merge OpenCode and available Pi/OMP model lists (with provider prefixes normalized), and you can always type a model id directly when a discovered list is present — discovery is never exhaustive. Use [Configuration](/reference/configuration/) for the full generated key reference; use this section for day-to-day edits. diff --git a/packages/pi-plugin/README.md b/packages/pi-plugin/README.md index 04df39925..9c0c18449 100644 --- a/packages/pi-plugin/README.md +++ b/packages/pi-plugin/README.md @@ -1,8 +1,8 @@ -# Magic Context — Pi extension +# Magic Context — Pi / OMP extension -Cross-session memory and context management for [Pi coding agent](https://github.com/earendil-works/pi-mono). Shares the same SQLite database as the [OpenCode plugin](https://www.npmjs.com/package/@cortexkit/opencode-magic-context), so memories, embeddings, dreamer state, and project knowledge follow you across both harnesses. +Cross-session memory and context management for [Pi coding agent](https://github.com/earendil-works/pi-mono) and [Oh My Pi (OMP)](https://github.com/can1357/oh-my-pi). The same extension package runs on both hosts and shares its SQLite database with the [OpenCode plugin](https://www.npmjs.com/package/@cortexkit/opencode-magic-context). -Requires `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` `>= 0.74.0`. +Requires Pi `>= 0.74.0` or OMP `>= 17.1.7`. --- @@ -34,7 +34,7 @@ npx @cortexkit/magic-context@latest setup --harness pi This handles everything for you: 1. Adds `npm:@cortexkit/pi-magic-context` to Pi's `packages` array in `~/.pi/agent/settings.json` (the same place `pi install` writes to) -2. Creates `~/.pi/agent/magic-context.jsonc` with defaults +2. Creates `~/.config/cortexkit/magic-context.jsonc` with defaults 3. Prompts you for historian, dreamer, sidekick, and embedding model choices 4. Warns about provider-specific gotchas (e.g. GitHub Copilot reasoning models need an explicit `thinking_level`) @@ -44,7 +44,7 @@ If you'd rather register the Pi extension package directly with Pi (skipping the pi install npm:@cortexkit/pi-magic-context ``` -This adds the extension to `~/.pi/agent/settings.json` but won't write `magic-context.jsonc` for you — you'll need to create it manually (see Configuration below). +This adds the extension to `~/.pi/agent/settings.json` but won't write `magic-context.jsonc` for you — create the shared config manually at `~/.config/cortexkit/magic-context.jsonc` (see Configuration below). To check installation health later: @@ -52,16 +52,40 @@ To check installation health later: npx @cortexkit/magic-context@latest doctor --harness pi ``` +### Oh My Pi (OMP) + +Use the OMP-specific setup path. It installs the package through OMP's plugin manager, disables OMP native compaction and automatic memory to prevent duplicate context managers, and verifies the effective project plugin state: + +```bash +npx @cortexkit/magic-context@latest setup --harness omp +``` + +Manual installation is also supported: + +```bash +omp plugin install @cortexkit/pi-magic-context +omp config set compaction.enabled false +omp config set memory.backend off +``` + +Verify with: + +```bash +npx @cortexkit/magic-context@latest doctor --harness omp +``` + +OMP's legacy Pi loader maps `@earendil-works/*` imports to its bundled `@oh-my-pi/*` runtime. Magic Context also re-invokes the current host executable for historian, dreamer, and sidekick children, so OMP children remain OMP processes. + --- ## Configuration -Magic Context reads two config files (in this priority order): +Magic Context reads the shared CortexKit config (project overrides user): -1. `$cwd/.pi/magic-context.jsonc` (project-level overrides) -2. `~/.pi/agent/magic-context.jsonc` (user-level defaults) +1. `$cwd/.cortexkit/magic-context.jsonc` +2. `~/.config/cortexkit/magic-context.jsonc` -Both are merged through a Zod schema. Invalid fields fall back to defaults — bad config never disables the plugin entirely. +The host's agent directory still controls session discovery and relative `pi.subagent_extensions`; `PI_CODING_AGENT_DIR` is honored by both Pi and OMP. ### Minimal config @@ -78,7 +102,7 @@ Both are merged through a Zod schema. Invalid fields fall back to defaults — b } ``` -For the full configuration reference (including dreamer, sidekick, auto-search, and experimental features), see [CONFIGURATION.md](https://github.com/cortexkit/magic-context/blob/master/CONFIGURATION.md) in the main repository — the schema is shared between both plugins. +For the full configuration reference (including dreamer, sidekick, auto-search, and experimental features), see [CONFIGURATION.md](https://github.com/cortexkit/magic-context/blob/master/CONFIGURATION.md) in the main repository — OpenCode, Pi, and OMP share the same schema. --- @@ -105,11 +129,11 @@ Magic Context stores everything in a single shared SQLite database at: ~/.local/share/cortexkit/magic-context/context.db ``` -This is the **same database** the OpenCode plugin uses. Tables are scoped by: -- `harness` column (`'pi'` or `'opencode'`) for session-scoped data (tags, compartments, facts, notes) +This is the **same database** the OpenCode plugin and OMP extension use. Tables are scoped by: +- `harness` column (`'pi'` or `'opencode'`) for session-scoped data; OMP intentionally uses `'pi'` - `project_path` (resolved git root) for project-scoped data (memories, embeddings, dreamer runs) -So memories and dreamer state are shared across both harnesses for the same project; per-session tagging stays correctly attributed. +Memories and dreamer state are shared across all three harnesses for the same project; per-session tagging stays correctly attributed. Storage failures are fatal — Magic Context will refuse to register hooks rather than run with ephemeral state, since that would let context grow unbounded across restarts. @@ -117,7 +141,7 @@ Storage failures are fatal — Magic Context will refuse to register hooks rathe ## Cross-harness coherence -For semantic search to work across harnesses, both plugins must use the **same embedding model**. Magic Context detects mismatch on Pi startup and warns: +For semantic search to work across harnesses, every host must use the **same embedding model**. Magic Context detects a mismatch on Pi or OMP startup and warns: ``` WARN embedding model mismatch detected for project ...: @@ -125,7 +149,7 @@ stored vectors use "openai-compatible:Qwen/Qwen3-Embedding-8B" but Pi is configu Cross-harness search will return zero results until vectors are re-embedded. ``` -Easiest fix: configure `embedding` once in `~/.pi/agent/magic-context.jsonc` (Pi) and `~/.config/opencode/magic-context.jsonc` (OpenCode) with identical settings. +Configure `embedding` once in the shared `~/.config/cortexkit/magic-context.jsonc`; OpenCode, Pi, and OMP all read it. Use `$cwd/.cortexkit/magic-context.jsonc` only when that project intentionally needs an override. --- @@ -136,26 +160,28 @@ Easiest fix: configure `embedding` once in `~/.pi/agent/magic-context.jsonc` (Pi | `ctx_search` | n/a | Search memories + raw session history; returns ranked results with previews | | `ctx_memory` | `write`, `delete` | Manage project memories explicitly (most writes happen via dreamer instead) | | `ctx_note` | `read`, `write`, `update`, `dismiss` | Defer intentions for later — surfaced via note nudges at work boundaries | +| `ctx_expand` | `start`/`end`, `message`, `verbose` | Recover complete messages or expand a compressed conversation range | +| `ctx_reduce` | `drop` | Queue tagged turns for cache-safe removal from the live context | -`ctx_expand` and `ctx_reduce` from the OpenCode plugin are **intentionally not exposed on Pi** — they depend on raw OpenCode message ordinals, while Pi has its own message identity model. Drops still happen automatically via threshold-driven historian; you don't need an explicit `ctx_reduce` to trigger reduction. +`ctx_note`, `ctx_expand`, and `ctx_reduce` are session-scoped and are exposed in primary Pi/OMP sessions. Magic Context omits them only from ephemeral `--no-session` child processes, where they would otherwise target the hidden child session. --- ## Architecture & implementation -This package is part of the [magic-context monorepo](https://github.com/cortexkit/magic-context). The Pi extension shares the core implementation with the OpenCode plugin via the `@magic-context/core` workspace dependency, exposing only the Pi-specific adapter layer: +This package is part of the [magic-context monorepo](https://github.com/cortexkit/magic-context). Pi and OMP share the same adapter layer and core implementation: | Pi-specific module | Responsibility | |---|---| | `context-handler.ts` | Pi `pi.on("context", ...)` adapter — tags, drops, runs nudges and auto-search | -| `subagent-runner.ts` | Spawns `pi --print --mode json --no-session ...` for historian/sidekick/dreamer subagents, keeps extension discovery on for provider/AFT extensions, sets `MAGIC_CONTEXT_PI_SUBAGENT=1` so the full Magic Context entry no-ops, and applies a per-agent `--tools`/`--no-tools` allow-list plus a 2-second terminal drain | +| `subagent-runner.ts` | Re-invokes the current Pi/OMP host with `--print --mode json --no-session`, resolves relative allowlisted extensions from `PI_CODING_AGENT_DIR`, and applies per-agent tool isolation | | `tools/` | Pi `pi.registerTool` wrappers around the shared tool implementations | | `commands/` | Pi `pi.registerCommand` wrappers for the five `/ctx-*` slash commands | | `dreamer/` | Pi-side adapter for the shared dreamer scheduler | | `system-prompt.ts` | Pi `before_agent_start` injector for ``, ``, `` | -| `config/` | Pi-convention config loader (`$cwd/.pi/magic-context.jsonc` + `~/.pi/agent/magic-context.jsonc`) | +| `config/` | Shared CortexKit config loader with legacy Pi migration fallback | -The CLI lives in the unified [`@cortexkit/magic-context`](https://www.npmjs.com/package/@cortexkit/magic-context) package — `setup --harness pi` and `doctor --harness pi` route to the Pi-specific code paths in `packages/cli/src/commands/`. +The unified [`@cortexkit/magic-context`](https://www.npmjs.com/package/@cortexkit/magic-context) CLI exposes separate `pi` and `omp` setup/doctor pipelines while both use this runtime adapter. For deeper architectural detail, see the main repo's [ARCHITECTURE.md](https://github.com/cortexkit/magic-context/blob/master/ARCHITECTURE.md). diff --git a/packages/pi-plugin/package.json b/packages/pi-plugin/package.json index 385fb0e8d..97ed45c54 100644 --- a/packages/pi-plugin/package.json +++ b/packages/pi-plugin/package.json @@ -2,13 +2,15 @@ "name": "@cortexkit/pi-magic-context", "version": "0.33.0", "type": "module", - "description": "Pi coding agent extension for Magic Context — cross-session memory and context management", + "description": "Pi and OMP coding agent extension for Magic Context — cross-session memory and context management", "main": "dist/index.js", "license": "MIT", "author": "ualtinok", "keywords": [ "pi", "pi-coding-agent", + "omp", + "oh-my-pi", "extension", "context", "memory", @@ -58,6 +60,14 @@ "@earendil-works/pi-coding-agent": "^0.80.2", "@earendil-works/pi-tui": "^0.80.2" }, + "peerDependenciesMeta": { + "@earendil-works/pi-coding-agent": { + "optional": true + }, + "@earendil-works/pi-tui": { + "optional": true + } + }, "exports": { ".": { "import": "./dist/index.js" @@ -67,5 +77,10 @@ "extensions": [ "./dist/index.js" ] + }, + "omp": { + "extensions": [ + "./dist/index.js" + ] } } diff --git a/packages/pi-plugin/src/subagent-runner.test.ts b/packages/pi-plugin/src/subagent-runner.test.ts index d628e45dc..fe4dc4671 100644 --- a/packages/pi-plugin/src/subagent-runner.test.ts +++ b/packages/pi-plugin/src/subagent-runner.test.ts @@ -286,6 +286,29 @@ describe("subagent-runner pure helpers", () => { expect(args).toContain("--no-extensions"); }); + it("resolves relative allowlist entries from the active host agent dir", () => { + const previous = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = "/tmp/omp-profile/agent"; + try { + const args = buildArgsForTest( + { ...baseOptions, model: "anthropic/claude-sonnet" }, + { + subagentExtensions: ["provider-package", "./extensions/provider.ts"], + }, + ); + const firstExtension = args.indexOf("--extension"); + expect(args.slice(firstExtension, firstExtension + 4)).toEqual([ + "--extension", + "/tmp/omp-profile/agent/provider-package", + "--extension", + "/tmp/omp-profile/agent/extensions/provider.ts", + ]); + } finally { + if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previous; + } + }); + it("keeps the current all-extension argv shape when no allowlist is configured", () => { const args = buildArgsForTest({ ...baseOptions, diff --git a/packages/pi-plugin/src/subagent-runner.ts b/packages/pi-plugin/src/subagent-runner.ts index 2ee815906..a52622cc4 100644 --- a/packages/pi-plugin/src/subagent-runner.ts +++ b/packages/pi-plugin/src/subagent-runner.ts @@ -175,13 +175,14 @@ const TERMINAL_DRAIN_GRACE_MS = 2_000; export const MAGIC_CONTEXT_PI_SUBAGENT_ENV = "MAGIC_CONTEXT_PI_SUBAGENT"; -// Pi resolves local entries in its user settings package list from the -// settings directory, not from the spawned child's project cwd. Keep the same -// base for explicit --extension entries; the installed Pi package is not -// available in every development worktree to import its resolver directly. -// Pi's current resolver treats bare names as local paths too; npm packages -// should use the explicit `npm:` source form. -const PI_AGENT_SETTINGS_DIR = join(homedir(), ".pi", "agent"); +// Pi and OMP both expose their active agent directory through +// PI_CODING_AGENT_DIR. OMP sets it for named profiles too. Resolve it at argv +// construction time so profile switches and test seams cannot freeze a stale +// user root; upstream Pi falls back to its stock ~/.pi/agent directory. +function getHostAgentSettingsDir(): string { + const configured = process.env.PI_CODING_AGENT_DIR?.trim(); + return configured ? resolvePath(configured) : join(homedir(), ".pi", "agent"); +} let configuredSubagentExtensions: readonly string[] | undefined; /** Configure the user-tier extension allowlist used by new Pi child runners. */ @@ -195,7 +196,7 @@ function resolveSubagentExtensionEntry(entry: string): string { const trimmed = entry.trim(); const isNpmSource = trimmed.startsWith("npm:"); return !isNpmSource && !isAbsolute(trimmed) - ? resolvePath(PI_AGENT_SETTINGS_DIR, trimmed) + ? resolvePath(getHostAgentSettingsDir(), trimmed) : trimmed; } diff --git a/packages/plugin/scripts/build-config-docs.ts b/packages/plugin/scripts/build-config-docs.ts index 906328cac..c7971b551 100644 --- a/packages/plugin/scripts/build-config-docs.ts +++ b/packages/plugin/scripts/build-config-docs.ts @@ -229,7 +229,7 @@ description: Every magic-context.jsonc key, with types, defaults, and where to p packages/plugin/src/config/schema/magic-context.ts; regenerate with \`bun packages/plugin/scripts/build-config-docs.ts\`. --> -Magic Context reads \`magic-context.jsonc\` (or \`.json\`) from one shared CortexKit location, the same for both harnesses. Project config overrides user config, key by key. +Magic Context reads \`magic-context.jsonc\` (or \`.json\`) from one shared CortexKit location across OpenCode, Pi, and OMP. Project config overrides user config, key by key. - **Project** — \`/.cortexkit/magic-context.jsonc\` - **User-wide** — \`~/.config/cortexkit/magic-context.jsonc\`