heyquilt 0.0.1 → 0.3.90
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/LICENSE +85 -0
- package/README.md +630 -2
- package/RELEASES.md +733 -0
- package/assets/logo.svg +1 -0
- package/bin/quilt.js +816 -0
- package/desktop/main.js +315 -0
- package/package.json +163 -7
- package/src/account.js +563 -0
- package/src/admit-policy.js +32 -0
- package/src/agent-join.js +370 -0
- package/src/agent-task-workflow.js +176 -0
- package/src/agents/actions.js +85 -0
- package/src/agents/claude-code.js +180 -0
- package/src/agents/common.js +21 -0
- package/src/agents/cursor-transcripts.js +170 -0
- package/src/agents/cursor.js +404 -0
- package/src/agents/index.js +69 -0
- package/src/ai-vm-signin.js +98 -0
- package/src/ai-vm.js +380 -0
- package/src/api/access.js +75 -0
- package/src/api/activity.js +181 -0
- package/src/api/agent-auth.js +130 -0
- package/src/api/agent-placement.js +157 -0
- package/src/api/audit.js +83 -0
- package/src/api/auth.js +17 -0
- package/src/api/domains.js +30 -0
- package/src/api/file-paths.js +40 -0
- package/src/api/file-store.js +99 -0
- package/src/api/fly-machines.js +76 -0
- package/src/api/github-app.js +172 -0
- package/src/api/http.js +34 -0
- package/src/api/invite-email.js +53 -0
- package/src/api/issues.js +110 -0
- package/src/api/join-text.js +78 -0
- package/src/api/mailer.js +47 -0
- package/src/api/memory-store.js +1083 -0
- package/src/api/org-access.js +54 -0
- package/src/api/permissions.js +71 -0
- package/src/api/relay-cluster.js +251 -0
- package/src/api/relay-machines.js +60 -0
- package/src/api/routes/access-types.js +54 -0
- package/src/api/routes/agent-invites.js +115 -0
- package/src/api/routes/agents.js +157 -0
- package/src/api/routes/ai-vm.js +369 -0
- package/src/api/routes/github.js +380 -0
- package/src/api/routes/grants.js +70 -0
- package/src/api/routes/invites.js +188 -0
- package/src/api/routes/issues.js +47 -0
- package/src/api/routes/join.js +167 -0
- package/src/api/routes/members.js +110 -0
- package/src/api/routes/orgs.js +177 -0
- package/src/api/routes/relay-cluster.js +90 -0
- package/src/api/routes/relay.js +76 -0
- package/src/api/routes/session-invites.js +119 -0
- package/src/api/routes/sessions.js +92 -0
- package/src/api/routes/teams.js +132 -0
- package/src/api/routes/workspace-agents.js +332 -0
- package/src/api/routes/workspace-files.js +277 -0
- package/src/api/routes/workspace-invites.js +196 -0
- package/src/api/routes/workspaces.js +225 -0
- package/src/api/server.js +623 -0
- package/src/api/slugs.js +20 -0
- package/src/api/supabase-store.js +765 -0
- package/src/api/team-access.js +30 -0
- package/src/api/tokens.js +31 -0
- package/src/api/workspace-access.js +62 -0
- package/src/api/workspace-reach.js +60 -0
- package/src/autowork-tools.js +166 -0
- package/src/autowork.js +387 -0
- package/src/blobstore.js +99 -0
- package/src/boxes.js +37 -0
- package/src/branchdocs.js +235 -0
- package/src/branches.js +194 -0
- package/src/catchup.js +111 -0
- package/src/chat-archive.js +85 -0
- package/src/chat-links.js +923 -0
- package/src/clash.js +140 -0
- package/src/cloud-ai.js +50 -0
- package/src/commit-request.js +59 -0
- package/src/commit.js +213 -0
- package/src/connection.js +569 -0
- package/src/control.js +245 -0
- package/src/conversation.js +125 -0
- package/src/doctor.js +156 -0
- package/src/duties.js +303 -0
- package/src/editors.js +228 -0
- package/src/fsutil.js +87 -0
- package/src/git-hook.js +48 -0
- package/src/git.js +147 -0
- package/src/github-commit.js +320 -0
- package/src/gitignore.js +49 -0
- package/src/gitstate.js +900 -0
- package/src/history.js +307 -0
- package/src/hooks.js +371 -0
- package/src/hosted-agents.js +24 -0
- package/src/hosted-client.js +112 -0
- package/src/identity.js +99 -0
- package/src/inbox.js +237 -0
- package/src/integrations.js +221 -0
- package/src/key-cli.js +115 -0
- package/src/largefiles.js +91 -0
- package/src/legacy.js +25 -0
- package/src/mcp-proxy.js +40 -0
- package/src/mcp-room.js +32 -0
- package/src/mcp.js +1261 -0
- package/src/merge3.js +158 -0
- package/src/merges.js +148 -0
- package/src/pass-source.js +176 -0
- package/src/passes.js +64 -0
- package/src/pathrules.js +127 -0
- package/src/persona.js +101 -0
- package/src/presence.js +360 -0
- package/src/private-file.js +41 -0
- package/src/procs.js +70 -0
- package/src/project-rules.js +178 -0
- package/src/protocol.js +92 -0
- package/src/quiet-warnings.js +9 -0
- package/src/relay-ai-vm.js +82 -0
- package/src/relay-ask.js +141 -0
- package/src/relay-ci.js +111 -0
- package/src/relay-clash.js +597 -0
- package/src/relay-cluster.js +454 -0
- package/src/relay-commit.js +887 -0
- package/src/relay-filekey.js +46 -0
- package/src/relay-git.js +274 -0
- package/src/relay-githttp.js +137 -0
- package/src/relay-github.js +109 -0
- package/src/relay-lease.js +137 -0
- package/src/relay-lfs.js +37 -0
- package/src/relay-mcp.js +1679 -0
- package/src/relay-metrics.js +75 -0
- package/src/relay-outbound.js +1144 -0
- package/src/relay-sync.js +719 -0
- package/src/relay-turn-sync.js +307 -0
- package/src/relay-upstream.js +934 -0
- package/src/relay-webhooks.js +111 -0
- package/src/releases.js +131 -0
- package/src/report.js +129 -0
- package/src/runner.js +306 -0
- package/src/same-edit.js +18 -0
- package/src/server.js +4187 -0
- package/src/session-access.js +170 -0
- package/src/session-name.js +15 -0
- package/src/session.js +7361 -0
- package/src/settings.js +46 -0
- package/src/setup.js +184 -0
- package/src/shell-path.js +46 -0
- package/src/status.js +199 -0
- package/src/summarize.js +109 -0
- package/src/sync-words.js +42 -0
- package/src/task-comments.js +92 -0
- package/src/tasks.js +670 -0
- package/src/textdiff.js +20 -0
- package/src/turn-sync-plan.js +142 -0
- package/src/turn-sync.js +191 -0
- package/src/ui/access-form.js +37 -0
- package/src/ui/agent-env-key.js +76 -0
- package/src/ui/agent-guide.js +127 -0
- package/src/ui/agent-kinds.js +175 -0
- package/src/ui/agent-place.js +265 -0
- package/src/ui/app.css +2309 -0
- package/src/ui/app.js +542 -0
- package/src/ui/board.js +465 -0
- package/src/ui/boxes.js +76 -0
- package/src/ui/branches.js +111 -0
- package/src/ui/catchup.js +77 -0
- package/src/ui/changes.js +159 -0
- package/src/ui/chat.js +266 -0
- package/src/ui/cloud-ai-model.js +107 -0
- package/src/ui/cloud-ai.js +221 -0
- package/src/ui/common.js +431 -0
- package/src/ui/csv.js +21 -0
- package/src/ui/diffview.js +74 -0
- package/src/ui/feed-convs.js +39 -0
- package/src/ui/feed.js +174 -0
- package/src/ui/files.js +579 -0
- package/src/ui/fileview.js +123 -0
- package/src/ui/git-rule.js +60 -0
- package/src/ui/home.js +1323 -0
- package/src/ui/index.html +16 -0
- package/src/ui/invite.js +97 -0
- package/src/ui/invites.js +79 -0
- package/src/ui/loom-model.js +516 -0
- package/src/ui/loom.css +349 -0
- package/src/ui/loom.js +753 -0
- package/src/ui/mark.js +104 -0
- package/src/ui/merges.js +158 -0
- package/src/ui/rail-model.js +78 -0
- package/src/ui/rail.js +173 -0
- package/src/ui/releases.js +182 -0
- package/src/ui/schedule.js +156 -0
- package/src/ui/session.js +3394 -0
- package/src/ui/signin.js +75 -0
- package/src/ui/sounds.js +110 -0
- package/src/ui/tool-logo.js +86 -0
- package/src/ui/tree.js +276 -0
- package/src/ui/workspaces.js +542 -0
- package/src/ui-server.js +1272 -0
- package/src/update-check.js +67 -0
- package/src/upstream.js +155 -0
- package/src/webhooks.js +235 -0
- package/src/workspace-cli.js +62 -0
- package/src/workspace-tools.js +340 -0
- package/index.js +0 -2
package/src/autowork.js
ADDED
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// "Work on its own": when work comes to "<person>'s AI" (a task given to it, a merge sent to
|
|
2
|
+
// it) and none of the person's AI sessions is live, Quilt can start the person's own AI tool
|
|
3
|
+
// in its non-interactive mode, on their computer, in the session's folder — only when they
|
|
4
|
+
// turned it on in Settings. The run talks through Quilt chat: it asks with quilt_message and
|
|
5
|
+
// stops; a reply to it carries the same tool chat on. Every run is logged with its tokens.
|
|
6
|
+
//
|
|
7
|
+
// One AutoWork per app: one run at a time, whatever the session; work that arrives meanwhile
|
|
8
|
+
// waits its turn. The tools themselves are presets in autowork-tools.js.
|
|
9
|
+
import fs from 'node:fs'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
import crypto from 'node:crypto'
|
|
12
|
+
import { spawn } from 'node:child_process'
|
|
13
|
+
import { getSettings } from './settings.js'
|
|
14
|
+
import { quiltHome } from './legacy.js'
|
|
15
|
+
import { autoToolFor, findExe } from './autowork-tools.js'
|
|
16
|
+
import { claudeCli, installedEditors, openIn } from './editors.js'
|
|
17
|
+
|
|
18
|
+
export const AUTO_MODES = ['chat', 'open', 'auto']
|
|
19
|
+
const RUN_MS = 30 * 60 * 1000 // a run that takes longer is stopped
|
|
20
|
+
const KEEP_CHATS = 30 // runs whose chat a reply can still carry on
|
|
21
|
+
const KEEP_CHAT_MS = 7 * 24 * 60 * 60 * 1000
|
|
22
|
+
const MAX_OUT = 4 * 1024 * 1024
|
|
23
|
+
|
|
24
|
+
const day = (ts) => new Date(ts).toISOString().slice(0, 10)
|
|
25
|
+
// What tools say when nobody is signed in to them (their own words differ; none of their fixes are repeated).
|
|
26
|
+
const SIGNED_OUT = /not (logged|signed) in|log ?in|sign ?in|authenticat|unauthori[sz]ed|api key|credentials|oauth/i
|
|
27
|
+
|
|
28
|
+
/** This computer's "Work on its own" settings, with defaults. */
|
|
29
|
+
export function autoConfig (s = getSettings()) {
|
|
30
|
+
const mode = AUTO_MODES.includes(s.autoWork) ? s.autoWork : 'chat'
|
|
31
|
+
const preset = autoToolFor(s.tool)
|
|
32
|
+
return {
|
|
33
|
+
mode,
|
|
34
|
+
tool: s.tool || '',
|
|
35
|
+
preset,
|
|
36
|
+
model: String(s.autoModel || '').trim() || (preset ? preset.models[0] : ''),
|
|
37
|
+
changeFiles: !!s.autoFiles,
|
|
38
|
+
cap: Number(s.autoCap) > 0 ? Math.floor(Number(s.autoCap)) : 0,
|
|
39
|
+
// The person's own instructions for every run (their cloud AI's settings).
|
|
40
|
+
prompt: String(s.autoPrompt || '').trim().slice(0, 4000)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Totals from log lines: { [day]: { [tool]: { runs, input, output, costUsd } } }. */
|
|
45
|
+
export function usageTotals (entries) {
|
|
46
|
+
const out = {}
|
|
47
|
+
for (const e of entries) {
|
|
48
|
+
if (!e || !e.ts) continue
|
|
49
|
+
const d = (out[day(e.ts)] ||= {})
|
|
50
|
+
const t = (d[e.tool] ||= { runs: 0, input: 0, output: 0, costUsd: 0 })
|
|
51
|
+
t.runs++
|
|
52
|
+
t.input += e.input || 0
|
|
53
|
+
t.output += e.output || 0
|
|
54
|
+
t.costUsd += e.costUsd || 0
|
|
55
|
+
}
|
|
56
|
+
return out
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Tokens used on `ts`'s day, every tool together. */
|
|
60
|
+
export function tokensOn (entries, ts) {
|
|
61
|
+
const d = usageTotals(entries)[day(ts)] || {}
|
|
62
|
+
return Object.values(d).reduce((n, t) => n + t.input + t.output, 0)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** What a run is asked to do: the work, and how to talk while nobody watches. */
|
|
66
|
+
export function runPrompt ({ person, job, changeFiles, prompt = '' }) {
|
|
67
|
+
const lines = [
|
|
68
|
+
`You are ${person}'s AI in the Quilt session in this folder, working on your own: nobody is watching this chat, and it isn't a conversation.`,
|
|
69
|
+
''
|
|
70
|
+
]
|
|
71
|
+
if (job.kind === 'task') {
|
|
72
|
+
lines.push(`${job.by || 'Someone'} gave ${person}'s AI a task on the board:`, `Task ${job.id}: ${job.title}`, '',
|
|
73
|
+
'Read it in full with quilt_task, move it to "doing" with quilt_move_task when you start (that briefs you), and to "qa" with qaNotes when it is done and checked.')
|
|
74
|
+
} else if (job.kind === 'merge') {
|
|
75
|
+
lines.push(job.prompt.trim())
|
|
76
|
+
} else if (job.kind === 'message') {
|
|
77
|
+
lines.push(`${job.by || 'Someone'} wrote to ${person}'s AI in Quilt chat:`, '', String(job.text || '').trim(), '',
|
|
78
|
+
`Answer ${job.by || 'them'} with quilt_message. If they ask for work, do it as ${person}'s AI would, and say in chat what you did.`)
|
|
79
|
+
}
|
|
80
|
+
if (prompt) lines.push('', `${person}'s own instructions for you:`, prompt)
|
|
81
|
+
lines.push('', 'Before your first change, read the project\'s rules with quilt_project_rules.')
|
|
82
|
+
if (!changeFiles) lines.push(`${person} hasn't let you change files: read, plan, comment on the task (quilt_comment_task) or ask, and leave the changes to a person or another session.`)
|
|
83
|
+
lines.push(
|
|
84
|
+
'If you need an answer from someone, ask them with quilt_message and stop: when they reply, this chat continues with their answer.',
|
|
85
|
+
'When you finish, or stop to wait, end with a sentence or two on where things stand.'
|
|
86
|
+
)
|
|
87
|
+
return lines.join('\n')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** How a reply carries a run on: the message, and for a tool that can't resume, what came before. */
|
|
91
|
+
export function replyPrompt ({ run, reply, resumes }) {
|
|
92
|
+
const said = `${reply.by} replied in Quilt chat: ${reply.text}`
|
|
93
|
+
if (resumes) return `${said}\n\nCarry on with the work.`
|
|
94
|
+
return `${run.prompt}\n\nEarlier, in this work, you said:\n${run.lastText || '(nothing)'}\n\n${said}\n\nCarry on from there.`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function defaultSpawn (exe, args, { cwd, onStart }) {
|
|
98
|
+
return new Promise((resolve) => {
|
|
99
|
+
let out = ''; let err = ''
|
|
100
|
+
let child
|
|
101
|
+
try {
|
|
102
|
+
child = spawn(exe, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: process.env, windowsHide: true })
|
|
103
|
+
} catch (e) { resolve({ code: -1, stdout: '', stderr: e.message }); return }
|
|
104
|
+
const timer = setTimeout(() => { try { child.kill() } catch {} }, RUN_MS)
|
|
105
|
+
child.stdout.on('data', (d) => { if (out.length < MAX_OUT) out += d })
|
|
106
|
+
child.stderr.on('data', (d) => { if (err.length < 64 * 1024) err += d })
|
|
107
|
+
child.on('error', (e) => { clearTimeout(timer); resolve({ code: -1, stdout: out, stderr: e.message }) })
|
|
108
|
+
child.on('close', (code) => { clearTimeout(timer); resolve({ code, stdout: out, stderr: err }) })
|
|
109
|
+
if (child.pid && onStart) onStart(child.pid)
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export class AutoWork {
|
|
114
|
+
/**
|
|
115
|
+
* `settings()`, `spawnRun(exe, args, { cwd, onStart(pid) })`, `exeFor(preset)`, `dir` and
|
|
116
|
+
* `now()` are injectable for tests.
|
|
117
|
+
*/
|
|
118
|
+
constructor ({ settings = getSettings, spawnRun = defaultSpawn, exeFor = null, open = null, dir = path.join(quiltHome(), 'autowork'), now = Date.now, cloudAi = () => false } = {}) {
|
|
119
|
+
this.settings = settings
|
|
120
|
+
// Whether the person has a cloud AI (the app asks the API now and then): its work is not this computer's.
|
|
121
|
+
this.cloudAi = cloudAi
|
|
122
|
+
// "Open it in my tool": the person's AI app, opened in the folder with the work as its prompt (editors.js).
|
|
123
|
+
this.open = open || (async (tool, dir, prompt) => {
|
|
124
|
+
const ed = installedEditors().find((e) => e.tool === tool)
|
|
125
|
+
if (!ed) throw new Error(`${tool || 'Your AI tool'} isn't installed on this computer`)
|
|
126
|
+
return openIn(ed.id, dir, { prompt })
|
|
127
|
+
})
|
|
128
|
+
this.spawnRun = spawnRun
|
|
129
|
+
this.exeFor = exeFor || ((p) => (p.id === 'claude' ? claudeCli() : findExe(p.exe)))
|
|
130
|
+
this.dir = dir
|
|
131
|
+
this.now = now
|
|
132
|
+
this.queue = []
|
|
133
|
+
this.running = null
|
|
134
|
+
this.idle = Promise.resolve()
|
|
135
|
+
this.sessions = new Set()
|
|
136
|
+
this.chats = this.loadChats()
|
|
137
|
+
this.toldCap = ''
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
config () { return autoConfig(this.settings()) }
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Watches a person's session: work for their AI, and replies to runs. Agents' sessions work for
|
|
144
|
+
* themselves, except a person's cloud AI (cloud-ai.js), whose session is that person's AI.
|
|
145
|
+
*/
|
|
146
|
+
attach (session) {
|
|
147
|
+
if (this.sessions.has(session)) return
|
|
148
|
+
this.sessions.add(session)
|
|
149
|
+
const onInbox = (events) => this.onInbox(session, events)
|
|
150
|
+
const onPersona = (p, events) => this.onReply(session, p, events)
|
|
151
|
+
session.on('inbox', onInbox)
|
|
152
|
+
session.on('persona-inbox', onPersona)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether work for the person's AI should run here now. */
|
|
156
|
+
on () {
|
|
157
|
+
const c = this.config()
|
|
158
|
+
return c.mode === 'auto' && !!c.preset
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
onInbox (session, events) {
|
|
162
|
+
if (!worksFor(session)) return
|
|
163
|
+
const c = this.config()
|
|
164
|
+
if (c.mode === 'open') { this.openTasks(session, events, c); return }
|
|
165
|
+
if (!this.on()) return
|
|
166
|
+
for (const e of events) {
|
|
167
|
+
// A reply to something a run said, written to "<me>'s AI": that run carries on.
|
|
168
|
+
if ((e.kind === 'dm' || e.kind === 'mention') && e.re) {
|
|
169
|
+
const run = this.chats.find((r) => r.room === session.room && r.said && r.said.includes(e.re))
|
|
170
|
+
if (run) { this.enqueue({ kind: 'reply', session, run, reply: { by: e.by, text: e.text, id: e.id } }); continue }
|
|
171
|
+
}
|
|
172
|
+
// A live AI session of theirs takes its own work (the inbox wakes it); this is for when none is.
|
|
173
|
+
// Their cloud AI, when it is in the session, takes it instead of this computer.
|
|
174
|
+
if (e.kind === 'task' && session.forMyAi(e) && !session.leadPersona() && !this.leaveToCloud(session)) {
|
|
175
|
+
if (this.queue.some((j) => j.kind === 'task' && j.id === e.task.id) || (this.running && this.running.job.id === e.task.id)) continue
|
|
176
|
+
this.enqueue({ kind: 'task', session, id: e.task.id, title: e.task.title, by: e.by })
|
|
177
|
+
continue
|
|
178
|
+
}
|
|
179
|
+
// A person's cloud AI answers what is written to it (its inbox only has it while no AI session of theirs is live).
|
|
180
|
+
if (session.aiOf && (e.kind === 'dm' || e.kind === 'mention') && !e.queue && !e.commit && !e.nudge && session.forMyAi(e)) {
|
|
181
|
+
if (this.queue.some((j) => j.kind === 'message' && j.id === e.id)) continue
|
|
182
|
+
this.enqueue({ kind: 'message', session, id: e.id, title: `message from ${e.by}`, by: e.by, text: e.text })
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Whether work for the person's AI is their cloud AI's, not this computer's: it is in the session, or they have one. */
|
|
188
|
+
leaveToCloud (session) {
|
|
189
|
+
if (session.aiOf) return false
|
|
190
|
+
return !!((session.cloudAiLive && session.cloudAiLive()) || this.cloudAi())
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** "Open it in my tool": a task for my AI, with no AI session of mine live, opens my tool on it. */
|
|
194
|
+
openTasks (session, events, c) {
|
|
195
|
+
if (session.aiOf) return // a cloud machine has no tool window to open
|
|
196
|
+
for (const e of events) {
|
|
197
|
+
if (e.kind !== 'task' || !session.forMyAi(e) || session.leadPersona() || this.leaveToCloud(session)) continue
|
|
198
|
+
const prompt = runPrompt({ person: session.name, job: { kind: 'task', id: e.task.id, title: e.task.title, by: e.by }, changeFiles: true })
|
|
199
|
+
.replace(', working on your own: nobody is watching this chat, and it isn\'t a conversation.', '.')
|
|
200
|
+
Promise.resolve(this.open(c.tool, session.root, prompt)).catch((err) => this.notice(session, `Couldn't open ${c.tool || 'your AI tool'} for ${e.task.title}: ${err.message}.`))
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
onReply (session, p, events) {
|
|
205
|
+
if (!worksFor(session) || !this.on()) return
|
|
206
|
+
const run = this.chats.find((r) => r.room === session.room && r.via === p.rid)
|
|
207
|
+
if (!run) return
|
|
208
|
+
for (const e of events) {
|
|
209
|
+
if (e.kind !== 'dm' && e.kind !== 'mention') continue
|
|
210
|
+
this.enqueue({ kind: 'reply', session, run, reply: { by: e.by, text: e.text, id: e.id } })
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** A merge sent to the person's AI. Resolves once it is queued; the run follows. */
|
|
215
|
+
sendMerge (session, { id, path: file, prompt }) {
|
|
216
|
+
const c = this.config()
|
|
217
|
+
if (!c.preset) throw new Error(`${c.tool || 'Your AI tool'} can't work on its own: open the merge in it instead`)
|
|
218
|
+
this.enqueue({ kind: 'merge', session, id, path: file, title: `merge ${file}`, prompt, by: session.name })
|
|
219
|
+
return { queued: true, tool: c.tool }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
enqueue (job) {
|
|
223
|
+
this.queue.push(job)
|
|
224
|
+
this.idle = this.idle.then(() => this.next()).catch(() => {})
|
|
225
|
+
return this.idle
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
async next () {
|
|
229
|
+
const job = this.queue.shift()
|
|
230
|
+
if (!job) return
|
|
231
|
+
this.running = { job }
|
|
232
|
+
try { await this.run(job) } finally { this.running = null }
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** Runs one piece of work to its end (or its question). */
|
|
236
|
+
async run (job) {
|
|
237
|
+
const { session } = job
|
|
238
|
+
const c = this.config()
|
|
239
|
+
if (session.stopped) this.sessions.delete(session)
|
|
240
|
+
if (c.mode !== 'auto' || !c.preset || !this.sessions.has(session)) return
|
|
241
|
+
const p = c.preset
|
|
242
|
+
// Over today's cap: the work waits for the person's chat, and they are told once a day.
|
|
243
|
+
if (c.cap && tokensOn(this.log(), this.now()) >= c.cap) {
|
|
244
|
+
if (this.toldCap !== day(this.now())) {
|
|
245
|
+
this.toldCap = day(this.now())
|
|
246
|
+
this.notice(session, `Your AI has used today's ${c.cap.toLocaleString('en-US')} tokens for working on its own, so new work waits for you in your chat until tomorrow. Change the limit in Settings → Your AI.`)
|
|
247
|
+
}
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
const exe = this.exeFor(p)
|
|
251
|
+
let run = job.kind === 'reply' ? job.run : null
|
|
252
|
+
const resumes = p.resumes !== false && !!(run && run.chatId)
|
|
253
|
+
if (!run) {
|
|
254
|
+
const prompt = runPrompt({ person: personOf(session), job, changeFiles: c.changeFiles, prompt: c.prompt })
|
|
255
|
+
run = { id: crypto.randomBytes(6).toString('hex'), via: `auto${crypto.randomBytes(6).toString('hex')}`, room: session.room, dir: session.root, tool: p.tool, kind: job.kind, work: job.id, path: job.path || '', title: job.title, by: job.by, prompt, chatId: p.newChatId(), said: [], ts: this.now() }
|
|
256
|
+
}
|
|
257
|
+
if (!exe) {
|
|
258
|
+
this.fail(session, run, `${p.exe} isn't installed on this computer`)
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
const o = { model: c.model, changeFiles: c.changeFiles, chatId: run.chatId }
|
|
262
|
+
const args = resumes
|
|
263
|
+
? p.resume({ ...o, prompt: replyPrompt({ run, reply: job.reply, resumes: true }) })
|
|
264
|
+
: p.start({ ...o, prompt: job.kind === 'reply' ? replyPrompt({ run, reply: job.reply, resumes: false }) : run.prompt })
|
|
265
|
+
const label = job.kind === 'merge' ? job.title : (run.title || 'work')
|
|
266
|
+
const started = this.now()
|
|
267
|
+
let persona = null
|
|
268
|
+
const r = await this.spawnRun(exe, args, {
|
|
269
|
+
cwd: run.dir,
|
|
270
|
+
onStart: (pid) => { try { persona = session.autoPersona(run.via, { tool: p.tool, label: `on its own: ${label}`, pid }) } catch {} }
|
|
271
|
+
})
|
|
272
|
+
if (!persona) persona = session.autoPersonaOf(run.via)
|
|
273
|
+
const out = p.parse(r.stdout)
|
|
274
|
+
if (out.chatId) run.chatId = out.chatId
|
|
275
|
+
this.record({ ts: started, ms: this.now() - started, room: run.room, run: run.id, tool: p.tool, model: c.model, kind: job.kind, work: run.work || '', title: run.title || '', input: out.usage ? out.usage.input : 0, output: out.usage ? out.usage.output : 0, costUsd: out.usage && out.usage.costUsd ? out.usage.costUsd : 0, reported: !!out.usage, chatId: run.chatId || null, outcome: out.error || r.code ? 'failed' : 'ok' })
|
|
276
|
+
if (out.error || (r.code && !out.text)) {
|
|
277
|
+
// What the tool printed as its result says most; with nothing printed, its error output does.
|
|
278
|
+
const why = (String(r.stdout || '').trim() && out.error) || firstLine(r.stderr) || out.error || `${p.exe} stopped (exit ${r.code})`
|
|
279
|
+
this.fail(session, run, why)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
run.lastText = out.text
|
|
283
|
+
// What the run said in chat, so a reply to any of it carries this chat on.
|
|
284
|
+
const name = persona ? [persona.name, ...persona.aliases] : []
|
|
285
|
+
const mine = session.chat.toArray().filter((m) => m && m.ts >= started && name.includes(m.by)).map((m) => m.id)
|
|
286
|
+
run.said = [...new Set([...run.said, ...mine])].slice(-50)
|
|
287
|
+
// A run that said nothing in chat: its last words go to whoever gave it the work.
|
|
288
|
+
if (!mine.length && out.text && persona) {
|
|
289
|
+
try {
|
|
290
|
+
const to = run.by && run.by !== session.name ? run.by : personOf(session)
|
|
291
|
+
const m = session.say(out.text.slice(0, 3000), { to, via: persona.via })
|
|
292
|
+
if (m && m.id) run.said.push(m.id)
|
|
293
|
+
} catch {}
|
|
294
|
+
}
|
|
295
|
+
this.keepChat(run)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* One plain line in chat for the person; the work stays where it was (a merge lets go of its
|
|
300
|
+
* file again). A tool that isn't signed in is said so plainly, never with one tool's fix.
|
|
301
|
+
*/
|
|
302
|
+
fail (session, run, why) {
|
|
303
|
+
const where = session.aiOf ? 'your cloud AI (Settings → Your AI)' : 'this computer'
|
|
304
|
+
const text = SIGNED_OUT.test(String(why)) ? `${run.tool || 'your AI tool'} isn't signed in on ${where}` : String(why).replace(/[.\s]+$/, '').slice(0, 300)
|
|
305
|
+
this.notice(session, `Couldn't run your AI for ${run.title || 'that work'}: ${text}. It's still waiting for your AI in your chat.`, run)
|
|
306
|
+
session.dropAutoPersona(run.via)
|
|
307
|
+
if (run.kind === 'merge' && run.path) session.releaseMergeClaim(run.path).catch(() => {})
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
notice (session, text, run = null) {
|
|
311
|
+
try {
|
|
312
|
+
const rid = run ? run.via : 'autowork'
|
|
313
|
+
const p = session.autoPersona(rid, { tool: this.config().tool, label: 'on its own' })
|
|
314
|
+
session.say(text, { to: personOf(session), via: p.via })
|
|
315
|
+
if (!run) session.dropAutoPersona(rid)
|
|
316
|
+
} catch (err) { session.log(`Work on its own: ${text} (${err.message})`) }
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ------------------------------------------------------------ records --
|
|
320
|
+
|
|
321
|
+
file (name) { return path.join(this.dir, name) }
|
|
322
|
+
|
|
323
|
+
record (entry) {
|
|
324
|
+
try {
|
|
325
|
+
fs.mkdirSync(this.dir, { recursive: true })
|
|
326
|
+
fs.appendFileSync(this.file('runs.jsonl'), JSON.stringify(entry) + '\n')
|
|
327
|
+
} catch {}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Every logged run, oldest first. */
|
|
331
|
+
log () {
|
|
332
|
+
let text = ''
|
|
333
|
+
try { text = fs.readFileSync(this.file('runs.jsonl'), 'utf8') } catch { return [] }
|
|
334
|
+
return text.split('\n').map((l) => { try { return JSON.parse(l) } catch { return null } }).filter(Boolean)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** For Settings: the config, today's totals per tool, and the latest runs. */
|
|
338
|
+
usage () {
|
|
339
|
+
const log = this.log()
|
|
340
|
+
const today = usageTotals(log)[day(this.now())] || {}
|
|
341
|
+
return { config: publicConfig(this.config()), today, runs: log.slice(-20).reverse() }
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
loadChats () {
|
|
345
|
+
try {
|
|
346
|
+
const list = JSON.parse(fs.readFileSync(this.file('chats.json'), 'utf8'))
|
|
347
|
+
return Array.isArray(list) ? list.filter((r) => r && this.now() - r.ts < KEEP_CHAT_MS) : []
|
|
348
|
+
} catch { return [] }
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
keepChat (run) {
|
|
352
|
+
this.chats = [...this.chats.filter((r) => r.id !== run.id), run].filter((r) => this.now() - r.ts < KEEP_CHAT_MS).slice(-KEEP_CHATS)
|
|
353
|
+
try {
|
|
354
|
+
fs.mkdirSync(this.dir, { recursive: true })
|
|
355
|
+
fs.writeFileSync(this.file('chats.json'), JSON.stringify(this.chats), { mode: 0o600 })
|
|
356
|
+
} catch {}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** How the person carries a logged run's chat on in their tool: { link, command } or null. */
|
|
360
|
+
takeOver (runId) {
|
|
361
|
+
const e = [...this.log()].reverse().find((x) => x.run === runId && x.chatId)
|
|
362
|
+
const preset = e && autoToolFor(e.tool)
|
|
363
|
+
return preset ? preset.takeOver(e.chatId) : null
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Whose AI a session works as: its person's, for a person's cloud AI; else its own member's. */
|
|
368
|
+
const personOf = (session) => session.aiOf || session.name
|
|
369
|
+
|
|
370
|
+
/** Whether "Work on its own" works for this session: a person's, or a person's cloud AI's. */
|
|
371
|
+
const worksFor = (session) => session.kind !== 'agent' || !!session.aiOf
|
|
372
|
+
|
|
373
|
+
function firstLine (s) {
|
|
374
|
+
return String(s || '').split('\n').map((l) => l.trim()).find(Boolean)?.slice(0, 300) || ''
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** The settings as Settings shows them. */
|
|
378
|
+
export function publicConfig (c) {
|
|
379
|
+
return { mode: c.mode, tool: c.tool, supported: !!c.preset, models: c.preset ? c.preset.models : [], model: c.model, changeFiles: c.changeFiles, limitsFiles: c.preset ? c.preset.limitsFiles : true, cap: c.cap }
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
let shared = null
|
|
383
|
+
/** The app's one AutoWork. */
|
|
384
|
+
export function autowork () {
|
|
385
|
+
shared ||= new AutoWork()
|
|
386
|
+
return shared
|
|
387
|
+
}
|
package/src/blobstore.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Where the relay keeps large files. The files are already encrypted by the
|
|
2
|
+
// apps; the relay only hands out short-lived links and deletes what's unused.
|
|
3
|
+
import fs from 'node:fs'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import crypto from 'node:crypto'
|
|
6
|
+
import { createClient } from '@supabase/supabase-js'
|
|
7
|
+
|
|
8
|
+
const LINK_MS = 10 * 60 * 1000
|
|
9
|
+
const MAX_REMOVE_ROUNDS = 1000
|
|
10
|
+
|
|
11
|
+
/** Files on the relay's own disk, reached through signed links to the relay. */
|
|
12
|
+
export class DiskStore {
|
|
13
|
+
constructor (dir) {
|
|
14
|
+
this.dir = dir
|
|
15
|
+
this.signing = crypto.randomBytes(32)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
file (room, id) { return path.join(this.dir, room, id) }
|
|
19
|
+
|
|
20
|
+
sign (room, id, method, exp, size) {
|
|
21
|
+
return crypto.createHmac('sha256', this.signing).update(`${method} ${room}/${id} ${exp} ${size ?? ''}`).digest('hex')
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
link (room, id, method, size) {
|
|
25
|
+
const exp = Date.now() + LINK_MS
|
|
26
|
+
const n = size === undefined ? '' : `&n=${size}`
|
|
27
|
+
return `/blobs/${room}/${id}/data?m=${method}&exp=${exp}&sig=${this.sign(room, id, method, exp, size)}${n}`
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
verify (room, id, method, exp, sig, size) {
|
|
31
|
+
if (!(Number(exp) > Date.now())) return false
|
|
32
|
+
const want = Buffer.from(this.sign(room, id, method, exp, size), 'hex')
|
|
33
|
+
const got = Buffer.from(String(sig || ''), 'hex')
|
|
34
|
+
return got.length === want.length && crypto.timingSafeEqual(got, want)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The declared size is signed into the PUT link, so a client can't upload
|
|
38
|
+
// more than it told the relay it would; GET links carry no size.
|
|
39
|
+
async uploadTarget (room, id, size) { return { method: 'PUT', url: this.link(room, id, 'PUT', size) } }
|
|
40
|
+
async downloadTarget (room, id) { return { url: this.link(room, id, 'GET') } }
|
|
41
|
+
async remove (room, ids) { for (const id of ids) fs.rmSync(this.file(room, id), { force: true }) }
|
|
42
|
+
async removeRoom (room) { fs.rmSync(path.join(this.dir, room), { recursive: true, force: true }) }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Files in a private Supabase Storage bucket; apps upload and download directly. */
|
|
46
|
+
export class SupabaseStore {
|
|
47
|
+
constructor ({ url, key, bucket }) {
|
|
48
|
+
this.bucket = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false } }).storage.from(bucket)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
static path (room, id) { return `${room}/${id}` }
|
|
52
|
+
|
|
53
|
+
// `size` is accepted but ignored: Supabase signed uploads can't bound size; the bucket's file_size_limit caps each object.
|
|
54
|
+
// Write-once: ids come from the content, so an upload never replaces what's stored.
|
|
55
|
+
async uploadTarget (room, id, size) {
|
|
56
|
+
const { data, error } = await this.bucket.createSignedUploadUrl(SupabaseStore.path(room, id), { upsert: false })
|
|
57
|
+
// Storage may refuse to sign at all when the file is already there.
|
|
58
|
+
if (error && alreadyExists(error)) return { exists: true }
|
|
59
|
+
if (error) throw new Error(`storage: ${error.message}`)
|
|
60
|
+
return { method: 'PUT', url: data.signedUrl }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async downloadTarget (room, id) {
|
|
64
|
+
const { data, error } = await this.bucket.createSignedUrl(SupabaseStore.path(room, id), LINK_MS / 1000)
|
|
65
|
+
if (error) throw new Error(`storage: ${error.message}`)
|
|
66
|
+
return { url: data.signedUrl }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Deletes stored files; resolves to how many were deleted. */
|
|
70
|
+
async remove (room, ids) {
|
|
71
|
+
if (!ids.length) return 0
|
|
72
|
+
const { data, error } = await this.bucket.remove(ids.map((id) => SupabaseStore.path(room, id)))
|
|
73
|
+
if (error) throw new Error(`storage: ${error.message}`)
|
|
74
|
+
return (data || []).length
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Deleted 1000 at a time. A round that deletes nothing (say, something
|
|
78
|
+
// that isn't a file is in the way) stops it, rather than looping forever.
|
|
79
|
+
async removeRoom (room) {
|
|
80
|
+
for (let round = 0; round < MAX_REMOVE_ROUNDS; round++) {
|
|
81
|
+
const { data, error } = await this.bucket.list(room, { limit: 1000 })
|
|
82
|
+
if (error) throw new Error(`storage: ${error.message}`)
|
|
83
|
+
if (!data.length) return
|
|
84
|
+
if (!await this.remove(room, data.map((f) => f.name))) throw new Error(`storage: could not delete the stored files of ${room}; ${data.length} remain`)
|
|
85
|
+
}
|
|
86
|
+
throw new Error(`storage: gave up deleting the stored files of ${room} after ${MAX_REMOVE_ROUNDS} rounds`)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const alreadyExists = (error) => String(error.statusCode) === '409' || /exists|duplicate/i.test(String(error.message))
|
|
91
|
+
|
|
92
|
+
export function makeStore (cfg, dir) {
|
|
93
|
+
// Half set up is a mistake: falling back to the disk would quietly fill it.
|
|
94
|
+
if (!cfg.storageUrl !== !cfg.storageKey) {
|
|
95
|
+
throw new Error('Large-file storage is half set up: set both QUILT_STORAGE_URL and QUILT_STORAGE_KEY to use Supabase Storage, or neither to keep files on the relay\'s disk.')
|
|
96
|
+
}
|
|
97
|
+
if (cfg.storageUrl && cfg.storageKey) return new SupabaseStore({ url: cfg.storageUrl, key: cfg.storageKey, bucket: cfg.storageBucket })
|
|
98
|
+
return new DiskStore(dir)
|
|
99
|
+
}
|
package/src/boxes.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Boxes as the app shows them (docs/superpowers/specs/2026-10-16-git-boxes-design.md): a box is
|
|
2
|
+
// someone's unfinished work on one file. For each, what it changes from the version the viewer's
|
|
3
|
+
// folder has (theirs: from before the box; ours: what git last committed), counted line by line.
|
|
4
|
+
import { lineDiff } from './history.js'
|
|
5
|
+
|
|
6
|
+
const isBin = (v) => !!v && typeof v === 'object' && v.binary === true
|
|
7
|
+
const textOf = (v) => (v == null || isBin(v) ? '' : String(v))
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* One box for the app. `before` and `after` are the file's text, null (no file) or { binary: true }.
|
|
11
|
+
* The diff is capped as history's are; added and removed count every line all the same.
|
|
12
|
+
*/
|
|
13
|
+
export function boxEntry ({ claim, mine = false, before = null, after = null }) {
|
|
14
|
+
const kind = before == null && after != null ? 'new' : after == null && before != null ? 'deleted' : isBin(before) || isBin(after) ? 'binary' : 'edited'
|
|
15
|
+
let added = 0
|
|
16
|
+
let removed = 0
|
|
17
|
+
let diff = ''
|
|
18
|
+
if (kind !== 'binary') {
|
|
19
|
+
diff = lineDiff(textOf(before), textOf(after))
|
|
20
|
+
for (const line of lineDiff(textOf(before), textOf(after), { context: 0, max: Infinity }).split('\n')) {
|
|
21
|
+
if (line.startsWith('+')) added++
|
|
22
|
+
else if (line.startsWith('-')) removed++
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
path: claim.pattern,
|
|
27
|
+
by: claim.by,
|
|
28
|
+
...(claim.of ? { of: claim.of } : {}),
|
|
29
|
+
ts: claim.ts || 0,
|
|
30
|
+
mine: !!mine,
|
|
31
|
+
kind,
|
|
32
|
+
added,
|
|
33
|
+
removed,
|
|
34
|
+
diff,
|
|
35
|
+
queue: Array.isArray(claim.queue) ? claim.queue.map((r) => ({ id: r.id, by: r.by })) : []
|
|
36
|
+
}
|
|
37
|
+
}
|