teapot-coding-agent 0.1.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,63 @@
1
+ /**
2
+ * LLM access via the official `openai` npm client (OpenAI-compatible APIs:
3
+ * OpenAI, OpenRouter, local vLLM/Ollama, ...).
4
+ *
5
+ * We deliberately delegate retries/timeouts/response-shape handling to the
6
+ * SDK instead of hand-rolling them.
7
+ */
8
+ import OpenAI from "openai";
9
+ const clients = new WeakMap();
10
+ function client(cfg) {
11
+ let c = clients.get(cfg);
12
+ if (!c) {
13
+ c = new OpenAI({
14
+ baseURL: cfg.baseUrl,
15
+ apiKey: cfg.apiKey,
16
+ timeout: cfg.timeoutMs ?? 120_000,
17
+ maxRetries: 4, // SDK handles backoff for 429/5xx/network errors
18
+ });
19
+ clients.set(cfg, c);
20
+ }
21
+ return c;
22
+ }
23
+ /**
24
+ * Providers are picky in different ways; the common denominator is that
25
+ * empty text content in user/assistant messages makes many of them 400.
26
+ * Fill blanks with a harmless placeholder before sending.
27
+ */
28
+ function sanitize(messages) {
29
+ return messages.map((m) => {
30
+ if ((m.role === "user" || m.role === "assistant") && !m.content) {
31
+ return { ...m, content: m.tool_calls?.length ? "(tool call)" : "(no content)" };
32
+ }
33
+ if (m.role === "tool" && typeof m.content !== "string") {
34
+ return { ...m, content: String(m.content ?? "(no output)") };
35
+ }
36
+ return m;
37
+ });
38
+ }
39
+ export async function chat(cfg, messages, tools, signal) {
40
+ try {
41
+ const res = await client(cfg).chat.completions.create({
42
+ model: cfg.model,
43
+ messages: sanitize(messages),
44
+ ...(tools.length ? { tools } : {}),
45
+ }, { signal });
46
+ const message = res.choices?.[0]?.message;
47
+ if (!message)
48
+ throw new Error("LLM API returned no choices");
49
+ return {
50
+ message,
51
+ usage: res.usage
52
+ ? { inputTokens: res.usage.prompt_tokens, outputTokens: res.usage.completion_tokens }
53
+ : undefined,
54
+ };
55
+ }
56
+ catch (err) {
57
+ const e = err;
58
+ if (e.status === undefined)
59
+ throw err; // not an API error (abort, bug, ...)
60
+ const detail = e.error?.message ?? e.message ?? "unknown provider error";
61
+ throw new Error(`LLM API error ${e.status}: ${String(detail).slice(0, 500)}`);
62
+ }
63
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Agent Skills: reusable playbooks the agent can load on demand — and create
3
+ * itself, so knowledge accumulates across sessions instead of living only in
4
+ * chat history.
5
+ *
6
+ * A skill is a directory with a SKILL.md:
7
+ *
8
+ * ---
9
+ * name: release-checklist
10
+ * description: Steps to cut a release safely
11
+ * ---
12
+ * (free-form instructions shown to the agent when it loads the skill)
13
+ *
14
+ * Roots are scanned in priority order; the first skill with a given name wins.
15
+ * Workspace skills (<workspace>/skills) beat global ones (~config/skills), so
16
+ * a project can override shared defaults. Everything stays human-readable and
17
+ * git-friendly Markdown — no database, no lock-in.
18
+ */
19
+ import { promises as fs } from "node:fs";
20
+ import path from "node:path";
21
+ export const SKILL_FILE = "SKILL.md";
22
+ export function isValidSkillName(name) {
23
+ return /^[a-z0-9][a-z0-9._-]{0,63}$/.test(name);
24
+ }
25
+ /**
26
+ * Minimal frontmatter parser for the subset we need: a leading `---` block of
27
+ * `key: value` lines. No YAML dependency by design.
28
+ */
29
+ export function parseSkillMd(text) {
30
+ const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
31
+ if (!m)
32
+ return { meta: {}, body: text.trim() };
33
+ const meta = {};
34
+ for (const line of m[1].split(/\r?\n/)) {
35
+ const kv = line.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/);
36
+ if (kv)
37
+ meta[kv[1].toLowerCase()] = kv[2].trim();
38
+ }
39
+ return { meta, body: m[2].trim() };
40
+ }
41
+ /** Root dirs in priority order: [0] overrides later ones on name clash. */
42
+ export async function discoverSkills(roots) {
43
+ const byName = new Map();
44
+ for (const root of roots) {
45
+ let entries;
46
+ try {
47
+ entries = await fs.readdir(root.dir, { withFileTypes: true });
48
+ }
49
+ catch {
50
+ continue; // root missing → simply no skills there
51
+ }
52
+ for (const e of entries) {
53
+ if (!e.isDirectory() || e.name.startsWith("."))
54
+ continue;
55
+ const filePath = path.join(root.dir, e.name, SKILL_FILE);
56
+ let text;
57
+ try {
58
+ text = await fs.readFile(filePath, "utf8");
59
+ }
60
+ catch {
61
+ continue; // directory without SKILL.md is not a skill
62
+ }
63
+ const parsed = parseSkillMd(text);
64
+ const name = parsed.meta.name || e.name;
65
+ if (byName.has(name))
66
+ continue; // higher-priority root already defined it
67
+ byName.set(name, {
68
+ name,
69
+ description: parsed.meta.description || "",
70
+ source: root.source,
71
+ filePath,
72
+ });
73
+ }
74
+ }
75
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
76
+ }
77
+ export async function readSkillFile(def) {
78
+ return fs.readFile(def.filePath, "utf8");
79
+ }
80
+ /** Write (or overwrite) a workspace skill; returns the file path written. */
81
+ export async function saveSkill(workspaceRoot, name, description, content) {
82
+ const dir = path.join(workspaceRoot, name);
83
+ await fs.mkdir(dir, { recursive: true });
84
+ const filePath = path.join(dir, SKILL_FILE);
85
+ await fs.writeFile(filePath, `---\nname: ${name}\ndescription: ${description}\n---\n\n${content.trim()}\n`, "utf8");
86
+ return filePath;
87
+ }
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Tool execution: file ops, shell (with process-group kill + timeout), git.
3
+ * Tool specs are plain JSON-schema function definitions — provider-agnostic.
4
+ */
5
+ import { spawn } from "node:child_process";
6
+ import { promises as fs } from "node:fs";
7
+ import path from "node:path";
8
+ import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, } from "./skills.js";
9
+ const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
10
+ const num = (v, fallback) => (typeof v === "number" ? v : fallback);
11
+ function clip(s, max) {
12
+ if (s.length <= max)
13
+ return s;
14
+ return s.slice(0, max) + `\n... [truncated, ${s.length} bytes total]`;
15
+ }
16
+ async function readText(p) {
17
+ return fs.readFile(p, "utf8");
18
+ }
19
+ /** Resolve a path inside the workspace; reject escapes. */
20
+ function safeJoin(cwd, p) {
21
+ const abs = path.resolve(cwd, p);
22
+ const rel = path.relative(cwd, abs);
23
+ if (rel.startsWith("..") || path.isAbsolute(rel)) {
24
+ throw new Error(`path escapes workspace: ${p}`);
25
+ }
26
+ return abs;
27
+ }
28
+ /** Run a command in its own process group; kill the whole group on timeout. */
29
+ function runShell(cmd, ctx, timeoutMs) {
30
+ return new Promise((resolve) => {
31
+ const child = spawn("/bin/bash", ["-lc", cmd], {
32
+ cwd: ctx.cwd,
33
+ detached: true, // own process group
34
+ stdio: ["ignore", "pipe", "pipe"],
35
+ env: { ...process.env, TERM: "dumb", GIT_PAGER: "cat", PAGER: "cat" },
36
+ });
37
+ let out = "";
38
+ let done = false;
39
+ let timedOut = false;
40
+ const collect = (chunk) => {
41
+ if (out.length < ctx.maxOutputBytes)
42
+ out += chunk.toString("utf8");
43
+ };
44
+ child.stdout.on("data", collect);
45
+ child.stderr.on("data", collect);
46
+ const killGroup = () => {
47
+ try {
48
+ if (child.pid)
49
+ process.kill(-child.pid, "SIGKILL"); // whole group
50
+ }
51
+ catch {
52
+ /* already gone */
53
+ }
54
+ };
55
+ const timer = setTimeout(() => {
56
+ timedOut = true;
57
+ killGroup();
58
+ }, timeoutMs);
59
+ child.on("error", (err) => {
60
+ clearTimeout(timer);
61
+ if (!done) {
62
+ done = true;
63
+ resolve({ ok: false, result: `spawn error: ${err.message}` });
64
+ }
65
+ });
66
+ child.on("close", (code, signal) => {
67
+ clearTimeout(timer);
68
+ if (done)
69
+ return;
70
+ done = true;
71
+ if (timedOut) {
72
+ resolve({
73
+ ok: false,
74
+ result: `TIMEOUT after ${timeoutMs}ms. Partial output:\n${clip(out.trim() || "(no output)", ctx.maxOutputBytes)}`,
75
+ });
76
+ return;
77
+ }
78
+ resolve({
79
+ ok: code === 0,
80
+ result: (code === 0 ? "" : `exit=${code}${signal ? ` signal=${signal}` : ""}\n`) +
81
+ clip(out.trim() || "(no output)", ctx.maxOutputBytes),
82
+ });
83
+ });
84
+ });
85
+ }
86
+ export const DEFAULT_TIMEOUT_MS = 120_000;
87
+ export const TOOLS = [
88
+ {
89
+ name: "read_file",
90
+ description: "Read a text file from the workspace. Supports offset/limit for large files. Returns numbered lines.",
91
+ parameters: {
92
+ type: "object",
93
+ properties: {
94
+ path: { type: "string", description: "Path relative to workspace root" },
95
+ offset: { type: "number", description: "1-indexed start line" },
96
+ limit: { type: "number", description: "Max lines to return" },
97
+ },
98
+ required: ["path"],
99
+ },
100
+ async run(args, ctx) {
101
+ const p = safeJoin(ctx.cwd, str(args.path));
102
+ const text = await readText(p);
103
+ const lines = text.split("\n");
104
+ const off = Math.max(0, num(args.offset, 1) - 1);
105
+ const lim = num(args.limit, 2000);
106
+ const slice = lines.slice(off, off + lim).map((l, i) => `${off + i + 1}| ${l}`);
107
+ const more = off + lim < lines.length ? `\n... (${lines.length - off - lim} more lines)` : "";
108
+ return { ok: true, result: slice.join("\n") + more };
109
+ },
110
+ },
111
+ {
112
+ name: "write_file",
113
+ description: "Create or overwrite a file with the given content (parent dirs auto-created).",
114
+ parameters: {
115
+ type: "object",
116
+ properties: {
117
+ path: { type: "string" },
118
+ content: { type: "string" },
119
+ },
120
+ required: ["path", "content"],
121
+ },
122
+ async run(args, ctx) {
123
+ const p = safeJoin(ctx.cwd, str(args.path));
124
+ await fs.mkdir(path.dirname(p), { recursive: true });
125
+ await fs.writeFile(p, str(args.content), "utf8");
126
+ return { ok: true, result: `wrote ${p} (${String(args.content).length} bytes)` };
127
+ },
128
+ },
129
+ {
130
+ name: "edit_file",
131
+ description: "Replace an exact unique substring in a file. old_text must match exactly and be unique.",
132
+ parameters: {
133
+ type: "object",
134
+ properties: {
135
+ path: { type: "string" },
136
+ old_text: { type: "string" },
137
+ new_text: { type: "string" },
138
+ },
139
+ required: ["path", "old_text", "new_text"],
140
+ },
141
+ async run(args, ctx) {
142
+ const p = safeJoin(ctx.cwd, str(args.path));
143
+ const text = await readText(p);
144
+ const oldText = str(args.old_text);
145
+ const count = text.split(oldText).length - 1;
146
+ if (count === 0)
147
+ return { ok: false, result: "old_text not found in file" };
148
+ if (count > 1)
149
+ return { ok: false, result: `old_text matched ${count} times; must be unique` };
150
+ await fs.writeFile(p, text.replace(oldText, str(args.new_text)), "utf8");
151
+ return { ok: true, result: "edited" };
152
+ },
153
+ },
154
+ {
155
+ name: "list_dir",
156
+ description: "List files under a directory of the workspace.",
157
+ parameters: {
158
+ type: "object",
159
+ properties: { path: { type: "string", description: "default '.'" } },
160
+ },
161
+ async run(args, ctx) {
162
+ const p = safeJoin(ctx.cwd, str(args.path, "."));
163
+ const entries = await fs.readdir(p, { withFileTypes: true });
164
+ return {
165
+ ok: true,
166
+ result: entries.map((e) => (e.isDirectory() ? `${e.name}/` : e.name)).join("\n"),
167
+ };
168
+ },
169
+ },
170
+ {
171
+ name: "bash",
172
+ description: "Run a bash command inside the workspace (use it for git, builds, tests, etc.). " +
173
+ "Killed (whole process group) on timeout. stdout+stderr are returned.",
174
+ parameters: {
175
+ type: "object",
176
+ properties: {
177
+ command: { type: "string" },
178
+ timeout_ms: { type: "number", description: `default ${DEFAULT_TIMEOUT_MS}` },
179
+ },
180
+ required: ["command"],
181
+ },
182
+ async run(args, ctx) {
183
+ return runShell(str(args.command), ctx, Math.min(num(args.timeout_ms, ctx.defaultTimeoutMs), 600_000));
184
+ },
185
+ },
186
+ {
187
+ name: "load_skill",
188
+ description: "Load a skill's full instructions by name. Use when the system prompt's skill list " +
189
+ "matches your current task; follow the loaded playbook.",
190
+ parameters: {
191
+ type: "object",
192
+ properties: {
193
+ name: { type: "string", description: "skill name from the list in your system prompt" },
194
+ },
195
+ required: ["name"],
196
+ },
197
+ async run(args, ctx) {
198
+ const roots = ctx.skillRoots ?? [];
199
+ const skills = await discoverSkills(roots);
200
+ const def = skills.find((s) => s.name === str(args.name));
201
+ if (!def) {
202
+ return {
203
+ ok: false,
204
+ result: `unknown skill: ${str(args.name)}. Available: ${skills.map((s) => s.name).join(", ") || "(none)"}`,
205
+ };
206
+ }
207
+ return { ok: true, result: await readSkillFile(def) };
208
+ },
209
+ },
210
+ {
211
+ name: "save_skill",
212
+ description: "Create or update a reusable skill (a playbook you want to survive this session and be " +
213
+ "loadable later via load_skill). Write distilled, step-by-step instructions — not a chat log. " +
214
+ "The skill becomes available in your system prompt from the next turn.",
215
+ parameters: {
216
+ type: "object",
217
+ properties: {
218
+ name: { type: "string", description: "kebab-case id, e.g. release-checklist" },
219
+ description: { type: "string", description: "one line: what it is for / when to use it" },
220
+ content: { type: "string", description: "markdown instructions" },
221
+ },
222
+ required: ["name", "description", "content"],
223
+ },
224
+ async run(args, ctx) {
225
+ const roots = ctx.skillRoots ?? [];
226
+ if (roots.length === 0)
227
+ return { ok: false, result: "no skill roots configured" };
228
+ const name = str(args.name);
229
+ if (!isValidSkillName(name))
230
+ return { ok: false, result: "invalid skill name (use kebab-case: [a-z0-9.-], max 64 chars)" };
231
+ const description = str(args.description).slice(0, 200);
232
+ if (!description)
233
+ return { ok: false, result: "description required" };
234
+ const content = str(args.content);
235
+ if (!content.trim())
236
+ return { ok: false, result: "content required" };
237
+ const filePath = await saveSkill(roots[0].dir, name, description, content.slice(0, 64_000));
238
+ return { ok: true, result: `saved skill "${name}" to ${filePath} (listed from next turn)` };
239
+ },
240
+ },
241
+ ];
242
+ /** Current skills across the configured roots (for the system prompt listing). */
243
+ export async function currentSkills(ctx) {
244
+ return discoverSkills(ctx.skillRoots ?? []);
245
+ }
246
+ export function toolSpecs() {
247
+ return TOOLS.map((t) => ({
248
+ type: "function",
249
+ function: { name: t.name, description: t.description, parameters: t.parameters },
250
+ }));
251
+ }
252
+ export async function executeTool(name, rawArgs, ctx) {
253
+ const def = TOOLS.find((t) => t.name === name);
254
+ if (!def)
255
+ return { ok: false, result: `unknown tool: ${name}` };
256
+ let args;
257
+ try {
258
+ args = rawArgs ? JSON.parse(rawArgs) : {};
259
+ }
260
+ catch {
261
+ return { ok: false, result: `invalid JSON arguments: ${rawArgs.slice(0, 200)}` };
262
+ }
263
+ try {
264
+ return await def.run(args, ctx);
265
+ }
266
+ catch (err) {
267
+ return { ok: false, result: `tool error: ${err.message}` };
268
+ }
269
+ }
package/dist/bus.js ADDED
@@ -0,0 +1,4 @@
1
+ import { EventEmitter } from "node:events";
2
+ /** Tiny process-wide pub/sub used to push updates to SSE clients (no polling). */
3
+ export const bus = new EventEmitter();
4
+ bus.setMaxListeners(100);
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * teapot master entry point.
4
+ * Usage: teapot [config.json]
5
+ * Config may also come from TEAPOT_* env vars (see src/master.ts).
6
+ */
7
+ import { loadConfig, resolveConfigPath, Master } from "./master.js";
8
+ import { buildApp, serveApp } from "./server/api.js";
9
+ async function main() {
10
+ const configPath = resolveConfigPath(process.argv[2]);
11
+ const config = loadConfig(configPath);
12
+ console.log(`[teapot] config: ${configPath}`);
13
+ const hasProviders = Object.keys(config.providers ?? {}).length > 0;
14
+ if (!config.llm.apiKey && !hasProviders)
15
+ console.warn("[teapot] warning: no API key configured");
16
+ if (!config.llm.model && !hasProviders)
17
+ console.warn("[teapot] warning: no model configured");
18
+ const master = new Master(config, configPath);
19
+ await master.start();
20
+ const app = buildApp(master);
21
+ serveApp(app, config.port);
22
+ // agent crash isolation: an agent error never escapes its own loop; here we
23
+ // also make sure the process survives unexpected rejections.
24
+ process.on("uncaughtException", (err) => {
25
+ console.error("[teapot] uncaught exception (master survived):", err);
26
+ });
27
+ process.on("unhandledRejection", (err) => {
28
+ console.error("[teapot] unhandled rejection (master survived):", err);
29
+ });
30
+ const shutdown = async () => {
31
+ console.log("\n[teapot] shutting down...");
32
+ await Promise.allSettled([...master.agents.values()].map((a) => a.dispose()));
33
+ process.exit(0);
34
+ };
35
+ process.on("SIGINT", () => void shutdown());
36
+ process.on("SIGTERM", () => void shutdown());
37
+ }
38
+ void main();
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Append-only JSONL event log.
3
+ *
4
+ * Design goals:
5
+ * - One file per agent (sessions.log.jsonl). Every conversation, including
6
+ * forks, lives in the SAME file as an interleaved event stream.
7
+ * - Each line is one self-contained JSON object; humans can `cat`/`jq` it.
8
+ * - Lineage is explicit: every event carries `session`, `branch`, and
9
+ * `parent` (the previous event on the same branch). A `fork` event records
10
+ * where the new branch started (fromSession/fromBranch/fromEvent), so any
11
+ * session can be reconstructed by filtering `branch === X` or by walking
12
+ * parent links from the fork point backwards into ancestor branches.
13
+ * - Append-only + monotonic `seq` makes corruption detectable (a torn final
14
+ * line is simply ignored on read).
15
+ */
16
+ import { createWriteStream } from "node:fs";
17
+ import { mkdirSync } from "node:fs";
18
+ import path from "node:path";
19
+ export class EventLog {
20
+ filePath;
21
+ agentId;
22
+ stream = null;
23
+ seq = 0;
24
+ chain = Promise.resolve();
25
+ /** branch -> last event id (in-memory reconstruction of parent chains) */
26
+ lastByBranch = new Map();
27
+ constructor(filePath, agentId) {
28
+ this.filePath = filePath;
29
+ this.agentId = agentId;
30
+ }
31
+ async load() {
32
+ mkdirSync(path.dirname(this.filePath), { recursive: true });
33
+ try {
34
+ const { readFile } = await import("node:fs/promises");
35
+ const text = await readFile(this.filePath, "utf8");
36
+ for (const line of text.split("\n")) {
37
+ if (!line.trim())
38
+ continue;
39
+ try {
40
+ const e = JSON.parse(line);
41
+ if (typeof e.seq === "number" && e.seq > this.seq)
42
+ this.seq = e.seq;
43
+ this.lastByBranch.set(e.branch, e.id);
44
+ }
45
+ catch {
46
+ /* torn trailing line: ignore */
47
+ }
48
+ }
49
+ }
50
+ catch {
51
+ /* new log */
52
+ }
53
+ // repair a torn tail: without this, the next append would fuse into the
54
+ // corrupt partial line and destroy two events instead of one
55
+ try {
56
+ const { stat, appendFile } = await import("node:fs/promises");
57
+ const st = await stat(this.filePath);
58
+ if (st.size > 0) {
59
+ const buf = Buffer.alloc(1);
60
+ const fh = await import("node:fs/promises").then((m) => m.open(this.filePath, "r"));
61
+ await fh.read(buf, 0, 1, st.size - 1);
62
+ await fh.close();
63
+ if (buf[0] !== 0x0a)
64
+ await appendFile(this.filePath, "\n");
65
+ }
66
+ }
67
+ catch {
68
+ /* file may not exist yet */
69
+ }
70
+ this.stream = createWriteStream(this.filePath, { flags: "a" });
71
+ this.stream.on("error", (err) => {
72
+ console.error(`[teapot] log write error (${this.filePath}):`, err.message);
73
+ });
74
+ }
75
+ /** Append an event; resolves when it is handed to the OS (write flushed). */
76
+ append(type, session, branch, data) {
77
+ const evt = {
78
+ v: 1,
79
+ id: `e${++this.seq}`,
80
+ seq: this.seq,
81
+ ts: new Date().toISOString(),
82
+ agent: this.agentId,
83
+ session,
84
+ branch,
85
+ parent: this.lastByBranch.get(branch) ?? null,
86
+ type,
87
+ data,
88
+ };
89
+ this.lastByBranch.set(branch, evt.id);
90
+ const p = new Promise((resolve, reject) => {
91
+ this.chain = this.chain.then(() => {
92
+ if (!this.stream)
93
+ return resolve(evt);
94
+ this.stream.write(JSON.stringify(evt) + "\n", "utf8", (err) => err ? reject(err) : resolve(evt));
95
+ }, () => resolve(evt));
96
+ });
97
+ this.chain = this.chain.then(() => { }, () => { });
98
+ return p;
99
+ }
100
+ lastEventId(branch) {
101
+ return this.lastByBranch.get(branch) ?? null;
102
+ }
103
+ async close() {
104
+ await this.chain.catch(() => { });
105
+ if (!this.stream)
106
+ return;
107
+ const s = this.stream;
108
+ this.stream = null;
109
+ await new Promise((res) => s.end(res));
110
+ }
111
+ }
112
+ /** Read all events from a JSONL file (tolerates a torn final line). */
113
+ export async function readEvents(filePath) {
114
+ try {
115
+ const { readFile } = await import("node:fs/promises");
116
+ const out = [];
117
+ for (const line of (await readFile(filePath, "utf8")).split("\n")) {
118
+ if (!line.trim())
119
+ continue;
120
+ try {
121
+ out.push(JSON.parse(line));
122
+ }
123
+ catch {
124
+ /* skip bad line */
125
+ }
126
+ }
127
+ return out;
128
+ }
129
+ catch {
130
+ return [];
131
+ }
132
+ }