@bitkyc08/opencodex 2.15.1 → 2.17.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/gui/dist/assets/{index-CMCDkQ7U.js → index-DOKr6RBR.js} +10 -10
- package/gui/dist/index.html +1 -1
- package/package.json +1 -1
- package/src/adapters/base.ts +2 -0
- package/src/adapters/google-antigravity-replay.ts +9 -1
- package/src/adapters/kiro-thinking.ts +8 -0
- package/src/adapters/kiro.ts +45 -42
- package/src/adapters/openai-chat.ts +5 -2
- package/src/adapters/openai-responses.ts +5 -1
- package/src/cli/dispatch.ts +6 -3
- package/src/cli/export-command.ts +19 -7
- package/src/cli/help.ts +1 -1
- package/src/cli/index.ts +1 -0
- package/src/cli/registry.ts +2 -2
- package/src/clients/config-export.ts +165 -3
- package/src/generated/compatibility-version.json +59 -31
- package/src/integrations/config-io.ts +119 -1
- package/src/integrations/omp-yaml-source.ts +232 -99
- package/src/integrations/registry.ts +14 -0
- package/src/integrations/serialize.ts +80 -1
- package/src/integrations/state.ts +38 -6
- package/src/integrations/writer-lock.ts +98 -0
- package/src/integrations/writer.ts +152 -19
- package/src/lab/automation/orchestrator.ts +19 -0
- package/src/lib/lab-activation.ts +161 -0
- package/src/lib/lab-passive-linker-registration.ts +26 -0
- package/src/lib/optional-shutdown-hooks.ts +57 -0
- package/src/lib/shadow-call.ts +6 -14
- package/src/lib/translator-budget.ts +34 -0
- package/src/providers/antigravity-models.ts +65 -10
- package/src/routing/compatibility/assemble.ts +21 -107
- package/src/routing/compatibility/lab-evidence-provider.ts +130 -0
- package/src/routing/compatibility/provider-slot.ts +56 -0
- package/src/server/index.ts +8 -17
- package/src/server/lifecycle.ts +5 -3
- package/src/server/management/integration-routes.ts +21 -14
- package/src/server/management/routing-profile-routes.ts +9 -1
- package/src/server/management-api.ts +37 -6
- package/src/server/passive-route-linker.ts +66 -0
- package/src/server/responses/core.ts +20 -21
- package/src/types.ts +15 -5
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Source-preserving mutation for
|
|
2
|
+
* Source-preserving mutation for one plain block-map YAML fragment.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* Unsupported or ambiguous YAML returns `null`. The caller treats that as an
|
|
11
|
-
* unsafe refusal instead of falling back to whole-document serialization.
|
|
4
|
+
* Shared client settings are not ours to re-render. This scanner accepts only
|
|
5
|
+
* the small, unambiguous source shape we can patch byte-for-byte around the
|
|
6
|
+
* owned leaf. Every candidate is parsed again and compared with the complete
|
|
7
|
+
* expected document; unsupported YAML fails closed and never falls back to a
|
|
8
|
+
* whole-document serializer.
|
|
12
9
|
*/
|
|
13
10
|
import { renderYaml } from "./serialize";
|
|
14
11
|
|
|
@@ -18,6 +15,26 @@ interface SourceLine {
|
|
|
18
15
|
body: string;
|
|
19
16
|
}
|
|
20
17
|
|
|
18
|
+
interface LocatedEntry {
|
|
19
|
+
lines: readonly SourceLine[];
|
|
20
|
+
index: number;
|
|
21
|
+
indent: number;
|
|
22
|
+
endIndex: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface MissingEntry {
|
|
26
|
+
lines: readonly SourceLine[];
|
|
27
|
+
missingDepth: number;
|
|
28
|
+
indent: number;
|
|
29
|
+
insertAt: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type LocatedPath = { kind: "existing"; entry: LocatedEntry } | { kind: "missing"; entry: MissingEntry };
|
|
33
|
+
|
|
34
|
+
export type YamlFragmentMutation =
|
|
35
|
+
| { kind: "upsert"; value: unknown }
|
|
36
|
+
| { kind: "remove"; createdContainers: readonly string[] };
|
|
37
|
+
|
|
21
38
|
export type OmpYamlMutation =
|
|
22
39
|
| { kind: "upsert"; value: unknown }
|
|
23
40
|
| { kind: "remove"; removeEmptyProviders: boolean };
|
|
@@ -54,11 +71,15 @@ function hasInlineComment(line: string): boolean {
|
|
|
54
71
|
return line.includes("#");
|
|
55
72
|
}
|
|
56
73
|
|
|
74
|
+
function regexpEscape(value: string): string {
|
|
75
|
+
return value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
76
|
+
}
|
|
77
|
+
|
|
57
78
|
function isPlainBlockKey(line: string, indent: number, key: string): boolean {
|
|
58
79
|
const spaces = leadingSpaces(line);
|
|
59
80
|
if (spaces !== indent) return false;
|
|
60
81
|
const rest = line.slice(indent);
|
|
61
|
-
return new RegExp(`^${key}:[ ]*(?:#.*)?$`, "u").test(rest);
|
|
82
|
+
return new RegExp(`^${regexpEscape(key)}:[ ]*(?:#.*)?$`, "u").test(rest);
|
|
62
83
|
}
|
|
63
84
|
|
|
64
85
|
function containerEnd(lines: readonly SourceLine[], start: number, indent: number): number | null {
|
|
@@ -86,9 +107,8 @@ function childEnd(
|
|
|
86
107
|
const body = lines[index]!.body;
|
|
87
108
|
const spaces = leadingSpaces(body);
|
|
88
109
|
if (spaces === null) return null;
|
|
89
|
-
// Blank lines and same-level comments
|
|
90
|
-
//
|
|
91
|
-
// refuse rather than guessing whether the user meant to keep them.
|
|
110
|
+
// Blank lines and same-level comments remain outside our replacement.
|
|
111
|
+
// Deeper comments belong to the leaf and would be destroyed, so refuse.
|
|
92
112
|
if (isBlank(body)) return index;
|
|
93
113
|
if (isComment(body)) return spaces <= indent ? index : null;
|
|
94
114
|
if (spaces <= indent) return index;
|
|
@@ -112,8 +132,7 @@ function canonicalValue(value: unknown): unknown {
|
|
|
112
132
|
function semanticallyMatches(text: string, expected: unknown): boolean {
|
|
113
133
|
try {
|
|
114
134
|
const parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
|
|
115
|
-
return JSON.stringify(canonicalValue(parsed))
|
|
116
|
-
=== JSON.stringify(canonicalValue(expected));
|
|
135
|
+
return JSON.stringify(canonicalValue(parsed)) === JSON.stringify(canonicalValue(expected));
|
|
117
136
|
} catch {
|
|
118
137
|
return false;
|
|
119
138
|
}
|
|
@@ -123,103 +142,217 @@ function lineEnding(text: string): "\n" | "\r\n" {
|
|
|
123
142
|
return text.includes("\r\n") ? "\r\n" : "\n";
|
|
124
143
|
}
|
|
125
144
|
|
|
126
|
-
function
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
* The returned text is parsed and compared with `expected` before it is
|
|
133
|
-
* accepted, so a scanner mistake fails closed rather than writing bad YAML.
|
|
134
|
-
*/
|
|
135
|
-
export function patchOmpYamlSource(
|
|
136
|
-
text: string,
|
|
137
|
-
mutation: OmpYamlMutation,
|
|
138
|
-
expected: unknown,
|
|
139
|
-
): string | null {
|
|
140
|
-
const lines = sourceLines(text);
|
|
141
|
-
const eol = lineEnding(text);
|
|
142
|
-
const providerIndexes = lines
|
|
143
|
-
.map((line, index) => isPlainBlockKey(line.body, 0, "providers") ? index : -1)
|
|
144
|
-
.filter(index => index >= 0);
|
|
145
|
-
|
|
146
|
-
if (providerIndexes.length === 0) {
|
|
147
|
-
if (mutation.kind === "remove") return null;
|
|
148
|
-
const separator = text.length === 0 || text.endsWith("\n") ? "" : eol;
|
|
149
|
-
const patched = `${text}${separator}providers:${eol}${renderedEntry(mutation.value, 2, eol)}`;
|
|
150
|
-
return semanticallyMatches(patched, expected) ? patched : null;
|
|
145
|
+
function readPath(doc: unknown, path: readonly string[]): unknown {
|
|
146
|
+
let cursor = doc;
|
|
147
|
+
for (const key of path) {
|
|
148
|
+
if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) return undefined;
|
|
149
|
+
cursor = (cursor as Record<string, unknown>)[key];
|
|
150
|
+
if (cursor === undefined) return undefined;
|
|
151
151
|
}
|
|
152
|
-
|
|
152
|
+
return cursor;
|
|
153
|
+
}
|
|
153
154
|
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
155
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
156
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
157
|
+
}
|
|
157
158
|
|
|
158
|
-
|
|
159
|
-
|
|
159
|
+
function immediateIndent(
|
|
160
|
+
lines: readonly SourceLine[],
|
|
161
|
+
start: number,
|
|
162
|
+
end: number,
|
|
163
|
+
parentIndent: number,
|
|
164
|
+
): number | null {
|
|
165
|
+
let indent: number | null = null;
|
|
166
|
+
for (let index = start; index < end; index += 1) {
|
|
160
167
|
const body = lines[index]!.body;
|
|
161
168
|
const spaces = leadingSpaces(body);
|
|
162
169
|
if (spaces === null) return null;
|
|
163
|
-
if (
|
|
164
|
-
|
|
170
|
+
if (isBlank(body) || isComment(body) || spaces <= parentIndent) continue;
|
|
171
|
+
indent = indent === null ? spaces : Math.min(indent, spaces);
|
|
172
|
+
}
|
|
173
|
+
return indent ?? parentIndent + 2;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Locate a path only when every present segment is a plain block-map key. */
|
|
177
|
+
function locatePath(text: string, parsed: unknown, path: readonly string[]): LocatedPath | null {
|
|
178
|
+
if (path.length === 0 || !isPlainRecord(parsed)) return null;
|
|
179
|
+
const lines = sourceLines(text);
|
|
180
|
+
if (lines.some(line => leadingSpaces(line.body) === null)) return null;
|
|
181
|
+
|
|
182
|
+
let rangeStart = 0;
|
|
183
|
+
let rangeEnd = lines.length;
|
|
184
|
+
let parentIndent = -2;
|
|
185
|
+
const prefix: string[] = [];
|
|
186
|
+
for (let depth = 0; depth < path.length; depth += 1) {
|
|
187
|
+
const indent = depth === 0 ? 0 : immediateIndent(lines, rangeStart, rangeEnd, parentIndent);
|
|
188
|
+
if (indent === null) return null;
|
|
189
|
+
const matches: number[] = [];
|
|
190
|
+
for (let index = rangeStart; index < rangeEnd; index += 1) {
|
|
191
|
+
if (isPlainBlockKey(lines[index]!.body, indent, path[depth]!)) matches.push(index);
|
|
192
|
+
}
|
|
193
|
+
if (matches.length > 1) return null;
|
|
194
|
+
prefix.push(path[depth]!);
|
|
195
|
+
if (matches.length === 0) {
|
|
196
|
+
// The parser saw this key through syntax we do not patch (quoted/flow,
|
|
197
|
+
// merge aliases, or an ambiguous indentation shape).
|
|
198
|
+
if (readPath(parsed, prefix) !== undefined) return null;
|
|
199
|
+
const insertAt = rangeEnd < lines.length ? lines[rangeEnd]!.start : text.length;
|
|
200
|
+
return { kind: "missing", entry: { lines, missingDepth: depth, indent, insertAt } };
|
|
165
201
|
}
|
|
202
|
+
|
|
203
|
+
const index = matches[0]!;
|
|
204
|
+
const end = containerEnd(lines, index, indent);
|
|
205
|
+
if (end === null) return null;
|
|
206
|
+
if (depth === path.length - 1) {
|
|
207
|
+
if (hasInlineComment(lines[index]!.body)) return null;
|
|
208
|
+
const leafEnd = childEnd(lines, index, end, indent);
|
|
209
|
+
if (leafEnd === null) return null;
|
|
210
|
+
return { kind: "existing", entry: { lines, index, indent, endIndex: leafEnd } };
|
|
211
|
+
}
|
|
212
|
+
if (!isPlainRecord(readPath(parsed, prefix))) return null;
|
|
213
|
+
rangeStart = index + 1;
|
|
214
|
+
rangeEnd = end;
|
|
215
|
+
parentIndent = indent;
|
|
166
216
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function nestedValue(path: readonly string[], value: unknown): Record<string, unknown> {
|
|
221
|
+
let nested: unknown = value;
|
|
222
|
+
for (let index = path.length - 1; index >= 0; index -= 1) nested = { [path[index]!]: nested };
|
|
223
|
+
return nested as Record<string, unknown>;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function rendered(value: unknown, indent: number, eol: "\n" | "\r\n"): string {
|
|
227
|
+
return renderYaml(value as Record<string, unknown>, indent).replaceAll("\n", eol);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function preserveFinalNewline(candidate: string, original: string, eol: "\n" | "\r\n"): string {
|
|
231
|
+
if (original.length > 0 && !original.endsWith("\n") && candidate.endsWith(eol)) {
|
|
232
|
+
return candidate.slice(0, -eol.length);
|
|
233
|
+
}
|
|
234
|
+
return candidate;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function upsertSource(
|
|
238
|
+
text: string,
|
|
239
|
+
parsed: unknown,
|
|
240
|
+
path: readonly string[],
|
|
241
|
+
value: unknown,
|
|
242
|
+
): string | null {
|
|
243
|
+
const located = locatePath(text, parsed, path);
|
|
244
|
+
if (located === null) return null;
|
|
245
|
+
const eol = lineEnding(text);
|
|
246
|
+
if (located.kind === "existing") {
|
|
247
|
+
const { lines, index, indent, endIndex } = located.entry;
|
|
248
|
+
const startOffset = lines[index]!.start;
|
|
249
|
+
const endOffset = endIndex < lines.length ? lines[endIndex]!.start : text.length;
|
|
250
|
+
const candidate = `${text.slice(0, startOffset)}${rendered({ [path[path.length - 1]!]: value }, indent, eol)}${text.slice(endOffset)}`;
|
|
251
|
+
return preserveFinalNewline(candidate, text, eol);
|
|
186
252
|
}
|
|
187
253
|
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
const
|
|
192
|
-
|
|
193
|
-
|
|
254
|
+
const { missingDepth, indent, insertAt } = located.entry;
|
|
255
|
+
const prefix = insertAt > 0 && !text.slice(0, insertAt).endsWith("\n") ? eol : "";
|
|
256
|
+
const insertion = rendered(nestedValue(path.slice(missingDepth), value), indent, eol);
|
|
257
|
+
const candidate = `${text.slice(0, insertAt)}${prefix}${insertion}${text.slice(insertAt)}`;
|
|
258
|
+
return preserveFinalNewline(candidate, text, eol);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function removeExactPath(text: string, path: readonly string[], requireEmpty: boolean): string | null {
|
|
262
|
+
let parsed: unknown;
|
|
263
|
+
try {
|
|
264
|
+
parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
|
|
265
|
+
} catch {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
const located = locatePath(text, parsed, path);
|
|
269
|
+
if (located === null || located.kind !== "existing") return null;
|
|
270
|
+
const { lines, index, endIndex } = located.entry;
|
|
271
|
+
const startOffset = lines[index]!.start;
|
|
194
272
|
const endOffset = endIndex < lines.length ? lines[endIndex]!.start : text.length;
|
|
273
|
+
if (requireEmpty) {
|
|
274
|
+
const sourceBody = text.slice(lines[index]!.end, endOffset);
|
|
275
|
+
if (sourceBody.trim().length > 0) return null;
|
|
276
|
+
}
|
|
277
|
+
return `${text.slice(0, startOffset)}${text.slice(endOffset)}`;
|
|
278
|
+
}
|
|
195
279
|
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
280
|
+
function planSourceRemoval(
|
|
281
|
+
text: string,
|
|
282
|
+
path: readonly string[],
|
|
283
|
+
createdContainers: readonly string[],
|
|
284
|
+
): { text: string; prunedContainers: string[] } | null {
|
|
285
|
+
let next = removeExactPath(text, path, false);
|
|
286
|
+
if (next === null) return null;
|
|
287
|
+
const created = new Set(createdContainers);
|
|
288
|
+
const prunedContainers: string[] = [];
|
|
289
|
+
for (let depth = path.length - 1; depth >= 1; depth -= 1) {
|
|
290
|
+
const containerPath = path.slice(0, depth);
|
|
291
|
+
const encoded = containerPath.join("\u0000");
|
|
292
|
+
if (!created.has(encoded)) continue;
|
|
293
|
+
const pruned = removeExactPath(next, containerPath, true);
|
|
294
|
+
// A user-added sibling/comment makes this ancestor source-owned now. Keep
|
|
295
|
+
// it (and every parent) while still removing our leaf.
|
|
296
|
+
if (pruned === null) break;
|
|
297
|
+
next = pruned;
|
|
298
|
+
prunedContainers.push(encoded);
|
|
199
299
|
}
|
|
300
|
+
return { text: next, prunedContainers };
|
|
301
|
+
}
|
|
200
302
|
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
303
|
+
/** Containers whose source ranges are still empty and safe to prune. */
|
|
304
|
+
export function sourcePrunableYamlContainers(
|
|
305
|
+
text: string,
|
|
306
|
+
path: readonly string[],
|
|
307
|
+
createdContainers: readonly string[],
|
|
308
|
+
): readonly string[] | null {
|
|
309
|
+
return planSourceRemoval(text, path, createdContainers)?.prunedContainers ?? null;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function removeSource(
|
|
313
|
+
text: string,
|
|
314
|
+
path: readonly string[],
|
|
315
|
+
createdContainers: readonly string[],
|
|
316
|
+
): string | null {
|
|
317
|
+
return planSourceRemoval(text, path, createdContainers)?.text ?? null;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Patch one arbitrary plain block-map path, preserving every other byte.
|
|
322
|
+
*/
|
|
323
|
+
export function patchYamlFragmentSource(
|
|
324
|
+
text: string,
|
|
325
|
+
path: readonly string[],
|
|
326
|
+
mutation: YamlFragmentMutation,
|
|
327
|
+
expected: unknown,
|
|
328
|
+
): string | null {
|
|
329
|
+
let parsed: unknown;
|
|
330
|
+
try {
|
|
331
|
+
parsed = text.trim().length === 0 ? {} : Bun.YAML.parse(text);
|
|
332
|
+
} catch {
|
|
333
|
+
return null;
|
|
223
334
|
}
|
|
224
|
-
|
|
335
|
+
const patched = mutation.kind === "upsert"
|
|
336
|
+
? upsertSource(text, parsed, path, mutation.value)
|
|
337
|
+
: removeSource(text, path, mutation.createdContainers);
|
|
338
|
+
return patched !== null && semanticallyMatches(patched, expected) ? patched : null;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Backward-compatible OMP wrapper around the generic path patcher. */
|
|
342
|
+
export function patchOmpYamlSource(
|
|
343
|
+
text: string,
|
|
344
|
+
mutation: OmpYamlMutation,
|
|
345
|
+
expected: unknown,
|
|
346
|
+
): string | null {
|
|
347
|
+
return patchYamlFragmentSource(
|
|
348
|
+
text,
|
|
349
|
+
["providers", "opencodex"],
|
|
350
|
+
mutation.kind === "upsert"
|
|
351
|
+
? mutation
|
|
352
|
+
: {
|
|
353
|
+
kind: "remove",
|
|
354
|
+
createdContainers: mutation.removeEmptyProviders ? ["providers"] : [],
|
|
355
|
+
},
|
|
356
|
+
expected,
|
|
357
|
+
);
|
|
225
358
|
}
|
|
@@ -12,6 +12,8 @@ import { homedir } from "node:os";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import {
|
|
14
14
|
EXPORT_CLIENTS,
|
|
15
|
+
dshConfigPath,
|
|
16
|
+
dshHomeDir,
|
|
15
17
|
gajaeConfigPath,
|
|
16
18
|
gajaeHomeDir,
|
|
17
19
|
hermesConfigPath,
|
|
@@ -38,6 +40,10 @@ export interface IntegrationClientSpec {
|
|
|
38
40
|
configPath: (env?: NodeJS.ProcessEnv, home?: string) => string;
|
|
39
41
|
/** Directory whose existence is the cheap "is it installed?" signal. */
|
|
40
42
|
detectDir: (env?: NodeJS.ProcessEnv, home?: string) => string;
|
|
43
|
+
/** Patch only this block-map YAML leaf; never re-render the shared file. */
|
|
44
|
+
sourcePreservingYaml?: { path: readonly string[] };
|
|
45
|
+
/** Coordinate the complete mutation through a sibling config lock. */
|
|
46
|
+
writerLock?: { suffix: ".lock" };
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
/**
|
|
@@ -74,6 +80,7 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
|
|
|
74
80
|
id: "omp",
|
|
75
81
|
configPath: (env = process.env, home = homedir()) => ompModelsConfigPath(env, home),
|
|
76
82
|
detectDir: (env = process.env, home = homedir()) => ompAgentDir(env, home),
|
|
83
|
+
sourcePreservingYaml: { path: ["providers", "opencodex"] },
|
|
77
84
|
},
|
|
78
85
|
hermes: {
|
|
79
86
|
id: "hermes",
|
|
@@ -98,6 +105,13 @@ export const INTEGRATION_CLIENTS: Record<IntegrationClientId, IntegrationClientS
|
|
|
98
105
|
configPath: (env = process.env, home = homedir()) => gajaeConfigPath(env, home),
|
|
99
106
|
detectDir: (env = process.env, home = homedir()) => gajaeHomeDir(env, home),
|
|
100
107
|
},
|
|
108
|
+
dsh: {
|
|
109
|
+
id: "dsh",
|
|
110
|
+
configPath: (env = process.env, home = homedir()) => dshConfigPath(env, home),
|
|
111
|
+
detectDir: (env = process.env, home = homedir()) => dshHomeDir(env, home),
|
|
112
|
+
sourcePreservingYaml: { path: ["llm-pi-ai", "providers", "opencodex"] },
|
|
113
|
+
writerLock: { suffix: ".lock" },
|
|
114
|
+
},
|
|
101
115
|
};
|
|
102
116
|
|
|
103
117
|
export const INTEGRATION_CLIENT_IDS: readonly IntegrationClientId[] =
|
|
@@ -221,10 +221,89 @@ export function renderToml(document: Record<string, unknown>, prefix = ""): stri
|
|
|
221
221
|
return `${[scalars.join("\n"), tables.join("\n\n")].filter(Boolean).join("\n\n")}\n`;
|
|
222
222
|
}
|
|
223
223
|
|
|
224
|
+
/**
|
|
225
|
+
* Ceiling on container nesting for json documents, shared by the parse-time
|
|
226
|
+
* scanner (config-io.ts) and the serializer walk below. One constant on
|
|
227
|
+
* purpose: the walk must accept every document the scanner admits, or a file
|
|
228
|
+
* the classifier reported as recoverable would refuse at rewrite time. Real
|
|
229
|
+
* configs nest a handful of levels.
|
|
230
|
+
*/
|
|
231
|
+
export const MAX_JSON_NESTING = 1000;
|
|
232
|
+
|
|
233
|
+
/** Error messages carry the path to the offending value; keep them readable. */
|
|
234
|
+
function clampPath(path: string): string {
|
|
235
|
+
return path.length > 200 ? `${path.slice(0, 100)}…${path.slice(-100)}` : path;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* JSON.stringify writes a non-finite number as `null` and -0 as `0`; any
|
|
240
|
+
* other finite double round-trips value-exactly (literal-level rounding is
|
|
241
|
+
* the parse-time scanner's concern), so those two are exactly what this walk
|
|
242
|
+
* refuses — refusing more turned a state the classifier had promised as
|
|
243
|
+
* recoverable into a permanent refusal. Documents read from disk are already
|
|
244
|
+
* guarded at parse time, and the writer's merge layer JSON-clones documents —
|
|
245
|
+
* normalizing these values — before serializing, so on the apply/disable path
|
|
246
|
+
* this walk is unreachable for them: it guards the direct serializers
|
|
247
|
+
* (preview/export builders), same posture as the YAML and TOML renderers
|
|
248
|
+
* above, and enforces the nesting ceiling for every json caller before the
|
|
249
|
+
* recursive JSON.stringify can turn depth into a RangeError.
|
|
250
|
+
*
|
|
251
|
+
* Iterative frames instead of recursion or a node stack: depth AND size of
|
|
252
|
+
* the document are inputs under the writer of the config file. Recursion made
|
|
253
|
+
* a deep file a RangeError-500; materializing every node with its path made a
|
|
254
|
+
* wide file allocate a large multiple of its size. Frames keep memory
|
|
255
|
+
* proportional to nesting depth, and path strings exist only for the
|
|
256
|
+
* containers on the current path plus the failing value itself.
|
|
257
|
+
*/
|
|
258
|
+
function assertJsonNumbersRoundTrip(document: unknown, rootPath: string): void {
|
|
259
|
+
const refuse = (value: number, path: string): never => {
|
|
260
|
+
throw new UnserializableValueError(Object.is(value, -0)
|
|
261
|
+
? `JSON cannot rewrite -0 at ${clampPath(path)} without changing it to 0`
|
|
262
|
+
: `JSON cannot rewrite the number at ${clampPath(path)} without changing it to null`);
|
|
263
|
+
};
|
|
264
|
+
if (typeof document === "number" && (!Number.isFinite(document) || Object.is(document, -0))) {
|
|
265
|
+
refuse(document, rootPath);
|
|
266
|
+
}
|
|
267
|
+
type Frame = { container: unknown; keys: string[] | null; index: number; prefix: string };
|
|
268
|
+
const frames: Frame[] = [];
|
|
269
|
+
const pushContainer = (value: unknown, prefix: string) => {
|
|
270
|
+
if (Array.isArray(value)) frames.push({ container: value, keys: null, index: 0, prefix });
|
|
271
|
+
else if (isPlainRecord(value)) frames.push({ container: value, keys: Object.keys(value), index: 0, prefix });
|
|
272
|
+
};
|
|
273
|
+
pushContainer(document, rootPath);
|
|
274
|
+
while (frames.length > 0) {
|
|
275
|
+
const frame = frames[frames.length - 1]!;
|
|
276
|
+
const length = frame.keys ? frame.keys.length : (frame.container as unknown[]).length;
|
|
277
|
+
if (frame.index >= length) { frames.pop(); continue; }
|
|
278
|
+
const i = frame.index;
|
|
279
|
+
frame.index += 1;
|
|
280
|
+
const child = frame.keys
|
|
281
|
+
? (frame.container as Record<string, unknown>)[frame.keys[i]!]
|
|
282
|
+
: (frame.container as unknown[])[i];
|
|
283
|
+
const childPath = () => frame.keys
|
|
284
|
+
? (frame.prefix === "$" ? frame.keys[i]! : `${frame.prefix}.${frame.keys[i]!}`)
|
|
285
|
+
: `${frame.prefix}[${i}]`;
|
|
286
|
+
if (typeof child === "number") {
|
|
287
|
+
if (!Number.isFinite(child) || Object.is(child, -0)) refuse(child, childPath());
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
if (typeof child === "object" && child !== null) {
|
|
291
|
+
if (frames.length >= MAX_JSON_NESTING) {
|
|
292
|
+
throw new UnserializableValueError(
|
|
293
|
+
`the document nests deeper than ${MAX_JSON_NESTING} levels at ${clampPath(childPath())}, which JSON serialization cannot rewrite safely`);
|
|
294
|
+
}
|
|
295
|
+
pushContainer(child, childPath());
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
224
300
|
/** Every serializer returns text ending in exactly one newline. */
|
|
225
301
|
export function serializeDocument(document: unknown, format: ConfigFormat): string {
|
|
226
302
|
switch (format) {
|
|
227
|
-
case "json":
|
|
303
|
+
case "json": {
|
|
304
|
+
assertJsonNumbersRoundTrip(document, "$");
|
|
305
|
+
return `${JSON.stringify(document, null, 2)}\n`;
|
|
306
|
+
}
|
|
228
307
|
case "json5": return `${Bun.JSON5.stringify(document, null, 2)}\n`;
|
|
229
308
|
case "yaml": return renderYaml(document);
|
|
230
309
|
case "toml": {
|
|
@@ -134,9 +134,15 @@ function recordedFragmentFingerprint(
|
|
|
134
134
|
/**
|
|
135
135
|
* The two-axis rule: the recorded bytes or fragments prove nobody changed
|
|
136
136
|
* what we may rewrite, and the contribution hash proves our catalog has not
|
|
137
|
-
* moved on.
|
|
138
|
-
*
|
|
139
|
-
*
|
|
137
|
+
* moved on. Three classes of client (revising the unconditional whole-file
|
|
138
|
+
* rule of devlog 260802_client_toggle_api/021 §3 for json — #1631):
|
|
139
|
+
* registry-declared source-preserving YAML clients are fragment-scoped because
|
|
140
|
+
* their writers patch only the owned leaf, so the whole-file check is skipped;
|
|
141
|
+
* strict-json clients keep the whole-file check but downgrade a drift with
|
|
142
|
+
* intact owned fragments to `stale`, because a rewrite there can lose only
|
|
143
|
+
* formatting (comments cannot parse, non-round-tripping numbers are refused
|
|
144
|
+
* by the serializer); every comment-capable whole-document serializer (yaml,
|
|
145
|
+
* json5, toml) retains the whole-file fingerprint guard as a hard conflict.
|
|
140
146
|
*/
|
|
141
147
|
export function classifyIntegration(input: {
|
|
142
148
|
fileText: string | null;
|
|
@@ -180,12 +186,38 @@ export function classifyIntegration(input: {
|
|
|
180
186
|
return { state: "conflict", reason: "unowned-key" };
|
|
181
187
|
}
|
|
182
188
|
const clientId = input.clientId ?? input.record.clientId;
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
189
|
+
/*
|
|
190
|
+
* Checked BEFORE file-level drift: an edit INSIDE an owned fragment is a
|
|
191
|
+
* conflict no matter what the rest of the file looks like, so the sibling-
|
|
192
|
+
* edit exemption below can never mask it.
|
|
193
|
+
*/
|
|
186
194
|
if (recordedFragmentFingerprint(input.parsed, input.record) !== input.record.blockFingerprint) {
|
|
187
195
|
return { state: "conflict", reason: "foreign-edit" };
|
|
188
196
|
}
|
|
197
|
+
if (!INTEGRATION_CLIENTS[clientId].sourcePreservingYaml
|
|
198
|
+
&& fingerprint(input.fileText ?? "") !== input.record.fileFingerprint) {
|
|
199
|
+
/*
|
|
200
|
+
* The file changed since we wrote it, but every fragment we own is still
|
|
201
|
+
* byte-for-byte what we put there — a sibling edit, not tampering. Apply
|
|
202
|
+
* rewrites the WHOLE document, so for comment-capable formats (yaml,
|
|
203
|
+
* json5, toml) it would drop comments the user wrote next to us: fail
|
|
204
|
+
* closed there. Strict JSON cannot carry comments — a commented file
|
|
205
|
+
* never reaches this branch because parsing already failed — so the only
|
|
206
|
+
* possible loss is formatting normalization: everything a rewrite would
|
|
207
|
+
* actually change (numbers that would not round-trip, duplicate members
|
|
208
|
+
* a rewrite would delete) is PARSE_FAILED in parseConfig and classifies
|
|
209
|
+
* as unsafe long before this branch, exactly like comments. Refusing
|
|
210
|
+
* forever over formatting
|
|
211
|
+
* dead-ends the integration on the user's first own config edit (#1631).
|
|
212
|
+
* Report drift instead; a re-apply merges into the parsed document as it
|
|
213
|
+
* stands and re-owns the file. This also lets disable proceed on a
|
|
214
|
+
* drifted file — removal still touches only the recorded fragment paths.
|
|
215
|
+
*/
|
|
216
|
+
if (EXPORT_CLIENTS[clientId].format !== "json") {
|
|
217
|
+
return { state: "conflict", reason: "foreign-edit" };
|
|
218
|
+
}
|
|
219
|
+
return { state: "stale" };
|
|
220
|
+
}
|
|
189
221
|
return input.record.blockFingerprint === fingerprint(canonicalContribution(input.contribution))
|
|
190
222
|
? { state: "current" }
|
|
191
223
|
: { state: "stale" };
|