@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
package/src/cli.mjs ADDED
@@ -0,0 +1,241 @@
1
+ #!/usr/bin/env node
2
+ import { spawn } from "node:child_process"
3
+ import { readFile, mkdir } from "node:fs/promises"
4
+ import { homedir } from "node:os"
5
+ import { join, resolve } from "node:path"
6
+ import { Supervisor } from "./supervisor.mjs"
7
+ import { startServer } from "./server.mjs"
8
+ import { OpencodeClient } from "./api.mjs"
9
+ import * as git from "./git.mjs"
10
+
11
+ const HOME = join(homedir(), ".botfarm")
12
+ const CONFIG = join(HOME, "config.json")
13
+ const STATE = join(HOME, "metrics.json")
14
+
15
+ const args = process.argv.slice(2)
16
+ const cmd = args[0] ?? "up"
17
+ const flags = parseFlags(args.slice(1))
18
+ const positional = flags._
19
+
20
+ const config = await loadConfig()
21
+ const serverUrl = flags.server ?? config.server ?? "http://127.0.0.1:4096"
22
+
23
+ switch (cmd) {
24
+ case "up":
25
+ case "dash":
26
+ await up()
27
+ break
28
+ case "ls":
29
+ case "list":
30
+ await ls()
31
+ break
32
+ case "new":
33
+ case "start":
34
+ await start()
35
+ break
36
+ case "send":
37
+ await send()
38
+ break
39
+ case "stop":
40
+ case "abort":
41
+ await stop()
42
+ break
43
+ case "rm":
44
+ case "delete":
45
+ await remove()
46
+ break
47
+ case "attach":
48
+ await attach()
49
+ break
50
+ default:
51
+ usage()
52
+ }
53
+
54
+ // ---------------------------------------------------------------------------
55
+
56
+ async function up() {
57
+ await ensureServer(serverUrl, flags.serve !== false)
58
+ const port = Number(flags.port ?? config.port ?? 4777)
59
+ const repos = (flags.repo ? [].concat(flags.repo) : config.repos ?? []).map((p) => git.expandPath(p))
60
+
61
+ const supervisor = new Supervisor({
62
+ url: serverUrl,
63
+ statePath: STATE,
64
+ mcpBase: `http://127.0.0.1:${port}`,
65
+ pricing: config.pricing ?? {},
66
+ // The folder you open first. Change it with "defaultWorkspace" in
67
+ // ~/.botfarm/config.json, or add more folders from the app.
68
+ defaultWorkspace: git.expandPath(flags.workspace ?? config.defaultWorkspace ?? process.cwd()),
69
+ })
70
+ const dialect = await supervisor.start()
71
+ const { url } = startServer({ supervisor, port, repos })
72
+
73
+ console.log(`botfarm dashboard ${url}`)
74
+ console.log(` opencode ${serverUrl} (api ${dialect})`)
75
+ console.log(` watching ${supervisor.store.sessions.size} session(s), ${repos.length} repo(s)`)
76
+ if (flags.open !== false) openBrowser(url)
77
+
78
+ const bye = async () => { await supervisor.stop(); process.exit(0) }
79
+ process.on("SIGINT", bye)
80
+ process.on("SIGTERM", bye)
81
+ }
82
+
83
+ async function ls() {
84
+ const client = await connect()
85
+ const sessions = await client.listSessions({ limit: 100 })
86
+ const active = (await client.activeSessionIds()) ?? new Set()
87
+ const rows = sessions.map((s) => ({
88
+ id: s.id,
89
+ status: active.has(s.id) ? "busy" : "idle",
90
+ title: (s.title ?? "").slice(0, 34),
91
+ dir: (s.directory ?? s.location?.directory ?? "").replace(homedir(), "~"),
92
+ }))
93
+ if (rows.length === 0) return console.log("no sessions")
94
+ const w = (k, min) => Math.max(min, ...rows.map((r) => String(r[k]).length))
95
+ const cols = { id: w("id", 2), status: 6, title: w("title", 5) }
96
+ console.log(pad("id", cols.id), pad("status", cols.status), pad("title", cols.title), "directory")
97
+ for (const r of rows) console.log(pad(r.id, cols.id), pad(r.status, cols.status), pad(r.title, cols.title), r.dir)
98
+ }
99
+
100
+ async function start() {
101
+ const repo = git.expandPath(positional[0] ?? process.cwd())
102
+ const branch = flags.branch ?? flags.b ?? null
103
+ const supervisor = new Supervisor({ url: serverUrl, statePath: STATE })
104
+ await supervisor.client.connect()
105
+ const root = (await git.repoRoot(repo)) ?? repo
106
+ let directory = root
107
+ if (branch) {
108
+ const wt = await git.addWorktree(root, branch, { path: flags.path })
109
+ directory = wt.path
110
+ console.log(`${wt.reused ? "reusing" : "created"} worktree ${wt.path} (${wt.branch})`)
111
+ }
112
+ const info = await supervisor.client.createSession({
113
+ title: flags.name ?? branch ?? "botfarm session",
114
+ directory,
115
+ agent: flags.agent,
116
+ model: flags.model,
117
+ })
118
+ const task = flags.task ?? positional[1]
119
+ if (task) await supervisor.client.prompt(info.id, task)
120
+ console.log(`${info.id} ${directory}${task ? ` <- "${task.slice(0, 50)}"` : ""}`)
121
+ }
122
+
123
+ async function send() {
124
+ const client = await connect()
125
+ const id = positional[0]
126
+ const text = positional.slice(1).join(" ") || flags.text
127
+ if (!id || !text) return usage()
128
+ await client.prompt(id, text)
129
+ console.log(`queued -> ${id}`)
130
+ }
131
+
132
+ async function stop() {
133
+ const client = await connect()
134
+ const ids = positional.length ? positional : [...(await client.activeSessionIds() ?? [])]
135
+ for (const id of ids) {
136
+ await client.interrupt(id)
137
+ console.log(`aborted ${id}`)
138
+ }
139
+ }
140
+
141
+ async function remove() {
142
+ const client = await connect()
143
+ const id = positional[0]
144
+ if (!id) return usage()
145
+ const info = await client.getSession(id).catch(() => null)
146
+ await client.deleteSession(id)
147
+ if (flags.worktree && info?.directory) {
148
+ const root = await git.commonDir(info.directory)
149
+ if (root && root !== info.directory) await git.removeWorktree(root, info.directory, { force: !!flags.force })
150
+ console.log(`removed worktree ${info.directory}`)
151
+ }
152
+ console.log(`removed ${id}`)
153
+ }
154
+
155
+ async function attach() {
156
+ const client = await connect()
157
+ const id = positional[0]
158
+ if (!id) return usage()
159
+ const info = await client.getSession(id)
160
+ const dir = info.directory ?? info.location?.directory ?? process.cwd()
161
+ console.log(`attaching to ${id} in ${dir}`)
162
+ spawn("opencode", ["--session", id], { cwd: dir, stdio: "inherit" }).on("exit", (c) => process.exit(c ?? 0))
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+
167
+ async function connect() {
168
+ const client = new OpencodeClient(serverUrl)
169
+ await client.connect()
170
+ return client
171
+ }
172
+
173
+ async function ensureServer(url, allowSpawn) {
174
+ const probe = new OpencodeClient(url)
175
+ try {
176
+ await probe.connect()
177
+ return
178
+ } catch {}
179
+ if (!allowSpawn) throw new Error(`no opencode server at ${url}`)
180
+ const { port, hostname } = new URL(url)
181
+ console.log(`starting opencode serve on ${hostname}:${port}`)
182
+ const child = spawn("opencode", ["serve", "--port", port, "--hostname", hostname], {
183
+ stdio: "ignore",
184
+ detached: true,
185
+ })
186
+ child.unref()
187
+ for (let i = 0; i < 40; i++) {
188
+ await new Promise((r) => setTimeout(r, 500))
189
+ try {
190
+ await new OpencodeClient(url).connect()
191
+ return
192
+ } catch {}
193
+ }
194
+ throw new Error("opencode serve did not come up in 20s")
195
+ }
196
+
197
+ async function loadConfig() {
198
+ await mkdir(HOME, { recursive: true })
199
+ try {
200
+ return JSON.parse(await readFile(CONFIG, "utf8"))
201
+ } catch {
202
+ return {}
203
+ }
204
+ }
205
+
206
+ function parseFlags(argv) {
207
+ const out = { _: [] }
208
+ for (let i = 0; i < argv.length; i++) {
209
+ const a = argv[i]
210
+ if (a.startsWith("--no-")) out[a.slice(5)] = false
211
+ else if (a.startsWith("--")) {
212
+ const [k, v] = a.slice(2).split("=")
213
+ const value = v ?? (argv[i + 1]?.startsWith("--") ? true : argv[++i] ?? true)
214
+ if (out[k] === undefined) out[k] = value
215
+ else out[k] = [].concat(out[k], value)
216
+ } else if (a.startsWith("-") && a.length === 2) out[a[1]] = argv[++i]
217
+ else out._.push(a)
218
+ }
219
+ return out
220
+ }
221
+
222
+ const pad = (s, n) => String(s).padEnd(n)
223
+
224
+ function openBrowser(url) {
225
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open"
226
+ spawn(cmd, [url], { stdio: "ignore", detached: true }).on("error", () => {}).unref()
227
+ }
228
+
229
+ function usage() {
230
+ console.log(`botfarm โ€” process manager for opencode sessions
231
+
232
+ botfarm up [--port 4777] [--server URL] [--repo PATH]... dashboard (default)
233
+ botfarm ls list sessions
234
+ botfarm new <repo> [task] --branch NAME [--agent A] new session in a worktree
235
+ botfarm send <id> <text...> queue a prompt
236
+ botfarm stop [id...] interrupt (all busy if omitted)
237
+ botfarm rm <id> [--worktree] [--force] delete session, optionally its worktree
238
+ botfarm attach <id> open the session in the opencode TUI
239
+
240
+ config: ~/.botfarm/config.json { "server": "http://127.0.0.1:4096", "port": 4777, "repos": ["~/code/app"] }`)
241
+ }
package/src/craft.mjs ADDED
@@ -0,0 +1,172 @@
1
+ // Small, model-free helpers that keep bots cheap and on track:
2
+ //
3
+ // difficulty() how hard a card looks, from its text โ€” for model routing
4
+ // trimHandoff() a handoff squeezed to what the next bot needs (rules, or a
5
+ // local model when one is configured)
6
+ // stuckReason() a bot going round in circles, from its recent tool calls
7
+ //
8
+ // None of these call a paid model. They run on every dispatch / tick, so they
9
+ // have to be fast and they must never throw.
10
+
11
+ // --- difficulty -------------------------------------------------------------
12
+
13
+ const HARD = /\b(architect|architecture|redesign|refactor|migrat|concurren|race condition|deadlock|security|auth[nz]?|encrypt|performance|optimi[sz]|distributed|transaction|schema change|breaking change|rewrite|multi-?tenant|cache invalidation|design)\w*/gi
14
+ const EASY = /\b(typo|rename|docs?|documentation|readme|comment|log(ging)? line|bump|version|lint|format|copy change|label|text change|config value|flag|toggle|small|trivial|one-?line)\b/gi
15
+
16
+ /**
17
+ * easy | normal | hard for a card. Explicit wins: a split item or a stage can
18
+ * say `difficulty:`. Otherwise a score from the words in it, its length and
19
+ * how many criteria it has to satisfy.
20
+ */
21
+ export function difficulty({ explicit = null, title = "", detail = "", story = "", criteria = [], files = [] } = {}) {
22
+ if (["easy", "normal", "hard"].includes(explicit)) return { level: explicit, why: "set explicitly" }
23
+ const text = `${title}\n${detail || story}`.slice(0, 20000)
24
+ const hard = new Set((text.match(HARD) ?? []).map((w) => w.toLowerCase().slice(0, 10)))
25
+ const easy = new Set((text.match(EASY) ?? []).map((w) => w.toLowerCase()))
26
+ let score = 0
27
+ const why = []
28
+ if (hard.size) { score += Math.min(2, hard.size); why.push(`mentions ${[...hard].slice(0, 3).join(", ")}`) }
29
+ if (easy.size && !hard.size) { score -= 1; why.push(`looks small (${[...easy].slice(0, 3).join(", ")})`) }
30
+ if (text.length > 2500) { score += 1; why.push("long description") }
31
+ else if (text.length < 120 && !hard.size) { score -= 1; why.push("short description") }
32
+ if (criteria.length > 8) { score += 1; why.push(`${criteria.length} criteria`) }
33
+ if (files.length > 12) { score += 1; why.push(`${files.length} files`) }
34
+ const level = score >= 2 ? "hard" : score <= -1 ? "easy" : "normal"
35
+ return { level, why: why.join("; ") || "nothing stands out" }
36
+ }
37
+
38
+ /**
39
+ * The model and reasoning level for a card of this difficulty, from the
40
+ * agent's `tiers:` or the workspace routing file. null when routing is off
41
+ * or has nothing for this level.
42
+ */
43
+ export function routeFor(level, { agentTiers = null, routing = null } = {}) {
44
+ // Per level: the agent's own rule when it has one for this size, otherwise the
45
+ // workspace's (when workspace routing is on). An agent can override just "hard".
46
+ const set = (t) => t && typeof t === "object" && (t.model || t.variant)
47
+ const own = agentTiers?.[level]
48
+ const t = set(own) ? own : routing?.enabled ? routing.tiers?.[level] : null
49
+ if (!set(t)) return null
50
+ const out = { source: set(own) ? "agent" : "workspace" }
51
+ if (t.model) out.model = String(t.model)
52
+ if (t.variant) out.variant = String(t.variant)
53
+ // A quota on the tier's model, and where to go when it is used up.
54
+ if (t.model && t.quota && typeof t.quota === "object") {
55
+ out.quota = { ...t.quota, per: ["day", "week", "month", "workstream"].includes(t.quota.per) ? t.quota.per : "day" }
56
+ out.fallback = t.fallback && typeof t.fallback === "object" ? { ...(t.fallback.model ? { model: String(t.fallback.model) } : {}), ...(t.fallback.variant ? { variant: String(t.fallback.variant) } : {}) } : {}
57
+ }
58
+ return Object.keys(out).length ? out : null
59
+ }
60
+
61
+ // --- trimming handoffs -------------------------------------------------------
62
+
63
+ /**
64
+ * Squeeze text by rules alone: long code blocks become a one-line pointer,
65
+ * blank runs and repeated lines go, and it is cut at a sentence near `max`.
66
+ * Roughly 4 characters per token, so the default is ~450 tokens.
67
+ */
68
+ export function trimText(text, max = 1800) {
69
+ let s = String(text ?? "").replace(/\r/g, "")
70
+ s = s.replace(/```[^\n]*\n([\s\S]*?)```/g, (m, body) => {
71
+ const n = body.split("\n").length
72
+ return n > 12 ? `[code block, ${n} lines โ€” in the full handoff (botfarm_handoff)]` : m
73
+ })
74
+ const seen = new Set()
75
+ s = s.split("\n").map((l) => l.replace(/\s+$/, "")).filter((l) => {
76
+ const k = l.trim().toLowerCase()
77
+ if (k.length > 30 && seen.has(k)) return false
78
+ if (k.length > 30) seen.add(k)
79
+ return true
80
+ }).join("\n").replace(/\n{3,}/g, "\n\n").trim()
81
+ if (s.length <= max) return s
82
+ const cut = s.slice(0, max)
83
+ const end = Math.max(cut.lastIndexOf(". "), cut.lastIndexOf(".\n"), cut.lastIndexOf("\n- "), cut.lastIndexOf("\n\n"))
84
+ return (end > max * 0.6 ? cut.slice(0, end + 1) : cut).trimEnd() + ` โ€ฆ [${s.length - (end > max * 0.6 ? end + 1 : max)} more characters in the full handoff โ€” botfarm_handoff]`
85
+ }
86
+
87
+ /**
88
+ * A handoff as the next bot will read it: the summary trimmed, long lists
89
+ * capped. The full handoff stays on the card and behind botfarm_handoff.
90
+ */
91
+ export function trimHandoff(h, { maxChars = 1800, partChars = 500 } = {}) {
92
+ if (!h) return h
93
+ const cap = (list, n, label) => (list?.length > n ? [...list.slice(0, n), `โ€ฆ and ${list.length - n} more ${label}`] : list ?? [])
94
+ const summary = h.parts?.length
95
+ ? h.parts.map((p, i) => `[${i + 1}/${h.parts.length}] ${p.title}: ${trimText(p.summary, partChars)}`).join("\n\n")
96
+ : h.brief ?? trimText(h.summary, maxChars)
97
+ return {
98
+ ...h,
99
+ summary,
100
+ acceptance_criteria: (h.acceptance_criteria ?? []).map((a) => trimText(a, 300)),
101
+ artifacts: cap(h.artifacts, 40, "files"),
102
+ open_questions: cap(h.open_questions, 12, "questions"),
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Optional: have a local model (Ollama-compatible /api/generate) write the
108
+ * brief. Falls back to the rules on any error or after `timeoutMs`.
109
+ */
110
+ export async function localBrief(text, { url, model, maxChars = 1800, timeoutMs = 20_000 } = {}) {
111
+ if (!url || !model || String(text ?? "").length <= maxChars) return trimText(text, maxChars)
112
+ const ctl = new AbortController()
113
+ const timer = setTimeout(() => ctl.abort(), timeoutMs)
114
+ try {
115
+ const res = await fetch(new URL("/api/generate", url), {
116
+ method: "POST",
117
+ headers: { "content-type": "application/json" },
118
+ signal: ctl.signal,
119
+ body: JSON.stringify({
120
+ model,
121
+ stream: false,
122
+ options: { temperature: 0.1, num_predict: Math.ceil(maxChars / 3) },
123
+ prompt: `Rewrite this developer handoff for the next engineer in at most ${Math.round(maxChars / 5)} words. Keep every file path, identifier, decision, open problem and number exactly. Drop pleasantries and narration. Plain text, no preamble.\n\n---\n${String(text).slice(0, 24000)}`,
124
+ }),
125
+ })
126
+ if (!res.ok) throw new Error(`HTTP ${res.status}`)
127
+ const out = String((await res.json()).response ?? "").trim()
128
+ return out ? trimText(out, maxChars) : trimText(text, maxChars)
129
+ } catch {
130
+ return trimText(text, maxChars)
131
+ } finally {
132
+ clearTimeout(timer)
133
+ }
134
+ }
135
+
136
+ // --- loops -------------------------------------------------------------------
137
+
138
+ // Only used to tell a legitimate repeated call (editing the same file twice) from a loop.
139
+ // Whether files actually changed is asked of git, not guessed from tool names.
140
+ const EDITS = /edit|write|patch|replace/i
141
+
142
+ /**
143
+ * Why a bot looks stuck on its card, or null. Only its calls since the card
144
+ * started count. Three signals, all cheap:
145
+ * - the same call (tool + input) three times in the last six
146
+ * - its last five calls all failed
147
+ * - a lot of tokens with no file changed, for a bot that is meant to edit
148
+ */
149
+ export function stuckReason(calls, { tokens = 0, canEdit = true, tokenCeiling = 250_000, edited = undefined } = {}) {
150
+ const recent = calls.slice(-6)
151
+ const counts = new Map()
152
+ for (const c of recent) {
153
+ // The same call means the same tool with exactly the same arguments: a
154
+ // grep for three different things is work, not a loop. A file changing in
155
+ // between (build, edit, build) starts the count again.
156
+ if (EDITS.test(c.name)) { counts.clear(); continue }
157
+ const args = c.sig ?? (c.summary ? `s:${c.summary}` : null)
158
+ if (args === null) continue // arguments unknown: cannot tell, so do not guess
159
+ const k = `${c.name}\u0000${args}`
160
+ counts.set(k, (counts.get(k) ?? 0) + 1)
161
+ if (counts.get(k) >= 3 && !EDITS.test(c.name)) return { kind: "loop", why: `repeated ${c.name}${c.summary ? ` "${String(c.summary).slice(0, 60)}"` : ""} ${counts.get(k)} times` }
162
+ }
163
+ const last5 = calls.slice(-5)
164
+ if (last5.length === 5 && last5.every((c) => c.status === "error")) return { kind: "loop", why: `its last 5 tool calls failed (${[...new Set(last5.map((c) => c.name))].join(", ")})` }
165
+ // "edited" comes from git (did any file change since the card started, by any
166
+ // tool or MCP server); without it, fall back to the tool names.
167
+ const changedFiles = edited ?? calls.some((c) => EDITS.test(c.name))
168
+ if (canEdit && tokens > tokenCeiling && calls.length >= 15 && !changedFiles) {
169
+ return { kind: "loop", why: `${Math.round(tokens / 1000)}k tokens and ${calls.length} tool calls without changing a file` }
170
+ }
171
+ return null
172
+ }
package/src/db.mjs ADDED
@@ -0,0 +1,93 @@
1
+ // A very small document store: tables of JSON rows keyed by id.
2
+ //
3
+ // Runtime state (pipeline runs, tasks, handoffs) lives here. Definitions
4
+ // (personas, pipelines) stay in YAML files, because config that people edit
5
+ // and share in git should not live inside a binary. Two sources of truth is a
6
+ // bug factory, so the split is by *kind*: YAML in, YAML out, database only in
7
+ // the middle.
8
+ //
9
+ // node:sqlite is used when the runtime has it (Node 22.5+) and a JSON file
10
+ // otherwise โ€” the interface is identical, so nothing above here cares.
11
+
12
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"
13
+ import { dirname } from "node:path"
14
+
15
+ export async function openDb(path) {
16
+ try {
17
+ const { DatabaseSync } = await import("node:sqlite")
18
+ mkdirSync(dirname(path), { recursive: true })
19
+ return new SqliteStore(new DatabaseSync(path))
20
+ } catch {
21
+ return new JsonStore(path.replace(/\.db$/, ".json"))
22
+ }
23
+ }
24
+
25
+ class SqliteStore {
26
+ constructor(db) {
27
+ this.db = db
28
+ this.kind = "sqlite"
29
+ this.tables = new Set()
30
+ }
31
+ table(name) {
32
+ if (!this.tables.has(name)) {
33
+ this.db.exec(`CREATE TABLE IF NOT EXISTS ${safe(name)} (id TEXT PRIMARY KEY, data TEXT NOT NULL)`)
34
+ this.tables.add(name)
35
+ }
36
+ return safe(name)
37
+ }
38
+ put(name, row) {
39
+ const t = this.table(name)
40
+ this.db.prepare(`INSERT INTO ${t} (id, data) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data`)
41
+ .run(row.id, JSON.stringify(row))
42
+ return row
43
+ }
44
+ get(name, id) {
45
+ const r = this.db.prepare(`SELECT data FROM ${this.table(name)} WHERE id = ?`).get(id)
46
+ return r ? JSON.parse(r.data) : null
47
+ }
48
+ all(name) {
49
+ return this.db.prepare(`SELECT data FROM ${this.table(name)}`).all().map((r) => JSON.parse(r.data))
50
+ }
51
+ delete(name, id) {
52
+ this.db.prepare(`DELETE FROM ${this.table(name)} WHERE id = ?`).run(id)
53
+ }
54
+ close() {
55
+ try { this.db.close() } catch {}
56
+ }
57
+ }
58
+
59
+ class JsonStore {
60
+ constructor(path) {
61
+ this.path = path
62
+ this.kind = "json"
63
+ this.data = {}
64
+ if (existsSync(path)) {
65
+ try { this.data = JSON.parse(readFileSync(path, "utf8")) } catch {}
66
+ }
67
+ }
68
+ flush() {
69
+ mkdirSync(dirname(this.path), { recursive: true })
70
+ writeFileSync(this.path, JSON.stringify(this.data))
71
+ }
72
+ put(name, row) {
73
+ ;(this.data[name] ??= {})[row.id] = row
74
+ this.flush()
75
+ return row
76
+ }
77
+ get(name, id) {
78
+ return this.data[name]?.[id] ?? null
79
+ }
80
+ all(name) {
81
+ return Object.values(this.data[name] ?? {})
82
+ }
83
+ delete(name, id) {
84
+ delete this.data[name]?.[id]
85
+ this.flush()
86
+ }
87
+ close() {}
88
+ }
89
+
90
+ function safe(name) {
91
+ if (!/^[a-z_][a-z0-9_]*$/i.test(name)) throw new Error(`bad table name: ${name}`)
92
+ return name
93
+ }
@@ -0,0 +1,111 @@
1
+ // Levels, hats and harvests. Light on purpose: XP comes from work that landed
2
+ // cleanly and cheaply โ€” a handoff, no pause for a limit or a loop, well under
3
+ // budget โ€” never from tokens spent or tools called, so levelling up and saving
4
+ // money point the same way.
5
+ //
6
+ // XP belongs to a kind of bot in a workspace ("dev" in one workspace),
7
+ // not to one session: sessions come and go, the Dev Bot keeps its hat.
8
+
9
+ export const LEVELS = [0, 30, 80, 160, 280, 450, 700, 1000, 1400, 1900]
10
+ export const HATS = [null, "๐Ÿงข", "๐ŸŽฉ", "๐Ÿ‘’", "โ›‘๏ธ", "๐ŸŽ“", "๐Ÿ‘‘", "๐Ÿช–", "๐ŸŽ€", "๐ŸŒŸ"]
11
+ export const TITLES = ["Seedling", "Sprout", "Farmhand", "Grower", "Harvester", "Foreman", "Rancher", "Steward", "Legend", "Mythic"]
12
+
13
+ /** What each level unlocks. Functional perks are small and only ever make a bot a little more trusted. */
14
+ export const PERKS = [
15
+ { level: 2, id: "hat", label: "A cap on the farm", kind: "cosmetic" },
16
+ { level: 3, id: "seasoned", label: "Seasoned: card limits +10%", kind: "budget", bonus: 0.1 },
17
+ { level: 4, id: "hat2", label: "A sun hat", kind: "cosmetic" },
18
+ { level: 5, id: "trusted", label: "Trusted: the first limit hit on a card extends itself once (+25%) instead of stopping", kind: "autoextend" },
19
+ { level: 6, id: "crown", label: "The crown, and a gold name on the farm", kind: "cosmetic" },
20
+ { level: 8, id: "veteran", label: "Veteran: card limits +20%", kind: "budget", bonus: 0.2 },
21
+ ]
22
+
23
+ export function levelOf(xp) {
24
+ let level = 1
25
+ for (let i = 0; i < LEVELS.length; i++) if (xp >= LEVELS[i]) level = i + 1
26
+ const next = LEVELS[level] ?? null
27
+ const prev = LEVELS[level - 1] ?? 0
28
+ return { level, xp, next, progress: next ? (xp - prev) / (next - prev) : 1, hat: HATS[level - 1] ?? "๐ŸŒŸ", title: TITLES[level - 1] ?? "Mythic" }
29
+ }
30
+
31
+ export function perksAt(level) {
32
+ return PERKS.filter((p) => p.level <= level)
33
+ }
34
+
35
+ /** How much a card's limits grow for this level (Seasoned, Veteran). */
36
+ export function budgetBonus(level) {
37
+ return perksAt(level).filter((p) => p.kind === "budget").reduce((a, p) => Math.max(a, p.bonus), 0)
38
+ }
39
+
40
+ /**
41
+ * XP for one finished card, with the reasons. `usage` and `limits` are the
42
+ * card's; `paused` says whether it ever stopped for a limit or a loop.
43
+ */
44
+ export function xpForCard({ task, usage = null, paused = false, openQuestions = 0 }) {
45
+ const parts = [["handed off", 10]]
46
+ if (!paused) parts.push(["no pauses", 5])
47
+ const lim = task.limits
48
+ if (lim && usage) {
49
+ const frac = Math.max(0, ...Object.entries(lim).map(([k, v]) => (usage[k] ?? 0) / v))
50
+ if (frac <= 0.5) parts.push(["under half its limit", 5])
51
+ }
52
+ if (!openQuestions) parts.push(["nothing left open", 3])
53
+ if (task.item) parts.push(["one piece of a split", 2])
54
+ return { xp: parts.reduce((a, [, n]) => a + n, 0), reasons: parts }
55
+ }
56
+
57
+ export class Farmhands {
58
+ constructor({ db }) {
59
+ this.db = db
60
+ }
61
+
62
+ key(wsId, persona) { return `${wsId ?? "none"}:${persona}` }
63
+
64
+ get(wsId, persona) {
65
+ const row = this.db.get("xp", this.key(wsId, persona)) ?? { id: this.key(wsId, persona), ws: wsId ?? null, persona, xp: 0, cards: 0, clean: 0, history: [] }
66
+ return { ...row, ...levelOf(row.xp) }
67
+ }
68
+
69
+ all(wsId) {
70
+ return this.db.all("xp").filter((r) => !wsId || r.ws === wsId).map((r) => ({ ...r, ...levelOf(r.xp), perks: perksAt(levelOf(r.xp).level) }))
71
+ }
72
+
73
+ /** Add XP; returns { before, after, gained, levelUp }. */
74
+ award(wsId, persona, gained, { reasons = [], card = null } = {}) {
75
+ const row = this.db.get("xp", this.key(wsId, persona)) ?? { id: this.key(wsId, persona), ws: wsId ?? null, persona, xp: 0, cards: 0, clean: 0, history: [] }
76
+ const before = levelOf(row.xp)
77
+ row.xp += gained
78
+ row.cards += 1
79
+ if (reasons.some(([r]) => r === "no pauses")) row.clean += 1
80
+ row.history = [...(row.history ?? []), { at: Date.now(), xp: gained, card, reasons: reasons.map(([r, n]) => `${r} +${n}`) }].slice(-40)
81
+ this.db.put("xp", row)
82
+ const after = levelOf(row.xp)
83
+ return { before, after, gained, levelUp: after.level > before.level ? after : null }
84
+ }
85
+
86
+ // --- harvests -------------------------------------------------------------
87
+
88
+ saveHarvest(h) {
89
+ this.db.put("harvests", h)
90
+ return h
91
+ }
92
+
93
+ harvests(wsId, sinceMs = 0) {
94
+ return this.db.all("harvests").filter((h) => (!wsId || h.ws === wsId) && h.at >= sinceMs).sort((a, b) => b.at - a.at)
95
+ }
96
+
97
+ /** This week and all time, for the harvest log. */
98
+ totals(wsId) {
99
+ const week = Date.now() - 7 * 86400_000
100
+ const sum = (list) => list.reduce((a, h) => ({
101
+ stories: a.stories + 1,
102
+ cards: a.cards + (h.cards ?? 0),
103
+ usd: a.usd + (h.usd ?? 0),
104
+ saved: a.saved + Math.max(0, h.saved ?? 0),
105
+ botMinutes: a.botMinutes + (h.botMinutes ?? 0),
106
+ xp: a.xp + (h.xp ?? 0),
107
+ }), { stories: 0, cards: 0, usd: 0, saved: 0, botMinutes: 0, xp: 0 })
108
+ const all = this.harvests(wsId)
109
+ return { week: sum(all.filter((h) => h.at >= week)), all: sum(all), recent: all.slice(0, 12) }
110
+ }
111
+ }