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.
@@ -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
+ }