@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,91 @@
1
+ // Cost, computed here rather than taken from the session.
2
+ //
3
+ // opencode reports a cost only when the provider hands one back. Bedrock does
4
+ // not: it returns usage and bills you out of band, so every Bedrock session
5
+ // shows $0.00. We price the tokens ourselves from a table, and say plainly
6
+ // that the number is an estimate.
7
+ //
8
+ // Prices are USD per million tokens and go stale. Override or extend them in
9
+ // ~/.botfarm/config.json:
10
+ //
11
+ // "pricing": { "claude-sonnet-4-5": { "input": 3, "output": 15 } }
12
+ //
13
+ // Bedrock and Vertex list their own prices per region and are often a little
14
+ // above the first-party rate, so treat these as a floor on those providers.
15
+
16
+ const M = 1_000_000
17
+
18
+ // Matched longest-pattern-first against a normalised model id.
19
+ export const TABLE = {
20
+ "claude-opus-4-5": { input: 5, output: 25 },
21
+ "claude-opus-4": { input: 15, output: 75 },
22
+ "claude-opus-3": { input: 15, output: 75 },
23
+ "claude-sonnet-4": { input: 3, output: 15 },
24
+ "claude-3-7-sonnet": { input: 3, output: 15 },
25
+ "claude-3-5-sonnet": { input: 3, output: 15 },
26
+ "claude-3-5-haiku": { input: 0.8, output: 4 },
27
+ "claude-haiku-4": { input: 1, output: 5 },
28
+ "claude-3-haiku": { input: 0.25, output: 1.25 },
29
+ "gpt-4o-mini": { input: 0.15, output: 0.6 },
30
+ "gpt-4o": { input: 2.5, output: 10 },
31
+ "gpt-4.1-mini": { input: 0.4, output: 1.6 },
32
+ "gpt-4.1": { input: 2, output: 8 },
33
+ "llama": { input: 0.3, output: 0.6 },
34
+ "mistral": { input: 0.3, output: 0.9 },
35
+ "deepseek": { input: 0.3, output: 1.1 },
36
+ "qwen": { input: 0.3, output: 0.9 },
37
+ }
38
+
39
+ // Anthropic-style cache pricing: reads are a tenth of input, five-minute
40
+ // writes are a quarter more. Providers that do not price cache separately
41
+ // simply report zero cached tokens, so this costs nothing.
42
+ const CACHE_READ = 0.1
43
+ const CACHE_WRITE = 1.25
44
+
45
+ /**
46
+ * Bedrock ids carry a region prefix and a version suffix
47
+ * ("us.anthropic.claude-sonnet-4-20250514-v1:0"), Vertex uses "@" versions,
48
+ * and opencode prefixes the provider. Strip all of it down to the family.
49
+ */
50
+ export function normalise(model) {
51
+ if (!model) return ""
52
+ let id = String(model).toLowerCase()
53
+ if (id.includes("arn:aws:bedrock")) id = id.split("/").pop() ?? id
54
+ id = id.split("/").pop() ?? id // provider/model
55
+ id = id.replace(/^(us|eu|apac|global)\./, "") // bedrock region prefix
56
+ id = id.replace(/^(anthropic|amazon|meta|mistral|openai|deepseek|qwen)\./, "")
57
+ // Only strip real version suffixes: "-v1:0", "@001", ":0". A bare trailing
58
+ // number is part of the model name ("claude-opus-5"), not a version.
59
+ id = id.replace(/[-@]v\d+(:\d+)?$/, "")
60
+ id = id.replace(/@\d{3}$/, "")
61
+ id = id.replace(/:\d+$/, "")
62
+ id = id.replace(/-\d{8}$/, "") // date stamps
63
+ return id
64
+ }
65
+
66
+ export function rateFor(model, overrides = {}) {
67
+ const id = normalise(model)
68
+ if (!id) return null
69
+ const table = { ...TABLE, ...overrides }
70
+ const keys = Object.keys(table).sort((a, b) => b.length - a.length)
71
+ for (const k of keys) if (id.includes(k)) return { ...table[k], matched: k }
72
+ return null
73
+ }
74
+
75
+ /**
76
+ * @returns {{ usd: number, estimated: boolean, rate: object|null }}
77
+ * `estimated: false` means the provider told us the number and we believed it.
78
+ */
79
+ export function estimate({ model, totals, reported = 0, overrides = {} }) {
80
+ if (reported > 0) return { usd: reported, estimated: false, rate: null }
81
+ const rate = rateFor(model, overrides)
82
+ if (!rate) return { usd: 0, estimated: true, rate: null, unknownModel: normalise(model) || null }
83
+ const usd =
84
+ ((totals.input ?? 0) * rate.input +
85
+ (totals.output ?? 0) * rate.output +
86
+ (totals.reasoning ?? 0) * rate.output +
87
+ (totals.cacheRead ?? 0) * rate.input * CACHE_READ +
88
+ (totals.cacheWrite ?? 0) * rate.input * CACHE_WRITE) /
89
+ M
90
+ return { usd, estimated: true, rate }
91
+ }
@@ -0,0 +1,148 @@
1
+ // A project is the thing you actually work on: a checkout, the sessions
2
+ // working in it, the tasks they are working through, and the conversation they
3
+ // are having about it.
4
+ //
5
+ // Those used to be four separate ideas in botfarm — a group, a worktree, a board
6
+ // and a room — which meant four places to look and four things to create
7
+ // before anything could happen. They are one object now. Creating a project
8
+ // creates its chat; adding a session to a project adds it to that chat; the
9
+ // project's board is its tasks. The global board still exists, as a view
10
+ // across every project rather than as the primary one.
11
+
12
+ const slug = (name) =>
13
+ String(name ?? "project")
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9]+/g, "-")
16
+ .replace(/^-|-$/g, "")
17
+ .slice(0, 40) || "project"
18
+
19
+ export class Projects {
20
+ constructor({ db, supervisor }) {
21
+ this.db = db
22
+ this.sup = supervisor
23
+ }
24
+
25
+ list() {
26
+ const rows = this.db.all("projects")
27
+ if (!rows.some((p) => p.id === "default")) {
28
+ rows.unshift({ id: "default", name: "Default", repo: null, branch: null, created: 0, roomId: null })
29
+ }
30
+ return rows.sort((a, b) => (a.id === "default" ? -1 : b.id === "default" ? 1 : b.created - a.created))
31
+ }
32
+
33
+ get(id) {
34
+ if (!id) return null
35
+ return this.db.get("projects", id) ?? (id === "default" ? this.list()[0] : null)
36
+ }
37
+
38
+ find(ref) {
39
+ return this.get(ref) ?? this.list().find((p) => p.name === ref || p.id === slug(ref)) ?? null
40
+ }
41
+
42
+ create({ name, repo = null, branch = null, worktree = null, services = [], unique = false, workspaceId = null }) {
43
+ let id = slug(name)
44
+ if (unique) for (let n = 2; this.db.get("projects", id) || id === "default"; n++) id = `${slug(name).slice(0, 36)}-${n}`
45
+ if (this.db.get("projects", id)) throw new Error(`a project called "${id}" already exists`)
46
+ const project = {
47
+ id,
48
+ name: name ?? id,
49
+ repo,
50
+ branch,
51
+ worktree,
52
+ services,
53
+ workspaceId: workspaceId ?? this.sup.workspaces?.forPath(repo)?.id ?? null,
54
+ status: "open",
55
+ roomId: null,
56
+ created: Date.now(),
57
+ }
58
+ this.db.put("projects", project)
59
+ this.sup.store.note(`project ${project.name} created`)
60
+ this.sup.onChange()
61
+ return project
62
+ }
63
+
64
+ save(project) {
65
+ this.db.put("projects", project)
66
+ this.sup.onChange()
67
+ return project
68
+ }
69
+
70
+ remove(id) {
71
+ if (id === "default") throw new Error("the default project cannot be removed")
72
+ for (const s of this.sessions(id)) this.sup.registry.update(s.id, { project: "default" })
73
+ this.db.delete("projects", id)
74
+ this.sup.onChange()
75
+ }
76
+
77
+ sessions(id) {
78
+ return this.sup.store.list().filter((s) => (s.project ?? "default") === id)
79
+ }
80
+
81
+ tasks(id) {
82
+ return this.sup.tasks.all({ projectId: id })
83
+ }
84
+
85
+ /**
86
+ * A project's chat is created the first time it is needed, with whoever is
87
+ * in the project — so "the conversation about this project" never has to be
88
+ * set up as a separate act.
89
+ */
90
+ room(id, { create = true } = {}) {
91
+ const project = this.get(id)
92
+ if (!project) return null
93
+ const existing = project.roomId ? this.sup.rooms.rooms.get(project.roomId) : null
94
+ if (existing) return existing
95
+ if (!create) return null
96
+ const members = this.sessions(id)
97
+ const room = this.sup.rooms.create({
98
+ name: project.id,
99
+ topic: project.branch ? `${project.name} · ${project.branch}` : project.name,
100
+ members,
101
+ createdBy: "operator",
102
+ })
103
+ room.projectId = id
104
+ project.roomId = room.id
105
+ this.save(project)
106
+ return room
107
+ }
108
+
109
+ /** Moving a session into a project moves it into that project's chat too. */
110
+ attach(sessionId, projectId) {
111
+ const session = this.sup.store.get(sessionId)
112
+ if (!session) return null
113
+ // Naming a project that does not exist yet creates it: assigning work is
114
+ // how projects come into being, not a separate ceremony beforehand.
115
+ if (projectId !== "default" && !this.get(projectId)) {
116
+ this.create({ name: projectId, repo: session.repo ?? session.directory, branch: session.branch })
117
+ }
118
+ const previous = session.project ?? "default"
119
+ session.project = projectId
120
+ this.sup.registry.update(sessionId, { project: projectId })
121
+ if (previous !== projectId) {
122
+ const old = this.get(previous)?.roomId ? this.sup.rooms.rooms.get(this.get(previous).roomId) : null
123
+ if (old) this.sup.rooms.leave(old, session)
124
+ }
125
+ const room = this.room(projectId, { create: this.sessions(projectId).length > 1 })
126
+ if (room && session.policy.rooms !== "off") this.sup.rooms.join(room, session)
127
+ this.sup.onChange()
128
+ return session
129
+ }
130
+
131
+ snapshot() {
132
+ return this.list().map((p) => {
133
+ const sessions = this.sessions(p.id)
134
+ const tasks = this.tasks(p.id)
135
+ const room = p.roomId ? this.sup.rooms.rooms.get(p.roomId) : null
136
+ return {
137
+ ...p,
138
+ sessions: sessions.map((s) => ({ id: s.id, handle: s.handle, status: s.status, name: s.label ?? s.title })),
139
+ busy: sessions.filter((s) => s.status === "busy").length,
140
+ open: tasks.filter((t) => !["done", "cancelled"].includes(t.status)).length,
141
+ total: tasks.length,
142
+ unread: room ? [...room.unread.values()].reduce((a, v) => a + v, 0) : 0,
143
+ messages: room ? room.stats.posts : 0,
144
+ roomId: room?.id ?? null,
145
+ }
146
+ })
147
+ }
148
+ }