@cortexkit/opencode-magic-context 0.41.0 → 0.41.1
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/schema/magic-context.d.ts +15 -0
- package/dist/config/schema/magic-context.d.ts.map +1 -1
- package/dist/{conflict-warning-hook-xj1r88a7.js → conflict-warning-hook-rf17b6et.js} +3 -3
- package/dist/{derive-budgets-3xfyphvk.js → derive-budgets-bb7mrpzs.js} +2 -2
- package/dist/features/magic-context/compartment-chunk-embedding.d.ts +12 -16
- package/dist/features/magic-context/compartment-chunk-embedding.d.ts.map +1 -1
- package/dist/features/magic-context/context-authority.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-failure.d.ts +1 -1
- package/dist/features/magic-context/memory/embedding-failure.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-local.d.ts +12 -2
- package/dist/features/magic-context/memory/embedding-local.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding-synapse.d.ts +10 -1
- package/dist/features/magic-context/memory/embedding-synapse.d.ts.map +1 -1
- package/dist/features/magic-context/memory/embedding.d.ts.map +1 -1
- package/dist/features/magic-context/memory/project-identity.d.ts +4 -0
- package/dist/features/magic-context/memory/project-identity.d.ts.map +1 -1
- package/dist/features/magic-context/memory/transformers-web-entry.d.ts +2 -0
- package/dist/features/magic-context/memory/transformers-web-entry.d.ts.map +1 -0
- package/dist/features/magic-context/message-index.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/search.d.ts +18 -0
- package/dist/features/magic-context/search.d.ts.map +1 -1
- package/dist/features/magic-context/storage-ops.d.ts +2 -0
- package/dist/features/magic-context/storage-ops.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/boundary-execution.d.ts +3 -0
- package/dist/hooks/magic-context/boundary-execution.d.ts.map +1 -1
- package/dist/hooks/magic-context/format-embed-failure.d.ts.map +1 -1
- package/dist/hooks/magic-context/hook.d.ts +1 -3
- package/dist/hooks/magic-context/hook.d.ts.map +1 -1
- package/dist/hooks/magic-context/transform-postprocess-phase.d.ts +3 -0
- 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-2bykz3bx.js → index-2d4kf2ve.js} +1 -1
- package/dist/{index-b4gv9f7g.js → index-4x691bz5.js} +87 -2
- package/dist/{index-knna83k0.js → index-9j3tx8hy.js} +1 -1
- package/dist/{index-jmwq2wxr.js → index-b9j85d29.js} +1 -1
- package/dist/{index-bf799gbq.js → index-d1mxrd3p.js} +0 -1
- package/dist/{index-w8rkbhn6.js → index-dh0vfghk.js} +318 -143
- package/dist/{index-dmgbar7r.js → index-eq9wr75c.js} +2 -2
- package/dist/{index-26ds03jd.js → index-vnz6jvdc.js} +1 -1
- package/dist/{index-x9dmzyp5.js → index-z893kjyt.js} +3 -3
- package/dist/{index-88za3sz8.js → index-zwjhba2r.js} +3 -3
- package/dist/index.js +366 -148
- package/dist/{model-resolution-jgrkt0z2.js → model-resolution-6w6wzw18.js} +1 -1
- package/dist/plugin/embedding-routing.d.ts.map +1 -1
- package/dist/plugin/hooks/create-session-hooks.d.ts.map +1 -1
- package/dist/{recomp-orchestrator-1w4xj6jt.js → recomp-orchestrator-w7xg48e3.js} +6 -6
- package/dist/{safe-notification-target-p0c0csxw.js → safe-notification-target-2a8py95v.js} +2 -2
- package/dist/{send-session-notification-kn34g7mx.js → send-session-notification-bw7eex6w.js} +3 -3
- package/dist/shared/logger.d.ts.map +1 -1
- package/dist/shared/model-resolution.d.ts.map +1 -1
- package/dist/shared/test-temp-dir.d.ts +40 -0
- package/dist/shared/test-temp-dir.d.ts.map +1 -0
- package/dist/{storage-meta-session-snbzd26z.js → storage-meta-session-gr4zn7sq.js} +3 -3
- package/dist/{task-config-ty08ptc2.js → task-config-gv6s04fp.js} +2 -2
- package/dist/tools/ctx-search/tools.d.ts.map +1 -1
- package/dist/transformers-web.js +24570 -0
- package/dist/transformers.node-b1wa0hgx.js +28808 -0
- package/package.json +7 -3
- package/src/config/index.test.ts +9 -4
- package/src/config/schema/magic-context.test.ts +20 -0
- package/src/config/schema/magic-context.ts +8 -0
- package/src/shared/conflict-detector.test.ts +6 -19
- package/src/shared/conflict-fixer.test.ts +5 -8
- package/src/shared/logger.test.ts +135 -40
- package/src/shared/logger.ts +115 -2
- package/src/shared/model-resolution.test.ts +4 -6
- package/src/shared/model-resolution.ts +0 -1
- package/src/shared/test-temp-dir.test.ts +57 -0
- package/src/shared/test-temp-dir.ts +165 -0
- /package/dist/{index-rpgg35ms.js → index-8sseka65.js} +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cortexkit/opencode-magic-context",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "OpenCode plugin for Magic Context — cross-session memory and context management",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"README.md"
|
|
36
36
|
],
|
|
37
37
|
"scripts": {
|
|
38
|
-
"build": "tsc -p ../retina-local-fs/tsconfig.build.json && bun run build:tui && rm -f dist/index.js dist/*-*.js && bun build src/index.ts --outdir dist --target node --format esm --splitting --external @opencode-ai/plugin --external
|
|
38
|
+
"build": "tsc -p ../retina-local-fs/tsconfig.build.json && bun run build:tui && rm -f dist/index.js dist/transformers-web.js dist/*-*.js && bun build src/features/magic-context/memory/transformers-web-entry.ts --outfile dist/transformers-web.js --target browser --format esm --external onnxruntime-web && bun build src/index.ts --outdir dist --target node --format esm --splitting --external @opencode-ai/plugin --external onnxruntime-node --external onnxruntime-web --external sharp --external bun:sqlite --external node:sqlite && tsc --emitDeclarationOnly",
|
|
39
39
|
"build:tui": "bun scripts/build-tui.ts",
|
|
40
40
|
"check:tui-compiled": "bun run build:tui && git diff --exit-code -- src/tui-compiled && test -z \"$(git status --porcelain -- src/tui-compiled)\"",
|
|
41
41
|
"typecheck": "tsc -p ../retina-local-fs/tsconfig.build.json && tsc --noEmit && tsc -p tsconfig.scripts.json",
|
|
@@ -51,7 +51,6 @@
|
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@cortexkit/subc-client": "0.4.1",
|
|
54
|
-
"@huggingface/transformers": "^4.1.0",
|
|
55
54
|
"@jitl/quickjs-singlefile-cjs-release-asyncify": "0.32.0",
|
|
56
55
|
"@opencode-ai/plugin": "^1.17.11",
|
|
57
56
|
"@opencode-ai/sdk": "^1.17.11",
|
|
@@ -65,8 +64,13 @@
|
|
|
65
64
|
"solid-js": "1.9.12",
|
|
66
65
|
"zod": "^4.1.8"
|
|
67
66
|
},
|
|
67
|
+
"optionalDependencies": {
|
|
68
|
+
"onnxruntime-node": "1.24.3",
|
|
69
|
+
"sharp": "^0.34.5"
|
|
70
|
+
},
|
|
68
71
|
"devDependencies": {
|
|
69
72
|
"@ai-sdk/anthropic": "3.0.82",
|
|
73
|
+
"@huggingface/transformers": "^4.1.0",
|
|
70
74
|
"@ai-sdk/openai-compatible": "2.0.41",
|
|
71
75
|
"@biomejs/biome": "^2.5.1",
|
|
72
76
|
"@types/better-sqlite3": "^7.6.13",
|
package/src/config/index.test.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { describe, expect, it } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
|
|
5
6
|
import { resolveHistorianModel } from "../shared/model-resolution";
|
|
7
|
+
import { createTestTempDir } from "../shared/test-temp-dir";
|
|
6
8
|
import { loadPluginConfig, loadPluginConfigDetailed } from "./index";
|
|
7
9
|
import { resolveConfigProfile } from "./profiles";
|
|
8
10
|
import { DEFAULT_LOCAL_EMBEDDING_MODEL } from "./schema/magic-context";
|
|
@@ -121,6 +123,7 @@ describe("loadPluginConfig — preload user-config isolation", () => {
|
|
|
121
123
|
expect(loadPluginConfig(projectDir).embedding).toEqual({
|
|
122
124
|
provider: "local",
|
|
123
125
|
model: DEFAULT_LOCAL_EMBEDDING_MODEL,
|
|
126
|
+
local_runtime: "auto",
|
|
124
127
|
});
|
|
125
128
|
} finally {
|
|
126
129
|
rmSync(projectDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
@@ -693,7 +696,8 @@ describe("loadPluginConfig — legacy agent enabled migration", () => {
|
|
|
693
696
|
|
|
694
697
|
describe("loadPluginConfig — variable expansion scope", () => {
|
|
695
698
|
it("keeps {env:} and {file:} expansion enabled for user config", () => {
|
|
696
|
-
const
|
|
699
|
+
const { dir: secretDir, cleanup } = createTestTempDir("mc-config-secret-");
|
|
700
|
+
const secretFile = join(secretDir, "secret.txt");
|
|
697
701
|
writeFileSync(secretFile, "file-secret", "utf-8");
|
|
698
702
|
|
|
699
703
|
try {
|
|
@@ -715,12 +719,13 @@ describe("loadPluginConfig — variable expansion scope", () => {
|
|
|
715
719
|
}
|
|
716
720
|
expect(result.configWarnings).toBeUndefined();
|
|
717
721
|
} finally {
|
|
718
|
-
|
|
722
|
+
cleanup();
|
|
719
723
|
}
|
|
720
724
|
});
|
|
721
725
|
|
|
722
726
|
it("leaves {env:} and {file:} tokens literal in project config and warns", () => {
|
|
723
|
-
const
|
|
727
|
+
const { dir: secretDir, cleanup } = createTestTempDir("mc-config-secret-");
|
|
728
|
+
const secretFile = join(secretDir, "secret.txt");
|
|
724
729
|
writeFileSync(secretFile, "project-file-secret", "utf-8");
|
|
725
730
|
|
|
726
731
|
try {
|
|
@@ -743,7 +748,7 @@ describe("loadPluginConfig — variable expansion scope", () => {
|
|
|
743
748
|
expect(warnings).toContain("security reasons");
|
|
744
749
|
expect(warnings).toContain("embedding.endpoint/provider");
|
|
745
750
|
} finally {
|
|
746
|
-
|
|
751
|
+
cleanup();
|
|
747
752
|
}
|
|
748
753
|
});
|
|
749
754
|
|
|
@@ -30,6 +30,7 @@ describe("MagicContextConfigSchema", () => {
|
|
|
30
30
|
embedding: {
|
|
31
31
|
provider: "local",
|
|
32
32
|
model: DEFAULT_LOCAL_EMBEDDING_MODEL,
|
|
33
|
+
local_runtime: "auto",
|
|
33
34
|
},
|
|
34
35
|
memory: {
|
|
35
36
|
enabled: true,
|
|
@@ -654,17 +655,35 @@ describe("MagicContextConfigSchema", () => {
|
|
|
654
655
|
).toThrow();
|
|
655
656
|
});
|
|
656
657
|
|
|
658
|
+
it("defaults local embedding runtime to auto and accepts explicit overrides", () => {
|
|
659
|
+
expect(
|
|
660
|
+
MagicContextConfigSchema.parse({ embedding: { provider: "local" } }).embedding,
|
|
661
|
+
).toMatchObject({ local_runtime: "auto" });
|
|
662
|
+
expect(
|
|
663
|
+
MagicContextConfigSchema.parse({
|
|
664
|
+
embedding: { provider: "local", local_runtime: "wasm" },
|
|
665
|
+
}).embedding,
|
|
666
|
+
).toMatchObject({ local_runtime: "wasm" });
|
|
667
|
+
expect(
|
|
668
|
+
MagicContextConfigSchema.safeParse({
|
|
669
|
+
embedding: { provider: "local", local_runtime: "unsafe" },
|
|
670
|
+
}).success,
|
|
671
|
+
).toBe(false);
|
|
672
|
+
});
|
|
673
|
+
|
|
657
674
|
it("accepts a configured local embedding dtype", () => {
|
|
658
675
|
const result = MagicContextConfigSchema.parse({
|
|
659
676
|
embedding: {
|
|
660
677
|
provider: "local",
|
|
661
678
|
model: "Xenova/paraphrase-multilingual-MiniLM-L12-v2",
|
|
679
|
+
local_runtime: "auto",
|
|
662
680
|
local_dtype: "q8",
|
|
663
681
|
},
|
|
664
682
|
});
|
|
665
683
|
expect(result.embedding).toEqual({
|
|
666
684
|
provider: "local",
|
|
667
685
|
model: "Xenova/paraphrase-multilingual-MiniLM-L12-v2",
|
|
686
|
+
local_runtime: "auto",
|
|
668
687
|
local_dtype: "q8",
|
|
669
688
|
});
|
|
670
689
|
});
|
|
@@ -676,6 +695,7 @@ describe("MagicContextConfigSchema", () => {
|
|
|
676
695
|
expect(result.embedding).toEqual({
|
|
677
696
|
provider: "local",
|
|
678
697
|
model: DEFAULT_LOCAL_EMBEDDING_MODEL,
|
|
698
|
+
local_runtime: "auto",
|
|
679
699
|
});
|
|
680
700
|
expect("local_dtype" in result.embedding).toBe(false);
|
|
681
701
|
});
|
|
@@ -619,6 +619,12 @@ const BaseEmbeddingConfigSchema = z
|
|
|
619
619
|
.describe(
|
|
620
620
|
"Optional maximum input tokens for chunk embeddings. Defaults conservatively to 512 when omitted.",
|
|
621
621
|
),
|
|
622
|
+
local_runtime: z
|
|
623
|
+
.enum(["auto", "native", "wasm"])
|
|
624
|
+
.default("auto")
|
|
625
|
+
.describe(
|
|
626
|
+
"Local provider only: ONNX runtime selection. 'auto' uses native under Node and uses WASM under Bun versions before 1.4.0, where Bun's NAPI teardown race can panic on quit; native is restored automatically on Bun 1.4.0+. Set 'native' only to prefer speed while accepting that pre-1.4.0 Bun crash risk, or 'wasm' to avoid loading the native addon.",
|
|
627
|
+
),
|
|
622
628
|
local_dtype: z
|
|
623
629
|
.enum([
|
|
624
630
|
"auto",
|
|
@@ -685,6 +691,7 @@ export const EmbeddingConfigSchema = BaseEmbeddingConfigSchema.transform((data)
|
|
|
685
691
|
return {
|
|
686
692
|
provider: "local" as const,
|
|
687
693
|
model: data.model?.trim() || DEFAULT_LOCAL_EMBEDDING_MODEL,
|
|
694
|
+
local_runtime: data.local_runtime,
|
|
688
695
|
...(data.max_input_tokens ? { max_input_tokens: data.max_input_tokens } : {}),
|
|
689
696
|
// local_dtype is spread CONDITIONALLY: omitting it when unset keeps
|
|
690
697
|
// the identity byte-identical for the common no-dtype config, so
|
|
@@ -1161,6 +1168,7 @@ export const MagicContextConfigSchema = z
|
|
|
1161
1168
|
embedding: EmbeddingConfigSchema.default({
|
|
1162
1169
|
provider: "local",
|
|
1163
1170
|
model: DEFAULT_LOCAL_EMBEDDING_MODEL,
|
|
1171
|
+
local_runtime: "auto",
|
|
1164
1172
|
}).describe("Embedding provider configuration"),
|
|
1165
1173
|
subc: z
|
|
1166
1174
|
.object({
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/// <reference types="bun-types" />
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
4
|
-
import { mkdirSync,
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
4
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
6
5
|
import { join } from "node:path";
|
|
6
|
+
|
|
7
7
|
import { detectConflicts, resolveCompactionForBoot } from "./conflict-detector";
|
|
8
|
+
import { cleanupTestTempDir, createTestTempDir } from "./test-temp-dir";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Regression tests for plugin-conflict detection. The previous substring-
|
|
@@ -13,13 +14,14 @@ import { detectConflicts, resolveCompactionForBoot } from "./conflict-detector";
|
|
|
13
14
|
* a false-positive conflict warning. See issue #43.
|
|
14
15
|
*/
|
|
15
16
|
describe("detectConflicts", () => {
|
|
17
|
+
let root: string;
|
|
16
18
|
let projectDir: string;
|
|
17
19
|
let userConfigDir: string;
|
|
18
20
|
let homeDir: string;
|
|
19
21
|
let originalEnv: Record<string, string | undefined>;
|
|
20
22
|
|
|
21
23
|
beforeEach(() => {
|
|
22
|
-
|
|
24
|
+
root = createTestTempDir("mc-conflict-").dir;
|
|
23
25
|
projectDir = join(root, "project");
|
|
24
26
|
mkdirSync(projectDir, { recursive: true });
|
|
25
27
|
userConfigDir = join(root, "user-config", "opencode");
|
|
@@ -50,22 +52,7 @@ describe("detectConflicts", () => {
|
|
|
50
52
|
if (v === undefined) delete process.env[k];
|
|
51
53
|
else process.env[k] = v;
|
|
52
54
|
}
|
|
53
|
-
|
|
54
|
-
try {
|
|
55
|
-
rmSync(projectDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
56
|
-
} catch {
|
|
57
|
-
/* Ignore EBUSY on Windows */
|
|
58
|
-
}
|
|
59
|
-
try {
|
|
60
|
-
rmSync(userConfigDir, {
|
|
61
|
-
recursive: true,
|
|
62
|
-
force: true,
|
|
63
|
-
maxRetries: 10,
|
|
64
|
-
retryDelay: 100,
|
|
65
|
-
});
|
|
66
|
-
} catch {
|
|
67
|
-
/* Ignore EBUSY on Windows */
|
|
68
|
-
}
|
|
55
|
+
cleanupTestTempDir(root);
|
|
69
56
|
});
|
|
70
57
|
|
|
71
58
|
function writeProjectConfig(plugins: Array<string | [string, unknown]>): void {
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
/// <reference types="bun-types" />
|
|
2
2
|
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
|
4
|
-
import { existsSync, mkdirSync,
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
5
|
import { join } from "node:path";
|
|
7
6
|
import { parse as parseJsonc } from "comment-json";
|
|
7
|
+
|
|
8
8
|
import { detectConflicts } from "./conflict-detector";
|
|
9
9
|
import { fixConflicts } from "./conflict-fixer";
|
|
10
|
+
import { cleanupTestTempDir, createTestTempDir } from "./test-temp-dir";
|
|
10
11
|
|
|
11
12
|
const noOmoConflicts = {
|
|
12
13
|
omoPreemptiveCompaction: false,
|
|
@@ -22,7 +23,7 @@ describe("fixConflicts", () => {
|
|
|
22
23
|
let originalEnv: Record<string, string | undefined>;
|
|
23
24
|
|
|
24
25
|
beforeEach(() => {
|
|
25
|
-
root =
|
|
26
|
+
root = createTestTempDir("mc-conflict-fixer-").dir;
|
|
26
27
|
projectDir = join(root, "project");
|
|
27
28
|
userConfigDir = join(root, "user-config", "opencode");
|
|
28
29
|
homeDir = join(root, "home");
|
|
@@ -44,11 +45,7 @@ describe("fixConflicts", () => {
|
|
|
44
45
|
if (value === undefined) delete process.env[key];
|
|
45
46
|
else process.env[key] = value;
|
|
46
47
|
}
|
|
47
|
-
|
|
48
|
-
rmSync(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
|
|
49
|
-
} catch {
|
|
50
|
-
/* Ignore EBUSY on Windows */
|
|
51
|
-
}
|
|
48
|
+
cleanupTestTempDir(root);
|
|
52
49
|
});
|
|
53
50
|
|
|
54
51
|
it("preserves JSONC comments and tuple plugin entries while removing canonical DCP", () => {
|
|
@@ -18,49 +18,121 @@ type LoggerScenarioResult = {
|
|
|
18
18
|
};
|
|
19
19
|
};
|
|
20
20
|
|
|
21
|
+
type RotationScenarioResult = {
|
|
22
|
+
firstCurrent: string;
|
|
23
|
+
firstPredecessorFirstByte: number;
|
|
24
|
+
current: string;
|
|
25
|
+
predecessorFirstByte: number;
|
|
26
|
+
predecessorSize: number;
|
|
27
|
+
currentSize: number;
|
|
28
|
+
currentMode: number;
|
|
29
|
+
predecessorMode: number;
|
|
30
|
+
files: string[];
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
type HotPathScenarioResult = {
|
|
34
|
+
statCalls: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
21
37
|
const loggerScenario = `
|
|
22
|
-
import
|
|
38
|
+
import fs from "node:fs";
|
|
23
39
|
import * as path from "node:path";
|
|
40
|
+
import { mock } from "bun:test";
|
|
24
41
|
|
|
25
42
|
const root = process.env.LOGGER_SCENARIO_ROOT;
|
|
26
43
|
const loggerModuleUrl = process.env.LOGGER_MODULE_URL;
|
|
27
|
-
|
|
44
|
+
const scenario = process.env.LOGGER_SCENARIO;
|
|
45
|
+
if (!root || !loggerModuleUrl || !scenario) {
|
|
46
|
+
throw new Error("logger scenario environment is incomplete");
|
|
47
|
+
}
|
|
28
48
|
|
|
29
|
-
const logger = await import(loggerModuleUrl);
|
|
30
49
|
const logPath = path.join(root, "nested", "magic-context.log");
|
|
31
50
|
process.env.MAGIC_CONTEXT_LOG_PATH = logPath;
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
if (process.env.LOGGER_SCENARIO === "recovery") {
|
|
42
|
-
const { rmSync } = await import("node:fs");
|
|
43
|
-
rmSync(logDirectory, { recursive: true, force: true });
|
|
44
|
-
logger.log("second");
|
|
45
|
-
logger.flushLogger();
|
|
51
|
+
let statCalls = 0;
|
|
52
|
+
if (scenario === "hot-path") {
|
|
53
|
+
mock.module("node:fs", () => ({
|
|
54
|
+
...fs,
|
|
55
|
+
statSync: (...args) => {
|
|
56
|
+
statCalls++;
|
|
57
|
+
return fs.statSync(...args);
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
46
60
|
}
|
|
47
61
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
62
|
+
const logger = await import(loggerModuleUrl);
|
|
63
|
+
|
|
64
|
+
if (scenario === "rotation") {
|
|
65
|
+
const maxLogBytes = 32 * 1024 * 1024;
|
|
66
|
+
fs.mkdirSync(path.dirname(logPath), { recursive: true });
|
|
67
|
+
fs.writeFileSync(logPath, Buffer.alloc(maxLogBytes, "o"));
|
|
68
|
+
fs.chmodSync(logPath, 0o600);
|
|
69
|
+
fs.writeFileSync(logPath + ".1", "prior predecessor");
|
|
70
|
+
|
|
71
|
+
logger.log("after first rotation");
|
|
72
|
+
logger.flushLogger();
|
|
73
|
+
const firstCurrent = fs.readFileSync(logPath, "utf8");
|
|
74
|
+
const firstPredecessorFirstByte = fs.readFileSync(logPath + ".1")[0];
|
|
75
|
+
|
|
76
|
+
// Switch paths once so the logger rechecks the primary path after this
|
|
77
|
+
// external replacement, as a different plugin process would require.
|
|
78
|
+
fs.writeFileSync(logPath, Buffer.alloc(maxLogBytes, "n"));
|
|
79
|
+
process.env.MAGIC_CONTEXT_LOG_PATH = path.join(root, "alternate.log");
|
|
80
|
+
logger.log("alternate path");
|
|
81
|
+
logger.flushLogger();
|
|
82
|
+
process.env.MAGIC_CONTEXT_LOG_PATH = logPath;
|
|
83
|
+
logger.log("after second rotation");
|
|
84
|
+
logger.flushLogger();
|
|
85
|
+
|
|
86
|
+
const predecessorPath = logPath + ".1";
|
|
87
|
+
console.log(JSON.stringify({
|
|
88
|
+
firstCurrent,
|
|
89
|
+
firstPredecessorFirstByte,
|
|
90
|
+
current: fs.readFileSync(logPath, "utf8"),
|
|
91
|
+
predecessorFirstByte: fs.readFileSync(predecessorPath)[0],
|
|
92
|
+
predecessorSize: fs.statSync(predecessorPath).size,
|
|
93
|
+
currentSize: fs.statSync(logPath).size,
|
|
94
|
+
currentMode: fs.statSync(logPath).mode & 0o777,
|
|
95
|
+
predecessorMode: fs.statSync(predecessorPath).mode & 0o777,
|
|
96
|
+
files: fs.readdirSync(path.dirname(logPath)).filter((name) => name.startsWith("magic-context.log")).sort(),
|
|
97
|
+
}));
|
|
98
|
+
} else if (scenario === "hot-path") {
|
|
99
|
+
for (let index = 0; index < 20; index++) {
|
|
100
|
+
logger.log("hot-path-" + index);
|
|
101
|
+
logger.flushLogger();
|
|
102
|
+
}
|
|
103
|
+
console.log(JSON.stringify({ statCalls }));
|
|
104
|
+
} else {
|
|
105
|
+
logger.log("first");
|
|
106
|
+
logger.log(
|
|
107
|
+
"tokens.input=45000 api_key=sk-proj-abcdefghijklmnopqrstuvwxyzABCDEFGH",
|
|
108
|
+
{ authorization: "Bearer abcdefghijklmnop", totalInputTokens: 132000 },
|
|
109
|
+
);
|
|
110
|
+
logger.flushLogger();
|
|
111
|
+
const healthyDiagnostics = logger.getLoggerDiagnostics();
|
|
112
|
+
|
|
113
|
+
const logDirectory = path.dirname(logPath);
|
|
114
|
+
if (scenario === "recovery") {
|
|
115
|
+
fs.rmSync(logDirectory, { recursive: true, force: true });
|
|
116
|
+
logger.log("second");
|
|
117
|
+
logger.flushLogger();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// A directory used as the file target makes append fail deterministically on every platform.
|
|
121
|
+
const failedPath = path.join(root, "unwritable-log-target");
|
|
122
|
+
fs.mkdirSync(failedPath, { recursive: true });
|
|
123
|
+
process.env.MAGIC_CONTEXT_LOG_PATH = failedPath;
|
|
124
|
+
logger.log("failed write");
|
|
125
|
+
logger.flushLogger();
|
|
126
|
+
const failedDiagnostics = logger.getLoggerDiagnostics();
|
|
127
|
+
|
|
128
|
+
const content = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8") : "";
|
|
129
|
+
console.log(JSON.stringify({
|
|
130
|
+
exists: fs.existsSync(logPath),
|
|
131
|
+
content,
|
|
132
|
+
healthyDiagnostics,
|
|
133
|
+
failedDiagnostics,
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
64
136
|
`;
|
|
65
137
|
|
|
66
138
|
const scenarioRoots: string[] = [];
|
|
@@ -71,9 +143,9 @@ afterEach(() => {
|
|
|
71
143
|
}
|
|
72
144
|
});
|
|
73
145
|
|
|
74
|
-
async function runLoggerScenario(
|
|
75
|
-
scenario: "recovery" | "diagnostics",
|
|
76
|
-
): Promise<
|
|
146
|
+
async function runLoggerScenario<T>(
|
|
147
|
+
scenario: "recovery" | "diagnostics" | "rotation" | "hot-path",
|
|
148
|
+
): Promise<T> {
|
|
77
149
|
const root = mkdtempSync(path.join(os.tmpdir(), "magic-context-logger-test-"));
|
|
78
150
|
scenarioRoots.push(root);
|
|
79
151
|
const child = Bun.spawn({
|
|
@@ -95,19 +167,19 @@ async function runLoggerScenario(
|
|
|
95
167
|
new Response(child.stderr).text(),
|
|
96
168
|
]);
|
|
97
169
|
expect(exitCode, stderr).toBe(0);
|
|
98
|
-
return JSON.parse(stdout.trim()) as
|
|
170
|
+
return JSON.parse(stdout.trim()) as T;
|
|
99
171
|
}
|
|
100
172
|
|
|
101
173
|
describe("logger", () => {
|
|
102
174
|
test("recreates a log directory removed while the process is running", async () => {
|
|
103
|
-
const result = await runLoggerScenario("recovery");
|
|
175
|
+
const result = await runLoggerScenario<LoggerScenarioResult>("recovery");
|
|
104
176
|
|
|
105
177
|
expect(result.exists).toBe(true);
|
|
106
178
|
expect(result.content).toContain("second");
|
|
107
179
|
});
|
|
108
180
|
|
|
109
181
|
test("redacts secrets while preserving numeric diagnostics", async () => {
|
|
110
|
-
const result = await runLoggerScenario("diagnostics");
|
|
182
|
+
const result = await runLoggerScenario<LoggerScenarioResult>("diagnostics");
|
|
111
183
|
|
|
112
184
|
expect(result.content).toContain("tokens.input=45000");
|
|
113
185
|
expect(result.content).toContain('"totalInputTokens":132000');
|
|
@@ -117,8 +189,31 @@ describe("logger", () => {
|
|
|
117
189
|
expect(result.content).not.toContain("abcdefghijklmnop");
|
|
118
190
|
});
|
|
119
191
|
|
|
192
|
+
test("rotates at the 32 MiB bound with one private predecessor", async () => {
|
|
193
|
+
const result = await runLoggerScenario<RotationScenarioResult>("rotation");
|
|
194
|
+
const maxLogBytes = 32 * 1024 * 1024;
|
|
195
|
+
|
|
196
|
+
expect(result.firstCurrent).toContain("after first rotation");
|
|
197
|
+
expect(result.firstPredecessorFirstByte).toBe("o".charCodeAt(0));
|
|
198
|
+
expect(result.current).toContain("after second rotation");
|
|
199
|
+
expect(result.predecessorFirstByte).toBe("n".charCodeAt(0));
|
|
200
|
+
expect(result.predecessorSize).toBeLessThanOrEqual(maxLogBytes);
|
|
201
|
+
expect(result.currentSize).toBeLessThanOrEqual(maxLogBytes);
|
|
202
|
+
expect(result.files).toEqual(["magic-context.log", "magic-context.log.1"]);
|
|
203
|
+
if (process.platform !== "win32") {
|
|
204
|
+
expect(result.currentMode).toBe(0o600);
|
|
205
|
+
expect(result.predecessorMode).toBe(0o600);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("uses cached size between periodic rotation checks", async () => {
|
|
210
|
+
const result = await runLoggerScenario<HotPathScenarioResult>("hot-path");
|
|
211
|
+
|
|
212
|
+
expect(result.statCalls).toBeLessThanOrEqual(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
120
215
|
test("reports swallowed writes while healthy writes leave the counter at zero", async () => {
|
|
121
|
-
const result = await runLoggerScenario("diagnostics");
|
|
216
|
+
const result = await runLoggerScenario<LoggerScenarioResult>("diagnostics");
|
|
122
217
|
|
|
123
218
|
expect(result.healthyDiagnostics).toEqual({
|
|
124
219
|
swallowedWriteCount: 0,
|
package/src/shared/logger.ts
CHANGED
|
@@ -9,6 +9,12 @@ let buffer: string[] = [];
|
|
|
9
9
|
let flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
10
10
|
const FLUSH_INTERVAL_MS = 500;
|
|
11
11
|
const BUFFER_SIZE_LIMIT = 50;
|
|
12
|
+
const MAX_LOG_FILE_BYTES = 32 * 1024 * 1024;
|
|
13
|
+
const SIZE_CHECK_INTERVAL_FLUSHES = 64;
|
|
14
|
+
|
|
15
|
+
let activeLogFile: string | null = null;
|
|
16
|
+
let activeLogSize: number | null = null;
|
|
17
|
+
let flushesSinceSizeCheck = 0;
|
|
12
18
|
|
|
13
19
|
export interface LoggerDiagnostics {
|
|
14
20
|
swallowedWriteCount: number;
|
|
@@ -36,19 +42,126 @@ function ensureDir(filePath: string): void {
|
|
|
36
42
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
37
43
|
}
|
|
38
44
|
|
|
45
|
+
function isMissingFile(error: unknown): boolean {
|
|
46
|
+
return (
|
|
47
|
+
typeof error === "object" &&
|
|
48
|
+
error !== null &&
|
|
49
|
+
"code" in error &&
|
|
50
|
+
(error as { code?: unknown }).code === "ENOENT"
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function getCurrentLogSize(logFile: string): number {
|
|
55
|
+
if (
|
|
56
|
+
activeLogFile === logFile &&
|
|
57
|
+
activeLogSize !== null &&
|
|
58
|
+
flushesSinceSizeCheck < SIZE_CHECK_INTERVAL_FLUSHES
|
|
59
|
+
) {
|
|
60
|
+
return activeLogSize;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const stat = fs.statSync(logFile);
|
|
65
|
+
if (!stat.isFile()) {
|
|
66
|
+
throw new Error(`Magic Context log path is not a regular file: ${logFile}`);
|
|
67
|
+
}
|
|
68
|
+
// Logs can contain diagnostic context, so tighten files created by older
|
|
69
|
+
// versions before copying or appending any new content.
|
|
70
|
+
fs.chmodSync(logFile, 0o600);
|
|
71
|
+
activeLogFile = logFile;
|
|
72
|
+
activeLogSize = stat.size;
|
|
73
|
+
flushesSinceSizeCheck = 0;
|
|
74
|
+
return stat.size;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!isMissingFile(error)) throw error;
|
|
77
|
+
activeLogFile = logFile;
|
|
78
|
+
activeLogSize = 0;
|
|
79
|
+
flushesSinceSizeCheck = 0;
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function capLogData(data: string): string {
|
|
85
|
+
if (Buffer.byteLength(data) <= MAX_LOG_FILE_BYTES) return data;
|
|
86
|
+
|
|
87
|
+
// A single diagnostic payload must not defeat the on-disk bound. Cutting a
|
|
88
|
+
// Buffer can split UTF-8, so trim any replacement character expansion too.
|
|
89
|
+
let bounded = Buffer.from(data).subarray(0, MAX_LOG_FILE_BYTES).toString("utf8");
|
|
90
|
+
while (Buffer.byteLength(bounded) > MAX_LOG_FILE_BYTES) {
|
|
91
|
+
bounded = bounded.slice(0, -1);
|
|
92
|
+
}
|
|
93
|
+
return bounded;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function writeBoundedPredecessor(logFile: string, predecessorPath: string, size: number): void {
|
|
97
|
+
const predecessorFd = fs.openSync(predecessorPath, "w", 0o600);
|
|
98
|
+
try {
|
|
99
|
+
// `openSync(..., "w")` preserves an existing file's mode, so set it
|
|
100
|
+
// before any copied diagnostic text reaches the predecessor.
|
|
101
|
+
fs.fchmodSync(predecessorFd, 0o600);
|
|
102
|
+
const bytesToCopy = Math.min(size, MAX_LOG_FILE_BYTES);
|
|
103
|
+
const sourceFd = fs.openSync(logFile, "r");
|
|
104
|
+
try {
|
|
105
|
+
const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, bytesToCopy));
|
|
106
|
+
let remaining = bytesToCopy;
|
|
107
|
+
let position = Math.max(0, size - bytesToCopy);
|
|
108
|
+
while (remaining > 0) {
|
|
109
|
+
const bytesRead = fs.readSync(
|
|
110
|
+
sourceFd,
|
|
111
|
+
chunk,
|
|
112
|
+
0,
|
|
113
|
+
Math.min(chunk.length, remaining),
|
|
114
|
+
position,
|
|
115
|
+
);
|
|
116
|
+
if (bytesRead === 0) break;
|
|
117
|
+
fs.writeSync(predecessorFd, chunk, 0, bytesRead);
|
|
118
|
+
remaining -= bytesRead;
|
|
119
|
+
position += bytesRead;
|
|
120
|
+
}
|
|
121
|
+
} finally {
|
|
122
|
+
fs.closeSync(sourceFd);
|
|
123
|
+
}
|
|
124
|
+
} finally {
|
|
125
|
+
fs.closeSync(predecessorFd);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function rotateLogFile(logFile: string, size: number): void {
|
|
130
|
+
const predecessorPath = `${logFile}.1`;
|
|
131
|
+
writeBoundedPredecessor(logFile, predecessorPath, size);
|
|
132
|
+
// Keep the current inode in place so diagnostic readers can observe a
|
|
133
|
+
// shorter log during rotation instead of seeing the path disappear.
|
|
134
|
+
fs.truncateSync(logFile, 0);
|
|
135
|
+
activeLogSize = 0;
|
|
136
|
+
flushesSinceSizeCheck = 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
39
139
|
function flush(): void {
|
|
40
140
|
if (flushTimer) {
|
|
41
141
|
clearTimeout(flushTimer);
|
|
42
142
|
flushTimer = null;
|
|
43
143
|
}
|
|
44
144
|
if (buffer.length === 0) return;
|
|
45
|
-
const
|
|
145
|
+
const bufferedData = buffer.join("");
|
|
46
146
|
buffer = [];
|
|
47
147
|
try {
|
|
148
|
+
const data = capLogData(bufferedData);
|
|
48
149
|
const logFile = getMagicContextLogPath();
|
|
49
150
|
ensureDir(logFile);
|
|
50
|
-
|
|
151
|
+
let currentSize = getCurrentLogSize(logFile);
|
|
152
|
+
const dataSize = Buffer.byteLength(data);
|
|
153
|
+
if (currentSize > 0 && currentSize + dataSize > MAX_LOG_FILE_BYTES) {
|
|
154
|
+
rotateLogFile(logFile, currentSize);
|
|
155
|
+
currentSize = 0;
|
|
156
|
+
}
|
|
157
|
+
fs.appendFileSync(logFile, data, { encoding: "utf8", mode: 0o600 });
|
|
158
|
+
activeLogFile = logFile;
|
|
159
|
+
activeLogSize = currentSize + dataSize;
|
|
160
|
+
flushesSinceSizeCheck++;
|
|
51
161
|
} catch (error) {
|
|
162
|
+
activeLogFile = null;
|
|
163
|
+
activeLogSize = null;
|
|
164
|
+
flushesSinceSizeCheck = 0;
|
|
52
165
|
recordSwallowedWrite(error);
|
|
53
166
|
}
|
|
54
167
|
}
|
|
@@ -9,9 +9,8 @@ import {
|
|
|
9
9
|
} from "./model-resolution";
|
|
10
10
|
|
|
11
11
|
describe("model-resolution", () => {
|
|
12
|
-
test("
|
|
12
|
+
test("keeps historian temperature opt-in while preserving explicit overrides", () => {
|
|
13
13
|
expect(resolveHistorianAgentOverrides(undefined)).toEqual({
|
|
14
|
-
temperature: 0.1,
|
|
15
14
|
maxTokens: 32_000,
|
|
16
15
|
});
|
|
17
16
|
expect(
|
|
@@ -56,7 +55,7 @@ describe("model-resolution", () => {
|
|
|
56
55
|
]);
|
|
57
56
|
});
|
|
58
57
|
|
|
59
|
-
test("
|
|
58
|
+
test("keeps the flash calibration available only as an explicit override", () => {
|
|
60
59
|
expect(
|
|
61
60
|
resolveHistorianAgentOverrides({
|
|
62
61
|
opencode: { model: { model: "google/flash", variant: "fast" } },
|
|
@@ -64,15 +63,14 @@ describe("model-resolution", () => {
|
|
|
64
63
|
two_pass: true,
|
|
65
64
|
}),
|
|
66
65
|
).toEqual({
|
|
67
|
-
temperature: 0.1,
|
|
68
66
|
maxTokens: 32_000,
|
|
69
67
|
two_pass: true,
|
|
70
68
|
model: "google/flash",
|
|
71
69
|
variant: "fast",
|
|
72
70
|
});
|
|
73
71
|
expect(
|
|
74
|
-
resolveHistorianAgentOverrides({ temperature: 0.
|
|
75
|
-
).toMatchObject({ temperature: 0.
|
|
72
|
+
resolveHistorianAgentOverrides({ temperature: 0.1, maxTokens: 12_000 }),
|
|
73
|
+
).toMatchObject({ temperature: 0.1, maxTokens: 12_000 });
|
|
76
74
|
});
|
|
77
75
|
|
|
78
76
|
test("does not inherit a block qualifier into unqualified fallbacks", () => {
|