@compaction/cli 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. package/README.md +142 -64
  2. package/dist/cli/commands/activity.d.ts +8 -0
  3. package/dist/cli/commands/activity.js +35 -0
  4. package/dist/cli/commands/apply-context.js +68 -0
  5. package/dist/cli/commands/billing-delta.js +12 -1
  6. package/dist/cli/commands/capture-claude-code.d.ts +44 -0
  7. package/dist/cli/commands/capture-claude-code.js +206 -0
  8. package/dist/cli/commands/capture.js +283 -1
  9. package/dist/cli/commands/compact.js +9 -5
  10. package/dist/cli/commands/dev.d.ts +17 -0
  11. package/dist/cli/commands/dev.js +131 -0
  12. package/dist/cli/commands/gateway.d.ts +31 -0
  13. package/dist/cli/commands/gateway.js +312 -0
  14. package/dist/cli/commands/hooks.d.ts +5 -0
  15. package/dist/cli/commands/hooks.js +181 -0
  16. package/dist/cli/commands/init.js +422 -17
  17. package/dist/cli/commands/input-compaction-ab.d.ts +2 -0
  18. package/dist/cli/commands/input-compaction-ab.js +125 -0
  19. package/dist/cli/commands/optimize-hosted.d.ts +54 -0
  20. package/dist/cli/commands/optimize-hosted.js +123 -0
  21. package/dist/cli/commands/optimize.js +8 -0
  22. package/dist/cli/commands/output-shaping-ab.d.ts +2 -0
  23. package/dist/cli/commands/output-shaping-ab.js +132 -0
  24. package/dist/cli/commands/output-shaping.d.ts +7 -0
  25. package/dist/cli/commands/output-shaping.js +43 -0
  26. package/dist/cli/commands/policies.d.ts +2 -0
  27. package/dist/cli/commands/policies.js +80 -0
  28. package/dist/cli/commands/precall.d.ts +19 -0
  29. package/dist/cli/commands/precall.js +436 -0
  30. package/dist/cli/commands/recommend.js +1 -1
  31. package/dist/cli/commands/run.js +297 -12
  32. package/dist/cli/commands/upgrade-status.d.ts +42 -0
  33. package/dist/cli/commands/upgrade-status.js +152 -0
  34. package/dist/cli/cursor-export-read.d.ts +13 -0
  35. package/dist/cli/cursor-export-read.js +53 -0
  36. package/dist/cli/cursor-live-preflight.d.ts +19 -0
  37. package/dist/cli/cursor-live-preflight.js +46 -0
  38. package/dist/cli/index.js +36 -2
  39. package/dist/cli/onboarding/GatewayTui.d.ts +33 -0
  40. package/dist/cli/onboarding/GatewayTui.js +113 -0
  41. package/dist/cli/onboarding/model.d.ts +32 -1
  42. package/dist/cli/onboarding/model.js +27 -2
  43. package/dist/cli/terminal-logo.d.ts +27 -0
  44. package/dist/cli/terminal-logo.js +49 -0
  45. package/dist/core/activity-event.d.ts +107 -0
  46. package/dist/core/activity-event.js +182 -0
  47. package/dist/core/activity-store.d.ts +63 -0
  48. package/dist/core/activity-store.js +254 -0
  49. package/dist/core/activity-view.d.ts +76 -0
  50. package/dist/core/activity-view.js +120 -0
  51. package/dist/core/api-client/index.d.ts +3 -1
  52. package/dist/core/api-client/index.js +4 -0
  53. package/dist/core/api-client/payload.d.ts +5 -1
  54. package/dist/core/api-client/payload.js +8 -6
  55. package/dist/core/api-client/persisted-config.d.ts +57 -0
  56. package/dist/core/api-client/persisted-config.js +156 -0
  57. package/dist/core/api-client/tool.d.ts +29 -0
  58. package/dist/core/api-client/tool.js +50 -0
  59. package/dist/core/api-client/types.d.ts +15 -4
  60. package/dist/core/auto-apply-ask.d.ts +58 -0
  61. package/dist/core/auto-apply-ask.js +105 -0
  62. package/dist/core/auto-apply-gates.d.ts +76 -0
  63. package/dist/core/auto-apply-gates.js +113 -0
  64. package/dist/core/before-call-activity.d.ts +35 -0
  65. package/dist/core/before-call-activity.js +103 -0
  66. package/dist/core/before-call-recovery.d.ts +19 -0
  67. package/dist/core/before-call-recovery.js +46 -0
  68. package/dist/core/before-call-stdin.d.ts +59 -0
  69. package/dist/core/before-call-stdin.js +78 -0
  70. package/dist/core/before-call.d.ts +149 -0
  71. package/dist/core/before-call.js +358 -0
  72. package/dist/core/billing-delta/billing-delta-record.d.ts +28 -0
  73. package/dist/core/billing-delta/billing-delta-record.js +26 -0
  74. package/dist/core/capture-record.d.ts +40 -0
  75. package/dist/core/capture-record.js +55 -0
  76. package/dist/core/claude-code-before-call.d.ts +37 -0
  77. package/dist/core/claude-code-before-call.js +120 -0
  78. package/dist/core/claude-code-connect.d.ts +31 -0
  79. package/dist/core/claude-code-connect.js +87 -0
  80. package/dist/core/claude-code-hook-record.d.ts +71 -0
  81. package/dist/core/claude-code-hook-record.js +0 -0
  82. package/dist/core/claude-code-hooks.d.ts +77 -0
  83. package/dist/core/claude-code-hooks.js +141 -0
  84. package/dist/core/codex-capture.d.ts +45 -0
  85. package/dist/core/codex-capture.js +204 -0
  86. package/dist/core/command-runner.js +5 -1
  87. package/dist/core/compaction-artifacts.d.ts +51 -0
  88. package/dist/core/compaction-artifacts.js +242 -0
  89. package/dist/core/compactor.d.ts +9 -0
  90. package/dist/core/compactor.js +95 -0
  91. package/dist/core/cross-surface-event.d.ts +306 -0
  92. package/dist/core/cross-surface-event.js +330 -0
  93. package/dist/core/cursor-capture.d.ts +54 -0
  94. package/dist/core/cursor-capture.js +215 -0
  95. package/dist/core/cursor-preflight-probe.d.ts +8 -0
  96. package/dist/core/cursor-preflight-probe.js +88 -0
  97. package/dist/core/cursor-preflight.d.ts +86 -0
  98. package/dist/core/cursor-preflight.js +126 -0
  99. package/dist/core/gateway/apply-activation.d.ts +39 -0
  100. package/dist/core/gateway/apply-activation.js +84 -0
  101. package/dist/core/gateway/apply-policy.d.ts +64 -0
  102. package/dist/core/gateway/apply-policy.js +221 -0
  103. package/dist/core/gateway/apply-receipt.d.ts +36 -0
  104. package/dist/core/gateway/apply-receipt.js +75 -0
  105. package/dist/core/gateway/cache-proof.d.ts +45 -0
  106. package/dist/core/gateway/cache-proof.js +65 -0
  107. package/dist/core/gateway/configure.d.ts +50 -0
  108. package/dist/core/gateway/configure.js +169 -0
  109. package/dist/core/gateway/openai-usage.d.ts +56 -0
  110. package/dist/core/gateway/openai-usage.js +128 -0
  111. package/dist/core/gateway/receipt.d.ts +138 -0
  112. package/dist/core/gateway/receipt.js +120 -0
  113. package/dist/core/gateway/recovery.d.ts +23 -0
  114. package/dist/core/gateway/recovery.js +68 -0
  115. package/dist/core/gateway/server.d.ts +51 -0
  116. package/dist/core/gateway/server.js +276 -0
  117. package/dist/core/gateway/status.d.ts +45 -0
  118. package/dist/core/gateway/status.js +109 -0
  119. package/dist/core/hook-usage-aggregate.d.ts +47 -0
  120. package/dist/core/hook-usage-aggregate.js +161 -0
  121. package/dist/core/input-compaction-ab.d.ts +111 -0
  122. package/dist/core/input-compaction-ab.js +158 -0
  123. package/dist/core/local-run-record.d.ts +109 -0
  124. package/dist/core/local-run-record.js +223 -0
  125. package/dist/core/output-shaping-ab.d.ts +140 -0
  126. package/dist/core/output-shaping-ab.js +146 -0
  127. package/dist/core/output-shaping-attach.d.ts +31 -0
  128. package/dist/core/output-shaping-attach.js +57 -0
  129. package/dist/core/output-shaping.d.ts +56 -0
  130. package/dist/core/output-shaping.js +89 -0
  131. package/dist/core/policy-middleware.d.ts +121 -0
  132. package/dist/core/policy-middleware.js +919 -0
  133. package/dist/core/policy-preferences.d.ts +99 -0
  134. package/dist/core/policy-preferences.js +232 -0
  135. package/dist/core/run-aggregator.d.ts +11 -1
  136. package/dist/core/run-aggregator.js +29 -2
  137. package/dist/core/run-flow-report.d.ts +82 -0
  138. package/dist/core/run-flow-report.js +71 -0
  139. package/dist/core/safety-report.js +8 -1
  140. package/dist/core/shim-capture-bridge.d.ts +32 -0
  141. package/dist/core/shim-capture-bridge.js +88 -0
  142. package/dist/core/skill-injection-policy.d.ts +72 -0
  143. package/dist/core/skill-injection-policy.js +183 -0
  144. package/dist/core/spend-attribution.js +1 -1
  145. package/dist/core/token-accounting.d.ts +1 -1
  146. package/dist/core/tool-shim.d.ts +129 -0
  147. package/dist/core/tool-shim.js +447 -0
  148. package/dist/core/trace-parser.d.ts +13 -13
  149. package/dist/core/trace-parser.js +6 -6
  150. package/dist/core/types.d.ts +12 -2
  151. package/dist/core/waste-detector.d.ts +20 -0
  152. package/dist/core/waste-detector.js +160 -6
  153. package/package.json +1 -1
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Compaction Gateway APPLY recovery store (PUBLIC CLI/SDK core — engine-free). Charter run-17.2.
3
+ *
4
+ * When apply mode changes a request body, the ORIGINAL body is retained LOCALLY here so the exact
5
+ * pre-mutation request is always recoverable. This is the ONE place gateway request content is persisted
6
+ * — deliberately, for recovery — and it is:
7
+ * - local-only under `<cwd>/.compaction/gateway/recovery/` (gitignored; NEVER uploaded),
8
+ * - written with restrictive `0600` permissions where the OS supports it,
9
+ * - referenced from receipts by `recovery_id` ONLY (the content-free receipt never carries the body).
10
+ *
11
+ * `compaction gateway recover <recovery_id>` reads it back. Nothing here ever leaves the machine.
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
15
+ import path from "node:path";
16
+ export const GATEWAY_RECOVERY_DIR = ".compaction/gateway/recovery";
17
+ function recoveryDir(cwd) {
18
+ return path.join(cwd, GATEWAY_RECOVERY_DIR);
19
+ }
20
+ function recoveryPath(cwd, recoveryId) {
21
+ return path.join(recoveryDir(cwd), `${recoveryId}.json`);
22
+ }
23
+ /**
24
+ * Persist the original request body for recovery and return a fresh `recovery_id`. Best-effort restrictive
25
+ * permissions (0700 dir, 0600 file). Synchronous + fail-safe: throws only if the write genuinely fails
26
+ * (the caller treats a failure as "do NOT mutate" — apply must never proceed without a saved original).
27
+ */
28
+ export function saveOriginalForRecovery(cwd, params) {
29
+ const recoveryId = (params.id ?? (() => randomUUID()))();
30
+ const dir = recoveryDir(cwd);
31
+ mkdirSync(dir, { recursive: true });
32
+ try {
33
+ chmodSync(dir, 0o700);
34
+ }
35
+ catch {
36
+ /* best-effort — some filesystems (e.g. Windows) ignore POSIX modes */
37
+ }
38
+ const record = {
39
+ recovery_id: recoveryId,
40
+ captured_at: (params.now ?? (() => new Date().toISOString()))(),
41
+ endpoint: params.endpoint,
42
+ policy: params.policy,
43
+ original_body: params.originalBody
44
+ };
45
+ const file = recoveryPath(cwd, recoveryId);
46
+ writeFileSync(file, `${JSON.stringify(record, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
47
+ try {
48
+ chmodSync(file, 0o600);
49
+ }
50
+ catch {
51
+ /* best-effort */
52
+ }
53
+ return recoveryId;
54
+ }
55
+ /** Read a recovery record back by id, or null when it does not exist / cannot be parsed. */
56
+ export function readRecovery(cwd, recoveryId) {
57
+ const file = recoveryPath(cwd, recoveryId);
58
+ if (!existsSync(file))
59
+ return null;
60
+ try {
61
+ const rec = JSON.parse(readFileSync(file, "utf8"));
62
+ return rec && typeof rec.original_body === "string" ? rec : null;
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ //# sourceMappingURL=recovery.js.map
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Compaction Gateway server — RECORD MODE (PUBLIC CLI/SDK code — engine-free). Charter run-15.
3
+ *
4
+ * A LOCAL, byte-safe, OpenAI-compatible reverse proxy: a developer points their OpenAI client's base URL
5
+ * at this gateway; the gateway forwards the request to the real provider BYTE-FOR-BYTE, streams the
6
+ * response back BYTE-FOR-BYTE, and records ONE content-free receipt (`receipt.ts`) with the provider's
7
+ * reported token/cache usage. It NEVER mutates the request or the response (model-visible bytes
8
+ * unchanged), NEVER stores content, and NEVER persists the client's API key.
9
+ *
10
+ * Run-15 implements ONLY `record` mode (no cache-optimization, no apply, no LCM). Built on `node:http`
11
+ * (the repo's server style — see `apps/api/src/server.ts`); zero new dependency.
12
+ */
13
+ import http from "node:http";
14
+ import type { GatewayReceipt } from "./receipt.js";
15
+ /** The server's configured gateway mode. Default `record`; `apply`/`dry-run` require explicit opt-in. */
16
+ export type GatewayServerMode = "record" | "apply" | "dry-run";
17
+ export interface GatewayServerOptions {
18
+ provider: string;
19
+ /** The upstream provider base (only its ORIGIN is used; the client's request path is authoritative). */
20
+ upstream: string;
21
+ /** `record` (default, byte-safe), or the explicit `apply`/`dry-run` deterministic modes (run-17.2). */
22
+ mode: GatewayServerMode;
23
+ /** Deterministic apply policy (run-17.2). Only `deterministic-dedupe` is implemented. */
24
+ policy?: string;
25
+ /** Where receipts are written (defaults to process.cwd()). */
26
+ cwd?: string;
27
+ /** Test/observability hook fired after each receipt is built (before/independent of the file append). */
28
+ onReceipt?: (receipt: GatewayReceipt) => void;
29
+ /** Best-effort logger for content-free operational lines (defaults to no-op). */
30
+ log?: (line: string) => void;
31
+ }
32
+ /**
33
+ * Create (but do not start) the gateway record-mode server. `createGatewayServer` is exported so tests
34
+ * can bind an ephemeral port. Every request is forwarded byte-for-byte; the receipt append is
35
+ * best-effort and NEVER alters or delays the client's response bytes.
36
+ */
37
+ export declare function createGatewayServer(options: GatewayServerOptions): http.Server;
38
+ export interface StartedGateway {
39
+ server: http.Server;
40
+ /** The bound address (host + port). */
41
+ address: {
42
+ host: string;
43
+ port: number;
44
+ };
45
+ close: () => Promise<void>;
46
+ }
47
+ /** Start the gateway on `host:port`. Resolves once it is listening. */
48
+ export declare function startGatewayServer(options: GatewayServerOptions & {
49
+ host: string;
50
+ port: number;
51
+ }): Promise<StartedGateway>;
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Compaction Gateway server — RECORD MODE (PUBLIC CLI/SDK code — engine-free). Charter run-15.
3
+ *
4
+ * A LOCAL, byte-safe, OpenAI-compatible reverse proxy: a developer points their OpenAI client's base URL
5
+ * at this gateway; the gateway forwards the request to the real provider BYTE-FOR-BYTE, streams the
6
+ * response back BYTE-FOR-BYTE, and records ONE content-free receipt (`receipt.ts`) with the provider's
7
+ * reported token/cache usage. It NEVER mutates the request or the response (model-visible bytes
8
+ * unchanged), NEVER stores content, and NEVER persists the client's API key.
9
+ *
10
+ * Run-15 implements ONLY `record` mode (no cache-optimization, no apply, no LCM). Built on `node:http`
11
+ * (the repo's server style — see `apps/api/src/server.ts`); zero new dependency.
12
+ */
13
+ import http from "node:http";
14
+ import https from "node:https";
15
+ import { URL } from "node:url";
16
+ import { buildGatewayReceipt, appendGatewayReceipt, formatFreshBilledInputReduction } from "./receipt.js";
17
+ import { usageFromResponseBody } from "./openai-usage.js";
18
+ import { resolveApplyActivation } from "./apply-activation.js";
19
+ import { planDeterministicDedupe } from "./apply-policy.js";
20
+ import { saveOriginalForRecovery } from "./recovery.js";
21
+ import { buildApplyReceipt } from "./apply-receipt.js";
22
+ /** How much of the RESPONSE tail to keep for usage parsing (bounded — never the whole body). */
23
+ const USAGE_TAIL_BYTES = 64 * 1024;
24
+ /** Cap the buffered REQUEST body (forwarded verbatim). Large enough for any normal chat request. */
25
+ const MAX_REQUEST_BYTES = 25 * 1024 * 1024;
26
+ /** Hop-by-hop headers that must NOT be blindly forwarded (node manages the connection). */
27
+ const HOP_BY_HOP = new Set(["connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailer", "transfer-encoding", "upgrade"]);
28
+ /** Best-effort, content-free extraction of the `model` field from a request body (metadata, not content). */
29
+ function requestModel(body) {
30
+ const text = body.toString("utf8").trim();
31
+ if (!text.startsWith("{"))
32
+ return undefined;
33
+ try {
34
+ const obj = JSON.parse(text);
35
+ return typeof obj.model === "string" ? obj.model : undefined;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ /** Read the full request body into a Buffer (byte-exact, for verbatim forwarding). */
42
+ function readRequestBody(req) {
43
+ return new Promise((resolve, reject) => {
44
+ const chunks = [];
45
+ let size = 0;
46
+ req.on("data", (c) => {
47
+ size += c.length;
48
+ if (size > MAX_REQUEST_BYTES) {
49
+ reject(new Error("request body exceeds gateway limit"));
50
+ req.destroy();
51
+ return;
52
+ }
53
+ chunks.push(c);
54
+ });
55
+ req.on("end", () => resolve(Buffer.concat(chunks)));
56
+ req.on("error", reject);
57
+ });
58
+ }
59
+ /**
60
+ * Create (but do not start) the gateway record-mode server. `createGatewayServer` is exported so tests
61
+ * can bind an ephemeral port. Every request is forwarded byte-for-byte; the receipt append is
62
+ * best-effort and NEVER alters or delays the client's response bytes.
63
+ */
64
+ export function createGatewayServer(options) {
65
+ if (options.mode !== "record" && options.mode !== "apply" && options.mode !== "dry-run") {
66
+ // The server refuses to pretend to support a mode it does not implement.
67
+ throw new Error(`gateway mode '${options.mode}' is not implemented (record | apply | dry-run)`);
68
+ }
69
+ const log = options.log ?? (() => { });
70
+ const upstreamOrigin = new URL(options.upstream).origin;
71
+ return http.createServer((req, res) => {
72
+ void handleProxy(req, res, options, upstreamOrigin, log);
73
+ });
74
+ }
75
+ async function handleProxy(req, res, options, upstreamOrigin, log) {
76
+ const endpoint = (req.url ?? "/").split("?")[0];
77
+ // Optional CLIENT-SET proof-run grouping id — READ ONLY (an opaque label, not content). The gateway
78
+ // never injects/mutates the request; this header (when the client sends it) is forwarded to the
79
+ // upstream unchanged AND recorded on the receipt so a manual proof run can pair its receipts.
80
+ const proofHeader = req.headers["x-compaction-proof-run"];
81
+ const proofRunId = typeof proofHeader === "string" && proofHeader.trim() !== "" ? proofHeader : undefined;
82
+ let requestBody;
83
+ try {
84
+ requestBody = await readRequestBody(req);
85
+ }
86
+ catch {
87
+ if (!res.headersSent)
88
+ res.writeHead(413, { "content-type": "application/json" });
89
+ res.end(JSON.stringify({ error: { message: "compaction gateway: request body too large", type: "gateway_error" } }));
90
+ return;
91
+ }
92
+ // Resolve EXPLICIT apply activation (default record → byte-safe passthrough, exactly as before). Apply
93
+ // requires the server's `--mode apply --policy …` OR the request's x-compaction-mode/-policy headers;
94
+ // a conflict fails closed (record). The ORIGINAL request bytes are the default forwarded body.
95
+ const activation = resolveApplyActivation({
96
+ serverMode: options.mode,
97
+ ...(options.policy ? { serverPolicy: options.policy } : {}),
98
+ ...(headerValue(req.headers["x-compaction-mode"]) ? { headerMode: headerValue(req.headers["x-compaction-mode"]) } : {}),
99
+ ...(headerValue(req.headers["x-compaction-policy"]) ? { headerPolicy: headerValue(req.headers["x-compaction-policy"]) } : {})
100
+ });
101
+ let bodyToForward = requestBody;
102
+ const apply = resolveApplyOutcome(activation, endpoint, req.method, requestBody, options, log);
103
+ if (apply?.applied && apply.mutatedBody)
104
+ bodyToForward = apply.mutatedBody;
105
+ const target = new URL((req.url ?? "/"), upstreamOrigin);
106
+ const client = target.protocol === "http:" ? http : https;
107
+ // Forward headers verbatim EXCEPT host (must be the upstream host), content-length (recomputed from the
108
+ // forwarded body), hop-by-hop headers, and Compaction's own control headers (mode/policy are local-only
109
+ // and are NEVER sent upstream). The client's Authorization rides through untouched and is NEVER read,
110
+ // stored, or logged by the gateway.
111
+ const headers = {};
112
+ for (const [k, v] of Object.entries(req.headers)) {
113
+ if (v === undefined)
114
+ continue;
115
+ const key = k.toLowerCase();
116
+ if (key === "host" || key === "content-length" || key === "x-compaction-mode" || key === "x-compaction-policy" || HOP_BY_HOP.has(key))
117
+ continue;
118
+ headers[k] = v;
119
+ }
120
+ headers["host"] = target.host;
121
+ if (bodyToForward.length > 0)
122
+ headers["content-length"] = String(bodyToForward.length);
123
+ const upstreamReq = client.request({ protocol: target.protocol, hostname: target.hostname, port: target.port, path: target.pathname + target.search, method: req.method, headers }, (upstreamRes) => {
124
+ // Forward status + headers VERBATIM, then stream the body byte-for-byte to the client.
125
+ res.writeHead(upstreamRes.statusCode ?? 502, upstreamRes.headers);
126
+ upstreamRes.pipe(res); // byte-safe: the client sees the exact upstream bytes (streaming or not)
127
+ // Tee a bounded TAIL of the response for usage parsing (usage rides in the final JSON/SSE chunk).
128
+ // This is a READ-ONLY copy — it never touches the bytes the client receives.
129
+ const tail = [];
130
+ let tailBytes = 0;
131
+ upstreamRes.on("data", (c) => {
132
+ tail.push(c);
133
+ tailBytes += c.length;
134
+ while (tailBytes > USAGE_TAIL_BYTES && tail.length > 1) {
135
+ tailBytes -= tail[0].length;
136
+ tail.shift();
137
+ }
138
+ });
139
+ upstreamRes.on("end", () => {
140
+ const shared = {
141
+ endpoint,
142
+ upstreamStatus: upstreamRes.statusCode ?? 0,
143
+ responseTail: Buffer.concat(tail).toString("utf8"),
144
+ requestModel: requestModel(requestBody), // content-free model label from the ORIGINAL body
145
+ ...(proofRunId ? { proofRunId } : {})
146
+ };
147
+ if (activation.requested && apply) {
148
+ void recordApplyReceiptFor({ options, activation, apply, log, ...shared });
149
+ }
150
+ else {
151
+ void recordReceipt({ options, log, ...shared });
152
+ }
153
+ });
154
+ });
155
+ upstreamReq.on("error", (err) => {
156
+ // The gateway itself failed to reach upstream — an honest gateway error (never a faked success).
157
+ log(`compaction gateway: upstream error for ${endpoint}: ${err.message}`);
158
+ if (!res.headersSent) {
159
+ res.writeHead(502, { "content-type": "application/json" });
160
+ res.end(JSON.stringify({ error: { message: `compaction gateway: upstream request failed (${err.message})`, type: "gateway_error" } }));
161
+ }
162
+ else {
163
+ res.end();
164
+ }
165
+ });
166
+ if (bodyToForward.length > 0)
167
+ upstreamReq.write(bodyToForward);
168
+ upstreamReq.end();
169
+ }
170
+ /** First string value of a (possibly array) header, trimmed to non-empty, else undefined. */
171
+ function headerValue(v) {
172
+ const s = Array.isArray(v) ? v[0] : v;
173
+ return typeof s === "string" && s.trim() !== "" ? s : undefined;
174
+ }
175
+ /**
176
+ * Decide what apply does for this request. NEVER mutates unless: apply mode is active, the shape is
177
+ * supported, a safe duplicate exists, AND the original was successfully retained for recovery FIRST.
178
+ * dry-run always forwards the original. Any uncertainty → fail closed (forward original, record the reason).
179
+ */
180
+ function resolveApplyOutcome(activation, endpoint, method, originalBody, options, log) {
181
+ if (!activation.requested)
182
+ return null;
183
+ if (activation.failClosedReason)
184
+ return { applied: false, failClosedReason: activation.failClosedReason };
185
+ if (method !== "POST")
186
+ return { applied: false, failClosedReason: `apply supports only POST requests; got ${method ?? "unknown"} — forwarded unchanged` };
187
+ const plan = planDeterministicDedupe(endpoint, originalBody.toString("utf8"));
188
+ if (!plan.supported)
189
+ return { plan, applied: false, ...(plan.failClosedReason ? { failClosedReason: plan.failClosedReason } : {}) };
190
+ if (activation.mode === "dry-run")
191
+ return { plan, applied: false }; // preview only — original forwarded
192
+ if (!plan.changed || !plan.mutatedBody)
193
+ return { plan, applied: false }; // no safe duplicate → forward original
194
+ // APPLY: retain the exact original locally FIRST; only then forward the mutated body. If retention
195
+ // fails we fail closed (never mutate without a recoverable original).
196
+ try {
197
+ const recoveryId = saveOriginalForRecovery(options.cwd ?? process.cwd(), {
198
+ endpoint,
199
+ policy: activation.policy ?? "deterministic-dedupe",
200
+ originalBody: originalBody.toString("utf8")
201
+ });
202
+ return { plan, applied: true, mutatedBody: Buffer.from(plan.mutatedBody, "utf8"), recoveryId };
203
+ }
204
+ catch (e) {
205
+ log(`compaction gateway: apply retention failed — forwarding original unchanged (${e.message})`);
206
+ return { plan, applied: false, failClosedReason: `could not retain the original for recovery — forwarded unchanged (${e.message})` };
207
+ }
208
+ }
209
+ /** Build + append the content-free APPLY/dry-run receipt. Best-effort: never affects the client. */
210
+ async function recordApplyReceiptFor(params) {
211
+ try {
212
+ const usage = usageFromResponseBody(params.responseTail);
213
+ const receipt = buildApplyReceipt({
214
+ provider: params.options.provider,
215
+ endpoint: params.endpoint,
216
+ upstreamStatus: params.upstreamStatus,
217
+ usage,
218
+ activation: params.activation,
219
+ applied: params.apply.applied,
220
+ ...(params.apply.plan ? { plan: params.apply.plan } : {}),
221
+ ...(params.apply.recoveryId ? { recoveryId: params.apply.recoveryId } : {}),
222
+ ...(params.apply.failClosedReason ? { failClosedReason: params.apply.failClosedReason } : {}),
223
+ ...(params.requestModel ? { requestModel: params.requestModel } : {}),
224
+ ...(params.proofRunId ? { proofRunId: params.proofRunId } : {})
225
+ });
226
+ params.options.onReceipt?.(receipt);
227
+ await appendGatewayReceipt(receipt, params.options.cwd ?? process.cwd());
228
+ params.log(`compaction gateway: apply receipt ${receipt.receipt_id.slice(0, 8)}… policy=${receipt.policy} ` +
229
+ `request_mutated=${receipt.request_mutated} ${receipt.recovery_id ? `recovery=${receipt.recovery_id.slice(0, 8)}… ` : ""}` +
230
+ `${receipt.fail_closed_reason ? `(fail-closed: ${receipt.fail_closed_reason}) ` : ""}— response unchanged; content-free.`);
231
+ }
232
+ catch {
233
+ // Never let receipt recording break the proxy; the client's response already completed.
234
+ }
235
+ }
236
+ /** Build + append the content-free receipt. Best-effort: a failure here never affects the client. */
237
+ async function recordReceipt(params) {
238
+ try {
239
+ const usage = usageFromResponseBody(params.responseTail);
240
+ const receipt = buildGatewayReceipt({
241
+ provider: params.options.provider,
242
+ endpoint: params.endpoint,
243
+ mode: "record",
244
+ upstreamStatus: params.upstreamStatus,
245
+ usage,
246
+ ...(params.requestModel ? { requestModel: params.requestModel } : {}),
247
+ ...(params.proofRunId ? { proofRunId: params.proofRunId } : {})
248
+ });
249
+ params.options.onReceipt?.(receipt);
250
+ await appendGatewayReceipt(receipt, params.options.cwd ?? process.cwd());
251
+ const t = receipt.tokens;
252
+ params.log(`compaction gateway: receipt ${receipt.receipt_id.slice(0, 8)}… (${receipt.token_source}) ` +
253
+ `input=${t.prompt_input ?? "-"} cached=${t.cached_input ?? "-"} billed=${t.billed_fresh_input ?? "-"} output=${t.output ?? "-"} ` +
254
+ `· ${formatFreshBilledInputReduction(receipt.fresh_billed_input_reduction)} — content-free, no mutation (model-visible bytes unchanged).`);
255
+ }
256
+ catch {
257
+ // Never let receipt recording break the proxy; the client's response already completed.
258
+ }
259
+ }
260
+ /** Start the gateway on `host:port`. Resolves once it is listening. */
261
+ export function startGatewayServer(options) {
262
+ const server = createGatewayServer(options);
263
+ return new Promise((resolve, reject) => {
264
+ server.once("error", reject);
265
+ server.listen(options.port, options.host, () => {
266
+ const addr = server.address();
267
+ const port = typeof addr === "object" && addr ? addr.port : options.port;
268
+ resolve({
269
+ server,
270
+ address: { host: options.host, port },
271
+ close: () => new Promise((r) => server.close(() => r()))
272
+ });
273
+ });
274
+ });
275
+ }
276
+ //# sourceMappingURL=server.js.map
@@ -0,0 +1,45 @@
1
+ import { type GatewayReceipt } from "./receipt.js";
2
+ import { type CacheProofSummary } from "./cache-proof.js";
3
+ export declare const GATEWAY_PID_FILE = "gateway.json";
4
+ /** Content-free record of a running gateway (no request/response content). */
5
+ export interface GatewayPidRecord {
6
+ pid: number;
7
+ host: string;
8
+ port: number;
9
+ upstream: string;
10
+ provider: string;
11
+ mode: string;
12
+ startedAt: string;
13
+ }
14
+ export declare function writeGatewayPid(rec: GatewayPidRecord, cwd?: string): void;
15
+ export declare function readGatewayPid(cwd?: string): GatewayPidRecord | null;
16
+ export declare function removeGatewayPid(cwd?: string): void;
17
+ /** True if a process with `pid` is alive (signal 0). EPERM means it exists but is owned by another user. */
18
+ export declare function isProcessAlive(pid: number): boolean;
19
+ /** Probe whether something is listening on host:port (TCP connect, short timeout). Never throws. */
20
+ export declare function probeListening(host: string, port: number, timeoutMs?: number): Promise<boolean>;
21
+ /** Read the local-only content-free receipts (each line is a GatewayReceipt). */
22
+ export declare function readReceipts(cwd?: string): GatewayReceipt[];
23
+ export interface GatewayStatus {
24
+ running: boolean;
25
+ pid?: number;
26
+ base?: string;
27
+ provider?: string;
28
+ mode?: string;
29
+ /** Total local receipts (= requests observed through the gateway in this project). */
30
+ receiptsCount: number;
31
+ /** ISO timestamp of the most recent request observed, when any. */
32
+ lastRequestAt?: string;
33
+ /** True when ANY observed request reported provider cached input tokens. */
34
+ cachedObserved: boolean;
35
+ /** When a `since` is given: were any requests observed at/after it (this-session traffic)? */
36
+ observedSince?: boolean;
37
+ /** Content-free summary over the local receipts (best provider-backed fresh/billed input reduction). */
38
+ summary: CacheProofSummary;
39
+ }
40
+ /**
41
+ * Resolve the current gateway status: running (pidfile present + process alive + port listening),
42
+ * plus a content-free rollup of the local receipts. When `since` (ISO) is given, `observedSince`
43
+ * reports whether any request was observed at/after it (for "traffic observed this session"). Never throws.
44
+ */
45
+ export declare function getGatewayStatus(cwd?: string, since?: string): Promise<GatewayStatus>;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Gateway lifecycle status + local pidfile (PUBLIC CLI/SDK code — engine-free). Charter run-17.
3
+ *
4
+ * Supports `compaction gateway status|stop` and the guided onboarding. CONTENT-FREE: the pidfile holds
5
+ * only the process id + listen/upstream metadata (host/port/provider/mode/timestamps) — never any
6
+ * request/response content; the receipts it reads are already content-free. Local-only under
7
+ * `<cwd>/.compaction/gateway/` (gitignored).
8
+ */
9
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
10
+ import net from "node:net";
11
+ import path from "node:path";
12
+ import { DEFAULT_GATEWAY_RECEIPTS_DIR, GATEWAY_RECEIPTS_FILE } from "./receipt.js";
13
+ import { summarizeCacheProof } from "./cache-proof.js";
14
+ export const GATEWAY_PID_FILE = "gateway.json";
15
+ function pidPath(cwd) {
16
+ return path.join(cwd, DEFAULT_GATEWAY_RECEIPTS_DIR, GATEWAY_PID_FILE);
17
+ }
18
+ export function writeGatewayPid(rec, cwd = process.cwd()) {
19
+ mkdirSync(path.join(cwd, DEFAULT_GATEWAY_RECEIPTS_DIR), { recursive: true });
20
+ writeFileSync(pidPath(cwd), `${JSON.stringify(rec, null, 2)}\n`, "utf8");
21
+ }
22
+ export function readGatewayPid(cwd = process.cwd()) {
23
+ try {
24
+ const r = JSON.parse(readFileSync(pidPath(cwd), "utf8"));
25
+ return r && typeof r.pid === "number" ? r : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export function removeGatewayPid(cwd = process.cwd()) {
32
+ try {
33
+ rmSync(pidPath(cwd), { force: true });
34
+ }
35
+ catch {
36
+ /* best-effort */
37
+ }
38
+ }
39
+ /** True if a process with `pid` is alive (signal 0). EPERM means it exists but is owned by another user. */
40
+ export function isProcessAlive(pid) {
41
+ try {
42
+ process.kill(pid, 0);
43
+ return true;
44
+ }
45
+ catch (e) {
46
+ return e.code === "EPERM";
47
+ }
48
+ }
49
+ /** Probe whether something is listening on host:port (TCP connect, short timeout). Never throws. */
50
+ export function probeListening(host, port, timeoutMs = 400) {
51
+ return new Promise((resolve) => {
52
+ const sock = net.connect({ host, port });
53
+ let done = false;
54
+ const finish = (v) => {
55
+ if (done)
56
+ return;
57
+ done = true;
58
+ sock.destroy();
59
+ resolve(v);
60
+ };
61
+ sock.setTimeout(timeoutMs);
62
+ sock.on("connect", () => finish(true));
63
+ sock.on("timeout", () => finish(false));
64
+ sock.on("error", () => finish(false));
65
+ });
66
+ }
67
+ /** Read the local-only content-free receipts (each line is a GatewayReceipt). */
68
+ export function readReceipts(cwd = process.cwd()) {
69
+ const p = path.join(cwd, DEFAULT_GATEWAY_RECEIPTS_DIR, GATEWAY_RECEIPTS_FILE);
70
+ if (!existsSync(p))
71
+ return [];
72
+ return readFileSync(p, "utf8")
73
+ .trim()
74
+ .split("\n")
75
+ .filter(Boolean)
76
+ .map((l) => JSON.parse(l));
77
+ }
78
+ /**
79
+ * Resolve the current gateway status: running (pidfile present + process alive + port listening),
80
+ * plus a content-free rollup of the local receipts. When `since` (ISO) is given, `observedSince`
81
+ * reports whether any request was observed at/after it (for "traffic observed this session"). Never throws.
82
+ */
83
+ export async function getGatewayStatus(cwd = process.cwd(), since) {
84
+ const rec = readGatewayPid(cwd);
85
+ let running = false;
86
+ let base;
87
+ if (rec) {
88
+ const alive = isProcessAlive(rec.pid);
89
+ const listening = await probeListening(rec.host, rec.port);
90
+ running = alive && listening;
91
+ base = `http://${rec.host}:${rec.port}`;
92
+ }
93
+ const receipts = readReceipts(cwd);
94
+ const times = receipts.map((r) => r.captured_at).filter((t) => typeof t === "string").sort();
95
+ const lastRequestAt = times.length > 0 ? times[times.length - 1] : undefined;
96
+ const cachedObserved = receipts.some((r) => typeof r.tokens?.cached_input === "number" && r.tokens.cached_input > 0);
97
+ const observedSince = since ? receipts.some((r) => typeof r.captured_at === "string" && r.captured_at >= since) : undefined;
98
+ return {
99
+ running,
100
+ ...(rec ? { pid: rec.pid, provider: rec.provider, mode: rec.mode } : {}),
101
+ ...(base ? { base } : {}),
102
+ receiptsCount: receipts.length,
103
+ ...(lastRequestAt ? { lastRequestAt } : {}),
104
+ cachedObserved,
105
+ ...(observedSince !== undefined ? { observedSince } : {}),
106
+ summary: summarizeCacheProof(receipts)
107
+ };
108
+ }
109
+ //# sourceMappingURL=status.js.map
@@ -0,0 +1,47 @@
1
+ import { type ClaudeCodeHookRecord } from "./claude-code-hook-record.js";
2
+ export declare const HOOK_USAGE_AGGREGATE_SCHEMA: "compaction.hook-usage-aggregate.v1";
3
+ /** Load + validate all hook usage records under baseDir (default `.compaction/hooks`). Invalid files skipped. */
4
+ export declare function loadHookUsageRecords(baseDir?: string): Promise<ClaudeCodeHookRecord[]>;
5
+ export interface HookUsageToolAggregate {
6
+ tool: string;
7
+ /** Deduped record count for this tool. */
8
+ events: number;
9
+ /** Sum of input tokens across records that HAVE input; null when none do (never 0-for-missing). */
10
+ inputTokens: number | null;
11
+ outputTokens: number | null;
12
+ /** Reasoning tokens are not separately reported by Claude Code session usage → always null here. */
13
+ reasoningTokens: number | null;
14
+ /** Distinct honest token sources, preserved exactly (e.g. ["provider-reported"]). */
15
+ tokenSources: string[];
16
+ providers: string[];
17
+ models: string[];
18
+ /** Records whose output tokens are present. */
19
+ outputRecorded: number;
20
+ /** Records flagged provider-reported. */
21
+ providerReportedEvents: number;
22
+ /** Records with NO input and NO output token data (usage unavailable). */
23
+ unavailableEvents: number;
24
+ /** Distinct session ids seen. */
25
+ sessions: number;
26
+ firstRecordedAt: string | null;
27
+ lastRecordedAt: string | null;
28
+ }
29
+ export interface HookUsageAggregate {
30
+ schema: typeof HOOK_USAGE_AGGREGATE_SCHEMA;
31
+ /** Records loaded before dedup. */
32
+ totalRecords: number;
33
+ /** Records after dedup by dedupKey. */
34
+ dedupedRecords: number;
35
+ /** Optional ISO lower bound applied to recordedAt. */
36
+ since: string | null;
37
+ tools: HookUsageToolAggregate[];
38
+ }
39
+ export interface AggregateOptions {
40
+ /** Only include records with recordedAt >= this ISO timestamp (time-window filter). */
41
+ since?: string;
42
+ }
43
+ /**
44
+ * Aggregate hook records into a content-free source-status view. Dedups by `dedupKey`. Missing token
45
+ * counts stay null. Token source labels preserved exactly. No savings, no content.
46
+ */
47
+ export declare function aggregateHookUsageRecords(records: ClaudeCodeHookRecord[], options?: AggregateOptions): HookUsageAggregate;