@cortexkit/opencode-magic-context 0.36.1 → 0.37.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.
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/project-security.d.ts +2 -1
- package/dist/config/project-security.d.ts.map +1 -1
- package/dist/config/schema/magic-context.d.ts +10 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/{conflict-warning-hook-mt0ttc4x.js → conflict-warning-hook-an4fxhwf.js} +3 -3
- package/dist/{derive-budgets-88mxk2w1.js → derive-budgets-0f8xpyw2.js} +3 -2
- package/dist/features/magic-context/context-authority.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts +3 -1
- package/dist/features/magic-context/memory/embedding-openai.d.ts.map +1 -1
- package/dist/features/magic-context/memory/project-identity.d.ts +6 -0
- package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/runner.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/wake-plane.d.ts +21 -0
- package/dist/features/magic-context/smart-notes/wake-plane.d.ts.map +1 -0
- package/dist/features/magic-context/storage-db.d.ts +13 -2
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +22 -0
- package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-shared.d.ts.map +1 -1
- package/dist/features/magic-context/storage-primers.d.ts +11 -0
- package/dist/features/magic-context/storage-primers.d.ts.map +1 -1
- package/dist/features/magic-context/storage-schema-helpers.d.ts.map +1 -1
- package/dist/features/magic-context/user-memory/storage-user-memory.d.ts +7 -0
- package/dist/features/magic-context/user-memory/storage-user-memory.d.ts.map +1 -1
- package/dist/features/magic-context/window-report-ledger.d.ts +70 -0
- package/dist/features/magic-context/window-report-ledger.d.ts.map +1 -0
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-resolvers.d.ts +4 -0
- package/dist/hooks/magic-context/event-resolvers.d.ts.map +1 -1
- package/dist/hooks/magic-context/execute-status.d.ts +2 -1
- package/dist/hooks/magic-context/execute-status.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-replay.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-slot.d.ts +20 -0
- package/dist/hooks/magic-context/lkg-slot.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-state-sync.d.ts +8 -0
- package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-transport.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-wire.d.ts +6 -1
- package/dist/hooks/magic-context/module-wire.d.ts.map +1 -1
- package/dist/hooks/magic-context/rust-mode-transform.d.ts +17 -0
- package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/strip-content.d.ts +25 -0
- package/dist/hooks/magic-context/strip-content.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +11 -3
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts +10 -0
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/wrapup-orchestrator.d.ts.map +1 -1
- package/dist/{index-1skzp4dc.js → index-0ze9p983.js} +572 -92
- package/dist/{index-nsx2n9eg.js → index-4cz5nfxf.js} +3 -3
- package/dist/{index-f1tnb3vm.js → index-8pqfcghm.js} +21 -9
- package/dist/{index-zp6x6s6a.js → index-cae89eas.js} +25 -6
- package/dist/index-cgyfn1s2.js +53 -0
- package/dist/{index-3zqcdwqr.js → index-crke4z5j.js} +7 -4
- package/dist/{index-tsgs7td8.js → index-pck9vadr.js} +1 -1
- package/dist/index-q0t189ay.js +741 -0
- package/dist/{index-3tnkymva.js → index-sw8gnq34.js} +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1172 -287
- package/dist/plugin/hooks/create-session-hooks.d.ts +3 -0
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/plugin/rpc-handlers.d.ts.map +1 -1
- package/dist/{recomp-orchestrator-a0g9b52q.js → recomp-orchestrator-c3ff16k7.js} +7 -6
- package/dist/{safe-notification-target-c11fdda2.js → safe-notification-target-b693f9x3.js} +2 -2
- package/dist/{send-session-notification-bgtf4853.js → send-session-notification-dxske5zm.js} +3 -3
- package/dist/shared/conflict-detector.d.ts +63 -0
- package/dist/shared/conflict-detector.d.ts.map +1 -1
- package/dist/shared/harness-provider-map.d.ts +8 -0
- package/dist/shared/harness-provider-map.d.ts.map +1 -1
- package/dist/shared/logger.d.ts +8 -0
- package/dist/shared/logger.d.ts.map +1 -1
- package/dist/shared/models-dev-cache.d.ts +5 -0
- package/dist/shared/models-dev-cache.d.ts.map +1 -1
- package/dist/shared/prompt-surface-runtime.d.ts.map +1 -1
- package/dist/shared/prompt-surface.d.ts +2 -0
- package/dist/shared/prompt-surface.d.ts.map +1 -1
- package/dist/shared/rpc-client.d.ts.map +1 -1
- package/dist/shared/rpc-types.d.ts +14 -0
- package/dist/shared/rpc-types.d.ts.map +1 -1
- package/dist/shared/rpc-utils.d.ts +19 -9
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/shared/window-geometry.d.ts +89 -0
- package/dist/shared/window-geometry.d.ts.map +1 -0
- package/dist/{storage-meta-session-df9tstzr.js → storage-meta-session-mk4gsnke.js} +4 -3
- package/dist/tools/ctx-memory/tools.d.ts.map +1 -1
- package/dist/tools/ctx-note/tools.d.ts.map +1 -1
- package/dist/tui/data/context-db.d.ts.map +1 -1
- package/dist/tui-compiled/data/context-db.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/config/index.ts +2 -0
- package/src/config/latch-permanence-guard.test.ts +218 -0
- package/src/config/project-security.ts +10 -1
- package/src/config/schema/magic-context.ts +10 -0
- package/src/shared/__fixtures__/window-overlay.v1.json +337 -0
- package/src/shared/conflict-detector.test.ts +176 -1
- package/src/shared/conflict-detector.ts +126 -3
- package/src/shared/harness-provider-map.test.ts +20 -0
- package/src/shared/harness-provider-map.ts +19 -0
- package/src/shared/logger.test.ts +119 -0
- package/src/shared/logger.ts +33 -15
- package/src/shared/models-dev-cache.test.ts +17 -0
- package/src/shared/models-dev-cache.ts +58 -6
- package/src/shared/prompt-surface-runtime.ts +5 -3
- package/src/shared/prompt-surface.test.ts +38 -0
- package/src/shared/prompt-surface.ts +15 -2
- package/src/shared/rpc-client.ts +4 -2
- package/src/shared/rpc-types.ts +14 -0
- package/src/shared/rpc-utils.test.ts +137 -13
- package/src/shared/rpc-utils.ts +144 -31
- package/src/shared/window-geometry.test.ts +309 -0
- package/src/shared/window-geometry.ts +629 -0
- package/src/tui/data/context-db.ts +5 -0
- package/src/tui/index.tsx +22 -0
- package/src/tui-compiled/data/context-db.ts +5 -0
- package/src/tui-compiled/index.tsx +319 -255
- package/dist/index-wgvxkqpx.js +0 -342
|
@@ -4,7 +4,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
|
4
4
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
-
import { detectConflicts } from "./conflict-detector";
|
|
7
|
+
import { detectConflicts, resolveCompactionForBoot } from "./conflict-detector";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* Regression tests for plugin-conflict detection. The previous substring-
|
|
@@ -492,4 +492,179 @@ describe("detectConflicts", () => {
|
|
|
492
492
|
}
|
|
493
493
|
});
|
|
494
494
|
});
|
|
495
|
+
|
|
496
|
+
// --- Resolved-config arm (issue #309) ---
|
|
497
|
+
// The plugin boot now consumes the host's RESOLVED config
|
|
498
|
+
// (ctx.client.config.get()) instead of re-deriving compaction from files.
|
|
499
|
+
// These tests exercise the resolved arm directly via the
|
|
500
|
+
// `resolvedCompaction` option and the `resolveCompactionForBoot` helper.
|
|
501
|
+
describe("resolved-config arm (issue #309)", () => {
|
|
502
|
+
// The suite beforeEach sets OPENCODE_DISABLE_AUTOCOMPACT=1 to isolate
|
|
503
|
+
// plugin detection from compaction detection. These tests exercise the
|
|
504
|
+
// resolved arm, so they clear it and restore it per-case.
|
|
505
|
+
function withoutAutoCompactEnv<T>(fn: () => T): T {
|
|
506
|
+
const prev = process.env.OPENCODE_DISABLE_AUTOCOMPACT;
|
|
507
|
+
delete process.env.OPENCODE_DISABLE_AUTOCOMPACT;
|
|
508
|
+
try {
|
|
509
|
+
return fn();
|
|
510
|
+
} finally {
|
|
511
|
+
if (prev !== undefined) process.env.OPENCODE_DISABLE_AUTOCOMPACT = prev;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
it("resolved auto=false + file layer that would default true → NO conflict (#309)", () => {
|
|
516
|
+
// No compaction block in any file + no env override → the file-based
|
|
517
|
+
// arm would default to auto=true and wrongly disable the plugin.
|
|
518
|
+
// The resolved arm says auto=false, which must win.
|
|
519
|
+
withoutAutoCompactEnv(() => {
|
|
520
|
+
const result = detectConflicts(projectDir, {
|
|
521
|
+
compactionEnabled: true,
|
|
522
|
+
resolvedCompaction: { auto: false, prune: false },
|
|
523
|
+
});
|
|
524
|
+
expect(result.conflicts.compactionAuto).toBe(false);
|
|
525
|
+
expect(result.hasConflict).toBe(false);
|
|
526
|
+
expect(result.nativeCompaction.auto).toBe(false);
|
|
527
|
+
});
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
it("resolved auto=true → conflict, message carries '(resolved config)'", () => {
|
|
531
|
+
withoutAutoCompactEnv(() => {
|
|
532
|
+
const result = detectConflicts(projectDir, {
|
|
533
|
+
compactionEnabled: true,
|
|
534
|
+
resolvedCompaction: { auto: true, prune: false },
|
|
535
|
+
});
|
|
536
|
+
expect(result.conflicts.compactionAuto).toBe(true);
|
|
537
|
+
expect(result.hasConflict).toBe(true);
|
|
538
|
+
expect(result.reasons.join("; ")).toContain("(resolved config)");
|
|
539
|
+
});
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
it("resolved prune=true → conflict, message carries '(resolved config)'", () => {
|
|
543
|
+
withoutAutoCompactEnv(() => {
|
|
544
|
+
const result = detectConflicts(projectDir, {
|
|
545
|
+
compactionEnabled: true,
|
|
546
|
+
resolvedCompaction: { auto: false, prune: true },
|
|
547
|
+
});
|
|
548
|
+
expect(result.conflicts.compactionPrune).toBe(true);
|
|
549
|
+
expect(result.hasConflict).toBe(true);
|
|
550
|
+
expect(result.reasons.join("; ")).toContain("(resolved config)");
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
it("resolved arm is skipped when resolvedCompaction is absent (file-based fallback unchanged)", () => {
|
|
555
|
+
// No resolvedCompaction → the file-based check runs. With no
|
|
556
|
+
// compaction block and no env override, it defaults to auto=true.
|
|
557
|
+
withoutAutoCompactEnv(() => {
|
|
558
|
+
const result = detectConflicts(projectDir, { compactionEnabled: true });
|
|
559
|
+
expect(result.conflicts.compactionAuto).toBe(true);
|
|
560
|
+
expect(result.hasConflict).toBe(true);
|
|
561
|
+
// File-based arm does NOT carry the resolved-config label.
|
|
562
|
+
expect(result.reasons.join("; ")).not.toContain("(resolved config)");
|
|
563
|
+
});
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
it("OPENCODE_DISABLE_AUTOCOMPACT short-circuits the resolved arm", () => {
|
|
567
|
+
// Resolved says auto=true, but the env override must win.
|
|
568
|
+
process.env.OPENCODE_DISABLE_AUTOCOMPACT = "1";
|
|
569
|
+
try {
|
|
570
|
+
const result = detectConflicts(projectDir, {
|
|
571
|
+
compactionEnabled: true,
|
|
572
|
+
resolvedCompaction: { auto: true, prune: true },
|
|
573
|
+
});
|
|
574
|
+
expect(result.conflicts.compactionAuto).toBe(false);
|
|
575
|
+
expect(result.conflicts.compactionPrune).toBe(false);
|
|
576
|
+
expect(result.hasConflict).toBe(false);
|
|
577
|
+
expect(result.nativeCompaction.auto).toBe(false);
|
|
578
|
+
expect(result.nativeCompaction.prune).toBe(false);
|
|
579
|
+
} finally {
|
|
580
|
+
delete process.env.OPENCODE_DISABLE_AUTOCOMPACT;
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
it("compaction-off mode: resolved auto=true is NOT a conflict (native compaction active)", () => {
|
|
585
|
+
withoutAutoCompactEnv(() => {
|
|
586
|
+
const result = detectConflicts(projectDir, {
|
|
587
|
+
compactionEnabled: false,
|
|
588
|
+
resolvedCompaction: { auto: true, prune: false },
|
|
589
|
+
});
|
|
590
|
+
expect(result.conflicts.compactionAuto).toBe(false);
|
|
591
|
+
expect(result.hasConflict).toBe(false);
|
|
592
|
+
// Native compaction state is still reported honestly.
|
|
593
|
+
expect(result.nativeCompaction.auto).toBe(true);
|
|
594
|
+
});
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
// --- resolveCompactionForBoot (the resolved-config fetch helper) ---
|
|
599
|
+
describe("resolveCompactionForBoot", () => {
|
|
600
|
+
it("returns the resolved compaction block from the client", async () => {
|
|
601
|
+
const client = {
|
|
602
|
+
config: {
|
|
603
|
+
get: async () => ({
|
|
604
|
+
data: { compaction: { auto: false, prune: true } },
|
|
605
|
+
}),
|
|
606
|
+
},
|
|
607
|
+
};
|
|
608
|
+
const result = await resolveCompactionForBoot(client);
|
|
609
|
+
expect(result).toEqual({ auto: false, prune: true });
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it("returns null when the compaction block is absent (file-based fallback, not host defaults)", async () => {
|
|
613
|
+
// An absent block means the response shape did not carry the resolved
|
|
614
|
+
// state (server version drift, a fetch racing boot) — NOT that the
|
|
615
|
+
// host resolved its defaults. Reading absence as auto=true disabled
|
|
616
|
+
// the plugin for real users whose auto=false lived in the file layer
|
|
617
|
+
// (issue #309, second arm: the 2026-08-14 desktop incident where
|
|
618
|
+
// every session overflowed with nothing managing the window).
|
|
619
|
+
const client = {
|
|
620
|
+
config: {
|
|
621
|
+
get: async () => ({ data: {} }),
|
|
622
|
+
},
|
|
623
|
+
};
|
|
624
|
+
const result = await resolveCompactionForBoot(client);
|
|
625
|
+
expect(result).toBeNull();
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
it("returns null when compaction values are not explicit booleans", async () => {
|
|
629
|
+
const client = {
|
|
630
|
+
config: {
|
|
631
|
+
get: async () => ({ data: { compaction: { auto: "true", prune: null } } }),
|
|
632
|
+
},
|
|
633
|
+
};
|
|
634
|
+
const result = await resolveCompactionForBoot(client);
|
|
635
|
+
expect(result).toBeNull();
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
it("returns null when the response data is missing entirely", async () => {
|
|
639
|
+
const client = {
|
|
640
|
+
config: {
|
|
641
|
+
get: async () => ({}) as { data?: Record<string, unknown> },
|
|
642
|
+
},
|
|
643
|
+
};
|
|
644
|
+
const result = await resolveCompactionForBoot(client);
|
|
645
|
+
expect(result).toBeNull();
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
it("returns null when the client throws (file-based fallback used)", async () => {
|
|
649
|
+
const client = {
|
|
650
|
+
config: {
|
|
651
|
+
get: async () => {
|
|
652
|
+
throw new Error("boom");
|
|
653
|
+
},
|
|
654
|
+
},
|
|
655
|
+
};
|
|
656
|
+
const result = await resolveCompactionForBoot(client);
|
|
657
|
+
expect(result).toBeNull();
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
it("returns null when the client times out (boot never hangs)", async () => {
|
|
661
|
+
const client = {
|
|
662
|
+
config: {
|
|
663
|
+
get: () => new Promise<never>(() => {}), // never resolves
|
|
664
|
+
},
|
|
665
|
+
};
|
|
666
|
+
const result = await resolveCompactionForBoot(client, 20);
|
|
667
|
+
expect(result).toBeNull();
|
|
668
|
+
});
|
|
669
|
+
});
|
|
495
670
|
});
|
|
@@ -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
|
-
|
|
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(
|
|
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(
|
|
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
|
+
});
|
package/src/shared/logger.ts
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
24
|
-
|
|
24
|
+
swallowedWriteCount++;
|
|
25
|
+
lastErrorMessage = error instanceof Error ? error.message : String(error);
|
|
26
|
+
lastErrorTime = new Date().toISOString();
|
|
25
27
|
} catch {
|
|
26
|
-
//
|
|
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
|
-
|
|
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
|
|
@@ -78,6 +78,23 @@ describe("output-token reservation", () => {
|
|
|
78
78
|
).toBe(1_048_576);
|
|
79
79
|
});
|
|
80
80
|
|
|
81
|
+
test("output_reserve accepts both harness provider spellings with canonical precedence", () => {
|
|
82
|
+
const limit = { context: 100_000, output: 20_000 };
|
|
83
|
+
expect(
|
|
84
|
+
resolveLimit(limit, "openai-codex", "gpt-5.6-sol", {
|
|
85
|
+
default: 0,
|
|
86
|
+
"openai-codex/gpt-5.6-sol": 8_000,
|
|
87
|
+
}),
|
|
88
|
+
).toBe(92_000);
|
|
89
|
+
expect(
|
|
90
|
+
resolveLimit(limit, "openai-codex", "gpt-5.6-sol", {
|
|
91
|
+
default: 0,
|
|
92
|
+
"openai-codex/gpt-5.6-sol": 8_000,
|
|
93
|
+
"openai/gpt-5.6-sol": 4_000,
|
|
94
|
+
}),
|
|
95
|
+
).toBe(96_000);
|
|
96
|
+
});
|
|
97
|
+
|
|
81
98
|
test("output_reserve overrides shared and separate quota defaults", () => {
|
|
82
99
|
expect(resolveLimit({ context: 100_000, output: 20_000 }, "anthropic", "claude", 0)).toBe(
|
|
83
100
|
100_000,
|