lazyclaw 3.99.27 → 4.2.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.md +128 -2
- package/agents.mjs +179 -0
- package/channels/base.mjs +120 -0
- package/channels/http.mjs +54 -0
- package/channels/slack.mjs +386 -0
- package/channels/stub.mjs +52 -0
- package/cli.mjs +1636 -77
- package/daemon.mjs +171 -0
- package/docs/multi-agent.md +256 -0
- package/goals.mjs +128 -0
- package/loop-engine.mjs +182 -0
- package/loops.mjs +135 -0
- package/mas/agent_memory.mjs +188 -0
- package/mas/agent_turn.mjs +141 -0
- package/mas/audit.mjs +62 -0
- package/mas/mention_router.mjs +360 -0
- package/mas/tool_runner.mjs +87 -0
- package/mas/tools/bash.mjs +78 -0
- package/mas/tools/grep.mjs +91 -0
- package/mas/tools/read.mjs +45 -0
- package/mas/tools/write.mjs +42 -0
- package/memory.mjs +193 -0
- package/package.json +26 -6
- package/providers/registry.mjs +8 -1
- package/providers/tool_use/anthropic.mjs +151 -0
- package/providers/tool_use/gemini.mjs +189 -0
- package/providers/tool_use/openai.mjs +140 -0
- package/scripts/loop-worker.mjs +160 -0
- package/sessions.mjs +5 -0
- package/tasks.mjs +220 -0
- package/teams.mjs +199 -0
- package/web/dashboard.html +166 -0
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// Gemini tool-use adapter.
|
|
2
|
+
//
|
|
3
|
+
// Calls POST /v1beta/models/{model}:generateContent non-streaming and
|
|
4
|
+
// normalises the response into the agent_turn envelope shape:
|
|
5
|
+
// { kind: 'final', text }
|
|
6
|
+
// { kind: 'tool_calls', text?, calls, assistantContent }
|
|
7
|
+
//
|
|
8
|
+
// Wire-level peculiarities this module hides:
|
|
9
|
+
// - role names are "user" and "model" (not "assistant")
|
|
10
|
+
// - message body is `parts: [{text}, {functionCall}, ...]`
|
|
11
|
+
// - functionCall.args is a native JSON object (no string parsing)
|
|
12
|
+
// - functionResponse goes back in a role:user message inside its own
|
|
13
|
+
// `parts` block; correlation is by `name`, not an id
|
|
14
|
+
// - system prompt rides on `system_instruction.parts[0].text`
|
|
15
|
+
// - tools are declared inside ONE `tools[0].function_declarations` list
|
|
16
|
+
// - some JSON-Schema keys (`additionalProperties`, `$schema`,
|
|
17
|
+
// `examples`) are rejected by Gemini; we strip them before sending
|
|
18
|
+
//
|
|
19
|
+
// Docs: https://ai.google.dev/api/rest/v1beta/models/generateContent
|
|
20
|
+
|
|
21
|
+
const DEFAULT_BASE = 'https://generativelanguage.googleapis.com/v1beta';
|
|
22
|
+
|
|
23
|
+
export class GeminiToolUseError extends Error {
|
|
24
|
+
constructor(message, code, body) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = 'GeminiToolUseError';
|
|
27
|
+
this.code = code || 'GEMINI_ERR';
|
|
28
|
+
if (body) this.body = body;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Gemini's function_declarations.parameters schema is a strict subset of
|
|
33
|
+
// JSON Schema. Strip the keys it rejects so a registry-shared schema can
|
|
34
|
+
// be reused across providers without forcing the runner to know about
|
|
35
|
+
// per-provider quirks.
|
|
36
|
+
function sanitizeGeminiSchema(schema) {
|
|
37
|
+
if (!schema || typeof schema !== 'object') return schema;
|
|
38
|
+
if (Array.isArray(schema)) return schema.map(sanitizeGeminiSchema);
|
|
39
|
+
const { additionalProperties, $schema, examples, ...rest } = schema;
|
|
40
|
+
const out = {};
|
|
41
|
+
for (const [k, v] of Object.entries(rest)) {
|
|
42
|
+
out[k] = sanitizeGeminiSchema(v);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function toGeminiTools(schemas) {
|
|
48
|
+
if (!schemas || schemas.length === 0) return [];
|
|
49
|
+
const declarations = schemas.map((s) => ({
|
|
50
|
+
name: s.name,
|
|
51
|
+
description: s.description,
|
|
52
|
+
parameters: sanitizeGeminiSchema(s.parameters),
|
|
53
|
+
}));
|
|
54
|
+
return [{ function_declarations: declarations }];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// agent_turn passes history as plain [{role:'user'|'assistant', content:'text'}].
|
|
58
|
+
// Convert each entry into Gemini's parts shape. Anything that looks like
|
|
59
|
+
// an already-Gemini message (has `parts`) is passed through unchanged so
|
|
60
|
+
// callers can pre-build native turns when they need to.
|
|
61
|
+
export function normalizeHistory(turns) {
|
|
62
|
+
if (!Array.isArray(turns)) return [];
|
|
63
|
+
return turns.map((t) => {
|
|
64
|
+
if (t && typeof t === 'object' && Array.isArray(t.parts)) return t;
|
|
65
|
+
const role = t?.role === 'assistant' ? 'model' : (t?.role || 'user');
|
|
66
|
+
const text = typeof t?.content === 'string' ? t.content : '';
|
|
67
|
+
return { role, parts: [{ text }] };
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function initialUserMessage(text) {
|
|
72
|
+
return { role: 'user', parts: [{ text: String(text) }] };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function callOnce({
|
|
76
|
+
messages,
|
|
77
|
+
tools = [],
|
|
78
|
+
model,
|
|
79
|
+
apiKey,
|
|
80
|
+
system,
|
|
81
|
+
baseUrl,
|
|
82
|
+
fetchImpl,
|
|
83
|
+
signal,
|
|
84
|
+
} = {}) {
|
|
85
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
86
|
+
throw new GeminiToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
|
|
87
|
+
}
|
|
88
|
+
if (!apiKey) {
|
|
89
|
+
throw new GeminiToolUseError('apiKey is required', 'NO_API_KEY');
|
|
90
|
+
}
|
|
91
|
+
const m = model || 'gemini-2.5-flash';
|
|
92
|
+
const url = `${(baseUrl || DEFAULT_BASE).replace(/\/$/, '')}/models/${encodeURIComponent(m)}:generateContent`;
|
|
93
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
94
|
+
|
|
95
|
+
const body = { contents: messages };
|
|
96
|
+
if (tools && tools.length) body.tools = tools;
|
|
97
|
+
if (system && String(system).trim()) {
|
|
98
|
+
body.system_instruction = { parts: [{ text: String(system) }] };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const res = await fetchFn(url, {
|
|
102
|
+
method: 'POST',
|
|
103
|
+
headers: {
|
|
104
|
+
'Content-Type': 'application/json',
|
|
105
|
+
'x-goog-api-key': apiKey,
|
|
106
|
+
},
|
|
107
|
+
body: JSON.stringify(body),
|
|
108
|
+
signal,
|
|
109
|
+
});
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
let raw = '';
|
|
112
|
+
try { raw = await res.text(); } catch { /* ignore */ }
|
|
113
|
+
throw new GeminiToolUseError(`HTTP ${res.status}: ${raw.slice(0, 300)}`, 'HTTP_FAIL', raw);
|
|
114
|
+
}
|
|
115
|
+
const json = await res.json();
|
|
116
|
+
return parseResponse(json);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function parseResponse(json) {
|
|
120
|
+
const candidate = json?.candidates?.[0];
|
|
121
|
+
const parts = candidate?.content?.parts || [];
|
|
122
|
+
const textParts = [];
|
|
123
|
+
const calls = [];
|
|
124
|
+
for (const p of parts) {
|
|
125
|
+
if (!p || typeof p !== 'object') continue;
|
|
126
|
+
if (typeof p.text === 'string') textParts.push(p.text);
|
|
127
|
+
else if (p.functionCall && p.functionCall.name) {
|
|
128
|
+
// Gemini has no `id` field; we synthesise a stable one from the
|
|
129
|
+
// call index + name so multi-call turns can still be correlated
|
|
130
|
+
// by the runner. We strip the id again before sending the
|
|
131
|
+
// functionResponse back (Gemini matches by name).
|
|
132
|
+
calls.push({
|
|
133
|
+
id: `gem_${calls.length}_${p.functionCall.name}`,
|
|
134
|
+
name: p.functionCall.name,
|
|
135
|
+
input: p.functionCall.args || {},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const text = textParts.join('');
|
|
140
|
+
if (calls.length === 0) {
|
|
141
|
+
return { kind: 'final', text, raw: json };
|
|
142
|
+
}
|
|
143
|
+
// assistantContent is the entire model turn so the runner can echo it
|
|
144
|
+
// back into `contents` for the next request.
|
|
145
|
+
return {
|
|
146
|
+
kind: 'tool_calls',
|
|
147
|
+
text,
|
|
148
|
+
calls,
|
|
149
|
+
assistantContent: candidate?.content || { role: 'model', parts },
|
|
150
|
+
raw: json,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function assistantTurnMessages(resp) {
|
|
155
|
+
// Echo the model turn straight back so the next request includes the
|
|
156
|
+
// functionCall parts the API expects to see paired with responses.
|
|
157
|
+
return [resp.assistantContent];
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function toolResultMessages(results) {
|
|
161
|
+
// All tool responses fit into a SINGLE user-role message whose parts
|
|
162
|
+
// are functionResponse entries. Gemini matches by name, so we drop
|
|
163
|
+
// the synthetic id.
|
|
164
|
+
const parts = (results || []).map((r) => ({
|
|
165
|
+
functionResponse: {
|
|
166
|
+
name: nameFromSyntheticId(r.id),
|
|
167
|
+
response: normaliseToolResponse(r.content, r.isError),
|
|
168
|
+
},
|
|
169
|
+
}));
|
|
170
|
+
return [{ role: 'user', parts }];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function nameFromSyntheticId(id) {
|
|
174
|
+
if (typeof id !== 'string') return String(id || 'unknown');
|
|
175
|
+
// synthetic shape: `gem_<idx>_<name>`. Strip the prefix.
|
|
176
|
+
const m = /^gem_\d+_(.+)$/.exec(id);
|
|
177
|
+
return m ? m[1] : id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function normaliseToolResponse(content, isError) {
|
|
181
|
+
// Gemini wants an object, not a free-form string. Wrap primitives.
|
|
182
|
+
let body;
|
|
183
|
+
if (content === null || content === undefined) body = {};
|
|
184
|
+
else if (typeof content === 'string') body = { content };
|
|
185
|
+
else if (typeof content === 'object') body = content;
|
|
186
|
+
else body = { content: String(content) };
|
|
187
|
+
if (isError) return { ...body, is_error: true };
|
|
188
|
+
return body;
|
|
189
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// OpenAI tool-use adapter.
|
|
2
|
+
//
|
|
3
|
+
// Calls POST /v1/chat/completions non-streaming and normalises the
|
|
4
|
+
// response into the same envelope shape Anthropic's adapter uses:
|
|
5
|
+
// { kind: 'final', text }
|
|
6
|
+
// { kind: 'tool_calls', text?, calls, assistantContent }
|
|
7
|
+
//
|
|
8
|
+
// Wire-level differences vs Anthropic that this module hides:
|
|
9
|
+
// - `function.arguments` is a JSON STRING; we parse it.
|
|
10
|
+
// - `content` on the assistant message can be null when tool_calls is
|
|
11
|
+
// present; downstream code receives `text: ''` in that case.
|
|
12
|
+
// - Each tool result becomes its own message with role='tool' and the
|
|
13
|
+
// `tool_call_id` field — so `toolResultMessages()` returns N
|
|
14
|
+
// entries (vs Anthropic's single user-message wrapper).
|
|
15
|
+
//
|
|
16
|
+
// Docs: https://platform.openai.com/docs/guides/function-calling
|
|
17
|
+
|
|
18
|
+
const DEFAULT_BASE = 'https://api.openai.com/v1';
|
|
19
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
20
|
+
|
|
21
|
+
export class OpenAIToolUseError extends Error {
|
|
22
|
+
constructor(message, code, body) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = 'OpenAIToolUseError';
|
|
25
|
+
this.code = code || 'OPENAI_ERR';
|
|
26
|
+
if (body) this.body = body;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function toOpenAITools(schemas) {
|
|
31
|
+
return (schemas || []).map((s) => ({
|
|
32
|
+
type: 'function',
|
|
33
|
+
function: {
|
|
34
|
+
name: s.name,
|
|
35
|
+
description: s.description,
|
|
36
|
+
parameters: s.parameters,
|
|
37
|
+
},
|
|
38
|
+
}));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export async function callOnce({
|
|
42
|
+
messages,
|
|
43
|
+
tools = [],
|
|
44
|
+
model,
|
|
45
|
+
apiKey,
|
|
46
|
+
system,
|
|
47
|
+
maxTokens = DEFAULT_MAX_TOKENS,
|
|
48
|
+
baseUrl,
|
|
49
|
+
fetchImpl,
|
|
50
|
+
signal,
|
|
51
|
+
} = {}) {
|
|
52
|
+
if (!Array.isArray(messages) || messages.length === 0) {
|
|
53
|
+
throw new OpenAIToolUseError('messages[] is required and non-empty', 'NO_MESSAGES');
|
|
54
|
+
}
|
|
55
|
+
if (!apiKey) {
|
|
56
|
+
throw new OpenAIToolUseError('apiKey is required', 'NO_API_KEY');
|
|
57
|
+
}
|
|
58
|
+
const url = `${(baseUrl || DEFAULT_BASE).replace(/\/$/, '')}/chat/completions`;
|
|
59
|
+
const fetchFn = fetchImpl || globalThis.fetch;
|
|
60
|
+
|
|
61
|
+
// OpenAI carries the system prompt as the first message rather than a
|
|
62
|
+
// separate field. We only prepend when the caller has NOT already
|
|
63
|
+
// injected one (history replays may include the system turn).
|
|
64
|
+
const fullMessages = [];
|
|
65
|
+
const hasSystem = messages.some((m) => m?.role === 'system');
|
|
66
|
+
if (!hasSystem && system && String(system).trim()) {
|
|
67
|
+
fullMessages.push({ role: 'system', content: String(system) });
|
|
68
|
+
}
|
|
69
|
+
fullMessages.push(...messages);
|
|
70
|
+
|
|
71
|
+
const body = {
|
|
72
|
+
model: model || 'gpt-4.1',
|
|
73
|
+
messages: fullMessages,
|
|
74
|
+
max_tokens: maxTokens,
|
|
75
|
+
};
|
|
76
|
+
if (tools && tools.length) body.tools = tools;
|
|
77
|
+
|
|
78
|
+
const res = await fetchFn(url, {
|
|
79
|
+
method: 'POST',
|
|
80
|
+
headers: {
|
|
81
|
+
'Content-Type': 'application/json',
|
|
82
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
83
|
+
},
|
|
84
|
+
body: JSON.stringify(body),
|
|
85
|
+
signal,
|
|
86
|
+
});
|
|
87
|
+
if (!res.ok) {
|
|
88
|
+
let raw = '';
|
|
89
|
+
try { raw = await res.text(); } catch { /* ignore */ }
|
|
90
|
+
throw new OpenAIToolUseError(`HTTP ${res.status}: ${raw.slice(0, 300)}`, 'HTTP_FAIL', raw);
|
|
91
|
+
}
|
|
92
|
+
const json = await res.json();
|
|
93
|
+
return parseResponse(json);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseResponse(json) {
|
|
97
|
+
const choice = Array.isArray(json?.choices) ? json.choices[0] : null;
|
|
98
|
+
const msg = choice?.message || {};
|
|
99
|
+
const rawToolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
|
100
|
+
const text = typeof msg.content === 'string' ? msg.content : '';
|
|
101
|
+
if (rawToolCalls.length === 0) {
|
|
102
|
+
return { kind: 'final', text, raw: json };
|
|
103
|
+
}
|
|
104
|
+
const calls = rawToolCalls.map((tc) => {
|
|
105
|
+
let input = {};
|
|
106
|
+
const a = tc?.function?.arguments;
|
|
107
|
+
if (typeof a === 'string') {
|
|
108
|
+
try { input = JSON.parse(a); } catch { input = {}; }
|
|
109
|
+
} else if (a && typeof a === 'object') {
|
|
110
|
+
input = a;
|
|
111
|
+
}
|
|
112
|
+
return { id: tc.id, name: tc?.function?.name, input };
|
|
113
|
+
});
|
|
114
|
+
return { kind: 'tool_calls', text, calls, assistantContent: msg, raw: json };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// OpenAI accepts the agent_turn-native {role, content} shape directly.
|
|
118
|
+
export function normalizeHistory(turns) {
|
|
119
|
+
return Array.isArray(turns) ? [...turns] : [];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function initialUserMessage(text) {
|
|
123
|
+
return { role: 'user', content: String(text) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Echo the assistant turn so the next request preserves the model's
|
|
127
|
+
// reasoning and tool_calls ids for correlation. OpenAI already gives us
|
|
128
|
+
// a full message object; we just wrap it.
|
|
129
|
+
export function assistantTurnMessages(resp) {
|
|
130
|
+
return [resp.assistantContent];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Each tool result becomes its own role:tool message.
|
|
134
|
+
export function toolResultMessages(results) {
|
|
135
|
+
return (results || []).map((r) => ({
|
|
136
|
+
role: 'tool',
|
|
137
|
+
tool_call_id: r.id,
|
|
138
|
+
content: typeof r.content === 'string' ? r.content : JSON.stringify(r.content),
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Detached worker for `lazyclaw loop --detach`.
|
|
3
|
+
//
|
|
4
|
+
// Invoked by the parent CLI with the same provider/model the parent
|
|
5
|
+
// resolved, plus a loop id pointing at the state directory the parent
|
|
6
|
+
// pre-created. Streams nothing to stdout — we are headless. Every
|
|
7
|
+
// iteration's outcome lands in iterations.log, and the final disposition
|
|
8
|
+
// lands in result.json + meta.status. SIGTERM flips status to `killed`
|
|
9
|
+
// and unwinds cleanly so a follow-up SIGKILL is rarely needed.
|
|
10
|
+
//
|
|
11
|
+
// Argv contract (all required except --until / --session-existing):
|
|
12
|
+
// --loop-id <id>
|
|
13
|
+
// --prompt <text>
|
|
14
|
+
// --max <N>
|
|
15
|
+
// --provider <name>
|
|
16
|
+
// --until <regex>
|
|
17
|
+
// --session-existing <id> reuse the named chat session
|
|
18
|
+
// --cfg-dir <path> override LAZYCLAW_CONFIG_DIR
|
|
19
|
+
// --model <name> provider-specific model name
|
|
20
|
+
//
|
|
21
|
+
// LC_FAIL_AT_ITER=<N> is honored as a test hook: exits with code 7 just
|
|
22
|
+
// before iteration N's provider call. Used by phase 2 spec to assert
|
|
23
|
+
// the `failed` meta status path.
|
|
24
|
+
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const REPO_ROOT = path.resolve(HERE, '..');
|
|
30
|
+
|
|
31
|
+
function parseArgs(argv) {
|
|
32
|
+
const out = { _: [] };
|
|
33
|
+
for (let i = 0; i < argv.length; i++) {
|
|
34
|
+
const t = argv[i];
|
|
35
|
+
if (t.startsWith('--')) {
|
|
36
|
+
const key = t.slice(2);
|
|
37
|
+
const v = argv[i + 1];
|
|
38
|
+
if (v === undefined || v.startsWith('--')) {
|
|
39
|
+
out[key] = true;
|
|
40
|
+
} else {
|
|
41
|
+
out[key] = v;
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
} else {
|
|
45
|
+
out._.push(t);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const args = parseArgs(process.argv.slice(2));
|
|
52
|
+
const loopId = args['loop-id'];
|
|
53
|
+
if (!loopId) {
|
|
54
|
+
process.stderr.write('loop-worker: --loop-id required\n');
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (args['cfg-dir']) {
|
|
59
|
+
process.env.LAZYCLAW_CONFIG_DIR = args['cfg-dir'];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const loops = await import(path.join(REPO_ROOT, 'loops.mjs'));
|
|
63
|
+
const sessions = await import(path.join(REPO_ROOT, 'sessions.mjs'));
|
|
64
|
+
const loopEngine = await import(path.join(REPO_ROOT, 'loop-engine.mjs'));
|
|
65
|
+
const registryUrl = path.join(REPO_ROOT, 'providers', 'registry.mjs');
|
|
66
|
+
const { PROVIDERS } = await import(registryUrl);
|
|
67
|
+
|
|
68
|
+
const cfgDir = process.env.LAZYCLAW_CONFIG_DIR || loops.defaultConfigDir();
|
|
69
|
+
const sessionId = args['session-existing'] || `loop:${loopId}`;
|
|
70
|
+
|
|
71
|
+
const provName = args.provider || 'mock';
|
|
72
|
+
const prov = PROVIDERS[provName];
|
|
73
|
+
if (!prov) {
|
|
74
|
+
loops.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
|
|
75
|
+
loops.writeResult(loopId, { error: `unknown provider: ${provName}` }, cfgDir);
|
|
76
|
+
process.exit(2);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const until = args.until ? loopEngine.compileUntil(args.until) : null;
|
|
80
|
+
const max = Number(args.max) || loopEngine.LOOP_MAX_DEFAULT;
|
|
81
|
+
|
|
82
|
+
// Initial meta — pid was filled by parent. We update startedAt here so
|
|
83
|
+
// the timestamp reflects when the worker actually started executing, not
|
|
84
|
+
// when the parent forked us.
|
|
85
|
+
loops.patchMeta(loopId, { status: 'running', startedAt: new Date().toISOString() }, cfgDir);
|
|
86
|
+
|
|
87
|
+
const ac = new AbortController();
|
|
88
|
+
function onTerm(sig) {
|
|
89
|
+
ac.abort();
|
|
90
|
+
loops.patchMeta(loopId, { status: 'killed', finishedAt: new Date().toISOString(), signal: sig }, cfgDir);
|
|
91
|
+
loops.writeResult(loopId, { stoppedBy: 'kill', signal: sig }, cfgDir);
|
|
92
|
+
// Give the in-flight stream a moment to unwind before we exit.
|
|
93
|
+
setTimeout(() => process.exit(143), 50);
|
|
94
|
+
}
|
|
95
|
+
process.on('SIGTERM', () => onTerm('SIGTERM'));
|
|
96
|
+
process.on('SIGINT', () => onTerm('SIGINT'));
|
|
97
|
+
|
|
98
|
+
const failAtIter = Number(process.env.LC_FAIL_AT_ITER) || 0;
|
|
99
|
+
let iterCounter = 0;
|
|
100
|
+
|
|
101
|
+
async function sendOnce(messages, signal) {
|
|
102
|
+
iterCounter++;
|
|
103
|
+
if (failAtIter && iterCounter === failAtIter) {
|
|
104
|
+
process.exit(7);
|
|
105
|
+
}
|
|
106
|
+
let acc = '';
|
|
107
|
+
for await (const chunk of prov.sendMessage(messages, {
|
|
108
|
+
apiKey: process.env.LAZYCLAW_API_KEY || '',
|
|
109
|
+
model: args.model,
|
|
110
|
+
signal,
|
|
111
|
+
})) {
|
|
112
|
+
acc += chunk;
|
|
113
|
+
}
|
|
114
|
+
return acc;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const messages = [];
|
|
118
|
+
// Hydrate prior turns if reusing an existing session — the engine appends
|
|
119
|
+
// every successful pair, so resume semantics line up with `/loop` in REPL.
|
|
120
|
+
if (sessionId && sessions.sessionPath) {
|
|
121
|
+
try {
|
|
122
|
+
const prior = sessions.loadTurns(sessionId, cfgDir);
|
|
123
|
+
for (const t of prior) messages.push({ role: t.role, content: t.content });
|
|
124
|
+
} catch { /* fresh session */ }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const persist = (role, content) => {
|
|
128
|
+
try { sessions.appendTurn(sessionId, role, content, cfgDir); }
|
|
129
|
+
catch { /* surface via result.json on failure */ }
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const onIteration = ({ i, max: m, reply }) => {
|
|
133
|
+
loops.appendIteration(loopId, {
|
|
134
|
+
iteration: i,
|
|
135
|
+
of: m,
|
|
136
|
+
bytes: reply.length,
|
|
137
|
+
preview: reply.slice(0, 200),
|
|
138
|
+
}, cfgDir);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
const result = await loopEngine.runLoop({
|
|
143
|
+
prompt: args.prompt || '',
|
|
144
|
+
max,
|
|
145
|
+
until,
|
|
146
|
+
messages,
|
|
147
|
+
sendOnce,
|
|
148
|
+
persist,
|
|
149
|
+
onIteration,
|
|
150
|
+
signal: ac.signal,
|
|
151
|
+
});
|
|
152
|
+
const finalStatus = result.stoppedBy === 'abort' ? 'killed' : 'completed';
|
|
153
|
+
loops.patchMeta(loopId, { status: finalStatus, finishedAt: new Date().toISOString() }, cfgDir);
|
|
154
|
+
loops.writeResult(loopId, result, cfgDir);
|
|
155
|
+
process.exit(0);
|
|
156
|
+
} catch (err) {
|
|
157
|
+
loops.patchMeta(loopId, { status: 'failed', finishedAt: new Date().toISOString() }, cfgDir);
|
|
158
|
+
loops.writeResult(loopId, { error: err?.message || String(err), stack: err?.stack }, cfgDir);
|
|
159
|
+
process.exit(1);
|
|
160
|
+
}
|
package/sessions.mjs
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import os from 'node:os';
|
|
20
|
+
import { appendRecent as _memoryAppendRecent } from './memory.mjs';
|
|
20
21
|
|
|
21
22
|
const SESSIONS_DIRNAME = 'sessions';
|
|
22
23
|
|
|
@@ -74,6 +75,10 @@ export function appendTurn(id, role, content, configDir = defaultConfigDir()) {
|
|
|
74
75
|
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
75
76
|
const line = JSON.stringify({ role, content: String(content ?? ''), ts: Date.now() }) + '\n';
|
|
76
77
|
fs.appendFileSync(p, line);
|
|
78
|
+
// Write-through to the memory recency log. Best-effort; failures
|
|
79
|
+
// never propagate up — a missing or broken memory store must not
|
|
80
|
+
// break the session-write path.
|
|
81
|
+
_memoryAppendRecent(id, role, content, configDir);
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
export function clearSession(id, configDir = defaultConfigDir()) {
|