@cortexkit/opencode-magic-context 0.42.4 → 0.42.6

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 (144) hide show
  1. package/README.md +2 -2
  2. package/dist/config/index.d.ts +9 -0
  3. package/dist/config/index.d.ts.map +1 -1
  4. package/dist/config/schema/magic-context.d.ts +1 -0
  5. package/dist/config/schema/magic-context.d.ts.map +1 -1
  6. package/dist/{conflict-warning-hook-jyj54gbe.js → conflict-warning-hook-aktepwpz.js} +3 -3
  7. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  8. package/dist/features/magic-context/context-authority.d.ts +28 -1
  9. package/dist/features/magic-context/context-authority.d.ts.map +1 -1
  10. package/dist/features/magic-context/dreamer/curate-category-rotation.d.ts +26 -0
  11. package/dist/features/magic-context/dreamer/curate-category-rotation.d.ts.map +1 -0
  12. package/dist/features/magic-context/dreamer/open-opencode-db.d.ts.map +1 -1
  13. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts +7 -5
  14. package/dist/features/magic-context/dreamer/storage-task-schedule.d.ts.map +1 -1
  15. package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
  16. package/dist/features/magic-context/dreamer/task-gates.d.ts +1 -0
  17. package/dist/features/magic-context/dreamer/task-gates.d.ts.map +1 -1
  18. package/dist/features/magic-context/dreamer/task-prompts.d.ts +3 -0
  19. package/dist/features/magic-context/dreamer/task-prompts.d.ts.map +1 -1
  20. package/dist/features/magic-context/dreamer/task-registry.d.ts +6 -0
  21. package/dist/features/magic-context/dreamer/task-registry.d.ts.map +1 -1
  22. package/dist/features/magic-context/dreamer/task-scheduler.d.ts +5 -1
  23. package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
  24. package/dist/features/magic-context/memory/embedding-local.d.ts +12 -0
  25. package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
  26. package/dist/features/magic-context/memory/embedding.d.ts +1 -1
  27. package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
  28. package/dist/features/magic-context/message-index.d.ts.map +1 -1
  29. package/dist/features/magic-context/migrations.d.ts +12 -0
  30. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  31. package/dist/features/magic-context/project-embedding-registry.d.ts +1 -0
  32. package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
  33. package/dist/features/magic-context/session-project-backfill.d.ts.map +1 -1
  34. package/dist/features/magic-context/storage-db.d.ts +1 -1
  35. package/dist/features/magic-context/tool-owner-backfill.d.ts.map +1 -1
  36. package/dist/hooks/auto-update-checker/index.d.ts.map +1 -1
  37. package/dist/hooks/magic-context/cache-busting-signals.d.ts +0 -1
  38. package/dist/hooks/magic-context/cache-busting-signals.d.ts.map +1 -1
  39. package/dist/hooks/magic-context/channel2-delivery.d.ts +8 -0
  40. package/dist/hooks/magic-context/channel2-delivery.d.ts.map +1 -1
  41. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts +12 -0
  42. package/dist/hooks/magic-context/compartment-runner-incremental.d.ts.map +1 -1
  43. package/dist/hooks/magic-context/hook.d.ts +4 -4
  44. package/dist/hooks/magic-context/hook.d.ts.map +1 -1
  45. package/dist/hooks/magic-context/module-state-sync.d.ts +3 -0
  46. package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
  47. package/dist/hooks/magic-context/module-transport.d.ts +24 -2
  48. package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
  49. package/dist/hooks/magic-context/module-wire.d.ts +1 -1
  50. package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
  51. package/dist/hooks/magic-context/producer-window-guard.d.ts +31 -1
  52. package/dist/hooks/magic-context/producer-window-guard.d.ts.map +1 -1
  53. package/dist/hooks/magic-context/read-session-chunk.d.ts +6 -0
  54. package/dist/hooks/magic-context/read-session-chunk.d.ts.map +1 -1
  55. package/dist/hooks/magic-context/read-session-db.d.ts +8 -0
  56. package/dist/hooks/magic-context/read-session-db.d.ts.map +1 -1
  57. package/dist/hooks/magic-context/read-session-formatting.d.ts +6 -0
  58. package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
  59. package/dist/hooks/magic-context/rust-mode-transform.d.ts +30 -0
  60. package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
  61. package/dist/hooks/magic-context/rust-refusal-recovery.d.ts +28 -0
  62. package/dist/hooks/magic-context/rust-refusal-recovery.d.ts.map +1 -0
  63. package/dist/hooks/magic-context/tail-hygiene-walk.d.ts.map +1 -1
  64. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  65. package/dist/hooks/magic-context/transform.d.ts +7 -0
  66. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  67. package/dist/{index-bkg2fdj1.js → index-4wwr6cz0.js} +3 -0
  68. package/dist/{index-jkrqhc7y.js → index-a9ssj59b.js} +94 -15
  69. package/dist/{index-faay035c.js → index-djp6rwz6.js} +484 -57
  70. package/dist/{index-p474rw9p.js → index-ngjmj6xx.js} +1 -1
  71. package/dist/{index-d8mgqezq.js → index-s61hh7gv.js} +50 -17
  72. package/dist/{index-1ar7b3e9.js → index-znraa0kv.js} +1 -1
  73. package/dist/index.d.ts.map +1 -1
  74. package/dist/index.js +1667 -380
  75. package/dist/plugin/embedding-bootstrap.d.ts.map +1 -1
  76. package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
  77. package/dist/plugin/memory-id-translation.d.ts +23 -0
  78. package/dist/plugin/memory-id-translation.d.ts.map +1 -0
  79. package/dist/plugin/rust-tool-backends.d.ts +1 -1
  80. package/dist/plugin/rust-tool-backends.d.ts.map +1 -1
  81. package/dist/{recomp-orchestrator-c4sa354a.js → recomp-orchestrator-0xx1364v.js} +4 -4
  82. package/dist/{send-session-notification-xnypkxpx.js → send-session-notification-jbxknvar.js} +2 -2
  83. package/dist/shared/auto-update-provenance.d.ts +9 -0
  84. package/dist/shared/auto-update-provenance.d.ts.map +1 -0
  85. package/dist/shared/conflict-detector.d.ts +5 -1
  86. package/dist/shared/conflict-detector.d.ts.map +1 -1
  87. package/dist/shared/opencode-db-path.d.ts +31 -2
  88. package/dist/shared/opencode-db-path.d.ts.map +1 -1
  89. package/dist/shared/prompt-surface-runtime.d.ts +3 -3
  90. package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
  91. package/dist/{storage-meta-session-x3j0q9qy.js → storage-meta-session-z818pn1e.js} +2 -2
  92. package/dist/{task-config-ys0arma5.js → task-config-cv8k9y8r.js} +1 -1
  93. package/dist/tools/ctx-expand/mode.d.ts +31 -0
  94. package/dist/tools/ctx-expand/mode.d.ts.map +1 -0
  95. package/dist/tools/ctx-expand/tools.d.ts.map +1 -1
  96. package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
  97. package/dist/tools/ctx-note/constants.d.ts +1 -1
  98. package/dist/tools/ctx-note/constants.d.ts.map +1 -1
  99. package/dist/tools/ctx-note/tools.d.ts.map +1 -1
  100. package/dist/tools/ctx-note/types.d.ts +1 -1
  101. package/dist/tools/ctx-note/types.d.ts.map +1 -1
  102. package/dist/tools/ctx-reduce/constants.d.ts +1 -1
  103. package/dist/tools/ctx-reduce/constants.d.ts.map +1 -1
  104. package/dist/tools/ctx-search/types.d.ts +1 -1
  105. package/dist/tools/ctx-search/types.d.ts.map +1 -1
  106. package/dist/tools/light-descriptions.d.ts +2 -2
  107. package/dist/tools/light-descriptions.d.ts.map +1 -1
  108. package/dist/v2/hidden-completion.d.ts +69 -13
  109. package/dist/v2/hidden-completion.d.ts.map +1 -1
  110. package/dist/v2/hooks/context.d.ts +13 -1
  111. package/dist/v2/hooks/context.d.ts.map +1 -1
  112. package/dist/v2/hooks/hidden-child.d.ts +31 -0
  113. package/dist/v2/hooks/hidden-child.d.ts.map +1 -0
  114. package/dist/v2/hooks/store.d.ts.map +1 -1
  115. package/dist/v2/hooks/types.d.ts +99 -19
  116. package/dist/v2/hooks/types.d.ts.map +1 -1
  117. package/dist/v2/server.d.ts +19 -0
  118. package/dist/v2/server.d.ts.map +1 -1
  119. package/dist/v2/server.js +2311 -2724
  120. package/dist/v2/store-reader.d.ts +11 -5
  121. package/dist/v2/store-reader.d.ts.map +1 -1
  122. package/dist/v2/tui/index.d.ts +10 -0
  123. package/dist/v2/tui/index.d.ts.map +1 -0
  124. package/dist/v2/tui/types.d.ts +80 -0
  125. package/dist/v2/tui/types.d.ts.map +1 -0
  126. package/package.json +5 -4
  127. package/src/agents/magic-context-prompt.ts +1 -1
  128. package/src/config/index.ts +31 -1
  129. package/src/config/protected-tokens.test.ts +40 -0
  130. package/src/config/schema/magic-context.ts +8 -3
  131. package/src/features/magic-context/dreamer/task-registry.ts +10 -0
  132. package/src/shared/auto-update-provenance.ts +42 -0
  133. package/src/shared/conflict-detector.test.ts +53 -0
  134. package/src/shared/conflict-detector.ts +88 -19
  135. package/src/shared/models-dev-cache.test.ts +25 -0
  136. package/src/shared/opencode-db-path.test.ts +96 -0
  137. package/src/shared/opencode-db-path.ts +146 -15
  138. package/src/shared/prompt-surface-a1-golden.md +12 -16
  139. package/src/shared/prompt-surface-runtime.test.ts +20 -0
  140. package/src/shared/prompt-surface-runtime.ts +7 -5
  141. package/src/tui/entry.mjs +11 -1
  142. package/src/v2/tui/host-contract.test.ts +202 -0
  143. package/src/v2/tui/index.ts +270 -0
  144. package/src/v2/tui/types.ts +67 -0
@@ -3,11 +3,14 @@ import { join } from "node:path";
3
3
  import { readJsoncFile } from "./jsonc-parser";
4
4
  import { log } from "./logger";
5
5
  import { getOpenCodeConfigPaths } from "./opencode-config-dir";
6
+ import type { OpenCodeHostGeneration } from "./opencode-db-path";
6
7
 
7
8
  interface OpenCodeConfig {
8
9
  compaction?: {
9
10
  auto?: boolean;
10
11
  prune?: boolean;
12
+ keep?: { tokens?: number };
13
+ buffer?: number;
11
14
  };
12
15
  // OpenCode allows plugins as plain strings or [name, options] tuples.
13
16
  plugin?: Array<string | [string, unknown]>;
@@ -62,6 +65,8 @@ export interface ConflictResult {
62
65
  export interface ResolvedCompaction {
63
66
  auto: boolean;
64
67
  prune: boolean;
68
+ keepTokens?: number;
69
+ buffer?: number;
65
70
  }
66
71
 
67
72
  /**
@@ -92,6 +97,7 @@ export interface ResolvedCompaction {
92
97
  export interface DetectConflictsOptions {
93
98
  compactionEnabled?: boolean;
94
99
  resolvedCompaction?: ResolvedCompaction;
100
+ hostGeneration?: OpenCodeHostGeneration;
95
101
  }
96
102
 
97
103
  /**
@@ -113,6 +119,7 @@ export function detectConflicts(
113
119
  options?: DetectConflictsOptions,
114
120
  ): ConflictResult {
115
121
  const compactionEnabled = options?.compactionEnabled ?? true;
122
+ const hostGeneration = options?.hostGeneration ?? "v1";
116
123
  const conflicts: ConflictResult["conflicts"] = {
117
124
  compactionAuto: false,
118
125
  compactionPrune: false,
@@ -129,7 +136,8 @@ export function detectConflicts(
129
136
  // wrongly disabling the plugin for users whose auto=false lives in a layer
130
137
  // the file reader cannot see). When the resolved fetch failed, fall back to
131
138
  // the file-based check unchanged.
132
- let compactionResult = options?.resolvedCompaction ?? checkCompaction(directory);
139
+ let compactionResult =
140
+ options?.resolvedCompaction ?? checkCompaction(directory, hostGeneration);
133
141
  // OPENCODE_DISABLE_AUTOCOMPACT short-circuits BOTH arms: it is the first,
134
142
  // cheapest check and is correct regardless of which arm produced the value.
135
143
  // (checkCompaction already applies it internally for the file arm; this
@@ -141,7 +149,7 @@ export function detectConflicts(
141
149
  // compaction-off mode the user has explicitly handed the window to native
142
150
  // compaction (or nothing), so compaction.auto=true / prune=true are the
143
151
  // intended state, not a plugin-disabling conflict.
144
- if (compactionEnabled && compactionResult.auto) {
152
+ if (hostGeneration === "v1" && compactionEnabled && compactionResult.auto) {
145
153
  conflicts.compactionAuto = true;
146
154
  reasons.push(
147
155
  options?.resolvedCompaction
@@ -149,7 +157,7 @@ export function detectConflicts(
149
157
  : "OpenCode auto-compaction is enabled (compaction.auto=true)",
150
158
  );
151
159
  }
152
- if (compactionEnabled && compactionResult.prune) {
160
+ if (hostGeneration === "v1" && compactionEnabled && compactionResult.prune) {
153
161
  conflicts.compactionPrune = true;
154
162
  reasons.push(
155
163
  options?.resolvedCompaction
@@ -192,7 +200,10 @@ export function detectConflicts(
192
200
  hasConflict: reasons.length > 0,
193
201
  reasons,
194
202
  conflicts,
195
- nativeCompaction: { auto: compactionResult.auto, prune: compactionResult.prune },
203
+ nativeCompaction: {
204
+ auto: compactionResult.auto,
205
+ prune: hostGeneration === "v1" ? compactionResult.prune : false,
206
+ },
196
207
  };
197
208
  }
198
209
 
@@ -221,6 +232,8 @@ interface ResolvedCompactionBlock {
221
232
  compaction?: {
222
233
  auto?: boolean;
223
234
  prune?: boolean;
235
+ keep?: { tokens?: number };
236
+ buffer?: number;
224
237
  };
225
238
  }
226
239
 
@@ -249,6 +262,7 @@ interface ResolvedCompactionBlock {
249
262
  export async function resolveCompactionForBoot(
250
263
  client: OpencodeConfigClientLike,
251
264
  timeoutMs = 2_000,
265
+ hostGeneration: OpenCodeHostGeneration = "v1",
252
266
  ): Promise<ResolvedCompaction | null> {
253
267
  const controller = new AbortController();
254
268
  let timer: ReturnType<typeof setTimeout> | undefined;
@@ -276,6 +290,28 @@ export async function resolveCompactionForBoot(
276
290
  // The SDK's generated `Config` type has no `compaction` key, so read it
277
291
  // defensively from the runtime response.
278
292
  const compaction = (result?.data as ResolvedCompactionBlock | undefined)?.compaction;
293
+ if (hostGeneration === "v2") {
294
+ if (typeof compaction?.auto !== "boolean") {
295
+ log(
296
+ `[magic-context] conflict-detector: resolved v2 config carried no explicit compaction.auto (${JSON.stringify(compaction) ?? "absent"}); falling back to file-based detection`,
297
+ );
298
+ return null;
299
+ }
300
+ const keepTokens = compaction.keep?.tokens;
301
+ const buffer = compaction.buffer;
302
+ if (
303
+ (keepTokens !== undefined && (!Number.isInteger(keepTokens) || keepTokens < 0)) ||
304
+ (buffer !== undefined && (!Number.isInteger(buffer) || buffer < 0))
305
+ ) {
306
+ return null;
307
+ }
308
+ return {
309
+ auto: compaction.auto,
310
+ prune: false,
311
+ ...(keepTokens === undefined ? {} : { keepTokens }),
312
+ ...(buffer === undefined ? {} : { buffer }),
313
+ };
314
+ }
279
315
  // Explicit booleans only. An absent block (or non-boolean values) means
280
316
  // the response shape did not carry the resolved state — fall back to the
281
317
  // file arm rather than resolving to the plugin-disabling default.
@@ -293,28 +329,30 @@ export async function resolveCompactionForBoot(
293
329
  }
294
330
  }
295
331
 
296
- function checkCompaction(directory: string): { auto: boolean; prune: boolean } {
332
+ function checkCompaction(
333
+ directory: string,
334
+ hostGeneration: OpenCodeHostGeneration,
335
+ ): ResolvedCompaction {
297
336
  if (process.env.OPENCODE_DISABLE_AUTOCOMPACT) {
298
337
  return { auto: false, prune: false };
299
338
  }
300
339
 
301
340
  // Check project-level config first (higher precedence)
302
- const projectResult = readProjectCompaction(directory);
341
+ const projectResult = readProjectCompaction(directory, hostGeneration);
303
342
  if (projectResult.resolved) return projectResult;
304
343
 
305
344
  // Fall back to user-level config
306
- const userResult = readUserCompaction();
345
+ const userResult = readUserCompaction(hostGeneration);
307
346
  if (userResult.resolved) return userResult;
308
347
 
309
348
  // Default: OpenCode has compaction enabled by default
310
349
  return { auto: true, prune: false };
311
350
  }
312
351
 
313
- function readProjectCompaction(directory: string): {
314
- auto: boolean;
315
- prune: boolean;
316
- resolved: boolean;
317
- } {
352
+ function readProjectCompaction(
353
+ directory: string,
354
+ hostGeneration: OpenCodeHostGeneration,
355
+ ): ResolvedCompaction & { resolved: boolean } {
318
356
  // .opencode/ config has higher precedence
319
357
  const dotOcJsonc = join(directory, ".opencode", "opencode.jsonc");
320
358
  const dotOcJson = join(directory, ".opencode", "opencode.json");
@@ -323,8 +361,12 @@ function readProjectCompaction(directory: string): {
323
361
 
324
362
  if (dotOcConfig?.compaction) {
325
363
  const c = dotOcConfig.compaction;
326
- if (c.auto !== undefined || c.prune !== undefined) {
327
- return { auto: c.auto === true, prune: c.prune === true, resolved: true };
364
+ if (
365
+ c.auto !== undefined ||
366
+ (hostGeneration === "v1" && c.prune !== undefined) ||
367
+ (hostGeneration === "v2" && (c.keep?.tokens !== undefined || c.buffer !== undefined))
368
+ ) {
369
+ return resolvedCompactionBlock(c, hostGeneration);
328
370
  }
329
371
  }
330
372
 
@@ -336,15 +378,21 @@ function readProjectCompaction(directory: string): {
336
378
 
337
379
  if (rootConfig?.compaction) {
338
380
  const c = rootConfig.compaction;
339
- if (c.auto !== undefined || c.prune !== undefined) {
340
- return { auto: c.auto === true, prune: c.prune === true, resolved: true };
381
+ if (
382
+ c.auto !== undefined ||
383
+ (hostGeneration === "v1" && c.prune !== undefined) ||
384
+ (hostGeneration === "v2" && (c.keep?.tokens !== undefined || c.buffer !== undefined))
385
+ ) {
386
+ return resolvedCompactionBlock(c, hostGeneration);
341
387
  }
342
388
  }
343
389
 
344
390
  return { auto: false, prune: false, resolved: false };
345
391
  }
346
392
 
347
- function readUserCompaction(): { auto: boolean; prune: boolean; resolved: boolean } {
393
+ function readUserCompaction(
394
+ hostGeneration: OpenCodeHostGeneration,
395
+ ): ResolvedCompaction & { resolved: boolean } {
348
396
  try {
349
397
  const paths = getOpenCodeConfigPaths({ binary: "opencode" });
350
398
  const config =
@@ -353,8 +401,13 @@ function readUserCompaction(): { auto: boolean; prune: boolean; resolved: boolea
353
401
 
354
402
  if (config?.compaction) {
355
403
  const c = config.compaction;
356
- if (c.auto !== undefined || c.prune !== undefined) {
357
- return { auto: c.auto === true, prune: c.prune === true, resolved: true };
404
+ if (
405
+ c.auto !== undefined ||
406
+ (hostGeneration === "v1" && c.prune !== undefined) ||
407
+ (hostGeneration === "v2" &&
408
+ (c.keep?.tokens !== undefined || c.buffer !== undefined))
409
+ ) {
410
+ return resolvedCompactionBlock(c, hostGeneration);
358
411
  }
359
412
  }
360
413
  } catch {
@@ -363,6 +416,22 @@ function readUserCompaction(): { auto: boolean; prune: boolean; resolved: boolea
363
416
  return { auto: false, prune: false, resolved: false };
364
417
  }
365
418
 
419
+ function resolvedCompactionBlock(
420
+ block: NonNullable<OpenCodeConfig["compaction"]>,
421
+ hostGeneration: OpenCodeHostGeneration,
422
+ ): ResolvedCompaction & { resolved: true } {
423
+ if (hostGeneration === "v2") {
424
+ return {
425
+ auto: block.auto !== false,
426
+ prune: false,
427
+ resolved: true,
428
+ ...(typeof block.keep?.tokens === "number" ? { keepTokens: block.keep.tokens } : {}),
429
+ ...(typeof block.buffer === "number" ? { buffer: block.buffer } : {}),
430
+ };
431
+ }
432
+ return { auto: block.auto === true, prune: block.prune === true, resolved: true };
433
+ }
434
+
366
435
  // --- DCP detection ---
367
436
 
368
437
  /**
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { mkdtempSync, rmSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { resolveKnownHistorianContextLimit } from "../hooks/magic-context/derive-budgets";
5
6
  import {
6
7
  clearModelsDevCache,
7
8
  getModelsDevCacheState,
@@ -230,6 +231,30 @@ describe("models-dev-cache (SDK-only)", () => {
230
231
  expect(getSdkContextLimit("openai", "gpt-5.4-mini")).toBe(922000);
231
232
  });
232
233
 
234
+ test("explicit SDK-resolved config limit wins over a larger catalog or detected value", async () => {
235
+ // OpenCode's config.providers() has already applied opencode.json over its
236
+ // catalog. The cache must preserve that 120K effective value, and a later
237
+ // API-detected 262K value may narrow but never enlarge it.
238
+ await refreshModelLimitsFromApi(
239
+ makeClient([
240
+ {
241
+ id: "regolo",
242
+ models: {
243
+ "qwen3.5-122b": { limit: { context: 120_000, output: 32_000 } },
244
+ },
245
+ },
246
+ ]),
247
+ );
248
+
249
+ expect(
250
+ getSdkContextLimit("regolo", "qwen3.5-122b", 262_144, {
251
+ reservation: "none",
252
+ detectedLimitProvenance: "combined",
253
+ }),
254
+ ).toBe(120_000);
255
+ expect(resolveKnownHistorianContextLimit("regolo/qwen3.5-122b")).toBe(120_000);
256
+ });
257
+
233
258
  test("narrows raw context with detected wire truth before reserving output", async () => {
234
259
  await refreshModelLimitsFromApi(
235
260
  makeClient([
@@ -5,13 +5,17 @@ import { mkdirSync, mkdtempSync, rmSync, unlinkSync, utimesSync, writeFileSync }
5
5
  import { tmpdir } from "node:os";
6
6
  import { join } from "node:path";
7
7
  import {
8
+ assertOpenCodeStoreGeneration,
9
+ detectOpenCodeStoreGeneration,
8
10
  formatOpenCodeDbDoctorLine,
9
11
  formatOpenCodeDbMissingBanner,
10
12
  formatOpenCodeDbMissingStatusLine,
11
13
  openCodeDbPathExists,
12
14
  resetOpenCodeDbPathStateForTesting,
13
15
  resolveOpenCodeDbPath,
16
+ sourceOpenCodeDatabaseFilename,
14
17
  } from "./opencode-db-path";
18
+ import { Database } from "./sqlite";
15
19
 
16
20
  const ORIGINAL_ENV = {
17
21
  XDG_DATA_HOME: process.env.XDG_DATA_HOME,
@@ -173,6 +177,98 @@ describe("resolveOpenCodeDbPath", () => {
173
177
  });
174
178
  });
175
179
 
180
+ it("uses the verbatim v2 channel filename table without changing v1 defaults", () => {
181
+ const { dataHome, openCodeDir } = useDataHome();
182
+ for (const channel of ["latest", "dev", "beta", "next", "prod"]) {
183
+ expect(sourceOpenCodeDatabaseFilename("v2", channel, {})).toBe("opencode.db");
184
+ }
185
+ expect(sourceOpenCodeDatabaseFilename("v2", "a/b c!._-", {})).toBe("opencode-abc._-.db");
186
+ expect(
187
+ resolveOpenCodeDbPath("v2", {
188
+ dataHome,
189
+ channel: "local",
190
+ env: { OPENCODE_DB: "opencode2.db" },
191
+ }),
192
+ ).toEqual({
193
+ path: join(openCodeDir, "opencode2.db"),
194
+ source: "OPENCODE_DB",
195
+ channel: null,
196
+ });
197
+ });
198
+
199
+ it("detects store generations and refuses a mismatched schema before reading", () => {
200
+ const { openCodeDir } = useDataHome();
201
+ const v1Path = join(openCodeDir, "v1.db");
202
+ const v2Path = join(openCodeDir, "v2.db");
203
+ const v1 = new Database(v1Path);
204
+ const v2 = new Database(v2Path);
205
+ try {
206
+ v1.exec("CREATE TABLE message(id TEXT); CREATE TABLE part(id TEXT)");
207
+ v2.exec("CREATE TABLE session_message(id TEXT)");
208
+ expect(detectOpenCodeStoreGeneration(v1)).toBe("v1");
209
+ expect(detectOpenCodeStoreGeneration(v2)).toBe("v2");
210
+ expect(() => assertOpenCodeStoreGeneration(v2, "v1", v2Path)).toThrow(
211
+ "expected v1, found v2",
212
+ );
213
+ expect(() => assertOpenCodeStoreGeneration(v1, "v2", v1Path)).toThrow(
214
+ "expected v2, found v1",
215
+ );
216
+ } finally {
217
+ v1.close();
218
+ v2.close();
219
+ }
220
+ });
221
+
222
+ it("reads a live OpenCode 1.18.x store as v1 even though it ships session_message", () => {
223
+ const { openCodeDir } = useDataHome();
224
+ const livePath = join(openCodeDir, "live-v1.db");
225
+ const live = new Database(livePath);
226
+ try {
227
+ // Captured from a running OpenCode 1.18.30 store. session_message exists in v1,
228
+ // so a detector keyed on its presence calls a v1 host v2 and every v1 reader
229
+ // (historian chunk, marker discovery, message index, tool-owner backfill) then
230
+ // refuses. Keep this table list as observed, not as remembered.
231
+ for (const table of [
232
+ "account",
233
+ "event",
234
+ "message",
235
+ "part",
236
+ "permission",
237
+ "project",
238
+ "session",
239
+ "session_message",
240
+ "session_run_lease",
241
+ "todo",
242
+ "workspace",
243
+ ]) {
244
+ live.exec(`CREATE TABLE ${table}(id TEXT)`);
245
+ }
246
+ expect(detectOpenCodeStoreGeneration(live)).toBe("v1");
247
+ expect(() => assertOpenCodeStoreGeneration(live, "v1", livePath)).not.toThrow();
248
+ expect(() => assertOpenCodeStoreGeneration(live, "v2", livePath)).toThrow(
249
+ "expected v2, found v1",
250
+ );
251
+ } finally {
252
+ live.close();
253
+ }
254
+ });
255
+
256
+ it("treats a store with no schema yet as empty rather than as a conflicting host", () => {
257
+ const { openCodeDir } = useDataHome();
258
+ const freshPath = join(openCodeDir, "fresh.db");
259
+ const fresh = new Database(freshPath);
260
+ try {
261
+ // A host that has not written its first row looks like this. Readers have always
262
+ // seen it as empty; refusing here throws inside the historian, marker and index
263
+ // readers on every fresh data directory.
264
+ expect(detectOpenCodeStoreGeneration(fresh)).toBe("unknown");
265
+ expect(() => assertOpenCodeStoreGeneration(fresh, "v1", freshPath)).not.toThrow();
266
+ expect(() => assertOpenCodeStoreGeneration(fresh, "v2", freshPath)).not.toThrow();
267
+ } finally {
268
+ fresh.close();
269
+ }
270
+ });
271
+
176
272
  it("formats the missing banner, status, and doctor lines by value", () => {
177
273
  const { openCodeDir } = useDataHome();
178
274
  const lookedFor = [
@@ -3,6 +3,25 @@ import { homedir } from "node:os";
3
3
  import { isAbsolute, join } from "node:path";
4
4
 
5
5
  export type OpenCodeDbPathSource = "OPENCODE_DB" | "channel" | "default" | "discovered";
6
+ export type OpenCodeHostGeneration = "v1" | "v2";
7
+ export type OpenCodeStoreGeneration = OpenCodeHostGeneration | "unknown";
8
+
9
+ export interface ResolveOpenCodeDbPathOptions {
10
+ dataHome?: string;
11
+ channel?: string;
12
+ env?: NodeJS.ProcessEnv;
13
+ }
14
+
15
+ export function openCodeHostGenerationFromVersion(
16
+ version: string | null | undefined,
17
+ ): OpenCodeHostGeneration {
18
+ const major = Number.parseInt(version?.match(/\d+/)?.[0] ?? "", 10);
19
+ return Number.isFinite(major) && major >= 2 ? "v2" : "v1";
20
+ }
21
+
22
+ export interface OpenCodeStoreSchemaDatabase {
23
+ prepare(sql: string): { all(...params: unknown[]): unknown[] };
24
+ }
6
25
 
7
26
  export interface OpenCodeDbPathResolution {
8
27
  path: string;
@@ -24,16 +43,22 @@ let cachedResolution: CachedResolution | null = null;
24
43
  let lastReadFailure: OpenCodeDbReadFailure | null = null;
25
44
  const claimedDiagnostics = new Set<string>();
26
45
 
27
- function openCodeDataDir(): string {
28
- return join(process.env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode");
46
+ function openCodeDataDir(env: NodeJS.ProcessEnv = process.env, dataHome?: string): string {
47
+ return join(dataHome ?? env.XDG_DATA_HOME ?? join(homedir(), ".local", "share"), "opencode");
29
48
  }
30
49
 
31
- function environmentKey(dataDir: string): string {
50
+ function environmentKey(
51
+ dataDir: string,
52
+ hostGeneration: OpenCodeHostGeneration,
53
+ channel: string | undefined,
54
+ env: NodeJS.ProcessEnv,
55
+ ): string {
32
56
  return [
57
+ hostGeneration,
33
58
  dataDir,
34
- process.env.OPENCODE_DB ?? "",
35
- process.env.OPENCODE_DISABLE_CHANNEL_DB ?? "",
36
- process.env.OPENCODE_CHANNEL ?? "",
59
+ env.OPENCODE_DB ?? "",
60
+ env.OPENCODE_DISABLE_CHANNEL_DB ?? "",
61
+ channel ?? env.OPENCODE_CHANNEL ?? "",
37
62
  ].join("\0");
38
63
  }
39
64
 
@@ -81,8 +106,11 @@ function discoverOpenCodeDb(dataDir: string): OpenCodeDbPathResolution {
81
106
  return { path: existing.path, source: "discovered", channel };
82
107
  }
83
108
 
84
- function resolveFresh(dataDir: string): OpenCodeDbPathResolution {
85
- const explicit = process.env.OPENCODE_DB;
109
+ function resolveV1Fresh(
110
+ dataDir: string,
111
+ env: NodeJS.ProcessEnv = process.env,
112
+ ): OpenCodeDbPathResolution {
113
+ const explicit = env.OPENCODE_DB;
86
114
  if (explicit !== undefined && explicit.length > 0) {
87
115
  if (explicit === ":memory:") {
88
116
  return { path: explicit, source: "OPENCODE_DB", channel: null };
@@ -94,12 +122,12 @@ function resolveFresh(dataDir: string): OpenCodeDbPathResolution {
94
122
  };
95
123
  }
96
124
 
97
- const disableChannelDb = process.env.OPENCODE_DISABLE_CHANNEL_DB;
125
+ const disableChannelDb = env.OPENCODE_DISABLE_CHANNEL_DB;
98
126
  if (disableChannelDb === "1" || disableChannelDb === "true") {
99
127
  return { path: join(dataDir, "opencode.db"), source: "default", channel: null };
100
128
  }
101
129
 
102
- const channel = process.env.OPENCODE_CHANNEL;
130
+ const channel = env.OPENCODE_CHANNEL;
103
131
  if (channel !== undefined && channel.length > 0) {
104
132
  return { path: channelPath(dataDir, channel), source: "channel", channel };
105
133
  }
@@ -107,10 +135,59 @@ function resolveFresh(dataDir: string): OpenCodeDbPathResolution {
107
135
  return discoverOpenCodeDb(dataDir);
108
136
  }
109
137
 
110
- /** Resolve an explicit DB override first, then channel paths; discover candidates when the compiled channel is unknown. */
111
- export function resolveOpenCodeDbPath(): OpenCodeDbPathResolution {
112
- const dataDir = openCodeDataDir();
113
- const key = environmentKey(dataDir);
138
+ /** Match OpenCode 2's filename table and strip path separators from custom channels. */
139
+ export function sourceOpenCodeDatabaseFilename(
140
+ hostGeneration: OpenCodeHostGeneration,
141
+ channel: string,
142
+ env: NodeJS.ProcessEnv = process.env,
143
+ ): string {
144
+ if (hostGeneration === "v1") {
145
+ const explicit = env.OPENCODE_DB;
146
+ if (explicit !== undefined && explicit.length > 0) return explicit;
147
+ if (env.OPENCODE_DISABLE_CHANNEL_DB === "1" || env.OPENCODE_DISABLE_CHANNEL_DB === "true") {
148
+ return "opencode.db";
149
+ }
150
+ return ["latest", "beta", "prod"].includes(channel)
151
+ ? "opencode.db"
152
+ : `opencode-${channel}.db`;
153
+ }
154
+
155
+ return (
156
+ env.OPENCODE_DB ??
157
+ (["latest", "dev", "beta", "next", "prod"].includes(channel) ||
158
+ env.OPENCODE_DISABLE_CHANNEL_DB === "1" ||
159
+ env.OPENCODE_DISABLE_CHANNEL_DB === "true"
160
+ ? "opencode.db"
161
+ : `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "")}.db`)
162
+ );
163
+ }
164
+
165
+ function resolveV2Fresh(
166
+ dataDir: string,
167
+ channel: string,
168
+ env: NodeJS.ProcessEnv,
169
+ ): OpenCodeDbPathResolution {
170
+ const filename = sourceOpenCodeDatabaseFilename("v2", channel, env);
171
+ const explicit = env.OPENCODE_DB !== undefined;
172
+ return {
173
+ path: filename === ":memory:" ? filename : join(dataDir, filename),
174
+ source: explicit ? "OPENCODE_DB" : env.OPENCODE_CHANNEL ? "channel" : "default",
175
+ channel: explicit ? null : channel,
176
+ };
177
+ }
178
+
179
+ /**
180
+ * Resolve the store for one host generation. The default is the historical v1
181
+ * resolver, including candidate discovery and all existing diagnostic text.
182
+ */
183
+ export function resolveOpenCodeDbPath(
184
+ hostGeneration: OpenCodeHostGeneration = "v1",
185
+ options: ResolveOpenCodeDbPathOptions = {},
186
+ ): OpenCodeDbPathResolution {
187
+ const env = options.env ?? process.env;
188
+ const dataDir = openCodeDataDir(env, options.dataHome);
189
+ const channel = options.channel ?? env.OPENCODE_CHANNEL;
190
+ const key = environmentKey(dataDir, hostGeneration, channel, env);
114
191
  if (
115
192
  cachedResolution?.key === key &&
116
193
  (!cachedResolution.existed || existsSync(cachedResolution.resolution.path))
@@ -119,7 +196,10 @@ export function resolveOpenCodeDbPath(): OpenCodeDbPathResolution {
119
196
  // An absent result is re-probed so a DB created after plugin boot is detected.
120
197
  }
121
198
 
122
- const resolution = resolveFresh(dataDir);
199
+ const resolution =
200
+ hostGeneration === "v2"
201
+ ? resolveV2Fresh(dataDir, channel ?? "latest", env)
202
+ : resolveV1Fresh(dataDir, env);
123
203
  cachedResolution = {
124
204
  key,
125
205
  resolution,
@@ -128,6 +208,57 @@ export function resolveOpenCodeDbPath(): OpenCodeDbPathResolution {
128
208
  return resolution;
129
209
  }
130
210
 
211
+ function schemaTableNames(
212
+ db: OpenCodeStoreSchemaDatabase,
213
+ schema: "main" | "oc_backfill" = "main",
214
+ ): Set<string> {
215
+ const rows = db
216
+ .prepare(
217
+ `SELECT name FROM ${schema}.sqlite_master WHERE type = 'table' AND name IN ('message', 'part', 'session', 'project', 'session_message')`,
218
+ )
219
+ .all() as Array<{ name?: unknown }>;
220
+ return new Set(rows.flatMap((row) => (typeof row.name === "string" ? [row.name] : [])));
221
+ }
222
+
223
+ /**
224
+ * Detect the persisted host schema.
225
+ *
226
+ * `session_message` does NOT identify v2: OpenCode 1.18.x ships that table beside `message`
227
+ * and `part` (verified against a live 1.18.30 store, pinned in this module's tests). Only the
228
+ * ABSENCE of the v1 message tables identifies a v2 store, so a v1 host is never mistaken for
229
+ * a v2 one. A store carrying neither is a host that has not written its schema yet.
230
+ */
231
+ export function detectOpenCodeStoreGeneration(
232
+ db: OpenCodeStoreSchemaDatabase,
233
+ schema: "main" | "oc_backfill" = "main",
234
+ ): OpenCodeStoreGeneration {
235
+ const tables = schemaTableNames(db, schema);
236
+ const hasV1Messages = tables.has("message") && tables.has("part");
237
+ if (hasV1Messages) return "v1";
238
+ if (tables.has("session_message")) return "v2";
239
+ if (tables.has("session") || tables.has("project")) return "v1";
240
+ return "unknown";
241
+ }
242
+
243
+ /** Refuse before a generation-specific query can read the other host's schema. */
244
+ export function assertOpenCodeStoreGeneration(
245
+ db: OpenCodeStoreSchemaDatabase,
246
+ expected: OpenCodeHostGeneration,
247
+ path: string,
248
+ schema: "main" | "oc_backfill" = "main",
249
+ ): void {
250
+ const actual = detectOpenCodeStoreGeneration(db, schema);
251
+ if (actual === expected) return;
252
+ // A store with none of these tables has no schema YET — a host that has not written its
253
+ // first row, or a fresh data directory. That is "nothing to read", not a conflicting host,
254
+ // and readers have always treated it as empty. Refusing here made every reader throw before
255
+ // OpenCode created its tables, which is how this guard took down 15 host e2e tests.
256
+ if (actual === "unknown") return;
257
+ throw new Error(
258
+ `OpenCode store generation mismatch at ${path}: expected ${expected}, found ${actual}; refusing generation-specific database access`,
259
+ );
260
+ }
261
+
131
262
  export function openCodeDbPathExists(
132
263
  resolution: OpenCodeDbPathResolution = resolveOpenCodeDbPath(),
133
264
  ): boolean {