lazyclaw 4.3.0 → 5.0.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/README.ko.md +44 -0
- package/README.md +172 -508
- package/channels/handoff.mjs +41 -0
- package/channels/loader.mjs +124 -0
- package/channels/threads.mjs +116 -0
- package/cli.mjs +398 -6
- package/daemon.mjs +13 -0
- package/mas/agent_turn.mjs +28 -0
- package/mas/confidence.mjs +108 -0
- package/mas/index_db.mjs +242 -0
- package/mas/nudge.mjs +97 -0
- package/mas/prompt_stack.mjs +80 -0
- package/mas/skill_synth.mjs +124 -25
- package/mas/tool_runner.mjs +10 -61
- package/mas/tools/browser.mjs +77 -0
- package/mas/tools/clarify.mjs +36 -0
- package/mas/tools/coding.mjs +109 -0
- package/mas/tools/delegation.mjs +53 -0
- package/mas/tools/edit.mjs +36 -0
- package/mas/tools/git.mjs +110 -0
- package/mas/tools/ha.mjs +34 -0
- package/mas/tools/learning.mjs +168 -0
- package/mas/tools/media.mjs +105 -0
- package/mas/tools/os.mjs +152 -0
- package/mas/tools/patch.mjs +91 -0
- package/mas/tools/recall.mjs +103 -0
- package/mas/tools/registry.mjs +93 -0
- package/mas/tools/scheduling.mjs +62 -0
- package/mas/tools/web.mjs +137 -0
- package/mas/toolsets.mjs +64 -0
- package/mas/trajectory_export.mjs +169 -0
- package/mas/trajectory_store.mjs +179 -0
- package/mas/user_modeler.mjs +108 -0
- package/package.json +20 -3
- package/providers/codex_cli.mjs +200 -0
- package/providers/gemini_cli.mjs +179 -0
- package/providers/registry.mjs +61 -1
- package/sandbox/base.mjs +82 -0
- package/sandbox/confiners/bubblewrap.mjs +21 -0
- package/sandbox/confiners/firejail.mjs +16 -0
- package/sandbox/confiners/landlock.mjs +14 -0
- package/sandbox/confiners/seatbelt.mjs +28 -0
- package/sandbox/daytona.mjs +37 -0
- package/sandbox/docker.mjs +91 -0
- package/sandbox/index.mjs +67 -0
- package/sandbox/local.mjs +59 -0
- package/sandbox/modal.mjs +53 -0
- package/sandbox/singularity.mjs +39 -0
- package/sandbox/ssh.mjs +56 -0
- package/sandbox.mjs +11 -127
- package/scripts/hermes-import.mjs +111 -0
- package/scripts/migrate-v5.mjs +342 -0
- package/scripts/openclaw-import.mjs +71 -0
- package/sessions.mjs +20 -1
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// mas/trajectory_store.mjs — Phase A.
|
|
2
|
+
//
|
|
3
|
+
// Persists TrajectoryRecord (spec §3.3) to JSONL on disk plus an
|
|
4
|
+
// in-memory cache. Storage layout:
|
|
5
|
+
// <configDir>/trajectories/<YYYY-MM-DD>/<id>.jsonl
|
|
6
|
+
// One file per trajectory id (a ULID) so concurrent writers never
|
|
7
|
+
// contend. A single in-memory Map<id, record> serves hot reads; cold
|
|
8
|
+
// reads stream the file back through JSON.parse.
|
|
9
|
+
//
|
|
10
|
+
// Phase A scope: write, read, list-by-task. Recall by full-text query
|
|
11
|
+
// lives in mas/index_db.mjs (FTS5). The two stores share the same
|
|
12
|
+
// record but the FTS5 mirror is best-effort — disk JSONL is the
|
|
13
|
+
// source of truth.
|
|
14
|
+
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import crypto from 'node:crypto';
|
|
19
|
+
import { redactSecrets } from './redact.mjs';
|
|
20
|
+
import { indexTrajectory as _indexTrajectory } from './index_db.mjs';
|
|
21
|
+
|
|
22
|
+
export const OUTCOME_ENUM = Object.freeze(['done', 'failed', 'abandoned']);
|
|
23
|
+
|
|
24
|
+
const _cache = new Map(); // id → record (capped at CACHE_MAX entries)
|
|
25
|
+
const CACHE_MAX = 256;
|
|
26
|
+
|
|
27
|
+
function defaultConfigDir() {
|
|
28
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function trajectoriesDir(configDir) {
|
|
32
|
+
return path.join(configDir, 'trajectories');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Crockford-base32 ULID generator. Monotonic within a single ms by
|
|
36
|
+
// appending a counter — same-millisecond puts stay sortable.
|
|
37
|
+
const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
38
|
+
let _ulidLastMs = 0;
|
|
39
|
+
let _ulidCounter = 0;
|
|
40
|
+
let _ulidLastRandPrefix = '';
|
|
41
|
+
function ulid() {
|
|
42
|
+
let now = Date.now();
|
|
43
|
+
let sameMs = (now === _ulidLastMs);
|
|
44
|
+
if (sameMs) _ulidCounter++;
|
|
45
|
+
else { _ulidLastMs = now; _ulidCounter = 0; }
|
|
46
|
+
let timePart = '';
|
|
47
|
+
let t = now;
|
|
48
|
+
for (let i = 0; i < 10; i++) {
|
|
49
|
+
timePart = ULID_ALPHABET[t % 32] + timePart;
|
|
50
|
+
t = Math.floor(t / 32);
|
|
51
|
+
}
|
|
52
|
+
// Hold the random prefix stable within a single ms so the counter
|
|
53
|
+
// suffix is the ONLY thing that changes — that's what keeps same-ms
|
|
54
|
+
// ULIDs lexicographically sortable (canonical ULID monotonicity).
|
|
55
|
+
if (!sameMs) {
|
|
56
|
+
const rand = crypto.randomBytes(10);
|
|
57
|
+
let randPart = '';
|
|
58
|
+
for (let i = 0; i < 16; i++) {
|
|
59
|
+
randPart += ULID_ALPHABET[rand[i % 10] % 32];
|
|
60
|
+
}
|
|
61
|
+
_ulidLastRandPrefix = randPart.slice(0, 14);
|
|
62
|
+
}
|
|
63
|
+
// Counter suffix (last 2 chars) keeps monotonicity intra-ms.
|
|
64
|
+
const ctr = ULID_ALPHABET[(_ulidCounter >> 5) % 32]
|
|
65
|
+
+ ULID_ALPHABET[_ulidCounter % 32];
|
|
66
|
+
return timePart + _ulidLastRandPrefix + ctr;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function redactTurns(turns) {
|
|
70
|
+
return (turns || []).map(t => ({
|
|
71
|
+
...t,
|
|
72
|
+
content: typeof t.content === 'string' ? redactSecrets(t.content) : t.content,
|
|
73
|
+
thinking: typeof t.thinking === 'string' ? redactSecrets(t.thinking) : t.thinking,
|
|
74
|
+
toolCalls: (t.toolCalls || []).map(c => ({
|
|
75
|
+
...c,
|
|
76
|
+
result: typeof c.result === 'string' ? redactSecrets(c.result) : c.result,
|
|
77
|
+
})),
|
|
78
|
+
}));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function dateBucket(ms) {
|
|
82
|
+
return new Date(ms).toISOString().slice(0, 10); // YYYY-MM-DD
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function recordPath(configDir, bucket, id) {
|
|
86
|
+
return path.join(trajectoriesDir(configDir), bucket, `${id}.jsonl`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function cachePush(id, rec) {
|
|
90
|
+
_cache.set(id, rec);
|
|
91
|
+
if (_cache.size > CACHE_MAX) {
|
|
92
|
+
const oldest = _cache.keys().next().value;
|
|
93
|
+
_cache.delete(oldest);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export async function put(record, opts = {}) {
|
|
98
|
+
if (!record || typeof record !== 'object') {
|
|
99
|
+
throw new TypeError('trajectory_store.put: record must be an object');
|
|
100
|
+
}
|
|
101
|
+
if (!OUTCOME_ENUM.includes(record.outcome)) {
|
|
102
|
+
throw new Error(
|
|
103
|
+
`outcome must be one of ${OUTCOME_ENUM.join('|')}, got ${record.outcome}`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
const configDir = opts.configDir || defaultConfigDir();
|
|
107
|
+
const id = record.id || ulid();
|
|
108
|
+
const stored = {
|
|
109
|
+
...record,
|
|
110
|
+
id,
|
|
111
|
+
systemPrompt: typeof record.systemPrompt === 'string'
|
|
112
|
+
? redactSecrets(record.systemPrompt) : '',
|
|
113
|
+
userMessages: (record.userMessages || []).map(m =>
|
|
114
|
+
typeof m === 'string' ? redactSecrets(m) : m),
|
|
115
|
+
turns: redactTurns(record.turns),
|
|
116
|
+
finalAnswer: typeof record.finalAnswer === 'string'
|
|
117
|
+
? redactSecrets(record.finalAnswer) : '',
|
|
118
|
+
};
|
|
119
|
+
const bucket = dateBucket(stored.startedAt || Date.now());
|
|
120
|
+
const file = recordPath(configDir, bucket, id);
|
|
121
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
122
|
+
fs.writeFileSync(file, JSON.stringify(stored) + '\n');
|
|
123
|
+
// Phase A: FTS5 mirror (spec §4.4). Content is the concatenation of
|
|
124
|
+
// the final answer plus every turn's textual content so a single
|
|
125
|
+
// recall() can surface trajectories by either signal.
|
|
126
|
+
try {
|
|
127
|
+
const ftsContent = [
|
|
128
|
+
stored.finalAnswer || '',
|
|
129
|
+
...(stored.turns || []).map(t => String(t.content || '')),
|
|
130
|
+
].filter(Boolean).join('\n');
|
|
131
|
+
_indexTrajectory({
|
|
132
|
+
trajectory_id: id,
|
|
133
|
+
agent: stored.agentName || '',
|
|
134
|
+
outcome: stored.outcome,
|
|
135
|
+
content: ftsContent,
|
|
136
|
+
}, configDir);
|
|
137
|
+
} catch { /* swallow */ }
|
|
138
|
+
cachePush(id, stored);
|
|
139
|
+
return stored;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function get(id, opts = {}) {
|
|
143
|
+
if (_cache.has(id)) return _cache.get(id);
|
|
144
|
+
const configDir = opts.configDir || defaultConfigDir();
|
|
145
|
+
const root = trajectoriesDir(configDir);
|
|
146
|
+
if (!fs.existsSync(root)) return null;
|
|
147
|
+
for (const bucket of fs.readdirSync(root)) {
|
|
148
|
+
const file = recordPath(configDir, bucket, id);
|
|
149
|
+
if (fs.existsSync(file)) {
|
|
150
|
+
const raw = fs.readFileSync(file, 'utf8').trim();
|
|
151
|
+
const rec = JSON.parse(raw);
|
|
152
|
+
cachePush(id, rec);
|
|
153
|
+
return rec;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function listByTaskId(taskId, opts = {}) {
|
|
160
|
+
const configDir = opts.configDir || defaultConfigDir();
|
|
161
|
+
const root = trajectoriesDir(configDir);
|
|
162
|
+
if (!fs.existsSync(root)) return [];
|
|
163
|
+
const matches = [];
|
|
164
|
+
for (const bucket of fs.readdirSync(root).sort()) {
|
|
165
|
+
const bdir = path.join(root, bucket);
|
|
166
|
+
if (!fs.statSync(bdir).isDirectory()) continue;
|
|
167
|
+
for (const f of fs.readdirSync(bdir).sort()) {
|
|
168
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
169
|
+
try {
|
|
170
|
+
const rec = JSON.parse(fs.readFileSync(path.join(bdir, f), 'utf8'));
|
|
171
|
+
if (rec.taskId === taskId) matches.push(rec);
|
|
172
|
+
} catch { /* skip corrupt */ }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return matches;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Test/maintenance hook.
|
|
179
|
+
export function _resetCache() { _cache.clear(); }
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// User modeler — Phase B (v5 §4.10, §9.2, §0.1 C6).
|
|
2
|
+
//
|
|
3
|
+
// Honcho-equivalent. At session end, take the session's turns and ask
|
|
4
|
+
// the trainer to produce a dialectic update for ~/.lazyclaw/memory/USER.md:
|
|
5
|
+
//
|
|
6
|
+
// ## Thesis — durable facts the user just confirmed
|
|
7
|
+
// ## Antithesis — contradictions to prior model (if any)
|
|
8
|
+
// ## Synthesis — the reconciled, persisted summary
|
|
9
|
+
//
|
|
10
|
+
// The synthesis block is also fed to mas/index_db.mjs as a row of
|
|
11
|
+
// fts_memories with kind='user_model' so recall() can pull user facts
|
|
12
|
+
// at prompt assembly time.
|
|
13
|
+
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
|
|
18
|
+
import { runTextCompletion } from './provider_adapters.mjs';
|
|
19
|
+
import { redactSecrets, neutralizeRoleLabels } from './redact.mjs';
|
|
20
|
+
|
|
21
|
+
const USER_MD_REL = path.join('memory', 'USER.md');
|
|
22
|
+
const MAX_TRANSCRIPT_CHARS = 16 * 1024;
|
|
23
|
+
const MAX_USER_MD_BYTES = 32 * 1024;
|
|
24
|
+
|
|
25
|
+
export function defaultConfigDir() {
|
|
26
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function userModelPath(configDir = defaultConfigDir()) {
|
|
30
|
+
return path.join(configDir, USER_MD_REL);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function readUserModel(configDir = defaultConfigDir()) {
|
|
34
|
+
const p = userModelPath(configDir);
|
|
35
|
+
try { return fs.readFileSync(p, 'utf8'); } catch { return ''; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function flattenTurns(turns) {
|
|
39
|
+
if (!Array.isArray(turns) || turns.length === 0) return '';
|
|
40
|
+
const text = turns
|
|
41
|
+
.map((t) => {
|
|
42
|
+
const who = t.role === 'user' ? 'User' : t.role === 'assistant' ? 'Assistant' : (t.role || 'unknown');
|
|
43
|
+
return `[${who}] ${neutralizeRoleLabels(String(t.content || ''))}`;
|
|
44
|
+
})
|
|
45
|
+
.join('\n\n');
|
|
46
|
+
return redactSecrets(text).slice(0, MAX_TRANSCRIPT_CHARS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function updateUserModel({
|
|
50
|
+
sessionTurns,
|
|
51
|
+
provider,
|
|
52
|
+
model,
|
|
53
|
+
apiKey,
|
|
54
|
+
baseUrl,
|
|
55
|
+
fetchImpl,
|
|
56
|
+
configDir = defaultConfigDir(),
|
|
57
|
+
ts = new Date(),
|
|
58
|
+
} = {}) {
|
|
59
|
+
const transcript = flattenTurns(sessionTurns);
|
|
60
|
+
if (!transcript.trim()) return null;
|
|
61
|
+
|
|
62
|
+
const prior = readUserModel(configDir).slice(-8 * 1024);
|
|
63
|
+
const userMessage =
|
|
64
|
+
`Below is a recent session transcript and the current USER model. ` +
|
|
65
|
+
`Update the model using a dialectic structure. Reply in EXACTLY this format:\n\n` +
|
|
66
|
+
`## Thesis\n<bullets of new durable facts about the user>\n\n` +
|
|
67
|
+
`## Antithesis\n<bullets of contradictions with the prior model, if any; "(none)" if none>\n\n` +
|
|
68
|
+
`## Synthesis\n<the reconciled model, ≤ 20 bullets, suitable for permanent storage>\n\n` +
|
|
69
|
+
`Prior USER model (may be empty):\n\n` + (prior || '(empty)') + `\n\n` +
|
|
70
|
+
`Session transcript:\n\n` + transcript;
|
|
71
|
+
|
|
72
|
+
let raw;
|
|
73
|
+
try {
|
|
74
|
+
raw = await runTextCompletion({
|
|
75
|
+
provider, model, system: 'You maintain a durable user model.',
|
|
76
|
+
userMessage, apiKey, baseUrl, fetchImpl,
|
|
77
|
+
});
|
|
78
|
+
} catch (err) {
|
|
79
|
+
return { path: userModelPath(configDir), error: String(err?.message || err) };
|
|
80
|
+
}
|
|
81
|
+
const cleaned = redactSecrets(String(raw || '')).trim();
|
|
82
|
+
if (!cleaned || !/##\s*Synthesis/i.test(cleaned)) return null;
|
|
83
|
+
|
|
84
|
+
const date = (ts instanceof Date ? ts : new Date(ts)).toISOString().slice(0, 10);
|
|
85
|
+
const header = `# USER\n\n_Last updated ${date}_\n\n`;
|
|
86
|
+
let body = header + cleaned + '\n';
|
|
87
|
+
if (Buffer.byteLength(body, 'utf8') > MAX_USER_MD_BYTES) {
|
|
88
|
+
body = Buffer.from(body, 'utf8').subarray(0, MAX_USER_MD_BYTES).toString('utf8') + '\n…[truncated]\n';
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const p = userModelPath(configDir);
|
|
92
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
93
|
+
const tmp = p + '.tmp';
|
|
94
|
+
fs.writeFileSync(tmp, body);
|
|
95
|
+
fs.renameSync(tmp, p);
|
|
96
|
+
|
|
97
|
+
// Best-effort FTS5 mirror — only the Synthesis section is indexed.
|
|
98
|
+
try {
|
|
99
|
+
const synth = (cleaned.match(/##\s*Synthesis\s*\n([\s\S]*?)(?=\n##\s|$)/i) || [, ''])[1].trim();
|
|
100
|
+
if (synth) {
|
|
101
|
+
const idx = await import('./index_db.mjs');
|
|
102
|
+
idx.openIndex(configDir);
|
|
103
|
+
idx.indexMemory({ topic: 'USER', kind: 'user_model', content: synth }, configDir);
|
|
104
|
+
}
|
|
105
|
+
} catch { /* non-fatal */ }
|
|
106
|
+
|
|
107
|
+
return { path: p, body };
|
|
108
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lazyclaw",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "5.0.0",
|
|
4
4
|
"description": "Lazy, elegant terminal CLI for chatting with Claude / OpenAI / Gemini / Ollama, orchestrating multi-step LLM workflows, and running multi-agent Slack teams with cross-task memory. Banner-on-launch, slash-command ghost autocomplete, persistent sessions, local HTTP gateway.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
|
@@ -31,8 +31,12 @@
|
|
|
31
31
|
"lazyclaw": "cli.mjs"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"test": "playwright test",
|
|
35
|
-
"test:bench": "node scripts/bench-providers.mjs"
|
|
34
|
+
"test": "node --test tests/phaseC-*.test.mjs && playwright test",
|
|
35
|
+
"test:bench": "node scripts/bench-providers.mjs",
|
|
36
|
+
"test:bench:index": "node --test tests/index_store.bench.mjs",
|
|
37
|
+
"test:perf": "node --test tests/phaseH-perf.test.mjs tests/index_store.bench.mjs",
|
|
38
|
+
"migrate:v5": "node scripts/migrate-v5.mjs",
|
|
39
|
+
"build:splash": "node scripts/build-splash.mjs"
|
|
36
40
|
},
|
|
37
41
|
"files": [
|
|
38
42
|
"cli.mjs",
|
|
@@ -47,6 +51,7 @@
|
|
|
47
51
|
"workspace.mjs",
|
|
48
52
|
"browse.mjs",
|
|
49
53
|
"sandbox.mjs",
|
|
54
|
+
"sandbox/",
|
|
50
55
|
"skills_install.mjs",
|
|
51
56
|
"skills_curator.mjs",
|
|
52
57
|
"cron.mjs",
|
|
@@ -65,12 +70,24 @@
|
|
|
65
70
|
"gateway/",
|
|
66
71
|
"docs/multi-agent.md",
|
|
67
72
|
"scripts/loop-worker.mjs",
|
|
73
|
+
"scripts/migrate-v5.mjs",
|
|
74
|
+
"scripts/hermes-import.mjs",
|
|
75
|
+
"scripts/openclaw-import.mjs",
|
|
68
76
|
"README.md",
|
|
69
77
|
"LICENSE"
|
|
70
78
|
],
|
|
71
79
|
"engines": {
|
|
72
80
|
"node": ">=18"
|
|
73
81
|
},
|
|
82
|
+
"dependencies": {
|
|
83
|
+
"@modelcontextprotocol/sdk": "^1.18.0",
|
|
84
|
+
"better-sqlite3": "^11.10.0",
|
|
85
|
+
"chalk": "^5.3.0",
|
|
86
|
+
"ink": "^5.0.1",
|
|
87
|
+
"node-ssh": "^13.2.0",
|
|
88
|
+
"string-width": "^7.2.0",
|
|
89
|
+
"undici": "^6.21.0"
|
|
90
|
+
},
|
|
74
91
|
"devDependencies": {
|
|
75
92
|
"@playwright/test": "^1.59.1",
|
|
76
93
|
"@types/node": "^25.6.0",
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Codex subscription/CLI provider (no API key).
|
|
2
|
+
//
|
|
3
|
+
// Spawns the local `codex` CLI binary (OpenAI's Codex CLI shipped from
|
|
4
|
+
// npm: @openai/codex) in non-interactive mode:
|
|
5
|
+
//
|
|
6
|
+
// codex exec --skip-git-repo-check --json [-m model] "<prompt>"
|
|
7
|
+
//
|
|
8
|
+
// Auth is whatever `codex` is signed into — a ChatGPT Plus/Pro/Business
|
|
9
|
+
// session bound to ~/.codex/auth — so no OPENAI_API_KEY is required and
|
|
10
|
+
// no key lands in the lazyclaw config. This is the CLI counterpart to
|
|
11
|
+
// providers/openai.mjs (which talks to api.openai.com and needs an API
|
|
12
|
+
// key).
|
|
13
|
+
//
|
|
14
|
+
// Output protocol (NDJSON on stdout, one JSON object per line):
|
|
15
|
+
// {"type":"thread.started", "thread_id": "..."}
|
|
16
|
+
// {"type":"turn.started"}
|
|
17
|
+
// {"type":"item.completed", "item": {"type":"agent_message","text":"..."}}
|
|
18
|
+
// {"type":"turn.completed", "usage": {...}}
|
|
19
|
+
//
|
|
20
|
+
// We stream-parse line-by-line and yield the `text` field of every
|
|
21
|
+
// agent_message item. Reasoning items (`item.type === 'reasoning'`)
|
|
22
|
+
// are skipped — they're internal thought summaries, not the visible
|
|
23
|
+
// answer. Usage from turn.completed is surfaced via onUsage.
|
|
24
|
+
//
|
|
25
|
+
// Why `--skip-git-repo-check` is hard-coded: lazyclaw orchestrator
|
|
26
|
+
// runs workers from scratch dirs (often /tmp) that aren't git repos,
|
|
27
|
+
// and codex's default git-repo gate would reject every such spawn.
|
|
28
|
+
// The flag is a UX shim for headless invocation, not a security
|
|
29
|
+
// downgrade — we're handing codex our own prompt, not user-trusted
|
|
30
|
+
// shell input.
|
|
31
|
+
|
|
32
|
+
import { spawnSandboxed } from '../sandbox.mjs';
|
|
33
|
+
|
|
34
|
+
class AbortError extends Error {
|
|
35
|
+
constructor(message = 'aborted') {
|
|
36
|
+
super(message);
|
|
37
|
+
this.name = 'AbortError';
|
|
38
|
+
this.code = 'ABORT';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
class CliMissingError extends Error {
|
|
43
|
+
constructor() {
|
|
44
|
+
super('codex CLI not found in PATH — install @openai/codex or use the openai API provider');
|
|
45
|
+
this.name = 'CodexCliMissingError';
|
|
46
|
+
this.code = 'CLI_MISSING';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class CliExitError extends Error {
|
|
51
|
+
constructor(code, signal, stderr) {
|
|
52
|
+
super(`codex CLI exited ${code ?? signal}: ${String(stderr).slice(0, 400)}`);
|
|
53
|
+
this.name = 'CodexCliExitError';
|
|
54
|
+
this.code = 'CLI_EXIT';
|
|
55
|
+
this.exitCode = code;
|
|
56
|
+
this.signal = signal;
|
|
57
|
+
this.stderr = stderr;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Map a few friendly aliases. Codex CLI accepts the literal model id
|
|
62
|
+
// (e.g. gpt-5-codex) directly, so unknown inputs pass through.
|
|
63
|
+
const _ALIASES = {
|
|
64
|
+
codex: 'gpt-5-codex',
|
|
65
|
+
'gpt-codex': 'gpt-5-codex',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// Drop cross-vendor model ids (claude-*, gemini-*) silently so the
|
|
69
|
+
// CLI falls back to its own default. The orchestrator workflow forwards
|
|
70
|
+
// cfg.model verbatim to every worker, and `providers test` does the
|
|
71
|
+
// same — both would otherwise crash here when cfg.model is Anthropic
|
|
72
|
+
// or Google.
|
|
73
|
+
function resolveModel(model) {
|
|
74
|
+
if (!model) return '';
|
|
75
|
+
const lower = String(model).toLowerCase();
|
|
76
|
+
if (_ALIASES[lower]) return _ALIASES[lower];
|
|
77
|
+
if (
|
|
78
|
+
lower.startsWith('gpt-') ||
|
|
79
|
+
lower.startsWith('o1') ||
|
|
80
|
+
lower.startsWith('o3') ||
|
|
81
|
+
lower.startsWith('o4')
|
|
82
|
+
) return String(model);
|
|
83
|
+
return '';
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function buildPrompt(messages, system) {
|
|
87
|
+
const parts = [];
|
|
88
|
+
if (system) parts.push(`[System instructions: ${system}]`);
|
|
89
|
+
for (const m of messages) {
|
|
90
|
+
if (!m || !m.content) continue;
|
|
91
|
+
if (m.role === 'system' && !system) parts.push(`[System instructions: ${m.content}]`);
|
|
92
|
+
else if (m.role === 'user') parts.push(`User: ${m.content}`);
|
|
93
|
+
else if (m.role === 'assistant') parts.push(`Assistant: ${m.content}`);
|
|
94
|
+
}
|
|
95
|
+
return parts.length ? parts.join('\n') + '\n\nAssistant:' : '';
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Pull text out of a single NDJSON event. We only surface agent_message
|
|
99
|
+
// items (the visible answer). Reasoning summaries are intentionally
|
|
100
|
+
// dropped so the orchestrator's planner doesn't see the model's
|
|
101
|
+
// internal deliberation as part of "the worker's answer".
|
|
102
|
+
function extractEventText(obj) {
|
|
103
|
+
if (!obj || typeof obj !== 'object') return '';
|
|
104
|
+
if (obj.type !== 'item.completed') return '';
|
|
105
|
+
const item = obj.item || {};
|
|
106
|
+
if (item.type === 'agent_message' && typeof item.text === 'string') return item.text;
|
|
107
|
+
return '';
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractUsage(obj) {
|
|
111
|
+
if (!obj || typeof obj !== 'object' || obj.type !== 'turn.completed') return null;
|
|
112
|
+
const u = obj.usage || {};
|
|
113
|
+
const input = (u.input_tokens ?? 0) + (u.cached_input_tokens ?? 0);
|
|
114
|
+
const output = (u.output_tokens ?? 0) + (u.reasoning_output_tokens ?? 0);
|
|
115
|
+
if (!input && !output) return null;
|
|
116
|
+
return { inputTokens: input, outputTokens: output, totalCostUsd: 0 };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const codexCliProvider = {
|
|
120
|
+
name: 'codex-cli',
|
|
121
|
+
async *sendMessage(messages, opts = {}) {
|
|
122
|
+
const bin = opts.bin || 'codex';
|
|
123
|
+
const prompt = buildPrompt(messages, opts.system || messages.find(m => m.role === 'system')?.content);
|
|
124
|
+
if (!prompt) return;
|
|
125
|
+
|
|
126
|
+
const args = ['exec', '--skip-git-repo-check', '--json'];
|
|
127
|
+
const model = resolveModel(opts.model);
|
|
128
|
+
if (model) args.push('-m', model);
|
|
129
|
+
args.push(prompt);
|
|
130
|
+
|
|
131
|
+
if (opts.signal?.aborted) throw new AbortError('aborted before spawn');
|
|
132
|
+
|
|
133
|
+
let proc;
|
|
134
|
+
try {
|
|
135
|
+
proc = spawnSandboxed(opts.sandbox || null, bin, args, {
|
|
136
|
+
cwd: opts.cwd || process.cwd(),
|
|
137
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
138
|
+
});
|
|
139
|
+
} catch (err) {
|
|
140
|
+
if (err && err.code === 'ENOENT') throw new CliMissingError();
|
|
141
|
+
throw err;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const onAbort = () => { try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ } };
|
|
145
|
+
if (opts.signal) opts.signal.addEventListener('abort', onAbort);
|
|
146
|
+
|
|
147
|
+
let stderr = '';
|
|
148
|
+
proc.stderr.setEncoding('utf8');
|
|
149
|
+
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
150
|
+
|
|
151
|
+
proc.stdout.setEncoding('utf8');
|
|
152
|
+
let buffer = '';
|
|
153
|
+
let exitInfo = null;
|
|
154
|
+
const exitPromise = new Promise((resolve) => {
|
|
155
|
+
proc.on('close', (code, signal) => {
|
|
156
|
+
exitInfo = { code, signal };
|
|
157
|
+
resolve();
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
for await (const chunk of proc.stdout) {
|
|
163
|
+
if (opts.signal?.aborted) throw new AbortError('aborted mid-stream');
|
|
164
|
+
buffer += chunk;
|
|
165
|
+
let nl;
|
|
166
|
+
while ((nl = buffer.indexOf('\n')) >= 0) {
|
|
167
|
+
const line = buffer.slice(0, nl).trim();
|
|
168
|
+
buffer = buffer.slice(nl + 1);
|
|
169
|
+
if (!line) continue;
|
|
170
|
+
let obj;
|
|
171
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
172
|
+
const text = extractEventText(obj);
|
|
173
|
+
if (text) yield text;
|
|
174
|
+
const usage = extractUsage(obj);
|
|
175
|
+
if (usage && typeof opts.onUsage === 'function') {
|
|
176
|
+
try { opts.onUsage(usage); } catch (_) { /* never break stream on usage */ }
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
if (buffer.trim()) {
|
|
181
|
+
try {
|
|
182
|
+
const obj = JSON.parse(buffer.trim());
|
|
183
|
+
const text = extractEventText(obj);
|
|
184
|
+
if (text) yield text;
|
|
185
|
+
} catch (_) { /* incomplete tail — drop */ }
|
|
186
|
+
}
|
|
187
|
+
await exitPromise;
|
|
188
|
+
if (exitInfo && exitInfo.code !== 0 && !opts.signal?.aborted) {
|
|
189
|
+
throw new CliExitError(exitInfo.code, exitInfo.signal, stderr);
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
if (opts.signal) opts.signal.removeEventListener('abort', onAbort);
|
|
193
|
+
if (!proc.killed && exitInfo === null) {
|
|
194
|
+
try { proc.kill('SIGTERM'); } catch (_) { /* ignore */ }
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export { CliMissingError, CliExitError, AbortError, resolveModel, buildPrompt, extractEventText, extractUsage };
|