claude-smart 0.2.26 → 0.2.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/claude-smart.js +250 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +7 -7
- package/plugin/pyproject.toml +2 -1
- package/plugin/scripts/_codex_env.sh +1 -0
- package/plugin/scripts/backend-service.sh +50 -7
- package/plugin/scripts/codex-claude-compat.py +144 -0
- package/plugin/scripts/codex-hook.js +386 -0
- package/plugin/scripts/ensure-plugin-root.sh +3 -2
- package/plugin/scripts/smart-install.sh +0 -1
- package/plugin/src/claude_smart/cli.py +6 -1
- package/plugin/src/claude_smart/events/stop.py +16 -1
- package/plugin/src/claude_smart/internal_call.py +30 -0
- package/plugin/src/claude_smart/optimizer_assistant.py +86 -6
- package/plugin/uv.lock +12 -1
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
const { spawn, spawnSync } = require("node:child_process");
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const http = require("node:http");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const path = require("node:path");
|
|
9
|
+
|
|
10
|
+
const HOME = os.homedir();
|
|
11
|
+
const STATE_DIR = path.join(HOME, ".claude-smart");
|
|
12
|
+
const REFLEXIO_DIR = path.join(HOME, ".reflexio");
|
|
13
|
+
const DEFAULT_BACKEND_PORT = 8071;
|
|
14
|
+
const FALLBACK_BACKEND_PORT = 8072;
|
|
15
|
+
const DASHBOARD_PORT = 3001;
|
|
16
|
+
|
|
17
|
+
function emitOk() {
|
|
18
|
+
process.stdout.write('{"continue":true,"suppressOutput":true}\n');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ensureDir(dir) {
|
|
22
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function appendLog(name, line) {
|
|
26
|
+
ensureDir(STATE_DIR);
|
|
27
|
+
fs.appendFileSync(path.join(STATE_DIR, name), `${line}\n`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function pluginRoot() {
|
|
31
|
+
for (const value of [process.env.CLAUDE_PLUGIN_ROOT, process.env.PLUGIN_ROOT]) {
|
|
32
|
+
if (value && fs.existsSync(path.join(value, "pyproject.toml"))) {
|
|
33
|
+
return path.resolve(value);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const fromScript = path.resolve(__dirname, "..");
|
|
37
|
+
if (fs.existsSync(path.join(fromScript, "pyproject.toml"))) return fromScript;
|
|
38
|
+
const cacheRoot = path.join(HOME, ".codex", "plugins", "cache", "reflexioai", "claude-smart");
|
|
39
|
+
try {
|
|
40
|
+
const versions = fs
|
|
41
|
+
.readdirSync(cacheRoot, { withFileTypes: true })
|
|
42
|
+
.filter((entry) => entry.isDirectory())
|
|
43
|
+
.map((entry) => path.join(cacheRoot, entry.name))
|
|
44
|
+
.sort((a, b) => fs.statSync(b).mtimeMs - fs.statSync(a).mtimeMs);
|
|
45
|
+
for (const candidate of versions) {
|
|
46
|
+
if (fs.existsSync(path.join(candidate, "pyproject.toml"))) return candidate;
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// Fall through to the stable plugin-root link.
|
|
50
|
+
}
|
|
51
|
+
return path.join(REFLEXIO_DIR, "plugin-root");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function prependRuntimePath() {
|
|
55
|
+
const privateNode = path.join(STATE_DIR, "node", "current");
|
|
56
|
+
const parts = [
|
|
57
|
+
path.join(privateNode, "bin"),
|
|
58
|
+
privateNode,
|
|
59
|
+
path.join(HOME, ".local", "bin"),
|
|
60
|
+
path.join(HOME, ".cargo", "bin"),
|
|
61
|
+
];
|
|
62
|
+
process.env.PATH = `${parts.join(path.delimiter)}${path.delimiter}${process.env.PATH || ""}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function commandPath(names) {
|
|
66
|
+
const pathParts = (process.env.PATH || "").split(path.delimiter).filter(Boolean);
|
|
67
|
+
for (const dir of pathParts) {
|
|
68
|
+
for (const name of names) {
|
|
69
|
+
const candidate = path.join(dir, name);
|
|
70
|
+
if (fs.existsSync(candidate)) return candidate;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function uvPath() {
|
|
77
|
+
return commandPath(process.platform === "win32" ? ["uv.exe", "uv"] : ["uv"]);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function npmPath() {
|
|
81
|
+
return commandPath(process.platform === "win32" ? ["npm.cmd", "npm.exe", "npm"] : ["npm"]);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function stateFile(name) {
|
|
85
|
+
return path.join(STATE_DIR, name);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function backendUrlFile() {
|
|
89
|
+
return stateFile("backend-url");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function writeBackendUrl(port) {
|
|
93
|
+
ensureDir(STATE_DIR);
|
|
94
|
+
fs.writeFileSync(backendUrlFile(), `http://localhost:${port}/\n`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function codexCompatPath(root) {
|
|
98
|
+
return path.join(root, "scripts", "codex-claude-compat.py");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function readBackendUrl() {
|
|
102
|
+
if (process.env.REFLEXIO_URL) return process.env.REFLEXIO_URL;
|
|
103
|
+
try {
|
|
104
|
+
const value = fs.readFileSync(backendUrlFile(), "utf8").trim();
|
|
105
|
+
if (value) return value;
|
|
106
|
+
} catch {
|
|
107
|
+
// Fall through to default.
|
|
108
|
+
}
|
|
109
|
+
return `http://localhost:${DEFAULT_BACKEND_PORT}/`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function healthOk(port, pathname, markerHeader) {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const req = http.request(
|
|
115
|
+
{
|
|
116
|
+
host: "127.0.0.1",
|
|
117
|
+
port,
|
|
118
|
+
path: pathname,
|
|
119
|
+
method: "GET",
|
|
120
|
+
timeout: 1200,
|
|
121
|
+
},
|
|
122
|
+
(res) => {
|
|
123
|
+
const ok = res.statusCode && res.statusCode >= 200 && res.statusCode < 400;
|
|
124
|
+
const markerOk = markerHeader ? Boolean(res.headers[markerHeader]) : true;
|
|
125
|
+
res.resume();
|
|
126
|
+
resolve(Boolean(ok && markerOk));
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
req.on("timeout", () => req.destroy());
|
|
130
|
+
req.on("error", () => resolve(false));
|
|
131
|
+
req.end();
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function portOccupied(port) {
|
|
136
|
+
return new Promise((resolve) => {
|
|
137
|
+
const req = http.request(
|
|
138
|
+
{
|
|
139
|
+
host: "127.0.0.1",
|
|
140
|
+
port,
|
|
141
|
+
path: "/",
|
|
142
|
+
method: "GET",
|
|
143
|
+
timeout: 900,
|
|
144
|
+
},
|
|
145
|
+
(res) => {
|
|
146
|
+
res.resume();
|
|
147
|
+
resolve(true);
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
req.on("timeout", () => req.destroy());
|
|
151
|
+
req.on("error", (err) => {
|
|
152
|
+
resolve(err && err.code !== "ECONNREFUSED");
|
|
153
|
+
});
|
|
154
|
+
req.end();
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function waitForHealth(port, pathname, markerHeader, attempts) {
|
|
159
|
+
for (let i = 0; i < attempts; i += 1) {
|
|
160
|
+
if (await healthOk(port, pathname, markerHeader)) return true;
|
|
161
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function detached(command, args, options = {}) {
|
|
167
|
+
const child = spawn(command, args, {
|
|
168
|
+
cwd: options.cwd,
|
|
169
|
+
env: options.env || process.env,
|
|
170
|
+
detached: true,
|
|
171
|
+
shell: process.platform === "win32" && /\.(?:cmd|bat)$/i.test(command),
|
|
172
|
+
stdio: "ignore",
|
|
173
|
+
windowsHide: true,
|
|
174
|
+
});
|
|
175
|
+
child.unref();
|
|
176
|
+
return child.pid;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function readPid(file) {
|
|
180
|
+
try {
|
|
181
|
+
const value = fs.readFileSync(file, "utf8").trim();
|
|
182
|
+
return value ? Number(value) : null;
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function pidAlive(pid) {
|
|
189
|
+
if (!pid || Number.isNaN(pid)) return false;
|
|
190
|
+
try {
|
|
191
|
+
process.kill(pid, 0);
|
|
192
|
+
return true;
|
|
193
|
+
} catch {
|
|
194
|
+
return false;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function writePid(file, pid) {
|
|
199
|
+
ensureDir(path.dirname(file));
|
|
200
|
+
fs.writeFileSync(file, `${pid}\n`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function ensurePluginRoot(root) {
|
|
204
|
+
ensureDir(REFLEXIO_DIR);
|
|
205
|
+
const link = path.join(REFLEXIO_DIR, "plugin-root");
|
|
206
|
+
try {
|
|
207
|
+
fs.rmSync(link, { recursive: true, force: true });
|
|
208
|
+
} catch {
|
|
209
|
+
// Ignore and try to recreate below.
|
|
210
|
+
}
|
|
211
|
+
try {
|
|
212
|
+
fs.symlinkSync(root, link, process.platform === "win32" ? "junction" : "dir");
|
|
213
|
+
} catch {
|
|
214
|
+
fs.writeFileSync(path.join(REFLEXIO_DIR, "plugin-root.txt"), `${root}\n`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function startBackend(root) {
|
|
219
|
+
if (process.env.CLAUDE_SMART_BACKEND_AUTOSTART === "0") {
|
|
220
|
+
emitOk();
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
const pidFile = path.join(STATE_DIR, "backend.pid");
|
|
224
|
+
for (const port of [DEFAULT_BACKEND_PORT, FALLBACK_BACKEND_PORT]) {
|
|
225
|
+
if (pidAlive(readPid(pidFile)) && await healthOk(port, "/health")) {
|
|
226
|
+
writeBackendUrl(port);
|
|
227
|
+
emitOk();
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
if (await healthOk(port, "/health")) {
|
|
231
|
+
writeBackendUrl(port);
|
|
232
|
+
emitOk();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
const uv = uvPath();
|
|
237
|
+
if (!uv) {
|
|
238
|
+
appendLog("backend.log", "[claude-smart] backend: uv not on PATH; skipping");
|
|
239
|
+
emitOk();
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
let selectedPort = DEFAULT_BACKEND_PORT;
|
|
243
|
+
if (await portOccupied(DEFAULT_BACKEND_PORT)) {
|
|
244
|
+
appendLog("backend.log", "[claude-smart] backend: port 8071 occupied; trying 8072");
|
|
245
|
+
selectedPort = FALLBACK_BACKEND_PORT;
|
|
246
|
+
}
|
|
247
|
+
const backendUrl = `http://localhost:${selectedPort}/`;
|
|
248
|
+
const env = {
|
|
249
|
+
...process.env,
|
|
250
|
+
BACKEND_PORT: String(selectedPort),
|
|
251
|
+
REFLEXIO_URL: backendUrl,
|
|
252
|
+
CLAUDE_SMART_USE_LOCAL_CLI: process.env.CLAUDE_SMART_USE_LOCAL_CLI || "1",
|
|
253
|
+
CLAUDE_SMART_USE_LOCAL_EMBEDDING: process.env.CLAUDE_SMART_USE_LOCAL_EMBEDDING || "1",
|
|
254
|
+
CLAUDE_SMART_HOST: "codex",
|
|
255
|
+
CLAUDE_SMART_CLI_PATH: process.env.CLAUDE_SMART_CLI_PATH || codexCompatPath(root),
|
|
256
|
+
INTERACTION_CLEANUP_THRESHOLD: process.env.INTERACTION_CLEANUP_THRESHOLD || "500",
|
|
257
|
+
INTERACTION_CLEANUP_DELETE_COUNT: process.env.INTERACTION_CLEANUP_DELETE_COUNT || "200",
|
|
258
|
+
};
|
|
259
|
+
const pid = detached(
|
|
260
|
+
uv,
|
|
261
|
+
[
|
|
262
|
+
"run",
|
|
263
|
+
"--project",
|
|
264
|
+
root,
|
|
265
|
+
"--quiet",
|
|
266
|
+
"reflexio",
|
|
267
|
+
"services",
|
|
268
|
+
"start",
|
|
269
|
+
"--only",
|
|
270
|
+
"backend",
|
|
271
|
+
"--no-reload",
|
|
272
|
+
],
|
|
273
|
+
{ cwd: root, env },
|
|
274
|
+
);
|
|
275
|
+
writePid(pidFile, pid);
|
|
276
|
+
if (await waitForHealth(selectedPort, "/health", null, 10)) {
|
|
277
|
+
writeBackendUrl(selectedPort);
|
|
278
|
+
}
|
|
279
|
+
emitOk();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function startDashboard(root) {
|
|
283
|
+
if (process.env.CLAUDE_SMART_DASHBOARD_AUTOSTART === "0") {
|
|
284
|
+
emitOk();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
const dashboard = path.join(root, "dashboard");
|
|
288
|
+
if (!fs.existsSync(dashboard)) {
|
|
289
|
+
emitOk();
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
const pidFile = path.join(STATE_DIR, "dashboard.pid");
|
|
293
|
+
if (
|
|
294
|
+
pidAlive(readPid(pidFile)) &&
|
|
295
|
+
await healthOk(DASHBOARD_PORT, "/api/health", "x-claude-smart-dashboard")
|
|
296
|
+
) {
|
|
297
|
+
emitOk();
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
const npm = npmPath();
|
|
301
|
+
if (!npm) {
|
|
302
|
+
appendLog("dashboard.log", "[claude-smart] dashboard: npm not on PATH; skipping");
|
|
303
|
+
emitOk();
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
if (!fs.existsSync(path.join(dashboard, ".next"))) {
|
|
307
|
+
const buildPidFile = path.join(STATE_DIR, "dashboard-build.pid");
|
|
308
|
+
if (!pidAlive(readPid(buildPidFile))) {
|
|
309
|
+
const pid = detached(npm, ["run", "build"], { cwd: dashboard });
|
|
310
|
+
writePid(buildPidFile, pid);
|
|
311
|
+
appendLog("dashboard.log", "[claude-smart] dashboard: .next missing; started background build");
|
|
312
|
+
}
|
|
313
|
+
emitOk();
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
const env = {
|
|
317
|
+
...process.env,
|
|
318
|
+
PORT: String(DASHBOARD_PORT),
|
|
319
|
+
REFLEXIO_URL: readBackendUrl(),
|
|
320
|
+
CLAUDE_SMART_DASHBOARD_WORKSPACE: process.cwd(),
|
|
321
|
+
};
|
|
322
|
+
const pid = detached(npm, ["run", "start"], { cwd: dashboard, env });
|
|
323
|
+
writePid(pidFile, pid);
|
|
324
|
+
await waitForHealth(DASHBOARD_PORT, "/api/health", "x-claude-smart-dashboard", 5);
|
|
325
|
+
emitOk();
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function runHook(root, event) {
|
|
329
|
+
const uv = uvPath();
|
|
330
|
+
if (!uv) {
|
|
331
|
+
appendLog("backend.log", "[claude-smart] hook: uv not on PATH; skipping");
|
|
332
|
+
emitOk();
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
const input = fs.readFileSync(0);
|
|
336
|
+
const result = spawnSync(
|
|
337
|
+
uv,
|
|
338
|
+
["run", "--project", root, "--quiet", "python", "-m", "claude_smart.hook", "codex", event],
|
|
339
|
+
{
|
|
340
|
+
cwd: root,
|
|
341
|
+
env: {
|
|
342
|
+
...process.env,
|
|
343
|
+
REFLEXIO_URL: readBackendUrl(),
|
|
344
|
+
CLAUDE_SMART_HOST: "codex",
|
|
345
|
+
CLAUDE_SMART_CLI_PATH: process.env.CLAUDE_SMART_CLI_PATH || codexCompatPath(root),
|
|
346
|
+
},
|
|
347
|
+
input,
|
|
348
|
+
stdio: ["pipe", "inherit", "inherit"],
|
|
349
|
+
windowsHide: true,
|
|
350
|
+
},
|
|
351
|
+
);
|
|
352
|
+
return typeof result.status === "number" ? result.status : 1;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function main() {
|
|
356
|
+
prependRuntimePath();
|
|
357
|
+
const root = pluginRoot();
|
|
358
|
+
process.env.PLUGIN_ROOT = root;
|
|
359
|
+
process.env.CLAUDE_PLUGIN_ROOT = root;
|
|
360
|
+
const action = process.argv[2] || "hook";
|
|
361
|
+
if (action === "ensure-root") {
|
|
362
|
+
ensurePluginRoot(root);
|
|
363
|
+
emitOk();
|
|
364
|
+
return 0;
|
|
365
|
+
}
|
|
366
|
+
if (action === "backend") {
|
|
367
|
+
await startBackend(root);
|
|
368
|
+
return 0;
|
|
369
|
+
}
|
|
370
|
+
if (action === "dashboard") {
|
|
371
|
+
await startDashboard(root);
|
|
372
|
+
return 0;
|
|
373
|
+
}
|
|
374
|
+
if (action === "hook") {
|
|
375
|
+
return runHook(root, process.argv[3] || "session-start");
|
|
376
|
+
}
|
|
377
|
+
emitOk();
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
main()
|
|
382
|
+
.then((code) => process.exit(code))
|
|
383
|
+
.catch((err) => {
|
|
384
|
+
appendLog("backend.log", `[claude-smart] codex hook failed: ${err && err.stack ? err.stack : err}`);
|
|
385
|
+
emitOk();
|
|
386
|
+
});
|
|
@@ -51,7 +51,8 @@ if [ "$FOLLOW" = "1" ]; then
|
|
|
51
51
|
fi
|
|
52
52
|
|
|
53
53
|
# Cache-tracking: if the link currently resolves to a path under the
|
|
54
|
-
# managed plugin cache (~/.claude/plugins/cache/),
|
|
54
|
+
# managed plugin cache (~/.claude/plugins/cache/ or ~/.codex/plugins/cache/),
|
|
55
|
+
# always retarget it to
|
|
55
56
|
# $TARGET. Plugin updates leave old version directories behind, so a
|
|
56
57
|
# valid pyproject.toml at the stale target is not proof the link is
|
|
57
58
|
# fresh. Links pointing outside the cache (e.g., a user's local-dev
|
|
@@ -60,7 +61,7 @@ if [ -L "$LINK" ]; then
|
|
|
60
61
|
# Literal target string, not realpath: we compare against what was written by ln -s.
|
|
61
62
|
CURRENT="$(readlink "$LINK" 2>/dev/null || true)"
|
|
62
63
|
case "$CURRENT" in
|
|
63
|
-
"$HOME/.claude/plugins/cache/"*)
|
|
64
|
+
"$HOME/.claude/plugins/cache/"*|"$HOME/.codex/plugins/cache/"*)
|
|
64
65
|
CURRENT_NORM="${CURRENT%/}"
|
|
65
66
|
TARGET_NORM="${TARGET%/}"
|
|
66
67
|
if [ "$CURRENT_NORM" != "$TARGET_NORM" ]; then
|
|
@@ -311,7 +311,6 @@ if ! grep -q '^CLAUDE_SMART_USE_LOCAL_EMBEDDING=' "$REFLEXIO_ENV"; then
|
|
|
311
311
|
printf '# Use the in-process ONNX embedder (chromadb) — no API key for semantic search\nCLAUDE_SMART_USE_LOCAL_EMBEDDING=1\n' >> "$REFLEXIO_ENV"
|
|
312
312
|
echo "[claude-smart] appended CLAUDE_SMART_USE_LOCAL_EMBEDDING=1 to $REFLEXIO_ENV" >&2
|
|
313
313
|
fi
|
|
314
|
-
|
|
315
314
|
# Migrate stale REFLEXIO_URL from reflexio's library default (8081) to the
|
|
316
315
|
# plugin backend port (8071). Matches the quoted and unquoted forms but
|
|
317
316
|
# requires paired quotes, so malformed or deliberately different values
|
|
@@ -76,6 +76,8 @@ _CODEX_REQUIRED_FILES = (
|
|
|
76
76
|
Path(".agents/plugins/marketplace.json"),
|
|
77
77
|
Path("plugin/.codex-plugin/plugin.json"),
|
|
78
78
|
Path("plugin/hooks/codex-hooks.json"),
|
|
79
|
+
Path("plugin/scripts/codex-claude-compat.py"),
|
|
80
|
+
Path("plugin/scripts/codex-hook.js"),
|
|
79
81
|
Path("plugin/scripts/_codex_env.sh"),
|
|
80
82
|
)
|
|
81
83
|
_COPYTREE_IGNORE = shutil.ignore_patterns(
|
|
@@ -110,7 +112,10 @@ def _seed_reflexio_env() -> list[str]:
|
|
|
110
112
|
_REFLEXIO_ENV_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
111
113
|
_REFLEXIO_ENV_PATH.touch(exist_ok=True)
|
|
112
114
|
existing = _REFLEXIO_ENV_PATH.read_text()
|
|
113
|
-
flags = (
|
|
115
|
+
flags = (
|
|
116
|
+
"CLAUDE_SMART_USE_LOCAL_CLI",
|
|
117
|
+
"CLAUDE_SMART_USE_LOCAL_EMBEDDING",
|
|
118
|
+
)
|
|
114
119
|
missing = [f for f in flags if f"{f}=" not in existing]
|
|
115
120
|
if not missing:
|
|
116
121
|
return []
|
|
@@ -8,7 +8,7 @@ import time
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Any
|
|
10
10
|
|
|
11
|
-
from claude_smart import cs_cite, ids, publish, runtime, state
|
|
11
|
+
from claude_smart import cs_cite, ids, internal_call, publish, runtime, state
|
|
12
12
|
|
|
13
13
|
_LOGGER = logging.getLogger(__name__)
|
|
14
14
|
|
|
@@ -112,6 +112,16 @@ def _scan_transcript_for_assistant_text(entries: list[dict[str, Any]]) -> str:
|
|
|
112
112
|
return "\n\n".join(parts)
|
|
113
113
|
|
|
114
114
|
|
|
115
|
+
def _scan_transcript_for_user_text(entries: list[dict[str, Any]]) -> str:
|
|
116
|
+
"""Return the user text that opened the current transcript turn."""
|
|
117
|
+
for entry in reversed(entries):
|
|
118
|
+
if not _is_user_turn_boundary(entry):
|
|
119
|
+
continue
|
|
120
|
+
message = entry.get("message") or {}
|
|
121
|
+
return "\n\n".join(_extract_text_blocks(message.get("content")))
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
|
|
115
125
|
def _is_user_turn_boundary(entry: dict[str, Any]) -> bool:
|
|
116
126
|
"""True if ``entry`` is the user message that opened the current turn.
|
|
117
127
|
|
|
@@ -350,6 +360,11 @@ def handle(payload: dict[str, Any]) -> None:
|
|
|
350
360
|
if path.is_file():
|
|
351
361
|
entries = _load_transcript_with_retry(path)
|
|
352
362
|
|
|
363
|
+
if runtime.is_codex():
|
|
364
|
+
prompt = payload.get("prompt") or _scan_transcript_for_user_text(entries)
|
|
365
|
+
if internal_call.is_codex_internal_prompt(prompt):
|
|
366
|
+
return
|
|
367
|
+
|
|
353
368
|
last_assistant_message = payload.get("last_assistant_message")
|
|
354
369
|
assistant_text = (
|
|
355
370
|
last_assistant_message
|
|
@@ -29,6 +29,9 @@ Detection signals, OR'd:
|
|
|
29
29
|
- ``payload.cwd`` resolves inside the reflexio submodule. Catches
|
|
30
30
|
direct interactive ``claude`` runs from inside reflexio (manual
|
|
31
31
|
debugging) that would otherwise pollute the corpus.
|
|
32
|
+
- Known Codex-internal prompt templates (title generation and home-screen
|
|
33
|
+
suggestions). These are model calls made by Codex itself, not user
|
|
34
|
+
coding turns, and must never be reflected into claude-smart memory.
|
|
32
35
|
"""
|
|
33
36
|
|
|
34
37
|
from __future__ import annotations
|
|
@@ -41,6 +44,19 @@ from claude_smart import runtime
|
|
|
41
44
|
|
|
42
45
|
_ENTRYPOINT_VAR = "CLAUDE_CODE_ENTRYPOINT"
|
|
43
46
|
_INTERACTIVE_ENTRYPOINT = "cli"
|
|
47
|
+
_CODEX_TITLE_PROMPT_PREFIX = (
|
|
48
|
+
"You are a helpful assistant. You will be presented with a user prompt, "
|
|
49
|
+
"and your job is to provide a short title for a task"
|
|
50
|
+
)
|
|
51
|
+
_CODEX_SUGGESTIONS_PROMPT_PREFIX = "# Overview\n\nGenerate 0 to 3 "
|
|
52
|
+
_CODEX_SUGGESTIONS_PROMPT_MARKER = (
|
|
53
|
+
"hyperpersonalized suggestions for what this user can do with Codex "
|
|
54
|
+
"in this local project:"
|
|
55
|
+
)
|
|
56
|
+
_CODEX_SUGGESTIONS_APPS_MARKER = (
|
|
57
|
+
"Get an understanding of the user's intent and goals by deeply viewing "
|
|
58
|
+
"their connected apps."
|
|
59
|
+
)
|
|
44
60
|
|
|
45
61
|
# Reflexio submodule lives at <repo>/reflexio when this package runs from
|
|
46
62
|
# a dev checkout (<repo>/plugin/src/claude_smart/internal_call.py); anchor
|
|
@@ -75,6 +91,8 @@ def is_internal_invocation(payload: dict[str, Any]) -> bool:
|
|
|
75
91
|
entrypoint = os.environ.get(_ENTRYPOINT_VAR)
|
|
76
92
|
if entrypoint and entrypoint != _INTERACTIVE_ENTRYPOINT:
|
|
77
93
|
return True
|
|
94
|
+
if runtime.is_codex() and is_codex_internal_prompt(payload.get("prompt")):
|
|
95
|
+
return True
|
|
78
96
|
cwd = payload.get("cwd")
|
|
79
97
|
if not isinstance(cwd, str) or not cwd:
|
|
80
98
|
return False
|
|
@@ -87,3 +105,15 @@ def is_internal_invocation(payload: dict[str, Any]) -> bool:
|
|
|
87
105
|
except ValueError:
|
|
88
106
|
return False
|
|
89
107
|
return True
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def is_codex_internal_prompt(prompt: Any) -> bool:
|
|
111
|
+
"""True for Codex's own UI/task prompts, not user-authored turns."""
|
|
112
|
+
if not isinstance(prompt, str):
|
|
113
|
+
return False
|
|
114
|
+
text = prompt.strip()
|
|
115
|
+
return text.startswith(_CODEX_TITLE_PROMPT_PREFIX) or (
|
|
116
|
+
text.startswith(_CODEX_SUGGESTIONS_PROMPT_PREFIX)
|
|
117
|
+
and _CODEX_SUGGESTIONS_PROMPT_MARKER in text
|
|
118
|
+
and _CODEX_SUGGESTIONS_APPS_MARKER in text
|
|
119
|
+
)
|
|
@@ -2,22 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Reflexio's ``LocalScriptAssistant`` sends one JSON payload on stdin and expects
|
|
4
4
|
one JSON object on stdout. This module bridges that protocol to a guarded
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
local CLI subprocess so candidate playbooks can be evaluated against the active
|
|
6
|
+
host without re-entering claude-smart/reflexio hooks.
|
|
7
7
|
"""
|
|
8
8
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import json
|
|
12
12
|
import os
|
|
13
|
+
from pathlib import Path
|
|
13
14
|
import shutil
|
|
14
15
|
import subprocess
|
|
15
16
|
import sys
|
|
17
|
+
import tempfile
|
|
16
18
|
from typing import Any
|
|
17
19
|
|
|
18
20
|
from claude_smart import internal_call, runtime
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
_CLI_TIMEOUT_SECONDS = 300
|
|
21
23
|
_READ_ONLY_TOOLS = "Read,Grep,Glob,LS"
|
|
22
24
|
_MUTATING_TOOLS = "Bash,Edit,Write,MultiEdit,NotebookEdit"
|
|
23
25
|
|
|
@@ -33,7 +35,7 @@ def main() -> int:
|
|
|
33
35
|
messages = _validated_list(payload, "messages")
|
|
34
36
|
playbooks = _validated_list(payload, "playbooks")
|
|
35
37
|
prompt, system_prompt = _build_prompt(messages, playbooks)
|
|
36
|
-
content =
|
|
38
|
+
content = _run_local_cli(prompt=prompt, system_prompt=system_prompt)
|
|
37
39
|
except Exception as exc: # noqa: BLE001 - script errors become LocalScript failures.
|
|
38
40
|
sys.stderr.write(f"{type(exc).__name__}: {exc}\n")
|
|
39
41
|
return 1
|
|
@@ -134,6 +136,12 @@ def _render_transcript(messages: list[dict[str, str]]) -> str:
|
|
|
134
136
|
)
|
|
135
137
|
|
|
136
138
|
|
|
139
|
+
def _run_local_cli(*, prompt: str, system_prompt: str) -> str:
|
|
140
|
+
if runtime.is_codex():
|
|
141
|
+
return _run_codex(prompt=prompt, system_prompt=system_prompt)
|
|
142
|
+
return _run_claude(prompt=prompt, system_prompt=system_prompt)
|
|
143
|
+
|
|
144
|
+
|
|
137
145
|
def _run_claude(*, prompt: str, system_prompt: str) -> str:
|
|
138
146
|
cli_path = shutil.which("claude") or "claude"
|
|
139
147
|
# This is an evaluation rollout, not a real user session: allow local
|
|
@@ -167,13 +175,13 @@ def _run_claude(*, prompt: str, system_prompt: str) -> str:
|
|
|
167
175
|
input=prompt,
|
|
168
176
|
capture_output=True,
|
|
169
177
|
text=True,
|
|
170
|
-
timeout=
|
|
178
|
+
timeout=_CLI_TIMEOUT_SECONDS,
|
|
171
179
|
check=False,
|
|
172
180
|
env=env,
|
|
173
181
|
)
|
|
174
182
|
except subprocess.TimeoutExpired as exc:
|
|
175
183
|
raise OptimizerAssistantError(
|
|
176
|
-
f"claude CLI timed out after {
|
|
184
|
+
f"claude CLI timed out after {_CLI_TIMEOUT_SECONDS}s"
|
|
177
185
|
) from exc
|
|
178
186
|
except FileNotFoundError as exc:
|
|
179
187
|
raise OptimizerAssistantError("claude CLI not found on PATH") from exc
|
|
@@ -199,5 +207,77 @@ def _run_claude(*, prompt: str, system_prompt: str) -> str:
|
|
|
199
207
|
return content
|
|
200
208
|
|
|
201
209
|
|
|
210
|
+
def _run_codex(*, prompt: str, system_prompt: str) -> str:
|
|
211
|
+
cli_path = shutil.which("codex") or "codex"
|
|
212
|
+
output_path = _temporary_output_path()
|
|
213
|
+
cmd = [
|
|
214
|
+
cli_path,
|
|
215
|
+
"exec",
|
|
216
|
+
"--sandbox",
|
|
217
|
+
"read-only",
|
|
218
|
+
"--skip-git-repo-check",
|
|
219
|
+
"--ephemeral",
|
|
220
|
+
"--ignore-rules",
|
|
221
|
+
"--output-last-message",
|
|
222
|
+
str(output_path),
|
|
223
|
+
"-",
|
|
224
|
+
]
|
|
225
|
+
|
|
226
|
+
env = os.environ.copy()
|
|
227
|
+
env[runtime.HOST_ENV] = runtime.HOST_CODEX
|
|
228
|
+
env[runtime.INTERNAL_ENV] = "1"
|
|
229
|
+
env[internal_call._ENTRYPOINT_VAR] = "optimizer" # noqa: SLF001
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
proc = subprocess.run( # noqa: S603 - command is fixed plus resolved executable.
|
|
233
|
+
cmd,
|
|
234
|
+
input=_codex_prompt(prompt=prompt, system_prompt=system_prompt),
|
|
235
|
+
capture_output=True,
|
|
236
|
+
text=True,
|
|
237
|
+
timeout=_CLI_TIMEOUT_SECONDS,
|
|
238
|
+
check=False,
|
|
239
|
+
env=env,
|
|
240
|
+
)
|
|
241
|
+
except subprocess.TimeoutExpired as exc:
|
|
242
|
+
raise OptimizerAssistantError(
|
|
243
|
+
f"codex CLI timed out after {_CLI_TIMEOUT_SECONDS}s"
|
|
244
|
+
) from exc
|
|
245
|
+
except FileNotFoundError as exc:
|
|
246
|
+
raise OptimizerAssistantError("codex CLI not found on PATH") from exc
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
content = output_path.read_text(encoding="utf-8").strip()
|
|
250
|
+
except OSError as exc:
|
|
251
|
+
raise OptimizerAssistantError("codex CLI did not write output") from exc
|
|
252
|
+
finally:
|
|
253
|
+
try:
|
|
254
|
+
output_path.unlink()
|
|
255
|
+
except OSError:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
if proc.returncode != 0:
|
|
259
|
+
stderr = proc.stderr.strip()
|
|
260
|
+
raise OptimizerAssistantError(
|
|
261
|
+
f"codex CLI exited {proc.returncode}: {stderr[:500]}"
|
|
262
|
+
)
|
|
263
|
+
if not content:
|
|
264
|
+
raise OptimizerAssistantError("codex CLI returned empty output")
|
|
265
|
+
return content
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _temporary_output_path() -> Path:
|
|
269
|
+
handle = tempfile.NamedTemporaryFile(prefix="claude-smart-codex-", delete=False)
|
|
270
|
+
try:
|
|
271
|
+
return Path(handle.name)
|
|
272
|
+
finally:
|
|
273
|
+
handle.close()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _codex_prompt(*, prompt: str, system_prompt: str) -> str:
|
|
277
|
+
if not system_prompt:
|
|
278
|
+
return prompt
|
|
279
|
+
return f"{system_prompt}\n\n## Task\n{prompt}"
|
|
280
|
+
|
|
281
|
+
|
|
202
282
|
if __name__ == "__main__":
|
|
203
283
|
raise SystemExit(main())
|