llm-cli-gateway 2.14.1 → 2.16.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/CHANGELOG.md +61 -1
- package/README.md +35 -0
- package/dist/acp/process-manager.js +2 -1
- package/dist/acp/provider-registry.js +8 -8
- package/dist/async-job-manager.d.ts +4 -1
- package/dist/async-job-manager.js +15 -6
- package/dist/compressor/estimate.d.ts +5 -0
- package/dist/compressor/estimate.js +21 -0
- package/dist/compressor/index.d.ts +23 -0
- package/dist/compressor/index.js +77 -0
- package/dist/compressor/router.d.ts +2 -0
- package/dist/compressor/router.js +57 -0
- package/dist/compressor/transforms/ansi.d.ts +3 -0
- package/dist/compressor/transforms/ansi.js +89 -0
- package/dist/compressor/transforms/json.d.ts +1 -0
- package/dist/compressor/transforms/json.js +156 -0
- package/dist/compressor/transforms/log.d.ts +12 -0
- package/dist/compressor/transforms/log.js +55 -0
- package/dist/compressor/transforms/whitespace.d.ts +8 -0
- package/dist/compressor/transforms/whitespace.js +79 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +57 -0
- package/dist/executor.d.ts +1 -0
- package/dist/executor.js +5 -2
- package/dist/flight-recorder.d.ts +9 -0
- package/dist/flight-recorder.js +42 -0
- package/dist/index.d.ts +18 -2
- package/dist/index.js +282 -88
- package/dist/job-store.d.ts +4 -0
- package/dist/job-store.js +16 -0
- package/dist/provider-definitions.d.ts +1 -0
- package/dist/provider-definitions.js +28 -10
- package/dist/provider-tool-capabilities.js +3 -3
- package/dist/request-helpers.js +1 -1
- package/dist/skill-loader.d.ts +24 -0
- package/dist/skill-loader.js +201 -0
- package/dist/spawn-env-isolation.d.ts +10 -0
- package/dist/spawn-env-isolation.js +55 -0
- package/dist/upstream-contracts.js +47 -27
- package/npm-shrinkwrap.json +2 -2
- package/package.json +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,66 @@ All notable changes to the llm-cli-gateway project.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [2.16.0] - 2026-07-10
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **Native outbound display-text compressor, PR-1 (#151).** A built-in,
|
|
12
|
+
provider-agnostic compressor that shrinks provider display text before it is
|
|
13
|
+
returned to the caller. It is ANSI- and fence-aware (protects code fences,
|
|
14
|
+
inline code, OSC sequences, and wide characters from lossy rewriting) and is
|
|
15
|
+
**off by default**; opt in per the compressor configuration. This is the first
|
|
16
|
+
slice; response-side wiring and API-provider coverage are named follow-ups.
|
|
17
|
+
- **Site machine-discovery generation (#169).** The machine-readable discovery
|
|
18
|
+
artifacts under `site/` (`llms.txt`, `.well-known/*`, `tools.md`,
|
|
19
|
+
`openapi.json`, ...) are now generated from the live MCP tool surface by
|
|
20
|
+
`scripts/generate-site-discovery.mjs`. Regenerate with `npm run site:generate`;
|
|
21
|
+
`npm run site:generate:check` + `npm run site:validate` gate them in
|
|
22
|
+
`npm run check` so the published surface can never silently drift from the
|
|
23
|
+
tools the server actually exposes.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- **Vibe/Mistral no longer defaults to `--agent auto-approve` (#155).** Under the
|
|
28
|
+
default `legacy` approval strategy, Vibe was spawned in auto-approve ("YOLO"),
|
|
29
|
+
auto-executing every tool call including shell. It now defaults to
|
|
30
|
+
`--agent accept-edits` (auto-accept file edits; dangerous ops such as shell stay
|
|
31
|
+
gated, and Vibe denies rather than blocks on them in programmatic mode), matching
|
|
32
|
+
the `mcp_managed` posture and the other providers' non-bypass defaults. This
|
|
33
|
+
shrinks the blast radius of a prompt-injected tool call. `auto-approve` remains
|
|
34
|
+
reachable deliberately: pass `permissionMode: "auto-approve"` on the request, or
|
|
35
|
+
set `LLM_GATEWAY_APPROVAL_ALLOW_BYPASS` operator-wide. **Behaviour change:**
|
|
36
|
+
`legacy` callers that relied on Vibe running shell unattended will now see the
|
|
37
|
+
gated op denied unless they adopt one of those opt-ins.
|
|
38
|
+
- **Provider contract and target-version refreshes (#157 through #171).**
|
|
39
|
+
`PROVIDER_TARGET_VERSIONS` is now the single source of truth for provider
|
|
40
|
+
target versions (#165), and every provider's declared upstream contract was
|
|
41
|
+
re-probed against the installed CLI and refreshed: codex-cli 0.144.1, claude
|
|
42
|
+
2.1.206, grok 0.2.93, gemini/agy 1.1.0, mistral vibe 2.19.1 (with
|
|
43
|
+
`--disabled-tools` acknowledged, #170), cursor-agent 2026.07.09, and the devin
|
|
44
|
+
ACP target 3000.1.27. Internal metadata only; no request-time behaviour change.
|
|
45
|
+
|
|
46
|
+
### Security
|
|
47
|
+
|
|
48
|
+
- **Opt-in isolation of provider child-process environment (#154).** A new
|
|
49
|
+
`LLM_GATEWAY_ISOLATE_SPAWN_ENV` opt-in scrubs the spawned provider CLI's
|
|
50
|
+
environment at the single `spawnCliProcess` chokepoint, shrinking the blast
|
|
51
|
+
radius of a hostile provider attempting to exfiltrate redirected/inherited
|
|
52
|
+
environment secrets.
|
|
53
|
+
- **Installer Go toolchain bumped 1.26.4 to 1.26.5 (#166).** Clears a Go stdlib
|
|
54
|
+
vulnerability (GO-2026-4970) in the shipped installer binaries.
|
|
55
|
+
|
|
56
|
+
## [2.15.0] - 2026-07-03
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- **Updateable local skill packs.** Gateway startup now loads bundled skills,
|
|
61
|
+
optional `[skills].paths`, `LLM_GATEWAY_SKILLS_PATH`, and
|
|
62
|
+
`~/.llm-cli-gateway/skills` with deterministic override precedence. Skill
|
|
63
|
+
pack roots can include a `skill-pack.json` integrity manifest that pins each
|
|
64
|
+
loaded `SKILL.md` by SHA-256, allowing operators to update workflow skills
|
|
65
|
+
outside core gateway npm releases without silent network fetches.
|
|
66
|
+
|
|
7
67
|
## [2.14.1] - 2026-07-03
|
|
8
68
|
|
|
9
69
|
### Added
|
|
@@ -21,7 +81,7 @@ All notable changes to the llm-cli-gateway project.
|
|
|
21
81
|
- **Durable instance-lease orphan recovery (#139).** The blanket
|
|
22
82
|
`markOrphanedOnStartup` sweep in the `AsyncJobManager` constructor rewrote
|
|
23
83
|
every `running` row to `orphaned`, so on a SHARED store (`backend =
|
|
24
|
-
|
|
84
|
+
"postgres"`) a fresh instance (especially an ephemeral stdio spawn)
|
|
25
85
|
transiently orphaned other live instances' in-flight jobs (a `running` ->
|
|
26
86
|
`orphaned` -> `completed` flap a poller can trip on). The sweep is now a
|
|
27
87
|
per-job fencing lease: each instance registers a lease and advances a
|
package/README.md
CHANGED
|
@@ -53,6 +53,40 @@ Or use directly with `npx` from an MCP client:
|
|
|
53
53
|
|
|
54
54
|
The repo ships agent-ready workflow skills under [`.agents/skills`](.agents/skills) for async orchestration, session continuity, multi-LLM review, implement-review-fix loops, retrospective evidence walks, and secure approval-gated dispatch. Seven caller-facing skills are bundled in the published npm package: `async-job-orchestration`, `multi-llm-review`, `session-workflow`, `secure-orchestration`, `implement-review-fix`, `retrospective-walk`, and `public-demo-session`. Machine-readable DAG-TOML plans live under [`docs/plans`](docs/plans) and [`setup/install-plan.dag.toml`](setup/install-plan.dag.toml) for workflows that need deterministic sequencing and verification gates.
|
|
55
55
|
|
|
56
|
+
Skill packs can be updated outside the core npm release by placing skill
|
|
57
|
+
directories in local, operator-controlled paths. The gateway loads bundled
|
|
58
|
+
skills first, then `[skills].paths`, then `LLM_GATEWAY_SKILLS_PATH`, then
|
|
59
|
+
`~/.llm-cli-gateway/skills` when it exists; later roots override earlier skills
|
|
60
|
+
by name. Each skill is a directory containing `SKILL.md`. A root may also carry
|
|
61
|
+
`skill-pack.json` to pin expected `SKILL.md` hashes:
|
|
62
|
+
|
|
63
|
+
```toml
|
|
64
|
+
[skills]
|
|
65
|
+
paths = ["/opt/llm-cli-gateway/skills"]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
export LLM_GATEWAY_SKILLS_PATH="/opt/team-skill-pack:/opt/incident-skill-pack"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
{
|
|
74
|
+
"name": "team-pack",
|
|
75
|
+
"version": "1.0.0",
|
|
76
|
+
"skills": [
|
|
77
|
+
{
|
|
78
|
+
"name": "incident-retrospective",
|
|
79
|
+
"sha256": "<sha256 of incident-retrospective/SKILL.md>"
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
The loader is intentionally local-only: it never fetches remote Markdown at
|
|
86
|
+
startup. To update a pack, install or replace files through your package manager
|
|
87
|
+
or deployment system, then restart the gateway so the advertised `skills://...`
|
|
88
|
+
resources refresh.
|
|
89
|
+
|
|
56
90
|
The next documentation focus is provider-specific skill and DAG-TOML pairs for each outbound CLI and API-provider family: Claude, Codex, Gemini/Antigravity, Grok, Mistral Vibe, Devin, Cursor Agent, OpenAI-compatible endpoints, Anthropic Messages, and xAI Responses. The implementation plan is tracked in [`docs/plans/provider-workflow-assets.dag.toml`](docs/plans/provider-workflow-assets.dag.toml), with each provider asset expected to cover install/login checks or token-env checks, session behavior, approval modes, cache/telemetry surfaces, failure modes, and a smoke-test gate.
|
|
57
91
|
|
|
58
92
|
## Trust & Supply Chain
|
|
@@ -1435,6 +1469,7 @@ await callTool("session_delete", {
|
|
|
1435
1469
|
LLM_GATEWAY_OAUTH_REQUIRE_CONSENT=1 LLM_GATEWAY_OAUTH_CONSENT_SECRET='choose-a-strong-code' node dist/index.js
|
|
1436
1470
|
```
|
|
1437
1471
|
- `LLM_GATEWAY_CONFIG`: Path to the gateway TOML config (default: `~/.llm-cli-gateway/config.toml`). See **Persistence configuration** above for the `[persistence]` schema.
|
|
1472
|
+
- `LLM_GATEWAY_SKILLS_PATH`: Extra local skill-pack roots to load at startup, separated by the host path delimiter (`:` on Linux/macOS, `;` on Windows). These paths are appended after `[skills].paths`; `~/.llm-cli-gateway/skills` still loads last when present.
|
|
1438
1473
|
- `LLM_GATEWAY_LOGS_DB`: **Deprecated** — overrides `[persistence].path` and selects `backend = "sqlite"` (or `backend = "none"` when set to `none`). Emits a deprecation warning at startup; migrate to `config.toml`.
|
|
1439
1474
|
```bash
|
|
1440
1475
|
# Custom path
|
|
@@ -6,6 +6,7 @@ import { AcpError, AcpProcessExitError, ProviderUnavailableError } from "./error
|
|
|
6
6
|
import { JsonRpcStdioTransport, } from "./json-rpc-stdio.js";
|
|
7
7
|
import { getAcpProviderEntry } from "./provider-registry.js";
|
|
8
8
|
import { envWithExtendedPath, getExtendedPath } from "../executor.js";
|
|
9
|
+
import { applySpawnEnvIsolation } from "../spawn-env-isolation.js";
|
|
9
10
|
import { noopLogger } from "../logger.js";
|
|
10
11
|
const SHUTDOWN_KILL_GRACE_MS = 5000;
|
|
11
12
|
const SHELL_METACHARACTERS = /[\s|&;<>(){}$`"'\\*?[\]~#! ]/;
|
|
@@ -15,7 +16,7 @@ function assertSafeExecutable(command, provider) {
|
|
|
15
16
|
}
|
|
16
17
|
}
|
|
17
18
|
export function buildProviderEnv(provider, providerConfig, baseEnv) {
|
|
18
|
-
const env = envWithExtendedPath(baseEnv, getExtendedPath());
|
|
19
|
+
const env = applySpawnEnvIsolation(envWithExtendedPath(baseEnv, getExtendedPath()));
|
|
19
20
|
if (provider === "grok" && providerConfig.isolatedLeaderSocket) {
|
|
20
21
|
const socketDir = tmpdir();
|
|
21
22
|
const socketName = `grok-acp-leader-${process.pid}-${Date.now()}.sock`;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getProviderDefinition } from "../provider-definitions.js";
|
|
1
|
+
import { getProviderDefinition, PROVIDER_TARGET_VERSIONS } from "../provider-definitions.js";
|
|
2
2
|
function acpEntrypointFromDefinition(provider) {
|
|
3
3
|
const entry = getProviderDefinition(provider).acp.entrypoint;
|
|
4
4
|
if (!entry) {
|
|
@@ -12,7 +12,7 @@ const ACP_PROVIDER_REGISTRY = Object.freeze({
|
|
|
12
12
|
displayName: "Mistral Vibe",
|
|
13
13
|
status: "native_smoke_passed",
|
|
14
14
|
supportKind: "native",
|
|
15
|
-
targetVersion:
|
|
15
|
+
targetVersion: PROVIDER_TARGET_VERSIONS.mistral,
|
|
16
16
|
entrypoint: acpEntrypointFromDefinition("mistral"),
|
|
17
17
|
runtimeEnabledDefault: false,
|
|
18
18
|
shipRuntimePilot: true,
|
|
@@ -25,7 +25,7 @@ const ACP_PROVIDER_REGISTRY = Object.freeze({
|
|
|
25
25
|
displayName: "xAI Grok CLI",
|
|
26
26
|
status: "native_smoke_passed",
|
|
27
27
|
supportKind: "native",
|
|
28
|
-
targetVersion:
|
|
28
|
+
targetVersion: PROVIDER_TARGET_VERSIONS.grok,
|
|
29
29
|
entrypoint: acpEntrypointFromDefinition("grok"),
|
|
30
30
|
runtimeEnabledDefault: false,
|
|
31
31
|
shipRuntimePilot: true,
|
|
@@ -38,7 +38,7 @@ const ACP_PROVIDER_REGISTRY = Object.freeze({
|
|
|
38
38
|
displayName: "OpenAI Codex CLI",
|
|
39
39
|
status: "adapter_mediated_deferred",
|
|
40
40
|
supportKind: "adapter_mediated",
|
|
41
|
-
targetVersion:
|
|
41
|
+
targetVersion: PROVIDER_TARGET_VERSIONS.codex,
|
|
42
42
|
entrypoint: acpEntrypointFromDefinition("codex"),
|
|
43
43
|
runtimeEnabledDefault: false,
|
|
44
44
|
shipRuntimePilot: false,
|
|
@@ -51,7 +51,7 @@ const ACP_PROVIDER_REGISTRY = Object.freeze({
|
|
|
51
51
|
displayName: "Anthropic Claude Code",
|
|
52
52
|
status: "adapter_mediated_deferred",
|
|
53
53
|
supportKind: "adapter_mediated",
|
|
54
|
-
targetVersion:
|
|
54
|
+
targetVersion: PROVIDER_TARGET_VERSIONS.claude,
|
|
55
55
|
entrypoint: acpEntrypointFromDefinition("claude"),
|
|
56
56
|
runtimeEnabledDefault: false,
|
|
57
57
|
shipRuntimePilot: false,
|
|
@@ -64,7 +64,7 @@ const ACP_PROVIDER_REGISTRY = Object.freeze({
|
|
|
64
64
|
displayName: "Google Antigravity",
|
|
65
65
|
status: "absent_watchlist",
|
|
66
66
|
supportKind: "none",
|
|
67
|
-
targetVersion:
|
|
67
|
+
targetVersion: PROVIDER_TARGET_VERSIONS.gemini,
|
|
68
68
|
entrypoint: acpEntrypointFromDefinition("gemini"),
|
|
69
69
|
runtimeEnabledDefault: false,
|
|
70
70
|
shipRuntimePilot: false,
|
|
@@ -77,7 +77,7 @@ const ACP_PROVIDER_REGISTRY = Object.freeze({
|
|
|
77
77
|
displayName: "Cognition Devin CLI",
|
|
78
78
|
status: "native_smoke_passed",
|
|
79
79
|
supportKind: "native",
|
|
80
|
-
targetVersion:
|
|
80
|
+
targetVersion: PROVIDER_TARGET_VERSIONS.devin,
|
|
81
81
|
entrypoint: acpEntrypointFromDefinition("devin"),
|
|
82
82
|
runtimeEnabledDefault: false,
|
|
83
83
|
shipRuntimePilot: true,
|
|
@@ -90,7 +90,7 @@ const ACP_PROVIDER_REGISTRY = Object.freeze({
|
|
|
90
90
|
displayName: "Cursor Agent CLI",
|
|
91
91
|
status: "native_smoke_passed",
|
|
92
92
|
supportKind: "native",
|
|
93
|
-
targetVersion:
|
|
93
|
+
targetVersion: PROVIDER_TARGET_VERSIONS.cursor,
|
|
94
94
|
entrypoint: acpEntrypointFromDefinition("cursor"),
|
|
95
95
|
runtimeEnabledDefault: false,
|
|
96
96
|
shipRuntimePilot: true,
|
|
@@ -45,6 +45,7 @@ export interface AsyncJobFlightRecorderEntry {
|
|
|
45
45
|
stablePrefixTokens?: number;
|
|
46
46
|
cacheControlBlocks?: number;
|
|
47
47
|
cacheControlTtlSeconds?: number;
|
|
48
|
+
optimizationApplied?: boolean;
|
|
48
49
|
}
|
|
49
50
|
export type AsyncJobUsageExtractor = (stdout: string) => {
|
|
50
51
|
inputTokens?: number;
|
|
@@ -91,6 +92,7 @@ export interface StartJobOptions {
|
|
|
91
92
|
writeFlightStart?: boolean;
|
|
92
93
|
flightRecorderEntry?: AsyncJobFlightRecorderEntry;
|
|
93
94
|
extractUsage?: AsyncJobUsageExtractor;
|
|
95
|
+
compressResponse?: boolean;
|
|
94
96
|
}
|
|
95
97
|
export interface StartJobOutcome {
|
|
96
98
|
snapshot: AsyncJobSnapshot;
|
|
@@ -169,7 +171,7 @@ export declare class AsyncJobManager {
|
|
|
169
171
|
private persistComplete;
|
|
170
172
|
private hydrateFromStore;
|
|
171
173
|
getJobOwner(jobId: string): string | null | undefined;
|
|
172
|
-
startJob(cli: LlmCli, args: string[], correlationId: string, cwd?: string, idleTimeoutMs?: number, outputFormat?: string, forceRefresh?: boolean, env?: Record<string, string>, onComplete?: () => void, flightRecorderEntry?: AsyncJobFlightRecorderEntry, extractUsage?: AsyncJobUsageExtractor, writeFlightStart?: boolean, stdin?: string): AsyncJobSnapshot;
|
|
174
|
+
startJob(cli: LlmCli, args: string[], correlationId: string, cwd?: string, idleTimeoutMs?: number, outputFormat?: string, forceRefresh?: boolean, env?: Record<string, string>, onComplete?: () => void, flightRecorderEntry?: AsyncJobFlightRecorderEntry, extractUsage?: AsyncJobUsageExtractor, writeFlightStart?: boolean, stdin?: string, compressResponse?: boolean): AsyncJobSnapshot;
|
|
173
175
|
startJobWithDedup(cli: LlmCli, args: string[], correlationId: string, opts?: StartJobOptions): StartJobOutcome;
|
|
174
176
|
private launchProcessJob;
|
|
175
177
|
private failQueuedJob;
|
|
@@ -199,6 +201,7 @@ export declare class AsyncJobManager {
|
|
|
199
201
|
getLimiterSnapshot(): JobLimiterSnapshot;
|
|
200
202
|
getConfiguredLimits(): JobLimitsConfig;
|
|
201
203
|
getJobOutputFormat(jobId: string): string | undefined;
|
|
204
|
+
getJobCompressResponse(jobId: string): boolean;
|
|
202
205
|
getJobCli(jobId: string): JobProvider | undefined;
|
|
203
206
|
private snapshot;
|
|
204
207
|
private appendOutput;
|
|
@@ -657,11 +657,12 @@ export class AsyncJobManager {
|
|
|
657
657
|
}
|
|
658
658
|
}
|
|
659
659
|
}
|
|
660
|
-
buildRequestKey(cli, args, env, stdin, cwd, outputFormat) {
|
|
660
|
+
buildRequestKey(cli, args, env, stdin, cwd, outputFormat, compressResponse) {
|
|
661
661
|
const extraEnv = canonicaliseEnvForKey(env);
|
|
662
662
|
const withStdin = stdin === undefined ? extraEnv : `${extraEnv}|stdin:${stdin}`;
|
|
663
663
|
const withCwd = cwd === undefined ? withStdin : `${withStdin}|cwd:${cwd}`;
|
|
664
|
-
const
|
|
664
|
+
const withFmt = cli === "codex" ? `${withCwd}|fmt:${outputFormat ?? "text"}` : withCwd;
|
|
665
|
+
const extra = compressResponse ? `${withFmt}|compress:1` : withFmt;
|
|
665
666
|
const principal = resolveOwnerPrincipal(getRequestContext());
|
|
666
667
|
const scoped = principal === "local" ? extra : `${extra}|principal:${principal}`;
|
|
667
668
|
return computeRequestKey(cli, args, scoped);
|
|
@@ -927,7 +928,7 @@ export class AsyncJobManager {
|
|
|
927
928
|
durationMs,
|
|
928
929
|
retryCount: 0,
|
|
929
930
|
circuitBreakerState: "closed",
|
|
930
|
-
optimizationApplied: false,
|
|
931
|
+
optimizationApplied: job.flightRecorderEntry.optimizationApplied ?? false,
|
|
931
932
|
exitCode,
|
|
932
933
|
httpStatus: job.transport === "http" ? (job.httpStatus ?? undefined) : undefined,
|
|
933
934
|
errorMessage,
|
|
@@ -1068,6 +1069,7 @@ export class AsyncJobManager {
|
|
|
1068
1069
|
exited: row.status !== "running" && row.status !== "queued",
|
|
1069
1070
|
metricsRecorded: true,
|
|
1070
1071
|
outputFormat: row.outputFormat ?? undefined,
|
|
1072
|
+
compressResponse: row.compressResponse ?? null,
|
|
1071
1073
|
ownerPrincipal: row.ownerPrincipal,
|
|
1072
1074
|
outputDirty: false,
|
|
1073
1075
|
lastOutputFlushAt: Date.now(),
|
|
@@ -1081,7 +1083,7 @@ export class AsyncJobManager {
|
|
|
1081
1083
|
job = this.hydrateFromStore(jobId) ?? undefined;
|
|
1082
1084
|
return job?.ownerPrincipal;
|
|
1083
1085
|
}
|
|
1084
|
-
startJob(cli, args, correlationId, cwd, idleTimeoutMs, outputFormat, forceRefresh, env, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, stdin) {
|
|
1086
|
+
startJob(cli, args, correlationId, cwd, idleTimeoutMs, outputFormat, forceRefresh, env, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, stdin, compressResponse) {
|
|
1085
1087
|
return this.startJobWithDedup(cli, args, correlationId, {
|
|
1086
1088
|
cwd,
|
|
1087
1089
|
idleTimeoutMs,
|
|
@@ -1093,11 +1095,12 @@ export class AsyncJobManager {
|
|
|
1093
1095
|
flightRecorderEntry,
|
|
1094
1096
|
extractUsage,
|
|
1095
1097
|
writeFlightStart,
|
|
1098
|
+
compressResponse,
|
|
1096
1099
|
}).snapshot;
|
|
1097
1100
|
}
|
|
1098
1101
|
startJobWithDedup(cli, args, correlationId, opts = {}) {
|
|
1099
|
-
const { cwd, idleTimeoutMs, outputFormat, forceRefresh, env: extraEnv, stdin, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, } = opts;
|
|
1100
|
-
const requestKey = this.buildRequestKey(cli, args, extraEnv, stdin, cwd, outputFormat);
|
|
1102
|
+
const { cwd, idleTimeoutMs, outputFormat, forceRefresh, env: extraEnv, stdin, onComplete, flightRecorderEntry, extractUsage, writeFlightStart, compressResponse, } = opts;
|
|
1103
|
+
const requestKey = this.buildRequestKey(cli, args, extraEnv, stdin, cwd, outputFormat, compressResponse);
|
|
1101
1104
|
if (!forceRefresh) {
|
|
1102
1105
|
const reused = this.tryReuseDedupedJob(requestKey, correlationId, cli, onComplete);
|
|
1103
1106
|
if (reused)
|
|
@@ -1130,6 +1133,7 @@ export class AsyncJobManager {
|
|
|
1130
1133
|
exited: false,
|
|
1131
1134
|
metricsRecorded: false,
|
|
1132
1135
|
outputFormat,
|
|
1136
|
+
compressResponse: compressResponse ?? null,
|
|
1133
1137
|
onComplete,
|
|
1134
1138
|
onCompleteFired: false,
|
|
1135
1139
|
outputDirty: false,
|
|
@@ -1178,6 +1182,7 @@ export class AsyncJobManager {
|
|
|
1178
1182
|
cli,
|
|
1179
1183
|
args: [...args],
|
|
1180
1184
|
outputFormat,
|
|
1185
|
+
compressResponse,
|
|
1181
1186
|
startedAt,
|
|
1182
1187
|
pid: null,
|
|
1183
1188
|
ownerPrincipal,
|
|
@@ -1222,6 +1227,7 @@ export class AsyncJobManager {
|
|
|
1222
1227
|
cwd,
|
|
1223
1228
|
stdio: stdin === undefined ? ["ignore", "pipe", "pipe"] : ["pipe", "pipe", "pipe"],
|
|
1224
1229
|
env: { ...baseEnv, ...(extraEnv ?? {}) },
|
|
1230
|
+
logger: this.logger,
|
|
1225
1231
|
});
|
|
1226
1232
|
job.process = child;
|
|
1227
1233
|
try {
|
|
@@ -1531,6 +1537,9 @@ export class AsyncJobManager {
|
|
|
1531
1537
|
getJobOutputFormat(jobId) {
|
|
1532
1538
|
return this.jobs.get(jobId)?.outputFormat;
|
|
1533
1539
|
}
|
|
1540
|
+
getJobCompressResponse(jobId) {
|
|
1541
|
+
return this.jobs.get(jobId)?.compressResponse === true;
|
|
1542
|
+
}
|
|
1534
1543
|
getJobCli(jobId) {
|
|
1535
1544
|
return this.jobs.get(jobId)?.cli;
|
|
1536
1545
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { ContentRoute } from "./router.js";
|
|
2
|
+
export type DevTokenizer = (text: string) => number;
|
|
3
|
+
export declare function setDevTokenizer(tokenizer: DevTokenizer | null): void;
|
|
4
|
+
export declare function estimateTokensForRoute(text: string, route: ContentRoute): number;
|
|
5
|
+
export declare function estimateTokensSaved(originalText: string, compressedText: string, route: ContentRoute): number;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
const DIVISORS = {
|
|
2
|
+
json: 3.0,
|
|
3
|
+
log: 3.4,
|
|
4
|
+
"ansi-text": 3.4,
|
|
5
|
+
plain: 3.6,
|
|
6
|
+
identity: 3.6,
|
|
7
|
+
};
|
|
8
|
+
let devTokenizer = null;
|
|
9
|
+
export function setDevTokenizer(tokenizer) {
|
|
10
|
+
devTokenizer = tokenizer;
|
|
11
|
+
}
|
|
12
|
+
export function estimateTokensForRoute(text, route) {
|
|
13
|
+
if (devTokenizer)
|
|
14
|
+
return devTokenizer(text);
|
|
15
|
+
if (text.length === 0)
|
|
16
|
+
return 0;
|
|
17
|
+
return Math.ceil(text.length / DIVISORS[route]);
|
|
18
|
+
}
|
|
19
|
+
export function estimateTokensSaved(originalText, compressedText, route) {
|
|
20
|
+
return Math.max(0, estimateTokensForRoute(originalText, route) - estimateTokensForRoute(compressedText, route));
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type ContentRoute } from "./router.js";
|
|
2
|
+
export interface CompressCtx {
|
|
3
|
+
provider: string;
|
|
4
|
+
direction: "outbound";
|
|
5
|
+
outputFormat?: string;
|
|
6
|
+
outputSchemaDeclared: boolean;
|
|
7
|
+
lossless: true;
|
|
8
|
+
}
|
|
9
|
+
export interface CompressResult {
|
|
10
|
+
text: string;
|
|
11
|
+
originalChars: number;
|
|
12
|
+
compressedChars: number;
|
|
13
|
+
route: ContentRoute;
|
|
14
|
+
transforms: string[];
|
|
15
|
+
estimatedTokensSaved: number;
|
|
16
|
+
}
|
|
17
|
+
export interface Compressor {
|
|
18
|
+
compact(text: string, ctx: CompressCtx): CompressResult;
|
|
19
|
+
}
|
|
20
|
+
export declare class NativeCompressor implements Compressor {
|
|
21
|
+
compact(text: string, ctx: CompressCtx): CompressResult;
|
|
22
|
+
}
|
|
23
|
+
export declare function compressDisplayText(text: string, ctx: CompressCtx): CompressResult;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { classify } from "./router.js";
|
|
2
|
+
import { minifyJson } from "./transforms/json.js";
|
|
3
|
+
import { dedupAdjacentLines, escapeSentinelLikeLines, noteLine, } from "./transforms/log.js";
|
|
4
|
+
import { stripAnsi } from "./transforms/ansi.js";
|
|
5
|
+
import { normalizeWhitespace } from "./transforms/whitespace.js";
|
|
6
|
+
import { estimateTokensSaved } from "./estimate.js";
|
|
7
|
+
function identityResult(text, route = "identity") {
|
|
8
|
+
return {
|
|
9
|
+
text,
|
|
10
|
+
originalChars: text.length,
|
|
11
|
+
compressedChars: text.length,
|
|
12
|
+
route,
|
|
13
|
+
transforms: [],
|
|
14
|
+
estimatedTokensSaved: 0,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export class NativeCompressor {
|
|
18
|
+
compact(text, ctx) {
|
|
19
|
+
if (ctx.outputFormat === "json" || ctx.outputSchemaDeclared) {
|
|
20
|
+
return identityResult(text);
|
|
21
|
+
}
|
|
22
|
+
const route = classify(text);
|
|
23
|
+
if (route === "identity")
|
|
24
|
+
return identityResult(text);
|
|
25
|
+
if (route === "json") {
|
|
26
|
+
const minified = minifyJson(text.trim());
|
|
27
|
+
if (minified === null || minified.length >= text.length) {
|
|
28
|
+
return identityResult(text);
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
text: minified,
|
|
32
|
+
originalChars: text.length,
|
|
33
|
+
compressedChars: minified.length,
|
|
34
|
+
route,
|
|
35
|
+
transforms: ["json-minify"],
|
|
36
|
+
estimatedTokensSaved: estimateTokensSaved(text, minified, route),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const counts = { folded: 0, escaped: 0 };
|
|
40
|
+
const transforms = [];
|
|
41
|
+
let current = text;
|
|
42
|
+
const apply = (name, fn) => {
|
|
43
|
+
const next = fn(current);
|
|
44
|
+
if (next !== current)
|
|
45
|
+
transforms.push(name);
|
|
46
|
+
current = next;
|
|
47
|
+
};
|
|
48
|
+
apply("lit-escape", input => escapeSentinelLikeLines(input, counts));
|
|
49
|
+
if (route === "ansi-text") {
|
|
50
|
+
apply("ansi-strip", input => stripAnsi(input, counts));
|
|
51
|
+
apply("dedup", input => dedupAdjacentLines(input, counts));
|
|
52
|
+
}
|
|
53
|
+
if (route === "log") {
|
|
54
|
+
apply("dedup", input => dedupAdjacentLines(input, counts));
|
|
55
|
+
}
|
|
56
|
+
apply("whitespace", normalizeWhitespace);
|
|
57
|
+
if (counts.folded + counts.escaped > 0) {
|
|
58
|
+
current = `${noteLine(counts.folded, counts.escaped)}\n${current}`;
|
|
59
|
+
transforms.push("leading-note");
|
|
60
|
+
}
|
|
61
|
+
if (current.length >= text.length) {
|
|
62
|
+
return identityResult(text, route);
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
text: current,
|
|
66
|
+
originalChars: text.length,
|
|
67
|
+
compressedChars: current.length,
|
|
68
|
+
route,
|
|
69
|
+
transforms,
|
|
70
|
+
estimatedTokensSaved: estimateTokensSaved(text, current, route),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const defaultCompressor = new NativeCompressor();
|
|
75
|
+
export function compressDisplayText(text, ctx) {
|
|
76
|
+
return defaultCompressor.compact(text, ctx);
|
|
77
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { hasDangerousSequences } from "./transforms/ansi.js";
|
|
2
|
+
import { minifyJson } from "./transforms/json.js";
|
|
3
|
+
import { splitFences } from "./transforms/whitespace.js";
|
|
4
|
+
import { MIN_RUN } from "./transforms/log.js";
|
|
5
|
+
const HAS_ESCAPE = /\x1b/;
|
|
6
|
+
const HAS_INTERNAL_CR = /\r(?!\n|$)/;
|
|
7
|
+
const HAS_FENCE = /^ {0,3}(?:`{3,}|~{3,})/m;
|
|
8
|
+
function hasFence(text) {
|
|
9
|
+
return HAS_FENCE.test(text);
|
|
10
|
+
}
|
|
11
|
+
function hasRepeatedRuns(text) {
|
|
12
|
+
for (const segment of splitFences(text)) {
|
|
13
|
+
if (segment.fenced)
|
|
14
|
+
continue;
|
|
15
|
+
const lines = segment.text.split("\n");
|
|
16
|
+
let run = 1;
|
|
17
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
18
|
+
if (lines[i] === lines[i - 1] && lines[i].trim() !== "") {
|
|
19
|
+
run += 1;
|
|
20
|
+
if (run >= MIN_RUN)
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
run = 1;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
function isStructuralJson(text) {
|
|
31
|
+
const trimmed = text.trim();
|
|
32
|
+
if (trimmed.length < 2)
|
|
33
|
+
return false;
|
|
34
|
+
const first = trimmed[0];
|
|
35
|
+
const last = trimmed[trimmed.length - 1];
|
|
36
|
+
const structural = (first === "{" && last === "}") || (first === "[" && last === "]");
|
|
37
|
+
if (!structural)
|
|
38
|
+
return false;
|
|
39
|
+
return minifyJson(trimmed) !== null;
|
|
40
|
+
}
|
|
41
|
+
export function classify(text) {
|
|
42
|
+
if (text.length === 0)
|
|
43
|
+
return "identity";
|
|
44
|
+
const escapes = HAS_ESCAPE.test(text);
|
|
45
|
+
const internalCr = HAS_INTERNAL_CR.test(text);
|
|
46
|
+
if (escapes || internalCr) {
|
|
47
|
+
if (hasDangerousSequences(text))
|
|
48
|
+
return "identity";
|
|
49
|
+
if (!hasFence(text))
|
|
50
|
+
return "ansi-text";
|
|
51
|
+
}
|
|
52
|
+
if (isStructuralJson(text))
|
|
53
|
+
return "json";
|
|
54
|
+
if (hasRepeatedRuns(text))
|
|
55
|
+
return "log";
|
|
56
|
+
return "plain";
|
|
57
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { mapUnfenced } from "./whitespace.js";
|
|
2
|
+
import { crMarker } from "./log.js";
|
|
3
|
+
const CSI = /\x1b\[([0-9:;<=>?]*)[ -/]*([@-~])/g;
|
|
4
|
+
const OSC = /\x1b\][\s\S]*?(?:\x07|\x1b\\)/g;
|
|
5
|
+
const DCS_APC_PM_SOS = /\x1b[PX^_][\s\S]*?\x1b\\/g;
|
|
6
|
+
const ESC_TWO_BYTE = /\x1b[@-Z\\-_]/g;
|
|
7
|
+
const STRAY_C0 = /[\x00-\x07\x0b\x0c\x0e-\x1f]/g;
|
|
8
|
+
const DANGEROUS_CSI_FINALS = new Set([
|
|
9
|
+
"A",
|
|
10
|
+
"B",
|
|
11
|
+
"C",
|
|
12
|
+
"D",
|
|
13
|
+
"E",
|
|
14
|
+
"F",
|
|
15
|
+
"G",
|
|
16
|
+
"H",
|
|
17
|
+
"f",
|
|
18
|
+
"S",
|
|
19
|
+
"T",
|
|
20
|
+
"J",
|
|
21
|
+
"s",
|
|
22
|
+
"u",
|
|
23
|
+
]);
|
|
24
|
+
export function hasDangerousSequences(text) {
|
|
25
|
+
if (text.includes("\b"))
|
|
26
|
+
return true;
|
|
27
|
+
CSI.lastIndex = 0;
|
|
28
|
+
let match;
|
|
29
|
+
while ((match = CSI.exec(text)) !== null) {
|
|
30
|
+
const params = match[1];
|
|
31
|
+
const final = match[2];
|
|
32
|
+
if (DANGEROUS_CSI_FINALS.has(final))
|
|
33
|
+
return true;
|
|
34
|
+
if ((final === "h" || final === "l") && params.includes("?")) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
function stripEscapes(text) {
|
|
41
|
+
return text
|
|
42
|
+
.replace(OSC, "")
|
|
43
|
+
.replace(DCS_APC_PM_SOS, "")
|
|
44
|
+
.replace(CSI, "")
|
|
45
|
+
.replace(ESC_TWO_BYTE, "")
|
|
46
|
+
.replace(STRAY_C0, "");
|
|
47
|
+
}
|
|
48
|
+
const ASCII_ONLY = /^[\x09\x0d\x20-\x7e]*$/;
|
|
49
|
+
function overlayFrames(frames) {
|
|
50
|
+
const cells = [];
|
|
51
|
+
for (const frame of frames) {
|
|
52
|
+
for (let i = 0; i < frame.length; i += 1)
|
|
53
|
+
cells[i] = frame[i];
|
|
54
|
+
}
|
|
55
|
+
return cells.join("");
|
|
56
|
+
}
|
|
57
|
+
function collapseLine(line, out, counts) {
|
|
58
|
+
const crlf = line.endsWith("\r");
|
|
59
|
+
const body = crlf ? line.slice(0, -1) : line;
|
|
60
|
+
if (!body.includes("\r")) {
|
|
61
|
+
out.push(line);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (!ASCII_ONLY.test(body)) {
|
|
65
|
+
out.push(line);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const frames = body.split("\r");
|
|
69
|
+
const visible = overlayFrames(frames);
|
|
70
|
+
out.push(crlf ? `${visible}\r` : visible);
|
|
71
|
+
out.push(crMarker(frames.length - 1));
|
|
72
|
+
counts.folded += 1;
|
|
73
|
+
}
|
|
74
|
+
export function stripAnsi(text, counts) {
|
|
75
|
+
if (hasDangerousSequences(text))
|
|
76
|
+
return text;
|
|
77
|
+
return mapUnfenced(text, segment => {
|
|
78
|
+
const stripped = stripEscapes(segment);
|
|
79
|
+
const out = [];
|
|
80
|
+
for (const line of stripped.split("\n")) {
|
|
81
|
+
if (line.includes("`")) {
|
|
82
|
+
out.push(line);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
collapseLine(line, out, counts);
|
|
86
|
+
}
|
|
87
|
+
return out.join("\n");
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function minifyJson(text: string): string | null;
|