@cortexkit/opencode-magic-context 0.39.0 → 0.40.1

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 (66) hide show
  1. package/README.md +20 -0
  2. package/dist/agents/permissions.d.ts +5 -3
  3. package/dist/agents/permissions.d.ts.map +1 -1
  4. package/dist/config/index.d.ts.map +1 -1
  5. package/dist/config/profiles.d.ts +22 -0
  6. package/dist/config/profiles.d.ts.map +1 -0
  7. package/dist/config/project-security.d.ts +3 -0
  8. package/dist/config/project-security.d.ts.map +1 -1
  9. package/dist/config/schema/magic-context.d.ts +400 -0
  10. package/dist/config/schema/magic-context.d.ts.map +1 -1
  11. package/dist/features/magic-context/fail-closed-block.d.ts +11 -1
  12. package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
  13. package/dist/features/magic-context/memory/embedding-failure.d.ts +11 -0
  14. package/dist/features/magic-context/memory/embedding-failure.d.ts.map +1 -0
  15. package/dist/features/magic-context/memory/embedding-local.d.ts +19 -0
  16. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  17. package/dist/features/magic-context/memory/embedding-openai.d.ts +5 -0
  18. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  19. package/dist/features/magic-context/memory/embedding-provider.d.ts +3 -0
  20. package/dist/features/magic-context/memory/embedding-provider.d.ts.map +1 -1
  21. package/dist/features/magic-context/project-embedding-registry.d.ts +2 -0
  22. package/dist/features/magic-context/project-embedding-registry.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/transform-decision-log.d.ts.map +1 -1
  26. package/dist/hooks/magic-context/format-embed-failure.d.ts +3 -0
  27. package/dist/hooks/magic-context/format-embed-failure.d.ts.map +1 -0
  28. package/dist/hooks/magic-context/inject-compartments.d.ts +4 -3
  29. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  30. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
  31. package/dist/hooks/magic-context/strip-content.d.ts +6 -0
  32. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  33. package/dist/hooks/magic-context/temporal-awareness.d.ts +1 -1
  34. package/dist/hooks/magic-context/temporal-awareness.d.ts.map +1 -1
  35. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +1 -0
  36. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  37. package/dist/{index-k2ts0wk1.js → index-x64cmpej.js} +507 -89
  38. package/dist/index.js +128 -166
  39. package/dist/plugin/hooks/create-session-hooks.d.ts +2 -0
  40. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  41. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  42. package/dist/{recomp-orchestrator-2pncxee7.js → recomp-orchestrator-dtgww0f0.js} +1 -1
  43. package/dist/shared/rpc-types.d.ts +2 -0
  44. package/dist/shared/rpc-types.d.ts.map +1 -1
  45. package/dist/shared/rpc-utils.d.ts +12 -1
  46. package/dist/shared/rpc-utils.d.ts.map +1 -1
  47. package/dist/shared/status-detail-text.d.ts.map +1 -1
  48. package/dist/tui/data/context-db.d.ts.map +1 -1
  49. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  50. package/package.json +2 -1
  51. package/src/agents/permissions.ts +14 -4
  52. package/src/config/index.test.ts +361 -2
  53. package/src/config/index.ts +29 -10
  54. package/src/config/profiles.ts +106 -0
  55. package/src/config/project-security.hostile-matrix.test.ts +9 -0
  56. package/src/config/project-security.test.ts +17 -0
  57. package/src/config/project-security.ts +10 -0
  58. package/src/config/schema/magic-context.test.ts +128 -0
  59. package/src/config/schema/magic-context.ts +89 -0
  60. package/src/shared/rpc-types.ts +2 -0
  61. package/src/shared/rpc-utils.test.ts +32 -0
  62. package/src/shared/rpc-utils.ts +38 -13
  63. package/src/shared/status-detail-text.test.ts +2 -0
  64. package/src/shared/status-detail-text.ts +1 -0
  65. package/src/tui/data/context-db.ts +1 -0
  66. package/src/tui-compiled/data/context-db.ts +1 -0
@@ -285,6 +285,134 @@ describe("MagicContextConfigSchema", () => {
285
285
  });
286
286
  });
287
287
 
288
+ describe("config profiles", () => {
289
+ it("admits model-selection fields and preserves per-entry harness qualifiers", () => {
290
+ const result = MagicContextConfigSchema.parse({
291
+ profile: "work",
292
+ profiles: {
293
+ work: {
294
+ historian: {
295
+ opencode: {
296
+ model: { model: "anthropic/work-historian", variant: "high" },
297
+ fallback_models: [
298
+ { model: "openai/work-fallback", variant: "low" },
299
+ ],
300
+ variant: "high",
301
+ },
302
+ pi: {
303
+ model: {
304
+ model: "github-copilot/work-historian",
305
+ thinking_level: "high",
306
+ },
307
+ thinking_level: "high",
308
+ },
309
+ },
310
+ dreamer: {
311
+ opencode: {
312
+ model: { model: "anthropic/work-dreamer", variant: "medium" },
313
+ fallback_models: [
314
+ { model: "openai/work-dreamer-fallback", variant: "low" },
315
+ ],
316
+ variant: "medium",
317
+ },
318
+ pi: {
319
+ model: {
320
+ model: "github-copilot/work-dreamer",
321
+ thinking_level: "high",
322
+ },
323
+ fallback_models: [
324
+ {
325
+ model: "openai/work-dreamer-fallback",
326
+ thinking_level: "minimal",
327
+ },
328
+ ],
329
+ thinking_level: "high",
330
+ },
331
+ },
332
+ sidekick: {
333
+ model: "anthropic/work-sidekick",
334
+ fallback_models: ["openai/work-sidekick-fallback"],
335
+ },
336
+ },
337
+ },
338
+ });
339
+
340
+ expect(result.profiles?.work?.historian?.opencode?.fallback_models).toEqual([
341
+ { model: "openai/work-fallback", variant: "low" },
342
+ ]);
343
+ expect(result.profiles?.work?.dreamer?.opencode).toEqual({
344
+ model: { model: "anthropic/work-dreamer", variant: "medium" },
345
+ fallback_models: [{ model: "openai/work-dreamer-fallback", variant: "low" }],
346
+ variant: "medium",
347
+ });
348
+ expect(result.profiles?.work?.dreamer?.pi?.fallback_models).toEqual([
349
+ { model: "openai/work-dreamer-fallback", thinking_level: "minimal" },
350
+ ]);
351
+ expect(result.profiles?.work?.sidekick?.model).toBe("anthropic/work-sidekick");
352
+ });
353
+
354
+ it("rejects timeout_minutes in a dreamer profile", () => {
355
+ const profiles = {
356
+ work: {
357
+ dreamer: {
358
+ opencode: {
359
+ tasks: { verify: { timeout_minutes: 30 } },
360
+ },
361
+ },
362
+ },
363
+ };
364
+
365
+ expect(MagicContextConfigSchema.safeParse({ profiles }).success).toBe(false);
366
+ });
367
+
368
+ it("rejects task execution and every other non-model field class in profiles", () => {
369
+ const profilesWithExcludedFields = [
370
+ { work: { embedding: { provider: "off" } } },
371
+ { work: { historian: { two_pass: true } } },
372
+ { work: { historian: { opencode: { prompt: "override" } } } },
373
+ { work: { dreamer: { opencode: { tasks: {} } } } },
374
+ {
375
+ work: {
376
+ dreamer: {
377
+ opencode: {
378
+ tasks: {
379
+ verify: {
380
+ model: "anthropic/task-model",
381
+ fallback_models: ["openai/task-fallback"],
382
+ variant: "high",
383
+ timeout_minutes: 30,
384
+ },
385
+ },
386
+ },
387
+ },
388
+ },
389
+ },
390
+ {
391
+ work: {
392
+ dreamer: {
393
+ pi: {
394
+ tasks: {
395
+ verify: {
396
+ model: "github-copilot/task-model",
397
+ fallback_models: ["openai/task-fallback"],
398
+ thinking_level: "high",
399
+ timeout_minutes: 30,
400
+ },
401
+ },
402
+ },
403
+ },
404
+ },
405
+ },
406
+ { work: { dreamer: { tasks: { verify: { schedule: "0 3 * * *" } } } } },
407
+ { work: { sidekick: { timeout_ms: 60_000 } } },
408
+ ];
409
+
410
+ for (const profiles of profilesWithExcludedFields) {
411
+ expect(MagicContextConfigSchema.safeParse({ profiles }).success).toBe(false);
412
+ }
413
+ });
414
+ });
415
+
288
416
  describe("per-harness model configuration", () => {
289
417
  it("keeps the migration inventory exhaustive and field-specific", () => {
290
418
  expect(PER_HARNESS_MIGRATION_INVENTORY).toEqual({
@@ -310,6 +310,80 @@ export const DreamerPiHarnessBlockSchema = z
310
310
  .describe("Strict Pi dreamer model-resolution block. It accepts no OpenCode vocabulary.");
311
311
  export type DreamerPiHarnessBlock = z.infer<typeof DreamerPiHarnessBlockSchema>;
312
312
 
313
+ /**
314
+ * A profile may select models but must not alter execution policy. Keep this
315
+ * separate from the full harness schemas, whose dreamer blocks also admit task
316
+ * overrides such as timeout_minutes.
317
+ */
318
+ const ProfileOpenCodeModelBlockSchema = z
319
+ .object({
320
+ model: OcEntrySchema.optional().describe("Primary OpenCode model entry."),
321
+ fallback_models: z
322
+ .array(OcEntrySchema)
323
+ .optional()
324
+ .describe("Ordered fallback OpenCode model entries."),
325
+ variant: z
326
+ .string()
327
+ .optional()
328
+ .describe("OpenCode reasoning variant for the primary model entry."),
329
+ })
330
+ .strict()
331
+ .describe("Strict profile-only OpenCode model-selection block.");
332
+ const ProfilePiModelBlockSchema = z
333
+ .object({
334
+ model: PiEntrySchema.optional().describe("Primary Pi model entry."),
335
+ fallback_models: z
336
+ .array(PiEntrySchema)
337
+ .optional()
338
+ .describe("Ordered fallback Pi model entries."),
339
+ thinking_level: PiThinkingLevelSchema.describe(
340
+ "Pi thinking level for the primary model entry.",
341
+ ),
342
+ })
343
+ .strict()
344
+ .describe("Strict profile-only Pi model-selection block.");
345
+ const ProfileHistorianSchema = z
346
+ .object({
347
+ opencode: ProfileOpenCodeModelBlockSchema.optional(),
348
+ pi: ProfilePiModelBlockSchema.optional(),
349
+ })
350
+ .strict();
351
+ const ProfileDreamerSchema = z
352
+ .object({
353
+ opencode: ProfileOpenCodeModelBlockSchema.optional(),
354
+ pi: ProfilePiModelBlockSchema.optional(),
355
+ })
356
+ .strict();
357
+ const ProfileSidekickSchema = AgentOverrideConfigSchema.pick({
358
+ model: true,
359
+ fallback_models: true,
360
+ variant: true,
361
+ })
362
+ .extend({
363
+ thinking_level: PiThinkingLevelSchema.describe(
364
+ "Pi thinking level for the sidekick model selection.",
365
+ ),
366
+ })
367
+ .strict();
368
+
369
+ export const ConfigProfileSchema = z
370
+ .object({
371
+ historian: ProfileHistorianSchema.optional(),
372
+ dreamer: ProfileDreamerSchema.optional(),
373
+ sidekick: ProfileSidekickSchema.optional(),
374
+ })
375
+ .strict()
376
+ .describe(
377
+ "User-owned model-selection overlay. Only historian/dreamer harness model blocks and sidekick model-selection fields are allowed.",
378
+ );
379
+ export type ConfigProfile = z.infer<typeof ConfigProfileSchema>;
380
+
381
+ export const ConfigProfilesSchema = z.record(
382
+ z.string().trim().min(1, "Profile names must not be empty or whitespace-only."),
383
+ ConfigProfileSchema,
384
+ );
385
+ export type ConfigProfiles = z.infer<typeof ConfigProfilesSchema>;
386
+
313
387
  /** A 5-field cron expression, or "" to disable the task. */
314
388
  const CronScheduleSchema = z
315
389
  .string()
@@ -672,6 +746,10 @@ export interface MagicContextConfig {
672
746
  auto_update?: boolean;
673
747
  /** Output language for generated Magic Context prose. USER config only. */
674
748
  language?: string;
749
+ /** Active user-owned model profile after user/project resolution. */
750
+ profile?: string;
751
+ /** Named user-owned model profiles. Declarations are consumed during resolution. */
752
+ profiles?: ConfigProfiles;
675
753
  historian?: HistorianConfig;
676
754
  dreamer?: DreamerConfig;
677
755
  smart_notes: {
@@ -892,6 +970,17 @@ export const MagicContextConfigSchema = z
892
970
  "triggers one cache re-materialization; existing compartments/memories keep their " +
893
971
  "original language until naturally rewritten.",
894
972
  ),
973
+ profile: z
974
+ .string()
975
+ .trim()
976
+ .min(1)
977
+ .optional()
978
+ .describe(
979
+ "Select a named user-owned model profile. A valid project name overrides this user default; an empty string, null, or other non-string project value is ignored with a warning so the user selection still applies. Unknown names warn and use the base configuration.",
980
+ ),
981
+ profiles: ConfigProfilesSchema.optional().describe(
982
+ "User-level named model profiles. A profile may contain only historian/dreamer model, fallback_models, OpenCode variant, and Pi thinking_level fields plus sidekick model-selection fields; task execution policy (including timeout_minutes) is excluded. Project configs may select a name but cannot define profiles.",
983
+ ),
895
984
  historian: HistorianConfigSchema.describe(
896
985
  "Historian metadata plus independent strict OpenCode and Pi execution blocks. Retained metadata stays at historian; model, fallback_models, variant, and thinking_level belong only in historian.opencode or historian.pi.",
897
986
  ),
@@ -141,6 +141,8 @@ export interface SidebarSnapshot {
141
141
  }
142
142
 
143
143
  export interface StatusDetail extends SidebarSnapshot {
144
+ /** User-owned model profile selected for this project, or null for the base config. */
145
+ activeProfile: string | null;
144
146
  tagCounter: number;
145
147
  activeTags: number;
146
148
  droppedTags: number;
@@ -3,6 +3,7 @@
3
3
  import { afterEach, describe, expect, test } from "bun:test";
4
4
  import type { execFileSync } from "node:child_process";
5
5
  import type { readFileSync } from "node:fs";
6
+
6
7
  import {
7
8
  __resetRpcIdentityTestHooks,
8
9
  __setRpcIdentityTestHooks,
@@ -12,6 +13,7 @@ import {
12
13
  isPidAlive,
13
14
  isPidIdentityPlausible,
14
15
  type RpcPortFileRecord,
16
+ readProcessProbeEvidence,
15
17
  } from "./rpc-utils";
16
18
 
17
19
  const PID = 1234;
@@ -210,6 +212,36 @@ describe("isPidAlive", () => {
210
212
  });
211
213
  });
212
214
 
215
+ describe("readProcessProbeEvidence", () => {
216
+ test("captures the same Linux start-time and command probes used by the guard", () => {
217
+ __setRpcIdentityTestHooks({
218
+ platform: "linux",
219
+ nowMs: () => NOW_MS,
220
+ readFileSync: linuxFiles({
221
+ [`/proc/${PID}/stat`]: procStat(10_000),
222
+ "/proc/uptime": `${UPTIME_SECONDS}.0 0.0`,
223
+ [`/proc/${PID}/cmdline`]:
224
+ "/usr/local/bin/opencode\u0000--directory\u0000/home/alice/project",
225
+ }),
226
+ });
227
+
228
+ expect(readProcessProbeEvidence(PID)).toEqual({
229
+ startTime: 1_100_000,
230
+ commandLine: "/usr/local/bin/opencode\u0000--directory\u0000/home/alice/project",
231
+ });
232
+ });
233
+
234
+ test("returns unavailable fields instead of throwing when probes fail", () => {
235
+ __setRpcIdentityTestHooks({
236
+ platform: "linux",
237
+ readFileSync: linuxFiles({}),
238
+ });
239
+
240
+ expect(() => readProcessProbeEvidence(PID)).not.toThrow();
241
+ expect(readProcessProbeEvidence(PID)).toEqual({ startTime: null, commandLine: null });
242
+ });
243
+ });
244
+
213
245
  describe("isPidIdentityPlausible", () => {
214
246
  test("rejects a reused Linux PID when proc start time is substantially newer", () => {
215
247
  const readPaths: string[] = [];
@@ -5,6 +5,14 @@ import { join } from "node:path";
5
5
 
6
6
  export type ProcessKind = "OpenCode server" | "OpenCode instance (TUI/CLI)" | "Pi" | "process";
7
7
 
8
+ /** Best-effort process details captured while validating a migration blocker. */
9
+ export interface ProcessProbeEvidence {
10
+ /** Epoch milliseconds, or null when the platform/probe cannot provide it. */
11
+ startTime: number | null;
12
+ /** The raw command line, or null when the platform/probe cannot provide it. */
13
+ commandLine: string | null;
14
+ }
15
+
8
16
  export interface RpcPortFileRecord {
9
17
  port: number;
10
18
  pid: number;
@@ -143,6 +151,24 @@ function readPsProcessStartTime(pid: number): number | null {
143
151
  }
144
152
  }
145
153
 
154
+ /** Read a process start time without changing the guard's liveness decision. */
155
+ export function readProcessStartTime(pid: number): number | null {
156
+ if (!Number.isInteger(pid) || pid <= 0) return null;
157
+ return rpcIdentityPlatform === "linux"
158
+ ? readLinuxProcessStartTime(pid)
159
+ : rpcIdentityPlatform === "win32"
160
+ ? null
161
+ : readPsProcessStartTime(pid);
162
+ }
163
+
164
+ /** Capture the command and start-time probes used to explain a blocker. */
165
+ export function readProcessProbeEvidence(pid: number): ProcessProbeEvidence {
166
+ return {
167
+ startTime: readProcessStartTime(pid),
168
+ commandLine: readProcessCommand(pid),
169
+ };
170
+ }
171
+
146
172
  interface ProcessListEntry {
147
173
  pid: number;
148
174
  command: string;
@@ -319,28 +345,27 @@ function commandLooksLikeOpenCode(command: string): boolean {
319
345
  */
320
346
  export type PidIdentityPlausibility = "plausible" | "implausible" | "inconclusive";
321
347
 
322
- export function isPidIdentityPlausible(record: RpcPortFileRecord): PidIdentityPlausibility {
348
+ export function isPidIdentityPlausible(
349
+ record: RpcPortFileRecord,
350
+ evidence?: ProcessProbeEvidence,
351
+ ): PidIdentityPlausibility {
323
352
  if (!Number.isInteger(record.pid) || record.pid <= 0) return "implausible";
324
353
 
325
354
  if (Number.isFinite(record.started_at) && record.started_at > 0) {
326
- const processStartTime =
327
- rpcIdentityPlatform === "linux"
328
- ? readLinuxProcessStartTime(record.pid)
329
- : rpcIdentityPlatform === "win32"
330
- ? null
331
- : readPsProcessStartTime(record.pid);
355
+ const processStartTime = evidence ? evidence.startTime : readProcessStartTime(record.pid);
332
356
  if (processStartTime === null) return "inconclusive";
333
357
  return processStartTime <= record.started_at + RPC_IDENTITY_SKEW_TOLERANCE_MS
334
358
  ? "plausible"
335
359
  : "implausible";
336
360
  }
337
361
 
338
- const command =
339
- rpcIdentityPlatform === "linux"
340
- ? readLinuxProcessCommand(record.pid)
341
- : rpcIdentityPlatform === "win32"
342
- ? (readWindowsProcess(record.pid).command ?? null)
343
- : readPsProcessCommand(record.pid);
362
+ const command = evidence
363
+ ? evidence.commandLine
364
+ : rpcIdentityPlatform === "linux"
365
+ ? readLinuxProcessCommand(record.pid)
366
+ : rpcIdentityPlatform === "win32"
367
+ ? (readWindowsProcess(record.pid).command ?? null)
368
+ : readPsProcessCommand(record.pid);
344
369
  if (command === null) return "inconclusive";
345
370
  return commandLooksLikeOpenCode(command) ? "plausible" : "implausible";
346
371
  }
@@ -4,6 +4,7 @@ import { formatStatusDetailMarkdown } from "./status-detail-text";
4
4
 
5
5
  const STATUS_FIXTURE: StatusDetail = {
6
6
  sessionId: "ses_status",
7
+ activeProfile: "work",
7
8
  usagePercentage: 75,
8
9
  inputTokens: 96_000,
9
10
  contextLimit: 128_000,
@@ -70,6 +71,7 @@ describe("formatStatusDetailMarkdown", () => {
70
71
  expect(formatStatusDetailMarkdown(STATUS_FIXTURE)).toBe(`## Magic Context Status
71
72
 
72
73
  - **Mode:** Magic Context compaction
74
+ - **Active profile:** work
73
75
  - **Usage:** 75.0% (96,000 / 128,000 usable tokens)
74
76
  - **Cache lane:** live (42s remaining); TTL 5m
75
77
  - **Historian:** running; boundary present; coverage 12
@@ -35,6 +35,7 @@ export function formatStatusDetailMarkdown(detail: StatusDetail): string {
35
35
  "## Magic Context Status",
36
36
  "",
37
37
  `- **Mode:** ${mode}`,
38
+ `- **Active profile:** ${detail.activeProfile ?? "none"}`,
38
39
  `- **Usage:** ${detail.usagePercentage.toFixed(1)}% (${formatCount(detail.inputTokens)} / ${usableLimit})`,
39
40
  `- **Cache lane:** ${formatCacheLane(detail)}`,
40
41
  `- **Historian:** ${[historianState, ...historianDetails].join("; ")}`,
@@ -181,6 +181,7 @@ export async function loadStatusDetail(
181
181
  const emptyDetail: StatusDetail = {
182
182
  ...EMPTY_SNAPSHOT,
183
183
  sessionId,
184
+ activeProfile: null,
184
185
  tagCounter: 0,
185
186
  activeTags: 0,
186
187
  droppedTags: 0,
@@ -181,6 +181,7 @@ export async function loadStatusDetail(
181
181
  const emptyDetail: StatusDetail = {
182
182
  ...EMPTY_SNAPSHOT,
183
183
  sessionId,
184
+ activeProfile: null,
184
185
  tagCounter: 0,
185
186
  activeTags: 0,
186
187
  droppedTags: 0,