codex-copilot-dx 0.9.23 → 0.9.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -20
- package/bin/cli.mjs +51 -19
- package/docs/ARCHITECTURE.md +50 -0
- package/docs/README.md +4 -0
- package/docs/REFERENCE.md +124 -71
- package/package.json +3 -2
- package/src/adapter.mjs +5 -2
- package/src/cli-image.mjs +13 -10
- package/src/cli-options.mjs +13 -4
- package/src/cli-status.mjs +5 -0
- package/src/client-integration.mjs +41 -0
- package/src/codex-config-doctor.mjs +3 -3
- package/src/codex-paths.mjs +10 -0
- package/src/codex-thread-compression.mjs +11 -6
- package/src/config.mjs +10 -6
- package/src/copilot-responses-policy.mjs +4 -2
- package/src/dashboard/index.html +7 -6
- package/src/dashboard/language.js +13 -21
- package/src/dashboard/ui.js +10 -20
- package/src/dashboard-api.mjs +0 -12
- package/src/launcher.mjs +7 -2
- package/src/lock.mjs +29 -0
- package/src/native-prediction-overlay.mjs +92 -0
- package/src/native-prediction-profile.mjs +178 -0
- package/src/native-prediction-state.mjs +29 -0
- package/src/native-prediction-transport.mjs +255 -0
- package/src/native-prediction.mjs +170 -0
- package/src/network-route.mjs +102 -0
- package/src/observability.mjs +2 -0
- package/src/operation-error.mjs +37 -0
- package/src/package-update.mjs +2 -3
- package/src/prediction-retirement.mjs +86 -0
- package/src/proxy-tunnel.mjs +102 -0
- package/src/responses-request.mjs +5 -2
- package/src/suggestion-auto.mjs +3 -103
- package/src/suggestion-mcp.mjs +64 -132
- package/src/user-settings.mjs +17 -4
- package/src/version.mjs +23 -46
- package/src/cli-suggestion.mjs +0 -120
- package/src/suggestion-config.mjs +0 -98
- package/src/suggestion-context.mjs +0 -108
- package/src/suggestion-provider.mjs +0 -41
- package/src/suggestion-ui.html +0 -26
package/src/cli-image.mjs
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
3
|
import readline from "node:readline";
|
|
5
4
|
import { createInterface } from "node:readline/promises";
|
|
5
|
+
import { codexPaths } from "./codex-paths.mjs";
|
|
6
|
+
import { withFileLockSync } from "./lock.mjs";
|
|
6
7
|
import { atomicWriteFileIfChangedSync, atomicWriteFilePairSync } from "./atomic-file.mjs";
|
|
7
8
|
import { terminalCell } from "./cli-table.mjs";
|
|
8
9
|
import { computeImageMcpCodexConfig } from "./config.mjs";
|
|
@@ -15,11 +16,6 @@ import {
|
|
|
15
16
|
readImageProviderConfig,
|
|
16
17
|
validateImageProviderConfig,
|
|
17
18
|
} from "./image-provider-config.mjs";
|
|
18
|
-
|
|
19
|
-
function defaultCodexConfigPath(home) {
|
|
20
|
-
return path.join(home, ".codex", "config.toml");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
19
|
async function visibleQuestion(input, output, question) {
|
|
24
20
|
const prompt = createInterface({ input, output });
|
|
25
21
|
try { return await prompt.question(question); }
|
|
@@ -102,7 +98,10 @@ function configSummary(config) {
|
|
|
102
98
|
};
|
|
103
99
|
}
|
|
104
100
|
|
|
105
|
-
function enableImageProvider(
|
|
101
|
+
function enableImageProvider(options) {
|
|
102
|
+
return withFileLockSync(`${options.codexPath}.ccdx.lock`, () => enableImageProviderUnlocked(options));
|
|
103
|
+
}
|
|
104
|
+
function enableImageProviderUnlocked({ providerConfig, providerPath, codexPath, codexContent, adapterPort, adapterHost }) {
|
|
106
105
|
const codex = computeImageMcpCodexConfig(codexContent, {
|
|
107
106
|
enabled: true,
|
|
108
107
|
adapterPort,
|
|
@@ -128,7 +127,11 @@ function enableImageProvider({ providerConfig, providerPath, codexPath, codexCon
|
|
|
128
127
|
return { codexChanged: codex.changed, config: normalized };
|
|
129
128
|
}
|
|
130
129
|
|
|
131
|
-
function disableImageProvider(
|
|
130
|
+
function disableImageProvider(options) {
|
|
131
|
+
if (!fs.existsSync(options.codexPath)) return disableImageProviderUnlocked(options);
|
|
132
|
+
return withFileLockSync(`${options.codexPath}.ccdx.lock`, () => disableImageProviderUnlocked(options));
|
|
133
|
+
}
|
|
134
|
+
function disableImageProviderUnlocked({ providerPath, codexPath, codexContent, adapterPort, adapterHost }) {
|
|
132
135
|
const codex = computeImageMcpCodexConfig(codexContent, {
|
|
133
136
|
enabled: false,
|
|
134
137
|
adapterPort,
|
|
@@ -154,7 +157,7 @@ function disableImageProvider({ providerPath, codexPath, codexContent, adapterPo
|
|
|
154
157
|
return { changed: providerExists || codex.changed || skill.changed, preservedGuidance: Boolean(skill.preserved) };
|
|
155
158
|
}
|
|
156
159
|
|
|
157
|
-
export function syncEnabledImageSkill({ home = os.homedir(), codexPath =
|
|
160
|
+
export function syncEnabledImageSkill({ home = os.homedir(), env = process.env, codexPath = codexPaths({ env, home }).config, ...options } = {}) {
|
|
158
161
|
return updateImageSkill({ ...options, enabled: true, codexPath });
|
|
159
162
|
}
|
|
160
163
|
|
|
@@ -179,7 +182,7 @@ export async function runImageCommand({
|
|
|
179
182
|
probeFetchImpl = fetch,
|
|
180
183
|
adapterPort = 2026,
|
|
181
184
|
adapterHost = "127.0.0.1",
|
|
182
|
-
codexPath =
|
|
185
|
+
codexPath = codexPaths({ env, home }).config,
|
|
183
186
|
} = {}) {
|
|
184
187
|
const providerPath = imageProviderConfigPath({ env, home });
|
|
185
188
|
if (action === "status") {
|
package/src/cli-options.mjs
CHANGED
|
@@ -28,6 +28,7 @@ const HELP_TOPICS = new Set([
|
|
|
28
28
|
"image-status",
|
|
29
29
|
"auto-review-model",
|
|
30
30
|
"suggestion",
|
|
31
|
+
"prediction",
|
|
31
32
|
"update",
|
|
32
33
|
"version",
|
|
33
34
|
]);
|
|
@@ -252,11 +253,18 @@ export function parseCliArgs(args = []) {
|
|
|
252
253
|
if (rest.length) unexpectedArgs(rest);
|
|
253
254
|
return baseCommand("animation");
|
|
254
255
|
}
|
|
256
|
+
if (command === "prediction") {
|
|
257
|
+
const [action = "status", ...extra] = rest;
|
|
258
|
+
if (!["on", "off", "status"].includes(action)) throw new Error("Prediction action must be on, off, or status");
|
|
259
|
+
if (extra.length) unexpectedArgs(extra);
|
|
260
|
+
return baseCommand("prediction", { action });
|
|
261
|
+
}
|
|
255
262
|
if (command === "suggestion") {
|
|
256
263
|
const [action = "status", model, ...extra] = rest;
|
|
257
264
|
if (!["on", "off", "model", "status"].includes(action)) throw new Error("Suggestion action must be on, off, model, or status");
|
|
258
265
|
if (extra.length || (model !== undefined && action !== "model")) unexpectedArgs(rest.slice(1));
|
|
259
|
-
|
|
266
|
+
if (["on", "model"].includes(action)) throw new Error("The CCDX prediction panel has been retired; use ccdx prediction for the separate optional native feature");
|
|
267
|
+
return baseCommand("suggestion", { action });
|
|
260
268
|
}
|
|
261
269
|
if (command === "enable-image" || command === "disable-image" || command === "image-status") {
|
|
262
270
|
if (rest.length) unexpectedArgs(rest);
|
|
@@ -363,7 +371,8 @@ function topicHelp(name, topic) {
|
|
|
363
371
|
"disable-image": `Usage:\n ${name} disable-image\n\nDisables the local CCDX image tool and removes its stored API credential and unmodified CCDX image guidance.`,
|
|
364
372
|
"image-status": `Usage:\n ${name} image-status\n\nShows provider configuration and locally advertised generation/editing tools without generating an image or exposing its API key.`,
|
|
365
373
|
"auto-review-model": `Usage:\n ${name} auto-review-model\n\nSelect a Responses model, then an advertised reasoning effort. Enter keeps the default/current choice; q cancels both steps without saving. Effort 0 follows the client (low when omitted). Settings apply only to Auto-review on the next request; no client patch or restart is required for adapters running 0.9.13 or newer.`,
|
|
366
|
-
"suggestion": `
|
|
374
|
+
"suggestion": `The CCDX prediction panel has been retired. Run ${name} suggestion off to safely remove unchanged CCDX registrations. This cleanup does not change native predictions; see ${name} prediction --help for that separate opt-in feature.`,
|
|
375
|
+
prediction: `Usage:\n ${name} prediction [status|on|off]\n\nOptional native next-message predictions; off by default. macOS only. Quit Codex before on/off, then restart ccdx. On adds private App-profile certificate trust and an App-only route for chatgpt.com and ab.chatgpt.com. HTTPS on those hosts is decrypted locally; only prediction configuration is changed. No client program files, OS trust, account permissions, MCP or Hooks are changed. Static HTTP/HTTPS/SOCKS5 proxies and bypass rules are preserved; dynamic PAC/WPAD and CIDR bypass rules are reported as unsupported. Official prediction configuration is preserved; the conversation's model is used. App updates require another compatibility check. Status distinguishes saved preference and observed config traffic from unverified candidate visibility.`,
|
|
367
376
|
update: `Usage:\n ${name} update [npm|github]\n\nUpdates the global package from the configured npm registry or the latest stable GitHub release. With no source, an interactive terminal prompts for one.`,
|
|
368
377
|
version: `Usage:\n ${name} --version`,
|
|
369
378
|
};
|
|
@@ -389,7 +398,7 @@ export function cliHelp(commandName = "ccdx", topic = "") {
|
|
|
389
398
|
${name} disable-image
|
|
390
399
|
${name} image-status
|
|
391
400
|
${name} auto-review-model
|
|
392
|
-
${name}
|
|
401
|
+
${name} prediction [status|on|off]
|
|
393
402
|
${name} update [npm|github]
|
|
394
403
|
${name} --version
|
|
395
404
|
${name} --help
|
|
@@ -408,7 +417,7 @@ Commands:
|
|
|
408
417
|
disable-image Disable the image provider and remove its credential
|
|
409
418
|
image-status Show the optional image provider status
|
|
410
419
|
auto-review-model Select the Codex Auto-review model and reasoning effort
|
|
411
|
-
|
|
420
|
+
prediction Toggle optional native next-message predictions
|
|
412
421
|
update Update the global package from npm or GitHub
|
|
413
422
|
|
|
414
423
|
Run ${name} <command> --help for command details.`;
|
package/src/cli-status.mjs
CHANGED
|
@@ -188,6 +188,11 @@ export function formatAdapterStatus({ baseUrl, data }, { commandName = "ccdx", c
|
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
190
|
lines.push(status("info", `Models: ${count(models.models)} total; Copilot token ${copilot.token_cached ? `cached (${duration(copilot.token_expires_in_ms)} remaining)` : "not cached"}`));
|
|
191
|
+
const prediction = data.client_integrations?.prediction;
|
|
192
|
+
if (prediction) {
|
|
193
|
+
lines.push(status("info", `Native predictions: ${prediction.configured ? "on" : "off"}; bridge ${prediction.bridge}; client route ${prediction.client_route}; visible candidate unknown`));
|
|
194
|
+
if (prediction.reason) lines.push(status("warn", terminalCell(prediction.reason).slice(0, 400)));
|
|
195
|
+
}
|
|
191
196
|
if (data.profiles?.codex) {
|
|
192
197
|
lines.push(status("info", `Profile: ${profileSummary("Codex", data.profiles.codex)}`));
|
|
193
198
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { ensureCodexConfig } from "./config.mjs";
|
|
2
|
+
import { readImageProviderConfig } from "./image-provider-config.mjs";
|
|
3
|
+
import { ensureCodexThreadCompression } from "./codex-thread-compression.mjs";
|
|
4
|
+
import { runInBackground } from "./startup.mjs";
|
|
5
|
+
import { createPredictionState } from "./native-prediction-state.mjs";
|
|
6
|
+
|
|
7
|
+
// The CLI composes the services; this module owns optional client integration
|
|
8
|
+
// lifecycle. It never forwards model requests or depends on dashboard rendering.
|
|
9
|
+
export function createClientIntegration({ port, host, info = () => {}, warning = () => {} } = {}) {
|
|
10
|
+
const prediction = createPredictionState();
|
|
11
|
+
let native = null;
|
|
12
|
+
return {
|
|
13
|
+
async configure() {
|
|
14
|
+
try {
|
|
15
|
+
const { retirePredictionIntegration } = await import("./prediction-retirement.mjs");
|
|
16
|
+
const result = retirePredictionIntegration();
|
|
17
|
+
if (result.changed) info("Removed the retired CCDX prediction integration");
|
|
18
|
+
if (result.preserved) warning("Modified prediction settings were preserved");
|
|
19
|
+
} catch (error) { warning(`${error.message}. Core startup will continue.`); }
|
|
20
|
+
const enabled = Boolean(readImageProviderConfig());
|
|
21
|
+
const { imageMcpSkipped } = ensureCodexConfig(port, { host, imageProviderEnabled: enabled });
|
|
22
|
+
if (enabled && !imageMcpSkipped) {
|
|
23
|
+
try {
|
|
24
|
+
const { syncEnabledImageSkill } = await import("./cli-image.mjs");
|
|
25
|
+
syncEnabledImageSkill({ adapterPort: port, adapterHost: host });
|
|
26
|
+
} catch (error) { warning(`Image guidance could not be updated: ${error.message}`); }
|
|
27
|
+
}
|
|
28
|
+
return { compressionSetup: runInBackground(ensureCodexThreadCompression) };
|
|
29
|
+
},
|
|
30
|
+
async start() {
|
|
31
|
+
try {
|
|
32
|
+
const { startNativePrediction } = await import("./native-prediction.mjs");
|
|
33
|
+
native = await startNativePrediction({ state: prediction, warning: info });
|
|
34
|
+
} catch (error) { prediction.unavailable(error); warning(`${error.message}. Core startup will continue.`); }
|
|
35
|
+
return native;
|
|
36
|
+
},
|
|
37
|
+
pacUrl() { return native?.pacUrl || null; },
|
|
38
|
+
snapshot() { return { prediction: prediction.snapshot() }; },
|
|
39
|
+
async close() { await native?.close(); native = null; },
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
|
-
import
|
|
3
|
+
import { codexPaths } from "./codex-paths.mjs";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
5
|
import { parse } from "smol-toml";
|
|
6
6
|
import { computeUpdatedCodexConfig, initialCodexConfig } from "./config.mjs";
|
|
@@ -87,8 +87,8 @@ function checkUrl(value, expected, label) {
|
|
|
87
87
|
: { kind: "warn", message: `${label} differs from the CCDX address ${expected}` };
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
export function inspectCodexConfig({ home = os.homedir(), host = "127.0.0.1", port = 2026 } = {}) {
|
|
91
|
-
const filePath =
|
|
90
|
+
export function inspectCodexConfig({ home = os.homedir(), env: processEnv = process.env, host = "127.0.0.1", port = 2026 } = {}) {
|
|
91
|
+
const filePath = codexPaths({ env: processEnv, home }).config;
|
|
92
92
|
let content;
|
|
93
93
|
try {
|
|
94
94
|
content = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(fs.readFileSync(filePath));
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export function codexPaths({ env = process.env, home = os.homedir() } = {}) {
|
|
5
|
+
const directory = path.resolve(String(env.CODEX_HOME || "").trim() || path.join(home, ".codex"));
|
|
6
|
+
const profile = path.resolve(String(env.CODEX_ELECTRON_USER_DATA_PATH || "").trim()
|
|
7
|
+
|| path.join(home, "Library", "Application Support", "Codex"));
|
|
8
|
+
return { directory, config: path.join(directory, "config.toml"), profile,
|
|
9
|
+
certificateStore: path.join(profile, "Default", "ServerCertificate") };
|
|
10
|
+
}
|
|
@@ -6,6 +6,8 @@ import { promisify } from "node:util";
|
|
|
6
6
|
import { CODEX_APP_BINARY_PATHS } from "./codex-model-catalog.mjs";
|
|
7
7
|
import { parseCodexToml, codexTomlStatements, validateManagedConfigEdit } from "./config-toml.mjs";
|
|
8
8
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
9
|
+
import { codexPaths } from "./codex-paths.mjs";
|
|
10
|
+
import { withFileLockSync } from "./lock.mjs";
|
|
9
11
|
import { status } from "./status.mjs";
|
|
10
12
|
|
|
11
13
|
const FEATURE = "local_thread_store_compression";
|
|
@@ -70,7 +72,8 @@ export async function codexNeedsThreadCompressionDefault({
|
|
|
70
72
|
}
|
|
71
73
|
|
|
72
74
|
export async function ensureCodexThreadCompression({
|
|
73
|
-
|
|
75
|
+
env = process.env, home = os.homedir(),
|
|
76
|
+
filePath = codexPaths({ env, home }).config,
|
|
74
77
|
probe = codexNeedsThreadCompressionDefault,
|
|
75
78
|
} = {}) {
|
|
76
79
|
try {
|
|
@@ -79,11 +82,13 @@ export async function ensureCodexThreadCompression({
|
|
|
79
82
|
if (!await probe()) return;
|
|
80
83
|
// The client/user may edit the shared file while the asynchronous probe runs.
|
|
81
84
|
// Recompute against its current bytes; explicit opt-outs always win.
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
85
|
+
withFileLockSync(`${filePath}.ccdx.lock`, () => {
|
|
86
|
+
const updated = computeThreadCompressionDefault(read());
|
|
87
|
+
if (updated.changed) {
|
|
88
|
+
atomicWriteFileIfChangedSync(filePath, updated.content);
|
|
89
|
+
console.log(status("ok", "Enabled Codex local history compression (experimental)"));
|
|
90
|
+
}
|
|
91
|
+
});
|
|
87
92
|
} catch {
|
|
88
93
|
console.warn(status("warn", "Optional Codex history compression setup was skipped; core proxy remains available"));
|
|
89
94
|
}
|
package/src/config.mjs
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import os from "node:os";
|
|
4
2
|
import { status } from "./status.mjs";
|
|
5
3
|
import { atomicWriteFileIfChangedSync } from "./atomic-file.mjs";
|
|
6
4
|
import { adapterBaseUrl } from "./running-adapter.mjs";
|
|
7
5
|
import { isLoopbackHost } from "./security.mjs";
|
|
8
6
|
import { codexTomlStatements, configValue, parseCodexToml, validateManagedConfigEdit } from "./config-toml.mjs";
|
|
7
|
+
import { codexPaths } from "./codex-paths.mjs";
|
|
8
|
+
import { withFileLockSync } from "./lock.mjs";
|
|
9
9
|
|
|
10
|
-
const CONFIG_PATH = path.join(os.homedir(), ".codex", "config.toml");
|
|
11
10
|
const MODEL_CONTEXT_WINDOW = 1_000_000;
|
|
12
11
|
const MODEL_AUTO_COMPACT_TOKEN_LIMIT = 900_000;
|
|
13
12
|
const IMAGE_MCP_START = "# ccdx:image-mcp:start";
|
|
@@ -278,8 +277,13 @@ export function computeImageMcpCodexConfig(content, options = {}) {
|
|
|
278
277
|
return computeImageConfig(content, options);
|
|
279
278
|
}
|
|
280
279
|
|
|
281
|
-
export function ensureCodexConfig(adapterPort = 2026, {
|
|
282
|
-
filePath =
|
|
280
|
+
export function ensureCodexConfig(adapterPort = 2026, options = {}) {
|
|
281
|
+
const filePath = options.filePath || codexPaths(options).config;
|
|
282
|
+
return withFileLockSync(`${filePath}.ccdx.lock`, () => ensureCodexConfigUnlocked(adapterPort, { ...options, filePath }));
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function ensureCodexConfigUnlocked(adapterPort = 2026, {
|
|
286
|
+
filePath,
|
|
283
287
|
host = "127.0.0.1",
|
|
284
288
|
imageProviderEnabled = false,
|
|
285
289
|
} = {}) {
|
|
@@ -303,7 +307,7 @@ export function ensureCodexConfig(adapterPort = 2026, {
|
|
|
303
307
|
console.log(status("ok", `Codex already points to ${baseUrl}`));
|
|
304
308
|
} else {
|
|
305
309
|
atomicWriteFileIfChangedSync(filePath, core.content);
|
|
306
|
-
console.log(status("ok", content === undefined ?
|
|
310
|
+
console.log(status("ok", content === undefined ? `Created Codex configuration: ${filePath}` : `Configured Codex base URL: ${baseUrl}`));
|
|
307
311
|
}
|
|
308
312
|
// Disabled means no image maintenance, including no implicit removal of old
|
|
309
313
|
// or user-owned entries. Explicit disable-image owns the cleanup operation.
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
+
import { errorEvidenceText } from "./operation-error.mjs";
|
|
2
|
+
|
|
1
3
|
function cloneJson(value) {
|
|
2
4
|
return value === undefined ? undefined : structuredClone(value);
|
|
3
5
|
}
|
|
4
6
|
|
|
5
7
|
export function isEncryptedContentVerificationError(statusCode, text) {
|
|
6
8
|
if (statusCode < 400 || !text) return false;
|
|
7
|
-
const lower =
|
|
9
|
+
const lower = errorEvidenceText(text).toLowerCase();
|
|
8
10
|
const reasoningFailure = lower.includes("encrypted content")
|
|
9
11
|
&& lower.includes("could not be verified")
|
|
10
12
|
&& (lower.includes("could not be decrypted") || lower.includes("could not be parsed"));
|
|
@@ -17,7 +19,7 @@ export function isEncryptedContentVerificationError(statusCode, text) {
|
|
|
17
19
|
|
|
18
20
|
export function isImageNamespaceCollisionError(statusCode, text) {
|
|
19
21
|
if (statusCode < 400 || !text) return false;
|
|
20
|
-
const lower =
|
|
22
|
+
const lower = errorEvidenceText(text).toLowerCase();
|
|
21
23
|
return lower.includes("namespace")
|
|
22
24
|
&& lower.includes("image_gen")
|
|
23
25
|
&& lower.includes("collid");
|
package/src/dashboard/index.html
CHANGED
|
@@ -50,12 +50,6 @@
|
|
|
50
50
|
</article>
|
|
51
51
|
</section>
|
|
52
52
|
|
|
53
|
-
<section class="panel dashboard-section" id="suggestion-panel">
|
|
54
|
-
<div class="panel-heading"><h2 data-i18n>NEXT MESSAGE</h2><span data-i18n>OPTIONAL</span></div>
|
|
55
|
-
<dl class="stats"><div><dt data-i18n>Status</dt><dd id="suggestion-enabled">—</dd></div><div><dt data-i18n>Model</dt><dd id="suggestion-model">—</dd></div><div><dt data-i18n>Auto generation</dt><dd id="suggestion-auto">—</dd></div><div><dt data-i18n>Draft insertion</dt><dd data-i18n>Choose Add to draft</dd></div></dl>
|
|
56
|
-
<p id="suggestion-recent" class="hint" role="status">—</p><p id="suggestion-detail" class="hint">—</p><p class="hint"><span data-i18n>Commands</span>: <code>ccdx suggestion on|off|model|status</code></p>
|
|
57
|
-
</section>
|
|
58
|
-
|
|
59
53
|
<details class="panel dashboard-section" id="request-context">
|
|
60
54
|
<summary data-i18n>RECENT REQUEST CONTEXT</summary>
|
|
61
55
|
<p id="context-note" class="table-note">—</p>
|
|
@@ -63,6 +57,13 @@
|
|
|
63
57
|
<p class="hint" data-i18n>Recent upstream request metadata, not exact live session occupancy. Missing values are unknown. No prompt or image content is retained.</p>
|
|
64
58
|
</details>
|
|
65
59
|
|
|
60
|
+
<section class="panel dashboard-section" aria-label="CLIENT FEATURES" data-i18n-label>
|
|
61
|
+
<div class="panel-heading"><h2 data-i18n>CLIENT FEATURES</h2><span data-i18n>OBSERVED STATE</span></div>
|
|
62
|
+
<dl class="stats"><div><dt data-i18n>Native predictions</dt><dd id="prediction-preference">—</dd></div><div><dt data-i18n>Local bridge</dt><dd id="prediction-bridge">—</dd></div><div><dt data-i18n>Client route</dt><dd id="prediction-route">—</dd></div><div><dt data-i18n>Visible candidate</dt><dd id="prediction-candidate">—</dd></div></dl>
|
|
63
|
+
<p id="prediction-note" class="hint">—</p>
|
|
64
|
+
<p class="hint" data-i18n>Configured does not mean displayed. Use ccdx prediction status for details.</p>
|
|
65
|
+
</section>
|
|
66
|
+
|
|
66
67
|
<section class="panel dashboard-section" aria-label="Live upstream models" data-i18n-label>
|
|
67
68
|
<div class="panel-heading"><h2 data-i18n>UPSTREAM GPT MODELS</h2><div class="heading-actions"><span id="models-state" data-i18n>CHECKING LIVE</span><button id="refresh-models" type="button" data-i18n>Refresh</button></div></div>
|
|
68
69
|
<p id="models-note" class="table-note" data-i18n>Querying GitHub Copilot…</p>
|
|
@@ -1,26 +1,18 @@
|
|
|
1
1
|
(() => {
|
|
2
2
|
const translations = {
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"
|
|
9
|
-
"
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
"Commands":["Commands","命令"],
|
|
17
|
-
"No automatic suggestion yet":["No automatic suggestion yet.","暂无自动建议。"],
|
|
18
|
-
"Generating suggestion":["Generating…","正在生成。"],
|
|
19
|
-
"Suggestion ready; open Next message":["Ready. Open Next message.","已生成,请打开下一句。"],
|
|
20
|
-
"No useful suggestion":["No useful suggestion.","暂无合适建议。"],
|
|
21
|
-
"Suggestion failed; retry manually":["Failed. Retry in Next message.","生成失败,请手动重试。"],
|
|
22
|
-
"Suggestion cancelled":["Cancelled.","已取消。"],
|
|
23
|
-
"Run ccdx suggestion status":["Run ccdx suggestion status.","请用命令检查设置。"],
|
|
3
|
+
"CLIENT FEATURES": ["CLIENT FEATURES", "接入能力"],
|
|
4
|
+
"OBSERVED STATE": ["OBSERVED STATE", "实际状态"],
|
|
5
|
+
"Native predictions": ["Native predictions", "输入建议"],
|
|
6
|
+
"Local bridge": ["Local bridge", "本地接入"],
|
|
7
|
+
"Client route": ["Client route", "配置请求"],
|
|
8
|
+
"Visible candidate": ["Visible candidate", "显示结果"],
|
|
9
|
+
"Configured does not mean displayed. Use ccdx prediction status for details.": ["Enabled is not proof of a visible suggestion. Check ccdx prediction status.", "开启不等于已显示。\n终端命令可查看详情。"],
|
|
10
|
+
"Enabled": ["Enabled", "已开启"],
|
|
11
|
+
"Disabled": ["Disabled", "已关闭"],
|
|
12
|
+
"Running": ["Running", "运行中"],
|
|
13
|
+
"Stopped": ["Stopped", "未运行"],
|
|
14
|
+
"Unavailable": ["Unavailable", "不可用"],
|
|
15
|
+
"Observed": ["Observed", "已收到配置请求"],
|
|
24
16
|
"First output": ["First output", "首次输出"],
|
|
25
17
|
"Token time": ["Token time", "输出耗时"],
|
|
26
18
|
"Gateway timing. Token time is estimated.": ["Observed at the gateway. Token time is an estimate.", "网关观测时间。\n每Token耗时为估算值。"],
|
package/src/dashboard/ui.js
CHANGED
|
@@ -16,7 +16,6 @@ const finite = (value) => value !== null && value !== undefined && Number.isFini
|
|
|
16
16
|
const number = (value) => finite(value) ? Math.max(0, Math.round(Number(value))).toLocaleString() : "—";
|
|
17
17
|
const mib = (value) => finite(value) ? `${(Number(value) / 1048576).toFixed(1)} MiB` : "—";
|
|
18
18
|
let authLoading = false;
|
|
19
|
-
let suggestionLoading=false;
|
|
20
19
|
let contextRequests = [];
|
|
21
20
|
let contextCompactions = {};
|
|
22
21
|
|
|
@@ -41,23 +40,6 @@ async function loadAuth() {
|
|
|
41
40
|
} finally { authLoading = false; }
|
|
42
41
|
}
|
|
43
42
|
|
|
44
|
-
async function loadSuggestion(){
|
|
45
|
-
if(suggestionLoading)return;
|
|
46
|
-
suggestionLoading=true;
|
|
47
|
-
try{
|
|
48
|
-
const response=await fetch("/_ccdx/ui/suggestion",{cache:"no-store",headers:{"X-CCDX-Dashboard":"1"},signal:AbortSignal.timeout(5000)});
|
|
49
|
-
const data=await response.json();
|
|
50
|
-
if(!response.ok||data.source!=="local_suggestion_settings")throw new Error();
|
|
51
|
-
text("suggestion-enabled",data.enabled?"ENABLED":"DISABLED");
|
|
52
|
-
element("suggestion-model").textContent=data.model;
|
|
53
|
-
text("suggestion-auto",data.automatic_generation_configured?"Configured; review hook":"DISABLED");
|
|
54
|
-
const state={idle:"No automatic suggestion yet",generating:"Generating suggestion",ready:"Suggestion ready; open Next message",empty:"No useful suggestion",failed:"Suggestion failed; retry manually",cancelled:"Suggestion cancelled",unknown:"Unknown"}[data.recent?.state]||"Unknown";
|
|
55
|
-
text("suggestion-recent",state);
|
|
56
|
-
text("suggestion-detail","{time} / {reason}",{time:data.recent?.at?new Date(data.recent.at).toLocaleTimeString():"—",reason:phrase({context_unavailable:"Context unavailable",model_failed:"Model request failed",settings_changed:"Settings changed",turn_changed:"Conversation changed",timeout:"Suggestion timed out"}[data.recent?.reason]||"No recorded error")});
|
|
57
|
-
}catch{text("suggestion-enabled","UNAVAILABLE");element("suggestion-model").textContent="—";text("suggestion-auto","UNAVAILABLE");text("suggestion-recent","Run ccdx suggestion status");element("suggestion-detail").textContent="—";}
|
|
58
|
-
finally{suggestionLoading=false;}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
43
|
function duration(value) {
|
|
62
44
|
if (!finite(value)) return "—";
|
|
63
45
|
const seconds = Math.max(0, Math.round(Number(value) / 1000));
|
|
@@ -186,6 +168,15 @@ function render(data) {
|
|
|
186
168
|
|
|
187
169
|
renderFailures(data.response_failures?.recent, data.stream_performance?.recent_requests);
|
|
188
170
|
renderContext(data.stream_performance);
|
|
171
|
+
if (element("prediction-preference")) {
|
|
172
|
+
const prediction = data.client_integrations?.prediction;
|
|
173
|
+
text("prediction-preference", prediction ? prediction.configured ? "Enabled" : "Disabled" : "Unknown");
|
|
174
|
+
text("prediction-bridge", ({ running: "Running", stopped: "Stopped", unavailable: "Unavailable" })[prediction?.bridge] || "Unknown");
|
|
175
|
+
text("prediction-route", prediction?.client_route === "observed" ? "Observed" : "Unknown");
|
|
176
|
+
text("prediction-candidate", "Unknown");
|
|
177
|
+
const note = element("prediction-note");
|
|
178
|
+
if (note) note.textContent = prediction?.reason || "";
|
|
179
|
+
}
|
|
189
180
|
text("updated", "Snapshot {time}", { time: new Date().toLocaleTimeString() });
|
|
190
181
|
}
|
|
191
182
|
|
|
@@ -241,11 +232,10 @@ async function refresh() {
|
|
|
241
232
|
}
|
|
242
233
|
}
|
|
243
234
|
|
|
244
|
-
element("refresh").addEventListener("click", () => { refresh(); loadAuth();
|
|
235
|
+
element("refresh").addEventListener("click", () => { refresh(); loadAuth(); });
|
|
245
236
|
element("request-context").addEventListener("toggle", () => { if (element("request-context").open) renderContext(); });
|
|
246
237
|
refresh();
|
|
247
238
|
loadAuth();
|
|
248
|
-
loadSuggestion();
|
|
249
239
|
|
|
250
240
|
const animationRows = new Map();
|
|
251
241
|
let animationTimelines = [];
|
package/src/dashboard-api.mjs
CHANGED
|
@@ -15,14 +15,11 @@ import { cacheReadTokens, summarizeUsageLogs, usageCacheHitRate } from "./usage-
|
|
|
15
15
|
import { createUsageAnalytics } from "./usage-analytics.mjs";
|
|
16
16
|
import { analyzeUsageLogs } from "./usage-analysis.mjs";
|
|
17
17
|
import { readUserSettings, terminalAnimationPreference, writeTerminalAnimationTheme } from "./user-settings.mjs";
|
|
18
|
-
import { readSuggestionConfig, suggestionConfigPath } from "./suggestion-config.mjs";
|
|
19
|
-
import { automaticSuggestionStatus } from "./suggestion-auto.mjs";
|
|
20
18
|
|
|
21
19
|
const ANIMATION_PATH = "/_ccdx/ui/animation";
|
|
22
20
|
const MODELS_PATH = "/_ccdx/ui/models/live";
|
|
23
21
|
const USAGE_PATH = "/_ccdx/ui/usage";
|
|
24
22
|
const AUTH_PATH = "/_ccdx/ui/auth";
|
|
25
|
-
const SUGGESTION_PATH = "/_ccdx/ui/suggestion";
|
|
26
23
|
const DISABLED_ANIMATION = /^(0|false|no|off)$/i;
|
|
27
24
|
|
|
28
25
|
function sendJson(res, statusCode, value) {
|
|
@@ -149,15 +146,6 @@ export async function handleDashboardApi(req, res, pathname, {
|
|
|
149
146
|
}
|
|
150
147
|
return;
|
|
151
148
|
}
|
|
152
|
-
if(pathname===SUGGESTION_PATH && req.method==="GET"){
|
|
153
|
-
try{
|
|
154
|
-
const filePath=suggestionConfigPath({env,home}),config=readSuggestionConfig({filePath,strict:true});
|
|
155
|
-
sendJson(res,200,{source:"local_suggestion_settings",enabled:config.enabled,model:config.model,
|
|
156
|
-
automatic_generation_configured:config.enabled&&config.automatic===true,hook_trust:"unknown",automatic_insertion:false,
|
|
157
|
-
recent:config.enabled?automaticSuggestionStatus(filePath):{state:"idle",at:null,reason:null}});
|
|
158
|
-
}catch{sendJson(res,409,{error:"Suggestion settings are invalid; run ccdx suggestion status"})}
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
149
|
|
|
162
150
|
if (pathname === ANIMATION_PATH && req.method === "GET") {
|
|
163
151
|
try {
|
package/src/launcher.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export function launchAttempts() {
|
|
|
36
36
|
return [["-b", CODEX_BUNDLE_ID], ...APP_PATH_CANDIDATES.map((path) => [path])];
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
export async function openCodex({ env = process.env, spawnImpl = spawn, platform = process.platform } = {}) {
|
|
39
|
+
export async function openCodex({ env = process.env, spawnImpl = spawn, platform = process.platform, predictionPacUrl = null, predictionApp = null } = {}) {
|
|
40
40
|
if (platform !== "darwin") {
|
|
41
41
|
console.log(status("warn", "Auto-launch is only supported on macOS"));
|
|
42
42
|
console.log(status("info", "Open Codex App manually; it will connect to the adapter."));
|
|
@@ -48,7 +48,12 @@ export async function openCodex({ env = process.env, spawnImpl = spawn, platform
|
|
|
48
48
|
return false;
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
const extraArgs = typeof predictionPacUrl === "string" && /^http:\/\/127\.0\.0\.1:\d{1,5}\/proxy\.pac$/.test(predictionPacUrl)
|
|
52
|
+
&& APP_PATH_CANDIDATES.includes(predictionApp)
|
|
53
|
+
? ["--args", `--proxy-pac-url=${predictionPacUrl}`] : [];
|
|
54
|
+
const attempts = extraArgs.length ? [[predictionApp]] : launchAttempts();
|
|
55
|
+
for (const attempt of attempts) {
|
|
56
|
+
const args = [...attempt, ...extraArgs];
|
|
52
57
|
if (await tryOpen(args, { spawnImpl })) {
|
|
53
58
|
console.log(status("ok", "Codex app launched"));
|
|
54
59
|
return true;
|
package/src/lock.mjs
CHANGED
|
@@ -6,6 +6,35 @@ function sleep(ms) {
|
|
|
6
6
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
// Synchronous configuration writers share the same cross-process lock format.
|
|
10
|
+
// Readers never take the lock. A live lock is never stolen on timeout.
|
|
11
|
+
export function withFileLockSync(lockPath, fn, { timeoutMs = 1000, staleMs = 60_000 } = {}) {
|
|
12
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
|
|
13
|
+
const started = Date.now(), owner = randomUUID();
|
|
14
|
+
const sleeper = new Int32Array(new SharedArrayBuffer(4));
|
|
15
|
+
while (true) {
|
|
16
|
+
if (lockPathExists(reclaimGuardPath(lockPath), fs)) {
|
|
17
|
+
if (Date.now() - started >= timeoutMs) throw new Error("Configuration lock recovery is in progress; retry shortly");
|
|
18
|
+
Atomics.wait(sleeper, 0, 0, 10);
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
try {
|
|
22
|
+
const fd = writeLockRecord(lockPath, { pid: process.pid, owner, created_at: new Date().toISOString() }, fs);
|
|
23
|
+
fs.closeSync(fd);
|
|
24
|
+
if (lockPathExists(reclaimGuardPath(lockPath), fs)) { removeOwnedLock(lockPath, owner, fs); continue; }
|
|
25
|
+
break;
|
|
26
|
+
} catch (error) {
|
|
27
|
+
if (error.code !== "EEXIST") throw error;
|
|
28
|
+
if (maybeRemoveStaleLock(lockPath, staleMs, Date.now(), fs, processIsAlive)) continue;
|
|
29
|
+
let sameProcess = false;
|
|
30
|
+
try { sameProcess = readLockSnapshot(lockPath).pid === process.pid; } catch {}
|
|
31
|
+
if (sameProcess || Date.now() - started >= timeoutMs) throw new Error("Configuration is being updated by another operation; retry shortly");
|
|
32
|
+
Atomics.wait(sleeper, 0, 0, 10);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
try { return fn(); } finally { removeOwnedLock(lockPath, owner, fs); }
|
|
36
|
+
}
|
|
37
|
+
|
|
9
38
|
function readLockSnapshot(lockPath) {
|
|
10
39
|
const stat = fs.statSync(lockPath);
|
|
11
40
|
let record = {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const NATIVE_PREDICTION_GATE = "4290031608";
|
|
2
|
+
|
|
3
|
+
const LOCAL_PREDICTION_CONFIG = Object.freeze({
|
|
4
|
+
is_enabled: true,
|
|
5
|
+
prompt: 'Predict one useful next message the user may send, based on the conversation. Write from the user\'s perspective and use the user\'s language. Do not answer the message, call tools, or invent new requests, consent, or permission. Prefer a short, natural follow-up. If no useful follow-up is apparent, use an empty string. Return only a JSON object with exactly one key: {"suggestion":"the predicted user message"}.',
|
|
6
|
+
prompt_version: "ccdx-native-prediction-v1",
|
|
7
|
+
prediction_reasoning_effort: "low",
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
function isObject(value) {
|
|
11
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function parseBody(body) {
|
|
15
|
+
if (typeof body !== "string" && !Buffer.isBuffer(body)) return null;
|
|
16
|
+
try { return JSON.parse(body.toString()); } catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function encodeBody(value, original) {
|
|
20
|
+
const text = JSON.stringify(value);
|
|
21
|
+
return Buffer.isBuffer(original) ? Buffer.from(text) : text;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function classifyNativePredictionPath({ hostname, pathname } = {}) {
|
|
25
|
+
const host = typeof hostname === "string" ? hostname.toLowerCase() : "";
|
|
26
|
+
if (host === "chatgpt.com") {
|
|
27
|
+
if (pathname === "/backend-api/wham/statsig/bootstrap") return "bootstrap";
|
|
28
|
+
if (pathname === "/backend-api/wham/predictions/config") return "config";
|
|
29
|
+
}
|
|
30
|
+
if (host === "ab.chatgpt.com" && pathname === "/v1/initialize") return "refresh";
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function enablePredictionGate(payload) {
|
|
35
|
+
if (!isObject(payload) || (payload.response_mode === "live_overlay" ? payload.has_updates === false : payload.has_updates !== true) ||
|
|
36
|
+
(payload.is_delta !== undefined && payload.is_delta !== false) ||
|
|
37
|
+
(payload.response_mode !== undefined && !["full", "live_overlay"].includes(payload.response_mode)) ||
|
|
38
|
+
!isObject(payload.feature_gates)) return false;
|
|
39
|
+
// Statsig V1 first resolves the literal name, then its djb2 lookup key.
|
|
40
|
+
let hash = 0;
|
|
41
|
+
for (const char of NATIVE_PREDICTION_GATE) hash = ((hash << 5) - hash + char.charCodeAt(0)) | 0;
|
|
42
|
+
const hashedKey = String(hash >>> 0);
|
|
43
|
+
const key = Object.hasOwn(payload.feature_gates, NATIVE_PREDICTION_GATE) ? NATIVE_PREDICTION_GATE
|
|
44
|
+
: Object.hasOwn(payload.feature_gates, hashedKey) ? hashedKey : NATIVE_PREDICTION_GATE;
|
|
45
|
+
const gate = payload.feature_gates[key];
|
|
46
|
+
if (gate !== undefined && (!isObject(gate) || typeof gate.value !== "boolean" || gate.value === true)) return false;
|
|
47
|
+
if (payload.response_mode === "live_overlay" && !Array.isArray(payload.live_entity_names?.feature_gates)) return false;
|
|
48
|
+
if (gate) gate.value = true;
|
|
49
|
+
else payload.feature_gates[key] = { name: key, value: true, rule_id: "ccdx-local-compatibility", secondary_exposures: [] };
|
|
50
|
+
if (payload.response_mode === "live_overlay" && !payload.live_entity_names.feature_gates.includes(key)) {
|
|
51
|
+
payload.live_entity_names.feature_gates.push(key);
|
|
52
|
+
}
|
|
53
|
+
return true;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// No-op results retain the original bytes; callers own HTTP headers and decoding.
|
|
57
|
+
export function applyNativePredictionOverlay({ hostname, pathname, statusCode, body, enabled = false } = {}) {
|
|
58
|
+
const kind = classifyNativePredictionPath({ hostname, pathname });
|
|
59
|
+
const result = { body, statusCode, changed: false, official: false, kind };
|
|
60
|
+
if (!enabled || !kind) return result;
|
|
61
|
+
if (kind === "config" && statusCode === 404) {
|
|
62
|
+
return { ...result, statusCode: 200, body: encodeBody(LOCAL_PREDICTION_CONFIG, body), changed: true };
|
|
63
|
+
}
|
|
64
|
+
if (statusCode !== 200) return result;
|
|
65
|
+
const parsed = parseBody(body);
|
|
66
|
+
if (!isObject(parsed)) return result;
|
|
67
|
+
if (kind === "config") {
|
|
68
|
+
if (parsed.is_enabled === true) return { ...result, official: true };
|
|
69
|
+
if (parsed.is_enabled !== false || typeof parsed.prompt !== "string" || !parsed.prompt.trim()) return result;
|
|
70
|
+
parsed.is_enabled = true;
|
|
71
|
+
} else if (kind === "bootstrap") {
|
|
72
|
+
const stringPayload = typeof parsed.statsigPayload === "string";
|
|
73
|
+
const payload = stringPayload ? parseBody(parsed.statsigPayload) : parsed.statsigPayload;
|
|
74
|
+
if (!enablePredictionGate(payload)) return result;
|
|
75
|
+
if (stringPayload) parsed.statsigPayload = JSON.stringify(payload);
|
|
76
|
+
} else if (!enablePredictionGate(parsed)) return result;
|
|
77
|
+
return { ...result, body: encodeBody(parsed, body), changed: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Overlaying a delta would invalidate its checksum. Ask for a full response instead.
|
|
81
|
+
export function normalizeStatsigRequest({ hostname, pathname, body, enabled = false } = {}) {
|
|
82
|
+
const result = { body, changed: false };
|
|
83
|
+
if (!enabled || classifyNativePredictionPath({ hostname, pathname }) !== "refresh") return result;
|
|
84
|
+
const parsed = parseBody(body);
|
|
85
|
+
if (!isObject(parsed) || !isObject(parsed.user) ||
|
|
86
|
+
(parsed.responseMode !== undefined && !["full", "live_overlay"].includes(parsed.responseMode))) return result;
|
|
87
|
+
if (parsed.deltasResponseRequested === false && parsed.full_checksum === null && parsed.sinceTime === 0) return result;
|
|
88
|
+
parsed.deltasResponseRequested = false;
|
|
89
|
+
parsed.full_checksum = null;
|
|
90
|
+
parsed.sinceTime = 0;
|
|
91
|
+
return { body: encodeBody(parsed, body), changed: true };
|
|
92
|
+
}
|