@cortexkit/opencode-magic-context 0.35.0 → 0.36.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +9 -5
  2. package/dist/agents/hidden-agent-registrations.d.ts +10 -2
  3. package/dist/agents/hidden-agent-registrations.d.ts.map +1 -1
  4. package/dist/agents/permissions.d.ts +21 -3
  5. package/dist/agents/permissions.d.ts.map +1 -1
  6. package/dist/{announcement-gms2nhya.js → announcement-6c1g4es4.js} +2 -2
  7. package/dist/config/schema/agent-overrides.d.ts +1 -1
  8. package/dist/config/schema/magic-context.d.ts +19 -9
  9. package/dist/config/schema/magic-context.d.ts.map +1 -1
  10. package/dist/{conflict-warning-hook-p2pmhvn7.js → conflict-warning-hook-dw9fs934.js} +4 -4
  11. package/dist/{derive-budgets-9crp0cvd.js → derive-budgets-88mxk2w1.js} +3 -3
  12. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +2 -0
  13. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  14. package/dist/features/magic-context/dreamer/task-executor.d.ts +1 -0
  15. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  16. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  17. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +37 -0
  18. package/dist/features/magic-context/smart-notes/condition-compiler.d.ts.map +1 -0
  19. package/dist/features/magic-context/smart-notes/runner.d.ts +1 -0
  20. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  21. package/dist/features/magic-context/smart-notes/storage.d.ts +3 -3
  22. package/dist/features/magic-context/smart-notes/storage.d.ts.map +1 -1
  23. package/dist/features/magic-context/storage-db.d.ts +1 -1
  24. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  25. package/dist/features/magic-context/storage-notes.d.ts +13 -0
  26. package/dist/features/magic-context/storage-notes.d.ts.map +1 -1
  27. package/dist/features/magic-context/storage.d.ts +1 -1
  28. package/dist/features/magic-context/storage.d.ts.map +1 -1
  29. package/dist/hooks/magic-context/compartment-prompt.d.ts +6 -6
  30. package/dist/hooks/magic-context/compartment-prompt.d.ts.map +1 -1
  31. package/dist/hooks/magic-context/hook.d.ts +3 -0
  32. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  33. package/dist/hooks/magic-context/lkg-slot.d.ts +15 -1
  34. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  35. package/dist/hooks/magic-context/module-state-sync.d.ts +6 -0
  36. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  37. package/dist/hooks/magic-context/module-transport.d.ts +9 -0
  38. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts +10 -0
  40. package/dist/hooks/magic-context/raw-fallback-context-limit.d.ts.map +1 -0
  41. package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
  42. package/dist/hooks/magic-context/rust-mode-transform.d.ts +7 -2
  43. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  44. package/dist/{index-8aaadh98.js → index-37rt2bka.js} +2 -2
  45. package/dist/{index-4anesz49.js → index-3nfgbsp4.js} +103 -56
  46. package/dist/{index-fcyvznch.js → index-3tnkymva.js} +47 -4
  47. package/dist/{index-k1esqfwk.js → index-f1tnb3vm.js} +1 -1
  48. package/dist/{index-kvrcg31b.js → index-mmrhhk9a.js} +8 -1
  49. package/dist/{index-cn960ffd.js → index-nsx2n9eg.js} +3 -3
  50. package/dist/{index-dmr999a2.js → index-qf20mv7e.js} +1 -1
  51. package/dist/{index-73xz0247.js → index-tsgs7td8.js} +1 -1
  52. package/dist/{index-ry8rh8cv.js → index-wgvxkqpx.js} +5 -4
  53. package/dist/index-z8v31d85.js +2892 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/index.js +822 -119
  56. package/dist/plugin/dream-timer.d.ts +1 -0
  57. package/dist/plugin/dream-timer.d.ts.map +1 -1
  58. package/dist/plugin/hooks/create-session-hooks.d.ts +3 -0
  59. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  60. package/dist/plugin/messages-transform.d.ts +5 -5
  61. package/dist/plugin/messages-transform.d.ts.map +1 -1
  62. package/dist/{recomp-orchestrator-zc915ceq.js → recomp-orchestrator-7wbbvf87.js} +7 -7
  63. package/dist/{safe-notification-target-q9jk5n44.js → safe-notification-target-c11fdda2.js} +3 -3
  64. package/dist/{send-session-notification-6njqewzk.js → send-session-notification-bgtf4853.js} +4 -4
  65. package/dist/shared/data-path.d.ts +27 -0
  66. package/dist/shared/data-path.d.ts.map +1 -1
  67. package/dist/shared/harness-provider-map.d.ts +23 -20
  68. package/dist/shared/harness-provider-map.d.ts.map +1 -1
  69. package/dist/shared/prompt-surface-a1-golden.d.ts +28 -0
  70. package/dist/shared/prompt-surface-a1-golden.d.ts.map +1 -0
  71. package/dist/shared/prompt-surface-runtime.d.ts +15 -2
  72. package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
  73. package/dist/shared/sqlite.d.ts +13 -0
  74. package/dist/shared/sqlite.d.ts.map +1 -1
  75. package/dist/{storage-meta-session-53cjs6mr.js → storage-meta-session-kh5egr11.js} +4 -4
  76. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  77. package/package.json +3 -3
  78. package/src/agents/hidden-agent-registrations.ts +49 -1
  79. package/src/agents/permissions.ts +91 -3
  80. package/src/config/compaction-accessor-guard.test.ts +9 -4
  81. package/src/config/schema/magic-context.test.ts +2 -0
  82. package/src/config/schema/magic-context.ts +15 -0
  83. package/src/shared/data-path.test.ts +65 -9
  84. package/src/shared/data-path.ts +66 -1
  85. package/src/shared/harness-provider-map.test.ts +42 -1
  86. package/src/shared/harness-provider-map.ts +48 -26
  87. package/src/shared/prompt-surface-a1-golden.ts +41 -0
  88. package/src/shared/prompt-surface-runtime.ts +13 -5
  89. package/src/shared/sqlite.test.ts +32 -0
  90. package/src/shared/sqlite.ts +85 -9
  91. package/dist/index-h8mk7vpa.js +0 -135318
@@ -6,9 +6,10 @@ import { log } from "../shared/logger";
6
6
  * # Why this exists
7
7
  *
8
8
  * Hidden agents (`historian`, `historian-editor`, `dreamer`, `sidekick`) are
9
- * registered with `mode: "subagent"` and `hidden: true`, but those flags
10
- * only control visibility in the UI picker — they do NOT restrict which
11
- * tools the spawned session can call. By default a registered subagent
9
+ * registered with `mode: "primary"` and `hidden: true`: primary mode keeps
10
+ * them out of OpenCode's general Task candidate list, while hidden keeps them
11
+ * out of the UI picker. Those flags do NOT restrict which tools the spawned
12
+ * session can call. By default a registered agent
12
13
  * inherits the FULL primary-agent tool surface: `task`, `bash`, `edit`,
13
14
  * `webfetch`, `websearch`, `read`, `grep`, `glob`, every MCP tool, etc.
14
15
  *
@@ -108,6 +109,93 @@ export function buildAllowOnlyPermission(
108
109
  return permission;
109
110
  }
110
111
 
112
+ type PermissionAction = "ask" | "allow" | "deny";
113
+
114
+ function isPermissionAction(value: unknown): value is PermissionAction {
115
+ return value === "ask" || value === "allow" || value === "deny";
116
+ }
117
+
118
+ function isPermissionMap(value: unknown): value is Record<string, unknown> {
119
+ return value !== null && typeof value === "object" && !Array.isArray(value);
120
+ }
121
+
122
+ /**
123
+ * Add final `permission.task` denies for Magic Context's internal workers.
124
+ *
125
+ * OpenCode accepts either a whole-permission action or a pattern map for
126
+ * `permission.task`; its evaluator uses the last matching rule. Normalize the
127
+ * whole-permission form, retain every unrelated user rule, then append our
128
+ * exact agent-id denies after both the user's task patterns and any `*` rule.
129
+ */
130
+ export function denyTaskRoutingToAgents(
131
+ permission: unknown,
132
+ internalAgentIds: readonly string[],
133
+ ): Record<string, unknown> {
134
+ const configured = isPermissionAction(permission)
135
+ ? { "*": permission }
136
+ : isPermissionMap(permission)
137
+ ? permission
138
+ : {};
139
+ const { task, ...otherPermissions } = configured;
140
+ const configuredTask = isPermissionAction(task)
141
+ ? { "*": task }
142
+ : isPermissionMap(task)
143
+ ? task
144
+ : {};
145
+ const internalAgentIdSet = new Set(internalAgentIds);
146
+ const retainedTask = Object.fromEntries(
147
+ Object.entries(configuredTask).filter(([agentId]) => !internalAgentIdSet.has(agentId)),
148
+ );
149
+
150
+ return {
151
+ ...otherPermissions,
152
+ task: {
153
+ ...retainedTask,
154
+ ...Object.fromEntries(internalAgentIds.map((agentId) => [agentId, "deny"])),
155
+ },
156
+ };
157
+ }
158
+
159
+ const BUILTIN_TASK_CALLER_IDS = ["build", "plan"] as const;
160
+
161
+ function isTaskRoutingCaller(agentId: string, config: Record<string, unknown>): boolean {
162
+ const mode = config.mode;
163
+ // OpenCode compatibility:
164
+ // Only strictly-primary callers receive explicit Task routing rules.
165
+ // Agents that may execute as Task children are intentionally left untouched,
166
+ // because explicit task permissions can alter OpenCode's default anti-nesting
167
+ // behavior in the currently supported permission model.
168
+ if (mode === "primary") return true;
169
+ if (mode === "subagent" || mode === "all") return false;
170
+ return agentId === "build" || agentId === "plan";
171
+ }
172
+
173
+ /**
174
+ * Apply Task routing denies only to agents that can act as Task callers.
175
+ *
176
+ * A top-level permission rule is merged into every OpenCode agent. Adding one
177
+ * there would suppress the Task tool's default deny for ordinary subagents, so
178
+ * seed only the built-in interactive callers and configured primary agents.
179
+ */
180
+ export function denyTaskRoutingToCallerAgents(
181
+ agentConfigs: Record<string, Record<string, unknown>>,
182
+ internalAgentIds: readonly string[],
183
+ ): Record<string, Record<string, unknown>> {
184
+ const result = { ...agentConfigs };
185
+ const candidateIds = new Set([...BUILTIN_TASK_CALLER_IDS, ...Object.keys(agentConfigs)]);
186
+
187
+ for (const agentId of candidateIds) {
188
+ const agentConfig = agentConfigs[agentId] ?? {};
189
+ if (!isTaskRoutingCaller(agentId, agentConfig)) continue;
190
+ result[agentId] = {
191
+ ...agentConfig,
192
+ permission: denyTaskRoutingToAgents(agentConfig.permission, internalAgentIds),
193
+ };
194
+ }
195
+
196
+ return result;
197
+ }
198
+
111
199
  /**
112
200
  * Tools the historian + historian-editor + compressor agents need.
113
201
  *
@@ -27,6 +27,11 @@ const ALLOWED_READERS = new Set<string>([
27
27
  // The strip operates on a raw Record<string, unknown> by key name, not on
28
28
  // a parsed MagicContextConfig.
29
29
  "packages/plugin/src/config/project-security.ts",
30
+ // OMP's own setting key appears only as an external CLI string literal;
31
+ // these files never read Magic Context's parsed compaction config.
32
+ "packages/cli/src/lib/omp-helpers.ts",
33
+ "packages/cli/src/commands/setup-omp.ts",
34
+ "packages/cli/src/commands/doctor-omp.ts",
30
35
  ]);
31
36
 
32
37
  function sourceFiles(directory: string): string[] {
@@ -42,10 +47,10 @@ function sourceFiles(directory: string): string[] {
42
47
  return result;
43
48
  }
44
49
 
45
- // Matches direct reads of the config path: `compaction.enabled` or
46
- // `compaction?.enabled` as a property access (not a string literal). This is a
47
- // structural grep, not a full AST parse — it is deliberately conservative and
48
- // may surface false positives, which are then allow-listed only after review.
50
+ // Conservatively matches the textual token `compaction.enabled` or
51
+ // `compaction?.enabled`, including string literals. False positives are
52
+ // allow-listed only after confirming they do not read Magic Context's parsed
53
+ // config path.
49
54
  // It does NOT match the DB column `compaction_mode_record`, the accessor name
50
55
  // `isCompactionEnabled`, or the schema's own `.object({ enabled: ... })`.
51
56
  const COMPACTION_ENABLED_READ = /\bcompaction\??\s*\.\s*enabled\b(?!_)/;
@@ -18,6 +18,7 @@ describe("MagicContextConfigSchema", () => {
18
18
  fail_closed_blocking: true,
19
19
  transform_mode: "ts",
20
20
  storage: { enforce_private_permissions: true },
21
+ smart_notes: { retina_handoff: false },
21
22
  cache_ttl: "5m",
22
23
  prompt_surface: { default: "full" },
23
24
  execute_threshold_percentage: 65,
@@ -108,6 +109,7 @@ describe("MagicContextConfigSchema", () => {
108
109
  overlay: false,
109
110
  },
110
111
  smart_drops: false,
112
+ smart_notes: { retina_handoff: false },
111
113
  shadow_embedding: {
112
114
  enabled: false,
113
115
  },
@@ -470,6 +470,10 @@ export interface MagicContextConfig {
470
470
  language?: string;
471
471
  historian?: HistorianConfig;
472
472
  dreamer?: DreamerConfig;
473
+ smart_notes: {
474
+ /** Flip ownership of authoring-compiled conditions from dreamer to retina. */
475
+ retina_handoff: boolean;
476
+ };
473
477
  cache_ttl: string | { default: string; [modelKey: string]: string };
474
478
  /** Preset routing for guidance and provider-visible prompt surfaces. */
475
479
  prompt_surface: PromptSurfaceConfig;
@@ -688,6 +692,17 @@ export const MagicContextConfigSchema = z
688
692
  dreamer: DreamerConfigSchema.optional().describe(
689
693
  "Dreamer agent + scheduling configuration (model, fallback_models, disable, schedule, tasks, etc.)",
690
694
  ),
695
+ smart_notes: z
696
+ .object({
697
+ retina_handoff: z
698
+ .boolean()
699
+ .default(false)
700
+ .describe(
701
+ "When true, dreamer skips smart notes whose surface conditions compiled to retina provider configs at authoring time. Default false keeps both paths active until the retina consumer is deployed.",
702
+ ),
703
+ })
704
+ .default({ retina_handoff: false })
705
+ .describe("Smart-note ownership transition controls."),
691
706
  cache_ttl: z
692
707
  .union([z.string(), z.object({ default: z.string() }).catchall(z.string())])
693
708
  .default("5m")
@@ -20,6 +20,8 @@ const savedEnv = {
20
20
  XDG_DATA_HOME: process.env.XDG_DATA_HOME,
21
21
  LOCALAPPDATA: process.env.LOCALAPPDATA,
22
22
  MAGIC_CONTEXT_LOG_PATH: process.env.MAGIC_CONTEXT_LOG_PATH,
23
+ MAGIC_CONTEXT_TEST_DATA_DIR: process.env.MAGIC_CONTEXT_TEST_DATA_DIR,
24
+ NODE_ENV: process.env.NODE_ENV,
23
25
  };
24
26
 
25
27
  describe("data-path", () => {
@@ -36,14 +38,14 @@ describe("data-path", () => {
36
38
  });
37
39
 
38
40
  afterEach(() => {
39
- if (savedEnv.XDG_CACHE_HOME !== undefined)
40
- process.env.XDG_CACHE_HOME = savedEnv.XDG_CACHE_HOME;
41
- if (savedEnv.XDG_DATA_HOME !== undefined)
42
- process.env.XDG_DATA_HOME = savedEnv.XDG_DATA_HOME;
43
- if (savedEnv.LOCALAPPDATA !== undefined) process.env.LOCALAPPDATA = savedEnv.LOCALAPPDATA;
44
- if (savedEnv.MAGIC_CONTEXT_LOG_PATH !== undefined)
45
- process.env.MAGIC_CONTEXT_LOG_PATH = savedEnv.MAGIC_CONTEXT_LOG_PATH;
46
- else delete process.env.MAGIC_CONTEXT_LOG_PATH;
41
+ // Restore-or-delete every var this suite touches. Several tests lift a
42
+ // guard (NODE_ENV, MAGIC_CONTEXT_TEST_DATA_DIR) to assert production
43
+ // shape, so a restore that skips the unset case would leak state into
44
+ // the next test and make results order-dependent.
45
+ for (const [key, value] of Object.entries(savedEnv)) {
46
+ if (value !== undefined) process.env[key] = value;
47
+ else delete process.env[key];
48
+ }
47
49
  });
48
50
 
49
51
  test("getCacheDir falls back to <homedir>/.cache when XDG_CACHE_HOME is unset (all platforms)", () => {
@@ -89,8 +91,62 @@ describe("data-path", () => {
89
91
  // Cross-harness shared path: both OpenCode and Pi plugins read/write here,
90
92
  // unlike the legacy opencode/storage/plugin/magic-context location which
91
93
  // was OpenCode-specific. See ARCHITECTURE_DECISIONS memory for rationale.
94
+ // Production shape, so both test-isolation guards (the preload's data
95
+ // dir and the NODE_ENV backstop bun sets for every `bun test`) are
96
+ // lifted for the duration of this assertion.
97
+ const savedTestDir = process.env.MAGIC_CONTEXT_TEST_DATA_DIR;
98
+ const savedNodeEnv = process.env.NODE_ENV;
99
+ delete process.env.MAGIC_CONTEXT_TEST_DATA_DIR;
100
+ delete process.env.NODE_ENV;
101
+ try {
102
+ expect(getMagicContextStorageDir()).toBe(
103
+ path.join(os.homedir(), ".local", "share", "cortexkit", "magic-context"),
104
+ );
105
+ } finally {
106
+ if (savedTestDir !== undefined) process.env.MAGIC_CONTEXT_TEST_DATA_DIR = savedTestDir;
107
+ if (savedNodeEnv !== undefined) process.env.NODE_ENV = savedNodeEnv;
108
+ }
109
+ });
110
+
111
+ test("getMagicContextStorageDir backstops to a temp dir under NODE_ENV=test with no guard set", () => {
112
+ // CWD-independent backstop: a `bun test` from a dir whose bunfig has no
113
+ // `[test] preload` runs every suite with neither guard env var set. The
114
+ // DB resolver used to own this branch, so direct callers of this helper
115
+ // (the CLI doctors' own PRAGMA integrity_check) still reached the real
116
+ // shared DB. Memoized, so repeated calls must agree — openDatabase()
117
+ // caches by path.
118
+ const savedTestDir = process.env.MAGIC_CONTEXT_TEST_DATA_DIR;
119
+ delete process.env.MAGIC_CONTEXT_TEST_DATA_DIR;
120
+ process.env.NODE_ENV = "test";
121
+ try {
122
+ const resolved = getMagicContextStorageDir();
123
+ expect(resolved).not.toContain(path.join(os.homedir(), ".local", "share"));
124
+ expect(resolved.endsWith(path.join("cortexkit", "magic-context"))).toBe(true);
125
+ expect(getMagicContextStorageDir()).toBe(resolved);
126
+ } finally {
127
+ if (savedTestDir !== undefined) process.env.MAGIC_CONTEXT_TEST_DATA_DIR = savedTestDir;
128
+ }
129
+ });
130
+
131
+ test("getMagicContextStorageDir honors MAGIC_CONTEXT_TEST_DATA_DIR when XDG_DATA_HOME is unset", () => {
132
+ // The hole this closes: a test that deletes XDG_DATA_HOME to exercise
133
+ // path fallbacks used to resolve to the user's REAL shared storage,
134
+ // because bun caches os.homedir() and a mutated process.env.HOME cannot
135
+ // move getDataDir(). Callers that build their own context.db path (the
136
+ // CLI doctors) then ran integrity checks against production data.
137
+ process.env.MAGIC_CONTEXT_TEST_DATA_DIR = "/tmp/mc-test-isolation";
92
138
  expect(getMagicContextStorageDir()).toBe(
93
- path.join(os.homedir(), ".local", "share", "cortexkit", "magic-context"),
139
+ path.join("/tmp/mc-test-isolation", "cortexkit", "magic-context"),
140
+ );
141
+ });
142
+
143
+ test("getMagicContextStorageDir prefers XDG_DATA_HOME over MAGIC_CONTEXT_TEST_DATA_DIR", () => {
144
+ // A test managing its own per-test data home is already controlled, and
145
+ // several suites depend on that dir being honored.
146
+ process.env.MAGIC_CONTEXT_TEST_DATA_DIR = "/tmp/mc-test-isolation";
147
+ process.env.XDG_DATA_HOME = "/tmp/custom-data";
148
+ expect(getMagicContextStorageDir()).toBe(
149
+ path.join("/tmp/custom-data", "cortexkit", "magic-context"),
94
150
  );
95
151
  });
96
152
 
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import * as os from "node:os";
3
3
  import * as path from "node:path";
4
4
  import { getHarness, type HarnessId } from "./harness";
@@ -167,11 +167,76 @@ export function getOpenCodeStorageDir(): string {
167
167
  * - Future cross-harness session migration
168
168
  *
169
169
  * Layout: <XDG_DATA_HOME>/cortexkit/magic-context/
170
+ *
171
+ * TEST-ISOLATION GUARD. `openDatabase()` has been guarded in
172
+ * `resolveDatabasePath()` since the 2026-06-01 (v26) and 2026-06-19 (v41)
173
+ * incidents, in which unisolated tests migrated the user's REAL shared DB.
174
+ * Every OTHER caller bypassed that guard — notably the CLI doctors, which
175
+ * build `join(getMagicContextStorageDir(), "context.db")` themselves and run
176
+ * `PRAGMA integrity_check` against it. A test that deletes XDG_DATA_HOME to
177
+ * exercise path fallbacks cannot restore isolation by setting
178
+ * `process.env.HOME`: bun caches `os.homedir()` at startup, so `getDataDir()`
179
+ * still resolves to the real home. MAGIC_CONTEXT_TEST_DATA_DIR — set by
180
+ * `packages/plugin/test-preload.ts`, and restored by any test that touches it
181
+ * — is honored here, so the preload's "cannot be defeated" guarantee holds for
182
+ * every caller, not just `openDatabase()`. It is never set in production.
183
+ *
184
+ * CWD-INDEPENDENT BACKSTOP. The preload only runs when `bun test`'s CWD has a
185
+ * bunfig wiring `[test] preload`. A run from a dir WITHOUT that wiring (a
186
+ * package missing its bunfig, or a brand-new one) executes every *.test.ts with
187
+ * NO preload, and this resolver would hand back the REAL shared path — which is
188
+ * how the live DB reached v41. Bun sets NODE_ENV=test for EVERY `bun test`
189
+ * regardless of CWD, and production never sets it, so that window redirects to
190
+ * a memoized throwaway dir instead. It lives here rather than in
191
+ * `resolveDatabasePath()` so direct callers (the CLI doctors' own
192
+ * `PRAGMA integrity_check`, announcements, the models.dev cache) are covered
193
+ * too — they never go through the DB resolver.
194
+ *
195
+ * XDG_DATA_HOME still wins over both: a test that manages its own data home is
196
+ * already controlled, and production has no test dir set at all.
170
197
  */
171
198
  export function getMagicContextStorageDir(): string {
199
+ if (!process.env.XDG_DATA_HOME) {
200
+ const testDataDir = process.env.MAGIC_CONTEXT_TEST_DATA_DIR;
201
+ if (testDataDir) {
202
+ return path.join(testDataDir, "cortexkit", "magic-context");
203
+ }
204
+ if (process.env.NODE_ENV === "test") {
205
+ return getTestBackstopStorageDir();
206
+ }
207
+ }
172
208
  return path.join(getDataDir(), "cortexkit", "magic-context");
173
209
  }
174
210
 
211
+ let testBackstopStorageDir: string | null = null;
212
+ let testBackstopWarned = false;
213
+
214
+ /**
215
+ * Memoized per process so repeated calls in the same unisolated test resolve to
216
+ * the SAME path — `openDatabase()` caches by path, and a fresh temp dir per call
217
+ * would defeat that cache and hand back different DB handles.
218
+ */
219
+ function getTestBackstopStorageDir(): string {
220
+ if (!testBackstopStorageDir) {
221
+ testBackstopStorageDir = path.join(
222
+ mkdtempSync(path.join(os.tmpdir(), "mc-test-db-backstop-")),
223
+ "cortexkit",
224
+ "magic-context",
225
+ );
226
+ }
227
+ if (!testBackstopWarned) {
228
+ testBackstopWarned = true;
229
+ // Deliberately console, not the logger: logger.ts imports this module.
230
+ console.warn(
231
+ "[magic-context] TEST BACKSTOP: NODE_ENV=test with no MAGIC_CONTEXT_TEST_DATA_DIR " +
232
+ `— redirecting storage to a throwaway temp dir (${testBackstopStorageDir}) so no ` +
233
+ "test can touch the user's real shared database. Wire `[test] preload` in this " +
234
+ "package's bunfig.toml.",
235
+ );
236
+ }
237
+ return testBackstopStorageDir;
238
+ }
239
+
175
240
  /**
176
241
  * Legacy magic-context storage directory used by the OpenCode plugin before the
177
242
  * shared cortexkit path. Used only for one-time migration of existing data into
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, it } from "bun:test";
2
- import { piModelRefToCanonical, resolveModelRefForPi } from "./harness-provider-map";
2
+ import {
3
+ ompModelRefToCanonical,
4
+ piModelRefToCanonical,
5
+ resolveModelRefForOmp,
6
+ resolveModelRefForPi,
7
+ } from "./harness-provider-map";
3
8
 
4
9
  describe("harness-provider-map", () => {
5
10
  describe("resolveModelRefForPi (canonical -> Pi, used when spawning)", () => {
@@ -61,3 +66,39 @@ describe("harness-provider-map", () => {
61
66
  });
62
67
  });
63
68
  });
69
+
70
+ describe("OMP provider boundary", () => {
71
+ const representativeRefs = [
72
+ ["openai/gpt-5.5", "openai-codex/gpt-5.5"],
73
+ ["google/antigravity/gemini-3.5-flash", "google-antigravity/antigravity/gemini-3.5-flash"],
74
+ ["anthropic/claude-opus-4-8", "anthropic/claude-opus-4-8"],
75
+ ["@scope/provider/nested/model", "@scope/provider/nested/model"],
76
+ ] as const;
77
+
78
+ it.each(
79
+ representativeRefs,
80
+ )("round-trips canonical %s through OMP selector %s", (canonical, omp) => {
81
+ expect(resolveModelRefForOmp(canonical)).toBe(omp);
82
+ expect(ompModelRefToCanonical(omp)).toBe(canonical);
83
+ });
84
+
85
+ it("normalizes an already-native OMP selector idempotently", () => {
86
+ const selector = "openai-codex/team/nested/gpt-5.5";
87
+ expect(resolveModelRefForOmp(selector)).toBe(selector);
88
+ expect(resolveModelRefForOmp(ompModelRefToCanonical(selector))).toBe(selector);
89
+ });
90
+
91
+ it("passes through provider ids that collide with Object.prototype members", () => {
92
+ for (const ref of [
93
+ "constructor/model",
94
+ "toString/model",
95
+ "__proto__/model",
96
+ "hasOwnProperty/model",
97
+ ]) {
98
+ expect(resolveModelRefForOmp(ref)).toBe(ref);
99
+ expect(ompModelRefToCanonical(ref)).toBe(ref);
100
+ expect(resolveModelRefForPi(ref)).toBe(ref);
101
+ expect(piModelRefToCanonical(ref)).toBe(ref);
102
+ }
103
+ });
104
+ });
@@ -2,54 +2,66 @@
2
2
  * Provider-id translation between the canonical (OpenCode) form stored in the
3
3
  * shared magic-context config and Pi's harness-native provider ids.
4
4
  *
5
- * OpenCode and Pi now share ONE config, but a few auth-plugin providers were
6
- * named differently on each side. The model id AFTER the slash is identical;
7
- * only the provider prefix differs:
5
+ * Provider IDs are a harness boundary, not a database identity. Shared config
6
+ * always stores the canonical OpenCode form. Each non-OpenCode harness owns an
7
+ * explicit pair of edge transforms:
8
8
  *
9
- * canonical (OpenCode) Pi's preferred auth-plugin form
10
- * -------------------- ------------------------------
9
+ * canonical (OpenCode) Pi / OMP selector
10
+ * -------------------- -----------------
11
11
  * openai/<model> openai-codex/<model>
12
12
  * google/<model> google-antigravity/<model>
13
13
  * anthropic/<model> anthropic/<model> (same; every other provider too)
14
14
  *
15
- * The mapping is intentionally not a one-to-one provider identity. Pi also
16
- * exposes plain `openai` and `google` providers for direct API keys, while the
17
- * canonical prefix does not record whether a subscription or API-key backend
18
- * should win. Pi therefore starts with the preferred auth-plugin form at the
19
- * spawn boundary and subagent-runner may retry once with the untranslated
20
- * canonical form when that form reports missing credentials. The runner caches
21
- * the winning form per provider prefix in memory only; setup writes still use
22
- * the reverse mapping below so configs remain canonical.
15
+ * Pi and OMP currently expose the same two subscription-provider aliases, but
16
+ * their exported functions remain distinct deliberately. A future OMP catalog
17
+ * rename must not silently change plain-Pi behavior (or vice versa).
23
18
  *
24
- * Canonical = OpenCode: the config always stores the OpenCode form. Pi
25
- * translates at its edges:
26
- * - read: canonical -> Pi when spawning a configured model (subagent-runner),
27
- * with the runtime credential fallback described above.
28
- * - write: Pi -> canonical in the Pi setup wizard, so a config written from
29
- * the Pi side stays readable by OpenCode.
19
+ * The mapping is intentionally not a one-to-one provider identity. Both
20
+ * harnesses also expose plain `openai` and `google` providers for direct API
21
+ * keys, while the canonical prefix does not record whether a subscription or
22
+ * API-key backend should win. The runtime starts with the preferred
23
+ * subscription form and subagent-runner may retry once with the untranslated
24
+ * canonical form when that form reports missing credentials.
30
25
  *
31
- * OpenCode needs no translation (canonical IS the OpenCode form).
26
+ * Only the provider prefix before the first slash is translated. Model IDs,
27
+ * including nested IDs containing more slashes, are preserved byte-for-byte.
28
+ * Scoped or otherwise unknown provider prefixes are identities.
29
+ *
30
+ * OpenCode needs no translation because canonical is its native form.
32
31
  */
33
32
 
34
- const CANONICAL_TO_PI_PROVIDER: Record<string, string> = {
33
+ const CANONICAL_TO_PI_PROVIDER: Readonly<Record<string, string>> = {
35
34
  openai: "openai-codex",
36
35
  google: "google-antigravity",
37
36
  };
38
37
 
39
- const PI_TO_CANONICAL_PROVIDER: Record<string, string> = {
38
+ const PI_TO_CANONICAL_PROVIDER: Readonly<Record<string, string>> = {
39
+ "openai-codex": "openai",
40
+ "google-antigravity": "google",
41
+ };
42
+
43
+ const CANONICAL_TO_OMP_PROVIDER: Readonly<Record<string, string>> = {
44
+ openai: "openai-codex",
45
+ google: "google-antigravity",
46
+ };
47
+
48
+ const OMP_TO_CANONICAL_PROVIDER: Readonly<Record<string, string>> = {
40
49
  "openai-codex": "openai",
41
50
  "google-antigravity": "google",
42
51
  };
43
52
 
44
53
  /** Remap only the provider prefix (text before the first "/"), preserving the
45
- * model id verbatim. No "/", empty provider, or unmapped provider -> unchanged. */
46
- function remapProviderPrefix(ref: string, map: Record<string, string>): string {
54
+ * model id verbatim. No "/", empty provider, or unmapped provider -> unchanged.
55
+ * Lookups are own-property only, so provider ids that collide with
56
+ * `Object.prototype` members (`constructor/model`, `toString/model`) stay
57
+ * identities instead of becoming garbage prefixes. */
58
+ function remapProviderPrefix(ref: string, map: Readonly<Record<string, string>>): string {
47
59
  if (typeof ref !== "string") return ref;
48
60
  const slash = ref.indexOf("/");
49
61
  if (slash <= 0) return ref;
50
62
  const provider = ref.slice(0, slash);
51
- const mapped = map[provider];
52
- return mapped ? `${mapped}${ref.slice(slash)}` : ref;
63
+ if (!Object.hasOwn(map, provider)) return ref;
64
+ return `${map[provider]}${ref.slice(slash)}`;
53
65
  }
54
66
 
55
67
  /** Pi-native `provider/model` -> canonical (OpenCode). Identity when unmapped.
@@ -64,3 +76,13 @@ export function piModelRefToCanonical(ref: string): string {
64
76
  export function resolveModelRefForPi(ref: string): string {
65
77
  return remapProviderPrefix(piModelRefToCanonical(ref), CANONICAL_TO_PI_PROVIDER);
66
78
  }
79
+
80
+ /** OMP-native selector -> canonical shared-config model reference. */
81
+ export function ompModelRefToCanonical(ref: string): string {
82
+ return remapProviderPrefix(ref, OMP_TO_CANONICAL_PROVIDER);
83
+ }
84
+
85
+ /** Canonical shared-config model reference -> OMP-native selector. */
86
+ export function resolveModelRefForOmp(ref: string): string {
87
+ return remapProviderPrefix(ompModelRefToCanonical(ref), CANONICAL_TO_OMP_PROVIDER);
88
+ }
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ /**
5
+ * Shared accessors for the A1 prompt-surface golden.
6
+ *
7
+ * The golden (`prompt-surface-a1-golden.md`, generated by
8
+ * `scripts/export-agent-surface.ts`) is read by tests in BOTH packages, and its
9
+ * section headings are the contract between the generator and those readers.
10
+ * Keeping the path, the heading strings, and the offset guard here means a
11
+ * change to the golden's section format is a one-file edit rather than a hunt
12
+ * across packages.
13
+ *
14
+ * This module resolves the golden relative to ITSELF, so consumers never carry
15
+ * their own `../..` path arithmetic — that arithmetic differs per package and is
16
+ * exactly the kind of thing that rots silently when files move.
17
+ */
18
+
19
+ export const A1_TOOL_SECTION_HEADING = "## 2. Tool surface";
20
+ export const A1_HASH_BASELINE_HEADING = "## 3. System-prompt hash baseline";
21
+
22
+ export function readA1GoldenDocument(): string {
23
+ return readFileSync(join(import.meta.dir, "prompt-surface-a1-golden.md"), "utf8");
24
+ }
25
+
26
+ /**
27
+ * Locate a golden section heading, refusing to continue when it is absent.
28
+ *
29
+ * A bare `indexOf` returns -1 for a missing heading, and `slice(start, -1)`
30
+ * silently yields an empty (or truncated) section rather than failing: the
31
+ * golden then parses as ZERO tools and the consuming test passes by comparing
32
+ * empty to empty. Failing loudly here keeps a malformed golden from reading as a
33
+ * clean run.
34
+ */
35
+ export function a1GoldenSectionOffset(document: string, heading: string): number {
36
+ const offset = document.indexOf(heading);
37
+ if (offset === -1) {
38
+ throw new Error(`A1 golden is missing the "${heading}" section heading`);
39
+ }
40
+ return offset;
41
+ }
@@ -23,7 +23,12 @@ import {
23
23
  const GUIDANCE_MARKER = "## Magic Context";
24
24
  const GUIDANCE_MARKER_LINE = /^## Magic Context[\t ]*\r?$/gm;
25
25
 
26
- export const PROMPT_SURFACE_TOOL_IDS = [
26
+ /**
27
+ * The single source of truth for the ctx_* tools exposed by the prompt surface.
28
+ * Consumers derive their registries and light-description catalogs from this list
29
+ * so adding a tool cannot silently skip one prompt-surface integration point.
30
+ */
31
+ export const ACTIVE_TOOL_IDS = [
27
32
  "ctx_reduce",
28
33
  "ctx_expand",
29
34
  "ctx_note",
@@ -31,15 +36,18 @@ export const PROMPT_SURFACE_TOOL_IDS = [
31
36
  "ctx_search",
32
37
  ] as const;
33
38
 
34
- export type PromptSurfaceToolId = (typeof PROMPT_SURFACE_TOOL_IDS)[number];
39
+ /** @deprecated Use ACTIVE_TOOL_IDS. Kept as an alias for existing consumers. */
40
+ export const PROMPT_SURFACE_TOOL_IDS = ACTIVE_TOOL_IDS;
41
+
42
+ export type PromptSurfaceToolId = (typeof ACTIVE_TOOL_IDS)[number];
35
43
 
36
- export const LIGHT_TOOL_DESCRIPTIONS: Readonly<Record<PromptSurfaceToolId, string>> = {
44
+ export const LIGHT_TOOL_DESCRIPTIONS = {
37
45
  ctx_reduce: CTX_REDUCE_LIGHT_DESCRIPTION,
38
46
  ctx_expand: CTX_EXPAND_LIGHT_DESCRIPTION,
39
47
  ctx_note: CTX_NOTE_LIGHT_DESCRIPTION,
40
48
  ctx_memory: CTX_MEMORY_LIGHT_DESCRIPTION,
41
49
  ctx_search: CTX_SEARCH_LIGHT_DESCRIPTION,
42
- };
50
+ } as const satisfies Readonly<Record<PromptSurfaceToolId, string>>;
43
51
 
44
52
  /**
45
53
  * Preserve the existing full-preset hash exactly while giving other presets a
@@ -54,7 +62,7 @@ export function promptSurfaceHashMaterial(
54
62
  : `${systemContent}\n\0magic-context-prompt-surface:${preset}`;
55
63
  }
56
64
 
57
- const PROMPT_SURFACE_TOOL_ID_SET = new Set<string>(PROMPT_SURFACE_TOOL_IDS);
65
+ const PROMPT_SURFACE_TOOL_ID_SET = new Set<string>(ACTIVE_TOOL_IDS);
58
66
 
59
67
  export interface PromptSurfaceGuidanceSelection {
60
68
  /** The configured built-in preset. */
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { loadSqliteModule, SqliteRuntimeUnavailableError } from "./sqlite";
3
+
4
+ describe("SQLite runtime selector", () => {
5
+ it("wraps a missing node:sqlite module with the detected runtime and cause", async () => {
6
+ const cause = Object.assign(new Error("No such built-in module: node:sqlite"), {
7
+ code: "ERR_UNKNOWN_BUILTIN_MODULE",
8
+ name: "ResolveMessage",
9
+ });
10
+ let requestedSpecifier = "";
11
+ let thrown: unknown;
12
+
13
+ try {
14
+ await loadSqliteModule("Node.js", async (specifier) => {
15
+ requestedSpecifier = specifier;
16
+ throw cause;
17
+ });
18
+ } catch (error) {
19
+ thrown = error;
20
+ }
21
+
22
+ expect(requestedSpecifier).toBe("node:sqlite");
23
+ expect(thrown).toBeInstanceOf(SqliteRuntimeUnavailableError);
24
+ const compatibilityError = thrown as SqliteRuntimeUnavailableError;
25
+ expect(compatibilityError.runtime).toBe("Node.js");
26
+ expect(compatibilityError.specifier).toBe("node:sqlite");
27
+ expect(compatibilityError.message).toContain("Node.js >= 24");
28
+ expect(compatibilityError.message).toContain("Bun with bun:sqlite");
29
+ expect(compatibilityError.message).toContain("node:sqlite");
30
+ expect(compatibilityError.cause).toBe(cause);
31
+ });
32
+ });