@cortexkit/opencode-magic-context 0.36.1 → 0.38.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 (176) hide show
  1. package/README.md +2 -0
  2. package/dist/config/index.d.ts.map +1 -1
  3. package/dist/config/project-security.d.ts +2 -1
  4. package/dist/config/project-security.d.ts.map +1 -1
  5. package/dist/config/schema/magic-context.d.ts +10 -0
  6. package/dist/config/schema/magic-context.d.ts.map +1 -1
  7. package/dist/{conflict-warning-hook-mt0ttc4x.js → conflict-warning-hook-an4fxhwf.js} +3 -3
  8. package/dist/{derive-budgets-88mxk2w1.js → derive-budgets-da4yg3tz.js} +3 -2
  9. package/dist/features/magic-context/context-authority.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/classify-prompt.d.ts +5 -1
  11. package/dist/features/magic-context/dreamer/classify-prompt.d.ts.map +1 -1
  12. package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
  13. package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
  14. package/dist/features/magic-context/dreamer/manifest-parser.d.ts +8 -0
  15. package/dist/features/magic-context/dreamer/manifest-parser.d.ts.map +1 -1
  16. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts +7 -1
  17. package/dist/features/magic-context/dreamer/map-memories-prompt.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/map-memories.d.ts +18 -0
  19. package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts +5 -0
  21. package/dist/features/magic-context/dreamer/storage-dream-runs.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  23. package/dist/features/magic-context/dreamer/verify-prompt.d.ts +5 -1
  24. package/dist/features/magic-context/dreamer/verify-prompt.d.ts.map +1 -1
  25. package/dist/features/magic-context/dreamer/verify.d.ts.map +1 -1
  26. package/dist/features/magic-context/fail-closed-block.d.ts +6 -1
  27. package/dist/features/magic-context/fail-closed-block.d.ts.map +1 -1
  28. package/dist/features/magic-context/memory/embedding-openai.d.ts +3 -1
  29. package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
  30. package/dist/features/magic-context/memory/project-identity.d.ts +6 -0
  31. package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
  32. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  33. package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
  34. package/dist/features/magic-context/reclaim-protection.d.ts +13 -0
  35. package/dist/features/magic-context/reclaim-protection.d.ts.map +1 -0
  36. package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
  37. package/dist/features/magic-context/smart-notes/wake-plane.d.ts +21 -0
  38. package/dist/features/magic-context/smart-notes/wake-plane.d.ts.map +1 -0
  39. package/dist/features/magic-context/storage-db.d.ts +19 -3
  40. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  41. package/dist/features/magic-context/storage-meta-persisted.d.ts +23 -0
  42. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  43. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  44. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  45. package/dist/features/magic-context/storage-primers.d.ts +11 -0
  46. package/dist/features/magic-context/storage-primers.d.ts.map +1 -1
  47. package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
  48. package/dist/features/magic-context/storage-tags.d.ts +13 -5
  49. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  50. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts +7 -0
  51. package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
  52. package/dist/features/magic-context/window-report-ledger.d.ts +70 -0
  53. package/dist/features/magic-context/window-report-ledger.d.ts.map +1 -0
  54. package/dist/hooks/auto-update-checker/checker.d.ts +8 -1
  55. package/dist/hooks/auto-update-checker/checker.d.ts.map +1 -1
  56. package/dist/hooks/auto-update-checker/index.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/channel2-cycle.d.ts +16 -0
  58. package/dist/hooks/magic-context/channel2-cycle.d.ts.map +1 -0
  59. package/dist/hooks/magic-context/channel2-delivery.d.ts +3 -9
  60. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/command-handler.d.ts +2 -0
  62. package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
  63. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +33 -103
  64. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  65. package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
  66. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  67. package/dist/hooks/magic-context/event-resolvers.d.ts +4 -0
  68. package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
  69. package/dist/hooks/magic-context/execute-status.d.ts +3 -1
  70. package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/fold-execution-gate.d.ts +2 -0
  72. package/dist/hooks/magic-context/fold-execution-gate.d.ts.map +1 -0
  73. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  74. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  75. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  76. package/dist/hooks/magic-context/live-session-state.d.ts +3 -0
  77. package/dist/hooks/magic-context/live-session-state.d.ts.map +1 -1
  78. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
  79. package/dist/hooks/magic-context/lkg-slot.d.ts +20 -0
  80. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  81. package/dist/hooks/magic-context/module-state-sync.d.ts +8 -0
  82. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  83. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/module-wire.d.ts +6 -1
  85. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
  86. package/dist/hooks/magic-context/rust-mode-transform.d.ts +21 -2
  87. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  88. package/dist/hooks/magic-context/strip-content.d.ts +27 -0
  89. package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
  90. package/dist/hooks/magic-context/supersession-reclaim.d.ts.map +1 -1
  91. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  92. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +78 -0
  93. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -0
  94. package/dist/hooks/magic-context/tool-reclaim.d.ts.map +1 -1
  95. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +14 -3
  96. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  97. package/dist/hooks/magic-context/transform.d.ts +10 -0
  98. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  99. package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
  100. package/dist/{index-nsx2n9eg.js → index-4cz5nfxf.js} +3 -3
  101. package/dist/{index-f1tnb3vm.js → index-8pqfcghm.js} +21 -9
  102. package/dist/index-9zhhvr4w.js +733 -0
  103. package/dist/{index-zp6x6s6a.js → index-cae89eas.js} +25 -6
  104. package/dist/index-cgyfn1s2.js +53 -0
  105. package/dist/{index-3zqcdwqr.js → index-crke4z5j.js} +7 -4
  106. package/dist/{index-1skzp4dc.js → index-mmvhrqqb.js} +1991 -274
  107. package/dist/{index-tsgs7td8.js → index-pck9vadr.js} +1 -1
  108. package/dist/{index-3tnkymva.js → index-sw8gnq34.js} +1 -1
  109. package/dist/index.d.ts.map +1 -1
  110. package/dist/index.js +1714 -988
  111. package/dist/plugin/hooks/create-session-hooks.d.ts +3 -0
  112. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  113. package/dist/plugin/messages-transform.d.ts.map +1 -1
  114. package/dist/plugin/rpc-handlers.d.ts +2 -0
  115. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  116. package/dist/{recomp-orchestrator-a0g9b52q.js → recomp-orchestrator-jsde4ygw.js} +7 -6
  117. package/dist/{safe-notification-target-c11fdda2.js → safe-notification-target-b693f9x3.js} +2 -2
  118. package/dist/{send-session-notification-bgtf4853.js → send-session-notification-dxske5zm.js} +3 -3
  119. package/dist/shared/conflict-detector.d.ts +63 -0
  120. package/dist/shared/conflict-detector.d.ts.map +1 -1
  121. package/dist/shared/harness-provider-map.d.ts +8 -0
  122. package/dist/shared/harness-provider-map.d.ts.map +1 -1
  123. package/dist/shared/logger.d.ts +8 -0
  124. package/dist/shared/logger.d.ts.map +1 -1
  125. package/dist/shared/models-dev-cache.d.ts +7 -0
  126. package/dist/shared/models-dev-cache.d.ts.map +1 -1
  127. package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
  128. package/dist/shared/prompt-surface.d.ts +2 -0
  129. package/dist/shared/prompt-surface.d.ts.map +1 -1
  130. package/dist/shared/rpc-client.d.ts.map +1 -1
  131. package/dist/shared/rpc-server.d.ts.map +1 -1
  132. package/dist/shared/rpc-types.d.ts +29 -0
  133. package/dist/shared/rpc-types.d.ts.map +1 -1
  134. package/dist/shared/rpc-utils.d.ts +28 -9
  135. package/dist/shared/rpc-utils.d.ts.map +1 -1
  136. package/dist/shared/tail-hygiene-status.d.ts +15 -0
  137. package/dist/shared/tail-hygiene-status.d.ts.map +1 -0
  138. package/dist/shared/window-geometry.d.ts +90 -0
  139. package/dist/shared/window-geometry.d.ts.map +1 -0
  140. package/dist/{storage-meta-session-df9tstzr.js → storage-meta-session-mk4gsnke.js} +4 -3
  141. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  142. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  143. package/dist/tui/data/context-db.d.ts.map +1 -1
  144. package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
  145. package/package.json +3 -1
  146. package/src/config/index.ts +2 -0
  147. package/src/config/latch-permanence-guard.test.ts +218 -0
  148. package/src/config/project-security.ts +10 -1
  149. package/src/config/schema/magic-context.ts +12 -2
  150. package/src/shared/__fixtures__/window-overlay.v1.json +337 -0
  151. package/src/shared/conflict-detector.test.ts +176 -1
  152. package/src/shared/conflict-detector.ts +126 -3
  153. package/src/shared/harness-provider-map.test.ts +20 -0
  154. package/src/shared/harness-provider-map.ts +19 -0
  155. package/src/shared/logger.test.ts +119 -0
  156. package/src/shared/logger.ts +33 -15
  157. package/src/shared/models-dev-cache.test.ts +42 -0
  158. package/src/shared/models-dev-cache.ts +62 -9
  159. package/src/shared/prompt-surface-runtime.ts +5 -3
  160. package/src/shared/prompt-surface.test.ts +38 -0
  161. package/src/shared/prompt-surface.ts +15 -2
  162. package/src/shared/rpc-client.ts +4 -2
  163. package/src/shared/rpc-server.ts +1 -0
  164. package/src/shared/rpc-types.ts +30 -0
  165. package/src/shared/rpc-utils.test.ts +162 -13
  166. package/src/shared/rpc-utils.ts +224 -31
  167. package/src/shared/tail-hygiene-status.ts +57 -0
  168. package/src/shared/window-geometry.test.ts +342 -0
  169. package/src/shared/window-geometry.ts +626 -0
  170. package/src/tui/data/context-db.ts +5 -0
  171. package/src/tui/index.tsx +33 -1
  172. package/src/tui/slots/sidebar-content.tsx +17 -5
  173. package/src/tui-compiled/data/context-db.ts +5 -0
  174. package/src/tui-compiled/index.tsx +359 -273
  175. package/src/tui-compiled/slots/sidebar-content.tsx +316 -287
  176. package/dist/index-wgvxkqpx.js +0 -342
@@ -1,6 +1,7 @@
1
1
  import { homedir } from "node:os";
2
2
  import { join } from "node:path";
3
3
  import { readJsoncFile } from "./jsonc-parser";
4
+ import { log } from "./logger";
4
5
  import { getOpenCodeConfigPaths } from "./opencode-config-dir";
5
6
 
6
7
  interface OpenCodeConfig {
@@ -51,6 +52,18 @@ export interface ConflictResult {
51
52
  };
52
53
  }
53
54
 
55
+ /**
56
+ * Resolved native compaction state, as reported by the host's own config
57
+ * resolution (ctx.client.config.get() — the same object `opencode debug
58
+ * config` prints). `auto`/`prune` are booleans; the OpenCode schema annotates
59
+ * `auto` with default `true` and `prune` with default `false`, so an absent
60
+ * compaction block resolves to `{ auto: true, prune: false }`.
61
+ */
62
+ export interface ResolvedCompaction {
63
+ auto: boolean;
64
+ prune: boolean;
65
+ }
66
+
54
67
  /**
55
68
  * Options for {@link detectConflicts}.
56
69
  *
@@ -63,6 +76,13 @@ export interface ConflictResult {
63
76
  * omit it and accept the default `true` (mode-on) behavior, which preserves
64
77
  * today's conflict semantics; it must never silently skip the check.
65
78
  *
79
+ * `resolvedCompaction` is the host's RESOLVED native compaction state
80
+ * (fetched via {@link resolveCompactionForBoot}). When present, the
81
+ * file-based {@link checkCompaction} is NOT called — the resolved values are
82
+ * used directly. Absent means the file-based fallback is used. The
83
+ * OPENCODE_DISABLE_AUTOCOMPACT env short-circuit is applied on top of
84
+ * whichever arm produced the value.
85
+ *
66
86
  * When `compactionEnabled` is `false` (compaction-off mode), OpenCode
67
87
  * `compaction.auto=true` / `compaction.prune=true` are NOT plugin-disabling
68
88
  * conflicts — native compaction is the user's chosen window manager. DCP
@@ -71,6 +91,7 @@ export interface ConflictResult {
71
91
  */
72
92
  export interface DetectConflictsOptions {
73
93
  compactionEnabled?: boolean;
94
+ resolvedCompaction?: ResolvedCompaction;
74
95
  }
75
96
 
76
97
  /**
@@ -81,6 +102,11 @@ export interface DetectConflictsOptions {
81
102
  * When `false` (compaction-off mode), native `compaction.auto`/`prune` are
82
103
  * reported in {@link ConflictResult.nativeCompaction} but are NOT flagged as
83
104
  * conflicts — native compaction is the intended window manager in that mode.
105
+ *
106
+ * `resolvedCompaction` (optional) is the host's RESOLVED native compaction
107
+ * state from {@link resolveCompactionForBoot}. When present it is used
108
+ * directly and the file-based {@link checkCompaction} is skipped; when absent
109
+ * the file-based fallback runs unchanged.
84
110
  */
85
111
  export function detectConflicts(
86
112
  directory: string,
@@ -98,18 +124,38 @@ export function detectConflicts(
98
124
  const reasons: string[] = [];
99
125
 
100
126
  // --- Check OpenCode compaction config ---
101
- const compactionResult = checkCompaction(directory);
127
+ // The host's resolved config is the authority when available (issue #309:
128
+ // the file-based re-derivation defaults to auto=true when no file resolves,
129
+ // wrongly disabling the plugin for users whose auto=false lives in a layer
130
+ // the file reader cannot see). When the resolved fetch failed, fall back to
131
+ // the file-based check unchanged.
132
+ let compactionResult = options?.resolvedCompaction ?? checkCompaction(directory);
133
+ // OPENCODE_DISABLE_AUTOCOMPACT short-circuits BOTH arms: it is the first,
134
+ // cheapest check and is correct regardless of which arm produced the value.
135
+ // (checkCompaction already applies it internally for the file arm; this
136
+ // covers the resolved arm, which skips checkCompaction entirely.)
137
+ if (process.env.OPENCODE_DISABLE_AUTOCOMPACT) {
138
+ compactionResult = { auto: false, prune: false };
139
+ }
102
140
  // Native compaction is a conflict ONLY when MC compaction is ON. In
103
141
  // compaction-off mode the user has explicitly handed the window to native
104
142
  // compaction (or nothing), so compaction.auto=true / prune=true are the
105
143
  // intended state, not a plugin-disabling conflict.
106
144
  if (compactionEnabled && compactionResult.auto) {
107
145
  conflicts.compactionAuto = true;
108
- reasons.push("OpenCode auto-compaction is enabled (compaction.auto=true)");
146
+ reasons.push(
147
+ options?.resolvedCompaction
148
+ ? "OpenCode auto-compaction is enabled (compaction.auto=true) (resolved config)"
149
+ : "OpenCode auto-compaction is enabled (compaction.auto=true)",
150
+ );
109
151
  }
110
152
  if (compactionEnabled && compactionResult.prune) {
111
153
  conflicts.compactionPrune = true;
112
- reasons.push("OpenCode prune is enabled (compaction.prune=true)");
154
+ reasons.push(
155
+ options?.resolvedCompaction
156
+ ? "OpenCode prune is enabled (compaction.prune=true) (resolved config)"
157
+ : "OpenCode prune is enabled (compaction.prune=true)",
158
+ );
113
159
  }
114
160
 
115
161
  // --- Check for DCP plugin ---
@@ -152,6 +198,83 @@ export function detectConflicts(
152
198
 
153
199
  // --- Compaction detection (extracted from opencode-compaction-detector.ts) ---
154
200
 
201
+ /**
202
+ * Minimal shape of the OpenCode SDK client's `config.get()` response. The
203
+ * SDK's generated `Config` type does not declare a `compaction` field (the
204
+ * schema lives in OpenCode's core config, not the SDK surface), so we read it
205
+ * defensively at runtime. `config.get()` returns a `RequestResult` whose
206
+ * `data` is the resolved config object — the same object `opencode debug
207
+ * config` prints. The `data` is typed as `unknown` here so the real SDK client
208
+ * (whose `Config` has no `compaction` key) is structurally assignable.
209
+ */
210
+ export interface OpencodeConfigClientLike {
211
+ config: {
212
+ get: () => Promise<{ data?: unknown }>;
213
+ };
214
+ }
215
+
216
+ /**
217
+ * Shape of the `compaction` block inside the resolved config, read defensively
218
+ * from the SDK response at runtime.
219
+ */
220
+ interface ResolvedCompactionBlock {
221
+ compaction?: {
222
+ auto?: boolean;
223
+ prune?: boolean;
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Fetch the host's RESOLVED native compaction state from the OpenCode SDK
229
+ * client (`ctx.client.config.get()`). This is the authority for the plugin's
230
+ * conflict decision (issue #309): the file-based re-derivation cannot see
231
+ * every layer OpenCode folds in (env-var config path, managed configs,
232
+ * multi-file merge), so any user whose `auto=false` lives in a layer we don't
233
+ * read would be wrongly flagged. We never re-derive what the host will tell
234
+ * us.
235
+ *
236
+ * A response WITHOUT a compaction block is INCONCLUSIVE, not "host defaults
237
+ * apply": a server whose `/config` shape drifted (OpenCode Desktop bundles
238
+ * its own server version) or a fetch racing boot returns data with no
239
+ * `compaction` key, and reading that absence as `auto=true` disables the
240
+ * plugin — the one wrong direction, because a false disable leaves NOTHING
241
+ * managing the window and every long session overflows (issue #309, second
242
+ * arm). Only an explicit boolean from the host resolves this arm; anything
243
+ * else returns `null` so the caller falls back to the file-based check,
244
+ * which reads the layers the user actually wrote.
245
+ *
246
+ * Returns `null` when the fetch fails, times out (bounded to `timeoutMs` so
247
+ * boot never hangs), or serves no explicit compaction block.
248
+ */
249
+ export async function resolveCompactionForBoot(
250
+ client: OpencodeConfigClientLike,
251
+ timeoutMs = 2_000,
252
+ ): Promise<ResolvedCompaction | null> {
253
+ try {
254
+ const result = await Promise.race([
255
+ client.config.get(),
256
+ new Promise<never>((_, reject) =>
257
+ setTimeout(() => reject(new Error("config.get() timed out")), timeoutMs),
258
+ ),
259
+ ]);
260
+ // The SDK's generated `Config` type has no `compaction` key, so read it
261
+ // defensively from the runtime response.
262
+ const compaction = (result?.data as ResolvedCompactionBlock | undefined)?.compaction;
263
+ // Explicit booleans only. An absent block (or non-boolean values) means
264
+ // the response shape did not carry the resolved state — fall back to the
265
+ // file arm rather than resolving to the plugin-disabling default.
266
+ if (typeof compaction?.auto !== "boolean" || typeof compaction?.prune !== "boolean") {
267
+ log(
268
+ `[magic-context] conflict-detector: resolved config carried no explicit compaction block (${JSON.stringify(compaction) ?? "absent"}); falling back to file-based detection`,
269
+ );
270
+ return null;
271
+ }
272
+ return { auto: compaction.auto, prune: compaction.prune };
273
+ } catch {
274
+ return null;
275
+ }
276
+ }
277
+
155
278
  function checkCompaction(directory: string): { auto: boolean; prune: boolean } {
156
279
  if (process.env.OPENCODE_DISABLE_AUTOCOMPACT) {
157
280
  return { auto: false, prune: false };
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
2
  import {
3
+ modelRefLookupOrder,
3
4
  ompModelRefToCanonical,
4
5
  piModelRefToCanonical,
5
6
  resolveModelRefForOmp,
@@ -64,6 +65,25 @@ describe("harness-provider-map", () => {
64
65
  const piForm = "openai-codex/gpt-5.5";
65
66
  expect(resolveModelRefForPi(piModelRefToCanonical(piForm))).toBe(piForm);
66
67
  });
68
+
69
+ describe("modelRefLookupOrder (config read edge)", () => {
70
+ it("tries canonical before the Pi-native spelling", () => {
71
+ expect(modelRefLookupOrder("openai-codex/gpt-5.6-sol")).toEqual([
72
+ "openai/gpt-5.6-sol",
73
+ "openai-codex/gpt-5.6-sol",
74
+ ]);
75
+ expect(modelRefLookupOrder("openai/gpt-5.6-sol")).toEqual([
76
+ "openai/gpt-5.6-sol",
77
+ "openai-codex/gpt-5.6-sol",
78
+ ]);
79
+ });
80
+
81
+ it("keeps unknown provider prefixes as a single passthrough key", () => {
82
+ expect(modelRefLookupOrder("custom-provider/model")).toEqual([
83
+ "custom-provider/model",
84
+ ]);
85
+ });
86
+ });
67
87
  });
68
88
  });
69
89
 
@@ -77,6 +77,25 @@ export function resolveModelRefForPi(ref: string): string {
77
77
  return remapProviderPrefix(piModelRefToCanonical(ref), CANONICAL_TO_PI_PROVIDER);
78
78
  }
79
79
 
80
+ /**
81
+ * Return every known spelling of a model reference with the canonical shared
82
+ * form first. The raw input remains the first fallback, so a single config file
83
+ * works on every harness: canonical wins when both spellings are present, while
84
+ * Pi/OMP-native provider ids are still accepted at the read edge. Unknown
85
+ * providers pass through unchanged and therefore produce one candidate.
86
+ */
87
+ export function modelRefLookupOrder(ref: string): string[] {
88
+ const canonical = piModelRefToCanonical(ompModelRefToCanonical(ref));
89
+ return [
90
+ ...new Set([
91
+ canonical,
92
+ ref,
93
+ resolveModelRefForPi(canonical),
94
+ resolveModelRefForOmp(canonical),
95
+ ]),
96
+ ];
97
+ }
98
+
80
99
  /** OMP-native selector -> canonical shared-config model reference. */
81
100
  export function ompModelRefToCanonical(ref: string): string {
82
101
  return remapProviderPrefix(ref, OMP_TO_CANONICAL_PROVIDER);
@@ -0,0 +1,119 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import * as os from "node:os";
4
+ import * as path from "node:path";
5
+
6
+ type LoggerScenarioResult = {
7
+ exists: boolean;
8
+ content: string;
9
+ healthyDiagnostics: {
10
+ swallowedWriteCount: number;
11
+ lastErrorMessage: string | null;
12
+ lastErrorTime: string | null;
13
+ };
14
+ failedDiagnostics: {
15
+ swallowedWriteCount: number;
16
+ lastErrorMessage: string | null;
17
+ lastErrorTime: string | null;
18
+ };
19
+ };
20
+
21
+ const loggerScenario = `
22
+ import { existsSync, readFileSync } from "node:fs";
23
+ import * as path from "node:path";
24
+
25
+ const root = process.env.LOGGER_SCENARIO_ROOT;
26
+ const loggerModuleUrl = process.env.LOGGER_MODULE_URL;
27
+ if (!root || !loggerModuleUrl) throw new Error("logger scenario environment is incomplete");
28
+
29
+ const logger = await import(loggerModuleUrl);
30
+ const logPath = path.join(root, "nested", "magic-context.log");
31
+ process.env.MAGIC_CONTEXT_LOG_PATH = logPath;
32
+ logger.log("first");
33
+ logger.flushLogger();
34
+ const healthyDiagnostics = logger.getLoggerDiagnostics();
35
+
36
+ const logDirectory = path.dirname(logPath);
37
+ if (process.env.LOGGER_SCENARIO === "recovery") {
38
+ const { rmSync } = await import("node:fs");
39
+ rmSync(logDirectory, { recursive: true, force: true });
40
+ logger.log("second");
41
+ logger.flushLogger();
42
+ }
43
+
44
+ // A directory used as the file target makes append fail deterministically on every platform.
45
+ const failedPath = path.join(root, "unwritable-log-target");
46
+ const { mkdirSync } = await import("node:fs");
47
+ mkdirSync(failedPath, { recursive: true });
48
+ process.env.MAGIC_CONTEXT_LOG_PATH = failedPath;
49
+ logger.log("failed write");
50
+ logger.flushLogger();
51
+ const failedDiagnostics = logger.getLoggerDiagnostics();
52
+
53
+ const content = existsSync(logPath) ? readFileSync(logPath, "utf8") : "";
54
+ console.log(JSON.stringify({
55
+ exists: existsSync(logPath),
56
+ content,
57
+ healthyDiagnostics,
58
+ failedDiagnostics,
59
+ }));
60
+ `;
61
+
62
+ const scenarioRoots: string[] = [];
63
+
64
+ afterEach(() => {
65
+ for (const root of scenarioRoots.splice(0)) {
66
+ rmSync(root, { recursive: true, force: true });
67
+ }
68
+ });
69
+
70
+ async function runLoggerScenario(
71
+ scenario: "recovery" | "diagnostics",
72
+ ): Promise<LoggerScenarioResult> {
73
+ const root = mkdtempSync(path.join(os.tmpdir(), "magic-context-logger-test-"));
74
+ scenarioRoots.push(root);
75
+ const child = Bun.spawn({
76
+ cmd: ["bun", "--eval", loggerScenario],
77
+ cwd: import.meta.dir,
78
+ env: {
79
+ ...process.env,
80
+ NODE_ENV: "production",
81
+ LOGGER_MODULE_URL: new URL("./logger.ts", import.meta.url).href,
82
+ LOGGER_SCENARIO: scenario,
83
+ LOGGER_SCENARIO_ROOT: root,
84
+ },
85
+ stdout: "pipe",
86
+ stderr: "pipe",
87
+ });
88
+ const [exitCode, stdout, stderr] = await Promise.all([
89
+ child.exited,
90
+ new Response(child.stdout).text(),
91
+ new Response(child.stderr).text(),
92
+ ]);
93
+ expect(exitCode, stderr).toBe(0);
94
+ return JSON.parse(stdout.trim()) as LoggerScenarioResult;
95
+ }
96
+
97
+ describe("logger", () => {
98
+ test("recreates a log directory removed while the process is running", async () => {
99
+ const result = await runLoggerScenario("recovery");
100
+
101
+ expect(result.exists).toBe(true);
102
+ expect(result.content).toContain("second");
103
+ });
104
+
105
+ test("reports swallowed writes while healthy writes leave the counter at zero", async () => {
106
+ const result = await runLoggerScenario("diagnostics");
107
+
108
+ expect(result.healthyDiagnostics).toEqual({
109
+ swallowedWriteCount: 0,
110
+ lastErrorMessage: null,
111
+ lastErrorTime: null,
112
+ });
113
+ expect(result.failedDiagnostics.swallowedWriteCount).toBe(1);
114
+ expect(result.failedDiagnostics.lastErrorMessage).toBeTruthy();
115
+ expect(result.failedDiagnostics.lastErrorTime).toMatch(
116
+ /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/,
117
+ );
118
+ });
119
+ });
@@ -9,25 +9,30 @@ let flushTimer: ReturnType<typeof setTimeout> | null = null;
9
9
  const FLUSH_INTERVAL_MS = 500;
10
10
  const BUFFER_SIZE_LIMIT = 50;
11
11
 
12
- // Cache the last log directory we mkdir'd successfully so we only retry the
13
- // filesystem call when the resolved path actually changes. The path is
14
- // re-evaluated on every flush because `setHarness("pi")` runs after module
15
- // load on Pi; we MUST NOT freeze it at import time, or Pi's first flush
16
- // could land in the OpenCode subtree.
17
- let lastEnsuredDir: string | null = null;
12
+ export interface LoggerDiagnostics {
13
+ swallowedWriteCount: number;
14
+ lastErrorMessage: string | null;
15
+ lastErrorTime: string | null;
16
+ }
18
17
 
19
- function ensureDir(filePath: string): void {
20
- const dir = path.dirname(filePath);
21
- if (dir === lastEnsuredDir) return;
18
+ let swallowedWriteCount = 0;
19
+ let lastErrorMessage: string | null = null;
20
+ let lastErrorTime: string | null = null;
21
+
22
+ function recordSwallowedWrite(error: unknown): void {
22
23
  try {
23
- fs.mkdirSync(dir, { recursive: true });
24
- lastEnsuredDir = dir;
24
+ swallowedWriteCount++;
25
+ lastErrorMessage = error instanceof Error ? error.message : String(error);
26
+ lastErrorTime = new Date().toISOString();
25
27
  } catch {
26
- // Intentional: logging must never throw. If mkdir fails we still
27
- // try the append; failure there is also swallowed.
28
+ // Diagnostics must not make the logger throw either.
28
29
  }
29
30
  }
30
31
 
32
+ function ensureDir(filePath: string): void {
33
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
34
+ }
35
+
31
36
  function flush(): void {
32
37
  if (flushTimer) {
33
38
  clearTimeout(flushTimer);
@@ -40,8 +45,8 @@ function flush(): void {
40
45
  const logFile = getMagicContextLogPath();
41
46
  ensureDir(logFile);
42
47
  fs.appendFileSync(logFile, data);
43
- } catch {
44
- // Intentional: logging must never throw
48
+ } catch (error) {
49
+ recordSwallowedWrite(error);
45
50
  }
46
51
  }
47
52
 
@@ -78,6 +83,19 @@ export function sessionLog(sessionId: string, message: string, data?: unknown):
78
83
  log(`[magic-context][${sessionId}] ${message}`, data);
79
84
  }
80
85
 
86
+ export function getLoggerDiagnostics(): LoggerDiagnostics {
87
+ return {
88
+ swallowedWriteCount,
89
+ lastErrorMessage,
90
+ lastErrorTime,
91
+ };
92
+ }
93
+
94
+ /** Flush buffered log entries immediately. Primarily useful to diagnostic readers and tests. */
95
+ export function flushLogger(): void {
96
+ flush();
97
+ }
98
+
81
99
  /**
82
100
  * Resolve the current log file path. The path is harness-aware (see
83
101
  * {@link getMagicContextLogPath}) and re-evaluated on every call, so callers
@@ -7,6 +7,7 @@ import {
7
7
  getModelsDevCacheState,
8
8
  getSdkContextLimit,
9
9
  getSdkInputLimit,
10
+ getSdkWindowGeometry,
10
11
  refreshModelLimitsAfterAuthOnce,
11
12
  refreshModelLimitsFromApi,
12
13
  resetAuthRewarmLatchForTest,
@@ -78,6 +79,23 @@ describe("output-token reservation", () => {
78
79
  ).toBe(1_048_576);
79
80
  });
80
81
 
82
+ test("output_reserve accepts both harness provider spellings with canonical precedence", () => {
83
+ const limit = { context: 100_000, output: 20_000 };
84
+ expect(
85
+ resolveLimit(limit, "openai-codex", "gpt-5.6-sol", {
86
+ default: 0,
87
+ "openai-codex/gpt-5.6-sol": 8_000,
88
+ }),
89
+ ).toBe(92_000);
90
+ expect(
91
+ resolveLimit(limit, "openai-codex", "gpt-5.6-sol", {
92
+ default: 0,
93
+ "openai-codex/gpt-5.6-sol": 8_000,
94
+ "openai/gpt-5.6-sol": 4_000,
95
+ }),
96
+ ).toBe(96_000);
97
+ });
98
+
81
99
  test("output_reserve overrides shared and separate quota defaults", () => {
82
100
  expect(resolveLimit({ context: 100_000, output: 20_000 }, "anthropic", "claude", 0)).toBe(
83
101
  100_000,
@@ -131,6 +149,30 @@ describe("models-dev-cache (SDK-only)", () => {
131
149
  clearModelsDevCache();
132
150
  });
133
151
 
152
+ test("honors the reporter's default output_reserve on the SDK geometry path", async () => {
153
+ await refreshModelLimitsFromApi(
154
+ makeClient([
155
+ {
156
+ id: "openai-codex",
157
+ models: {
158
+ "gpt-5.6-sol": {
159
+ limit: { context: 400_000, input: 272_000, output: 128_000 },
160
+ },
161
+ },
162
+ },
163
+ ]),
164
+ );
165
+ setOutputReserveConfig({ default: 16_384 });
166
+
167
+ const geometry = getSdkWindowGeometry("openai-codex", "gpt-5.6-sol");
168
+ expect(geometry?.usableSoft).toBe(272_000 - 16_384);
169
+ expect(geometry?.derivation).toMatchObject({
170
+ window: 272_000,
171
+ reserve: 16_384,
172
+ reserveSource: "output_config",
173
+ });
174
+ });
175
+
134
176
  test("resolves from the SDK and prefers limit.input over limit.context", async () => {
135
177
  // github-copilot / codex shape: input is the max prompt, context the total
136
178
  // window. Pressure math must use the input cap (OpenCode's own overflow.ts
@@ -30,9 +30,15 @@ import { join } from "node:path";
30
30
  import type { ContextLimitProvenance } from "./context-limit-provenance";
31
31
  import { getMagicContextStorageDir } from "./data-path";
32
32
  import { getHarness } from "./harness";
33
- import { piModelRefToCanonical } from "./harness-provider-map";
33
+ import { modelRefLookupOrder } from "./harness-provider-map";
34
34
  import { sessionLog } from "./logger";
35
35
  import { shouldEnforcePrivateStoragePermissions } from "./storage-permissions";
36
+ import {
37
+ deriveWindowGeometry,
38
+ getWindowOverlay,
39
+ resolveWindowOverlayFacts,
40
+ type WindowGeometryResult,
41
+ } from "./window-geometry";
36
42
 
37
43
  interface OpencodeClientLike {
38
44
  config: {
@@ -196,22 +202,20 @@ function isFinitePositive(value: number | undefined): value is number {
196
202
  }
197
203
 
198
204
  function modelKeyLookupOrder(providerID: string, modelID: string): string[] {
199
- const full = `${providerID}/${modelID}`;
200
- const canonicalFull = piModelRefToCanonical(full);
201
- const candidates = [full, canonicalFull, modelID];
205
+ const candidates = [...modelRefLookupOrder(`${providerID}/${modelID}`), modelID];
202
206
  const colon = modelID.lastIndexOf(":");
203
207
  if (colon > 0) {
204
208
  const bareModel = modelID.slice(0, colon);
205
- const providerBare = `${providerID}/${bareModel}`;
206
- candidates.push(providerBare, piModelRefToCanonical(providerBare), bareModel);
209
+ candidates.push(...modelRefLookupOrder(`${providerID}/${bareModel}`), bareModel);
207
210
  }
208
211
  return [...new Set(candidates)];
209
212
  }
210
213
 
211
- function configuredOutputReserve(
212
- config: OutputReserveConfig | undefined,
214
+ /** Resolve the user-tier output reservation for one runtime model. */
215
+ export function resolveOutputReserve(
213
216
  providerID: string,
214
217
  modelID: string,
218
+ config: OutputReserveConfig | undefined = outputReserveConfig,
215
219
  ): number | undefined {
216
220
  if (typeof config === "number")
217
221
  return Number.isFinite(config) && config >= 0 ? config : undefined;
@@ -256,7 +260,7 @@ export function resolveLimit(
256
260
  if (input !== undefined && (context === undefined || input < context)) return input;
257
261
  if (context === undefined) return undefined;
258
262
 
259
- const configuredReserve = configuredOutputReserve(reserveConfig, providerID, modelID);
263
+ const configuredReserve = resolveOutputReserve(providerID, modelID, reserveConfig);
260
264
  let reserve: number;
261
265
  if (configuredReserve !== undefined) {
262
266
  reserve = configuredReserve;
@@ -493,6 +497,50 @@ async function refreshModelLimitsOnce(client: OpencodeClientLike): Promise<boole
493
497
  * `ctx.model.contextWindow`), so for Pi this returns `undefined` and Pi's
494
498
  * own resolution path is used.
495
499
  */
500
+ export function getSdkWindowGeometry(
501
+ providerID: string,
502
+ modelID: string,
503
+ detectedContextLimit?: number,
504
+ options?: {
505
+ detectedLimitProvenance?: ContextLimitProvenance;
506
+ harness?: "opencode" | "pi";
507
+ },
508
+ ): WindowGeometryResult | undefined {
509
+ loadPersistedApiCacheOnce();
510
+ const metadata = lookupMetadataWithTagFallback(apiCache, providerID, modelID);
511
+ if (!metadata) return undefined;
512
+ const rawContext = metadata.contextLimit ?? metadata.limit;
513
+ const promptOnlyDetected =
514
+ options?.detectedLimitProvenance === "prompt_only" && isFinitePositive(detectedContextLimit)
515
+ ? detectedContextLimit
516
+ : undefined;
517
+ const result = deriveWindowGeometry(
518
+ providerID,
519
+ modelID,
520
+ {
521
+ context: rawContext,
522
+ input: metadata.inputLimit,
523
+ output: metadata.outputLimit,
524
+ },
525
+ {
526
+ overlay: resolveWindowOverlayFacts(providerID, modelID, getWindowOverlay()),
527
+ outputReserveOverride: resolveOutputReserve(providerID, modelID),
528
+ harness: options?.harness ?? "opencode",
529
+ contextCap:
530
+ promptOnlyDetected === undefined && isFinitePositive(detectedContextLimit)
531
+ ? detectedContextLimit
532
+ : undefined,
533
+ },
534
+ );
535
+ if (!result || promptOnlyDetected === undefined) return result;
536
+ const usableSoft = promptOnlyDetected;
537
+ return {
538
+ ...result,
539
+ usableSoft,
540
+ usableHard: Math.max(usableSoft, Math.min(result.usableHard, promptOnlyDetected)),
541
+ };
542
+ }
543
+
496
544
  export function getSdkContextLimit(
497
545
  providerID: string,
498
546
  modelID: string,
@@ -502,6 +550,11 @@ export function getSdkContextLimit(
502
550
  detectedLimitProvenance?: ContextLimitProvenance;
503
551
  },
504
552
  ): number | undefined {
553
+ if (options?.reservation !== "none") {
554
+ return getSdkWindowGeometry(providerID, modelID, detectedContextLimit, {
555
+ detectedLimitProvenance: options?.detectedLimitProvenance,
556
+ })?.usableSoft;
557
+ }
505
558
  loadPersistedApiCacheOnce();
506
559
  const metadata = lookupMetadataWithTagFallback(apiCache, providerID, modelID);
507
560
  if (!metadata) return undefined;
@@ -13,6 +13,7 @@ import {
13
13
  CTX_REDUCE_LIGHT_DESCRIPTION,
14
14
  CTX_SEARCH_LIGHT_DESCRIPTION,
15
15
  } from "../tools/light-descriptions";
16
+ import { piModelRefToCanonical } from "./harness-provider-map";
16
17
  import { detectConfigFile } from "./jsonc-parser";
17
18
  import {
18
19
  type PromptSurfaceConfig,
@@ -242,13 +243,14 @@ export function createPromptSurfaceGuidanceEpochCache(runtime: PromptSurfaceRunt
242
243
 
243
244
  return {
244
245
  resolve(sessionId, config, modelKey) {
246
+ const canonicalModelKey = modelKey ? piModelRefToCanonical(modelKey) : undefined;
245
247
  const cached = epochs.get(sessionId);
246
- if (cached && cached.config === config && cached.modelKey === modelKey) {
248
+ if (cached && cached.config === config && cached.modelKey === canonicalModelKey) {
247
249
  return cached.selection;
248
250
  }
249
251
 
250
- const selection = runtime.resolveGuidance(config, modelKey);
251
- epochs.set(sessionId, { config, modelKey, selection });
252
+ const selection = runtime.resolveGuidance(config, canonicalModelKey);
253
+ epochs.set(sessionId, { config, modelKey: canonicalModelKey, selection });
252
254
  return selection;
253
255
  },
254
256
  clear(sessionId) {