@cortexkit/opencode-magic-context 0.15.1 → 0.15.3
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/README.md +12 -6
- package/dist/agents/magic-context-prompt.d.ts.map +1 -1
- package/dist/cli.js +12 -2
- package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/resolve-subagent-fallback.d.ts +40 -0
- package/dist/features/magic-context/resolve-subagent-fallback.d.ts.map +1 -0
- package/dist/features/magic-context/storage-db.d.ts +20 -0
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-meta-persisted.d.ts +7 -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.d.ts +1 -1
- package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
- package/dist/features/magic-context/storage.d.ts +1 -1
- package/dist/features/magic-context/storage.d.ts.map +1 -1
- package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
- package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform.d.ts.map +1 -1
- package/dist/index.js +207 -64
- package/dist/shared/bounded-session-map.d.ts +45 -0
- package/dist/shared/bounded-session-map.d.ts.map +1 -0
- package/dist/shared/conflict-detector.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/shared/bounded-session-map.test.ts +97 -0
- package/src/shared/bounded-session-map.ts +84 -0
- package/src/shared/conflict-detector.test.ts +189 -0
- package/src/shared/conflict-detector.ts +68 -7
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { BoundedSessionMap } from "./bounded-session-map";
|
|
3
|
+
|
|
4
|
+
describe("BoundedSessionMap", () => {
|
|
5
|
+
it("rejects non-positive caps", () => {
|
|
6
|
+
expect(() => new BoundedSessionMap(0)).toThrow();
|
|
7
|
+
expect(() => new BoundedSessionMap(-5)).toThrow();
|
|
8
|
+
expect(() => new BoundedSessionMap(Number.NaN)).toThrow();
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("stores and retrieves values", () => {
|
|
12
|
+
const map = new BoundedSessionMap<number>(3);
|
|
13
|
+
map.set("a", 1);
|
|
14
|
+
map.set("b", 2);
|
|
15
|
+
expect(map.get("a")).toBe(1);
|
|
16
|
+
expect(map.get("b")).toBe(2);
|
|
17
|
+
expect(map.get("missing")).toBeUndefined();
|
|
18
|
+
expect(map.size).toBe(2);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("evicts the oldest entry when cap is exceeded", () => {
|
|
22
|
+
const map = new BoundedSessionMap<string>(3);
|
|
23
|
+
map.set("a", "alpha");
|
|
24
|
+
map.set("b", "bravo");
|
|
25
|
+
map.set("c", "charlie");
|
|
26
|
+
map.set("d", "delta"); // evicts "a"
|
|
27
|
+
expect(map.has("a")).toBe(false);
|
|
28
|
+
expect(map.has("b")).toBe(true);
|
|
29
|
+
expect(map.has("c")).toBe(true);
|
|
30
|
+
expect(map.has("d")).toBe(true);
|
|
31
|
+
expect(map.size).toBe(3);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("treats get() as a touch for LRU ordering", () => {
|
|
35
|
+
const map = new BoundedSessionMap<string>(3);
|
|
36
|
+
map.set("a", "alpha");
|
|
37
|
+
map.set("b", "bravo");
|
|
38
|
+
map.set("c", "charlie");
|
|
39
|
+
// Touch "a" — now "b" is the oldest.
|
|
40
|
+
expect(map.get("a")).toBe("alpha");
|
|
41
|
+
map.set("d", "delta");
|
|
42
|
+
expect(map.has("b")).toBe(false);
|
|
43
|
+
expect(map.has("a")).toBe(true);
|
|
44
|
+
expect(map.has("c")).toBe(true);
|
|
45
|
+
expect(map.has("d")).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("peek() does NOT touch recency", () => {
|
|
49
|
+
const map = new BoundedSessionMap<number>(3);
|
|
50
|
+
map.set("a", 1);
|
|
51
|
+
map.set("b", 2);
|
|
52
|
+
map.set("c", 3);
|
|
53
|
+
expect(map.peek("a")).toBe(1);
|
|
54
|
+
// Adding a fourth entry should still evict "a" since peek didn't touch it.
|
|
55
|
+
map.set("d", 4);
|
|
56
|
+
expect(map.has("a")).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("set() on existing key refreshes recency without growing size", () => {
|
|
60
|
+
const map = new BoundedSessionMap<number>(3);
|
|
61
|
+
map.set("a", 1);
|
|
62
|
+
map.set("b", 2);
|
|
63
|
+
map.set("c", 3);
|
|
64
|
+
map.set("a", 100); // refresh "a" to most-recent with new value
|
|
65
|
+
expect(map.size).toBe(3);
|
|
66
|
+
expect(map.get("a")).toBe(100);
|
|
67
|
+
map.set("d", 4); // evicts "b" (now oldest)
|
|
68
|
+
expect(map.has("b")).toBe(false);
|
|
69
|
+
expect(map.has("a")).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("delete() removes entries and returns true when present", () => {
|
|
73
|
+
const map = new BoundedSessionMap<number>(3);
|
|
74
|
+
map.set("a", 1);
|
|
75
|
+
expect(map.delete("a")).toBe(true);
|
|
76
|
+
expect(map.delete("a")).toBe(false);
|
|
77
|
+
expect(map.size).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("clear() drops all entries", () => {
|
|
81
|
+
const map = new BoundedSessionMap<number>(3);
|
|
82
|
+
map.set("a", 1);
|
|
83
|
+
map.set("b", 2);
|
|
84
|
+
map.clear();
|
|
85
|
+
expect(map.size).toBe(0);
|
|
86
|
+
expect(map.get("a")).toBeUndefined();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("tolerates cap=1 edge case (every set evicts previous)", () => {
|
|
90
|
+
const map = new BoundedSessionMap<number>(1);
|
|
91
|
+
map.set("a", 1);
|
|
92
|
+
map.set("b", 2);
|
|
93
|
+
expect(map.has("a")).toBe(false);
|
|
94
|
+
expect(map.get("b")).toBe(2);
|
|
95
|
+
expect(map.size).toBe(1);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bounded LRU map keyed by session id.
|
|
3
|
+
*
|
|
4
|
+
* Rationale: magic-context maintains several module-scope Maps that track
|
|
5
|
+
* per-session state (prepared injection cache, per-message token cache, etc.).
|
|
6
|
+
* These are cleared on the `session.deleted` event, but sessions that are
|
|
7
|
+
* never explicitly deleted — because OpenCode crashed, the user force-quit,
|
|
8
|
+
* the session was archived rather than deleted, or the session simply outlived
|
|
9
|
+
* the plugin process's interest in it — leak entries for the lifetime of the
|
|
10
|
+
* plugin process.
|
|
11
|
+
*
|
|
12
|
+
* In long-running OpenCode instances with thousands of sessions over time,
|
|
13
|
+
* an unbounded `Map<sessionId, LargeObject>` can retain tens of megabytes
|
|
14
|
+
* indefinitely. A session-scoped LRU with a generous cap (e.g. 100) covers
|
|
15
|
+
* any realistic working-set of active sessions a user actually cares about,
|
|
16
|
+
* while evicting cold session ids that will either never return or be
|
|
17
|
+
* rebuilt from durable SQLite state on their next transform pass.
|
|
18
|
+
*
|
|
19
|
+
* Implementation notes:
|
|
20
|
+
* - Built on `Map` which preserves insertion order. On every `set`/`get`
|
|
21
|
+
* touch we delete+reinsert to move the key to the tail (most-recent).
|
|
22
|
+
* - Eviction drops the oldest entry (first in iteration order).
|
|
23
|
+
* - The cached value type is generic — callers decide what per-session state
|
|
24
|
+
* to store. For injection/token state, all three properties of the cached
|
|
25
|
+
* object are safe to throw away: they are either recomputable from the
|
|
26
|
+
* messages array on the next pass, or reloadable from SQLite.
|
|
27
|
+
*/
|
|
28
|
+
export class BoundedSessionMap<V> {
|
|
29
|
+
private readonly maxEntries: number;
|
|
30
|
+
private readonly store = new Map<string, V>();
|
|
31
|
+
|
|
32
|
+
constructor(maxEntries: number) {
|
|
33
|
+
if (!Number.isFinite(maxEntries) || maxEntries < 1) {
|
|
34
|
+
throw new Error(`BoundedSessionMap: maxEntries must be >= 1, got ${maxEntries}`);
|
|
35
|
+
}
|
|
36
|
+
this.maxEntries = maxEntries;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
get(sessionId: string): V | undefined {
|
|
40
|
+
const value = this.store.get(sessionId);
|
|
41
|
+
if (value === undefined) return undefined;
|
|
42
|
+
// Touch: move to most-recent position.
|
|
43
|
+
this.store.delete(sessionId);
|
|
44
|
+
this.store.set(sessionId, value);
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Peek without touching recency — useful for `has`-style checks that
|
|
50
|
+
* should not rearrange LRU order. Use sparingly; `get` is the normal
|
|
51
|
+
* access path.
|
|
52
|
+
*/
|
|
53
|
+
peek(sessionId: string): V | undefined {
|
|
54
|
+
return this.store.get(sessionId);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
has(sessionId: string): boolean {
|
|
58
|
+
return this.store.has(sessionId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
set(sessionId: string, value: V): void {
|
|
62
|
+
if (this.store.has(sessionId)) {
|
|
63
|
+
// Refresh recency.
|
|
64
|
+
this.store.delete(sessionId);
|
|
65
|
+
} else if (this.store.size >= this.maxEntries) {
|
|
66
|
+
// Evict oldest entry. Map iteration is insertion-ordered.
|
|
67
|
+
const oldest = this.store.keys().next().value;
|
|
68
|
+
if (oldest !== undefined) this.store.delete(oldest);
|
|
69
|
+
}
|
|
70
|
+
this.store.set(sessionId, value);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
delete(sessionId: string): boolean {
|
|
74
|
+
return this.store.delete(sessionId);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
clear(): void {
|
|
78
|
+
this.store.clear();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get size(): number {
|
|
82
|
+
return this.store.size;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/// <reference types="bun-types" />
|
|
2
|
+
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
4
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { detectConflicts } from "./conflict-detector";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Regression tests for plugin-conflict detection. The previous substring-
|
|
11
|
+
* based matcher misclassified `oh-my-opencode-slim` and `opencode-dcp-fork`
|
|
12
|
+
* as the canonical plugins, causing magic-context to disable itself with
|
|
13
|
+
* a false-positive conflict warning. See issue #43.
|
|
14
|
+
*/
|
|
15
|
+
describe("detectConflicts", () => {
|
|
16
|
+
let projectDir: string;
|
|
17
|
+
let userConfigDir: string;
|
|
18
|
+
let originalEnv: Record<string, string | undefined>;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
const root = mkdtempSync(join(tmpdir(), "mc-conflict-"));
|
|
22
|
+
projectDir = join(root, "project");
|
|
23
|
+
mkdirSync(projectDir, { recursive: true });
|
|
24
|
+
userConfigDir = join(root, "user-config", "opencode");
|
|
25
|
+
mkdirSync(userConfigDir, { recursive: true });
|
|
26
|
+
|
|
27
|
+
// Save and override every env var that affects config-path resolution.
|
|
28
|
+
// OPENCODE_CONFIG_DIR takes precedence over XDG_CONFIG_HOME, so we set
|
|
29
|
+
// it directly and clear XDG to fully isolate from any inherited or
|
|
30
|
+
// test-leaked state.
|
|
31
|
+
originalEnv = {
|
|
32
|
+
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
|
|
33
|
+
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
|
34
|
+
OPENCODE_DISABLE_AUTOCOMPACT: process.env.OPENCODE_DISABLE_AUTOCOMPACT,
|
|
35
|
+
};
|
|
36
|
+
process.env.OPENCODE_CONFIG_DIR = userConfigDir;
|
|
37
|
+
delete process.env.XDG_CONFIG_HOME;
|
|
38
|
+
// Disable auto-compaction default during tests so we isolate plugin
|
|
39
|
+
// detection from compaction detection.
|
|
40
|
+
process.env.OPENCODE_DISABLE_AUTOCOMPACT = "1";
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
for (const [k, v] of Object.entries(originalEnv)) {
|
|
45
|
+
if (v === undefined) delete process.env[k];
|
|
46
|
+
else process.env[k] = v;
|
|
47
|
+
}
|
|
48
|
+
// Test directories live under tmpdir(); cleanup is best-effort.
|
|
49
|
+
rmSync(projectDir, { recursive: true, force: true });
|
|
50
|
+
rmSync(userConfigDir, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function writeProjectConfig(plugins: string[]): void {
|
|
54
|
+
writeFileSync(join(projectDir, "opencode.json"), JSON.stringify({ plugin: plugins }));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// --- DCP detection ---
|
|
58
|
+
|
|
59
|
+
describe("DCP detection", () => {
|
|
60
|
+
it("matches the canonical @tarquinen/opencode-dcp package", () => {
|
|
61
|
+
writeProjectConfig(["@tarquinen/opencode-dcp"]);
|
|
62
|
+
const result = detectConflicts(projectDir);
|
|
63
|
+
expect(result.conflicts.dcpPlugin).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("matches the canonical package with a version suffix", () => {
|
|
67
|
+
writeProjectConfig(["@tarquinen/opencode-dcp@latest"]);
|
|
68
|
+
const result = detectConflicts(projectDir);
|
|
69
|
+
expect(result.conflicts.dcpPlugin).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("matches with a semver range suffix", () => {
|
|
73
|
+
writeProjectConfig(["@tarquinen/opencode-dcp@^3.1.0"]);
|
|
74
|
+
const result = detectConflicts(projectDir);
|
|
75
|
+
expect(result.conflicts.dcpPlugin).toBe(true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("does NOT match a fork with a different package name", () => {
|
|
79
|
+
writeProjectConfig(["@some-fork/opencode-dcp-fork"]);
|
|
80
|
+
const result = detectConflicts(projectDir);
|
|
81
|
+
expect(result.conflicts.dcpPlugin).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("does NOT match a file:// path that contains 'opencode-dcp'", () => {
|
|
85
|
+
writeProjectConfig(["file:///home/user/work/opencode-dcp-fork"]);
|
|
86
|
+
const result = detectConflicts(projectDir);
|
|
87
|
+
expect(result.conflicts.dcpPlugin).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// --- OMO detection (the issue #43 case) ---
|
|
92
|
+
|
|
93
|
+
describe("OMO detection", () => {
|
|
94
|
+
it("matches the canonical oh-my-opencode package", () => {
|
|
95
|
+
writeProjectConfig(["oh-my-opencode"]);
|
|
96
|
+
const result = detectConflicts(projectDir);
|
|
97
|
+
// No OMO config = hooks default ACTIVE = all three flagged
|
|
98
|
+
expect(result.conflicts.omoPreemptiveCompaction).toBe(true);
|
|
99
|
+
expect(result.conflicts.omoContextWindowMonitor).toBe(true);
|
|
100
|
+
expect(result.conflicts.omoAnthropicRecovery).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("matches the canonical oh-my-openagent package alias", () => {
|
|
104
|
+
writeProjectConfig(["oh-my-openagent"]);
|
|
105
|
+
const result = detectConflicts(projectDir);
|
|
106
|
+
expect(result.conflicts.omoPreemptiveCompaction).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("matches a canonical OMO with a version suffix", () => {
|
|
110
|
+
writeProjectConfig(["oh-my-opencode@3.17.5", "oh-my-openagent@latest"]);
|
|
111
|
+
const result = detectConflicts(projectDir);
|
|
112
|
+
expect(result.conflicts.omoPreemptiveCompaction).toBe(true);
|
|
113
|
+
expect(result.conflicts.omoContextWindowMonitor).toBe(true);
|
|
114
|
+
expect(result.conflicts.omoAnthropicRecovery).toBe(true);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("does NOT match oh-my-opencode-slim (issue #43)", () => {
|
|
118
|
+
writeProjectConfig(["oh-my-opencode-slim"]);
|
|
119
|
+
const result = detectConflicts(projectDir);
|
|
120
|
+
expect(result.hasConflict).toBe(false);
|
|
121
|
+
expect(result.conflicts.omoPreemptiveCompaction).toBe(false);
|
|
122
|
+
expect(result.conflicts.omoContextWindowMonitor).toBe(false);
|
|
123
|
+
expect(result.conflicts.omoAnthropicRecovery).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does NOT match oh-my-opencode-slim with a version suffix (issue #43)", () => {
|
|
127
|
+
writeProjectConfig(["oh-my-opencode-slim@latest", "oh-my-opencode-slim@1.0.3"]);
|
|
128
|
+
const result = detectConflicts(projectDir);
|
|
129
|
+
expect(result.hasConflict).toBe(false);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("does NOT match a file:// path containing 'oh-my-opencode' (issue #43)", () => {
|
|
133
|
+
writeProjectConfig(["file:///home/user/workspace/oh-my-opencode-slim-dev"]);
|
|
134
|
+
const result = detectConflicts(projectDir);
|
|
135
|
+
expect(result.hasConflict).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("does NOT match other forks under different package names", () => {
|
|
139
|
+
writeProjectConfig([
|
|
140
|
+
"oh-my-opencode-cli",
|
|
141
|
+
"@some-org/oh-my-opencode-fork",
|
|
142
|
+
"my-oh-my-opencode-customizations",
|
|
143
|
+
]);
|
|
144
|
+
const result = detectConflicts(projectDir);
|
|
145
|
+
expect(result.hasConflict).toBe(false);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("still detects canonical OMO when slim is also installed", () => {
|
|
149
|
+
// A user running both slim and the real OMO should still get
|
|
150
|
+
// the conflict warning for the real one.
|
|
151
|
+
writeProjectConfig(["oh-my-opencode-slim", "oh-my-opencode@latest"]);
|
|
152
|
+
const result = detectConflicts(projectDir);
|
|
153
|
+
expect(result.conflicts.omoPreemptiveCompaction).toBe(true);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("respects disabled_hooks in project-level OMO config", () => {
|
|
157
|
+
writeProjectConfig(["oh-my-opencode"]);
|
|
158
|
+
// Use project-scoped OMO config to avoid relying on user
|
|
159
|
+
// config-path resolution, which can be leaked across files
|
|
160
|
+
// by `spyOn(getOpenCodeConfigPaths)` mocks in sibling tests.
|
|
161
|
+
writeFileSync(
|
|
162
|
+
join(projectDir, "oh-my-opencode.json"),
|
|
163
|
+
JSON.stringify({
|
|
164
|
+
disabled_hooks: [
|
|
165
|
+
"preemptive-compaction",
|
|
166
|
+
"context-window-monitor",
|
|
167
|
+
"anthropic-context-window-limit-recovery",
|
|
168
|
+
],
|
|
169
|
+
}),
|
|
170
|
+
);
|
|
171
|
+
const result = detectConflicts(projectDir);
|
|
172
|
+
expect(result.hasConflict).toBe(false);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
// --- Combined / control cases ---
|
|
177
|
+
|
|
178
|
+
it("returns no conflicts for an empty plugin list", () => {
|
|
179
|
+
writeProjectConfig([]);
|
|
180
|
+
const result = detectConflicts(projectDir);
|
|
181
|
+
expect(result.hasConflict).toBe(false);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("returns no conflicts for unrelated plugins", () => {
|
|
185
|
+
writeProjectConfig(["@cortexkit/opencode-magic-context@latest", "some-other-plugin"]);
|
|
186
|
+
const result = detectConflicts(projectDir);
|
|
187
|
+
expect(result.hasConflict).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -167,9 +167,60 @@ function readUserCompaction(): { auto: boolean; prune: boolean; resolved: boolea
|
|
|
167
167
|
|
|
168
168
|
// --- DCP detection ---
|
|
169
169
|
|
|
170
|
+
/**
|
|
171
|
+
* Canonical npm package names that represent the conflicting plugin.
|
|
172
|
+
* Matched against the npm-style segment of each plugin entry, so:
|
|
173
|
+
* - "@tarquinen/opencode-dcp" ✓ direct match
|
|
174
|
+
* - "@tarquinen/opencode-dcp@latest" ✓ version suffix stripped
|
|
175
|
+
* - "@tarquinen/opencode-dcp@^3.1.0" ✓ semver suffix stripped
|
|
176
|
+
* - "file:///path/to/opencode-dcp-fork" ✗ unrelated path
|
|
177
|
+
*
|
|
178
|
+
* forks/renames that don't ship the conflicting transform/system hooks are
|
|
179
|
+
* intentionally NOT matched.
|
|
180
|
+
*/
|
|
181
|
+
const DCP_PACKAGE_NAMES = new Set(["@tarquinen/opencode-dcp"]);
|
|
182
|
+
|
|
170
183
|
function checkDcpPlugin(directory: string): boolean {
|
|
171
184
|
const plugins = collectPluginEntries(directory);
|
|
172
|
-
return plugins.some((p) => p
|
|
185
|
+
return plugins.some((p) => matchesPackageName(p, DCP_PACKAGE_NAMES));
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Match a plugin entry against a set of canonical npm package names.
|
|
190
|
+
*
|
|
191
|
+
* A plugin entry can be:
|
|
192
|
+
* - "pkg-name"
|
|
193
|
+
* - "pkg-name@version"
|
|
194
|
+
* - "@scope/pkg-name"
|
|
195
|
+
* - "@scope/pkg-name@version"
|
|
196
|
+
* - "file://..." or other URL/path forms (never matched here)
|
|
197
|
+
*
|
|
198
|
+
* For the canonical-name path we only match the exact package name (with
|
|
199
|
+
* optional version suffix). file:// paths and forks with different
|
|
200
|
+
* package names are intentionally NOT matched — even if a path string
|
|
201
|
+
* happens to contain a substring like "oh-my-opencode" (e.g. forks like
|
|
202
|
+
* "oh-my-opencode-slim" published under a different package name).
|
|
203
|
+
*/
|
|
204
|
+
function matchesPackageName(entry: string, canonicalNames: Set<string>): boolean {
|
|
205
|
+
// Skip URL/path forms — only npm-style entries can be canonically matched.
|
|
206
|
+
// (Local file:// checkouts of canonical plugins are rare; users running
|
|
207
|
+
// those need to ensure the path itself doesn't match a fork's name.)
|
|
208
|
+
if (
|
|
209
|
+
entry.startsWith("file:") ||
|
|
210
|
+
entry.startsWith("http:") ||
|
|
211
|
+
entry.startsWith("https:") ||
|
|
212
|
+
entry.startsWith("/") ||
|
|
213
|
+
entry.startsWith("./") ||
|
|
214
|
+
entry.startsWith("../")
|
|
215
|
+
) {
|
|
216
|
+
return false;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Strip version suffix: "@scope/pkg@1.2.3" → "@scope/pkg"
|
|
220
|
+
// Careful with scoped packages: the leading "@" is part of the name.
|
|
221
|
+
const lastAt = entry.lastIndexOf("@");
|
|
222
|
+
const nameOnly = lastAt > 0 ? entry.slice(0, lastAt) : entry;
|
|
223
|
+
return canonicalNames.has(nameOnly);
|
|
173
224
|
}
|
|
174
225
|
|
|
175
226
|
function collectPluginEntries(directory: string): string[] {
|
|
@@ -206,6 +257,21 @@ function collectPluginEntries(directory: string): string[] {
|
|
|
206
257
|
|
|
207
258
|
// --- OMO hook detection ---
|
|
208
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Canonical OMO npm package names. The plugin publishes under both names as
|
|
262
|
+
* a versioned alias (latest 3.17.5 on npm at time of writing).
|
|
263
|
+
*
|
|
264
|
+
* Forks under a different package name (e.g. `oh-my-opencode-slim`,
|
|
265
|
+
* `oh-my-opencode-cli`, etc.) are intentionally NOT matched here — they
|
|
266
|
+
* don't ship the `preemptive-compaction`, `context-window-monitor`, or
|
|
267
|
+
* `anthropic-context-window-limit-recovery` hooks that conflict with
|
|
268
|
+
* Magic Context. See https://github.com/cortexkit/opencode-magic-context/issues/43.
|
|
269
|
+
*
|
|
270
|
+
* The legacy `@code-yeongyu/` scope is no longer used — both names are
|
|
271
|
+
* unscoped on npm.
|
|
272
|
+
*/
|
|
273
|
+
const OMO_PACKAGE_NAMES = new Set(["oh-my-opencode", "oh-my-openagent"]);
|
|
274
|
+
|
|
209
275
|
function checkOmoHooks(directory: string): {
|
|
210
276
|
preemptiveCompaction: boolean;
|
|
211
277
|
contextWindowMonitor: boolean;
|
|
@@ -219,12 +285,7 @@ function checkOmoHooks(directory: string): {
|
|
|
219
285
|
|
|
220
286
|
// First check if OMO is even installed
|
|
221
287
|
const plugins = collectPluginEntries(directory);
|
|
222
|
-
const hasOmo = plugins.some(
|
|
223
|
-
(p) =>
|
|
224
|
-
p.includes("oh-my-opencode") ||
|
|
225
|
-
p.includes("oh-my-openagent") ||
|
|
226
|
-
p.includes("@code-yeongyu/"),
|
|
227
|
-
);
|
|
288
|
+
const hasOmo = plugins.some((p) => matchesPackageName(p, OMO_PACKAGE_NAMES));
|
|
228
289
|
if (!hasOmo) return result;
|
|
229
290
|
|
|
230
291
|
// Read OMO config to check disabled_hooks
|