@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.
Files changed (33) hide show
  1. package/README.md +12 -6
  2. package/dist/agents/magic-context-prompt.d.ts.map +1 -1
  3. package/dist/cli.js +12 -2
  4. package/dist/features/magic-context/compaction-marker.d.ts.map +1 -1
  5. package/dist/features/magic-context/dreamer/runner.d.ts.map +1 -1
  6. package/dist/features/magic-context/migrations.d.ts.map +1 -1
  7. package/dist/features/magic-context/resolve-subagent-fallback.d.ts +40 -0
  8. package/dist/features/magic-context/resolve-subagent-fallback.d.ts.map +1 -0
  9. package/dist/features/magic-context/storage-db.d.ts +20 -0
  10. package/dist/features/magic-context/storage-db.d.ts.map +1 -1
  11. package/dist/features/magic-context/storage-meta-persisted.d.ts +7 -0
  12. package/dist/features/magic-context/storage-meta-persisted.d.ts.map +1 -1
  13. package/dist/features/magic-context/storage-meta-session.d.ts.map +1 -1
  14. package/dist/features/magic-context/storage-meta.d.ts +1 -1
  15. package/dist/features/magic-context/storage-meta.d.ts.map +1 -1
  16. package/dist/features/magic-context/storage.d.ts +1 -1
  17. package/dist/features/magic-context/storage.d.ts.map +1 -1
  18. package/dist/hooks/magic-context/event-handler.d.ts.map +1 -1
  19. package/dist/hooks/magic-context/hook-handlers.d.ts.map +1 -1
  20. package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
  21. package/dist/hooks/magic-context/system-prompt-hash.d.ts.map +1 -1
  22. package/dist/hooks/magic-context/tag-content-primitives.d.ts.map +1 -1
  23. package/dist/hooks/magic-context/transform-postprocess-phase.d.ts.map +1 -1
  24. package/dist/hooks/magic-context/transform.d.ts.map +1 -1
  25. package/dist/index.js +207 -64
  26. package/dist/shared/bounded-session-map.d.ts +45 -0
  27. package/dist/shared/bounded-session-map.d.ts.map +1 -0
  28. package/dist/shared/conflict-detector.d.ts.map +1 -1
  29. package/package.json +1 -1
  30. package/src/shared/bounded-session-map.test.ts +97 -0
  31. package/src/shared/bounded-session-map.ts +84 -0
  32. package/src/shared/conflict-detector.test.ts +189 -0
  33. 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.includes("opencode-dcp"));
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