lazyclaw 3.88.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/LICENSE +21 -0
- package/README.md +186 -0
- package/cli.mjs +2648 -0
- package/config-validate.mjs +61 -0
- package/daemon.mjs +1451 -0
- package/logger.mjs +55 -0
- package/package.json +55 -0
- package/providers/anthropic.mjs +313 -0
- package/providers/cache.mjs +132 -0
- package/providers/fallback.mjs +90 -0
- package/providers/gemini.mjs +187 -0
- package/providers/ollama.mjs +148 -0
- package/providers/openai.mjs +243 -0
- package/providers/rates.mjs +85 -0
- package/providers/registry.mjs +144 -0
- package/providers/retry.mjs +103 -0
- package/ratelimit.mjs +65 -0
- package/rates-validate.mjs +58 -0
- package/sessions.mjs +177 -0
- package/skills.mjs +97 -0
- package/web/server.mjs +33 -0
- package/workflow/executor.mjs +358 -0
- package/workflow/persistent.mjs +369 -0
- package/workflow/summary.mjs +318 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Provider registry for LazyClaw chat.
|
|
2
|
+
// Each provider exposes { name, sendMessage(messages, opts) } where
|
|
3
|
+
// sendMessage returns an AsyncIterable<string> of token chunks.
|
|
4
|
+
//
|
|
5
|
+
// The mock provider is the offline default exercised by phase 3 tests.
|
|
6
|
+
// The real Anthropic Messages-API streaming provider lives next door in
|
|
7
|
+
// providers/anthropic.mjs and is re-exported here so callers only need to
|
|
8
|
+
// know about PROVIDERS.
|
|
9
|
+
|
|
10
|
+
import { anthropicProvider } from './anthropic.mjs';
|
|
11
|
+
import { openaiProvider } from './openai.mjs';
|
|
12
|
+
import { ollamaProvider } from './ollama.mjs';
|
|
13
|
+
import { geminiProvider } from './gemini.mjs';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef {{ role: 'user'|'assistant'|'system', content: string }} ChatMessage
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @typedef {Object} Provider
|
|
21
|
+
* @property {string} name
|
|
22
|
+
* @property {(messages: ChatMessage[], opts: { apiKey?: string, model?: string }) => AsyncIterable<string>} sendMessage
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
async function* mockChunks(text, delayMs = 5, signal) {
|
|
26
|
+
for (const ch of text) {
|
|
27
|
+
if (signal?.aborted) {
|
|
28
|
+
const e = new Error('aborted');
|
|
29
|
+
e.code = 'ABORT';
|
|
30
|
+
throw e;
|
|
31
|
+
}
|
|
32
|
+
await new Promise(r => setTimeout(r, delayMs));
|
|
33
|
+
yield ch;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** @type {Provider} */
|
|
38
|
+
export const mockProvider = {
|
|
39
|
+
name: 'mock',
|
|
40
|
+
async *sendMessage(messages, opts = {}) {
|
|
41
|
+
const last = messages[messages.length - 1];
|
|
42
|
+
const reply = `mock-reply: ${last?.content ?? ''}`;
|
|
43
|
+
// Honor opts.signal so the chat REPL's Ctrl+C handler (and any
|
|
44
|
+
// other caller) can stop the stream mid-flight. The other concrete
|
|
45
|
+
// providers already do this; the mock should match for symmetry.
|
|
46
|
+
yield* mockChunks(reply, 5, opts.signal);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export { anthropicProvider, openaiProvider, ollamaProvider, geminiProvider };
|
|
51
|
+
|
|
52
|
+
export const PROVIDERS = {
|
|
53
|
+
mock: mockProvider,
|
|
54
|
+
anthropic: anthropicProvider,
|
|
55
|
+
openai: openaiProvider,
|
|
56
|
+
ollama: ollamaProvider,
|
|
57
|
+
gemini: geminiProvider,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Static metadata for `lazyclaw providers list/info`. Kept next to PROVIDERS
|
|
61
|
+
// so adding a provider in one place can't drift from the list shown to users.
|
|
62
|
+
export const PROVIDER_INFO = {
|
|
63
|
+
mock: {
|
|
64
|
+
name: 'mock',
|
|
65
|
+
requiresApiKey: false,
|
|
66
|
+
docs: 'In-process echo provider. Replies "mock-reply: <last user message>". Used for offline tests and demos.',
|
|
67
|
+
defaultModel: null,
|
|
68
|
+
suggestedModels: [],
|
|
69
|
+
},
|
|
70
|
+
anthropic: {
|
|
71
|
+
name: 'anthropic',
|
|
72
|
+
requiresApiKey: true,
|
|
73
|
+
keyPrefix: 'sk-ant-',
|
|
74
|
+
docs: 'Anthropic Messages API. Supports streaming + extended thinking.',
|
|
75
|
+
endpoint: 'https://api.anthropic.com/v1/messages',
|
|
76
|
+
defaultModel: 'claude-opus-4-7',
|
|
77
|
+
suggestedModels: ['claude-opus-4-7', 'claude-sonnet-4-6', 'claude-haiku-4-5-20251001'],
|
|
78
|
+
},
|
|
79
|
+
openai: {
|
|
80
|
+
name: 'openai',
|
|
81
|
+
requiresApiKey: true,
|
|
82
|
+
keyPrefix: 'sk-',
|
|
83
|
+
docs: 'OpenAI Chat Completions API. Streaming via SSE with [DONE] terminator.',
|
|
84
|
+
endpoint: 'https://api.openai.com/v1/chat/completions',
|
|
85
|
+
defaultModel: 'gpt-4.1',
|
|
86
|
+
suggestedModels: ['gpt-4.1', 'gpt-4o', 'gpt-4o-mini'],
|
|
87
|
+
},
|
|
88
|
+
ollama: {
|
|
89
|
+
name: 'ollama',
|
|
90
|
+
requiresApiKey: false,
|
|
91
|
+
docs: 'Local Ollama daemon. Streams newline-delimited JSON from /api/chat. No auth — defaults to 127.0.0.1:11434, override via OLLAMA_HOST or opts.baseUrl.',
|
|
92
|
+
endpoint: 'http://127.0.0.1:11434/api/chat',
|
|
93
|
+
defaultModel: 'llama3.1',
|
|
94
|
+
suggestedModels: ['llama3.1', 'llama3.2', 'mistral', 'qwen2.5-coder'],
|
|
95
|
+
},
|
|
96
|
+
gemini: {
|
|
97
|
+
name: 'gemini',
|
|
98
|
+
requiresApiKey: true,
|
|
99
|
+
docs: 'Google Generative Language API (Gemini). SSE streaming via :streamGenerateContent?alt=sse. Auth via ?key= query param.',
|
|
100
|
+
endpoint: 'https://generativelanguage.googleapis.com/v1/models/{model}:streamGenerateContent',
|
|
101
|
+
defaultModel: 'gemini-1.5-pro',
|
|
102
|
+
suggestedModels: ['gemini-1.5-pro', 'gemini-1.5-flash', 'gemini-2.0-flash'],
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Split a unified "provider/model" string (OpenClaw style:
|
|
108
|
+
* "anthropic/claude-opus-4-7"). Also accepts a bare model id and returns
|
|
109
|
+
* provider=null so callers can fall back to a separately-stored provider.
|
|
110
|
+
* @param {string} s
|
|
111
|
+
* @returns {{ provider: string|null, model: string }}
|
|
112
|
+
*/
|
|
113
|
+
export function parseProviderModel(s) {
|
|
114
|
+
if (!s || typeof s !== 'string') return { provider: null, model: '' };
|
|
115
|
+
const slash = s.indexOf('/');
|
|
116
|
+
if (slash > 0) {
|
|
117
|
+
return { provider: s.slice(0, slash).trim().toLowerCase(), model: s.slice(slash + 1).trim() };
|
|
118
|
+
}
|
|
119
|
+
return { provider: null, model: s.trim() };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Mask an API key for safe display. Keeps a recognised vendor prefix
|
|
124
|
+
* (sk-ant-, sk-, etc.) and the last 4 characters; masks everything in
|
|
125
|
+
* between. Returns '' when no key is set.
|
|
126
|
+
*
|
|
127
|
+
* The vendor prefix is deliberately conservative: only the well-known
|
|
128
|
+
* ones (sk-ant-, sk-) — anything else yields "****…tail" with no prefix
|
|
129
|
+
* so we never accidentally surface a meaningful chunk of a custom key.
|
|
130
|
+
* @param {string|undefined|null} key
|
|
131
|
+
* @returns {string}
|
|
132
|
+
*/
|
|
133
|
+
const KNOWN_KEY_PREFIXES = ['sk-ant-', 'sk-or-', 'sk-'];
|
|
134
|
+
export function maskApiKey(key) {
|
|
135
|
+
if (!key) return '';
|
|
136
|
+
const s = String(key);
|
|
137
|
+
let prefix = '';
|
|
138
|
+
for (const p of KNOWN_KEY_PREFIXES) {
|
|
139
|
+
if (s.startsWith(p)) { prefix = p; break; }
|
|
140
|
+
}
|
|
141
|
+
const tail = s.length - prefix.length >= 8 ? s.slice(-4) : '';
|
|
142
|
+
const middleLen = Math.max(4, Math.min(12, s.length - prefix.length - tail.length));
|
|
143
|
+
return `${prefix}${'*'.repeat(middleLen)}${tail}`;
|
|
144
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Opt-in retry wrapper for provider streams.
|
|
2
|
+
//
|
|
3
|
+
// Why this is a wrapper, not a provider option:
|
|
4
|
+
// - The retry decision is *policy*, not *transport*. Different callers
|
|
5
|
+
// want different retry budgets (a CLI script may want 3, a long-running
|
|
6
|
+
// daemon may want 10 with a max wall clock).
|
|
7
|
+
// - Wrapping keeps the providers themselves simple — they remain pure
|
|
8
|
+
// async iterators over a single attempt.
|
|
9
|
+
//
|
|
10
|
+
// Strategy:
|
|
11
|
+
// 1. We only retry RATE_LIMIT errors that surface *before* any chunk has
|
|
12
|
+
// been yielded. Once the model has started speaking we cannot retry
|
|
13
|
+
// without producing duplicate output, so mid-stream RATE_LIMIT bubbles
|
|
14
|
+
// to the caller unchanged.
|
|
15
|
+
// 2. Sleep duration is `min(opts.retryAfterMs, opts.maxBackoffMs)` —
|
|
16
|
+
// we trust `Retry-After` but cap it so a misbehaving provider can't
|
|
17
|
+
// pin us for an hour.
|
|
18
|
+
// 3. `attempts` is exclusive of the initial call: `attempts: 3` means
|
|
19
|
+
// one attempt + up to three retries.
|
|
20
|
+
// 4. AbortSignal is checked in the sleep so a cancel during the wait
|
|
21
|
+
// doesn't have to wait for the wake-up.
|
|
22
|
+
|
|
23
|
+
const DEFAULT_ATTEMPTS = 3;
|
|
24
|
+
const DEFAULT_MAX_BACKOFF_MS = 60_000;
|
|
25
|
+
const ABSOLUTE_MAX_BACKOFF_MS = 5 * 60_000; // hard ceiling, ignores caller
|
|
26
|
+
|
|
27
|
+
function clampBackoff(retryAfterMs, max) {
|
|
28
|
+
const ceiling = Math.min(max, ABSOLUTE_MAX_BACKOFF_MS);
|
|
29
|
+
if (!Number.isFinite(retryAfterMs) || retryAfterMs < 0) return ceiling;
|
|
30
|
+
return Math.min(retryAfterMs, ceiling);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function abortableSleep(ms, signal) {
|
|
34
|
+
if (ms <= 0) return;
|
|
35
|
+
if (signal?.aborted) {
|
|
36
|
+
const e = new Error('aborted during retry backoff');
|
|
37
|
+
e.code = 'ABORT';
|
|
38
|
+
throw e;
|
|
39
|
+
}
|
|
40
|
+
await new Promise((resolve, reject) => {
|
|
41
|
+
const t = setTimeout(() => {
|
|
42
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
43
|
+
resolve();
|
|
44
|
+
}, ms);
|
|
45
|
+
function onAbort() {
|
|
46
|
+
clearTimeout(t);
|
|
47
|
+
const e = new Error('aborted during retry backoff');
|
|
48
|
+
e.code = 'ABORT';
|
|
49
|
+
reject(e);
|
|
50
|
+
}
|
|
51
|
+
signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Wrap a provider's sendMessage with rate-limit-aware retries.
|
|
57
|
+
*
|
|
58
|
+
* @param {{ name: string, sendMessage: Function }} provider
|
|
59
|
+
* @param {{
|
|
60
|
+
* attempts?: number,
|
|
61
|
+
* maxBackoffMs?: number,
|
|
62
|
+
* onRetry?: (info: { attempt: number, retryAfterMs: number, err: Error }) => void,
|
|
63
|
+
* sleep?: (ms: number, signal?: AbortSignal) => Promise<void>,
|
|
64
|
+
* }} retryOpts
|
|
65
|
+
*/
|
|
66
|
+
export function withRateLimitRetry(provider, retryOpts = {}) {
|
|
67
|
+
const attempts = Number.isFinite(retryOpts.attempts) ? retryOpts.attempts : DEFAULT_ATTEMPTS;
|
|
68
|
+
const maxBackoffMs = retryOpts.maxBackoffMs ?? DEFAULT_MAX_BACKOFF_MS;
|
|
69
|
+
const sleep = retryOpts.sleep || abortableSleep;
|
|
70
|
+
const onRetry = retryOpts.onRetry;
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
name: `${provider.name}+retry`,
|
|
74
|
+
async *sendMessage(messages, opts = {}) {
|
|
75
|
+
let lastErr = null;
|
|
76
|
+
for (let attempt = 0; attempt <= attempts; attempt++) {
|
|
77
|
+
let yieldedAny = false;
|
|
78
|
+
try {
|
|
79
|
+
for await (const chunk of provider.sendMessage(messages, opts)) {
|
|
80
|
+
yieldedAny = true;
|
|
81
|
+
yield chunk;
|
|
82
|
+
}
|
|
83
|
+
return;
|
|
84
|
+
} catch (err) {
|
|
85
|
+
lastErr = err;
|
|
86
|
+
// Mid-stream errors cannot be retried: we'd produce duplicate text.
|
|
87
|
+
if (yieldedAny) throw err;
|
|
88
|
+
// Only retry RATE_LIMIT and only if we still have attempts left.
|
|
89
|
+
if (err?.code !== 'RATE_LIMIT' || attempt >= attempts) throw err;
|
|
90
|
+
const wait = clampBackoff(err.retryAfterMs, maxBackoffMs);
|
|
91
|
+
if (typeof onRetry === 'function') {
|
|
92
|
+
try { onRetry({ attempt: attempt + 1, retryAfterMs: wait, err }); } catch { /* swallow */ }
|
|
93
|
+
}
|
|
94
|
+
await sleep(wait, opts.signal);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// Loop exits only when attempts exhausted; lastErr always set.
|
|
98
|
+
throw lastErr;
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { clampBackoff, abortableSleep };
|
package/ratelimit.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Token-bucket rate limiter, opt-in for the daemon.
|
|
2
|
+
//
|
|
3
|
+
// Why token bucket and not fixed-window:
|
|
4
|
+
// - Fixed windows allow burst-double at the boundary (last second of
|
|
5
|
+
// window N + first second of window N+1 → 2× the limit). Token
|
|
6
|
+
// bucket smooths that out.
|
|
7
|
+
// - Bucket math is two arithmetic operations per request (refill +
|
|
8
|
+
// deduct), no per-request log entries to truncate.
|
|
9
|
+
//
|
|
10
|
+
// Per-key buckets — `key` is whatever the caller wants to scope by.
|
|
11
|
+
// The daemon uses the remote IP. A future caller could scope by API
|
|
12
|
+
// key prefix or path.
|
|
13
|
+
//
|
|
14
|
+
// Memory bound: stale buckets are evicted on access (the bucket would
|
|
15
|
+
// have refilled to capacity anyway after `capacity / rate` seconds, so
|
|
16
|
+
// we lose nothing by dropping it). No background sweep needed.
|
|
17
|
+
|
|
18
|
+
const DEFAULT_CAPACITY = 60; // requests
|
|
19
|
+
const DEFAULT_REFILL_PER_SEC = 1; // 60 req/min sustained
|
|
20
|
+
|
|
21
|
+
export class TokenBucketLimiter {
|
|
22
|
+
/**
|
|
23
|
+
* @param {{ capacity?: number, refillPerSec?: number, now?: () => number }} [opts]
|
|
24
|
+
*/
|
|
25
|
+
constructor(opts = {}) {
|
|
26
|
+
this.capacity = opts.capacity ?? DEFAULT_CAPACITY;
|
|
27
|
+
this.refillPerSec = opts.refillPerSec ?? DEFAULT_REFILL_PER_SEC;
|
|
28
|
+
this.now = opts.now ?? (() => Date.now());
|
|
29
|
+
/** @type {Map<string, { tokens: number, last: number }>} */
|
|
30
|
+
this.buckets = new Map();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Try to consume one token from the bucket for `key`.
|
|
35
|
+
* Returns { allowed: boolean, retryAfterMs: number, remaining: number }.
|
|
36
|
+
*
|
|
37
|
+
* When `allowed: false`, `retryAfterMs` is the wall-clock delay until
|
|
38
|
+
* one token would be available — the daemon advertises this in the
|
|
39
|
+
* `Retry-After` header so a polite client backs off correctly.
|
|
40
|
+
*/
|
|
41
|
+
consume(key) {
|
|
42
|
+
const t = this.now();
|
|
43
|
+
let b = this.buckets.get(key);
|
|
44
|
+
if (!b) {
|
|
45
|
+
b = { tokens: this.capacity, last: t };
|
|
46
|
+
this.buckets.set(key, b);
|
|
47
|
+
}
|
|
48
|
+
const elapsedSec = Math.max(0, (t - b.last) / 1000);
|
|
49
|
+
b.tokens = Math.min(this.capacity, b.tokens + elapsedSec * this.refillPerSec);
|
|
50
|
+
b.last = t;
|
|
51
|
+
if (b.tokens >= 1) {
|
|
52
|
+
b.tokens -= 1;
|
|
53
|
+
return { allowed: true, retryAfterMs: 0, remaining: Math.floor(b.tokens) };
|
|
54
|
+
}
|
|
55
|
+
const deficit = 1 - b.tokens;
|
|
56
|
+
const retryAfterMs = Math.ceil((deficit / this.refillPerSec) * 1000);
|
|
57
|
+
return { allowed: false, retryAfterMs, remaining: 0 };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Forget the bucket for `key`. Used by tests and by callers that
|
|
61
|
+
* know a client is gone. Memory is otherwise self-healing. */
|
|
62
|
+
forget(key) {
|
|
63
|
+
this.buckets.delete(key);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Structural integrity check for cfg.rates. Distinct from runtime
|
|
2
|
+
// "doctor" checks — this is purely about shape: keys in
|
|
3
|
+
// "provider/model" form, required fields present, numbers non-
|
|
4
|
+
// negative.
|
|
5
|
+
//
|
|
6
|
+
// Shared between `lazyclaw rates validate` (CLI) and
|
|
7
|
+
// `GET /rates/validate` (daemon) so both produce bit-for-bit
|
|
8
|
+
// identical output.
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @param {Record<string, unknown>} rates cfg.rates map (or undefined)
|
|
12
|
+
* @param {Record<string, unknown>} providers Registered providers map (keys = provider names)
|
|
13
|
+
* @returns {{ ok: boolean, rateCount: number, issues: string[], warnings: string[] }}
|
|
14
|
+
*/
|
|
15
|
+
export function validateRates(rates, providers) {
|
|
16
|
+
const issues = [];
|
|
17
|
+
const warnings = [];
|
|
18
|
+
const safeRates = (rates && typeof rates === 'object' && !Array.isArray(rates)) ? rates : {};
|
|
19
|
+
const knownProviders = new Set(Object.keys(providers || {}));
|
|
20
|
+
for (const key of Object.keys(safeRates)) {
|
|
21
|
+
if (!key.includes('/')) {
|
|
22
|
+
issues.push(`key "${key}": expected "provider/model" shape (slash required)`);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
const [provider] = key.split('/');
|
|
26
|
+
if (!knownProviders.has(provider)) {
|
|
27
|
+
warnings.push(`key "${key}": provider "${provider}" not in registered providers (registered: ${[...knownProviders].join(', ')})`);
|
|
28
|
+
}
|
|
29
|
+
const card = safeRates[key];
|
|
30
|
+
if (!card || typeof card !== 'object') {
|
|
31
|
+
issues.push(`key "${key}": value must be an object`);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
for (const required of ['inputPer1M', 'outputPer1M']) {
|
|
35
|
+
const v = card[required];
|
|
36
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
|
|
37
|
+
issues.push(`key "${key}": ${required} must be a non-negative finite number (got ${JSON.stringify(v)})`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
for (const optional of ['cacheReadPer1M', 'cacheCreatePer1M']) {
|
|
41
|
+
if (card[optional] !== undefined) {
|
|
42
|
+
const v = card[optional];
|
|
43
|
+
if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) {
|
|
44
|
+
issues.push(`key "${key}": ${optional} must be a non-negative finite number when set (got ${JSON.stringify(v)})`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (card.currency !== undefined && typeof card.currency !== 'string') {
|
|
49
|
+
issues.push(`key "${key}": currency must be a string (got ${typeof card.currency})`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
ok: issues.length === 0,
|
|
54
|
+
rateCount: Object.keys(safeRates).length,
|
|
55
|
+
issues,
|
|
56
|
+
warnings,
|
|
57
|
+
};
|
|
58
|
+
}
|
package/sessions.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Persistent chat sessions for LazyClaw.
|
|
2
|
+
//
|
|
3
|
+
// Storage layout under <configDir>/sessions/:
|
|
4
|
+
// <id>.jsonl — append-only log of {role, content, ts} turns
|
|
5
|
+
//
|
|
6
|
+
// Why JSONL not a single JSON file:
|
|
7
|
+
// - Atomic append per turn — no read-modify-write race when two
|
|
8
|
+
// terminals talk to the same session.
|
|
9
|
+
// - O(1) write per turn regardless of conversation length.
|
|
10
|
+
// - The last-turn timestamp is the file mtime, so listSessions does
|
|
11
|
+
// not have to read every file to sort.
|
|
12
|
+
//
|
|
13
|
+
// `loadTurns` is the only operation that reads the whole log; it splits
|
|
14
|
+
// on '\n' and JSON.parses each non-empty line, ignoring malformed lines
|
|
15
|
+
// rather than failing the chat.
|
|
16
|
+
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import os from 'node:os';
|
|
20
|
+
|
|
21
|
+
const SESSIONS_DIRNAME = 'sessions';
|
|
22
|
+
|
|
23
|
+
export function defaultConfigDir() {
|
|
24
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function sessionsDir(configDir = defaultConfigDir()) {
|
|
28
|
+
return path.join(configDir, SESSIONS_DIRNAME);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function sessionPath(id, configDir = defaultConfigDir()) {
|
|
32
|
+
if (!id || /[/\\]/.test(id) || id === '.' || id === '..') {
|
|
33
|
+
throw new Error(`invalid session id: ${id}`);
|
|
34
|
+
}
|
|
35
|
+
return path.join(sessionsDir(configDir), `${id}.jsonl`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function listSessions(configDir = defaultConfigDir()) {
|
|
39
|
+
const dir = sessionsDir(configDir);
|
|
40
|
+
if (!fs.existsSync(dir)) return [];
|
|
41
|
+
return fs.readdirSync(dir)
|
|
42
|
+
.filter(name => name.endsWith('.jsonl'))
|
|
43
|
+
.map(name => {
|
|
44
|
+
const fullPath = path.join(dir, name);
|
|
45
|
+
const stat = fs.statSync(fullPath);
|
|
46
|
+
return {
|
|
47
|
+
id: name.slice(0, -'.jsonl'.length),
|
|
48
|
+
path: fullPath,
|
|
49
|
+
bytes: stat.size,
|
|
50
|
+
mtimeMs: stat.mtimeMs,
|
|
51
|
+
};
|
|
52
|
+
})
|
|
53
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function loadTurns(id, configDir = defaultConfigDir()) {
|
|
57
|
+
const p = sessionPath(id, configDir);
|
|
58
|
+
if (!fs.existsSync(p)) return [];
|
|
59
|
+
const raw = fs.readFileSync(p, 'utf8');
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const line of raw.split('\n')) {
|
|
62
|
+
if (!line) continue;
|
|
63
|
+
try { out.push(JSON.parse(line)); }
|
|
64
|
+
catch { /* skip malformed line */ }
|
|
65
|
+
}
|
|
66
|
+
return out;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function appendTurn(id, role, content, configDir = defaultConfigDir()) {
|
|
70
|
+
if (role !== 'user' && role !== 'assistant' && role !== 'system') {
|
|
71
|
+
throw new Error(`invalid role: ${role}`);
|
|
72
|
+
}
|
|
73
|
+
const p = sessionPath(id, configDir);
|
|
74
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
75
|
+
const line = JSON.stringify({ role, content: String(content ?? ''), ts: Date.now() }) + '\n';
|
|
76
|
+
fs.appendFileSync(p, line);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function clearSession(id, configDir = defaultConfigDir()) {
|
|
80
|
+
const p = sessionPath(id, configDir);
|
|
81
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function resetSession(id, configDir = defaultConfigDir()) {
|
|
85
|
+
// Truncate without removing — mtime advances so the session stays at
|
|
86
|
+
// the top of `listSessions` order (it was just touched).
|
|
87
|
+
const p = sessionPath(id, configDir);
|
|
88
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
89
|
+
fs.writeFileSync(p, '');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Render a session as shareable Markdown. The session id and turn count
|
|
94
|
+
* become the H1 / metadata block; each turn becomes a section with the
|
|
95
|
+
* role as H2 and the content as a fenced block when it looks like code,
|
|
96
|
+
* else plain prose.
|
|
97
|
+
*
|
|
98
|
+
* Why fenced blocks: assistant replies often contain code that would
|
|
99
|
+
* otherwise be mis-rendered as Markdown. We use a triple-backtick fence
|
|
100
|
+
* with a language tag of `text` only when no code-fence is already
|
|
101
|
+
* present in the turn content (so models that already produce
|
|
102
|
+
* pre-formatted code blocks don't end up double-fenced).
|
|
103
|
+
*
|
|
104
|
+
* @param {string} id
|
|
105
|
+
* @param {string} [configDir]
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function exportMarkdown(id, configDir = defaultConfigDir()) {
|
|
109
|
+
const turns = loadTurns(id, configDir);
|
|
110
|
+
const lines = [`# Session: ${id}`, ''];
|
|
111
|
+
if (turns.length === 0) {
|
|
112
|
+
lines.push('_(empty)_');
|
|
113
|
+
return lines.join('\n');
|
|
114
|
+
}
|
|
115
|
+
const first = new Date(turns[0]?.ts || Date.now()).toISOString();
|
|
116
|
+
const last = new Date(turns[turns.length - 1]?.ts || Date.now()).toISOString();
|
|
117
|
+
lines.push(`- Turns: ${turns.length}`, `- First: ${first}`, `- Last: ${last}`, '');
|
|
118
|
+
for (const t of turns) {
|
|
119
|
+
const role = t.role === 'user' ? 'User' : t.role === 'assistant' ? 'Assistant' : 'System';
|
|
120
|
+
lines.push(`## ${role}`, '');
|
|
121
|
+
const text = String(t.content || '');
|
|
122
|
+
lines.push(text, '');
|
|
123
|
+
}
|
|
124
|
+
return lines.join('\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Structured export — JSON object with session id + ISO timestamps.
|
|
129
|
+
* Useful for piping into jq or feeding analytics tooling that
|
|
130
|
+
* doesn't want to parse markdown.
|
|
131
|
+
*
|
|
132
|
+
* Shape:
|
|
133
|
+
* { id, turnCount, first?, last?, turns: [{ role, content, ts }] }
|
|
134
|
+
*
|
|
135
|
+
* `first` / `last` are omitted on empty sessions so a downstream
|
|
136
|
+
* tool can distinguish "no turns yet" from "first turn at epoch 0".
|
|
137
|
+
*
|
|
138
|
+
* @param {string} id
|
|
139
|
+
* @param {string} [configDir]
|
|
140
|
+
* @returns {string} pretty-printed JSON (2-space indent)
|
|
141
|
+
*/
|
|
142
|
+
export function exportJson(id, configDir = defaultConfigDir()) {
|
|
143
|
+
const turns = loadTurns(id, configDir);
|
|
144
|
+
const out = { id, turnCount: turns.length };
|
|
145
|
+
if (turns.length > 0) {
|
|
146
|
+
out.first = new Date(turns[0]?.ts || Date.now()).toISOString();
|
|
147
|
+
out.last = new Date(turns[turns.length - 1]?.ts || Date.now()).toISOString();
|
|
148
|
+
}
|
|
149
|
+
out.turns = turns.map(t => ({
|
|
150
|
+
role: t.role,
|
|
151
|
+
content: String(t.content || ''),
|
|
152
|
+
ts: typeof t.ts === 'number' ? t.ts : null,
|
|
153
|
+
}));
|
|
154
|
+
return JSON.stringify(out, null, 2);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Plain-text export — `<ROLE>:` headers and turn content separated
|
|
159
|
+
* by blank lines, no markdown / fences / headers. Ideal for paste-
|
|
160
|
+
* into-anything contexts (issue templates, plain-text email).
|
|
161
|
+
*
|
|
162
|
+
* @param {string} id
|
|
163
|
+
* @param {string} [configDir]
|
|
164
|
+
* @returns {string}
|
|
165
|
+
*/
|
|
166
|
+
export function exportText(id, configDir = defaultConfigDir()) {
|
|
167
|
+
const turns = loadTurns(id, configDir);
|
|
168
|
+
if (turns.length === 0) return `Session: ${id}\n(empty)\n`;
|
|
169
|
+
const lines = [`Session: ${id}`, `Turns: ${turns.length}`, ''];
|
|
170
|
+
for (const t of turns) {
|
|
171
|
+
const role = (t.role || 'unknown').toUpperCase();
|
|
172
|
+
lines.push(`${role}:`);
|
|
173
|
+
lines.push(String(t.content || ''));
|
|
174
|
+
lines.push('');
|
|
175
|
+
}
|
|
176
|
+
return lines.join('\n');
|
|
177
|
+
}
|
package/skills.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Skills are markdown files in <configDir>/skills/<name>.md whose contents
|
|
2
|
+
// are prepended to the system prompt when chat or agent runs with --skill.
|
|
3
|
+
//
|
|
4
|
+
// This is the OpenClaw "skill" concept reduced to its load-bearing core:
|
|
5
|
+
// reusable instruction bundles, named, locally stored, no remote registry.
|
|
6
|
+
//
|
|
7
|
+
// Why .md and not JSON-with-content: skills are written by humans for
|
|
8
|
+
// humans, and markdown keeps headers / lists / code blocks readable both
|
|
9
|
+
// in the file system and inside the model prompt.
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
|
|
15
|
+
const SKILLS_DIRNAME = 'skills';
|
|
16
|
+
const SKILL_EXT = '.md';
|
|
17
|
+
|
|
18
|
+
export function defaultConfigDir() {
|
|
19
|
+
return process.env.LAZYCLAW_CONFIG_DIR || path.join(os.homedir(), '.lazyclaw');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function skillsDir(configDir = defaultConfigDir()) {
|
|
23
|
+
return path.join(configDir, SKILLS_DIRNAME);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function skillPath(name, configDir = defaultConfigDir()) {
|
|
27
|
+
if (!name || /[/\\]/.test(name) || name === '.' || name === '..' || name.startsWith('.')) {
|
|
28
|
+
throw new Error(`invalid skill name: ${name}`);
|
|
29
|
+
}
|
|
30
|
+
return path.join(skillsDir(configDir), `${name}${SKILL_EXT}`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function listSkills(configDir = defaultConfigDir()) {
|
|
34
|
+
const dir = skillsDir(configDir);
|
|
35
|
+
if (!fs.existsSync(dir)) return [];
|
|
36
|
+
return fs.readdirSync(dir)
|
|
37
|
+
.filter(name => name.endsWith(SKILL_EXT))
|
|
38
|
+
.map(name => {
|
|
39
|
+
const full = path.join(dir, name);
|
|
40
|
+
const stat = fs.statSync(full);
|
|
41
|
+
const head = readFirstLine(full);
|
|
42
|
+
return {
|
|
43
|
+
name: name.slice(0, -SKILL_EXT.length),
|
|
44
|
+
path: full,
|
|
45
|
+
bytes: stat.size,
|
|
46
|
+
mtimeMs: stat.mtimeMs,
|
|
47
|
+
summary: head.replace(/^#+\s*/, '').slice(0, 120),
|
|
48
|
+
};
|
|
49
|
+
})
|
|
50
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readFirstLine(p) {
|
|
54
|
+
try {
|
|
55
|
+
const buf = fs.readFileSync(p, 'utf8');
|
|
56
|
+
const nl = buf.indexOf('\n');
|
|
57
|
+
return nl < 0 ? buf : buf.slice(0, nl);
|
|
58
|
+
} catch { return ''; }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function loadSkill(name, configDir = defaultConfigDir()) {
|
|
62
|
+
const p = skillPath(name, configDir);
|
|
63
|
+
if (!fs.existsSync(p)) throw new Error(`skill not found: ${name}`);
|
|
64
|
+
return fs.readFileSync(p, 'utf8');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function installSkill(name, content, configDir = defaultConfigDir()) {
|
|
68
|
+
const p = skillPath(name, configDir);
|
|
69
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
70
|
+
fs.writeFileSync(p, content);
|
|
71
|
+
return p;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function removeSkill(name, configDir = defaultConfigDir()) {
|
|
75
|
+
const p = skillPath(name, configDir);
|
|
76
|
+
if (fs.existsSync(p)) fs.unlinkSync(p);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Compose the system prompt for a chat/agent invocation. Concatenates each
|
|
81
|
+
* named skill's contents with a separator, in the order given. Returns null
|
|
82
|
+
* when no skills are requested so the caller can pass through unchanged.
|
|
83
|
+
*
|
|
84
|
+
* @param {string[]} names
|
|
85
|
+
* @param {string} [configDir]
|
|
86
|
+
*/
|
|
87
|
+
export function composeSystemPrompt(names, configDir = defaultConfigDir()) {
|
|
88
|
+
if (!names || names.length === 0) return null;
|
|
89
|
+
const blocks = [];
|
|
90
|
+
for (const n of names) {
|
|
91
|
+
const trimmed = String(n || '').trim();
|
|
92
|
+
if (!trimmed) continue;
|
|
93
|
+
const body = loadSkill(trimmed, configDir);
|
|
94
|
+
blocks.push(`<!-- skill: ${trimmed} -->\n${body.trim()}`);
|
|
95
|
+
}
|
|
96
|
+
return blocks.length ? blocks.join('\n\n---\n\n') : null;
|
|
97
|
+
}
|
package/web/server.mjs
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Tiny static file server used by phase 3+ acceptance tests.
|
|
2
|
+
import http from 'node:http';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const MIME = {
|
|
7
|
+
'.html': 'text/html; charset=utf-8',
|
|
8
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
9
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
10
|
+
'.css': 'text/css; charset=utf-8',
|
|
11
|
+
'.json': 'application/json; charset=utf-8',
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function startStaticServer(rootDir, port = 0) {
|
|
15
|
+
const root = path.resolve(rootDir);
|
|
16
|
+
const server = http.createServer((req, res) => {
|
|
17
|
+
let urlPath = (req.url || '/').split('?')[0];
|
|
18
|
+
if (urlPath === '/') urlPath = '/index.html';
|
|
19
|
+
const file = path.normalize(path.join(root, urlPath));
|
|
20
|
+
if (!file.startsWith(root)) { res.statusCode = 403; res.end('forbidden'); return; }
|
|
21
|
+
if (!fs.existsSync(file) || !fs.statSync(file).isFile()) { res.statusCode = 404; res.end('not found'); return; }
|
|
22
|
+
res.setHeader('content-type', MIME[path.extname(file)] || 'application/octet-stream');
|
|
23
|
+
res.setHeader('cache-control', 'no-store');
|
|
24
|
+
res.end(fs.readFileSync(file));
|
|
25
|
+
});
|
|
26
|
+
return new Promise(resolve => {
|
|
27
|
+
server.listen(port, '127.0.0.1', () => {
|
|
28
|
+
const addr = server.address();
|
|
29
|
+
const realPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
30
|
+
resolve({ server, port: realPort, url: `http://127.0.0.1:${realPort}` });
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|