rippletide-package 0.1.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/PUBLISHING.md +144 -0
- package/README.md +44 -0
- package/bin/rippletide.js +27 -0
- package/counter/README.md +37 -0
- package/counter/frontend/window.jxa +601 -0
- package/counter/src/cli.js +22 -0
- package/counter/src/counter.js +165 -0
- package/counter/src/proxy.js +243 -0
- package/package.json +64 -0
- package/reviewer/.env.example +5 -0
- package/reviewer/README.md +137 -0
- package/reviewer/bin/rippletide-review.js +16 -0
- package/reviewer/bin/rippletide.js +8 -0
- package/reviewer/codex/rippletide-reviewer/README.md +28 -0
- package/reviewer/codex/rippletide-reviewer/scripts/rippletide-codex +16 -0
- package/reviewer/codex/rippletide-reviewer/scripts/rippletide-codex.mjs +24 -0
- package/reviewer/codex/rippletide-reviewer/scripts/rippletide-reviewer +37 -0
- package/reviewer/src/connect/evidence.js +235 -0
- package/reviewer/src/integrations/cli/rippletide.js +1595 -0
- package/reviewer/src/integrations/codex/prepare.js +277 -0
- package/tide/README.md +128 -0
- package/tide/bin/tide.js +4 -0
- package/tide/examples/no-file-writes/POLICY.md +3 -0
- package/tide/src/cli.js +173 -0
- package/tide/src/codex.js +65 -0
- package/tide/src/compile.js +193 -0
- package/tide/src/credentials.js +41 -0
- package/tide/src/engine.js +115 -0
- package/tide/src/expr.js +260 -0
- package/tide/src/hook.js +71 -0
- package/tide/src/ontology.js +28 -0
- package/tide/src/schema.js +87 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// Codex live-counter launcher.
|
|
2
|
+
//
|
|
3
|
+
// One command: point Codex's `openai_base_url` at the counter proxy, open the floating
|
|
4
|
+
// sticky window, and tally calls / tokens / cost live until you stop. Restores your Codex
|
|
5
|
+
// config on exit. Ported/trimmed from x-ray's launcher (`backend/xray.js`) — Codex only,
|
|
6
|
+
// no OpenTelemetry, no Claude, no risk.
|
|
7
|
+
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
import { startCounterProxy } from "./proxy.js";
|
|
14
|
+
|
|
15
|
+
const home = os.homedir();
|
|
16
|
+
const WINDOW = fileURLToPath(new URL("../frontend/window.jxa", import.meta.url));
|
|
17
|
+
const BEGIN = "# >>> rippletide counter >>>";
|
|
18
|
+
const END = "# <<< rippletide counter <<<";
|
|
19
|
+
const CODEX_BUNDLE = "com.openai.codex";
|
|
20
|
+
|
|
21
|
+
const TOKEN_FIELDS = ["inputTokens", "outputTokens", "totalTokens", "cachedInputTokens", "cacheCreationInputTokens", "reasoningOutputTokens"];
|
|
22
|
+
const emptyTokens = () => Object.fromEntries(TOKEN_FIELDS.map((f) => [f, 0]));
|
|
23
|
+
const tokenCount = (v) => { const n = Number(v); return Number.isFinite(n) && n > 0 ? Math.round(n) : 0; };
|
|
24
|
+
|
|
25
|
+
function codexHome() { return process.env.CODEX_HOME || path.join(home, ".codex"); }
|
|
26
|
+
|
|
27
|
+
// ---- temporary Codex config (openai_base_url -> the proxy) ----
|
|
28
|
+
const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
29
|
+
|
|
30
|
+
function stripManaged(config) {
|
|
31
|
+
return config.replace(new RegExp(`\\n?${escapeRe(BEGIN)}[\\s\\S]*?${escapeRe(END)}\\n?`, "g"), "\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function removeOpenaiBaseUrl(config) {
|
|
35
|
+
const kept = [];
|
|
36
|
+
let topLevel = true;
|
|
37
|
+
for (const line of config.split("\n")) {
|
|
38
|
+
if (/^\s*\[/.test(line)) topLevel = false;
|
|
39
|
+
if (topLevel && /^\s*openai_base_url\s*=/.test(line)) continue;
|
|
40
|
+
kept.push(line);
|
|
41
|
+
}
|
|
42
|
+
return kept.join("\n");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function withCounterConfig(config, baseUrl) {
|
|
46
|
+
const clean = removeOpenaiBaseUrl(stripManaged(config)).trimEnd();
|
|
47
|
+
return `${BEGIN}\nopenai_base_url = ${JSON.stringify(baseUrl)}\n${END}\n${clean ? `\n${clean}\n` : ""}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function atomicWrite(file, content, mode = 0o600) {
|
|
51
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
52
|
+
fs.writeFileSync(tmp, content, { mode });
|
|
53
|
+
fs.renameSync(tmp, file);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function installConfig(baseUrl) {
|
|
57
|
+
const dir = codexHome();
|
|
58
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
59
|
+
const cfg = path.join(dir, "config.toml");
|
|
60
|
+
const original = fs.existsSync(cfg) ? fs.readFileSync(cfg, "utf8") : "";
|
|
61
|
+
const mode = (() => { try { return fs.statSync(cfg).mode & 0o777; } catch { return 0o600; } })();
|
|
62
|
+
atomicWrite(cfg, withCounterConfig(original, baseUrl), mode);
|
|
63
|
+
return () => atomicWrite(cfg, original, mode);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ---- relaunch the Codex app so it picks up the base URL (app reads config at startup) ----
|
|
67
|
+
function codexAppRunning() {
|
|
68
|
+
const r = spawnSync("osascript", ["-e", `application id "${CODEX_BUNDLE}" is running`], { encoding: "utf8" });
|
|
69
|
+
return r.status === 0 && r.stdout.trim() === "true";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function relaunchCodexApp(log) {
|
|
73
|
+
if (process.env.RIPPLETIDE_RELAUNCH_CODEX_APP === "0" || process.platform !== "darwin" || !codexAppRunning()) return;
|
|
74
|
+
log("counter: relaunching the Codex app once so it routes through the counter.");
|
|
75
|
+
spawnSync("osascript", ["-e", `tell application id "${CODEX_BUNDLE}" to quit`], { encoding: "utf8" });
|
|
76
|
+
const start = Date.now();
|
|
77
|
+
while (codexAppRunning() && Date.now() - start < 8000) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100);
|
|
78
|
+
spawnSync("open", ["-b", CODEX_BUNDLE], { encoding: "utf8" });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---- state the window renders ----
|
|
82
|
+
function newState() {
|
|
83
|
+
return {
|
|
84
|
+
label: "codex", calls: 0, risky: 0, rerouted: 0,
|
|
85
|
+
models: {}, providers: {}, riskyModels: {}, riskyActions: {}, servedModels: {}, reroutes: {},
|
|
86
|
+
tokens: emptyTokens(), costUsd: 0, usageModels: {}, status: "counting", updated: 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function addTokens(target, delta) {
|
|
91
|
+
for (const f of TOKEN_FIELDS) target[f] = tokenCount(target[f]) + tokenCount(delta[f]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function writeState(file, state) {
|
|
95
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
96
|
+
fs.writeFileSync(tmp, JSON.stringify({ ...state, updated: Date.now() }));
|
|
97
|
+
fs.renameSync(tmp, file);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function openWindow(statePath) {
|
|
101
|
+
if (process.platform !== "darwin") return null;
|
|
102
|
+
return spawn("osascript", ["-l", "JavaScript", WINDOW, statePath], { stdio: "ignore" });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function run() {
|
|
106
|
+
const stateDir = path.join(process.env.XDG_STATE_HOME || path.join(home, ".local", "state"), "rippletide");
|
|
107
|
+
fs.mkdirSync(stateDir, { recursive: true });
|
|
108
|
+
const statePath = path.join(stateDir, "counter.json");
|
|
109
|
+
const state = newState();
|
|
110
|
+
const persist = (status = "counting") => { state.status = status; writeState(statePath, state); };
|
|
111
|
+
persist();
|
|
112
|
+
|
|
113
|
+
const proxy = startCounterProxy({
|
|
114
|
+
onCall({ model = "unknown" } = {}) {
|
|
115
|
+
state.calls += 1;
|
|
116
|
+
const name = String(model || "unknown").trim() || "unknown";
|
|
117
|
+
state.models[name] = (state.models[name] || 0) + 1;
|
|
118
|
+
state.providers[name] = "codex";
|
|
119
|
+
persist();
|
|
120
|
+
},
|
|
121
|
+
onUsage({ model = "unknown", usage = {}, costUsd = 0 } = {}) {
|
|
122
|
+
const delta = {}; for (const f of TOKEN_FIELDS) delta[f] = tokenCount(usage[f]);
|
|
123
|
+
if (!TOKEN_FIELDS.some((f) => delta[f] > 0)) return;
|
|
124
|
+
const name = String(model || "unknown").trim() || "unknown";
|
|
125
|
+
addTokens(state.tokens, delta);
|
|
126
|
+
state.costUsd += Number(costUsd) || 0;
|
|
127
|
+
if (!state.usageModels[name]) state.usageModels[name] = { ...emptyTokens(), costUsd: 0 };
|
|
128
|
+
addTokens(state.usageModels[name], delta);
|
|
129
|
+
state.usageModels[name].costUsd += Number(costUsd) || 0;
|
|
130
|
+
persist();
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
await proxy.listen();
|
|
134
|
+
|
|
135
|
+
let stopped = false;
|
|
136
|
+
let restoreConfig = () => {};
|
|
137
|
+
let window = null;
|
|
138
|
+
try {
|
|
139
|
+
restoreConfig = installConfig(proxy.baseUrl());
|
|
140
|
+
relaunchCodexApp(console.log);
|
|
141
|
+
window = openWindow(statePath);
|
|
142
|
+
console.log(`counter: live. Use Codex (app or CLI) — calls, tokens, and cost tally in the floating window.`);
|
|
143
|
+
console.log(`counter: routing Codex → ${proxy.baseUrl()} (config restored on exit). Ctrl-C to stop.`);
|
|
144
|
+
if (process.platform !== "darwin") console.log("counter: the sticky window is macOS-only; counting still works.");
|
|
145
|
+
} catch (e) {
|
|
146
|
+
try { restoreConfig(); } catch {}
|
|
147
|
+
proxy.close();
|
|
148
|
+
console.error(`counter: failed to start: ${e.message}`);
|
|
149
|
+
return 1;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const stop = () => {
|
|
153
|
+
if (stopped) return; stopped = true;
|
|
154
|
+
try { restoreConfig(); } catch (e) { console.error(`counter restore warning: ${e.message}`); }
|
|
155
|
+
try { proxy.close(); } catch {}
|
|
156
|
+
persist("stopped");
|
|
157
|
+
try { window?.kill(); } catch {}
|
|
158
|
+
process.exit(0);
|
|
159
|
+
};
|
|
160
|
+
for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, stop);
|
|
161
|
+
process.on("exit", () => { if (!stopped) { try { restoreConfig(); } catch {} } });
|
|
162
|
+
window?.on("exit", stop);
|
|
163
|
+
process.stdin.resume();
|
|
164
|
+
return new Promise(() => {}); // run until a signal stops us
|
|
165
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Codex live-counter proxy.
|
|
2
|
+
//
|
|
3
|
+
// A tiny reverse proxy that sits on Codex's `openai_base_url`: it forwards every model
|
|
4
|
+
// call to api.openai.com untouched, and on the way back it counts the call and reads the
|
|
5
|
+
// response `usage` to tally tokens and estimate cost. Ported from x-ray's proxy
|
|
6
|
+
// (`backend/xray-proxy.js`, codex/add-tokens-and-cost) — the counting + token/cost half
|
|
7
|
+
// only: no risk scanning, no HTTPS MITM, no OpenTelemetry, Codex/OpenAI only.
|
|
8
|
+
|
|
9
|
+
import http from "node:http";
|
|
10
|
+
import https from "node:https";
|
|
11
|
+
|
|
12
|
+
const INFERENCE_PATH = /\/(responses|chat\/completions)(\/|$|\?)/;
|
|
13
|
+
const DEBUG = process.env.COUNTER_DEBUG === "1";
|
|
14
|
+
const dbg = (m) => { if (DEBUG) process.stderr.write(`counter-proxy: ${m}\n`); };
|
|
15
|
+
|
|
16
|
+
const usageTokenFields = [
|
|
17
|
+
"inputTokens", "outputTokens", "totalTokens",
|
|
18
|
+
"cachedInputTokens", "cacheCreationInputTokens", "reasoningOutputTokens",
|
|
19
|
+
];
|
|
20
|
+
|
|
21
|
+
// USD per million tokens. Kept in sync with x-ray's OpenAI price table.
|
|
22
|
+
const openAiPriceTable = [
|
|
23
|
+
{ pattern: /^gpt-5\.5-pro(?:-|$)/, inputUsdPerMt: 30, outputUsdPerMt: 180 },
|
|
24
|
+
{ pattern: /^gpt-5\.5(?:-|$)/, inputUsdPerMt: 5, cachedInputUsdPerMt: 0.5, outputUsdPerMt: 30 },
|
|
25
|
+
{ pattern: /^gpt-5\.4-pro(?:-|$)/, inputUsdPerMt: 30, outputUsdPerMt: 180 },
|
|
26
|
+
{ pattern: /^gpt-5\.4-mini(?:-|$)/, inputUsdPerMt: 0.75, cachedInputUsdPerMt: 0.075, outputUsdPerMt: 4.5 },
|
|
27
|
+
{ pattern: /^gpt-5\.4-nano(?:-|$)/, inputUsdPerMt: 0.2, cachedInputUsdPerMt: 0.02, outputUsdPerMt: 1.25 },
|
|
28
|
+
{ pattern: /^gpt-5\.4(?:-|$)/, inputUsdPerMt: 2.5, cachedInputUsdPerMt: 0.25, outputUsdPerMt: 15 },
|
|
29
|
+
{ pattern: /^gpt-5\.3-codex(?:-|$)/, inputUsdPerMt: 1.75, cachedInputUsdPerMt: 0.175, outputUsdPerMt: 14 },
|
|
30
|
+
{ pattern: /^chat-latest(?:-|$)/, inputUsdPerMt: 5, cachedInputUsdPerMt: 0.5, outputUsdPerMt: 30 },
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
function tokenNumber(value) {
|
|
34
|
+
const n = Number(value);
|
|
35
|
+
return Number.isFinite(n) && n > 0 ? Math.round(n) : 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function normalizeModelName(model) {
|
|
39
|
+
return String(model || "").trim().toLowerCase();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function usagePricing(model) {
|
|
43
|
+
const normalized = normalizeModelName(model);
|
|
44
|
+
return openAiPriceTable.find((entry) => entry.pattern.test(normalized)) || null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function estimateUsageCostUsd(model, usage) {
|
|
48
|
+
const pricing = usagePricing(model);
|
|
49
|
+
if (!pricing || !usage) return 0;
|
|
50
|
+
const inputTokens = tokenNumber(usage.inputTokens);
|
|
51
|
+
const cachedInputTokens = Math.min(inputTokens, tokenNumber(usage.cachedInputTokens));
|
|
52
|
+
const cacheCreationInputTokens = Math.min(
|
|
53
|
+
Math.max(0, inputTokens - cachedInputTokens),
|
|
54
|
+
tokenNumber(usage.cacheCreationInputTokens),
|
|
55
|
+
);
|
|
56
|
+
const uncachedInputTokens = Math.max(0, inputTokens - cachedInputTokens - cacheCreationInputTokens);
|
|
57
|
+
const inputCost = uncachedInputTokens * pricing.inputUsdPerMt;
|
|
58
|
+
const cachedCost = cachedInputTokens * (pricing.cachedInputUsdPerMt ?? pricing.inputUsdPerMt);
|
|
59
|
+
const cacheWriteCost = cacheCreationInputTokens * (pricing.cacheWriteInputUsdPerMt ?? pricing.inputUsdPerMt);
|
|
60
|
+
const outputCost = tokenNumber(usage.outputTokens) * pricing.outputUsdPerMt;
|
|
61
|
+
return (inputCost + cachedCost + cacheWriteCost + outputCost) / 1_000_000;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeUsage(usage) {
|
|
65
|
+
const normalized = {};
|
|
66
|
+
for (const field of usageTokenFields) normalized[field] = tokenNumber(usage?.[field]);
|
|
67
|
+
if (!normalized.totalTokens) normalized.totalTokens = normalized.inputTokens + normalized.outputTokens;
|
|
68
|
+
return usageHasTokens(normalized) ? normalized : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function usageHasTokens(usage) {
|
|
72
|
+
return usageTokenFields.some((field) => tokenNumber(usage?.[field]) > 0);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeOpenAIUsage(usage) {
|
|
76
|
+
if (!usage || typeof usage !== "object") return null;
|
|
77
|
+
const inputTokens = tokenNumber(usage.input_tokens ?? usage.prompt_tokens);
|
|
78
|
+
const outputTokens = tokenNumber(usage.output_tokens ?? usage.completion_tokens);
|
|
79
|
+
return normalizeUsage({
|
|
80
|
+
inputTokens,
|
|
81
|
+
outputTokens,
|
|
82
|
+
totalTokens: tokenNumber(usage.total_tokens) || inputTokens + outputTokens,
|
|
83
|
+
cachedInputTokens: tokenNumber(
|
|
84
|
+
usage.input_tokens_details?.cached_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? usage.cached_tokens,
|
|
85
|
+
),
|
|
86
|
+
reasoningOutputTokens: tokenNumber(
|
|
87
|
+
usage.output_tokens_details?.reasoning_tokens ?? usage.completion_tokens_details?.reasoning_tokens ?? usage.reasoning_tokens,
|
|
88
|
+
),
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Take only the increase since the last usage seen on a streamed response (Codex sends
|
|
93
|
+
// cumulative usage across SSE events), so we never double-count within one call.
|
|
94
|
+
function usageDelta(previous, current) {
|
|
95
|
+
const merged = {};
|
|
96
|
+
const delta = {};
|
|
97
|
+
for (const field of usageTokenFields) {
|
|
98
|
+
if (field === "totalTokens") continue;
|
|
99
|
+
const prev = tokenNumber(previous?.[field]);
|
|
100
|
+
const next = Math.max(prev, tokenNumber(current?.[field]));
|
|
101
|
+
merged[field] = next;
|
|
102
|
+
delta[field] = Math.max(0, next - prev);
|
|
103
|
+
}
|
|
104
|
+
merged.totalTokens = Math.max(tokenNumber(previous?.totalTokens), tokenNumber(current?.totalTokens), merged.inputTokens + merged.outputTokens);
|
|
105
|
+
delta.totalTokens = Math.max(0, merged.totalTokens - tokenNumber(previous?.totalTokens));
|
|
106
|
+
return { merged, delta: usageHasTokens(delta) ? delta : null };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function requestModel(requestBody) {
|
|
110
|
+
try {
|
|
111
|
+
const parsed = JSON.parse(Buffer.isBuffer(requestBody) ? requestBody.toString("utf8") : String(requestBody || ""));
|
|
112
|
+
return String(parsed.model || "unknown").trim() || "unknown";
|
|
113
|
+
} catch {
|
|
114
|
+
return "unknown";
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function sseEvents(text) {
|
|
119
|
+
return String(text || "").split(/\r?\n\r?\n/).filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function eventData(event) {
|
|
123
|
+
return String(event || "").split(/\r?\n/).filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trimStart()).join("\n");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function responseUsage(chunk) {
|
|
127
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || "");
|
|
128
|
+
let found = null;
|
|
129
|
+
for (const event of sseEvents(text)) {
|
|
130
|
+
const data = eventData(event);
|
|
131
|
+
if (!data || data === "[DONE]") continue;
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(data);
|
|
134
|
+
found = normalizeOpenAIUsage(parsed.response?.usage || parsed.usage) || found;
|
|
135
|
+
} catch {}
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
const parsed = JSON.parse(text);
|
|
139
|
+
found = normalizeOpenAIUsage(parsed.response?.usage || parsed.usage) || found;
|
|
140
|
+
} catch {}
|
|
141
|
+
return found;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function responseModel(chunk) {
|
|
145
|
+
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || "");
|
|
146
|
+
for (const event of sseEvents(text)) {
|
|
147
|
+
const data = eventData(event);
|
|
148
|
+
if (!data || data === "[DONE]") continue;
|
|
149
|
+
try {
|
|
150
|
+
const parsed = JSON.parse(data);
|
|
151
|
+
const m = (parsed.response?.model || parsed.model || "").trim?.() || String(parsed.response?.model || parsed.model || "").trim();
|
|
152
|
+
if (m) return m;
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Start the reverse proxy. `onCall({model})` fires once per inference request;
|
|
159
|
+
// `onUsage({model, usage, costUsd})` fires as token usage arrives.
|
|
160
|
+
export function startCounterProxy({ onCall, onUsage } = {}) {
|
|
161
|
+
const server = http.createServer((req, res) => forward(req, res, { onCall, onUsage }));
|
|
162
|
+
server.on("error", () => {});
|
|
163
|
+
// Codex opens a WebSocket to /v1/responses when the provider supports it. This HTTP proxy
|
|
164
|
+
// can't carry that upgrade, so refuse it immediately — Codex then falls straight back to
|
|
165
|
+
// its HTTPS transport (which we do forward) instead of retrying the handshake until timeout.
|
|
166
|
+
server.on("upgrade", (req, socket) => {
|
|
167
|
+
dbg(`ws upgrade ${req.method} ${req.url} -> refused`);
|
|
168
|
+
try { socket.write("HTTP/1.1 426 Upgrade Required\r\nConnection: close\r\n\r\n"); } catch {}
|
|
169
|
+
try { socket.destroy(); } catch {}
|
|
170
|
+
});
|
|
171
|
+
return {
|
|
172
|
+
server,
|
|
173
|
+
listen(port = 0, host = "127.0.0.1") {
|
|
174
|
+
return new Promise((resolve, reject) => {
|
|
175
|
+
server.listen(port, host, resolve);
|
|
176
|
+
server.once("error", reject);
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
address() { return server.address(); },
|
|
180
|
+
baseUrl() { return `http://127.0.0.1:${server.address().port}/v1`; },
|
|
181
|
+
close() { try { server.close(); } catch {} },
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function forward(req, res, { onCall, onUsage }) {
|
|
186
|
+
const chunks = [];
|
|
187
|
+
req.on("data", (c) => chunks.push(c));
|
|
188
|
+
req.on("end", () => {
|
|
189
|
+
const requestBody = Buffer.concat(chunks);
|
|
190
|
+
const model = requestModel(requestBody);
|
|
191
|
+
// Count only real inference POSTs — not the GET /v1/responses connectivity probes.
|
|
192
|
+
const isInference = req.method === "POST" && INFERENCE_PATH.test(req.url || "");
|
|
193
|
+
const upgrade = req.headers.upgrade || (req.headers["sec-websocket-key"] ? "websocket?" : "");
|
|
194
|
+
dbg(`${req.method} ${req.url} model=${model} inference=${isInference} bodyLen=${requestBody.length}${upgrade ? ` upgrade=${upgrade}` : ""}`);
|
|
195
|
+
if (isInference) onCall?.({ model });
|
|
196
|
+
|
|
197
|
+
const headers = { ...req.headers, host: "api.openai.com" };
|
|
198
|
+
delete headers["proxy-connection"];
|
|
199
|
+
headers["accept-encoding"] = "identity";
|
|
200
|
+
|
|
201
|
+
const upstream = https.request(
|
|
202
|
+
{ hostname: "api.openai.com", port: 443, method: req.method, path: req.url, headers, ALPNProtocols: ["http/1.1"] },
|
|
203
|
+
(upRes) => {
|
|
204
|
+
res.writeHead(upRes.statusCode || 502, upRes.headers);
|
|
205
|
+
dbg(`resp ${upRes.statusCode} ct=${upRes.headers["content-type"]} ce=${upRes.headers["content-encoding"] || "none"}`);
|
|
206
|
+
let servedModel = null;
|
|
207
|
+
let lastUsage = null;
|
|
208
|
+
let buffer = "";
|
|
209
|
+
let sawUsage = false;
|
|
210
|
+
const noteUsage = (found) => {
|
|
211
|
+
if (!found) return;
|
|
212
|
+
const next = usageDelta(lastUsage, found);
|
|
213
|
+
lastUsage = next.merged;
|
|
214
|
+
if (!next.delta) return;
|
|
215
|
+
sawUsage = true;
|
|
216
|
+
const usageModel = servedModel || model;
|
|
217
|
+
onUsage?.({ model: usageModel, usage: next.delta, costUsd: estimateUsageCostUsd(usageModel, next.delta) });
|
|
218
|
+
};
|
|
219
|
+
upRes.on("data", (chunk) => {
|
|
220
|
+
buffer += chunk.toString("utf8");
|
|
221
|
+
const parts = buffer.split(/\r?\n\r?\n/);
|
|
222
|
+
buffer = parts.pop() || "";
|
|
223
|
+
for (const part of parts) {
|
|
224
|
+
if (!servedModel) servedModel = responseModel(part) || servedModel;
|
|
225
|
+
noteUsage(responseUsage(part));
|
|
226
|
+
}
|
|
227
|
+
res.write(chunk);
|
|
228
|
+
});
|
|
229
|
+
upRes.on("end", () => {
|
|
230
|
+
// non-streaming JSON: the whole body is still in the buffer here
|
|
231
|
+
servedModel = servedModel || responseModel(buffer);
|
|
232
|
+
noteUsage(responseUsage(buffer));
|
|
233
|
+
if (DEBUG && isInference) dbg(`usage extracted=${sawUsage} for ${servedModel || model}`);
|
|
234
|
+
res.end();
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
upstream.on("error", (e) => {
|
|
239
|
+
try { res.writeHead(502, { "content-type": "text/plain" }); res.end(String(e.message || e)); } catch {}
|
|
240
|
+
});
|
|
241
|
+
upstream.end(requestBody);
|
|
242
|
+
});
|
|
243
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "rippletide-package",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"description": "Rippletide's CLI for AI coding agents — one command, each feature a subcommand.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"agents",
|
|
8
|
+
"ai-agents",
|
|
9
|
+
"cli",
|
|
10
|
+
"rippletide"
|
|
11
|
+
],
|
|
12
|
+
"type": "module",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/rippletideco/rippletide-package.git"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public",
|
|
19
|
+
"registry": "https://registry.npmjs.org/"
|
|
20
|
+
},
|
|
21
|
+
"bin": {
|
|
22
|
+
"rippletide": "bin/rippletide.js"
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"bin/",
|
|
26
|
+
"reviewer/bin/",
|
|
27
|
+
"reviewer/src/",
|
|
28
|
+
"reviewer/codex/rippletide-reviewer/scripts/",
|
|
29
|
+
"reviewer/README.md",
|
|
30
|
+
"reviewer/.env.example",
|
|
31
|
+
"tide/bin/",
|
|
32
|
+
"tide/src/",
|
|
33
|
+
"tide/examples/",
|
|
34
|
+
"tide/README.md",
|
|
35
|
+
"counter/src/",
|
|
36
|
+
"counter/frontend/",
|
|
37
|
+
"counter/README.md",
|
|
38
|
+
"README.md",
|
|
39
|
+
"PUBLISHING.md"
|
|
40
|
+
],
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"js-yaml": "^4.1.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"help": "node ./bin/rippletide.js --help",
|
|
46
|
+
"start": "node ./bin/rippletide.js review",
|
|
47
|
+
"review": "node ./bin/rippletide.js review",
|
|
48
|
+
"tide": "node ./bin/rippletide.js tide",
|
|
49
|
+
"counter": "node ./bin/rippletide.js counter",
|
|
50
|
+
"codex:prepare": "npm --prefix reviewer run codex:prepare",
|
|
51
|
+
"pack:dry": "npm pack --dry-run",
|
|
52
|
+
"release:check": "npm run check && node ./bin/rippletide.js --help",
|
|
53
|
+
"release:dry": "npm publish --dry-run",
|
|
54
|
+
"release:publish": "npm publish",
|
|
55
|
+
"prepublishOnly": "npm run release:check",
|
|
56
|
+
"check": "node --check bin/rippletide.js && npm --prefix reviewer run check && npm --prefix tide run check && node --check counter/src/cli.js && node --check counter/src/counter.js && node --check counter/src/proxy.js && npm run pack:dry",
|
|
57
|
+
"lint": "npm run check",
|
|
58
|
+
"test": "npm run check"
|
|
59
|
+
},
|
|
60
|
+
"engines": {
|
|
61
|
+
"node": ">=18.0.0"
|
|
62
|
+
},
|
|
63
|
+
"license": "UNLICENSED"
|
|
64
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
# Rippletide Reviewer
|
|
2
|
+
|
|
3
|
+
Rippletide Reviewer reviews local AI agents before ship. It reads an agent repo, builds an evidence pack from local code/config/docs, produces short impact-driven findings, and can propose focused edits for findings the builder chooses.
|
|
4
|
+
|
|
5
|
+
The MVP has two local modes:
|
|
6
|
+
|
|
7
|
+
- **CLI mode**: Rippletide calls the configured model directly, can run an interactive fix loop, and attempts a local proof after accepted edits.
|
|
8
|
+
- **Codex-native mode**: Rippletide prepares local evidence for Codex, then Codex does the review and proposes edits in the user's existing Codex session.
|
|
9
|
+
|
|
10
|
+
## Quick Start
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npm install
|
|
14
|
+
cp .env.example .env
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Add your model API key to `.env` for CLI mode:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
OPENAI_API_KEY=...
|
|
21
|
+
RIPPLETIDE_MODEL=gpt-5.5
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Run a review with npx:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
npx rippletide review
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
The command reviews the current directory by default.
|
|
31
|
+
|
|
32
|
+
Explicit path:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npx rippletide review /path/to/agent
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Current local reviewer mode requires `OPENAI_API_KEY`. The product direction is hosted-first, so the final `npx rippletide review` flow will use the Rippletide hosted reviewer instead of asking users to configure model keys.
|
|
39
|
+
|
|
40
|
+
Run a review from a local checkout:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
node bin/rippletide.js review /path/to/agent
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Run a review and choose fixes interactively:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
node bin/rippletide.js review /path/to/agent --fix
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Inspect only, without user-facing findings:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
node bin/rippletide.js inspect /path/to/agent
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Try A Local Agent
|
|
59
|
+
|
|
60
|
+
The repo includes local agents for teammate testing:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
agents/
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Example:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
node bin/rippletide.js review agents/browser-use-apply-to-job
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Codex-native evidence preparation:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
codex/rippletide-reviewer/scripts/rippletide-codex prepare agents/langgraph-react-agent
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Product Flow
|
|
79
|
+
|
|
80
|
+
1. Collect local agent evidence.
|
|
81
|
+
2. Build an evidence pack with source excerpts and file citations.
|
|
82
|
+
3. Ask the LLM inspector to create an internal neutral dossier.
|
|
83
|
+
4. Ask the LLM reviewer to reread the dossier and evidence.
|
|
84
|
+
5. Show the user short findings only.
|
|
85
|
+
6. For a selected finding, ask the LLM action planner for a small patch or `NO_PATCH`.
|
|
86
|
+
7. Let the user apply, refuse, comment, skip, or quit.
|
|
87
|
+
8. After an accepted patch, run a safe local proof command when possible and save `proof.md`.
|
|
88
|
+
9. Save artifacts under the target agent repo.
|
|
89
|
+
|
|
90
|
+
Artifacts are written to:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
<agent>/.rippletide/actions/<run-id>/
|
|
94
|
+
<agent>/.rippletide/codex/<run-id>/
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
These folders are intentionally ignored by Git.
|
|
98
|
+
|
|
99
|
+
## Repo Layout
|
|
100
|
+
|
|
101
|
+
```text
|
|
102
|
+
agents/ Local agents for teammate testing
|
|
103
|
+
bin/rippletide.js CLI executable shim
|
|
104
|
+
src/connect/ Shared local evidence/context foundation
|
|
105
|
+
src/integrations/ CLI, Codex, MCP, and future assistant wrappers
|
|
106
|
+
codex/rippletide-reviewer/ Codex plugin packaging and skill wrapper
|
|
107
|
+
docs/ Architecture and development notes
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The Codex plugin has its own short guide at [codex/rippletide-reviewer/README.md](codex/rippletide-reviewer/README.md).
|
|
111
|
+
|
|
112
|
+
The intended architecture is one shared foundation with thin wrappers for CLI, MCP, Codex, Claude Code, Cursor, Gemini, OpenCode, and packaging surfaces. New reusable behavior should land under `src/connect/` or `src/features/`; wrappers should only translate host-specific setup and calls.
|
|
113
|
+
|
|
114
|
+
## Commands
|
|
115
|
+
|
|
116
|
+
```bash
|
|
117
|
+
npm run check # syntax-check the CLI/Codex scripts and run tests
|
|
118
|
+
npm run help # show CLI usage
|
|
119
|
+
npm run inspect -- /path/to/agent
|
|
120
|
+
npm run review -- /path/to/agent
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
## Safety Model
|
|
124
|
+
|
|
125
|
+
- Rippletide reads local source/config/doc files and redacts obvious secrets before sending evidence to the configured model in CLI mode.
|
|
126
|
+
- Codex-native mode does not call Rippletide APIs or external model APIs by itself. It only writes local evidence and instructions for Codex.
|
|
127
|
+
- Proposed edits are not applied until the user chooses apply.
|
|
128
|
+
- CLI fix mode only runs allowlisted local proof commands without a shell: `npm test`, `bun test`, `python3 -m pytest`, `python3 -m unittest discover`, `node --test`, `npm run check`, `node --check`, or `python3 -m py_compile`.
|
|
129
|
+
- Behavior tests/evals are proof. Syntax/compile checks are reported as code checks, not behavior proof.
|
|
130
|
+
- If behavior proof fails or no safe proof exists, Rippletide reports that plainly instead of claiming the finding is fixed.
|
|
131
|
+
- Generated `.rippletide/` artifacts, `.env`, and agent results must not be committed.
|
|
132
|
+
|
|
133
|
+
## Development
|
|
134
|
+
|
|
135
|
+
Read [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the current system shape and [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for local development rules.
|
|
136
|
+
|
|
137
|
+
The npm package and publishing flow are owned by the repo root. See [../docs/PUBLISHING.md](../docs/PUBLISHING.md).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
if (process.argv.length <= 2) {
|
|
4
|
+
process.argv.push("review");
|
|
5
|
+
process.argv.push(".");
|
|
6
|
+
} else {
|
|
7
|
+
const firstArg = process.argv[2];
|
|
8
|
+
const helpRequested = firstArg === "--help" || firstArg === "-h";
|
|
9
|
+
const commandProvided = firstArg === "review" || firstArg === "inspect";
|
|
10
|
+
|
|
11
|
+
if (!helpRequested && !commandProvided) {
|
|
12
|
+
process.argv.splice(2, 0, "review");
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
await import("../src/integrations/cli/rippletide.js");
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Rippletide Reviewer Codex Plugin
|
|
2
|
+
|
|
3
|
+
This plugin lets Codex run Rippletide in Codex-native mode.
|
|
4
|
+
|
|
5
|
+
Codex-native mode does not call Rippletide APIs or external model APIs by itself. It prepares local evidence under the target agent repo and gives Codex instructions for the review/action flow.
|
|
6
|
+
|
|
7
|
+
## Smoke Test
|
|
8
|
+
|
|
9
|
+
From the repository root:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
codex/rippletide-reviewer/scripts/rippletide-codex prepare agents/langgraph-react-agent
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Generated artifacts are written to:
|
|
16
|
+
|
|
17
|
+
```text
|
|
18
|
+
<agent>/.rippletide/codex/<run-id>/
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Files
|
|
22
|
+
|
|
23
|
+
```text
|
|
24
|
+
.codex-plugin/plugin.json Plugin manifest
|
|
25
|
+
skills/rippletide-reviewer/SKILL.md Codex-facing workflow
|
|
26
|
+
scripts/rippletide-codex Evidence preparation wrapper
|
|
27
|
+
scripts/rippletide-reviewer Standalone CLI wrapper
|
|
28
|
+
```
|