@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.
- package/.idea/bot-farm.iml +9 -0
- package/.idea/misc.xml +6 -0
- package/.idea/modules.xml +8 -0
- package/.idea/vcs.xml +6 -0
- package/LICENSE +21 -0
- package/README.md +584 -0
- package/package.json +13 -0
- package/src/api.mjs +567 -0
- package/src/cli.mjs +241 -0
- package/src/craft.mjs +172 -0
- package/src/db.mjs +93 -0
- package/src/farmhands.mjs +111 -0
- package/src/git.mjs +578 -0
- package/src/identity.mjs +199 -0
- package/src/legacy-defaults.mjs +44 -0
- package/src/mcp.mjs +681 -0
- package/src/mesh.mjs +222 -0
- package/src/packets.mjs +125 -0
- package/src/patches.mjs +141 -0
- package/src/pipelines.mjs +2145 -0
- package/src/pricing.mjs +91 -0
- package/src/projects.mjs +148 -0
- package/src/public/classic.html +1810 -0
- package/src/public/index.html +3284 -0
- package/src/questions.mjs +69 -0
- package/src/quota.mjs +76 -0
- package/src/registry.mjs +88 -0
- package/src/replay.mjs +151 -0
- package/src/rooms.mjs +320 -0
- package/src/server.mjs +765 -0
- package/src/store.mjs +546 -0
- package/src/supervisor.mjs +1803 -0
- package/src/tasks.mjs +191 -0
- package/src/template.mjs +86 -0
- package/src/tools.mjs +241 -0
- package/src/workspaces.mjs +605 -0
- package/src/ws.mjs +130 -0
- package/src/yaml.mjs +266 -0
- package/test/board.mjs +108 -0
- package/test/craft.mjs +281 -0
- package/test/extend.mjs +145 -0
- package/test/flow.mjs +165 -0
- package/test/handoff.mjs +228 -0
- package/test/linked.mjs +83 -0
- package/test/loop.mjs +168 -0
- package/test/mock-opencode.mjs +217 -0
- package/test/parallel.mjs +135 -0
- package/test/patches.mjs +93 -0
- package/test/repos.mjs +118 -0
- package/test/roles.mjs +157 -0
- package/test/shot.mjs +65 -0
- package/test/smoke.mjs +70 -0
- package/test/transport.mjs +131 -0
- package/test/workspaces.mjs +228 -0
- package/test/workspaces.mjs.tmp +0 -0
package/src/pricing.mjs
ADDED
|
@@ -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
|
+
}
|
package/src/projects.mjs
ADDED
|
@@ -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
|
+
}
|