@retaw106/mmc-oc 2.3.0 → 2.5.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 (216) hide show
  1. package/dist/cli.js +12 -1
  2. package/dist/config/schema/magic-context.d.ts +33 -0
  3. package/dist/config/schema/magic-context.d.ts.map +1 -1
  4. package/dist/config-init.js +12 -1
  5. package/dist/{context-wt7g4f8k.js → context-xx09sh1g.js} +1985 -603
  6. package/dist/features/magic-context/memory-maintainer/launcher.d.ts +13 -4
  7. package/dist/features/magic-context/memory-maintainer/launcher.d.ts.map +1 -1
  8. package/dist/features/magic-context/memory-maintainer/manual-commands.d.ts.map +1 -1
  9. package/dist/features/magic-context/memory-maintainer/model-chain.d.ts +47 -0
  10. package/dist/features/magic-context/memory-maintainer/model-chain.d.ts.map +1 -0
  11. package/dist/features/magic-context/memory-maintainer/oc2-adapter.d.ts +17 -4
  12. package/dist/features/magic-context/memory-maintainer/oc2-adapter.d.ts.map +1 -1
  13. package/dist/features/magic-context/memory-maintainer/pipeline.d.ts +6 -0
  14. package/dist/features/magic-context/memory-maintainer/pipeline.d.ts.map +1 -1
  15. package/dist/features/magic-context/message-index-async.d.ts +6 -2
  16. package/dist/features/magic-context/message-index-async.d.ts.map +1 -1
  17. package/dist/features/magic-context/message-index.d.ts +5 -8
  18. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  19. package/dist/features/magic-context/search.d.ts +5 -1
  20. package/dist/features/magic-context/search.d.ts.map +1 -1
  21. package/dist/features/magic-context/storage-db.d.ts +2 -0
  22. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  23. package/dist/features/magic-context/storage-host-instruction-retention.d.ts +4 -0
  24. package/dist/features/magic-context/storage-host-instruction-retention.d.ts.map +1 -0
  25. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  26. package/dist/features/magic-context/storage-meta-shared.d.ts +2 -2
  27. package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
  28. package/dist/features/magic-context/storage-session-tables.d.ts +9 -1
  29. package/dist/features/magic-context/storage-session-tables.d.ts.map +1 -1
  30. package/dist/features/magic-context/storage-tags.d.ts +4 -0
  31. package/dist/features/magic-context/storage-tags.d.ts.map +1 -1
  32. package/dist/features/magic-context/transform-decision-log.d.ts +57 -2
  33. package/dist/features/magic-context/transform-decision-log.d.ts.map +1 -1
  34. package/dist/hooks/magic-context/__fixtures__/tail-hygiene-refresh.d.ts +34 -0
  35. package/dist/hooks/magic-context/__fixtures__/tail-hygiene-refresh.d.ts.map +1 -0
  36. package/dist/hooks/magic-context/apply-operations.d.ts +3 -1
  37. package/dist/hooks/magic-context/apply-operations.d.ts.map +1 -1
  38. package/dist/hooks/magic-context/auto-search-hint.d.ts +3 -0
  39. package/dist/hooks/magic-context/auto-search-hint.d.ts.map +1 -1
  40. package/dist/hooks/magic-context/auto-search-runner.d.ts.map +1 -1
  41. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  42. package/dist/hooks/magic-context/compartment-runner-historian.d.ts +2 -2
  43. package/dist/hooks/magic-context/compartment-runner-types.d.ts +2 -2
  44. package/dist/hooks/magic-context/compartment-runner-types.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/compartment-runner-validation.d.ts.map +1 -1
  46. package/dist/hooks/magic-context/compartment-runner.d.ts +4 -0
  47. package/dist/hooks/magic-context/compartment-runner.d.ts.map +1 -1
  48. package/dist/hooks/magic-context/compartment-trigger-error.d.ts +11 -0
  49. package/dist/hooks/magic-context/compartment-trigger-error.d.ts.map +1 -0
  50. package/dist/hooks/magic-context/compartment-trigger.d.ts +1 -1
  51. package/dist/hooks/magic-context/compartment-trigger.d.ts.map +1 -1
  52. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts +1 -0
  53. package/dist/hooks/magic-context/ctx-reduce-nudge.d.ts.map +1 -1
  54. package/dist/hooks/magic-context/degraded-pass-refusal.d.ts +2 -0
  55. package/dist/hooks/magic-context/degraded-pass-refusal.d.ts.map +1 -1
  56. package/dist/hooks/magic-context/diagnostic-log-state.d.ts +4 -0
  57. package/dist/hooks/magic-context/diagnostic-log-state.d.ts.map +1 -0
  58. package/dist/hooks/magic-context/edit-marker.d.ts +2 -0
  59. package/dist/hooks/magic-context/edit-marker.d.ts.map +1 -1
  60. package/dist/hooks/magic-context/emergency-drop.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/file-token-coverage.d.ts +11 -0
  62. package/dist/hooks/magic-context/file-token-coverage.d.ts.map +1 -0
  63. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts +2 -0
  64. package/dist/hooks/magic-context/final-wire-token-estimate.d.ts.map +1 -1
  65. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  66. package/dist/hooks/magic-context/host-served-rows.d.ts +10 -0
  67. package/dist/hooks/magic-context/host-served-rows.d.ts.map +1 -1
  68. package/dist/hooks/magic-context/inject-compartments.d.ts +1 -1
  69. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  70. package/dist/hooks/magic-context/lkg-recovery-fence.d.ts.map +1 -1
  71. package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
  72. package/dist/hooks/magic-context/lkg-slot.d.ts +1 -1
  73. package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
  74. package/dist/hooks/magic-context/nudge-evaluation-log.d.ts +3 -0
  75. package/dist/hooks/magic-context/nudge-evaluation-log.d.ts.map +1 -0
  76. package/dist/hooks/magic-context/pass-outcome.d.ts +2 -0
  77. package/dist/hooks/magic-context/pass-outcome.d.ts.map +1 -1
  78. package/dist/hooks/magic-context/pending-drop-eligibility.d.ts +14 -0
  79. package/dist/hooks/magic-context/pending-drop-eligibility.d.ts.map +1 -0
  80. package/dist/hooks/magic-context/pending-materialization.d.ts +2 -0
  81. package/dist/hooks/magic-context/pending-materialization.d.ts.map +1 -1
  82. package/dist/hooks/magic-context/protected-tail-boundary.d.ts +2 -0
  83. package/dist/hooks/magic-context/protected-tail-boundary.d.ts.map +1 -1
  84. package/dist/hooks/magic-context/provider-visible-parts.d.ts +10 -4
  85. package/dist/hooks/magic-context/provider-visible-parts.d.ts.map +1 -1
  86. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  87. package/dist/hooks/magic-context/recomp-orchestrator.d.ts +3 -3
  88. package/dist/hooks/magic-context/recomp-orchestrator.d.ts.map +1 -1
  89. package/dist/hooks/magic-context/render-options.d.ts +19 -0
  90. package/dist/hooks/magic-context/render-options.d.ts.map +1 -0
  91. package/dist/hooks/magic-context/supersession-reclaim.d.ts +5 -3
  92. package/dist/hooks/magic-context/supersession-reclaim.d.ts.map +1 -1
  93. package/dist/hooks/magic-context/system-date-refresh.d.ts +3 -2
  94. package/dist/hooks/magic-context/system-date-refresh.d.ts.map +1 -1
  95. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  96. package/dist/hooks/magic-context/system-prompt-sticky-date.d.ts +4 -0
  97. package/dist/hooks/magic-context/system-prompt-sticky-date.d.ts.map +1 -0
  98. package/dist/hooks/magic-context/tag-content-primitives.d.ts +1 -1
  99. package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
  100. package/dist/hooks/magic-context/tag-messages.d.ts +15 -2
  101. package/dist/hooks/magic-context/tag-messages.d.ts.map +1 -1
  102. package/dist/hooks/magic-context/tail-hygiene-output-count.d.ts +18 -0
  103. package/dist/hooks/magic-context/tail-hygiene-output-count.d.ts.map +1 -0
  104. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts +8 -4
  105. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
  106. package/dist/hooks/magic-context/text-complete.d.ts +1 -1
  107. package/dist/hooks/magic-context/text-complete.d.ts.map +1 -1
  108. package/dist/hooks/magic-context/todo-view.d.ts +2 -2
  109. package/dist/hooks/magic-context/tool-drop-target.d.ts +3 -2
  110. package/dist/hooks/magic-context/tool-drop-target.d.ts.map +1 -1
  111. package/dist/hooks/magic-context/tool-reclaim.d.ts +5 -2
  112. package/dist/hooks/magic-context/tool-reclaim.d.ts.map +1 -1
  113. package/dist/hooks/magic-context/transform-compartment-phase.d.ts +1 -0
  114. package/dist/hooks/magic-context/transform-compartment-phase.d.ts.map +1 -1
  115. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +8 -0
  116. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  117. package/dist/hooks/magic-context/transform-stage-logger.d.ts +3 -0
  118. package/dist/hooks/magic-context/transform-stage-logger.d.ts.map +1 -1
  119. package/dist/hooks/magic-context/transform.d.ts +4 -9
  120. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  121. package/dist/hooks/magic-context/unresolved-history-boundary.d.ts +5 -1
  122. package/dist/hooks/magic-context/unresolved-history-boundary.d.ts.map +1 -1
  123. package/dist/{index-kw21z5pe.js → index-0d40b4q8.js} +17119 -16569
  124. package/dist/{index-z2w1eq3y.js → index-1vxffj5j.js} +558 -211
  125. package/dist/{index-z2g20msd.js → index-azbmnekv.js} +4 -4
  126. package/dist/{index-18yrysyj.js → index-bpjjcdy9.js} +1 -1
  127. package/dist/{index-a5mw0xp3.js → index-k25thxq7.js} +1 -1
  128. package/dist/{index-3gj1ak4c.js → index-mjf0p7yq.js} +1 -1
  129. package/dist/{index-czba8tw3.js → index-pte3cfs9.js} +226 -14
  130. package/dist/index.js +2 -2
  131. package/dist/{message-time-backfill-sykevdax.js → message-time-backfill-g9py24yt.js} +1 -1
  132. package/dist/model-resolution-cvyrrcjw.js +8 -0
  133. package/dist/plugin/rpc-handlers.d.ts.map +1 -1
  134. package/dist/plugin/subagent-tool-policy.d.ts +4 -0
  135. package/dist/plugin/subagent-tool-policy.d.ts.map +1 -1
  136. package/dist/{read-session-chunk-863d6c06.js → read-session-chunk-eb3fy45y.js} +3 -3
  137. package/dist/{recomp-orchestrator-wrjy3qym.js → recomp-orchestrator-mmpy5cb7.js} +6 -6
  138. package/dist/{send-session-notification-cz6ev088.js → send-session-notification-zpqs213d.js} +3 -3
  139. package/dist/shared/model-suggestion-retry.d.ts +23 -0
  140. package/dist/shared/model-suggestion-retry.d.ts.map +1 -1
  141. package/dist/shared/prompt-surface-runtime.d.ts +5 -0
  142. package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
  143. package/dist/shared/reclaim-distance.d.ts +33 -0
  144. package/dist/shared/reclaim-distance.d.ts.map +1 -0
  145. package/dist/shared/rpc-discovery-cleanup.d.ts +3 -0
  146. package/dist/shared/rpc-discovery-cleanup.d.ts.map +1 -0
  147. package/dist/shared/rpc-server.d.ts +2 -0
  148. package/dist/shared/rpc-server.d.ts.map +1 -1
  149. package/dist/shared/search-text.d.ts +8 -0
  150. package/dist/shared/search-text.d.ts.map +1 -0
  151. package/dist/shared/sqlite.d.ts +8 -1
  152. package/dist/shared/sqlite.d.ts.map +1 -1
  153. package/dist/shared/stable-json.d.ts +7 -0
  154. package/dist/shared/stable-json.d.ts.map +1 -1
  155. package/dist/shared/storage-file-mutex.d.ts.map +1 -1
  156. package/dist/shared/system-directive.d.ts +2 -0
  157. package/dist/shared/system-directive.d.ts.map +1 -1
  158. package/dist/shared/tail-hygiene-json.d.ts +22 -0
  159. package/dist/shared/tail-hygiene-json.d.ts.map +1 -0
  160. package/dist/shared/user-facing-codes.d.ts +2 -2
  161. package/dist/v2/fold/native-replay.d.ts +11 -2
  162. package/dist/v2/fold/native-replay.d.ts.map +1 -1
  163. package/dist/v2/fold/policy.d.ts.map +1 -1
  164. package/dist/v2/fold/restore.d.ts.map +1 -1
  165. package/dist/v2/hidden-completion.d.ts +20 -1
  166. package/dist/v2/hidden-completion.d.ts.map +1 -1
  167. package/dist/v2/hooks/context.d.ts +1 -1
  168. package/dist/v2/hooks/context.d.ts.map +1 -1
  169. package/dist/v2/hooks/generate.d.ts +36 -1
  170. package/dist/v2/hooks/generate.d.ts.map +1 -1
  171. package/dist/v2/hooks/hidden-child.d.ts +22 -1
  172. package/dist/v2/hooks/hidden-child.d.ts.map +1 -1
  173. package/dist/v2/hooks/lkg-system.d.ts +35 -4
  174. package/dist/v2/hooks/lkg-system.d.ts.map +1 -1
  175. package/dist/v2/hooks/payload.d.ts +10 -1
  176. package/dist/v2/hooks/payload.d.ts.map +1 -1
  177. package/dist/v2/hooks/store.d.ts +2 -0
  178. package/dist/v2/hooks/store.d.ts.map +1 -1
  179. package/dist/v2/hooks/types.d.ts +12 -0
  180. package/dist/v2/hooks/types.d.ts.map +1 -1
  181. package/dist/v2/server.js +16272 -13742
  182. package/dist/v2/store-reader.d.ts +38 -1
  183. package/dist/v2/store-reader.d.ts.map +1 -1
  184. package/dist/web/main.js +262 -17
  185. package/dist/{wrapup-orchestrator-bmybc5p5.js → wrapup-orchestrator-jq589pgr.js} +6 -6
  186. package/package.json +1 -1
  187. package/src/config/latch-permanence-guard.test.ts +8 -0
  188. package/src/config/project-security.test.ts +17 -0
  189. package/src/config/schema/magic-context.test.ts +50 -0
  190. package/src/config/schema/magic-context.ts +34 -1
  191. package/src/shared/model-suggestion-retry.test.ts +52 -0
  192. package/src/shared/model-suggestion-retry.ts +13 -4
  193. package/src/shared/prompt-surface-runtime.ts +12 -0
  194. package/src/shared/reclaim-distance.test.ts +111 -0
  195. package/src/shared/reclaim-distance.ts +91 -0
  196. package/src/shared/root-async-context.probe.mjs +36 -0
  197. package/src/shared/root-async-context.test.ts +49 -0
  198. package/src/shared/rpc-discovery-cleanup.test.ts +125 -0
  199. package/src/shared/rpc-discovery-cleanup.ts +57 -0
  200. package/src/shared/rpc-server.ts +36 -13
  201. package/src/shared/search-text.test.ts +30 -0
  202. package/src/shared/search-text.ts +62 -0
  203. package/src/shared/sqlite-busy-budget-review.test.ts +291 -0
  204. package/src/shared/sqlite-foreground-wait.test.ts +237 -1
  205. package/src/shared/sqlite-turn-deadline.test.ts +286 -0
  206. package/src/shared/sqlite.ts +292 -23
  207. package/src/shared/stable-json.ts +56 -14
  208. package/src/shared/storage-file-mutex.test.ts +51 -2
  209. package/src/shared/storage-file-mutex.ts +11 -1
  210. package/src/shared/system-directive.test.ts +37 -0
  211. package/src/shared/system-directive.ts +19 -1
  212. package/src/shared/tag-transcript.ts +16 -4
  213. package/src/shared/tail-hygiene-json.test.ts +117 -0
  214. package/src/shared/tail-hygiene-json.ts +217 -0
  215. package/src/shared/user-facing-codes.ts +2 -2
  216. package/dist/model-resolution-wazv26n0.js +0 -6
@@ -135,6 +135,7 @@ describe("MagicContextConfigSchema", () => {
135
135
  },
136
136
  maintenance: {
137
137
  enabled: true,
138
+ fallback_models: [],
138
139
  budgets: {
139
140
  inputTokens: 400_000,
140
141
  outputTokens: 32_000,
@@ -159,6 +160,55 @@ describe("MagicContextConfigSchema", () => {
159
160
  expect(result).toEqual(input);
160
161
  });
161
162
 
163
+ it("parses both maintenance model spellings and distinguishes absent from empty fallbacks", () => {
164
+ const stringForm = MagicContextConfigSchema.parse({
165
+ maintenance: { enabled: true, model: "openai/gpt-6-luna" },
166
+ }).maintenance;
167
+ expect(stringForm.model).toBe("openai/gpt-6-luna");
168
+ expect(stringForm.fallback_models).toBeUndefined();
169
+ expect(
170
+ MagicContextConfigSchema.parse({
171
+ maintenance: { fallback_models: [] },
172
+ }).maintenance.fallback_models,
173
+ ).toEqual([]);
174
+
175
+ const objectForm = MagicContextConfigSchema.parse({
176
+ maintenance: {
177
+ enabled: true,
178
+ model: { model: "xiaomi/mimo-v2.6-flash", variant: "high" },
179
+ fallback_models: ["openai/gpt-6-luna", { model: "p/l", thinking_level: "off" }],
180
+ },
181
+ }).maintenance;
182
+ // Object entries keep both harness qualifiers verbatim — the
183
+ // active harness reads its own, no cross-harness mapping.
184
+ expect(objectForm.model).toEqual({
185
+ model: "xiaomi/mimo-v2.6-flash",
186
+ variant: "high",
187
+ });
188
+ expect(objectForm.fallback_models).toEqual([
189
+ "openai/gpt-6-luna",
190
+ { model: "p/l", thinking_level: "off" },
191
+ ]);
192
+ });
193
+
194
+ it("plan 18: rejects blank maintenance model references at parse time (no silent executor flow)", () => {
195
+ expect(
196
+ MagicContextConfigSchema.safeParse({
197
+ maintenance: { enabled: true, model: " " },
198
+ }).success,
199
+ ).toBe(false);
200
+ expect(
201
+ MagicContextConfigSchema.safeParse({
202
+ maintenance: { enabled: true, model: { model: "" } },
203
+ }).success,
204
+ ).toBe(false);
205
+ expect(
206
+ MagicContextConfigSchema.safeParse({
207
+ maintenance: { enabled: true, fallback_models: ["ok/one", " "] },
208
+ }).success,
209
+ ).toBe(false);
210
+ });
211
+
162
212
  it("accepts a boolean storage permission policy and rejects non-booleans", () => {
163
213
  expect(
164
214
  MagicContextConfigSchema.parse({
@@ -135,6 +135,23 @@ const HistorianModelEntryObjectSchema = z
135
135
  export const HistorianModelEntrySchema = z.union([z.string(), HistorianModelEntryObjectSchema]);
136
136
  export type HistorianModelEntry = z.infer<typeof HistorianModelEntrySchema>;
137
137
 
138
+ /**
139
+ * A maintenance model entry (plan 18): the same string/object shape as a
140
+ * historian model entry, but blank references are REJECTED at parse time —
141
+ * a blank primary must surface as a config diagnostic, never silently flow
142
+ * into the hidden executor or get corrected to some other model.
143
+ */
144
+ const MaintenanceModelEntrySchema = HistorianModelEntrySchema.superRefine((entry, ctx) => {
145
+ const model = typeof entry === "string" ? entry : entry.model;
146
+ if (typeof model !== "string" || model.trim().length === 0) {
147
+ ctx.addIssue({
148
+ code: "custom",
149
+ message:
150
+ "maintenance model entries must be non-empty: a bare provider/model string or { model, variant?, thinking_level? }",
151
+ });
152
+ }
153
+ });
154
+
138
155
  /**
139
156
  * A profile may select models but must not alter execution policy. It uses the
140
157
  * same flat historian model-resolution fields; execution metadata is not
@@ -400,6 +417,13 @@ export interface MagicContextConfig {
400
417
  */
401
418
  maintenance: {
402
419
  enabled: boolean;
420
+ /**
421
+ * Primary maintenance model entry. When absent, inherit historian
422
+ * model configuration; never inherit the main-session model.
423
+ */
424
+ model?: HistorianModelEntry;
425
+ /** Ordered fallbacks; absent inherits historian fallbacks only with an inherited primary. */
426
+ fallback_models?: HistorianModelEntry[];
403
427
  /** Per-run four-dimensional budget (decision 9; provisional, calibrated in P3/P4). */
404
428
  budgets: {
405
429
  inputTokens: number;
@@ -800,6 +824,15 @@ export const MagicContextConfigSchema = z.object({
800
824
  .describe(
801
825
  "Enable the automatic memory-maintenance task (default: true). USER-LEVEL ONLY — the whole maintenance group is stripped from project-tier config.",
802
826
  ),
827
+ model: MaintenanceModelEntrySchema.optional().describe(
828
+ "Primary maintenance model entry: a bare provider/model string or { model, variant? (OpenCode), thinking_level? (Pi) }. USER-LEVEL ONLY. When absent, inherit historian model configuration. If neither has a primary, automatic passes skip and manual runs refuse. The main-session model is never inherited.",
829
+ ),
830
+ fallback_models: z
831
+ .array(MaintenanceModelEntrySchema)
832
+ .optional()
833
+ .describe(
834
+ "Ordered fallback maintenance model entries, tried after CALL failures only. When absent and the primary is inherited, inherit historian fallbacks; explicit [] disables fallbacks. Illegal manifests, permission refusals, and exhausted budgets never switch models.",
835
+ ),
803
836
  budgets: z
804
837
  .object({
805
838
  inputTokens: z
@@ -922,7 +955,7 @@ export const MagicContextConfigSchema = z.object({
922
955
  anomalyThreshold: { absoluteRows: 10, poolFraction: 0.25 },
923
956
  })
924
957
  .describe(
925
- "Automatic memory-maintenance run controls (dreamer replacement). USER-LEVEL ONLY: the whole group is stripped from project-tier config so a cloned repository cannot raise budgets, relax cooldowns, or loosen the anomaly guard.",
958
+ "Automatic memory-maintenance run controls (dreamer replacement). USER-LEVEL ONLY: project-tier config cannot pick maintenance models or change run controls. An absent model inherits historian model configuration, never the session model.",
926
959
  ),
927
960
  });
928
961
 
@@ -38,6 +38,58 @@ function createArgs(model?: { providerID: string; modelID: string }) {
38
38
  }
39
39
 
40
40
  describe("promptSyncWithModelSuggestionRetry", () => {
41
+ for (const validated of [false, true]) {
42
+ for (const allowModelSuggestions of [undefined, true, false]) {
43
+ test(`provider suggestions ${String(allowModelSuggestions)}, validated=${validated}`, async () => {
44
+ const models: string[] = [];
45
+ const prompt = mock(async (args: PromptCall) => {
46
+ const model = args.body.model?.modelID ?? "";
47
+ models.push(model);
48
+ if (model !== "suggested" && model !== "last") {
49
+ throw new Error(
50
+ `model not found: provider/${model}. did you mean: suggested?`,
51
+ );
52
+ }
53
+ return {};
54
+ });
55
+ const options = {
56
+ allowModelSuggestions,
57
+ fallbackModels: ["provider/fallback", "provider/last"],
58
+ };
59
+ const args = createArgs({ providerID: "provider", modelID: "primary" });
60
+ if (validated) {
61
+ await promptSyncWithValidatedOutputRetry(createClient(prompt), args, {
62
+ ...options,
63
+ fetchOutput: async () => "output",
64
+ validateOutput: (output) => output,
65
+ });
66
+ } else {
67
+ await promptSyncWithModelSuggestionRetry(createClient(prompt), args, options);
68
+ }
69
+ expect(models).toEqual(
70
+ allowModelSuggestions === false
71
+ ? ["primary", "fallback", "last"]
72
+ : ["primary", "suggested"],
73
+ );
74
+ });
75
+ }
76
+ }
77
+
78
+ test("disabled suggestions preserve the original error without fallbacks", async () => {
79
+ const error = new Error("model not found: provider/primary. did you mean: suggested?");
80
+ const prompt = mock(async () => {
81
+ throw error;
82
+ });
83
+ await expect(
84
+ promptSyncWithModelSuggestionRetry(
85
+ createClient(prompt),
86
+ createArgs({ providerID: "provider", modelID: "primary" }),
87
+ { allowModelSuggestions: false },
88
+ ),
89
+ ).rejects.toBe(error);
90
+ expect(prompt).toHaveBeenCalledTimes(1);
91
+ });
92
+
41
93
  test("primary succeeds, no fallback iteration", async () => {
42
94
  const prompt = mock(async () => ({}));
43
95
  const client = createClient(prompt);
@@ -71,6 +71,8 @@ export interface PromptAttemptInfo {
71
71
  export interface PromptRetryOptions {
72
72
  transport?: PromptTransport;
73
73
  timeoutMs?: number;
74
+ /** False for callers whose configured model chain must not accept provider suggestions. Defaults to true. */
75
+ allowModelSuggestions?: boolean;
74
76
  /** External abort signal — cancels the in-flight LLM prompt immediately when aborted */
75
77
  signal?: AbortSignal;
76
78
  /**
@@ -361,8 +363,12 @@ async function abortChildRun(client: Client, sessionId: string): Promise<void> {
361
363
  * Everything else (auth errors, ProviderModelNotFoundError without suggestion,
362
364
  * rate limits, transient network failures, etc.) is considered retryable on a
363
365
  * different model.
366
+ * Exported as the shared fallback-switch classifier (plan 18): the
367
+ * memory-maintenance attempt loop reuses this exact predicate so "which
368
+ * failures may switch models" has ONE definition across historian and
369
+ * maintainer call sites.
364
370
  */
365
- function isNonRetryable(error: unknown, externalSignal?: AbortSignal): boolean {
371
+ export function isNonRetryable(error: unknown, externalSignal?: AbortSignal): boolean {
366
372
  if (
367
373
  externalSignal?.aborted ||
368
374
  (error instanceof HiddenCompletionRefusal && error.terminal) ||
@@ -484,6 +490,7 @@ async function attemptOnce(
484
490
  callContext: string,
485
491
  label: string,
486
492
  transport?: PromptTransport,
493
+ allowModelSuggestions = true,
487
494
  ): Promise<void> {
488
495
  // Keep this snapshot separate from the object passed to the client. A
489
496
  // failed prompt facade may rewrite its request body before rejecting; the
@@ -494,9 +501,8 @@ async function attemptOnce(
494
501
  await promptWithTimeout(client, attemptArgs, timeoutMs, signal, transport);
495
502
  return;
496
503
  } catch (error) {
497
- // If non-retryable (abort, overflow, timeout), bubble up immediately.
498
- // Don't even try suggestion retry — caller needs the original error.
499
- if (isNonRetryable(error, signal)) throw error;
504
+ // Surface the original error when suggestions are disabled or cannot help.
505
+ if (isNonRetryable(error, signal) || !allowModelSuggestions) throw error;
500
506
 
501
507
  const suggestion = parseModelSuggestion(error);
502
508
  if (!suggestion || !originalBody.model) {
@@ -572,6 +578,7 @@ export async function promptSyncWithModelSuggestionRetry(
572
578
  callContext,
573
579
  explicitPrimaryLabel,
574
580
  options.transport,
581
+ options.allowModelSuggestions,
575
582
  );
576
583
  return;
577
584
  } catch (error) {
@@ -616,6 +623,7 @@ export async function promptSyncWithModelSuggestionRetry(
616
623
  callContext,
617
624
  label,
618
625
  options.transport,
626
+ options.allowModelSuggestions,
619
627
  );
620
628
  log(
621
629
  `[${callContext}] fallback succeeded with ${label} (attempt ${i + 2}/${fallbacks.length + 1})`,
@@ -661,6 +669,7 @@ async function attemptAndValidate<TOutput, TValidated>(
661
669
  callContext,
662
670
  attempt.label,
663
671
  options.transport,
672
+ options.allowModelSuggestions,
664
673
  );
665
674
  } catch (error) {
666
675
  throw {
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { readFileSync, statSync } from "node:fs";
2
3
  import { dirname, isAbsolute, resolve } from "node:path";
3
4
 
@@ -59,6 +60,17 @@ export function promptSurfaceHashMaterial(
59
60
  : `${systemContent}\n\0magic-context-prompt-surface:${preset}`;
60
61
  }
61
62
 
63
+ /**
64
+ * Hash system text after the caller resolves guidance and freezes host date lines.
65
+ * Keep the legacy MD5 hex encoding so existing cached heads retain their identity.
66
+ */
67
+ export function systemPromptFingerprint(
68
+ systemContent: string,
69
+ preset: PromptSurfacePreset = "full",
70
+ ): string {
71
+ return createHash("md5").update(promptSurfaceHashMaterial(systemContent, preset)).digest("hex");
72
+ }
73
+
62
74
  const PROMPT_SURFACE_TOOL_ID_SET = new Set<string>(ACTIVE_TOOL_IDS);
63
75
 
64
76
  export interface PromptSurfaceGuidanceSelection {
@@ -0,0 +1,111 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import {
3
+ farBehindToolBookmark,
4
+ isReclaimUserTurn,
5
+ type ReclaimDistanceEntry,
6
+ } from "./reclaim-distance";
7
+
8
+ const output = (tag: number, tokens = 1000): ReclaimDistanceEntry => ({
9
+ toolOutputTag: tag,
10
+ tokens,
11
+ });
12
+ const turn = (): ReclaimDistanceEntry => ({ userTurn: true, tokens: 10 });
13
+
14
+ describe("reclaim distance", () => {
15
+ it("repeated rebuilds cannot age the current task", () => {
16
+ const entries = [turn(), ...Array.from({ length: 28 }, (_, i) => output(i + 1))];
17
+ for (const count of [2, 3, 5]) {
18
+ let bookmark = 0;
19
+ for (let pass = 0; pass < count + 1; pass++) {
20
+ expect(bookmark).toBe(0);
21
+ bookmark = Math.max(bookmark, farBehindToolBookmark(entries, 200_000));
22
+ }
23
+ }
24
+ });
25
+
26
+ it("three newer user turns establish a bookmark, not two", () => {
27
+ const entries = [turn(), output(1), turn(), output(2), turn()];
28
+ expect(farBehindToolBookmark(entries, 200_000)).toBe(0);
29
+ expect(farBehindToolBookmark([...entries, turn()], 200_000)).toBe(1);
30
+ });
31
+
32
+ it("single-turn tool loop ages at exactly a quarter of the usable window", () => {
33
+ const entries = [turn(), output(1), output(2, 49_999)];
34
+ expect(farBehindToolBookmark(entries, 200_000)).toBe(0);
35
+ expect(farBehindToolBookmark([...entries, { tokens: 1 }], 200_000)).toBe(1);
36
+ expect(farBehindToolBookmark([turn(), output(1, 100_000)], 200_000)).toBe(0);
37
+ });
38
+
39
+ it("multipart outputs never age from their own trailing chunks", () => {
40
+ expect(farBehindToolBookmark([output(1), output(1, 60_000)], 200_000)).toBe(0);
41
+ });
42
+
43
+ it("unknown windows disable only token distance", () => {
44
+ expect(farBehindToolBookmark([output(1), output(2, 99_999)], 0)).toBe(0);
45
+ expect(farBehindToolBookmark([output(1), turn(), turn(), turn()], 0)).toBe(1);
46
+ });
47
+
48
+ it("excludes synthetic host wakes, MC reminders, heads and result carriers", () => {
49
+ expect(
50
+ isReclaimUserTurn({
51
+ role: "user",
52
+ parts: [
53
+ {
54
+ kind: "text",
55
+ synthetic: true,
56
+ text: "<system-reminder>Background task finished.</system-reminder>",
57
+ },
58
+ ],
59
+ }),
60
+ ).toBe(false);
61
+ expect(
62
+ isReclaimUserTurn({
63
+ role: "user",
64
+ parts: [
65
+ {
66
+ kind: "text",
67
+ text: "<system-reminder>Your next step: call ctx_reduce on the outputs you've already used (5).</system-reminder>",
68
+ },
69
+ ],
70
+ }),
71
+ ).toBe(false);
72
+ expect(
73
+ isReclaimUserTurn({
74
+ role: "user",
75
+ syntheticHead: true,
76
+ parts: [{ kind: "text", text: "history" }],
77
+ }),
78
+ ).toBe(false);
79
+ expect(
80
+ isReclaimUserTurn({ role: "user", parts: [{ kind: "tool_result", text: "output" }] }),
81
+ ).toBe(false);
82
+ expect(
83
+ isReclaimUserTurn({
84
+ role: "user",
85
+ parts: [
86
+ { kind: "tool_result", text: "output" },
87
+ { kind: "text", text: "continue" },
88
+ ],
89
+ }),
90
+ ).toBe(true);
91
+ });
92
+
93
+ it("counts an authored part even after synthetic or ignored parts", () => {
94
+ for (const provenance of [{ synthetic: true }, { ignored: true }]) {
95
+ expect(
96
+ isReclaimUserTurn({ role: "user", parts: [{ kind: "image", ...provenance }] }),
97
+ ).toBe(false);
98
+ expect(
99
+ isReclaimUserTurn({
100
+ role: "user",
101
+ parts: [{ kind: "text", text: "notice", ...provenance }, { kind: "image" }],
102
+ }),
103
+ ).toBe(true);
104
+ }
105
+ });
106
+
107
+ it("a young lower-tag result holds back distant higher tags", () => {
108
+ expect(farBehindToolBookmark([output(2), output(3, 50_000), output(1)], 200_000)).toBe(0);
109
+ expect(farBehindToolBookmark([output(1), output(3, 50_000), output(2)], 200_000)).toBe(1);
110
+ });
111
+ });
@@ -0,0 +1,91 @@
1
+ export const RECLAIM_DISTANCE_USER_TURNS = 3;
2
+ export const RECLAIM_DISTANCE_WINDOW_FRACTION = 0.25;
3
+
4
+ export interface ReclaimDistanceEntry {
5
+ /** Local content tokens, excluding Magic Context's history and reduction/note reminders. */
6
+ tokens: number;
7
+ /** One event per authored user-role message, after any folded tool results. */
8
+ userTurn?: boolean;
9
+ /** Durable identifier of the tool invocation whose result ends at this entry. */
10
+ toolOutputTag?: number;
11
+ }
12
+
13
+ /** Only authored content starts a user turn; host control notices do not. */
14
+ export function isReclaimUserTurn(args: {
15
+ role?: string;
16
+ syntheticHead?: boolean;
17
+ synthetic?: boolean;
18
+ ignored?: boolean;
19
+ parts: readonly { kind: string; text?: string; synthetic?: boolean; ignored?: boolean }[];
20
+ }): boolean {
21
+ return (
22
+ args.role === "user" &&
23
+ !args.syntheticHead &&
24
+ !args.synthetic &&
25
+ !args.ignored &&
26
+ args.parts.some((part) => {
27
+ if (part.synthetic || part.ignored) return false;
28
+ if (part.kind !== "text" && part.kind !== "image" && part.kind !== "file") return false;
29
+ if (part.kind !== "text") return true;
30
+ return stripReclaimMcText(part.text ?? "").trim().length > 0;
31
+ })
32
+ );
33
+ }
34
+
35
+ /** Remove Magic Context's reduction/note reminders, not generic host wake notices. */
36
+ export function stripReclaimMcText(text: string): string {
37
+ return text
38
+ .replace(/^§\d+§\s*/, "")
39
+ .replace(/<system-reminder>[\s\S]*?<\/system-reminder>/g, (reminder) =>
40
+ /^(?:Your next step: call ctx_reduce on the outputs you've already used|Still unstamped:|Housekeeping: .* are reclaimable\. Stamp each output with ctx_reduce|.* are reclaimable\. Make a ctx_reduce pass now|.* are still unstamped\. Call ctx_reduce now)/.test(
41
+ reminder.slice("<system-reminder>".length).trimStart(),
42
+ )
43
+ ? ""
44
+ : reminder,
45
+ )
46
+ .replace(/<instruction name="deferred_notes">[\s\S]*?<\/instruction>/g, "");
47
+ }
48
+
49
+ /**
50
+ * A free cache rebuild is not evidence that the model has moved on. An output
51
+ * is far behind only after three newer user turns or a quarter-window of newer
52
+ * content. Return a prefix of qualifying tags; callers drop against the PREVIOUS
53
+ * durable bookmark before advancing it, so distance must survive two rebuilds.
54
+ */
55
+ export function farBehindToolBookmark(
56
+ entries: readonly ReclaimDistanceEntry[],
57
+ usableWindow: number,
58
+ ): number {
59
+ let newerTurns = 0;
60
+ let newerTokens = 0;
61
+ const farTags: number[] = [];
62
+ let firstYoungTag = Infinity;
63
+ const seenOutputs = new Set<number>();
64
+ const tokenDistance =
65
+ usableWindow > 0 && Number.isFinite(usableWindow)
66
+ ? usableWindow * RECLAIM_DISTANCE_WINDOW_FRACTION
67
+ : Infinity;
68
+ for (let index = entries.length - 1; index >= 0; index--) {
69
+ const entry = entries[index];
70
+ if (!entry) continue;
71
+ const tag = entry.toolOutputTag;
72
+ // Multi-part outputs are positioned at their last result, not their call
73
+ // or first chunk: an output must never count its own remaining payload.
74
+ if (tag !== undefined && !seenOutputs.has(tag)) {
75
+ seenOutputs.add(tag);
76
+ if (newerTurns >= RECLAIM_DISTANCE_USER_TURNS || newerTokens >= tokenDistance) {
77
+ farTags.push(tag);
78
+ } else {
79
+ firstYoungTag = Math.min(firstYoungTag, tag);
80
+ }
81
+ }
82
+ if (entry.userTurn) newerTurns++;
83
+ if (Number.isFinite(entry.tokens) && entry.tokens > 0) newerTokens += entry.tokens;
84
+ }
85
+ // Tags follow call order, not result arrival. A late lower-tag sibling must
86
+ // hold the prefix even when a higher-tag result is already distant.
87
+ return farTags.reduce(
88
+ (bookmark, tag) => (tag < firstYoungTag ? Math.max(bookmark, tag) : bookmark),
89
+ 0,
90
+ );
91
+ }
@@ -0,0 +1,36 @@
1
+ import assert from "node:assert/strict";
2
+ import { AsyncLocalStorage } from "node:async_hooks";
3
+ import { setTimeout as delay, setImmediate as tick } from "node:timers/promises";
4
+ import { Worker } from "node:worker_threads";
5
+ import { inRootAsyncContext } from "./sqlite";
6
+
7
+ const scope = new AsyncLocalStorage();
8
+ const gc = globalThis.Bun ? () => Bun.gc(true) : globalThis.gc;
9
+ const references = {};
10
+ const resources = [];
11
+ const timerStores = {};
12
+ const ambientTick = () => { timerStores.ambient = scope.getStore() !== undefined; };
13
+ const rootTick = () => { timerStores.root = scope.getStore() !== undefined; };
14
+ function create(name, root, worker) {
15
+ const payload = { synthetic: name };
16
+ references[name] = new WeakRef(payload);
17
+ scope.run(payload, () => {
18
+ const make = worker
19
+ ? () => new Worker(new URL("worker.mjs", import.meta.url))
20
+ : () => setInterval(root ? rootTick : ambientTick, 5);
21
+ resources.push(root ? inRootAsyncContext(make) : make());
22
+ assert.equal(scope.getStore(), payload, "Root execution must restore the caller's store");
23
+ });
24
+ }
25
+ create("ambientWorker", false, true);
26
+ create("rootWorker", true, true);
27
+ create("ambientTimer", false, false);
28
+ create("rootTimer", true, false);
29
+ await delay(30);
30
+ for (let i = 0; i < 12; i++) { await tick(); gc(); await tick(); }
31
+ const retained = Object.fromEntries(Object.entries(references).map(([key, ref]) => [key, Number(ref.deref() !== undefined)]));
32
+ for (const resource of resources) {
33
+ if (resource instanceof Worker) await resource.terminate();
34
+ else clearInterval(resource);
35
+ }
36
+ console.log(JSON.stringify({ runtime: globalThis.Bun ? `Bun ${Bun.version}` : process.version, timerStores, retained }));
@@ -0,0 +1,49 @@
1
+ import { expect, test } from "bun:test";
2
+ import { rm, writeFile } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { createTestTempDirFromPath } from "./test-temp-dir";
6
+
7
+ for (const runtime of ["Node", "Bun"] as const) {
8
+ test(`root resources do not inherit request stores (${runtime})`, async () => {
9
+ const directory = createTestTempDirFromPath(join(tmpdir(), "root-context-"));
10
+ try {
11
+ const entry = join(directory, "probe.mjs");
12
+ const build = await Bun.build({
13
+ entrypoints: [new URL("root-async-context.probe.mjs", import.meta.url).pathname],
14
+ target: "node",
15
+ format: "esm",
16
+ external: ["bun:sqlite", "node:sqlite"],
17
+ });
18
+ expect(build.success).toBe(true);
19
+ await writeFile(entry, await build.outputs[0].text());
20
+ await writeFile(
21
+ join(directory, "worker.mjs"),
22
+ `import { parentPort } from 'node:worker_threads'; parentPort.on('message', () => {});`,
23
+ );
24
+ const child = Bun.spawn(
25
+ [...(runtime === "Node" ? ["node", "--expose-gc"] : [process.execPath]), entry],
26
+ {
27
+ stdout: "pipe",
28
+ stderr: "pipe",
29
+ windowsHide: true,
30
+ },
31
+ );
32
+ const [stdout, stderr, exit] = await Promise.all([
33
+ new Response(child.stdout).text(),
34
+ new Response(child.stderr).text(),
35
+ child.exited,
36
+ ]);
37
+ expect(exit, stderr).toBe(0);
38
+ const result = JSON.parse(stdout);
39
+ console.log(`${runtime} ALS propagation: ${stdout.trim()}`);
40
+ expect(result.timerStores).toEqual({ ambient: true, root: false });
41
+ expect(result.retained.rootWorker).toBe(0);
42
+ expect(result.retained.rootTimer).toBe(0);
43
+ expect(result.retained.ambientTimer).toBe(1);
44
+ if (runtime === "Node") expect(result.retained.ambientWorker).toBe(1);
45
+ } finally {
46
+ await rm(directory, { recursive: true, force: true });
47
+ }
48
+ });
49
+ }