From 8dab7cbfa0dae800dcb84d9cae39d641e8fdb73a Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Tue, 28 Jul 2026 15:43:35 +0800 Subject: [PATCH 01/16] =?UTF-8?q?feat(pi):=20=F0=9F=A7=A9=20support=20OMP-?= =?UTF-8?q?compatible=20runtime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- packages/pi-plugin/package.json | 17 +++++++++++++- .../pi-plugin/src/subagent-runner.test.ts | 23 +++++++++++++++++++ packages/pi-plugin/src/subagent-runner.ts | 17 +++++++------- 3 files changed, 48 insertions(+), 9 deletions(-) diff --git a/packages/pi-plugin/package.json b/packages/pi-plugin/package.json index b04816b0a..d99a67a67 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.1", "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; } From 866e08a2bd8d06259b322c9ad933cdedf91b0b88 Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Tue, 28 Jul 2026 15:43:41 +0800 Subject: [PATCH 02/16] =?UTF-8?q?feat(cli):=20=F0=9F=9B=A0=EF=B8=8F=20add?= =?UTF-8?q?=20OMP=20setup=20and=20doctor=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- packages/cli/package.json | 4 +- packages/cli/src/adapters/index.ts | 5 +- packages/cli/src/adapters/omp.test.ts | 48 ++ packages/cli/src/adapters/omp.ts | 193 ++++++++ packages/cli/src/adapters/types.ts | 4 +- packages/cli/src/commands/doctor-omp.test.ts | 104 +++++ packages/cli/src/commands/doctor-omp.ts | 411 ++++++++++++++++++ packages/cli/src/commands/doctor.ts | 6 + packages/cli/src/commands/migrate.test.ts | 6 + packages/cli/src/commands/migrate.ts | 31 +- packages/cli/src/commands/setup-omp.test.ts | 127 ++++++ packages/cli/src/commands/setup-omp.ts | 136 ++++++ packages/cli/src/commands/setup-pi.ts | 182 +++++--- packages/cli/src/commands/setup.ts | 21 +- packages/cli/src/index.ts | 11 +- packages/cli/src/lib/harness-select.ts | 19 +- packages/cli/src/lib/omp-helpers.test.ts | 28 ++ packages/cli/src/lib/omp-helpers.ts | 143 ++++++ packages/cli/src/lib/paths-omp.test.ts | 98 +++++ packages/cli/src/lib/paths.ts | 88 +++- packages/cli/src/lib/v22-backfill-commands.ts | 2 +- 21 files changed, 1570 insertions(+), 97 deletions(-) create mode 100644 packages/cli/src/adapters/omp.test.ts create mode 100644 packages/cli/src/adapters/omp.ts create mode 100644 packages/cli/src/commands/doctor-omp.test.ts create mode 100644 packages/cli/src/commands/doctor-omp.ts create mode 100644 packages/cli/src/commands/setup-omp.test.ts create mode 100644 packages/cli/src/commands/setup-omp.ts create mode 100644 packages/cli/src/lib/omp-helpers.test.ts create mode 100644 packages/cli/src/lib/omp-helpers.ts create mode 100644 packages/cli/src/lib/paths-omp.test.ts diff --git a/packages/cli/package.json b/packages/cli/package.json index 2a1000aa2..109148bdd 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,12 +1,14 @@ { "name": "@cortexkit/magic-context", "version": "0.33.1", - "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 }; From d909e814134b6121b777c421c7757e52e242aec0 Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Tue, 28 Jul 2026 15:43:51 +0800 Subject: [PATCH 03/16] =?UTF-8?q?feat(dashboard):=20=F0=9F=94=AD=20discove?= =?UTF-8?q?r=20OMP=20models=20and=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- packages/dashboard/src-tauri/src/commands.rs | 103 ++++++++++++- .../dashboard/src-tauri/src/pi_sessions.rs | 138 +++++++++++++++++- 2 files changed, 231 insertions(+), 10 deletions(-) diff --git a/packages/dashboard/src-tauri/src/commands.rs b/packages/dashboard/src-tauri/src/commands.rs index 5e37b903b..b4269be94 100644 --- a/packages/dashboard/src-tauri/src/commands.rs +++ b/packages/dashboard/src-tauri/src/commands.rs @@ -1037,6 +1037,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. @@ -1109,6 +1189,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 @@ -1319,9 +1403,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}; @@ -1577,6 +1661,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..35b8ccfaf 100644 --- a/packages/dashboard/src-tauri/src/pi_sessions.rs +++ b/packages/dashboard/src-tauri/src/pi_sessions.rs @@ -90,18 +90,109 @@ 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) + for entry in entries.flatten() { + if entry.file_type().is_ok_and(|kind| kind.is_dir()) { + roots.push(if xdg { + entry.path().join("sessions") + } else { + entry.path().join("agent/sessions") + }); + } + } } -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 +} + +pub fn scan_pi_session_dir() -> Vec { + pi_compatible_session_roots() + .into_iter() + .flat_map(|root| scan_pi_session_dir_at(&root)) + .collect() +} + +pub fn scan_pi_cache_session_dir() -> Vec { + 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 +694,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(); From 7d8ea2270eb0ecd8534ec242949a0bcf308161bb Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Tue, 28 Jul 2026 15:43:58 +0800 Subject: [PATCH 04/16] =?UTF-8?q?docs(omp):=20=F0=9F=93=9D=20document=20fu?= =?UTF-8?q?ll=20harness=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- .gitignore | 1 + CONFIGURATION.md | 17 +++--- README.md | 14 +++-- .../docs/src/content/docs/concepts/memory.md | 2 +- .../docs/src/content/docs/concepts/mural.md | 2 +- .../docs/getting-started/installation.md | 45 +++++++++++----- .../docs/getting-started/introduction.md | 6 +-- .../migrating-between-harnesses.md | 19 ++++--- .../src/content/docs/help/compatibility.md | 19 ++++++- packages/docs/src/content/docs/help/faq.md | 8 +-- .../src/content/docs/help/troubleshooting.md | 4 +- packages/docs/src/content/docs/index.mdx | 2 +- .../content/docs/reference/configuration.md | 2 +- .../src/content/docs/reference/dashboard.md | 6 +-- packages/pi-plugin/README.md | 52 ++++++++++++++----- packages/plugin/scripts/build-config-docs.ts | 2 +- 16 files changed, 139 insertions(+), 62 deletions(-) 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 742fd362a..85aa0abfe 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/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 69822149f..8edacc936 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..77e5c270f 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`. --- @@ -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 @@ -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. @@ -143,19 +167,19 @@ Easiest fix: configure `embedding` once in `~/.pi/agent/magic-context.jsonc` (Pi ## 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/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\` From 201acbcca44c44171b8be05bf634bfe2157d2699 Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Tue, 28 Jul 2026 16:03:46 +0800 Subject: [PATCH 05/16] =?UTF-8?q?docs(pi):=20=F0=9F=93=9D=20correct=20shar?= =?UTF-8?q?ed=20config=20and=20tool=20surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- packages/pi-plugin/README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/pi-plugin/README.md b/packages/pi-plugin/README.md index 77e5c270f..9c0c18449 100644 --- a/packages/pi-plugin/README.md +++ b/packages/pi-plugin/README.md @@ -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: @@ -102,7 +102,7 @@ The host's agent directory still controls session discovery and relative `pi.sub } ``` -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. --- @@ -141,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 ...: @@ -149,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. --- @@ -160,8 +160,10 @@ 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. --- From 45ca55727e4f0412f190aaed4585f80d8e2a4476 Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Tue, 28 Jul 2026 16:07:03 +0800 Subject: [PATCH 06/16] =?UTF-8?q?fix(dashboard):=20=F0=9F=90=9B=20deduplic?= =?UTF-8?q?ate=20multi-root=20sessions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- .../dashboard/src-tauri/src/pi_sessions.rs | 78 +++++++++++++++---- 1 file changed, 64 insertions(+), 14 deletions(-) diff --git a/packages/dashboard/src-tauri/src/pi_sessions.rs b/packages/dashboard/src-tauri/src/pi_sessions.rs index 35b8ccfaf..e4f2dc5fa 100644 --- a/packages/dashboard/src-tauri/src/pi_sessions.rs +++ b/packages/dashboard/src-tauri/src/pi_sessions.rs @@ -121,15 +121,19 @@ fn append_omp_profile_roots(roots: &mut Vec, profiles_dir: &Path, xdg: let Ok(entries) = fs::read_dir(profiles_dir) else { return; }; - for entry in entries.flatten() { - if entry.file_type().is_ok_and(|kind| kind.is_dir()) { - roots.push(if xdg { + 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); } fn pi_compatible_session_roots() -> Vec { @@ -181,18 +185,42 @@ fn pi_compatible_session_roots() -> Vec { 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 { - pi_compatible_session_roots() - .into_iter() - .flat_map(|root| scan_pi_session_dir_at(&root)) - .collect() + 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 { - pi_compatible_session_roots() - .into_iter() - .flat_map(|root| scan_pi_cache_session_dir_at(&root)) - .collect() + 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 { @@ -771,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(); From 81c6ed1f59da6f8d932bbfdf8d5484b1bb2bb72a Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Wed, 5 Aug 2026 22:09:19 +0800 Subject: [PATCH 07/16] =?UTF-8?q?fix(cli):=20=F0=9F=9B=A1=EF=B8=8F=20harde?= =?UTF-8?q?n=20OMP=20setup=20and=20diagnostics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- packages/cli/src/commands/doctor-omp.test.ts | 23 +++++++- packages/cli/src/commands/doctor-omp.ts | 62 ++++++++++++-------- packages/cli/src/commands/setup-omp.test.ts | 6 +- packages/cli/src/commands/setup-pi.test.ts | 24 +++++++- packages/cli/src/commands/setup-pi.ts | 22 ++++++- packages/cli/src/lib/omp-helpers.test.ts | 19 +++++- packages/cli/src/lib/omp-helpers.ts | 3 +- 7 files changed, 125 insertions(+), 34 deletions(-) diff --git a/packages/cli/src/commands/doctor-omp.test.ts b/packages/cli/src/commands/doctor-omp.test.ts index 26c8e23e9..df2fc42bb 100644 --- a/packages/cli/src/commands/doctor-omp.test.ts +++ b/packages/cli/src/commands/doctor-omp.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, 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"; @@ -101,4 +101,25 @@ describe("OMP doctor", () => { expect(prompts.messages.join("\n")).toContain("OMP 17.1.7 detected"); expect(prompts.messages.join("\n")).toContain("FAIL 0"); }); + + it("writes an independent default config even when OMP is missing", async () => { + const root = mkdtempSync(join(tmpdir(), "mc-omp-doctor-no-bin-")); + roots.push(root); + process.env.HOME = root; + process.env.XDG_CONFIG_HOME = join(root, ".config"); + delete process.env.XDG_DATA_HOME; + delete process.env.PI_CODING_AGENT_DIR; + const prompts = new MockPrompts(); + + const code = await runDoctor({ + cwd: root, + force: true, + prompts, + deps: { detectOmpBinary: () => null }, + }); + + expect(code).toBe(1); + expect(existsSync(join(root, ".config", "cortexkit", "magic-context.jsonc"))).toBe(true); + expect(prompts.messages.join("\n")).toContain("Wrote default Magic Context config"); + }); }); diff --git a/packages/cli/src/commands/doctor-omp.ts b/packages/cli/src/commands/doctor-omp.ts index 99d53da86..69ca4cec1 100644 --- a/packages/cli/src/commands/doctor-omp.ts +++ b/packages/cli/src/commands/doctor-omp.ts @@ -297,9 +297,14 @@ function writeDefaultConfig(path: string): void { } async function repair(plan: RepairPlan, deps: DoctorDeps, prompts: PromptIO): Promise { - const omp = deps.detectOmpBinary(); - if (!omp) return 0; let fixed = 0; + if (plan.writeUserConfig && !existsSync(getOmpUserConfigPath())) { + writeDefaultConfig(getOmpUserConfigPath()); + prompts.log.success(`Wrote default Magic Context config to ${getOmpUserConfigPath()}`); + fixed += 1; + } + const omp = deps.detectOmpBinary(); + if (!omp) return fixed; if (plan.installPlugin) { const result = await new OmpAdapter().ensurePluginEntry(); if (result.ok) { @@ -318,11 +323,7 @@ async function repair(plan: RepairPlan, deps: DoctorDeps, prompts: PromptIO): Pr 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; } @@ -362,27 +363,36 @@ async function runIssueFlow(options: { 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()); - } + options.deps.execFileSync("gh", ["--version"], { stdio: "ignore" }); } catch { options.prompts.log.info("gh CLI unavailable; submit the generated report manually"); + return 0; + } + try { + options.deps.execFileSync("gh", ["auth", "status"], { stdio: "ignore" }); + } catch { + options.prompts.log.info( + "gh CLI is installed but not authenticated; submit the generated report manually", + ); + return 0; + } + 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()); } return 0; } diff --git a/packages/cli/src/commands/setup-omp.test.ts b/packages/cli/src/commands/setup-omp.test.ts index f4bc35e63..ea8b42661 100644 --- a/packages/cli/src/commands/setup-omp.test.ts +++ b/packages/cli/src/commands/setup-omp.test.ts @@ -1,7 +1,7 @@ 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 { delimiter, join } from "node:path"; import type { PromptIO, PromptSpinner, SelectOption } from "../lib/prompts"; import { __test } from "./setup-omp"; @@ -63,7 +63,7 @@ function makeFakeOmp(): { binary: string; state: string } { writeFileSync(state, JSON.stringify({ compaction: true, memory: "mnemopi" })); writeFileSync( binary, - `#!/usr/bin/node + `#!/usr/bin/env node const fs = require("fs"); const statePath = ${JSON.stringify(state)}; const state = JSON.parse(fs.readFileSync(statePath, "utf8")); @@ -81,7 +81,7 @@ if (args[0] === "config" && args[1] === "get") { `, ); chmodSync(binary, 0o755); - process.env.PATH = root; + process.env.PATH = [root, original.PATH].filter(Boolean).join(delimiter); process.env.HOME = root; return { binary, state }; } diff --git a/packages/cli/src/commands/setup-pi.test.ts b/packages/cli/src/commands/setup-pi.test.ts index 3f839570c..c8ad69d90 100644 --- a/packages/cli/src/commands/setup-pi.test.ts +++ b/packages/cli/src/commands/setup-pi.test.ts @@ -5,7 +5,12 @@ import { join } from "node:path"; import { parse as parseJsonc } from "comment-json"; import type { PromptIO, PromptSpinner, SelectOption } from "../lib/prompts"; -import { runSetup, type SetupEnvironment, writePiSettingsPackage } from "./setup-pi"; +import { + removePiSettingsPackage, + runSetup, + type SetupEnvironment, + writePiSettingsPackage, +} from "./setup-pi"; const tempRoots: string[] = []; const originalHome = process.env.HOME; @@ -96,6 +101,23 @@ afterEach(() => { } }); +describe("Pi settings rollback", () => { + it("removes only the package entry added by setup", () => { + const root = makeTempRoot(); + const settingsPath = join(root, "settings.json"); + writeFileSync( + settingsPath, + JSON.stringify({ packages: ["unrelated-package", "npm:@cortexkit/pi-magic-context"] }), + ); + + expect(removePiSettingsPackage(settingsPath)).toBe(true); + expect(parseJsonc(readFileSync(settingsPath, "utf-8"))).toEqual({ + packages: ["unrelated-package"], + }); + expect(removePiSettingsPackage(settingsPath)).toBe(false); + }); +}); + describe("runSetup", () => { it("aborts before writing when an existing target is malformed", async () => { const root = makeTempRoot(); diff --git a/packages/cli/src/commands/setup-pi.ts b/packages/cli/src/commands/setup-pi.ts index 86b51b744..5d2ee77aa 100644 --- a/packages/cli/src/commands/setup-pi.ts +++ b/packages/cli/src/commands/setup-pi.ts @@ -107,6 +107,11 @@ const DEFAULT_HOST: PiCompatibleSetupHost = { configPath: settingsPath, }; }, + rollbackPluginEntry: async (registration) => { + if (registration.action === "added") { + removePiSettingsPackage(registration.configPath); + } + }, }; function ensureDir(path: string): void { @@ -161,6 +166,19 @@ export function writePiSettingsPackage( writeFileAtomic(settingsPath, `${stringifyJsonc(settings, null, 2)}\n`); return !hasPackage; } +export function removePiSettingsPackage( + settingsPath: string, + packageSource = PI_PACKAGE_SOURCE, +): boolean { + const settings = readJsoncConfigForUpdate(settingsPath); + if (!Array.isArray(settings.packages)) return false; + const packages = settings.packages; + const filtered = packages.filter((entry) => entry !== packageSource); + if (filtered.length === packages.length) return false; + settings.packages = filtered; + writeFileAtomic(settingsPath, `${stringifyJsonc(settings, null, 2)}\n`); + return true; +} export function writeMagicContextConfig( configPath: string, @@ -282,7 +300,9 @@ export async function runSetup(options: RunSetupOptions = {}): Promise { const binary = env.detectPiBinary(); if (!binary) { spinner.stop(`${host.displayName} not found`); - prompts.log.warn(`Could not find \`${host.cliName}\` on PATH.`); + prompts.log.warn( + `Could not find \`${host.cliName}\` on PATH or in standard user install directories.`, + ); prompts.log.message(`Install ${host.displayName} first, then rerun setup.`); prompts.outro(`Setup stopped — install ${host.displayName} and try again`); return 1; diff --git a/packages/cli/src/lib/omp-helpers.test.ts b/packages/cli/src/lib/omp-helpers.test.ts index 000002587..c3554fe66 100644 --- a/packages/cli/src/lib/omp-helpers.test.ts +++ b/packages/cli/src/lib/omp-helpers.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { parseOmpModelsOutput } from "./omp-helpers"; +import { parseOmpModelsOutput, runOmpCommand } from "./omp-helpers"; describe("OMP model discovery", () => { it("parses model selectors without flattening scoped or nested IDs", () => { @@ -26,3 +26,20 @@ describe("OMP model discovery", () => { ]); }); }); + +describe("OMP command execution", () => { + it("preserves spawn timeout errors when stderr is empty", () => { + const result = runOmpCommand(process.execPath, ["-e", "while (true) {}"], 10); + expect(result.ok).toBe(false); + expect(result.stderr.length).toBeGreaterThan(0); + }); + + it("captures JSON-sized output above Node's default buffer", () => { + const result = runOmpCommand(process.execPath, [ + "-e", + "process.stdout.write('x'.repeat(2 * 1024 * 1024))", + ]); + expect(result.ok).toBe(true); + expect(result.stdout.length).toBe(2 * 1024 * 1024); + }); +}); diff --git a/packages/cli/src/lib/omp-helpers.ts b/packages/cli/src/lib/omp-helpers.ts index 473d1724f..311ab9ce7 100644 --- a/packages/cli/src/lib/omp-helpers.ts +++ b/packages/cli/src/lib/omp-helpers.ts @@ -43,12 +43,13 @@ export function runOmpCommand(ompPath: string, args: string[], timeout = 30_000) const result = spawnSync(invocation.command, invocation.args, { encoding: "utf-8", timeout, + maxBuffer: 10 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"], }); return { ok: result.status === 0 && !result.error, stdout: result.stdout?.trim() ?? "", - stderr: result.stderr?.trim() ?? result.error?.message ?? "", + stderr: result.stderr?.trim() || result.error?.message || "", }; } catch (error) { return { From 275e07d353bb11c01e721b7ca3f0e62c44ed24c1 Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Wed, 5 Aug 2026 22:09:27 +0800 Subject: [PATCH 08/16] =?UTF-8?q?fix(dashboard):=20=F0=9F=94=92=20harden?= =?UTF-8?q?=20OMP=20discovery=20precedence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- packages/dashboard/src-tauri/src/commands.rs | 17 ++-- .../dashboard/src-tauri/src/pi_sessions.rs | 86 ++++++++++++------- 2 files changed, 67 insertions(+), 36 deletions(-) diff --git a/packages/dashboard/src-tauri/src/commands.rs b/packages/dashboard/src-tauri/src/commands.rs index b4269be94..ffe4cbd77 100644 --- a/packages/dashboard/src-tauri/src/commands.rs +++ b/packages/dashboard/src-tauri/src/commands.rs @@ -1189,17 +1189,20 @@ pub async fn get_available_pi_models() -> Vec { } } + // Resolve Pi through the user's login shell before falling back to OMP. + // Otherwise an OMP install can mask a real Pi install managed by an + // uncommon version manager whose shims are only present in login-shell PATH. + if let Some(text) = run_via_login_shell("pi --list-models".to_string()).await { + let models = parse_pi_models_output(&text); + if !models.is_empty() { + return models; + } + } + 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 - // carries the same PATH the user's terminal uses to find `pi`. - if let Some(text) = run_via_login_shell("pi --list-models".to_string()).await { - return parse_pi_models_output(&text); - } Vec::new() } diff --git a/packages/dashboard/src-tauri/src/pi_sessions.rs b/packages/dashboard/src-tauri/src/pi_sessions.rs index e4f2dc5fa..e84447d18 100644 --- a/packages/dashboard/src-tauri/src/pi_sessions.rs +++ b/packages/dashboard/src-tauri/src/pi_sessions.rs @@ -81,23 +81,19 @@ fn test_root() -> &'static RwLock> { TEST_ROOT.get_or_init(|| RwLock::new(None)) } -pub fn pi_sessions_root() -> Option { - if let Ok(root) = test_root().read() { - if let Some(path) = root.clone() { - return Some(path); - } - } - Some(dirs::home_dir()?.join(".pi/agent/sessions")) -} - 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()) + if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("default") || trimmed.len() > 64 { + return None; } + let mut chars = trimmed.chars(); + let first = chars.next()?; + let valid = (first.is_ascii_lowercase() || first.is_ascii_digit()) + && chars.all(|ch| { + ch.is_ascii_lowercase() || ch.is_ascii_digit() || matches!(ch, '.' | '_' | '-') + }); + valid.then(|| trimmed.into()) } fn resolve_omp_profile( @@ -181,35 +177,38 @@ fn pi_compatible_session_roots() -> Vec { } let mut seen = HashSet::new(); - roots.retain(|path| seen.insert(path.clone())); + roots.retain(|path| { + let canonical = fs::canonicalize(path).unwrap_or_else(|_| path.clone()); + seen.insert(canonical) + }); roots } -fn deduplicate_pi_sessions(mut sessions: Vec) -> Vec { - sessions.sort_by(|left, right| { +fn deduplicate_pi_sessions(mut sessions: Vec<(usize, PiSessionMeta)>) -> Vec { + sessions.sort_by(|(left_priority, left), (right_priority, right)| { right .modified .cmp(&left.modified) + .then_with(|| left_priority.cmp(right_priority)) .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; - } + sessions.retain(|(_, session)| { session.session_id.is_empty() || seen_ids.insert(session.session_id.clone()) }); - sessions + sessions.into_iter().map(|(_, session)| session).collect() } 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)) + .enumerate() + .flat_map(|(priority, root)| { + scan_pi_session_dir_at(&root) + .into_iter() + .map(move |session| (priority, session)) + }) .collect(), ) } @@ -218,7 +217,12 @@ 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)) + .enumerate() + .flat_map(|(priority, root)| { + scan_pi_cache_session_dir_at(&root) + .into_iter() + .map(move |session| (priority, session)) + }) .collect(), ) } @@ -741,10 +745,14 @@ mod tests { } #[test] - fn omp_profile_empty_and_default_select_the_default_profile() { + fn omp_profile_rejects_unsafe_names_and_preserves_default_semantics() { 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("../escape".into())), None); + assert_eq!(normalize_omp_profile(Some("UPPER".into())), None); + assert_eq!(normalize_omp_profile(Some("bad/name".into())), None); + assert_eq!(normalize_omp_profile(Some("a".repeat(65).into())), None); assert_eq!( normalize_omp_profile(Some(" work ".into())), Some("work".into()) @@ -813,14 +821,34 @@ mod tests { 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()); + let sessions = deduplicate_pi_sessions( + first_sessions + .into_iter() + .map(|session| (0, session)) + .chain(second_sessions.into_iter().map(|session| (1, session))) + .collect(), + ); assert_eq!(sessions.len(), 1); assert_eq!(sessions[0].session_id, "same-session"); assert_eq!(sessions[0].jsonl_path, newest_path); } + #[test] + fn tied_duplicates_keep_the_higher_priority_root() { + let dir = tempfile::tempdir().unwrap(); + let content = r#"{"type":"session","id":"same-session","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/tmp/proj"}"#; + fixture_path(&dir, content); + let original = scan_pi_session_dir_at(dir.path()).remove(0); + let mut lower_priority = original.clone(); + lower_priority.jsonl_path = PathBuf::from("/lower-priority/session.jsonl"); + + let sessions = deduplicate_pi_sessions(vec![(1, lower_priority), (0, original.clone())]); + + assert_eq!(sessions.len(), 1); + assert_eq!(sessions[0].jsonl_path, original.jsonl_path); + } + #[test] fn compaction_entry_handling() { clear_caches_for_tests(); From 6bdff9320000965a96cd2d04e2b9404dd402f22c Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Wed, 5 Aug 2026 22:09:43 +0800 Subject: [PATCH 09/16] =?UTF-8?q?docs(omp):=20=F0=9F=93=9D=20complete=20ho?= =?UTF-8?q?mepage=20support=20matrix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- packages/docs/src/content/docs/index.mdx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/docs/src/content/docs/index.mdx b/packages/docs/src/content/docs/index.mdx index 957a76739..2526f1163 100644 --- a/packages/docs/src/content/docs/index.mdx +++ b/packages/docs/src/content/docs/index.mdx @@ -42,9 +42,10 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; - **[OpenCode](https://opencode.ai)** — CLI, TUI, and Desktop - **[Pi](https://github.com/earendil-works/pi-mono)** — terminal coding agent +- **[Oh My Pi](https://github.com/can1357/oh-my-pi)** — terminal coding agent with IDE-grade tooling -One shared store: sessions, memories, and history are shared across both -harnesses on the same machine. +One shared store: sessions, memories, and history are shared across all +three harnesses on the same machine. ```bash npx @cortexkit/magic-context@latest setup From 5754598034d868c507ab3c2a270f4274bb1bd68f Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Wed, 5 Aug 2026 22:09:43 +0800 Subject: [PATCH 10/16] =?UTF-8?q?chore(repo):=20=F0=9F=99=88=20ignore=20ag?= =?UTF-8?q?ent=20task=20logs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index df143050b..bb9eae41b 100644 --- a/.gitignore +++ b/.gitignore @@ -113,3 +113,4 @@ 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/ +.agent-logs/ From 9730e23d219cfea26e72f65a7644ab9eb54552b2 Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Wed, 5 Aug 2026 22:19:48 +0800 Subject: [PATCH 11/16] =?UTF-8?q?fix(cli):=20=F0=9F=A7=AD=20normalize=20OM?= =?UTF-8?q?P=20doctor=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- packages/cli/src/commands/doctor-omp.test.ts | 2 +- packages/cli/src/commands/doctor-omp.ts | 22 ++++++++++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/commands/doctor-omp.test.ts b/packages/cli/src/commands/doctor-omp.test.ts index df2fc42bb..b8d63abc1 100644 --- a/packages/cli/src/commands/doctor-omp.test.ts +++ b/packages/cli/src/commands/doctor-omp.test.ts @@ -93,7 +93,7 @@ describe("OMP doctor", () => { ], getOmpSetting: ((_path: string, key: string) => key === "compaction.enabled" ? false : "off") as never, - runOmpCommand: () => ({ ok: true, stdout: agentDir, stderr: "" }), + runOmpCommand: () => ({ ok: true, stdout: `${agentDir}/./`, stderr: "" }), }, }); diff --git a/packages/cli/src/commands/doctor-omp.ts b/packages/cli/src/commands/doctor-omp.ts index 69ca4cec1..5918e76be 100644 --- a/packages/cli/src/commands/doctor-omp.ts +++ b/packages/cli/src/commands/doctor-omp.ts @@ -1,7 +1,7 @@ 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 { dirname, join, resolve } 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"; @@ -215,16 +215,20 @@ async function runHealthChecks(options: { } else add(results, "fail", "Could not read OMP memory.backend"); const reportedAgentDir = options.deps.runOmpCommand(omp.path, ["config", "path"], 10_000); - if (!reportedAgentDir.ok) + 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 reportedPath = resolve(reportedAgentDir.stdout); + const expectedPath = resolve(getOmpAgentDir()); + if (reportedPath === expectedPath) { + add(results, "pass", `OMP agent directory resolved to ${getOmpAgentDir()}`); + } else { + add( + results, + "fail", + `OMP reports agent directory ${reportedAgentDir.stdout}, but Magic Context resolved ${getOmpAgentDir()}`, + ); + } } } From c683ddb98f958216af14c0037d8d2d955dbf177c Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Wed, 5 Aug 2026 22:19:48 +0800 Subject: [PATCH 12/16] =?UTF-8?q?fix(dashboard):=20=F0=9F=A7=B9=20trim=20O?= =?UTF-8?q?MP=20environment=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- .../dashboard/src-tauri/src/pi_sessions.rs | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/packages/dashboard/src-tauri/src/pi_sessions.rs b/packages/dashboard/src-tauri/src/pi_sessions.rs index e84447d18..2dd5a54b5 100644 --- a/packages/dashboard/src-tauri/src/pi_sessions.rs +++ b/packages/dashboard/src-tauri/src/pi_sessions.rs @@ -81,6 +81,11 @@ fn test_root() -> &'static RwLock> { TEST_ROOT.get_or_init(|| RwLock::new(None)) } +fn trimmed_env_path(value: Option) -> Option { + let trimmed = value?.to_string_lossy().trim().to_string(); + (!trimmed.is_empty()).then(|| PathBuf::from(trimmed)) +} + fn normalize_omp_profile(value: Option) -> Option { let value = value?; let trimmed = value.to_string_lossy().trim().to_string(); @@ -144,13 +149,11 @@ fn pi_compatible_session_roots() -> Vec { }; 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")); + if let Some(agent_dir) = trimmed_env_path(std::env::var_os("PI_CODING_AGENT_DIR")) { + roots.push(agent_dir.join("sessions")); } - let config_dir = std::env::var_os("PI_CONFIG_DIR") - .filter(|v| !v.is_empty()) - .map(PathBuf::from) + let config_dir = trimmed_env_path(std::env::var_os("PI_CONFIG_DIR")) .unwrap_or_else(|| PathBuf::from(".omp")); let profile = active_omp_profile(); let profile_suffix = profile @@ -164,8 +167,8 @@ fn pi_compatible_session_roots() -> Vec { } 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"); + if let Some(xdg_data) = trimmed_env_path(std::env::var_os("XDG_DATA_HOME")) { + let app_root = 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 { @@ -726,6 +729,16 @@ mod tests { path } + #[test] + fn environment_paths_are_trimmed_and_blank_values_are_ignored() { + assert_eq!( + trimmed_env_path(Some(" /tmp/omp-agent ".into())), + Some(PathBuf::from("/tmp/omp-agent")) + ); + assert_eq!(trimmed_env_path(Some(" ".into())), None); + assert_eq!(trimmed_env_path(None), None); + } + #[test] fn discovers_named_omp_profile_session_roots() { let dir = tempfile::tempdir().unwrap(); From c83e6d7046ce54360395db959894f16e114bcbbb Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Thu, 6 Aug 2026 19:40:37 +0800 Subject: [PATCH 13/16] =?UTF-8?q?fix(omp):=20=F0=9F=90=9B=20isolate=20host?= =?UTF-8?q?=20config=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- packages/cli/src/commands/doctor-omp.test.ts | 110 ++++++++++++++++++ packages/cli/src/commands/doctor-omp.ts | 54 ++++++++- packages/cli/src/commands/setup-omp.test.ts | 87 +++++++++++++- packages/cli/src/commands/setup-omp.ts | 20 +++- packages/cli/src/commands/setup-pi.test.ts | 12 ++ packages/cli/src/commands/setup-pi.ts | 36 ++++-- packages/cli/src/lib/omp-helpers.test.ts | 57 ++++++++- packages/cli/src/lib/omp-helpers.ts | 44 ++++++- packages/cli/src/lib/paths-omp.test.ts | 25 +++- packages/cli/src/lib/paths.ts | 38 +++++- .../pi-plugin/src/subagent-runner.test.ts | 93 +++++++++++++-- packages/pi-plugin/src/subagent-runner.ts | 90 ++++++++++++-- .../src/shared/harness-provider-map.test.ts | 29 ++++- .../plugin/src/shared/harness-provider-map.ts | 65 +++++++---- 14 files changed, 687 insertions(+), 73 deletions(-) diff --git a/packages/cli/src/commands/doctor-omp.test.ts b/packages/cli/src/commands/doctor-omp.test.ts index b8d63abc1..d9fa03adf 100644 --- a/packages/cli/src/commands/doctor-omp.test.ts +++ b/packages/cli/src/commands/doctor-omp.test.ts @@ -46,6 +46,7 @@ const original = { 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, + PI_CONFIG_FILES: process.env.PI_CONFIG_FILES, }; afterEach(() => { @@ -122,4 +123,113 @@ describe("OMP doctor", () => { expect(existsSync(join(root, ".config", "cortexkit", "magic-context.jsonc"))).toBe(true); expect(prompts.messages.join("\n")).toContain("Wrote default Magic Context config"); }); + + it("does not repair global settings through a project config override", async () => { + const root = mkdtempSync(join(tmpdir(), "mc-omp-doctor-project-")); + roots.push(root); + const agentDir = join(root, ".omp", "agent"); + const pluginDir = join(root, "plugin"); + mkdirSync(join(root, ".omp"), { recursive: true }); + mkdirSync(agentDir, { recursive: true }); + mkdirSync(pluginDir, { recursive: true }); + mkdirSync(join(root, ".config", "cortexkit"), { recursive: true }); + writeFileSync(join(root, ".omp", "config.yml"), "compaction:\n enabled: true\n"); + writeFileSync( + join(pluginDir, "package.json"), + JSON.stringify({ omp: { extensions: ["./dist/index.js"] } }), + ); + writeFileSync(join(root, ".config", "cortexkit", "magic-context.jsonc"), "{}\n"); + process.env.HOME = root; + process.env.PI_CODING_AGENT_DIR = agentDir; + process.env.XDG_CONFIG_HOME = join(root, ".config"); + const calls: string[][] = []; + const prompts = new MockPrompts(); + + const code = await runDoctor({ + cwd: root, + force: true, + 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" ? true : "mnemopi") as never, + runOmpCommand: (_path, args) => { + calls.push(args); + return { ok: true, stdout: agentDir, stderr: "" }; + }, + }, + }); + + expect(code).toBe(1); + expect(calls.some((args) => args[0] === "config" && args[1] === "set")).toBe(false); + expect(prompts.messages.join("\n")).toContain("automatic global repair is disabled"); + }); + it("rejects an array at the Magic Context config root", async () => { + const root = mkdtempSync(join(tmpdir(), "mc-omp-doctor-array-")); + roots.push(root); + mkdirSync(join(root, ".config", "cortexkit"), { recursive: true }); + writeFileSync(join(root, ".config", "cortexkit", "magic-context.jsonc"), "[]\n"); + process.env.HOME = root; + process.env.XDG_CONFIG_HOME = join(root, ".config"); + const prompts = new MockPrompts(); + + const code = await runDoctor({ + cwd: root, + prompts, + deps: { detectOmpBinary: () => null }, + }); + + expect(code).toBe(1); + expect(prompts.messages.join("\n")).toContain("Invalid Magic Context config"); + }); + + it("does not write a default config after a refused legacy migration", async () => { + const root = mkdtempSync(join(tmpdir(), "mc-omp-doctor-migration-")); + const cwd = mkdtempSync(join(tmpdir(), "mc-omp-doctor-cwd-")); + roots.push(root, cwd); + const piAgentDir = join(root, ".pi", "agent"); + const opencodeDir = join(root, ".config", "opencode"); + mkdirSync(piAgentDir, { recursive: true }); + mkdirSync(opencodeDir, { recursive: true }); + mkdirSync(join(cwd, ".cortexkit"), { recursive: true }); + writeFileSync( + join(opencodeDir, "magic-context.jsonc"), + JSON.stringify({ protected_tags: 7 }), + ); + writeFileSync( + join(piAgentDir, "magic-context.jsonc"), + JSON.stringify({ protected_tags: 13 }), + ); + writeFileSync( + join(cwd, ".cortexkit", "magic-context.jsonc"), + JSON.stringify({ enabled: true }), + ); + process.env.HOME = root; + process.env.XDG_CONFIG_HOME = join(root, ".config"); + process.env.PI_CODING_AGENT_DIR = piAgentDir; + const prompts = new MockPrompts(); + + const code = await runDoctor({ + cwd, + force: true, + prompts, + deps: { detectOmpBinary: () => null }, + }); + + expect(code).toBe(1); + expect(existsSync(join(root, ".config", "cortexkit", "magic-context.jsonc"))).toBe(false); + expect(prompts.messages.join("\n")).toContain( + "Magic Context user config migration refused", + ); + expect(prompts.messages.join("\n")).not.toContain("Wrote default Magic Context config"); + }); }); diff --git a/packages/cli/src/commands/doctor-omp.ts b/packages/cli/src/commands/doctor-omp.ts index 5918e76be..7468cc9c9 100644 --- a/packages/cli/src/commands/doctor-omp.ts +++ b/packages/cli/src/commands/doctor-omp.ts @@ -9,7 +9,10 @@ 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 { + hasUserConfigLocationMigrationRefusal, + migrateConfigLocationsForCli, +} from "../lib/config-location-migration"; import { openExistingContextDatabase } from "../lib/database-access"; import { detectOmpBinary, @@ -24,6 +27,8 @@ import { getMagicContextLogPath, getOmpAgentDir, getOmpConfigPath, + getOmpNonGlobalConfigSources, + getOmpPackageDir, getOmpPluginsLockPath, getOmpSessionsRoot, getOmpUserConfigPath, @@ -128,7 +133,9 @@ 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" }; + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? {} + : { error: "top level is not an object" }; } catch (error) { return { error: error instanceof Error ? error.message : String(error) }; } @@ -214,6 +221,19 @@ async function runHealthChecks(options: { repairPlan.disableMemory = true; } else add(results, "fail", "Could not read OMP memory.backend"); + const nonGlobalSources = getOmpNonGlobalConfigSources(options.cwd); + if ( + nonGlobalSources.length > 0 && + (repairPlan.disableCompaction || repairPlan.disableMemory) + ) { + add( + results, + "warn", + "OMP project/overlay config owns effective conflicting settings; automatic global repair is disabled: " + + nonGlobalSources.join(", "), + ); + } + const reportedAgentDir = options.deps.runOmpCommand(omp.path, ["config", "path"], 10_000); if (!reportedAgentDir.ok) { add(results, "warn", "Could not verify OMP active agent directory"); @@ -273,6 +293,11 @@ async function runHealthChecks(options: { add(results, "info", `OMP config: ${getOmpConfigPath()}`); add(results, "info", `OMP plugin lock: ${getOmpPluginsLockPath()}`); add(results, "info", `OMP sessions: ${getOmpSessionsRoot()}`); + const packageDir = getOmpPackageDir(); + if (packageDir) add(results, "info", `OMP package override: ${packageDir}`); + for (const source of getOmpNonGlobalConfigSources(options.cwd)) { + add(results, "info", `OMP non-global config: ${source}`); + } const logPath = getMagicContextLogPath("pi"); add( results, @@ -300,7 +325,12 @@ function writeDefaultConfig(path: string): void { writeFileAtomic(path, `${stringifyJsonc(config, null, 2)}\n`); } -async function repair(plan: RepairPlan, deps: DoctorDeps, prompts: PromptIO): Promise { +async function repair( + plan: RepairPlan, + deps: DoctorDeps, + prompts: PromptIO, + cwd: string, +): Promise { let fixed = 0; if (plan.writeUserConfig && !existsSync(getOmpUserConfigPath())) { writeDefaultConfig(getOmpUserConfigPath()); @@ -316,11 +346,18 @@ async function repair(plan: RepairPlan, deps: DoctorDeps, prompts: PromptIO): Pr fixed += 1; } else prompts.log.error(result.message); } + const nonGlobalSources = getOmpNonGlobalConfigSources(cwd); for (const [enabled, key, value] of [ [plan.disableCompaction, "compaction.enabled", "false"], [plan.disableMemory, "memory.backend", "off"], ] as const) { if (!enabled) continue; + if (nonGlobalSources.length > 0) { + prompts.log.error( + `Refusing to set global OMP ${key}: effective settings include ${nonGlobalSources.join(", ")}`, + ); + continue; + } const result = deps.runOmpCommand(omp.path, ["config", "set", key, value], 10_000); if (result.ok) { prompts.log.success(`Set OMP ${key}=${value}`); @@ -409,15 +446,22 @@ export async function runDoctor(options: RunOmpDoctorOptions = {}): Promise { @@ -55,7 +56,10 @@ afterEach(() => { for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); }); -function makeFakeOmp(): { binary: string; state: string } { +function makeFakeOmp(options: { failMemorySet?: boolean } = {}): { + binary: string; + state: string; +} { const root = mkdtempSync(join(tmpdir(), "mc-omp-setup-")); roots.push(root); const state = join(root, "state.json"); @@ -67,13 +71,20 @@ function makeFakeOmp(): { binary: string; state: string } { const fs = require("fs"); const statePath = ${JSON.stringify(state)}; const state = JSON.parse(fs.readFileSync(statePath, "utf8")); +const failMemorySet = ${JSON.stringify(options.failMemorySet === true)}; 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]; + else { + if (failMemorySet) { + process.stderr.write("memory set failed"); + process.exit(1); + } + 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: [] })); @@ -92,6 +103,7 @@ describe("OMP setup transaction", () => { const prompts = new MockPrompts([true, true]); const rollback = await __test.OMP_HOST.beforeWrite?.({ binaryPath: binary, + cwd: process.cwd(), prompts, dryRun: false, configureHost: true, @@ -109,11 +121,33 @@ describe("OMP setup transaction", () => { }); }); + it("automatically restores an earlier setting when a later write fails", async () => { + const { binary, state } = makeFakeOmp({ failMemorySet: true }); + const prompts = new MockPrompts([true, true]); + + const result = await __test.OMP_HOST.beforeWrite?.({ + binaryPath: binary, + cwd: process.cwd(), + prompts, + dryRun: false, + configureHost: true, + }); + + expect(result).toBe(false); + expect(JSON.parse(readFileSync(state, "utf-8"))).toEqual({ + compaction: true, + memory: "mnemopi", + }); + expect(prompts.messages.join("\n")).toContain("memory set failed"); + expect(prompts.messages.join("\n")).toContain("Restored OMP compaction.enabled=true"); + }); + 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, + cwd: process.cwd(), prompts, dryRun: false, configureHost: false, @@ -124,4 +158,51 @@ describe("OMP setup transaction", () => { memory: "mnemopi", }); }); + + it("refuses global setting writes when a project OMP config is active", async () => { + const { binary, state } = makeFakeOmp(); + const cwd = mkdtempSync(join(tmpdir(), "mc-omp-project-")); + roots.push(cwd); + mkdirSync(join(cwd, ".omp"), { recursive: true }); + writeFileSync(join(cwd, ".omp", "config.yml"), "compaction:\n enabled: true\n"); + const prompts = new MockPrompts([true, true]); + + const result = await __test.OMP_HOST.beforeWrite?.({ + binaryPath: binary, + cwd, + prompts, + dryRun: false, + configureHost: true, + }); + + expect(result).toBe(false); + expect(JSON.parse(readFileSync(state, "utf-8"))).toEqual({ + compaction: true, + memory: "mnemopi", + }); + expect(prompts.messages.join("\n")).toContain("refusing to mutate the global config"); + }); + + it("refuses global setting writes when PI_CONFIG_FILES overlays are active", async () => { + const { binary, state } = makeFakeOmp(); + const cwd = mkdtempSync(join(tmpdir(), "mc-omp-overlay-")); + roots.push(cwd); + process.env.PI_CONFIG_FILES = "settings/omp.yml"; + const prompts = new MockPrompts([true, true]); + + const result = await __test.OMP_HOST.beforeWrite?.({ + binaryPath: binary, + cwd, + prompts, + dryRun: false, + configureHost: true, + }); + + expect(result).toBe(false); + expect(JSON.parse(readFileSync(state, "utf-8"))).toEqual({ + compaction: true, + memory: "mnemopi", + }); + expect(prompts.messages.join("\n")).toContain(join(cwd, "settings", "omp.yml")); + }); }); diff --git a/packages/cli/src/commands/setup-omp.ts b/packages/cli/src/commands/setup-omp.ts index ac450dfcb..6a558f46e 100644 --- a/packages/cli/src/commands/setup-omp.ts +++ b/packages/cli/src/commands/setup-omp.ts @@ -1,3 +1,4 @@ +import { ompModelRefToCanonical } from "@magic-context/core/shared/harness-provider-map"; import { OmpAdapter } from "../adapters/omp"; import { detectOmpBinary, @@ -7,7 +8,12 @@ import { OMP_PLUGIN_PACKAGE, runOmpCommand, } from "../lib/omp-helpers"; -import { getOmpAgentDir, getOmpPluginsLockPath, getOmpUserConfigPath } from "../lib/paths"; +import { + getOmpAgentDir, + getOmpNonGlobalConfigSources, + getOmpPluginsLockPath, + getOmpUserConfigPath, +} from "../lib/paths"; import { type PiCompatibleSetupHost, type RunSetupOptions, @@ -35,8 +41,9 @@ const OMP_HOST: PiCompatibleSetupHost = { versionWarning: (version, minimum) => `OMP ${version} is older than the tested minimum ${minimum}. ` + "Upgrade with `omp update` before enabling Magic Context.", + modelRefToCanonical: ompModelRefToCanonical, ensurePluginEntry: async () => new OmpAdapter().ensurePluginEntry(), - beforeWrite: async ({ binaryPath, prompts, dryRun, configureHost }) => { + beforeWrite: async ({ binaryPath, cwd, prompts, dryRun, configureHost }) => { if (!configureHost && !new OmpAdapter().hasPluginEntry()) { return async () => {}; } @@ -78,6 +85,15 @@ const OMP_HOST: PiCompatibleSetupHost = { } changes.push({ key: "memory.backend", from: memoryBackend, to: "off" }); } + const nonGlobalSources = getOmpNonGlobalConfigSources(cwd); + if (changes.length > 0 && nonGlobalSources.length > 0) { + prompts.log.error( + "OMP effective settings come from project/overlay config; refusing to mutate the global config.\n" + + nonGlobalSources.map((path) => `- ${path}`).join("\n") + + "\nEdit those files directly, then rerun setup.", + ); + return false; + } if (dryRun) { for (const change of changes) { diff --git a/packages/cli/src/commands/setup-pi.test.ts b/packages/cli/src/commands/setup-pi.test.ts index c8ad69d90..b433a969f 100644 --- a/packages/cli/src/commands/setup-pi.test.ts +++ b/packages/cli/src/commands/setup-pi.test.ts @@ -116,6 +116,18 @@ describe("Pi settings rollback", () => { }); expect(removePiSettingsPackage(settingsPath)).toBe(false); }); + + it("restores an absent packages field when setup added the only entry", () => { + const root = makeTempRoot(); + const settingsPath = join(root, "settings.json"); + writeFileSync(settingsPath, "{}"); + + expect(writePiSettingsPackage(settingsPath)).toBe(true); + expect(removePiSettingsPackage(settingsPath, "npm:@cortexkit/pi-magic-context", true)).toBe( + true, + ); + expect(parseJsonc(readFileSync(settingsPath, "utf-8"))).toEqual({}); + }); }); describe("runSetup", () => { diff --git a/packages/cli/src/commands/setup-pi.ts b/packages/cli/src/commands/setup-pi.ts index 5d2ee77aa..f7be23edf 100644 --- a/packages/cli/src/commands/setup-pi.ts +++ b/packages/cli/src/commands/setup-pi.ts @@ -31,7 +31,7 @@ type EmbeddingChoice = }; export interface SetupEnvironment { - detectPiBinary: typeof detectPiBinary; + detectPiBinary: () => { path: string } | null; getPiVersion: typeof getPiVersion; getAvailableModels: typeof getAvailableModels; paths: { @@ -50,9 +50,12 @@ export interface PiCompatibleSetupHost { installCommand: string; minimumVersion?: string; versionWarning?: (version: string, minimum: string) => string; + /** Convert this host's model selector to the shared canonical config form. */ + modelRefToCanonical?: (ref: string) => string; ensurePluginEntry: (settingsPath: string) => Promise; beforeWrite?: (options: { binaryPath: string; + cwd: string; prompts: PromptIO; dryRun: boolean; configureHost: boolean; @@ -97,6 +100,8 @@ const DEFAULT_HOST: PiCompatibleSetupHost = { `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 settings = readJsoncConfigForUpdate(settingsPath); + const packagesFieldExisted = Object.hasOwn(settings, "packages"); const added = writePiSettingsPackage(settingsPath); return { ok: true, @@ -105,11 +110,17 @@ const DEFAULT_HOST: PiCompatibleSetupHost = { ? `Added ${PI_PACKAGE_SOURCE} to ${settingsPath}` : `Magic Context package already present in ${settingsPath}`, configPath: settingsPath, + packagesFieldExisted, }; }, rollbackPluginEntry: async (registration) => { if (registration.action === "added") { - removePiSettingsPackage(registration.configPath); + removePiSettingsPackage( + registration.configPath, + PI_PACKAGE_SOURCE, + (registration as PluginEntryResult & { packagesFieldExisted?: boolean }) + .packagesFieldExisted === false, + ); } }, }; @@ -169,13 +180,15 @@ export function writePiSettingsPackage( export function removePiSettingsPackage( settingsPath: string, packageSource = PI_PACKAGE_SOURCE, + removeFieldWhenEmpty = false, ): boolean { const settings = readJsoncConfigForUpdate(settingsPath); if (!Array.isArray(settings.packages)) return false; const packages = settings.packages; const filtered = packages.filter((entry) => entry !== packageSource); if (filtered.length === packages.length) return false; - settings.packages = filtered; + if (removeFieldWhenEmpty && filtered.length === 0) delete settings.packages; + else settings.packages = filtered; writeFileAtomic(settingsPath, `${stringifyJsonc(settings, null, 2)}\n`); return true; } @@ -192,6 +205,7 @@ export function writeMagicContextConfig( sidekickEnabled: boolean; sidekickModel?: string; embedding: EmbeddingChoice; + modelRefToCanonical?: (ref: string) => string; }, ): void { const config = readJsoncConfigForUpdate(configPath); @@ -202,17 +216,17 @@ export function writeMagicContextConfig( "https://raw.githubusercontent.com/cortexkit/magic-context/master/assets/magic-context.schema.json"; } - // The Pi model picker yields Pi-native provider ids (openai-codex/..., - // google-antigravity/...). The shared config is canonical (OpenCode) form so - // OpenCode can read the same file; normalize before writing. + // Model pickers return harness-native provider IDs. Persist only canonical + // OpenCode-form IDs so every harness reads the same shared config. + const toCanonical = options.modelRefToCanonical ?? piModelRefToCanonical; config.historian = compactObject({ ...((config.historian as Record | undefined) ?? {}), - model: piModelRefToCanonical(options.historianModel), + model: toCanonical(options.historianModel), thinking_level: options.historianThinkingLevel, }); const dreamer = { ...((config.dreamer as Record | undefined) ?? {}), - model: options.dreamerModel ? piModelRefToCanonical(options.dreamerModel) : undefined, + model: options.dreamerModel ? toCanonical(options.dreamerModel) : undefined, disable: options.dreamerEnabled ? undefined : true, enabled: undefined, // Dreamer v2 per-task schedules — only set when the user declined the @@ -225,7 +239,7 @@ export function writeMagicContextConfig( ...((config.sidekick as Record | undefined) ?? {}), model: options.sidekickEnabled && options.sidekickModel - ? piModelRefToCanonical(options.sidekickModel) + ? toCanonical(options.sidekickModel) : undefined, disable: options.sidekickEnabled ? undefined : true, enabled: undefined, @@ -352,7 +366,7 @@ export async function runSetup(options: RunSetupOptions = {}): Promise { ); if (configureHost && dryRun) { prompts.log.message( - `[dry-run] would register ${host.packageSource} via \`${host.installCommand}\``, + `[dry-run] would register ${host.packageSource} for ${host.displayName} in ${settingsPath}`, ); } else if (!configureHost) { prompts.log.warn( @@ -408,6 +422,7 @@ export async function runSetup(options: RunSetupOptions = {}): Promise { const rollbackHost = (await host.beforeWrite?.({ binaryPath: binary.path, + cwd: process.cwd(), prompts, dryRun, configureHost, @@ -436,6 +451,7 @@ export async function runSetup(options: RunSetupOptions = {}): Promise { sidekickEnabled, sidekickModel, embedding, + modelRefToCanonical: host.modelRefToCanonical, }); prompts.log.success(`Config written to ${configPath}`); } diff --git a/packages/cli/src/lib/omp-helpers.test.ts b/packages/cli/src/lib/omp-helpers.test.ts index c3554fe66..38930c060 100644 --- a/packages/cli/src/lib/omp-helpers.test.ts +++ b/packages/cli/src/lib/omp-helpers.test.ts @@ -1,5 +1,58 @@ -import { describe, expect, it } from "bun:test"; -import { parseOmpModelsOutput, runOmpCommand } from "./omp-helpers"; +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 { + detectOmpBinary, + getOmpFallbackCandidates, + parseOmpModelsOutput, + runOmpCommand, +} from "./omp-helpers"; + +const originalPath = process.env.PATH; +const originalPackageDir = process.env.PI_PACKAGE_DIR; +const roots: string[] = []; + +afterEach(() => { + if (originalPath === undefined) delete process.env.PATH; + else process.env.PATH = originalPath; + if (originalPackageDir === undefined) delete process.env.PI_PACKAGE_DIR; + else process.env.PI_PACKAGE_DIR = originalPackageDir; + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("OMP binary discovery", () => { + it("honors a validated PI_PACKAGE_DIR install root", () => { + const root = mkdtempSync(join(tmpdir(), "mc-omp-package-")); + roots.push(root); + mkdirSync(join(root, "dist"), { recursive: true }); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "@oh-my-pi/pi-coding-agent" }), + ); + writeFileSync(join(root, "dist", "cli.js"), ""); + process.env.PATH = ""; + process.env.PI_PACKAGE_DIR = root; + + expect(detectOmpBinary()).toEqual({ + path: join(root, "dist", "cli.js"), + source: "package", + }); + }); +}); + +describe("OMP fallback discovery", () => { + it("covers standard Windows npm and Bun install directories", () => { + const home = "C:\\Users\\fox"; + const appData = "C:\\Users\\fox\\AppData\\Roaming"; + expect(getOmpFallbackCandidates("win32", home, appData)).toEqual([ + join(appData, "npm", "omp.cmd"), + join(appData, "npm", "omp.exe"), + join(home, ".bun", "bin", "omp.exe"), + join(home, ".bun", "bin", "omp.cmd"), + ]); + }); +}); describe("OMP model discovery", () => { it("parses model selectors without flattening scoped or nested IDs", () => { diff --git a/packages/cli/src/lib/omp-helpers.ts b/packages/cli/src/lib/omp-helpers.ts index 311ab9ce7..f47bac0cd 100644 --- a/packages/cli/src/lib/omp-helpers.ts +++ b/packages/cli/src/lib/omp-helpers.ts @@ -1,12 +1,13 @@ import { spawnSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { join } from "node:path"; import { findOnPath, isExecutableFile } from "./find-on-path"; +import { getOmpPackageDir } from "./paths"; import { getPiCommandInvocation } from "./pi-helpers"; - export interface OmpBinaryInfo { path: string; - source: "path" | "home"; + source: "path" | "home" | "package"; } export interface OmpCommandResult { @@ -24,15 +25,46 @@ export interface OmpPluginInfo { export const OMP_PLUGIN_PACKAGE = "@cortexkit/pi-magic-context"; +function detectOmpPackageCli(): string | null { + const packageDir = getOmpPackageDir(); + if (!packageDir) return null; + try { + const manifest = JSON.parse(readFileSync(join(packageDir, "package.json"), "utf-8")) as { + name?: unknown; + }; + if (manifest.name !== "@oh-my-pi/pi-coding-agent") return null; + const cli = join(packageDir, "dist", "cli.js"); + return existsSync(cli) ? cli : null; + } catch { + return null; + } +} + +export function getOmpFallbackCandidates( + platform: NodeJS.Platform, + home: string, + appData?: string, +): string[] { + if (platform !== "win32") { + return [join(home, ".bun", "bin", "omp"), join(home, ".local", "bin", "omp")]; + } + const npmRoot = appData?.trim(); + return [ + ...(npmRoot ? [join(npmRoot, "npm", "omp.cmd"), join(npmRoot, "npm", "omp.exe")] : []), + join(home, ".bun", "bin", "omp.exe"), + join(home, ".bun", "bin", "omp.cmd"), + ]; +} + export function detectOmpBinary(): OmpBinaryInfo | null { const fromPath = findOnPath("omp"); if (fromPath) return { path: fromPath, source: "path" }; + const fromPackage = detectOmpPackageCli(); + if (fromPackage) return { path: fromPackage, source: "package" }; + 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 candidates = getOmpFallbackCandidates(process.platform, home, process.env.APPDATA); const candidate = candidates.find((path) => isExecutableFile(path)); return candidate ? { path: candidate, source: "home" } : null; } diff --git a/packages/cli/src/lib/paths-omp.test.ts b/packages/cli/src/lib/paths-omp.test.ts index 7a0ae8c15..dc8b48a95 100644 --- a/packages/cli/src/lib/paths-omp.test.ts +++ b/packages/cli/src/lib/paths-omp.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; -import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { resolveOmpPaths } from "./paths"; +import { getOmpNonGlobalConfigSources, getOmpPackageDir, resolveOmpPaths } from "./paths"; const KEYS = [ "HOME", @@ -11,6 +11,8 @@ const KEYS = [ "OMP_PROFILE", "PI_PROFILE", "XDG_DATA_HOME", + "PI_PACKAGE_DIR", + "PI_CONFIG_FILES", ] as const; const original = new Map(); let root: string; @@ -95,4 +97,23 @@ describe("OMP path compatibility", () => { expect(paths.sessionsRoot).toBe(join(xdg, "omp", "profiles", "work", "sessions")); } }); + + it("resolves PI_PACKAGE_DIR and PI_CONFIG_FILES using OMP semantics", () => { + const cwd = join(root, "project"); + mkdirSync(join(cwd, ".omp"), { recursive: true }); + mkdirSync(join(root, "omp-package"), { recursive: true }); + process.env.PI_PACKAGE_DIR = "~/omp-package"; + process.env.PI_CONFIG_FILES = ["config/first.yml", join(root, "absolute.yml")].join( + process.platform === "win32" ? ";" : ":", + ); + const projectConfig = join(cwd, ".omp", "config.yml"); + writeFileSync(projectConfig, "memory:\n backend: off\n"); + + expect(getOmpPackageDir()).toBe(join(root, "omp-package")); + expect(getOmpNonGlobalConfigSources(cwd)).toEqual([ + join(cwd, "config", "first.yml"), + join(root, "absolute.yml"), + projectConfig, + ]); + }); }); diff --git a/packages/cli/src/lib/paths.ts b/packages/cli/src/lib/paths.ts index 0440705f9..39a989ed4 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, resolve } from "node:path"; +import { delimiter, dirname, join, resolve } from "node:path"; import { resolveCortexKitUserConfigPath } from "@magic-context/core/config/migrate-config-location"; import { getMagicContextHistorianDir as getMagicContextHistorianDirCore, @@ -221,6 +221,42 @@ export function getOmpConfigPath(): string { return join(resolveOmpPaths().agentDir, "config.yml"); } +/** OMP package root override used by Nix/Guix and source installations. */ +export function getOmpPackageDir(): string | undefined { + const value = process.env.PI_PACKAGE_DIR?.trim(); + if (!value) return undefined; + if (value === "~") return envFirstHomeDir(); + if (value.startsWith("~/") || value.startsWith("~\\")) { + return resolve(envFirstHomeDir(), value.slice(2)); + } + return resolve(value); +} + +/** + * Non-global OMP settings layers that can override `omp config set`. + * + * `PI_CONFIG_FILES` paths resolve relative to the command cwd. OMP also merges + * `/.omp/config.yml` above the global agent config. Callers must not mutate + * global settings in response to values owned by either higher-precedence layer. + */ +export function getOmpNonGlobalConfigSources(cwd = process.cwd()): string[] { + const sources = + process.env.PI_CONFIG_FILES?.split(delimiter) + .map((entry) => entry.trim()) + .filter(Boolean) + .map((entry) => { + if (entry === "~") return envFirstHomeDir(); + const expanded = + entry.startsWith("~/") || entry.startsWith("~\\") + ? join(envFirstHomeDir(), entry.slice(2)) + : entry; + return resolve(cwd, expanded); + }) ?? []; + const projectConfig = resolve(cwd, ".omp", "config.yml"); + if (existsSync(projectConfig)) sources.push(projectConfig); + return [...new Set(sources)]; +} + /** OMP's npm/link plugin root, including profile and XDG data layout. */ export function getOmpPluginsDir(): string { return resolveOmpPaths().pluginsDir; diff --git a/packages/pi-plugin/src/subagent-runner.test.ts b/packages/pi-plugin/src/subagent-runner.test.ts index fe4dc4671..5596cfa2c 100644 --- a/packages/pi-plugin/src/subagent-runner.test.ts +++ b/packages/pi-plugin/src/subagent-runner.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; import { EventEmitter } from "node:events"; -import { existsSync, readFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { homedir } from "node:os"; import { isAbsolute, join } from "node:path"; import { PassThrough } from "node:stream"; @@ -286,26 +292,95 @@ describe("subagent-runner pure helpers", () => { expect(args).toContain("--no-extensions"); }); - it("resolves relative allowlist entries from the active host agent dir", () => { + it("keeps plain Pi relative allowlist entries rooted at the stock agent dir", () => { const previous = process.env.PI_CODING_AGENT_DIR; + process.env.PI_CODING_AGENT_DIR = "/tmp/plain-pi-custom-agent"; + try { + const args = buildArgsForTest( + { ...baseOptions, model: "anthropic/claude-sonnet" }, + { subagentExtensions: ["provider-package"] }, + ); + const firstExtension = args.indexOf("--extension"); + expect(args.slice(firstExtension, firstExtension + 2)).toEqual([ + "--extension", + join(homedir(), ".pi/agent/provider-package"), + ]); + } finally { + if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previous; + } + }); + + it("uses PI_CODING_AGENT_DIR only for a positively identified OMP host", () => { + const root = mkdtempSync(join(homedir(), ".mc-omp-host-test-")); + const previousAgentDir = process.env.PI_CODING_AGENT_DIR; + const previousPackageDir = process.env.PI_PACKAGE_DIR; + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "@oh-my-pi/pi-coding-agent" }), + ); + process.env.PI_PACKAGE_DIR = root; 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"], - }, + { subagentExtensions: ["provider-package"] }, ); const firstExtension = args.indexOf("--extension"); - expect(args.slice(firstExtension, firstExtension + 4)).toEqual([ + expect(args.slice(firstExtension, firstExtension + 2)).toEqual([ "--extension", "/tmp/omp-profile/agent/provider-package", + ]); + } finally { + rmSync(root, { recursive: true, force: true }); + if (previousAgentDir === undefined) + delete process.env.PI_CODING_AGENT_DIR; + else process.env.PI_CODING_AGENT_DIR = previousAgentDir; + if (previousPackageDir === undefined) delete process.env.PI_PACKAGE_DIR; + else process.env.PI_PACKAGE_DIR = previousPackageDir; + } + }); + + it("uses the OMP default agent dir when PI_CODING_AGENT_DIR is unset", () => { + const root = mkdtempSync(join(homedir(), ".mc-omp-default-host-test-")); + const previous = { + agentDir: process.env.PI_CODING_AGENT_DIR, + packageDir: process.env.PI_PACKAGE_DIR, + configDir: process.env.PI_CONFIG_DIR, + ompProfile: process.env.OMP_PROFILE, + piProfile: process.env.PI_PROFILE, + }; + writeFileSync( + join(root, "package.json"), + JSON.stringify({ name: "@oh-my-pi/pi-coding-agent" }), + ); + process.env.PI_PACKAGE_DIR = root; + delete process.env.PI_CODING_AGENT_DIR; + delete process.env.PI_CONFIG_DIR; + delete process.env.OMP_PROFILE; + delete process.env.PI_PROFILE; + try { + const args = buildArgsForTest( + { ...baseOptions, model: "anthropic/claude-sonnet" }, + { subagentExtensions: ["provider-package"] }, + ); + const firstExtension = args.indexOf("--extension"); + expect(args.slice(firstExtension, firstExtension + 2)).toEqual([ "--extension", - "/tmp/omp-profile/agent/extensions/provider.ts", + join(homedir(), ".omp/agent/provider-package"), ]); } finally { - if (previous === undefined) delete process.env.PI_CODING_AGENT_DIR; - else process.env.PI_CODING_AGENT_DIR = previous; + rmSync(root, { recursive: true, force: true }); + for (const [key, value] of [ + ["PI_CODING_AGENT_DIR", previous.agentDir], + ["PI_PACKAGE_DIR", previous.packageDir], + ["PI_CONFIG_DIR", previous.configDir], + ["OMP_PROFILE", previous.ompProfile], + ["PI_PROFILE", previous.piProfile], + ] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } } }); diff --git a/packages/pi-plugin/src/subagent-runner.ts b/packages/pi-plugin/src/subagent-runner.ts index a52622cc4..b2e8853cd 100644 --- a/packages/pi-plugin/src/subagent-runner.ts +++ b/packages/pi-plugin/src/subagent-runner.ts @@ -1,5 +1,11 @@ import * as childProcess from "node:child_process"; -import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { createRequire } from "node:module"; import { homedir, tmpdir } from "node:os"; import { @@ -15,7 +21,9 @@ import { openDatabase } from "@magic-context/core/features/magic-context/storage import type { SubagentKind } from "@magic-context/core/features/magic-context/storage-subagent-invocations"; import { recordChildInvocation } from "@magic-context/core/features/magic-context/subagent-token-capture"; import { + ompModelRefToCanonical, piModelRefToCanonical, + resolveModelRefForOmp, resolveModelRefForPi, } from "@magic-context/core/shared/harness-provider-map"; import { sessionLog } from "@magic-context/core/shared/logger"; @@ -175,13 +183,74 @@ const TERMINAL_DRAIN_GRACE_MS = 2_000; export const MAGIC_CONTEXT_PI_SUBAGENT_ENV = "MAGIC_CONTEXT_PI_SUBAGENT"; -// 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 packageRootIsOmp(packageRoot: string): boolean { + try { + const manifest = JSON.parse( + readFileSync(join(packageRoot, "package.json"), "utf-8"), + ) as { name?: unknown }; + return manifest.name === "@oh-my-pi/pi-coding-agent"; + } catch { + return false; + } +} + +/** + * Positive OMP host identification. PI_CODING_AGENT_DIR alone is deliberately + * insufficient because upstream Pi supports the same variable. + */ +function isOmpHostProcess(): boolean { + const execName = basename(process.execPath).toLowerCase(); + if (/^omp(?:\.exe)?$/.test(execName)) return true; + + const packageOverride = process.env.PI_PACKAGE_DIR?.trim(); + if (packageOverride && packageRootIsOmp(resolvePath(packageOverride))) + return true; + + let current = process.argv[1] ? dirname(resolvePath(process.argv[1])) : ""; + while (current) { + if (packageRootIsOmp(current)) return true; + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return false; +} + +function normalizedOmpProfile(): string | undefined { + const raw = (process.env.OMP_PROFILE ?? process.env.PI_PROFILE)?.trim(); + return raw && raw !== "default" && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(raw) + ? raw + : undefined; +} + +// OMP exposes a profile/custom agent directory via PI_CODING_AGENT_DIR. +// Default-profile OMP normally leaves it unset, so derive ~/.omp/agent (or the +// PI_CONFIG_DIR equivalent). Plain Pi also supports PI_CODING_AGENT_DIR; never +// consume it without the positive host check or plain-Pi argv changes. function getHostAgentSettingsDir(): string { + if (!isOmpHostProcess()) return join(homedir(), ".pi", "agent"); const configured = process.env.PI_CODING_AGENT_DIR?.trim(); - return configured ? resolvePath(configured) : join(homedir(), ".pi", "agent"); + if (configured) return resolvePath(configured); + const configRoot = join( + homedir(), + process.env.PI_CONFIG_DIR?.trim() || ".omp", + ); + const profile = normalizedOmpProfile(); + return profile + ? join(configRoot, "profiles", profile, "agent") + : join(configRoot, "agent"); +} + +function modelRefToCanonicalForHost(ref: string): string { + return isOmpHostProcess() + ? ompModelRefToCanonical(ref) + : piModelRefToCanonical(ref); +} + +function resolveModelRefForHost(ref: string): string { + return isOmpHostProcess() + ? resolveModelRefForOmp(ref) + : resolveModelRefForPi(ref); } let configuredSubagentExtensions: readonly string[] | undefined; @@ -1416,11 +1485,11 @@ function resolveProviderModelAttempt( ): ProviderModelAttempt | undefined { if (typeof model !== "string" || model.length === 0) return undefined; - const canonicalRef = piModelRefToCanonical(model); + const canonicalRef = modelRefToCanonicalForHost(model); const canonicalProvider = providerPrefix(canonicalRef); if (!canonicalProvider) return undefined; - const translatedRef = resolveModelRefForPi(canonicalRef); + const translatedRef = resolveModelRefForHost(canonicalRef); const translatedProvider = providerPrefix(translatedRef); const cachedProvider = PI_PROVIDER_FORM_CACHE.get(canonicalProvider); if ( @@ -1610,7 +1679,10 @@ export function buildArgs( // google->google-antigravity). Translate to Pi's form HERE, at the only // point the model reaches the spawned process, so options.model stays // canonical everywhere else (accounting, logging, fallback selection). - args.push("--model", opts?.modelRef ?? resolveModelRefForPi(options.model)); + args.push( + "--model", + opts?.modelRef ?? resolveModelRefForHost(options.model), + ); } // Pass --thinking only when explicitly configured. diff --git a/packages/plugin/src/shared/harness-provider-map.test.ts b/packages/plugin/src/shared/harness-provider-map.test.ts index fa81b891c..b64c52657 100644 --- a/packages/plugin/src/shared/harness-provider-map.test.ts +++ b/packages/plugin/src/shared/harness-provider-map.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "bun:test"; -import { piModelRefToCanonical, resolveModelRefForPi } from "./harness-provider-map"; +import { + ompModelRefToCanonical, + piModelRefToCanonical, + resolveModelRefForOmp, + resolveModelRefForPi, +} from "./harness-provider-map"; describe("harness-provider-map", () => { describe("resolveModelRefForPi (canonical -> Pi, used when spawning)", () => { @@ -61,3 +66,25 @@ describe("harness-provider-map", () => { }); }); }); + +describe("OMP provider boundary", () => { + const representativeRefs = [ + ["openai/gpt-5.5", "openai-codex/gpt-5.5"], + ["google/antigravity/gemini-3.5-flash", "google-antigravity/antigravity/gemini-3.5-flash"], + ["anthropic/claude-opus-4-8", "anthropic/claude-opus-4-8"], + ["@scope/provider/nested/model", "@scope/provider/nested/model"], + ] as const; + + it.each( + representativeRefs, + )("round-trips canonical %s through OMP selector %s", (canonical, omp) => { + expect(resolveModelRefForOmp(canonical)).toBe(omp); + expect(ompModelRefToCanonical(omp)).toBe(canonical); + }); + + it("normalizes an already-native OMP selector idempotently", () => { + const selector = "openai-codex/team/nested/gpt-5.5"; + expect(resolveModelRefForOmp(selector)).toBe(selector); + expect(resolveModelRefForOmp(ompModelRefToCanonical(selector))).toBe(selector); + }); +}); diff --git a/packages/plugin/src/shared/harness-provider-map.ts b/packages/plugin/src/shared/harness-provider-map.ts index 4116351e1..ce7e8c2a2 100644 --- a/packages/plugin/src/shared/harness-provider-map.ts +++ b/packages/plugin/src/shared/harness-provider-map.ts @@ -2,48 +2,57 @@ * Provider-id translation between the canonical (OpenCode) form stored in the * shared magic-context config and Pi's harness-native provider ids. * - * OpenCode and Pi now share ONE config, but a few auth-plugin providers were - * named differently on each side. The model id AFTER the slash is identical; - * only the provider prefix differs: + * Provider IDs are a harness boundary, not a database identity. Shared config + * always stores the canonical OpenCode form. Each non-OpenCode harness owns an + * explicit pair of edge transforms: * - * canonical (OpenCode) Pi's preferred auth-plugin form - * -------------------- ------------------------------ + * canonical (OpenCode) Pi / OMP selector + * -------------------- ----------------- * openai/ openai-codex/ * google/ google-antigravity/ * anthropic/ anthropic/ (same; every other provider too) * - * The mapping is intentionally not a one-to-one provider identity. Pi also - * exposes plain `openai` and `google` providers for direct API keys, while the - * canonical prefix does not record whether a subscription or API-key backend - * should win. Pi therefore starts with the preferred auth-plugin form at the - * spawn boundary and subagent-runner may retry once with the untranslated - * canonical form when that form reports missing credentials. The runner caches - * the winning form per provider prefix in memory only; setup writes still use - * the reverse mapping below so configs remain canonical. + * Pi and OMP currently expose the same two subscription-provider aliases, but + * their exported functions remain distinct deliberately. A future OMP catalog + * rename must not silently change plain-Pi behavior (or vice versa). * - * Canonical = OpenCode: the config always stores the OpenCode form. Pi - * translates at its edges: - * - read: canonical -> Pi when spawning a configured model (subagent-runner), - * with the runtime credential fallback described above. - * - write: Pi -> canonical in the Pi setup wizard, so a config written from - * the Pi side stays readable by OpenCode. + * The mapping is intentionally not a one-to-one provider identity. Both + * harnesses also expose plain `openai` and `google` providers for direct API + * keys, while the canonical prefix does not record whether a subscription or + * API-key backend should win. The runtime starts with the preferred + * subscription form and subagent-runner may retry once with the untranslated + * canonical form when that form reports missing credentials. * - * OpenCode needs no translation (canonical IS the OpenCode form). + * Only the provider prefix before the first slash is translated. Model IDs, + * including nested IDs containing more slashes, are preserved byte-for-byte. + * Scoped or otherwise unknown provider prefixes are identities. + * + * OpenCode needs no translation because canonical is its native form. */ -const CANONICAL_TO_PI_PROVIDER: Record = { +const CANONICAL_TO_PI_PROVIDER: Readonly> = { openai: "openai-codex", google: "google-antigravity", }; -const PI_TO_CANONICAL_PROVIDER: Record = { +const PI_TO_CANONICAL_PROVIDER: Readonly> = { + "openai-codex": "openai", + "google-antigravity": "google", +}; + +const CANONICAL_TO_OMP_PROVIDER: Readonly> = { + openai: "openai-codex", + google: "google-antigravity", +}; + +const OMP_TO_CANONICAL_PROVIDER: Readonly> = { "openai-codex": "openai", "google-antigravity": "google", }; /** Remap only the provider prefix (text before the first "/"), preserving the * model id verbatim. No "/", empty provider, or unmapped provider -> unchanged. */ -function remapProviderPrefix(ref: string, map: Record): string { +function remapProviderPrefix(ref: string, map: Readonly>): string { if (typeof ref !== "string") return ref; const slash = ref.indexOf("/"); if (slash <= 0) return ref; @@ -64,3 +73,13 @@ export function piModelRefToCanonical(ref: string): string { export function resolveModelRefForPi(ref: string): string { return remapProviderPrefix(piModelRefToCanonical(ref), CANONICAL_TO_PI_PROVIDER); } + +/** OMP-native selector -> canonical shared-config model reference. */ +export function ompModelRefToCanonical(ref: string): string { + return remapProviderPrefix(ref, OMP_TO_CANONICAL_PROVIDER); +} + +/** Canonical shared-config model reference -> OMP-native selector. */ +export function resolveModelRefForOmp(ref: string): string { + return remapProviderPrefix(ompModelRefToCanonical(ref), CANONICAL_TO_OMP_PROVIDER); +} From a00685dc8a08082b4ead6a5126602c992a4f05af Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Thu, 6 Aug 2026 19:40:48 +0800 Subject: [PATCH 14/16] =?UTF-8?q?fix(dashboard):=20=F0=9F=90=9B=20gate=20a?= =?UTF-8?q?nd=20merge=20OMP=20discovery?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- packages/dashboard/src-tauri/src/commands.rs | 47 +++-- .../dashboard/src-tauri/src/pi_sessions.rs | 183 +++++++++++++++--- 2 files changed, 188 insertions(+), 42 deletions(-) diff --git a/packages/dashboard/src-tauri/src/commands.rs b/packages/dashboard/src-tauri/src/commands.rs index ffe4cbd77..dcb55ae53 100644 --- a/packages/dashboard/src-tauri/src/commands.rs +++ b/packages/dashboard/src-tauri/src/commands.rs @@ -1069,11 +1069,16 @@ pub fn parse_omp_models_output(text: &str) -> Vec { async fn get_available_omp_models() -> Vec { let candidates = if cfg!(target_os = "windows") { let appdata = std::env::var("APPDATA").unwrap_or_default(); + let userprofile = std::env::var("USERPROFILE").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)); } + if !userprofile.is_empty() { + list.push(format!("{}\\.bun\\bin\\omp.exe", userprofile)); + list.push(format!("{}\\.bun\\bin\\omp.cmd", userprofile)); + } list.push("omp".to_string()); list.push("omp.exe".to_string()); list @@ -1117,6 +1122,17 @@ async fn get_available_omp_models() -> Vec { Vec::new() } +fn merge_pi_and_omp_models(mut pi_models: Vec, omp_models: Vec) -> Vec { + pi_models.extend(omp_models); + pi_models.sort(); + pi_models.dedup(); + pi_models +} + +async fn include_omp_models(pi_models: Vec) -> Vec { + merge_pi_and_omp_models(pi_models, get_available_omp_models().await) +} + #[tauri::command] pub async fn get_available_pi_models() -> Vec { // GUI apps on macOS don't inherit shell PATH; try common locations. @@ -1173,7 +1189,7 @@ pub async fn get_available_pi_models() -> Vec { if let Some(text) = run_bounded_binary(bin, &["--list-models"]).await { let models = parse_pi_models_output(&text); if !models.is_empty() { - return models; + return include_omp_models(models).await; } } } @@ -1183,7 +1199,7 @@ pub async fn get_available_pi_models() -> Vec { if let Some(text) = run_bounded_binary(&bin, &["--list-models"]).await { let models = parse_pi_models_output(&text); if !models.is_empty() { - return models; + return include_omp_models(models).await; } } } @@ -1195,16 +1211,11 @@ pub async fn get_available_pi_models() -> Vec { if let Some(text) = run_via_login_shell("pi --list-models".to_string()).await { let models = parse_pi_models_output(&text); if !models.is_empty() { - return models; + return include_omp_models(models).await; } } - let omp_models = get_available_omp_models().await; - if !omp_models.is_empty() { - return omp_models; - } - - Vec::new() + get_available_omp_models().await } // ── Embedding test ────────────────────────────────────────── @@ -1406,9 +1417,10 @@ pub fn get_db_health(state: State<'_, AppState>) -> db::DbHealth { #[cfg(test)] mod tests { use super::{ - 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, + merge_pi_and_omp_models, 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}; @@ -1677,6 +1689,17 @@ mod tests { ); } + #[test] + fn pi_and_omp_model_catalogs_are_merged_and_deduplicated() { + assert_eq!( + merge_pi_and_omp_models( + vec!["anthropic/shared".into(), "pi/only".into()], + vec!["omp/only".into(), "anthropic/shared".into()], + ), + vec!["anthropic/shared", "omp/only", "pi/only"] + ); + } + #[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 2dd5a54b5..a4a7eeec3 100644 --- a/packages/dashboard/src-tauri/src/pi_sessions.rs +++ b/packages/dashboard/src-tauri/src/pi_sessions.rs @@ -82,8 +82,12 @@ fn test_root() -> &'static RwLock> { } fn trimmed_env_path(value: Option) -> Option { - let trimmed = value?.to_string_lossy().trim().to_string(); - (!trimmed.is_empty()).then(|| PathBuf::from(trimmed)) + let value = value?; + if let Some(text) = value.to_str() { + let trimmed = text.trim(); + return (!trimmed.is_empty()).then(|| PathBuf::from(trimmed)); + } + (!value.is_empty()).then(|| PathBuf::from(value)) } fn normalize_omp_profile(value: Option) -> Option { @@ -137,45 +141,109 @@ fn append_omp_profile_roots(roots: &mut Vec, profiles_dir: &Path, xdg: roots.extend(discovered); } -fn pi_compatible_session_roots() -> Vec { - if let Ok(root) = test_root().read() { - if let Some(path) = root.clone() { - return vec![path]; +fn omp_package_dir_is_valid(path: &Path) -> bool { + fs::read_to_string(path.join("package.json")) + .ok() + .and_then(|text| serde_json::from_str::(&text).ok()) + .and_then(|manifest| manifest.get("name")?.as_str().map(str::to_owned)) + .is_some_and(|name| name == "@oh-my-pi/pi-coding-agent") +} + +fn omp_fallback_binary_candidates( + home: &Path, + app_data: Option<&Path>, + windows: bool, +) -> Vec { + if windows { + let mut candidates = Vec::new(); + if let Some(app_data) = app_data { + candidates.push(app_data.join("npm/omp.cmd")); + candidates.push(app_data.join("npm/omp.exe")); } + candidates.push(home.join(".bun/bin/omp.exe")); + candidates.push(home.join(".bun/bin/omp.cmd")); + return candidates; } + vec![ + home.join(".bun/bin/omp"), + home.join(".local/bin/omp"), + PathBuf::from("/usr/local/bin/omp"), + PathBuf::from("/opt/homebrew/bin/omp"), + home.join(".local/share/mise/shims/omp"), + home.join(".asdf/shims/omp"), + home.join(".volta/bin/omp"), + ] +} - let Some(home) = dirs::home_dir() else { - return Vec::new(); +fn omp_installation_detected(home: &Path) -> bool { + if std::env::var_os("OMP_PROFILE").is_some() { + return true; + } + if trimmed_env_path(std::env::var_os("PI_PACKAGE_DIR")) + .is_some_and(|path| omp_package_dir_is_valid(&path)) + { + return true; + } + let binary_names: &[&str] = if cfg!(target_os = "windows") { + &["omp.exe", "omp.cmd"] + } else { + &["omp"] }; + if std::env::split_paths(&std::env::var_os("PATH").unwrap_or_default()) + .any(|dir| binary_names.iter().any(|name| dir.join(name).is_file())) + { + return true; + } + let app_data = trimmed_env_path(std::env::var_os("APPDATA")); + omp_fallback_binary_candidates(home, app_data.as_deref(), cfg!(target_os = "windows")) + .iter() + .any(|path| path.is_file()) +} + +fn pi_compatible_session_roots_for_home(home: &Path, include_omp: bool) -> Vec { let mut roots = vec![home.join(".pi/agent/sessions")]; if let Some(agent_dir) = trimmed_env_path(std::env::var_os("PI_CODING_AGENT_DIR")) { roots.push(agent_dir.join("sessions")); } - let config_dir = trimmed_env_path(std::env::var_os("PI_CONFIG_DIR")) - .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) = trimmed_env_path(std::env::var_os("XDG_DATA_HOME")) { - let app_root = 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 include_omp { + let config_dir = trimmed_env_path(std::env::var_os("PI_CONFIG_DIR")) + .unwrap_or_else(|| PathBuf::from(".omp")); + let config_root = home.join(&config_dir); + let default_agent = config_root.join("agent"); + + // Keep the default root visible even when a named profile is active. + roots.push(default_agent.join("sessions")); + append_omp_profile_roots(&mut roots, &config_root.join("profiles"), false); + if let Some(profile) = active_omp_profile() { + roots.push( + config_root + .join("profiles") + .join(profile) + .join("agent/sessions"), + ); } - if xdg_root.exists() { - roots.push(xdg_root.join("sessions")); + + // OMP only switches data into XDG on Unix when the default agent dir is + // in use. Mirror that guard so stale Windows/custom-agent roots do not + // appear in the dashboard. + let configured_agent = trimmed_env_path(std::env::var_os("PI_CODING_AGENT_DIR")); + let can_use_xdg = (cfg!(target_os = "linux") || cfg!(target_os = "macos")) + && configured_agent + .as_ref() + .map_or(true, |agent| agent == &default_agent); + if can_use_xdg { + if let Some(xdg_data) = trimmed_env_path(std::env::var_os("XDG_DATA_HOME")) { + let app_root = xdg_data.join("omp"); + if app_root.exists() { + roots.push(app_root.join("sessions")); + append_omp_profile_roots(&mut roots, &app_root.join("profiles"), true); + if let Some(profile) = active_omp_profile() { + roots.push(app_root.join("profiles").join(profile).join("sessions")); + } + } + } } } @@ -187,6 +255,19 @@ fn pi_compatible_session_roots() -> Vec { roots } +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(); + }; + pi_compatible_session_roots_for_home(&home, omp_installation_detected(&home)) +} + fn deduplicate_pi_sessions(mut sessions: Vec<(usize, PiSessionMeta)>) -> Vec { sessions.sort_by(|(left_priority, left), (right_priority, right)| { right @@ -739,6 +820,16 @@ mod tests { assert_eq!(trimmed_env_path(None), None); } + #[cfg(unix)] + #[test] + fn environment_paths_preserve_non_utf8_bytes() { + use std::os::unix::ffi::{OsStrExt, OsStringExt}; + + let raw = std::ffi::OsString::from_vec(vec![b'/', b't', b'm', b'p', b'/', 0xff]); + let path = trimmed_env_path(Some(raw.clone())).unwrap(); + assert_eq!(path.as_os_str().as_bytes(), raw.as_os_str().as_bytes()); + } + #[test] fn discovers_named_omp_profile_session_roots() { let dir = tempfile::tempdir().unwrap(); @@ -757,6 +848,38 @@ mod tests { ); } + #[test] + fn omp_profile_roots_require_positive_omp_detection() { + let home = tempfile::tempdir().unwrap(); + let profile_root = home.path().join(".omp/profiles/work/agent/sessions"); + let default_root = home.path().join(".omp/agent/sessions"); + fs::create_dir_all(&profile_root).unwrap(); + + let plain_pi_roots = pi_compatible_session_roots_for_home(home.path(), false); + assert!(!plain_pi_roots.contains(&profile_root)); + assert!(!plain_pi_roots.contains(&default_root)); + let omp_roots = pi_compatible_session_roots_for_home(home.path(), true); + assert!(omp_roots.contains(&profile_root)); + assert!(omp_roots.contains(&default_root)); + } + + #[test] + fn omp_detection_candidates_cover_gui_install_locations() { + let home = Path::new("/home/fox"); + let unix = omp_fallback_binary_candidates(home, None, false); + assert!(unix.contains(&PathBuf::from("/usr/local/bin/omp"))); + assert!(unix.contains(&PathBuf::from("/opt/homebrew/bin/omp"))); + assert!(unix.contains(&home.join(".local/share/mise/shims/omp"))); + assert!(unix.contains(&home.join(".asdf/shims/omp"))); + assert!(unix.contains(&home.join(".volta/bin/omp"))); + + let app_data = Path::new("C:/Users/fox/AppData/Roaming"); + let windows = + omp_fallback_binary_candidates(Path::new("C:/Users/fox"), Some(app_data), true); + assert!(windows.contains(&app_data.join("npm/omp.cmd"))); + assert!(windows.contains(&PathBuf::from("C:/Users/fox/.bun/bin/omp.exe"))); + } + #[test] fn omp_profile_rejects_unsafe_names_and_preserves_default_semantics() { assert_eq!(normalize_omp_profile(Some("".into())), None); From cad987e88dc3062bbd0cab10f9b86168aa9695f0 Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Thu, 6 Aug 2026 19:40:56 +0800 Subject: [PATCH 15/16] =?UTF-8?q?test(omp):=20=F0=9F=A7=AA=20add=20real=20?= =?UTF-8?q?runtime=20smoke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- .github/workflows/ci.yml | 31 +++++++++ tests/docker/Dockerfile.omp | 69 ++++++++++++++++++++ tests/docker/test-omp-e2e.sh | 119 +++++++++++++++++++++++++++++++++++ 3 files changed, 219 insertions(+) create mode 100644 tests/docker/Dockerfile.omp create mode 100755 tests/docker/test-omp-e2e.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8a0a38fa..d8d8f8b2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -215,6 +215,37 @@ jobs: - name: Run E2E run: docker run --rm --platform linux/amd64 mc-e2e-pi + e2e-omp: + name: E2E (Oh My Pi, real Docker) + runs-on: ubuntu-latest + needs: [check-pi-plugin] + timeout-minutes: 25 + steps: + - uses: actions/checkout@v5 + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install workspace deps + run: bun install --frozen-lockfile + + - name: Build Pi-compatible plugin + run: bun run --cwd packages/pi-plugin build + + - name: Build CLI + run: bun run --cwd packages/cli build + + - name: Build real OMP E2E image + run: | + docker build \ + --platform linux/amd64 \ + -f tests/docker/Dockerfile.omp \ + -t mc-e2e-omp \ + . + + - name: Run real OMP install and session smoke + run: docker run --rm --platform linux/amd64 mc-e2e-omp + e2e-host-opencode: name: E2E (OpenCode, host behavior) runs-on: ubuntu-latest diff --git a/tests/docker/Dockerfile.omp b/tests/docker/Dockerfile.omp new file mode 100644 index 000000000..ccd8d8e4d --- /dev/null +++ b/tests/docker/Dockerfile.omp @@ -0,0 +1,69 @@ +# Magic Context — real Oh My Pi E2E image +# +# Uses the current npm OMP binary, its native plugin manager, and a local mock +# provider. This catches manifest/loader/config regressions that mocked unit +# tests and upstream-Pi smoke tests cannot observe. +FROM debian:bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update && apt-get install -y \ + curl \ + git \ + ca-certificates \ + unzip \ + python3 \ + build-essential \ + sqlite3 \ + && rm -rf /var/lib/apt/lists/* + +RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# OMP's published CLI is a Bun executable (`#!/usr/bin/env bun`) even when +# installed through npm. +RUN curl -fsSL https://bun.sh/install | bash +ENV PATH="/root/.bun/bin:$PATH" + +# Deliberately float to the current published OMP. Compatibility must be tested +# against what users install today, not only against a local fork or fake CLI. +RUN npm install -g @oh-my-pi/pi-coding-agent@latest +RUN omp --version + +RUN mkdir -p /test +RUN cd /test && \ + npm init -y > /dev/null && \ + npm install --silent --no-audit --no-fund @copilotkit/aimock + +COPY packages/pi-plugin/dist/ /test/mc-pi/dist/ +COPY packages/pi-plugin/package.json /test/mc-pi/package.json +COPY packages/pi-plugin/README.md /test/mc-pi/README.md +RUN cd /test/mc-pi && \ + npm install --omit=dev --no-audit --no-fund --workspaces=false \ + --no-package-lock --legacy-peer-deps + +COPY packages/cli/dist/ /test/mc-cli/dist/ +COPY packages/cli/package.json /test/mc-cli/package.json +RUN cd /test/mc-cli && \ + npm install --omit=dev --no-audit --no-fund --workspaces=false \ + --no-package-lock --legacy-peer-deps +RUN chmod +x /test/mc-cli/dist/index.js && \ + ln -s /test/mc-cli/dist/index.js /usr/local/bin/magic-context + +# Exercise OMP's real plugin-manager path. Local installs are linked and listed +# under the package name declared by packages/pi-plugin/package.json. +RUN omp plugin install /test/mc-pi +RUN omp plugin list --json + +RUN git config --global user.email "test@cortexkit.local" && \ + git config --global user.name "Magic Context CI" && \ + mkdir -p /test/project && cd /test/project && git init -q && \ + echo '{"name":"test","version":"1.0.0"}' > package.json && \ + git add -A && git commit -q -m "init" + +COPY tests/docker/test-omp-e2e.sh /test/test-omp-e2e.sh +COPY tests/docker/fixtures/aimock-pi.cjs /test/aimock-server.cjs +RUN chmod +x /test/test-omp-e2e.sh + +WORKDIR /test/project +ENTRYPOINT ["/test/test-omp-e2e.sh"] diff --git a/tests/docker/test-omp-e2e.sh b/tests/docker/test-omp-e2e.sh new file mode 100755 index 000000000..a2b997f40 --- /dev/null +++ b/tests/docker/test-omp-e2e.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +set -euo pipefail + +PASS=0 +FAIL=0 +DB_PATH="$HOME/.local/share/cortexkit/magic-context/context.db" +PLUGIN_LOG="$(node -e 'console.log(require("os").tmpdir())')/pi/magic-context/magic-context.log" + +check() { + local label="$1" + local condition="$2" + if eval "$condition"; then + echo "PASS [$label]" + PASS=$((PASS + 1)) + else + echo "FAIL [$label]" + FAIL=$((FAIL + 1)) + fi +} + +section() { + echo + echo "--- $1 ---" +} + +section "Real OMP installation and plugin manager" +OMP_VERSION=$(omp --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || true) +PLUGIN_LIST=$(omp plugin list --json 2>&1) +echo "OMP version: ${OMP_VERSION:-unknown}" +echo "$PLUGIN_LIST" +check "omp --version returns a value" "test -n \"$OMP_VERSION\"" +check "OMP lists the linked Magic Context package" \ + "echo \"\$PLUGIN_LIST\" | grep -q '@cortexkit/pi-magic-context'" + +section "OMP doctor repair" +rm -rf "$HOME/.local/share/cortexkit" "$PLUGIN_LOG" +DOCTOR_OUT=$(magic-context doctor --harness omp --force 2>&1 || true) +echo "$DOCTOR_OUT" +check "OMP doctor reports zero hard failures" \ + "echo \"\$DOCTOR_OUT\" | grep -qE 'FAIL 0'" +check "OMP native compaction is disabled" \ + "test \"$(omp config get compaction.enabled --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(String(JSON.parse(s).value)))')\" = false" +check "OMP automatic memory is disabled" \ + "test \"$(omp config get memory.backend --json | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>process.stdout.write(String(JSON.parse(s).value)))')\" = off" + +section "Real OMP one-turn extension load" +mkdir -p "$HOME/.config/cortexkit" "$HOME/.omp/agent" +cat > "$HOME/.config/cortexkit/magic-context.jsonc" <<'JSON' +{ + "enabled": true, + "dreamer": { "enabled": false }, + "sidekick": { "enabled": false }, + "embedding": { "provider": "off" }, + "auto_update": false +} +JSON + +cat > "$HOME/.omp/agent/models.json" <<'JSON' +{ + "providers": { + "mock": { + "api": "openai-completions", + "baseUrl": "http://127.0.0.1:4010/v1", + "apiKey": "sk-mock", + "models": [ + { + "id": "mock-model", + "name": "Mock Model", + "input": ["text"], + "contextWindow": 128000, + "maxTokens": 4096, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } + } + ] + } + } +} +JSON + +node /test/aimock-server.cjs > /tmp/aimock.log 2>&1 & +AIMOCK_PID=$! +trap 'kill $AIMOCK_PID 2>/dev/null || true' EXIT +for _ in $(seq 1 15); do + if curl -fsS http://127.0.0.1:4010/v1/models > /dev/null 2>&1; then + break + fi + sleep 1 +done +check "aimock is ready" "curl -fsS http://127.0.0.1:4010/v1/models > /dev/null" + +set +e +timeout --signal=KILL 60 omp --print --mode json --no-session \ + --provider mock \ + --model mock/mock-model \ + "Say hello once and then stop." \ + > /tmp/omp.log 2>&1 +OMP_EXIT=$? +set -e +echo "OMP exit: $OMP_EXIT" +tail -20 /tmp/omp.log + +check "OMP turn exits successfully" "test \"$OMP_EXIT\" -eq 0" +check "OMP emits a terminal agent_end protocol event" \ + "grep -q '\"type\":\"agent_end\"' /tmp/omp.log" + +check "OMP produced protocol output" "test -s /tmp/omp.log" +check "Magic Context extension initialized" "test -s $PLUGIN_LOG" +check "shared context database exists" "test -f $DB_PATH" +if [[ -f "$DB_PATH" ]]; then + SESSION_COUNT=$(sqlite3 "$DB_PATH" \ + "SELECT COUNT(*) FROM session_meta WHERE harness='pi'" 2>/dev/null || echo 0) + check "OMP turn persisted through the Pi-compatible runtime" \ + "test \"$SESSION_COUNT\" -gt 0" +fi + +section "Summary" +echo "PASS: $PASS" +echo "FAIL: $FAIL" +test "$FAIL" -eq 0 From d231001bd5dd9b1da9c88432f62ce2cffc6cd0d5 Mon Sep 17 00:00:00 2001 From: Lynricsy Date: Thu, 6 Aug 2026 19:43:27 +0800 Subject: [PATCH 16/16] =?UTF-8?q?docs(pi):=20=F0=9F=93=9D=20clarify=20host?= =?UTF-8?q?-scoped=20extension=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Wine Fox --- packages/pi-plugin/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/pi-plugin/README.md b/packages/pi-plugin/README.md index 9c0c18449..29a41e50d 100644 --- a/packages/pi-plugin/README.md +++ b/packages/pi-plugin/README.md @@ -85,7 +85,7 @@ Magic Context reads the shared CortexKit config (project overrides user): 1. `$cwd/.cortexkit/magic-context.jsonc` 2. `~/.config/cortexkit/magic-context.jsonc` -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. +Session discovery follows the active host. Relative `pi.subagent_extensions` are deliberately isolated by host: plain Pi resolves them from `~/.pi/agent`, while a positively identified OMP process uses `PI_CODING_AGENT_DIR` when set and otherwise derives the active `~/.omp` config/profile agent directory. ### Minimal config @@ -174,7 +174,7 @@ This package is part of the [magic-context monorepo](https://github.com/cortexki | Pi-specific module | Responsibility | |---|---| | `context-handler.ts` | Pi `pi.on("context", ...)` adapter — tags, drops, runs nudges and auto-search | -| `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 | +| `subagent-runner.ts` | Re-invokes the current Pi/OMP host with `--print --mode json --no-session`; resolves relative allowlisted extensions from plain Pi's `~/.pi/agent` or an identified OMP host's active agent directory; 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 |