@cortexkit/opencode-magic-context 0.36.0 → 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-dw9fs934.js → conflict-warning-hook-an4fxhwf.js} +4 -4
- package/dist/{derive-budgets-88mxk2w1.js → derive-budgets-0f8xpyw2.js} +3 -2
- package/dist/features/magic-context/context-authority.d.ts +12 -0
- package/dist/features/magic-context/context-authority.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/classify.d.ts +2 -0
- package/dist/features/magic-context/dreamer/classify.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts +2 -0
- package/dist/features/magic-context/dreamer/evaluate-smart-notes.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/lease.d.ts +10 -3
- package/dist/features/magic-context/dreamer/lease.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/map-memories.d.ts +2 -0
- package/dist/features/magic-context/dreamer/map-memories.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/module-apply.d.ts +5 -0
- package/dist/features/magic-context/dreamer/module-apply.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/promote-primers.d.ts +2 -0
- package/dist/features/magic-context/dreamer/promote-primers.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/refresh-primers.d.ts +2 -0
- package/dist/features/magic-context/dreamer/refresh-primers.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-executor.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts +5 -2
- package/dist/features/magic-context/dreamer/task-scheduler.d.ts.map +1 -1
- package/dist/features/magic-context/dreamer/verify.d.ts +2 -0
- package/dist/features/magic-context/dreamer/verify.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/message-index-async.d.ts.map +1 -1
- package/dist/features/magic-context/migrations.d.ts.map +1 -1
- package/dist/features/magic-context/mural/compress-cues.d.ts +2 -0
- package/dist/features/magic-context/mural/compress-cues.d.ts.map +1 -1
- package/dist/features/magic-context/overflow-detection.d.ts.map +1 -1
- package/dist/features/magic-context/project-embedding-registry.d.ts.map +1 -1
- package/dist/features/magic-context/schema-fence-probe.d.ts +1 -0
- package/dist/features/magic-context/schema-fence-probe.d.ts.map +1 -1
- package/dist/features/magic-context/smart-notes/condition-compiler.d.ts +1 -0
- package/dist/features/magic-context/smart-notes/condition-compiler.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-clone.d.ts +6 -0
- package/dist/features/magic-context/storage-clone.d.ts.map +1 -1
- package/dist/features/magic-context/storage-db.d.ts +14 -3
- package/dist/features/magic-context/storage-db.d.ts.map +1 -1
- package/dist/features/magic-context/storage-identity-merge.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/storage-tags.d.ts +2 -0
- package/dist/features/magic-context/storage-tags.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/features/magic-context/user-memory/review-user-memories.d.ts +2 -0
- package/dist/features/magic-context/user-memory/review-user-memories.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/child-session-spawn.d.ts +1 -0
- package/dist/hooks/magic-context/child-session-spawn.d.ts.map +1 -1
- package/dist/hooks/magic-context/command-handler.d.ts.map +1 -1
- package/dist/hooks/magic-context/compaction-off-transition.d.ts.map +1 -1
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts +5 -3
- package/dist/hooks/magic-context/compartment-render-epoch.d.ts.map +1 -1
- package/dist/hooks/magic-context/embed-session-state.d.ts +1 -1
- package/dist/hooks/magic-context/embed-session-state.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 +8 -0
- package/dist/hooks/magic-context/inject-compartments.d.ts.map +1 -1
- package/dist/hooks/magic-context/lkg-replay.d.ts +5 -2
- 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 +9 -0
- package/dist/hooks/magic-context/module-state-sync.d.ts.map +1 -1
- package/dist/hooks/magic-context/module-transport.d.ts +20 -3
- 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/read-session-formatting.d.ts +1 -0
- package/dist/hooks/magic-context/read-session-formatting.d.ts.map +1 -1
- package/dist/hooks/magic-context/rust-mode-transform.d.ts +20 -0
- package/dist/hooks/magic-context/rust-mode-transform.d.ts.map +1 -1
- package/dist/hooks/magic-context/strip-content.d.ts +31 -1
- 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 +12 -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-3nfgbsp4.js → index-0ze9p983.js} +897 -114
- package/dist/{index-nsx2n9eg.js → index-4cz5nfxf.js} +3 -3
- package/dist/{index-0c9dmw9m.js → index-8mtsfhyg.js} +33 -3
- package/dist/{index-f1tnb3vm.js → index-8pqfcghm.js} +21 -9
- package/dist/{index-37rt2bka.js → index-cae89eas.js} +26 -7
- package/dist/index-cgyfn1s2.js +53 -0
- package/dist/{index-z8v31d85.js → index-crke4z5j.js} +144 -14
- 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 +1952 -795
- 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/plugin/rust-tool-backends.d.ts +4 -0
- package/dist/plugin/rust-tool-backends.d.ts.map +1 -1
- package/dist/{recomp-orchestrator-7wbbvf87.js → recomp-orchestrator-c3ff16k7.js} +8 -7
- 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/conflict-fixer.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/jsonc-edit.d.ts +18 -0
- package/dist/shared/jsonc-edit.d.ts.map +1 -0
- package/dist/shared/jsonc-parser.d.ts +11 -1
- package/dist/shared/jsonc-parser.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 +7 -3
- 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 +32 -6
- package/dist/shared/rpc-utils.d.ts.map +1 -1
- package/dist/shared/tui-preferences.d.ts +9 -0
- package/dist/shared/tui-preferences.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-kh5egr11.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 +2 -1
- package/src/config/index.test.ts +23 -0
- package/src/config/index.ts +43 -9
- package/src/config/latch-permanence-guard.test.ts +218 -0
- package/src/config/project-security.ts +10 -1
- package/src/config/schema/magic-context.test.ts +2 -1
- package/src/config/schema/magic-context.ts +13 -3
- 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/conflict-fixer.test.ts +174 -0
- package/src/shared/conflict-fixer.ts +66 -70
- package/src/shared/harness-provider-map.test.ts +20 -0
- package/src/shared/harness-provider-map.ts +19 -0
- package/src/shared/jsonc-edit.ts +295 -0
- package/src/shared/jsonc-parser.test.ts +33 -0
- package/src/shared/jsonc-parser.ts +54 -2
- 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 +91 -2
- package/src/shared/prompt-surface.ts +35 -11
- package/src/shared/rpc-client.ts +4 -2
- package/src/shared/rpc-types.ts +14 -0
- package/src/shared/rpc-utils.test.ts +173 -13
- package/src/shared/rpc-utils.ts +202 -18
- package/src/shared/tui-preferences.test.ts +52 -2
- package/src/shared/tui-preferences.ts +42 -17
- 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
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import {
|
|
2
|
+
applyEdits,
|
|
3
|
+
createScanner,
|
|
4
|
+
findNodeAtLocation,
|
|
5
|
+
getNodeValue,
|
|
6
|
+
type JSONPath,
|
|
7
|
+
modify,
|
|
8
|
+
type Node,
|
|
9
|
+
type ParseError,
|
|
10
|
+
parseTree,
|
|
11
|
+
} from "jsonc-parser";
|
|
12
|
+
|
|
13
|
+
interface TokenLocation {
|
|
14
|
+
offset: number;
|
|
15
|
+
length: number;
|
|
16
|
+
line: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* jsonc-parser exposes SyntaxKind as a const enum, which cannot be referenced
|
|
21
|
+
* from this isolated TypeScript module. Keep the scanner token values local.
|
|
22
|
+
*/
|
|
23
|
+
const TOKEN_COMMA = 5;
|
|
24
|
+
const TOKEN_LINE_COMMENT = 12;
|
|
25
|
+
const TOKEN_BLOCK_COMMENT = 13;
|
|
26
|
+
const TOKEN_EOF = 17;
|
|
27
|
+
|
|
28
|
+
function parseDocument(text: string): Node {
|
|
29
|
+
const errors: ParseError[] = [];
|
|
30
|
+
const root = parseTree(text, errors, { allowTrailingComma: true });
|
|
31
|
+
if (!root || errors.length > 0) {
|
|
32
|
+
throw new Error("Cannot edit invalid JSONC");
|
|
33
|
+
}
|
|
34
|
+
return root;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function findNode(text: string, path: JSONPath): Node | undefined {
|
|
38
|
+
return findNodeAtLocation(parseDocument(text), path);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findComma(text: string, start: number, end: number): TokenLocation | undefined {
|
|
42
|
+
const scanner = createScanner(text, false);
|
|
43
|
+
scanner.setPosition(start);
|
|
44
|
+
|
|
45
|
+
for (;;) {
|
|
46
|
+
const kind = scanner.scan();
|
|
47
|
+
const offset = scanner.getTokenOffset();
|
|
48
|
+
if (kind === TOKEN_EOF || offset >= end) return undefined;
|
|
49
|
+
if (kind === TOKEN_COMMA) {
|
|
50
|
+
return {
|
|
51
|
+
offset,
|
|
52
|
+
length: scanner.getTokenLength(),
|
|
53
|
+
line: scanner.getTokenStartLine(),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function lineStart(text: string, offset: number): number {
|
|
60
|
+
const previousNewline = text.lastIndexOf("\n", offset - 1);
|
|
61
|
+
return previousNewline === -1 ? 0 : previousNewline + 1;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function lineEnd(text: string, offset: number): number {
|
|
65
|
+
const nextNewline = text.indexOf("\n", offset);
|
|
66
|
+
if (nextNewline === -1) return text.length;
|
|
67
|
+
return text[nextNewline - 1] === "\r" ? nextNewline - 1 : nextNewline;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function inlineCommentEnd(
|
|
71
|
+
text: string,
|
|
72
|
+
start: number,
|
|
73
|
+
line: number,
|
|
74
|
+
limit: number,
|
|
75
|
+
): number | undefined {
|
|
76
|
+
const scanner = createScanner(text, false);
|
|
77
|
+
scanner.setPosition(start);
|
|
78
|
+
let end: number | undefined;
|
|
79
|
+
|
|
80
|
+
for (;;) {
|
|
81
|
+
const kind = scanner.scan();
|
|
82
|
+
const offset = scanner.getTokenOffset();
|
|
83
|
+
if (kind === TOKEN_EOF || offset >= limit || scanner.getTokenStartLine() !== line) {
|
|
84
|
+
return end;
|
|
85
|
+
}
|
|
86
|
+
if (kind === TOKEN_LINE_COMMENT || kind === TOKEN_BLOCK_COMMENT) {
|
|
87
|
+
end = offset + scanner.getTokenLength();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Keep comments written inline after a preceding separator with that preceding
|
|
94
|
+
* entry. Comments on a later line belong to the entry that follows the comma.
|
|
95
|
+
*/
|
|
96
|
+
function startAfterPrecedingInlineComments(
|
|
97
|
+
text: string,
|
|
98
|
+
comma: TokenLocation,
|
|
99
|
+
entryOffset: number,
|
|
100
|
+
): number {
|
|
101
|
+
const commentEnd = inlineCommentEnd(text, comma.offset + comma.length, comma.line, entryOffset);
|
|
102
|
+
if (commentEnd === undefined) return comma.offset + comma.length;
|
|
103
|
+
|
|
104
|
+
const nextNewline = text.indexOf("\n", commentEnd);
|
|
105
|
+
return nextNewline === -1 ? commentEnd : nextNewline + 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Remove inline comments following a removed entry's comma with that entry. */
|
|
109
|
+
function endAfterFollowingInlineComments(
|
|
110
|
+
text: string,
|
|
111
|
+
comma: TokenLocation,
|
|
112
|
+
nextEntryOffset: number,
|
|
113
|
+
): number {
|
|
114
|
+
const commentEnd = inlineCommentEnd(
|
|
115
|
+
text,
|
|
116
|
+
comma.offset + comma.length,
|
|
117
|
+
comma.line,
|
|
118
|
+
nextEntryOffset,
|
|
119
|
+
);
|
|
120
|
+
return commentEnd === undefined ? comma.offset + comma.length : lineEnd(text, commentEnd);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function closingWhitespace(text: string, entryEnd: number, closingBracket: number): string {
|
|
124
|
+
const trailing = text.slice(entryEnd, closingBracket);
|
|
125
|
+
const finalNewline = trailing.lastIndexOf("\n");
|
|
126
|
+
if (finalNewline === -1) return "";
|
|
127
|
+
const newlineStart = trailing[finalNewline - 1] === "\r" ? finalNewline - 1 : finalNewline;
|
|
128
|
+
return trailing.slice(newlineStart);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function removeArrayEntry(text: string, array: Node, index: number): string {
|
|
132
|
+
const entries = array.children ?? [];
|
|
133
|
+
const entry = entries[index];
|
|
134
|
+
if (!entry) return text;
|
|
135
|
+
|
|
136
|
+
const closingBracket = array.offset + array.length - 1;
|
|
137
|
+
if (entries.length === 1) {
|
|
138
|
+
const start = array.offset + 1;
|
|
139
|
+
const replacement = closingWhitespace(text, entry.offset + entry.length, closingBracket);
|
|
140
|
+
return text.slice(0, start) + replacement + text.slice(closingBracket);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (index === entries.length - 1) {
|
|
144
|
+
const previous = entries[index - 1];
|
|
145
|
+
if (!previous) return text;
|
|
146
|
+
const comma = findComma(text, previous.offset + previous.length, entry.offset);
|
|
147
|
+
if (!comma) return text;
|
|
148
|
+
const replacement = closingWhitespace(text, entry.offset + entry.length, closingBracket);
|
|
149
|
+
return text.slice(0, comma.offset) + replacement + text.slice(closingBracket);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const next = entries[index + 1];
|
|
153
|
+
if (!next) return text;
|
|
154
|
+
const followingComma = findComma(text, entry.offset + entry.length, next.offset);
|
|
155
|
+
if (!followingComma) return text;
|
|
156
|
+
|
|
157
|
+
const start =
|
|
158
|
+
index === 0
|
|
159
|
+
? array.offset + 1
|
|
160
|
+
: (() => {
|
|
161
|
+
const previous = entries[index - 1];
|
|
162
|
+
if (!previous) return entry.offset;
|
|
163
|
+
const precedingComma = findComma(
|
|
164
|
+
text,
|
|
165
|
+
previous.offset + previous.length,
|
|
166
|
+
entry.offset,
|
|
167
|
+
);
|
|
168
|
+
return precedingComma
|
|
169
|
+
? startAfterPrecedingInlineComments(text, precedingComma, entry.offset)
|
|
170
|
+
: entry.offset;
|
|
171
|
+
})();
|
|
172
|
+
const end = endAfterFollowingInlineComments(text, followingComma, next.offset);
|
|
173
|
+
|
|
174
|
+
return text.slice(0, start) + text.slice(end);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function indentationAt(text: string, offset: number): string {
|
|
178
|
+
const prefix = text.slice(lineStart(text, offset), offset);
|
|
179
|
+
return /^[\t ]*$/.test(prefix) ? prefix : "";
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function inferIndent(text: string, array: Node): string {
|
|
183
|
+
const firstEntry = array.children?.[0];
|
|
184
|
+
if (firstEntry) return indentationAt(text, firstEntry.offset);
|
|
185
|
+
|
|
186
|
+
const closingBracket = array.offset + array.length - 1;
|
|
187
|
+
const closingIndent = indentationAt(text, closingBracket);
|
|
188
|
+
return `${closingIndent}${closingIndent.includes("\t") ? "\t" : " "}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function appendArrayValue(text: string, array: Node, value: unknown): string {
|
|
192
|
+
const entries = array.children ?? [];
|
|
193
|
+
const closingBracket = array.offset + array.length - 1;
|
|
194
|
+
const serialized = JSON.stringify(value);
|
|
195
|
+
|
|
196
|
+
if (entries.length === 0) {
|
|
197
|
+
if (!text.slice(array.offset, closingBracket).includes("\n")) {
|
|
198
|
+
return text.slice(0, closingBracket) + serialized + text.slice(closingBracket);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const indent = inferIndent(text, array);
|
|
202
|
+
const closingLineStart = lineStart(text, closingBracket);
|
|
203
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
204
|
+
return (
|
|
205
|
+
text.slice(0, closingLineStart) +
|
|
206
|
+
indent +
|
|
207
|
+
serialized +
|
|
208
|
+
eol +
|
|
209
|
+
text.slice(closingLineStart)
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const lastEntry = entries.at(-1);
|
|
214
|
+
if (!lastEntry) return text;
|
|
215
|
+
const trailingComma = findComma(text, lastEntry.offset + lastEntry.length, closingBracket);
|
|
216
|
+
const isMultiline = text.slice(array.offset, closingBracket).includes("\n");
|
|
217
|
+
|
|
218
|
+
if (!isMultiline) {
|
|
219
|
+
return (
|
|
220
|
+
text.slice(0, lastEntry.offset + lastEntry.length) +
|
|
221
|
+
`,${serialized}` +
|
|
222
|
+
text.slice(lastEntry.offset + lastEntry.length)
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const closingLineStart = lineStart(text, closingBracket);
|
|
227
|
+
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
228
|
+
const inserted = `${inferIndent(text, array)}${serialized}${trailingComma ? "," : ""}${eol}`;
|
|
229
|
+
const withValue = text.slice(0, closingLineStart) + inserted + text.slice(closingLineStart);
|
|
230
|
+
|
|
231
|
+
if (trailingComma) return withValue;
|
|
232
|
+
return (
|
|
233
|
+
withValue.slice(0, lastEntry.offset + lastEntry.length) +
|
|
234
|
+
"," +
|
|
235
|
+
withValue.slice(lastEntry.offset + lastEntry.length)
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Replace one JSONC value without reserializing the rest of the document. New
|
|
241
|
+
* paths use jsonc-parser's structural edit so the original document remains
|
|
242
|
+
* untouched outside the inserted property.
|
|
243
|
+
*/
|
|
244
|
+
export function setJsoncValue(text: string, path: JSONPath, value: unknown): string {
|
|
245
|
+
const node = findNode(text, path);
|
|
246
|
+
if (node) {
|
|
247
|
+
if (Object.is(getNodeValue(node), value)) return text;
|
|
248
|
+
const serialized = JSON.stringify(value);
|
|
249
|
+
return text.slice(0, node.offset) + serialized + text.slice(node.offset + node.length);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
return applyEdits(text, modify(text, path, value, {}));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Remove matching array entries while retaining the exact bytes for survivor
|
|
257
|
+
* comments and surrounding JSONC regions.
|
|
258
|
+
*/
|
|
259
|
+
export function removeJsoncArrayEntries(
|
|
260
|
+
text: string,
|
|
261
|
+
path: JSONPath,
|
|
262
|
+
shouldRemove: (entry: unknown) => boolean,
|
|
263
|
+
): { text: string; removed: boolean } {
|
|
264
|
+
let nextText = text;
|
|
265
|
+
let removed = false;
|
|
266
|
+
|
|
267
|
+
for (;;) {
|
|
268
|
+
const array = findNode(nextText, path);
|
|
269
|
+
if (array?.type !== "array") return { text: nextText, removed };
|
|
270
|
+
const index = (array.children ?? []).findIndex((entry) =>
|
|
271
|
+
shouldRemove(getNodeValue(entry)),
|
|
272
|
+
);
|
|
273
|
+
if (index === -1) return { text: nextText, removed };
|
|
274
|
+
|
|
275
|
+
const updated = removeArrayEntry(nextText, array, index);
|
|
276
|
+
if (updated === nextText) return { text: nextText, removed };
|
|
277
|
+
nextText = updated;
|
|
278
|
+
removed = true;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** Append values to an existing JSONC array without reserializing sibling fields. */
|
|
283
|
+
export function appendJsoncArrayValues(text: string, path: JSONPath, values: unknown[]): string {
|
|
284
|
+
let nextText = text;
|
|
285
|
+
|
|
286
|
+
for (const value of values) {
|
|
287
|
+
const array = findNode(nextText, path);
|
|
288
|
+
if (array?.type !== "array") {
|
|
289
|
+
return setJsoncValue(nextText, path, values);
|
|
290
|
+
}
|
|
291
|
+
nextText = appendArrayValue(nextText, array, value);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return nextText;
|
|
295
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import { parseJsonc } from "./jsonc-parser";
|
|
4
|
+
|
|
5
|
+
describe("parseJsonc prototype-pollution hardening", () => {
|
|
6
|
+
it("rejects dangerous keys recursively, including inside arrays", () => {
|
|
7
|
+
const rejected: string[] = [];
|
|
8
|
+
const parsed = parseJsonc<Record<string, unknown>>(
|
|
9
|
+
`{
|
|
10
|
+
"safe": 1,
|
|
11
|
+
"constructor": { "hidden": true },
|
|
12
|
+
"nested": { "prototype": { "hidden": true }, "safe": 2 },
|
|
13
|
+
"items": [
|
|
14
|
+
{ "__proto__": { "polluted": true }, "safe": 3 },
|
|
15
|
+
{ "safe": 4 }
|
|
16
|
+
]
|
|
17
|
+
}`,
|
|
18
|
+
{ onRejectedKey: (path) => rejected.push(path.join(".")) },
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(rejected).toEqual(["constructor", "nested.prototype", "items.0.__proto__"]);
|
|
22
|
+
expect(Object.hasOwn(parsed, "constructor")).toBe(false);
|
|
23
|
+
|
|
24
|
+
const nested = parsed.nested as Record<string, unknown>;
|
|
25
|
+
expect(nested).toEqual({ safe: 2 });
|
|
26
|
+
expect(Object.getPrototypeOf(nested)).toBe(Object.prototype);
|
|
27
|
+
|
|
28
|
+
const items = parsed.items as Array<Record<string, unknown>>;
|
|
29
|
+
expect(items).toEqual([{ safe: 3 }, { safe: 4 }]);
|
|
30
|
+
expect(Object.getPrototypeOf(items[0])).toBe(Object.prototype);
|
|
31
|
+
expect("polluted" in items[0]).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -106,9 +106,61 @@ function stripTrailingCommas(content: string): string {
|
|
|
106
106
|
return result;
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
|
|
109
|
+
const PROTOTYPE_POLLUTION_KEYS = new Set(["__proto__", "constructor", "prototype"]);
|
|
110
|
+
|
|
111
|
+
export function isPrototypePollutionKey(key: string): boolean {
|
|
112
|
+
return PROTOTYPE_POLLUTION_KEYS.has(key);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ParsedJsonSanitizerOptions {
|
|
116
|
+
onRejectedKey?: (path: readonly (string | number)[]) => void;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Copy parsed JSON into fresh own-property-only containers while rejecting keys
|
|
121
|
+
* that can alter an object's prototype during a later merge. Rebuilding objects
|
|
122
|
+
* also removes an already-polluted prototype produced by third-party parsers.
|
|
123
|
+
*/
|
|
124
|
+
export function sanitizeParsedJson<T>(
|
|
125
|
+
value: T,
|
|
126
|
+
options: ParsedJsonSanitizerOptions = {},
|
|
127
|
+
path: readonly (string | number)[] = [],
|
|
128
|
+
): T {
|
|
129
|
+
if (Array.isArray(value)) {
|
|
130
|
+
return value.map((entry, index) =>
|
|
131
|
+
sanitizeParsedJson(entry, options, [...path, index]),
|
|
132
|
+
) as T;
|
|
133
|
+
}
|
|
134
|
+
if (value === null || typeof value !== "object") return value;
|
|
135
|
+
|
|
136
|
+
const source = value as Record<string, unknown>;
|
|
137
|
+
const sourcePrototype = Object.getPrototypeOf(source);
|
|
138
|
+
if (sourcePrototype !== null && sourcePrototype !== Object.prototype) {
|
|
139
|
+
options.onRejectedKey?.([...path, "__proto__"]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const sanitized: Record<string, unknown> = {};
|
|
143
|
+
for (const key of Object.keys(source)) {
|
|
144
|
+
if (isPrototypePollutionKey(key)) {
|
|
145
|
+
options.onRejectedKey?.([...path, key]);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
Object.defineProperty(sanitized, key, {
|
|
149
|
+
value: sanitizeParsedJson(source[key], options, [...path, key]),
|
|
150
|
+
enumerable: true,
|
|
151
|
+
configurable: true,
|
|
152
|
+
writable: true,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
return sanitized as T;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function parseJsonc<T = unknown>(
|
|
159
|
+
content: string,
|
|
160
|
+
options: ParsedJsonSanitizerOptions = {},
|
|
161
|
+
): T {
|
|
110
162
|
const normalized = stripTrailingCommas(stripJsonComments(content));
|
|
111
|
-
return JSON.parse(normalized) as T;
|
|
163
|
+
return sanitizeParsedJson(JSON.parse(normalized) as T, options);
|
|
112
164
|
}
|
|
113
165
|
|
|
114
166
|
export function readJsoncFile<T = unknown>(filePath: string): T | null {
|
|
@@ -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,
|