@dirwin517/bot-farm 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.
Files changed (55) hide show
  1. package/.idea/bot-farm.iml +9 -0
  2. package/.idea/misc.xml +6 -0
  3. package/.idea/modules.xml +8 -0
  4. package/.idea/vcs.xml +6 -0
  5. package/LICENSE +21 -0
  6. package/README.md +584 -0
  7. package/package.json +13 -0
  8. package/src/api.mjs +567 -0
  9. package/src/cli.mjs +241 -0
  10. package/src/craft.mjs +172 -0
  11. package/src/db.mjs +93 -0
  12. package/src/farmhands.mjs +111 -0
  13. package/src/git.mjs +578 -0
  14. package/src/identity.mjs +199 -0
  15. package/src/legacy-defaults.mjs +44 -0
  16. package/src/mcp.mjs +681 -0
  17. package/src/mesh.mjs +222 -0
  18. package/src/packets.mjs +125 -0
  19. package/src/patches.mjs +141 -0
  20. package/src/pipelines.mjs +2145 -0
  21. package/src/pricing.mjs +91 -0
  22. package/src/projects.mjs +148 -0
  23. package/src/public/classic.html +1810 -0
  24. package/src/public/index.html +3284 -0
  25. package/src/questions.mjs +69 -0
  26. package/src/quota.mjs +76 -0
  27. package/src/registry.mjs +88 -0
  28. package/src/replay.mjs +151 -0
  29. package/src/rooms.mjs +320 -0
  30. package/src/server.mjs +765 -0
  31. package/src/store.mjs +546 -0
  32. package/src/supervisor.mjs +1803 -0
  33. package/src/tasks.mjs +191 -0
  34. package/src/template.mjs +86 -0
  35. package/src/tools.mjs +241 -0
  36. package/src/workspaces.mjs +605 -0
  37. package/src/ws.mjs +130 -0
  38. package/src/yaml.mjs +266 -0
  39. package/test/board.mjs +108 -0
  40. package/test/craft.mjs +281 -0
  41. package/test/extend.mjs +145 -0
  42. package/test/flow.mjs +165 -0
  43. package/test/handoff.mjs +228 -0
  44. package/test/linked.mjs +83 -0
  45. package/test/loop.mjs +168 -0
  46. package/test/mock-opencode.mjs +217 -0
  47. package/test/parallel.mjs +135 -0
  48. package/test/patches.mjs +93 -0
  49. package/test/repos.mjs +118 -0
  50. package/test/roles.mjs +157 -0
  51. package/test/shot.mjs +65 -0
  52. package/test/smoke.mjs +70 -0
  53. package/test/transport.mjs +131 -0
  54. package/test/workspaces.mjs +228 -0
  55. package/test/workspaces.mjs.tmp +0 -0
@@ -0,0 +1,69 @@
1
+ // Questions for the operator, in the shape Claude and opencode use for theirs:
2
+ // each question may offer choices (pick one, or several), and every question
3
+ // can also be answered in your own words. A question with no choices is just
4
+ // open-ended.
5
+
6
+ const clip = (s, n) => String(s ?? "").trim().slice(0, n)
7
+
8
+ /** Strings, { question, options, multiple }, or a mix — always the same shape out. */
9
+ export function normalizeQuestions(list) {
10
+ return (Array.isArray(list) ? list : list ? [list] : [])
11
+ .map((q) => {
12
+ if (typeof q === "string") return { question: clip(q, 1000), options: [], multiple: false }
13
+ if (!q || typeof q !== "object") return null
14
+ const options = (Array.isArray(q.options) ? q.options : [])
15
+ .map((o) => (typeof o === "string" ? { label: clip(o, 200) } : o?.label ? { label: clip(o.label, 200), description: o.description ? clip(o.description, 400) : undefined } : null))
16
+ .filter((o) => o?.label)
17
+ .slice(0, 8)
18
+ return {
19
+ question: clip(q.question ?? q.text ?? "", 1000),
20
+ header: q.header ? clip(q.header, 40) : undefined,
21
+ options,
22
+ multiple: !!(q.multiple ?? q.multiSelect ?? q.multi_select),
23
+ }
24
+ })
25
+ .filter((q) => q?.question)
26
+ .slice(0, 12)
27
+ }
28
+
29
+ /** The questions as plain lines, for prompts and the chat. */
30
+ export function questionText(questions) {
31
+ return questions.map((q) => q.question)
32
+ }
33
+
34
+ /**
35
+ * Answers as text a bot can read. `answers[i]` is { picked: [labels], text }
36
+ * for question i; unanswered questions are said to be unanswered rather than
37
+ * silently dropped.
38
+ */
39
+ export function formatAnswers(questions, answers = []) {
40
+ return questions
41
+ .map((q, i) => {
42
+ const a = answers[i] ?? {}
43
+ const picked = (a.picked ?? []).filter(Boolean)
44
+ const own = clip(a.text, 4000)
45
+ const said = [picked.length ? picked.join(", ") : "", own].filter(Boolean).join(" — ")
46
+ return `${questions.length > 1 ? `${i + 1}. ` : ""}${q.question}\n → ${said || "(no answer — use your judgement)"}`
47
+ })
48
+ .join("\n")
49
+ }
50
+
51
+ export const QUESTION_SCHEMA = {
52
+ type: "object",
53
+ properties: {
54
+ question: { type: "string", description: "One thing, plainly, with enough context to answer without opening your session." },
55
+ header: { type: "string", description: "Optional short label (a few words)." },
56
+ options: {
57
+ type: "array",
58
+ description: "Concrete choices, when there are some. The operator can always answer in their own words too, so do not add an 'Other' option. Leave out for an open question.",
59
+ items: {
60
+ anyOf: [
61
+ { type: "string" },
62
+ { type: "object", properties: { label: { type: "string" }, description: { type: "string" } }, required: ["label"] },
63
+ ],
64
+ },
65
+ },
66
+ multiple: { type: "boolean", description: "True if more than one option can be picked." },
67
+ },
68
+ required: ["question"],
69
+ }
package/src/quota.mjs ADDED
@@ -0,0 +1,76 @@
1
+ // Per-model quotas for routing: "up to $5 a day on Opus, then Sonnet".
2
+ //
3
+ // Spend is kept per workspace, per model and per period in the db, in four
4
+ // buckets at once — day, week, month and workstream — so any quota can be
5
+ // answered with one read. Money is BotFarm's own estimate (the same pricing as
6
+ // everywhere else); tokens are what opencode reports; minutes are time a bot
7
+ // spent busy on that model.
8
+
9
+ export const PERIODS = ["day", "week", "month", "workstream"]
10
+
11
+ /** "amazon-bedrock/us.anthropic.claude-opus-5" and "claude-opus-5" are the same model for a quota. */
12
+ export function modelKey(m) {
13
+ return String(m ?? "").split("/").pop().replace(/^(us|eu|apac|global)\./i, "").replace(/^anthropic\./i, "").replace(/-\d{8}(-v\d+(:\d+)?)?$/, "").toLowerCase()
14
+ }
15
+
16
+ function isoWeek(d) {
17
+ const t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
18
+ const day = t.getUTCDay() || 7
19
+ t.setUTCDate(t.getUTCDate() + 4 - day)
20
+ const year = t.getUTCFullYear()
21
+ const week = Math.ceil(((t - Date.UTC(year, 0, 1)) / 86400_000 + 1) / 7)
22
+ return `${year}-W${String(week).padStart(2, "0")}`
23
+ }
24
+
25
+ export function bucketOf(per, at = Date.now(), projectId = null) {
26
+ const d = new Date(at)
27
+ const pad = (n) => String(n).padStart(2, "0")
28
+ if (per === "workstream") return `p:${projectId ?? "none"}`
29
+ if (per === "week") return `w:${isoWeek(d)}`
30
+ if (per === "month") return `m:${d.getFullYear()}-${pad(d.getMonth() + 1)}`
31
+ return `d:${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
32
+ }
33
+
34
+ /** The first quota a spend has reached, or null. */
35
+ export function overQuota(quota, spent) {
36
+ if (!quota) return null
37
+ for (const kind of ["usd", "tokens", "minutes"]) {
38
+ if (Number(quota[kind]) > 0 && (spent?.[kind] ?? 0) >= Number(quota[kind])) return { kind, limit: Number(quota[kind]), used: spent[kind] }
39
+ }
40
+ return null
41
+ }
42
+
43
+ export class ModelLedger {
44
+ constructor(db) {
45
+ this.db = db
46
+ }
47
+
48
+ id(wsId, model, bucket) {
49
+ return `${wsId ?? "none"}|${modelKey(model)}|${bucket}`
50
+ }
51
+
52
+ /** Also counted under `model@persona`, so an agent's own quota counts only that agent's spend. */
53
+ add(wsId, model, projectId, amounts = {}, at = Date.now(), persona = null) {
54
+ this.addOne(wsId, model, projectId, amounts, at)
55
+ if (persona) this.addOne(wsId, `${modelKey(model)}@${persona}`, projectId, amounts, at)
56
+ }
57
+
58
+ addOne(wsId, model, projectId, { usd = 0, tokens = 0, minutes = 0 } = {}, at = Date.now()) {
59
+ if (!model || (!usd && !tokens && !minutes)) return
60
+ for (const per of PERIODS) {
61
+ if (per === "workstream" && !projectId) continue
62
+ const id = this.id(wsId, model, bucketOf(per, at, projectId))
63
+ const row = this.db.get("modelspend", id) ?? { id, ws: wsId ?? null, model: modelKey(model), bucket: bucketOf(per, at, projectId), usd: 0, tokens: 0, minutes: 0 }
64
+ row.usd += usd
65
+ row.tokens += tokens
66
+ row.minutes += minutes
67
+ row.updated = at
68
+ this.db.put("modelspend", row)
69
+ }
70
+ }
71
+
72
+ spent(wsId, model, per = "day", projectId = null, at = Date.now()) {
73
+ const row = this.db.get("modelspend", this.id(wsId, model, bucketOf(per, at, projectId)))
74
+ return { usd: row?.usd ?? 0, tokens: row?.tokens ?? 0, minutes: row?.minutes ?? 0 }
75
+ }
76
+ }
@@ -0,0 +1,88 @@
1
+ // botfarm only manages sessions you put on the board.
2
+ //
3
+ // `opencode` accumulates every session you have ever opened; listing them all
4
+ // turns the dashboard into an archive browser. The registry is the line
5
+ // between "a session that exists" and "a session I am running right now",
6
+ // and it is also where botfarm keeps the metadata opencode has no place for:
7
+ // group, label, notes, mesh policy, lineage.
8
+
9
+ import { readFile, writeFile, mkdir } from "node:fs/promises"
10
+ import { dirname } from "node:path"
11
+
12
+ export class Registry {
13
+ constructor(path) {
14
+ this.path = path
15
+ this.entries = new Map() // sessionId -> { group, label, notes, adoptedAt, createdByBotfarm }
16
+ this.groups = ["default"]
17
+ }
18
+
19
+ async load() {
20
+ try {
21
+ const raw = JSON.parse(await readFile(this.path, "utf8"))
22
+ this.entries = new Map(Object.entries(raw.sessions ?? {}))
23
+ this.groups = raw.groups?.length ? raw.groups : ["default"]
24
+ } catch {}
25
+ }
26
+
27
+ async save() {
28
+ await mkdir(dirname(this.path), { recursive: true })
29
+ await writeFile(
30
+ this.path,
31
+ JSON.stringify({ sessions: Object.fromEntries(this.entries), groups: this.groups }, null, 2),
32
+ )
33
+ }
34
+
35
+ has(id) {
36
+ return this.entries.has(id)
37
+ }
38
+
39
+ get(id) {
40
+ return this.entries.get(id) ?? null
41
+ }
42
+
43
+ adopt(id, meta = {}) {
44
+ const existing = this.entries.get(id) ?? {}
45
+ const entry = {
46
+ group: "default",
47
+ label: null,
48
+ notes: "",
49
+ createdByBotfarm: false,
50
+ adoptedAt: Date.now(),
51
+ ...existing,
52
+ ...meta,
53
+ }
54
+ this.entries.set(id, entry)
55
+ if (entry.group && !this.groups.includes(entry.group)) this.groups.push(entry.group)
56
+ return entry
57
+ }
58
+
59
+ release(id) {
60
+ this.entries.delete(id)
61
+ }
62
+
63
+ update(id, patch) {
64
+ const entry = this.entries.get(id)
65
+ if (!entry) return null
66
+ Object.assign(entry, patch)
67
+ if (patch.group && !this.groups.includes(patch.group)) this.groups.push(patch.group)
68
+ return entry
69
+ }
70
+
71
+ addGroup(name) {
72
+ const clean = String(name).trim().slice(0, 32)
73
+ if (clean && !this.groups.includes(clean)) this.groups.push(clean)
74
+ return clean
75
+ }
76
+
77
+ removeGroup(name) {
78
+ if (name === "default") return
79
+ this.groups = this.groups.filter((g) => g !== name)
80
+ for (const e of this.entries.values()) if (e.group === name) e.group = "default"
81
+ }
82
+
83
+ counts() {
84
+ const out = Object.fromEntries(this.groups.map((g) => [g, 0]))
85
+ for (const e of this.entries.values()) out[e.group] = (out[e.group] ?? 0) + 1
86
+ return out
87
+ }
88
+ }
package/src/replay.mjs ADDED
@@ -0,0 +1,151 @@
1
+ // Replays: everything that happened in a workstream, as a timeline you can
2
+ // scrub through later — or save as one file and open on another computer.
3
+ //
4
+ // While a workstream runs, small events are appended to
5
+ // <workspace>/.botfarm/workstreams/<id>.replay.jsonl (buffered, a line each):
6
+ //
7
+ // { t, k: "task", task: { id, title, stage, status, assignee, parentId, item, kind, owner } }
8
+ // { t, k: "bot", bot: { id, handle, persona, personaTitle, status, tool } }
9
+ // { t, k: "msg", msg: { id, from, fromId, text } }
10
+ // { t, k: "note", text, level }
11
+ // { t, k: "harvest", harvest }
12
+ //
13
+ // An export bundles that with the workstream's header into a single JSON
14
+ // document ("botfarm-replay", version 1). Avatars are drawn from id + handle,
15
+ // so a replay needs nothing from the machine that recorded it.
16
+
17
+ import { appendFile, readFile, mkdir, writeFile, readdir } from "node:fs/promises"
18
+ import { join, dirname } from "node:path"
19
+
20
+ export const REPLAY_FORMAT = "botfarm-replay"
21
+
22
+ const pickTask = (t) => ({ id: t.id, title: t.title, stage: t.stage ?? null, status: t.status, assignee: t.assignee ?? null, parentId: t.parentId ?? null, item: t.item ?? null, kind: t.kind ?? "work", owner: t.owner ?? null, order: t.order ?? 0, children: t.children ?? null, dependsOn: t.dependsOn ?? [] })
23
+ const pickBot = (s) => ({ id: s.id, handle: s.handle, persona: s.persona ?? null, personaTitle: s.personaTitle ?? null, status: s.status, tool: s.currentTool?.name ?? null })
24
+
25
+ export class Recorder {
26
+ constructor({ pathFor }) {
27
+ this.pathFor = pathFor // (projectId) => file path, or null when it has no workspace
28
+ this.buf = new Map() // path -> lines
29
+ this.last = new Map() // `${projectId}:${kind}:${id}` -> signature, to record changes only
30
+ this.timer = setInterval(() => this.flush().catch(() => {}), 2000)
31
+ this.timer.unref?.()
32
+ }
33
+
34
+ push(projectId, ev) {
35
+ if (!projectId || projectId === "default") return
36
+ const path = this.pathFor(projectId)
37
+ if (!path) return
38
+ const line = JSON.stringify({ t: Date.now(), ...ev })
39
+ this.buf.set(path, [...(this.buf.get(path) ?? []), line])
40
+ }
41
+
42
+ /** Record a task only when something visible about it changed. */
43
+ task(t) {
44
+ if (!t?.projectId) return
45
+ const snap = pickTask(t)
46
+ const sig = JSON.stringify([snap.status, snap.assignee, snap.title, snap.children?.length])
47
+ const key = `${t.projectId}:task:${t.id}`
48
+ if (this.last.get(key) === sig) return
49
+ this.last.set(key, sig)
50
+ this.push(t.projectId, { k: "task", task: snap })
51
+ }
52
+
53
+ bot(s) {
54
+ if (!s?.project) return
55
+ const snap = pickBot(s)
56
+ const sig = JSON.stringify([snap.status, snap.tool, snap.handle])
57
+ const key = `${s.project}:bot:${s.id}`
58
+ if (this.last.get(key) === sig) return
59
+ this.last.set(key, sig)
60
+ this.push(s.project, { k: "bot", bot: snap })
61
+ }
62
+
63
+ msg(projectId, m) {
64
+ this.push(projectId, { k: "msg", msg: { id: m.id, from: m.from, fromId: m.fromId ?? null, text: String(m.text).slice(0, 4000) } })
65
+ }
66
+
67
+ note(projectId, text, level = "info") {
68
+ this.push(projectId, { k: "note", text: String(text).slice(0, 400), level })
69
+ }
70
+
71
+ harvest(projectId, h) {
72
+ this.push(projectId, { k: "harvest", harvest: h })
73
+ }
74
+
75
+ async flush() {
76
+ for (const [path, lines] of [...this.buf]) {
77
+ this.buf.delete(path)
78
+ if (!lines.length) continue
79
+ await mkdir(dirname(path), { recursive: true }).catch(() => {})
80
+ await appendFile(path, lines.join("\n") + "\n").catch(() => {})
81
+ }
82
+ }
83
+
84
+ async events(projectId) {
85
+ await this.flush()
86
+ const path = this.pathFor(projectId)
87
+ if (!path) return []
88
+ const text = await readFile(path, "utf8").catch(() => "")
89
+ return text.split("\n").filter(Boolean).map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
90
+ }
91
+
92
+ stop() {
93
+ clearInterval(this.timer)
94
+ return this.flush()
95
+ }
96
+ }
97
+
98
+ /** One portable document for a workstream. */
99
+ export function bundle({ project, run, team, tasks, messages, events, harvest = null }) {
100
+ const start = Math.min(...[run?.created, project?.created, events[0]?.t].filter(Boolean)) || Date.now()
101
+ // Seed the timeline with the state at the start, so a replay recorded
102
+ // mid-way (or before recording existed) still has a board to show.
103
+ const seeded = [
104
+ ...team.map((s) => ({ t: start, k: "bot", bot: pickBot({ ...s, status: "idle", currentTool: null }) })),
105
+ ...tasks.filter((t) => !events.some((e) => e.k === "task" && e.task.id === t.id)).map((t) => ({ t: t.created ?? start, k: "task", task: pickTask(t) })),
106
+ ...messages.filter((m) => !events.some((e) => e.k === "msg" && e.msg.id === m.id)).map((m) => ({ t: m.at, k: "msg", msg: { id: m.id, from: m.from, fromId: m.fromId ?? null, text: m.text } })),
107
+ ]
108
+ const all = [...seeded, ...events].sort((a, b) => a.t - b.t)
109
+ return {
110
+ format: REPLAY_FORMAT,
111
+ version: 1,
112
+ exportedAt: new Date().toISOString(),
113
+ project: { id: project.id, name: project.name, branch: project.branch ?? null, story: project.story ?? null, pipeline: project.pipelineTitle ?? project.pipeline ?? null },
114
+ team: team.map((s) => ({ id: s.id, handle: s.handle, persona: s.persona ?? null, personaTitle: s.personaTitle ?? null, model: s.model ?? s.configuredModel ?? null })),
115
+ start,
116
+ end: all.at(-1)?.t ?? start,
117
+ harvest,
118
+ events: all,
119
+ }
120
+ }
121
+
122
+ export function validBundle(doc) {
123
+ return doc && doc.format === REPLAY_FORMAT && Array.isArray(doc.events) && doc.project?.name
124
+ }
125
+
126
+ /** Imported replays live in ~/.botfarm/replays, one file each. */
127
+ export class ReplayShelf {
128
+ constructor(dir) { this.dir = dir }
129
+ async list() {
130
+ const names = await readdir(this.dir).catch(() => [])
131
+ const out = []
132
+ for (const n of names.filter((x) => x.endsWith(".json"))) {
133
+ try {
134
+ const d = JSON.parse(await readFile(join(this.dir, n), "utf8"))
135
+ out.push({ id: n.replace(/\.json$/, ""), name: d.project?.name, start: d.start, end: d.end, events: d.events?.length ?? 0, importedFrom: d.importedFrom ?? null })
136
+ } catch {}
137
+ }
138
+ return out.sort((a, b) => (b.end ?? 0) - (a.end ?? 0))
139
+ }
140
+ async get(id) {
141
+ if (!/^[\w.-]+$/.test(id)) throw new Error("bad replay id")
142
+ return JSON.parse(await readFile(join(this.dir, `${id}.json`), "utf8"))
143
+ }
144
+ async put(doc) {
145
+ if (!validBundle(doc)) throw new Error("that is not a BotFarm replay file")
146
+ const id = `${String(doc.project.name).toLowerCase().replace(/[^a-z0-9]+/g, "-").slice(0, 40)}-${Date.now().toString(36)}`
147
+ await mkdir(this.dir, { recursive: true })
148
+ await writeFile(join(this.dir, `${id}.json`), JSON.stringify(doc))
149
+ return id
150
+ }
151
+ }