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
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
// Chat links: Quilt for AIs that only have a chat window (ChatGPT, claude.ai, Grok…) or
|
|
2
|
+
// that work over plain HTTP.
|
|
3
|
+
//
|
|
4
|
+
// Those AIs can't install anything or add a tool to themselves, but they can open web
|
|
5
|
+
// pages. So the session owner makes a chat link, pastes it into the chat, and the AI
|
|
6
|
+
// works by opening links: every answer is a short text page that lists the links it
|
|
7
|
+
// can open next. Actions put their input in the link (…/say?text=…). Every page is also
|
|
8
|
+
// JSON, with stable ids and explicit ok/error, given ?format=json or Accept: application/json.
|
|
9
|
+
//
|
|
10
|
+
// What a chat link may do is deliberately small: read and send messages, read the board,
|
|
11
|
+
// add, assign, move, rename and comment on tasks, read the member list, list files and read
|
|
12
|
+
// text files, and add non-code files (pictures, PDFs, office documents, notes) as new files,
|
|
13
|
+
// never over an existing one. The "actions" page lists all of it with what this link may do.
|
|
14
|
+
// The board and messages come with a cursor: "changes?since=<cursor>" returns only what
|
|
15
|
+
// changed after it. The link is a member of the session (`chat:<id>`): it shows on the member
|
|
16
|
+
// list, the owner's controls (talk, folders, view only, remove) apply to it, and removing it
|
|
17
|
+
// kills the link. It is short-lived: ten minutes unless the owner extends it (up to 30 days)
|
|
18
|
+
// while it still works. Once it runs out, it's gone: a new link is needed.
|
|
19
|
+
//
|
|
20
|
+
// The link is the key, so it is long, random, stored only as a hash, and kept out of
|
|
21
|
+
// search engines and referrers.
|
|
22
|
+
import crypto from 'node:crypto'
|
|
23
|
+
import dns from 'node:dns'
|
|
24
|
+
import https from 'node:https'
|
|
25
|
+
import net from 'node:net'
|
|
26
|
+
import * as Y from 'yjs'
|
|
27
|
+
import { globMatcher, isSafeRelPath } from './pathrules.js'
|
|
28
|
+
import { readTasks, addTask, updateTask, formatTasks, columnName, COLUMNS, COLUMN_IDS, assignmentFields } from './tasks.js'
|
|
29
|
+
import { readComments, addComment, withComments, formatTaskDetails, MAX_COMMENT } from './task-comments.js'
|
|
30
|
+
import { qaNotesEnough, qaRefusal, verifiedEnough, doneRefusal, pickChecklist } from './agent-task-workflow.js'
|
|
31
|
+
import { HistoryLog } from './history.js'
|
|
32
|
+
import { changeRefusal } from './session-access.js'
|
|
33
|
+
import { waitingOn, renderUnanswered, unaddressed } from './duties.js'
|
|
34
|
+
|
|
35
|
+
export const CHAT_LINK_DEFAULT_MINUTES = 10
|
|
36
|
+
export const CHAT_LINK_MAX_MINUTES = 30 * 24 * 60
|
|
37
|
+
const MINUTE = 60 * 1000
|
|
38
|
+
export const CHAT_ADD_MAX_BYTES = 5 * 1024 * 1024
|
|
39
|
+
const MAX_LINKS = 20 // per session
|
|
40
|
+
const MAX_TEXT = 4000 // a message, a task title is shorter (tasks.js)
|
|
41
|
+
const MAX_NOTE = 20000
|
|
42
|
+
const MAX_READ = 100 * 1024
|
|
43
|
+
const RATE = { perMinute: 60 }
|
|
44
|
+
const ORIGIN = 'chat-link' // transaction origin
|
|
45
|
+
const CHAT_CAP = 500
|
|
46
|
+
const ACTIVITY_CAP = 300
|
|
47
|
+
|
|
48
|
+
const hashToken = (t) => crypto.createHash('sha256').update(String(t)).digest('hex')
|
|
49
|
+
const memberId = (id) => `chat:${id}`
|
|
50
|
+
|
|
51
|
+
// ------------------------------------------------------------ the links --
|
|
52
|
+
|
|
53
|
+
/** Makes a chat link in `room` (the owner asked). Returns { id, token, name, expiresAt }. */
|
|
54
|
+
/** Minutes asked for, kept between one and CHAT_LINK_MAX_MINUTES (CHAT_LINK_DEFAULT_MINUTES when not given). */
|
|
55
|
+
const minutesOf = (minutes) => Math.min(Math.max(Math.round(Number(minutes)) || CHAT_LINK_DEFAULT_MINUTES, 1), CHAT_LINK_MAX_MINUTES)
|
|
56
|
+
|
|
57
|
+
export function makeChatLink (room, { name, minutes, by } = {}, now = Date.now()) {
|
|
58
|
+
const links = room.meta.chatLinks = pruneChatLinks(room, now)
|
|
59
|
+
if (Object.keys(links).length >= MAX_LINKS) throw new Error(`a session can have ${MAX_LINKS} chat links at most; remove one first`)
|
|
60
|
+
const id = crypto.randomBytes(6).toString('hex')
|
|
61
|
+
const token = crypto.randomBytes(24).toString('base64url')
|
|
62
|
+
const wanted = String(name || '').replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 40) || 'Chat AI'
|
|
63
|
+
// Not the name of anyone already in the session.
|
|
64
|
+
const taken = new Set([...Object.values(room.meta.members || {}).map((m) => m && m.name), ...Object.keys(room.meta.identities || {})])
|
|
65
|
+
let label = wanted
|
|
66
|
+
for (let n = 2; taken.has(label); n++) label = `${wanted} ${n}`
|
|
67
|
+
const expiresAt = now + minutesOf(minutes) * MINUTE
|
|
68
|
+
links[hashToken(token)] = { id, name: label, by: String(by || ''), createdAt: now, expiresAt }
|
|
69
|
+
room.meta.members[memberId(id)] = { name: label, kind: 'agent', role: 'editor', scopes: [], scopesExcept: [], talk: true, since: now, chat: true, expiresAt }
|
|
70
|
+
room.saveMeta()
|
|
71
|
+
return { id, token, name: label, expiresAt }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The owner sets how long a chat link still works: `minutes` from now (shorter or longer).
|
|
76
|
+
* Only a link that still works: one that ran out is gone, and a new one is needed.
|
|
77
|
+
*/
|
|
78
|
+
export function extendChatLink (room, key, minutes, now = Date.now()) {
|
|
79
|
+
const links = pruneChatLinks(room, now)
|
|
80
|
+
const m = room.meta.members[key]
|
|
81
|
+
const entry = m && m.chat && Object.values(links).find((l) => memberId(l.id) === key)
|
|
82
|
+
if (!entry) throw new Error('that chat link has run out or was removed; make a new one')
|
|
83
|
+
entry.expiresAt = m.expiresAt = now + minutesOf(minutes) * MINUTE
|
|
84
|
+
room.saveMeta()
|
|
85
|
+
return { name: m.name, expiresAt: entry.expiresAt }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Drops links that expired or whose member the owner removed, with their members. Returns the remaining map. */
|
|
89
|
+
export function pruneChatLinks (room, now = Date.now()) {
|
|
90
|
+
const links = room.meta.chatLinks || {}
|
|
91
|
+
let changed = false
|
|
92
|
+
for (const [k, l] of Object.entries(links)) {
|
|
93
|
+
const gone = !l || l.expiresAt <= now || !room.meta.members[memberId(l.id)]
|
|
94
|
+
if (!gone) continue
|
|
95
|
+
delete links[k]
|
|
96
|
+
if (l && room.meta.members[memberId(l.id)]?.chat) delete room.meta.members[memberId(l.id)]
|
|
97
|
+
changed = true
|
|
98
|
+
}
|
|
99
|
+
if (changed) room.saveMeta()
|
|
100
|
+
return links
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The live link for `token` in `room`, with its member, or null. */
|
|
104
|
+
export function findChatLink (room, token, now = Date.now()) {
|
|
105
|
+
if (!room || !room.meta.chatLinks) return null
|
|
106
|
+
const l = pruneChatLinks(room, now)[hashToken(token)]
|
|
107
|
+
if (!l) return null
|
|
108
|
+
return { ...l, member: room.meta.members[memberId(l.id)], memberId: memberId(l.id) }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ------------------------------------------------------------ fetching --
|
|
112
|
+
|
|
113
|
+
// Addresses a fetch must never reach: this machine, private networks, cloud metadata.
|
|
114
|
+
const blocked = new net.BlockList()
|
|
115
|
+
for (const [a, p] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24], ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]]) blocked.addSubnet(a, p, 'ipv4')
|
|
116
|
+
for (const [a, p] of [['::', 128], ['::1', 128], ['fc00::', 7], ['fe80::', 10], ['ff00::', 8], ['64:ff9b::', 96], ['2001:db8::', 32]]) blocked.addSubnet(a, p, 'ipv6')
|
|
117
|
+
|
|
118
|
+
/** Is `ip` on the public internet? */
|
|
119
|
+
export function publicAddress (ip) {
|
|
120
|
+
const family = net.isIP(ip)
|
|
121
|
+
if (!family) return false
|
|
122
|
+
const mapped = family === 6 && /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(ip)
|
|
123
|
+
if (mapped) return !blocked.check(mapped[1], 'ipv4')
|
|
124
|
+
return !blocked.check(ip, family === 4 ? 'ipv4' : 'ipv6')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Downloads a file from a public https address: no private or local addresses (checked on
|
|
129
|
+
* the address actually connected to, so DNS can't be turned against us), at most three
|
|
130
|
+
* redirects, `maxBytes` and `timeoutMs`. Resolves to a Buffer; rejects with a plain reason.
|
|
131
|
+
*/
|
|
132
|
+
export function fetchPublicFile (address, { maxBytes = CHAT_ADD_MAX_BYTES, timeoutMs = 15000, redirects = 3, lookup = dns.lookup } = {}) {
|
|
133
|
+
return new Promise((resolve, reject) => {
|
|
134
|
+
let u
|
|
135
|
+
try { u = new URL(address) } catch { return reject(new Error('that is not a web address')) }
|
|
136
|
+
if (u.protocol !== 'https:') return reject(new Error('only https addresses can be added'))
|
|
137
|
+
if (u.username || u.password) return reject(new Error('addresses with a user name or password are refused'))
|
|
138
|
+
const safeLookup = (host, opts, cb) => {
|
|
139
|
+
lookup(host, { all: true }, (err, addrs) => {
|
|
140
|
+
if (err) return cb(err)
|
|
141
|
+
const ok = (addrs || []).filter((a) => publicAddress(a.address))
|
|
142
|
+
if (!ok.length || ok.length !== addrs.length) return cb(new Error('that address is not on the public internet'))
|
|
143
|
+
if (opts && opts.all) return cb(null, ok)
|
|
144
|
+
cb(null, ok[0].address, ok[0].family)
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
const req = https.get(u, { lookup: safeLookup, timeout: timeoutMs, headers: { 'user-agent': 'Quilt chat link', accept: '*/*' } }, (res) => {
|
|
148
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
|
|
149
|
+
res.resume()
|
|
150
|
+
if (redirects <= 0) return reject(new Error('too many redirects'))
|
|
151
|
+
let next
|
|
152
|
+
try { next = new URL(res.headers.location, u).href } catch { return reject(new Error('a bad redirect')) }
|
|
153
|
+
return fetchPublicFile(next, { maxBytes, timeoutMs, redirects: redirects - 1, lookup }).then(resolve, reject)
|
|
154
|
+
}
|
|
155
|
+
if (res.statusCode !== 200) { res.resume(); return reject(new Error(`the address answered ${res.statusCode}`)) }
|
|
156
|
+
const declared = Number(res.headers['content-length'])
|
|
157
|
+
if (declared > maxBytes) { res.destroy(); return reject(new Error(`the file is larger than ${Math.round(maxBytes / 1024 / 1024)} MB`)) }
|
|
158
|
+
const parts = []
|
|
159
|
+
let size = 0
|
|
160
|
+
res.on('data', (c) => {
|
|
161
|
+
size += c.length
|
|
162
|
+
if (size > maxBytes) { res.destroy(); reject(new Error(`the file is larger than ${Math.round(maxBytes / 1024 / 1024)} MB`)) } else parts.push(c)
|
|
163
|
+
})
|
|
164
|
+
res.on('end', () => { if (size <= maxBytes) resolve(Buffer.concat(parts)) })
|
|
165
|
+
res.on('error', (e) => reject(new Error(e.message)))
|
|
166
|
+
})
|
|
167
|
+
req.on('timeout', () => req.destroy(new Error('the address took too long to answer')))
|
|
168
|
+
req.on('error', (e) => reject(new Error(e.message)))
|
|
169
|
+
})
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ------------------------------------------------------------ what may be added --
|
|
173
|
+
|
|
174
|
+
const isUtf8Text = (buf) => {
|
|
175
|
+
if (buf.includes(0)) return false
|
|
176
|
+
try { new TextDecoder('utf-8', { fatal: true }).decode(buf); return true } catch { return false }
|
|
177
|
+
}
|
|
178
|
+
const starts = (buf, ...bytes) => bytes.every((b, i) => buf[i] === b)
|
|
179
|
+
const ascii = (buf, at, s) => buf.slice(at, at + s.length).toString('latin1') === s
|
|
180
|
+
|
|
181
|
+
// What a chat AI may add, by extension: pictures, documents, notes. Never code. Each is
|
|
182
|
+
// checked against what the file really is, so a web page can't come in renamed .pdf.
|
|
183
|
+
export const ADDABLE = {
|
|
184
|
+
png: { text: false, is: (b) => starts(b, 0x89, 0x50, 0x4e, 0x47) },
|
|
185
|
+
jpg: { text: false, is: (b) => starts(b, 0xff, 0xd8, 0xff) },
|
|
186
|
+
jpeg: { text: false, is: (b) => starts(b, 0xff, 0xd8, 0xff) },
|
|
187
|
+
gif: { text: false, is: (b) => ascii(b, 0, 'GIF8') },
|
|
188
|
+
webp: { text: false, is: (b) => ascii(b, 0, 'RIFF') && ascii(b, 8, 'WEBP') },
|
|
189
|
+
pdf: { text: false, is: (b) => ascii(b, 0, '%PDF') },
|
|
190
|
+
docx: { text: false, is: (b) => starts(b, 0x50, 0x4b, 0x03, 0x04) },
|
|
191
|
+
xlsx: { text: false, is: (b) => starts(b, 0x50, 0x4b, 0x03, 0x04) },
|
|
192
|
+
pptx: { text: false, is: (b) => starts(b, 0x50, 0x4b, 0x03, 0x04) },
|
|
193
|
+
svg: { text: true, is: (b) => isUtf8Text(b) && /<svg[\s>]/i.test(b.slice(0, 4096).toString('utf8')) && !/<script|\son\w+\s*=/i.test(b.toString('utf8')) },
|
|
194
|
+
csv: { text: true, is: isUtf8Text },
|
|
195
|
+
txt: { text: true, is: isUtf8Text },
|
|
196
|
+
md: { text: true, is: isUtf8Text }
|
|
197
|
+
}
|
|
198
|
+
const extOf = (rel) => (/\.([A-Za-z0-9]+)$/.exec(rel) || [])[1]?.toLowerCase() || ''
|
|
199
|
+
|
|
200
|
+
/** Why `rel` can't be added by a chat link, or null. */
|
|
201
|
+
export function addRefusal (rel) {
|
|
202
|
+
if (!rel || !isSafeRelPath(rel)) return 'that is not a path inside the project'
|
|
203
|
+
if (rel.split('/').some((p) => p.startsWith('.'))) return 'files and folders starting with "." are left alone'
|
|
204
|
+
const ext = extOf(rel)
|
|
205
|
+
if (!ADDABLE[ext]) return `only pictures, documents and notes can be added (${Object.keys(ADDABLE).join(', ')}), not .${ext || '(none)'} files`
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ------------------------------------------------------------ changes since --
|
|
210
|
+
|
|
211
|
+
// What changed on the board and in the chat, for "changes?since=<cursor>". Kept in memory on
|
|
212
|
+
// the relay's room from the first time a chat link opens it. A relay restart or an unloaded
|
|
213
|
+
// room starts a new log (another epoch), and a cursor older than what is kept has expired:
|
|
214
|
+
// either way the AI is told to read the board and messages again for a fresh cursor.
|
|
215
|
+
const LOG_CAP = 2000
|
|
216
|
+
|
|
217
|
+
export function changeLog (room) {
|
|
218
|
+
if (room.chatChanges) return room.chatChanges
|
|
219
|
+
const log = { epoch: crypto.randomBytes(4).toString('hex'), seq: 0, floor: 0, entries: [] }
|
|
220
|
+
const note = (kind, id) => {
|
|
221
|
+
log.entries.push({ seq: ++log.seq, kind, id })
|
|
222
|
+
if (log.entries.length > LOG_CAP) log.floor = log.entries.splice(0, log.entries.length - LOG_CAP).pop().seq
|
|
223
|
+
}
|
|
224
|
+
room.doc.getMap('tasks').observe((e) => { for (const k of e.keysChanged) note('task', k) })
|
|
225
|
+
room.doc.getMap('taskComments').observe((e) => { for (const k of e.keysChanged) note('task', k) })
|
|
226
|
+
room.doc.getArray('chat').observe((e) => {
|
|
227
|
+
for (const item of e.changes.added) for (const m of item.content.getContent()) if (m && m.id) note('message', m.id)
|
|
228
|
+
})
|
|
229
|
+
room.chatChanges = log
|
|
230
|
+
return log
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const cursorOf = (log) => `${log.epoch}.${log.seq}`
|
|
234
|
+
|
|
235
|
+
/** The log entries after `cursor`, or null when it isn't this log's or has expired. */
|
|
236
|
+
export function changesSince (log, cursor) {
|
|
237
|
+
const m = /^([0-9a-f]{8})\.(\d{1,12})$/.exec(String(cursor || '').trim())
|
|
238
|
+
if (!m || m[1] !== log.epoch) return null
|
|
239
|
+
const seq = Number(m[2])
|
|
240
|
+
if (seq > log.seq || seq < log.floor) return null
|
|
241
|
+
return log.entries.filter((e) => e.seq > seq)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ------------------------------------------------------------ the pages --
|
|
245
|
+
|
|
246
|
+
const clean = (s, max) => String(s ?? '').replace(/\r\n?/g, '\n').slice(0, max)
|
|
247
|
+
const cleanPath = (p) => String(p || '').trim().replace(/\\/g, '/').replace(/^\.\//, '').replace(/^\/+/, '')
|
|
248
|
+
const ago = (ts) => {
|
|
249
|
+
const s = Math.max(0, Math.round((Date.now() - (ts || 0)) / 1000))
|
|
250
|
+
return s < 60 ? `${s}s ago` : s < 3600 ? `${Math.round(s / 60)}m ago` : s < 86400 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago`
|
|
251
|
+
}
|
|
252
|
+
const iso = (ts) => new Date(ts).toISOString()
|
|
253
|
+
const flag = (v) => v === '1' || v === 'true' || v === 'yes'
|
|
254
|
+
const MAX_BODY = 64 * 1024
|
|
255
|
+
|
|
256
|
+
/** "todo", "To do", "in progress", "QA"… to a column id, or null. */
|
|
257
|
+
function columnId (v) {
|
|
258
|
+
const s = String(v || '').trim().toLowerCase().replace(/[\s_-]+/g, ' ')
|
|
259
|
+
if (COLUMN_IDS.has(s)) return s
|
|
260
|
+
const named = COLUMNS.find((c) => c.name.toLowerCase() === s)
|
|
261
|
+
if (named) return named.id
|
|
262
|
+
return { 'to do': 'todo', 'in progress': 'doing', progress: 'doing', started: 'doing' }[s] || null
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** A POST's fields (JSON object or a form), merged under the link's own. */
|
|
266
|
+
function readBody (req) {
|
|
267
|
+
return new Promise((resolve, reject) => {
|
|
268
|
+
const parts = []
|
|
269
|
+
let size = 0
|
|
270
|
+
req.on('data', (c) => {
|
|
271
|
+
size += c.length
|
|
272
|
+
if (size > MAX_BODY) { reject(new Error(`the body is larger than ${MAX_BODY / 1024} KB`)); req.destroy() } else parts.push(c)
|
|
273
|
+
})
|
|
274
|
+
req.on('end', () => {
|
|
275
|
+
const raw = Buffer.concat(parts).toString('utf8').trim()
|
|
276
|
+
if (!raw) return resolve({})
|
|
277
|
+
if (/json/i.test(String(req.headers['content-type'] || '')) || raw.startsWith('{')) {
|
|
278
|
+
try {
|
|
279
|
+
const o = JSON.parse(raw)
|
|
280
|
+
if (!o || typeof o !== 'object' || Array.isArray(o)) return reject(new Error('send a JSON object'))
|
|
281
|
+
return resolve(o)
|
|
282
|
+
} catch { return reject(new Error('the body is not valid JSON')) }
|
|
283
|
+
}
|
|
284
|
+
resolve(Object.fromEntries(new URLSearchParams(raw)))
|
|
285
|
+
})
|
|
286
|
+
req.on('error', reject)
|
|
287
|
+
})
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* Serves one request to /c/<room>/<token>[/<action>]. Returns false when the path isn't a
|
|
292
|
+
* chat link (the caller goes on), true once it answered. `relay` is { getRoom, roomEnded,
|
|
293
|
+
* refused, dropIfUnused, fetchFile, endedMessage }.
|
|
294
|
+
*/
|
|
295
|
+
export function handleChatLink (req, res, url, relay) {
|
|
296
|
+
const m = url.pathname.match(/^\/c\/([A-Za-z0-9_-]{1,64})\/([A-Za-z0-9_-]{20,64})(?:\/([a-z-]+))?\/?$/)
|
|
297
|
+
if (!m) return false
|
|
298
|
+
const [, roomName, token, action = ''] = m
|
|
299
|
+
const json = url.searchParams.get('format') === 'json' || /^\s*application\/json/i.test(String(req.headers.accept || ''))
|
|
300
|
+
const reply = (code, body, data) => {
|
|
301
|
+
res.writeHead(code, {
|
|
302
|
+
'content-type': json ? 'application/json; charset=utf-8' : 'text/plain; charset=utf-8',
|
|
303
|
+
'cache-control': 'no-store',
|
|
304
|
+
'x-robots-tag': 'noindex, nofollow',
|
|
305
|
+
'referrer-policy': 'no-referrer',
|
|
306
|
+
'x-content-type-options': 'nosniff'
|
|
307
|
+
})
|
|
308
|
+
if (!json) return res.end(body)
|
|
309
|
+
const text = String(body || '').trim()
|
|
310
|
+
res.end(JSON.stringify(code < 400 ? { ok: true, ...(data || { message: text }) } : { ok: false, status: code, error: text, ...(data || {}) }))
|
|
311
|
+
}
|
|
312
|
+
if (req.method !== 'GET' && req.method !== 'HEAD' && req.method !== 'POST') { reply(405, 'Open these links with GET, or POST their fields as JSON.'); return true }
|
|
313
|
+
if (relay.roomEnded(roomName)) { reply(410, relay.endedMessage); return true }
|
|
314
|
+
const room = relay.getRoom(roomName)
|
|
315
|
+
if (!room) { reply(...relay.refused(roomName)); return true }
|
|
316
|
+
const done = () => { if (!room.conns.size && room.onEmpty) room.onEmpty() }
|
|
317
|
+
const link = room.exists ? findChatLink(room, token) : null
|
|
318
|
+
if (!link || !link.member) { relay.dropIfUnused(room); reply(404, 'This chat link is not valid any more: it expired or the session owner removed it. Ask them for a new one.', { expired: true }); return true }
|
|
319
|
+
if (!rateOk(link.id)) { reply(429, 'Too many links opened in a minute; wait a little and try again.'); done(); return true }
|
|
320
|
+
room.hostedActive(link.memberId)
|
|
321
|
+
const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim() || (/^(localhost|127\.|\[::1\])/.test(String(req.headers.host)) ? 'http' : 'https')
|
|
322
|
+
const base = `${proto}://${req.headers.host}/c/${roomName}/${token}`
|
|
323
|
+
const page = new ChatPage(room, link, base, { json })
|
|
324
|
+
const q = url.searchParams
|
|
325
|
+
Promise.resolve(req.method === 'POST' ? readBody(req) : {})
|
|
326
|
+
.then((body) => {
|
|
327
|
+
for (const [k, v] of Object.entries(body)) if (v != null && typeof v !== 'object') q.set(k, String(v))
|
|
328
|
+
return page.run(action, q, relay)
|
|
329
|
+
})
|
|
330
|
+
.then(({ code = 200, body, data }) => reply(code, body, data))
|
|
331
|
+
.catch((err) => reply(/body|JSON/.test(err.message) ? 400 : 500, `Something went wrong: ${err.message}`))
|
|
332
|
+
.finally(done)
|
|
333
|
+
return true
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const hits = new Map() // link id -> { minute, n }
|
|
337
|
+
function rateOk (id) {
|
|
338
|
+
const minute = Math.floor(Date.now() / 60000)
|
|
339
|
+
const h = hits.get(id)
|
|
340
|
+
if (!h || h.minute !== minute) { hits.set(id, { minute, n: 1 }); if (hits.size > 10000) hits.clear(); return true }
|
|
341
|
+
h.n++
|
|
342
|
+
return h.n <= RATE.perMinute
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
class ChatPage {
|
|
346
|
+
constructor (room, link, base, { json = false } = {}) {
|
|
347
|
+
this.room = room
|
|
348
|
+
this.doc = room.doc
|
|
349
|
+
this.me = link.member.name
|
|
350
|
+
this.access = { role: link.member.role, scopes: link.member.scopes || [], scopesExcept: link.member.scopesExcept || [], talk: link.member.talk !== false }
|
|
351
|
+
this.link = link
|
|
352
|
+
this.base = base
|
|
353
|
+
this.json = json
|
|
354
|
+
this.log = changeLog(room)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Chat links have no branch of their own: files are the session's active branch's. The default
|
|
359
|
+
* branch may always start; any other new branch only for a link that may add files.
|
|
360
|
+
*/
|
|
361
|
+
get branch () {
|
|
362
|
+
if (!this._branch) {
|
|
363
|
+
const key = this.room.activeBranch()
|
|
364
|
+
this._branch = this.room.branchDoc(key, { by: this.me, editor: this.room.resolveKey(key) === this.room.defaultKey || this.access.role !== 'viewer' })
|
|
365
|
+
}
|
|
366
|
+
return this._branch
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
get bdoc () { return this.branch.doc }
|
|
370
|
+
|
|
371
|
+
get chat () { return this.doc.getArray('chat') }
|
|
372
|
+
|
|
373
|
+
get taskMap () { return this.doc.getMap('tasks') }
|
|
374
|
+
|
|
375
|
+
get commentMap () { return this.doc.getMap('taskComments') }
|
|
376
|
+
|
|
377
|
+
visible () { return this.chat.toArray().filter((m) => m && m.id && (!m.to || m.to === this.me || m.by === this.me)) }
|
|
378
|
+
/** Everyone this AI could address: who is here and who has been in the chat. */
|
|
379
|
+
memberNames () {
|
|
380
|
+
const names = new Set(this.room.hostedOnline().map((h) => h.name))
|
|
381
|
+
for (const st of this.room.awareness.getStates().values()) if (st && st.name) names.add(st.name)
|
|
382
|
+
for (const m of this.visible()) { if (m.by) names.add(m.by); if (m.to) names.add(m.to) }
|
|
383
|
+
for (const r of this.roster()) names.add(r.name)
|
|
384
|
+
names.delete(this.me)
|
|
385
|
+
return [...names].filter((n) => typeof n === 'string' && n)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
url (action, params = {}) {
|
|
389
|
+
const qs = Object.entries(params).map(([k, v]) => `${k}=${v}`).join('&')
|
|
390
|
+
return `${this.base}${action ? `/${action}` : ''}${qs ? `?${qs}` : ''}`
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
cursor () { return cursorOf(this.log) }
|
|
394
|
+
|
|
395
|
+
menu () {
|
|
396
|
+
if (this.json) return ''
|
|
397
|
+
return [
|
|
398
|
+
'',
|
|
399
|
+
this.timeLeft(),
|
|
400
|
+
'Links you can open (put your words in the link, URL-encoded; add &format=json to any of them for JSON):',
|
|
401
|
+
`- Overview: ${this.url('')}`,
|
|
402
|
+
`- What this link may do, with every action's inputs: ${this.url('actions')}`,
|
|
403
|
+
`- Read messages: ${this.url('messages')}`,
|
|
404
|
+
`- Send a message, starting with @Name of each person it is for (only they are told): ${this.url('say', { text: '@<name> <your message>' })}`,
|
|
405
|
+
`- An announcement for everyone: ${this.url('say', { text: '<your message>', everyone: '1' })}`,
|
|
406
|
+
`- Send a direct message: ${this.url('say', { to: '<name>', text: '<your message>' })}`,
|
|
407
|
+
`- Settle messages that need nothing back (thanks, a goodnight, an FYI), instead of replying: ${this.url('noreply', { ids: '<message id>' })}`,
|
|
408
|
+
`- The members, with their roles, focus and open tasks: ${this.url('members')}`,
|
|
409
|
+
`- Read the task board: ${this.url('tasks')}`,
|
|
410
|
+
`- Read one task in full, with its comments: ${this.url('details', { id: '<task id>' })}`,
|
|
411
|
+
`- Add a new task: ${this.url('task', { title: '<what needs doing>' })} (optionally &assignee=<name>)`,
|
|
412
|
+
`- Change an existing task: ${this.url('update', { id: '<task id>', assignee: '<name>' })} (any of &assignee=, &to_ai=1, &column=todo|doing|qa|done, &title=; moving to qa needs &qaNotes=, to done needs &verified=)`,
|
|
413
|
+
`- Comment on a task: ${this.url('comment', { id: '<task id>', text: '<your comment>' })}`,
|
|
414
|
+
`- What changed since a cursor (the board and messages give you one): ${this.url('changes', { since: '<cursor>' })}`,
|
|
415
|
+
`- List the project's files: ${this.url('files')} (optionally ?under=<folder>)`,
|
|
416
|
+
`- Read a text file: ${this.url('file', { path: '<path>' })}`,
|
|
417
|
+
`- Add a picture, PDF or document from the web: ${this.url('add', { url: '<https address of the file>', path: '<where, e.g. docs/mockup.png>' })}`,
|
|
418
|
+
`- Add a short note: ${this.url('note', { path: '<notes/name.md>', text: '<the note>' })}`
|
|
419
|
+
].join('\n')
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
async run (action, q, relay) {
|
|
423
|
+
switch (action) {
|
|
424
|
+
case '': return this.overview()
|
|
425
|
+
case 'actions': return this.actions()
|
|
426
|
+
case 'messages': return q.get('since') ? this.changes(q.get('since'), { tasks: false }) : this.messages(Number(q.get('limit')) || 30)
|
|
427
|
+
case 'say': return this.say(q.get('text'), q.get('to'), q.get('everyone') === '1')
|
|
428
|
+
case 'members': return this.members()
|
|
429
|
+
case 'tasks': return q.get('since') ? this.changes(q.get('since'), { messages: false }) : this.tasks()
|
|
430
|
+
case 'task': return this.addTask(q.get('title'), q.get('assignee'), flag(q.get('to_ai')))
|
|
431
|
+
case 'details': return this.details(q.get('id'))
|
|
432
|
+
case 'update': return this.update(q)
|
|
433
|
+
case 'comment': return this.comment(q.get('id'), q.get('text'))
|
|
434
|
+
case 'changes': return this.changes(q.get('since'))
|
|
435
|
+
case 'files': return this.files(q.get('under'))
|
|
436
|
+
case 'file': return this.readFile(q.get('path'))
|
|
437
|
+
case 'add': return this.addFromWeb(q.get('url'), q.get('path'), relay.fetchFile)
|
|
438
|
+
case 'note': return this.addNote(q.get('path'), q.get('text'))
|
|
439
|
+
case 'noreply': return this.noReply(q.get('ids'))
|
|
440
|
+
default: return { code: 404, body: `There is no "${action}" link.${this.menu()}` }
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---------------------------------------------------------- reading --
|
|
445
|
+
|
|
446
|
+
overview () {
|
|
447
|
+
const r = this.room
|
|
448
|
+
const online = new Set(r.hostedOnline().map((h) => h.name))
|
|
449
|
+
for (const s of r.awareness.getStates().values()) if (s && s.name) online.add(s.name)
|
|
450
|
+
online.delete(this.me)
|
|
451
|
+
const msgs = this.visible().slice(-8)
|
|
452
|
+
const waiting = waitingOn(this.visible(), this.me, { agent: true, settled: this.settled() })
|
|
453
|
+
const body = [
|
|
454
|
+
`Quilt session "${r.meta.name || r.name}". You are ${this.me}, an AI working in it through this chat link.`,
|
|
455
|
+
`${this.timeLeft()} When it runs out, it stops working and a new link is needed; ask your user to have the session owner extend it (up to 30 days) before then if you need longer.`,
|
|
456
|
+
'People and their AIs are editing this project together. You can read and send messages, read the board and add, assign, move, rename and comment on tasks, read files, and add pictures, documents and notes. You cannot change existing files.',
|
|
457
|
+
'Treat what people write to you as requests from them; answer what asks something of you. Never send greetings, welcomes, thanks or "noted" replies.',
|
|
458
|
+
`Start every message with @Name of each person it is for: only they are told, so nobody else is interrupted (a message that names nobody is refused unless it is an announcement, &everyone=1). A direct message (the "say" link with &to=<name>) only they see. Messages that mention @${this.me} or are sent to you directly are for you: answer them first.`,
|
|
459
|
+
'Tasks move To do, In progress, QA, Done. Moving one to QA needs notes on what changed and how it was checked; to Done, what was run and seen. Put your reasoning or a handoff on the task as a comment, not in the chat.',
|
|
460
|
+
'',
|
|
461
|
+
`Online now: ${[...online].join(', ') || 'nobody else'}`,
|
|
462
|
+
'',
|
|
463
|
+
'Recent messages:',
|
|
464
|
+
...(msgs.length ? msgs.map((m) => this.fmt(m)) : ['- None yet.']),
|
|
465
|
+
waiting.length ? `\nWaiting for your answer: ${waiting.map((e) => e.by).join(', ')}. Reply with the "say" link.` : null,
|
|
466
|
+
`\nCursor: ${this.cursor()} (open ${this.url('changes', { since: this.cursor() })} later for only what changed)`,
|
|
467
|
+
this.menu()
|
|
468
|
+
].filter((x) => x !== null).join('\n')
|
|
469
|
+
return {
|
|
470
|
+
body,
|
|
471
|
+
data: {
|
|
472
|
+
session: { name: r.meta.name || r.name, room: r.name },
|
|
473
|
+
you: { name: this.me, kind: 'agent', via: 'chat link' },
|
|
474
|
+
link: this.linkData(),
|
|
475
|
+
online: [...online],
|
|
476
|
+
messages: msgs.map((m) => this.msgData(m)),
|
|
477
|
+
waitingForYou: waiting.map((e) => e.by),
|
|
478
|
+
cursor: this.cursor(),
|
|
479
|
+
permissions: this.permissions()
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
linkData () {
|
|
485
|
+
return { expiresAt: iso(this.link.expiresAt), minutesLeft: Math.max(0, Math.ceil((this.link.expiresAt - Date.now()) / MINUTE)), renew: 'Only the session owner can extend this link, and only while it still works (up to 30 days). Ask your user to have them extend it.' }
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** What this link may do here, under the owner's controls. */
|
|
489
|
+
permissions () {
|
|
490
|
+
const a = this.access
|
|
491
|
+
const talk = a.talk && !this.room.full
|
|
492
|
+
return {
|
|
493
|
+
role: a.role,
|
|
494
|
+
readMessages: true,
|
|
495
|
+
sendMessages: talk,
|
|
496
|
+
readTasks: true,
|
|
497
|
+
addTasks: talk,
|
|
498
|
+
updateTasks: talk,
|
|
499
|
+
commentOnTasks: talk,
|
|
500
|
+
readMembers: true,
|
|
501
|
+
readFiles: true,
|
|
502
|
+
addNewFiles: a.role !== 'viewer' && !this.room.full && !this.branchFull(),
|
|
503
|
+
changeExistingFiles: false,
|
|
504
|
+
...(a.scopes.length ? { onlyIn: a.scopes } : {}),
|
|
505
|
+
...(a.scopesExcept.length ? { notIn: a.scopesExcept } : {}),
|
|
506
|
+
...(this.room.full ? { why: 'This session is over its size limit, so nothing new can be saved.' } : !a.talk ? { why: 'The session owner has turned off messages and task changes from you.' } : {})
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
actions () {
|
|
511
|
+
const p = this.permissions()
|
|
512
|
+
const list = [
|
|
513
|
+
{ action: '', does: 'The overview: who is here, recent messages, a cursor', allowed: true },
|
|
514
|
+
{ action: 'actions', does: 'This list', allowed: true },
|
|
515
|
+
{ action: 'messages', does: 'Read messages (with since: only new ones)', params: { limit: 'how many, up to 100 (default 30)', since: 'a cursor' }, allowed: p.readMessages },
|
|
516
|
+
{ action: 'say', does: 'Send a message', params: { text: 'the message, starting with @Name of each person it is for', to: 'a name, for a direct message', everyone: '1 for an announcement to everyone' }, required: ['text'], allowed: p.sendMessages },
|
|
517
|
+
{ action: 'noreply', does: 'Settle messages that need nothing back (thanks, a goodnight, an FYI), so work goes on without sending a reply', params: { ids: 'message ids, separated by commas' }, required: ['ids'], allowed: p.readMessages },
|
|
518
|
+
{ action: 'members', does: 'The members: name, human or agent, role, online, tool, focus and open tasks', allowed: p.readMembers },
|
|
519
|
+
{ action: 'tasks', does: 'Read the board (with since: only what changed)', params: { since: 'a cursor' }, allowed: p.readTasks },
|
|
520
|
+
{ action: 'details', does: 'One task in full: notes, files and comments', params: { id: 'the task id (or its first 6 or more characters)' }, required: ['id'], allowed: p.readTasks },
|
|
521
|
+
{ action: 'task', does: 'Create a new task in To do (it does not change an existing one: use update)', params: { title: 'what needs doing', assignee: 'a member\'s name, or "me"', to_ai: '1: that person\'s AI rather than the person' }, required: ['title'], allowed: p.addTasks },
|
|
522
|
+
{ action: 'update', does: 'Change an existing task and get it back as saved', params: { id: 'the task id', assignee: 'a member\'s name, "me", or empty to unassign', to_ai: '1: that person\'s AI', column: 'todo, doing, qa or done', title: 'a new title', qaNotes: 'needed to move to qa: what changed and how it was checked', verified: 'needed to move to done: what was run and seen' }, required: ['id'], allowed: p.updateTasks },
|
|
523
|
+
{ action: 'comment', does: 'Add a comment to a task (a work note, a handoff, why it went to whom)', params: { id: 'the task id', text: `the comment, up to ${MAX_COMMENT} characters` }, required: ['id', 'text'], allowed: p.commentOnTasks },
|
|
524
|
+
{ action: 'changes', does: 'What changed on the board and in messages since a cursor, with a new cursor', params: { since: 'a cursor from the overview, tasks, messages or an earlier changes' }, required: ['since'], allowed: true },
|
|
525
|
+
{ action: 'files', does: 'List the project\'s files', params: { under: 'a folder' }, allowed: p.readFiles },
|
|
526
|
+
{ action: 'file', does: 'Read a text file', params: { path: 'the file' }, required: ['path'], allowed: p.readFiles },
|
|
527
|
+
{ action: 'add', does: 'Add a picture, PDF or document from a public https address, as a new file', params: { url: 'the https address', path: 'where it goes' }, required: ['url'], allowed: p.addNewFiles },
|
|
528
|
+
{ action: 'note', does: 'Add a short note as a new .md or .txt file', params: { path: 'where it goes', text: 'the note' }, required: ['path', 'text'], allowed: p.addNewFiles }
|
|
529
|
+
].map((a) => ({ ...a, url: this.url(a.action) }))
|
|
530
|
+
const body = [
|
|
531
|
+
`What ${this.me} may do through this link (GET with the inputs in the link, or POST them as JSON; &format=json for JSON answers):`,
|
|
532
|
+
...list.map((a) => `- ${a.action || '(overview)'}: ${a.does}.${a.params ? ` Inputs: ${Object.entries(a.params).map(([k, v]) => `${k}${(a.required || []).includes(k) ? '' : ' (optional)'} = ${v}`).join('; ')}.` : ''}${a.allowed ? '' : ' NOT ALLOWED for you now.'}`),
|
|
533
|
+
'',
|
|
534
|
+
`You may not change existing files.${p.why ? ` ${p.why}` : ''}${p.onlyIn ? ` New files only in: ${p.onlyIn.join(', ')}.` : ''}${p.notIn ? ` Not in: ${p.notIn.join(', ')}.` : ''}`,
|
|
535
|
+
this.menu()
|
|
536
|
+
].join('\n')
|
|
537
|
+
return { body, data: { you: this.me, link: this.linkData(), permissions: p, actions: list } }
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/** "This link works for 7 more minutes." */
|
|
541
|
+
timeLeft () {
|
|
542
|
+
const min = Math.max(0, Math.ceil((this.link.expiresAt - Date.now()) / MINUTE))
|
|
543
|
+
const span = min < 60 ? `${min} more minute${min === 1 ? '' : 's'}` : min < 2880 ? `${Math.round(min / 60)} more hours` : `${Math.round(min / 1440)} more days`
|
|
544
|
+
return `This link works for ${span} (until ${iso(this.link.expiresAt)}).`
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
fmt (m) { return `- ${m.by}${m.to ? ` → ${m.to} (direct)` : ''} (${ago(m.ts)}): ${m.text}${m.file ? ` [file: ${m.file.name}]` : ''}` }
|
|
548
|
+
msgData (m) { return { id: m.id, by: m.by, to: m.to || null, text: m.text, at: iso(m.ts), ...(m.file ? { file: m.file.name } : {}) } }
|
|
549
|
+
|
|
550
|
+
messages (limit) {
|
|
551
|
+
const msgs = this.visible().slice(-Math.min(Math.max(limit, 1), 100))
|
|
552
|
+
const body = (msgs.length ? msgs.map((m) => this.fmt(m)).join('\n') : 'No messages yet.') + `\n\nCursor: ${this.cursor()}` + this.menu()
|
|
553
|
+
return { body, data: { messages: msgs.map((m) => this.msgData(m)), cursor: this.cursor() } }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** The members, from the session's member list and who is connected, with their open work. */
|
|
557
|
+
roster () {
|
|
558
|
+
const tasks = readTasks(this.taskMap)
|
|
559
|
+
const out = new Map()
|
|
560
|
+
const put = (name, fields) => { if (typeof name === 'string' && name) out.set(name, { ...(out.get(name) || { name }), ...fields }) }
|
|
561
|
+
for (const m of this.room.memberList ? this.room.memberList() : []) {
|
|
562
|
+
put(m.name, { kind: m.kind === 'agent' ? 'agent' : 'human', role: m.role || 'editor', online: !!m.online, ...(m.chat ? { via: 'chat link' } : m.http ? { via: 'http' } : {}) })
|
|
563
|
+
}
|
|
564
|
+
for (const st of this.room.awareness.getStates().values()) {
|
|
565
|
+
if (!st || typeof st.name !== 'string' || !st.name) continue
|
|
566
|
+
const tool = typeof st.tool === 'string' && st.tool && st.tool !== 'unknown' ? { tool: st.tool.slice(0, 40) } : {}
|
|
567
|
+
const focus = typeof st.focus === 'string' && st.focus ? { focus: st.focus.slice(0, 200) } : {}
|
|
568
|
+
put(st.name, { kind: out.get(st.name)?.kind || (st.kind === 'agent' ? 'agent' : 'human'), online: true, ...tool, ...focus })
|
|
569
|
+
for (const x of Array.isArray(st.personas) ? st.personas : []) {
|
|
570
|
+
if (!x || typeof x.name !== 'string' || !x.name) continue
|
|
571
|
+
put(x.name.slice(0, 80), { kind: 'agent', online: true, aiOf: st.name, ...(typeof x.tool === 'string' && x.tool ? { tool: x.tool.slice(0, 40) } : {}), ...(typeof x.focus === 'string' && x.focus ? { focus: x.focus.slice(0, 200) } : {}) })
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return [...out.values()].map((r) => {
|
|
575
|
+
const open = tasks.filter((t) => t.assignee === r.name && t.column !== 'done')
|
|
576
|
+
return { ...r, ...(r.name === this.me ? { you: true } : {}), openTasks: open.map((t) => ({ id: t.id, title: t.title, column: t.column, forAi: t.forAi })) }
|
|
577
|
+
})
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
members () {
|
|
581
|
+
const list = this.roster()
|
|
582
|
+
const line = (r) => {
|
|
583
|
+
const bits = [r.kind, r.role, r.online ? 'online' : 'away', r.aiOf ? `an AI session of ${r.aiOf}` : '', r.via ? `via ${r.via}` : ''].filter(Boolean).join(', ')
|
|
584
|
+
const work = r.openTasks.length ? ` · open tasks: ${r.openTasks.map((t) => `${t.title} (${columnName(t.column)}${t.forAi ? ', their AI' : ''}, ${t.id})`).join('; ')}` : ' · no open tasks'
|
|
585
|
+
return `- ${r.name}${r.you ? ' (you)' : ''} (${bits})${r.tool ? ` · ${r.tool}` : ''}${r.focus ? ` · focus: ${r.focus}` : ''}${work}`
|
|
586
|
+
}
|
|
587
|
+
const body = `Members (assign tasks by these names):\n${list.length ? list.map(line).join('\n') : '- Nobody yet.'}${this.menu()}`
|
|
588
|
+
return { body, data: { members: list } }
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
taskData (t) {
|
|
592
|
+
return {
|
|
593
|
+
id: t.id,
|
|
594
|
+
title: t.title,
|
|
595
|
+
column: t.column,
|
|
596
|
+
columnName: columnName(t.column),
|
|
597
|
+
by: t.by,
|
|
598
|
+
assignee: t.assignee || null,
|
|
599
|
+
forAi: !!t.forAi,
|
|
600
|
+
...(t.tool ? { tool: t.tool } : {}),
|
|
601
|
+
files: t.files || [],
|
|
602
|
+
qaNotes: t.qaNotes || '',
|
|
603
|
+
verified: t.verified || '',
|
|
604
|
+
recurring: !!t.recurring,
|
|
605
|
+
...(t.cron ? { cron: t.cron } : {}),
|
|
606
|
+
createdAt: iso(t.ts),
|
|
607
|
+
comments: readComments(this.commentMap, t.id).map((c) => ({ id: c.id, by: c.by, text: c.text, at: iso(c.ts) }))
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
taskText (t) { return formatTaskDetails({ ...t, comments: readComments(this.commentMap, t.id) }) }
|
|
612
|
+
|
|
613
|
+
tasks () {
|
|
614
|
+
const list = readTasks(this.taskMap)
|
|
615
|
+
const counts = {}
|
|
616
|
+
for (const [id, c] of Object.entries(Object.fromEntries([...this.commentMap.entries()]))) if (Array.isArray(c) && c.length) counts[id] = c.length
|
|
617
|
+
const board = formatTasks(list, { name: this.me, asAi: false })
|
|
618
|
+
const noted = Object.keys(counts).length ? `\n\nTasks with comments (open them with the details link): ${list.filter((t) => counts[t.id]).map((t) => `${t.id} (${counts[t.id]})`).join(', ')}` : ''
|
|
619
|
+
return {
|
|
620
|
+
body: `${board}${noted}\n\nCursor: ${this.cursor()}${this.menu()}`,
|
|
621
|
+
data: { columns: COLUMNS.map((c) => ({ id: c.id, name: c.name })), tasks: withComments(list, this.commentMap).map((t) => this.taskData(t)), cursor: this.cursor() }
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** The task `id` names (all of it, or a unique start of at least 6 characters), or a refusal. */
|
|
626
|
+
findTask (id) {
|
|
627
|
+
const want = String(id || '').trim().toLowerCase()
|
|
628
|
+
if (!want || want === '<task id>') return { refusal: { code: 400, body: `Say which task, by the id the board shows: ${this.url('details', { id: '<task id>' })}${this.menu()}` } }
|
|
629
|
+
const list = readTasks(this.taskMap)
|
|
630
|
+
const exact = list.find((t) => t.id === want)
|
|
631
|
+
if (exact) return { task: exact }
|
|
632
|
+
const some = want.length >= 6 ? list.filter((t) => t.id.startsWith(want)) : []
|
|
633
|
+
if (some.length === 1) return { task: some[0] }
|
|
634
|
+
if (some.length > 1) return { refusal: { code: 400, body: `More than one task starts with ${want}; give more of its id.${this.menu()}` } }
|
|
635
|
+
return { refusal: { code: 404, body: `There is no task ${want} on the board (it may have been removed). Read the board: ${this.url('tasks')}${this.menu()}` } }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
details (id) {
|
|
639
|
+
const { task, refusal } = this.findTask(id)
|
|
640
|
+
if (refusal) return refusal
|
|
641
|
+
return { body: this.taskText(task) + this.menu(), data: { task: this.taskData(task) } }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
changes (cursor, { tasks = true, messages = true } = {}) {
|
|
645
|
+
if (!cursor || cursor === '<cursor>') return { code: 400, body: `Give the cursor from the board, messages or overview: ${this.url('changes', { since: this.cursor() })}${this.menu()}` }
|
|
646
|
+
const entries = changesSince(this.log, cursor)
|
|
647
|
+
if (!entries) {
|
|
648
|
+
return {
|
|
649
|
+
code: 410,
|
|
650
|
+
body: `That cursor has expired (the session restarted, or a lot changed since). Read the board (${this.url('tasks')}) and messages (${this.url('messages')}) again: each gives a fresh cursor.${this.menu()}`,
|
|
651
|
+
data: { expired: true }
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
const list = readTasks(this.taskMap)
|
|
655
|
+
const ids = tasks ? [...new Set(entries.filter((e) => e.kind === 'task').map((e) => e.id))] : []
|
|
656
|
+
const changed = ids.map((id) => list.find((t) => t.id === id)).filter(Boolean)
|
|
657
|
+
const removed = ids.filter((id) => !list.some((t) => t.id === id))
|
|
658
|
+
const msgIds = new Set(messages ? entries.filter((e) => e.kind === 'message').map((e) => e.id) : [])
|
|
659
|
+
const msgs = this.visible().filter((m) => msgIds.has(m.id))
|
|
660
|
+
const next = this.cursor()
|
|
661
|
+
const lines = []
|
|
662
|
+
if (changed.length) lines.push(`Tasks changed (${changed.length}):`, ...changed.map((t) => `- ${t.id} ${t.title} (${columnName(t.column)})${t.assignee ? ` → ${t.assignee}${t.forAi ? "'s AI" : ''}` : ''}`))
|
|
663
|
+
if (removed.length) lines.push(`Tasks removed: ${removed.join(', ')}`)
|
|
664
|
+
if (msgs.length) lines.push(`New messages (${msgs.length}):`, ...msgs.map((m) => this.fmt(m)))
|
|
665
|
+
if (!lines.length) lines.push('Nothing changed since then.')
|
|
666
|
+
return {
|
|
667
|
+
body: `${lines.join('\n')}\n\nNext cursor: ${next} (${this.url('changes', { since: next })})${this.menu()}`,
|
|
668
|
+
data: {
|
|
669
|
+
...(tasks ? { tasks: changed.map((t) => this.taskData(t)), removedTasks: removed } : {}),
|
|
670
|
+
...(messages ? { messages: msgs.map((m) => this.msgData(m)) } : {}),
|
|
671
|
+
cursor: next
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// ---------------------------------------------------------- writing --
|
|
677
|
+
|
|
678
|
+
writable () {
|
|
679
|
+
if (this.room.full) return 'This session is over its size limit, so nothing new can be saved.'
|
|
680
|
+
return null
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
say (text, to, everyone = false) {
|
|
684
|
+
const t = clean(text, MAX_TEXT).trim()
|
|
685
|
+
if (!t || t === '<your message>' || t === '@<name> <your message>') return { code: 400, body: `Put your message in the link: ${this.url('say', { text: 'Hello%20everyone' })}` }
|
|
686
|
+
if (!this.access.talk) return { code: 403, body: 'The session owner has turned off messages from you.' }
|
|
687
|
+
const err = this.writable()
|
|
688
|
+
if (err) return { code: 403, body: err }
|
|
689
|
+
const who = to ? String(to).trim().slice(0, 80) : null
|
|
690
|
+
const why = unaddressed(t, { to: who, everyone, names: this.memberNames() })
|
|
691
|
+
if (why) return { code: 400, body: `${why.replace(/set "to"/, 'add &to=<name>').replace(/with everyone: true/, 'with &everyone=1')}${this.menu()}` }
|
|
692
|
+
// A chat app may open the same link twice: the same words in the last two minutes are sent once.
|
|
693
|
+
const recent = this.chat.toArray().slice(-50).find((m) => m && m.by === this.me && m.text === t && (m.to || null) === who && Date.now() - m.ts < 120000)
|
|
694
|
+
if (recent) return { body: `Already sent.${this.menu()}`, data: { message: this.msgData(recent), duplicate: true } }
|
|
695
|
+
const msg = { id: crypto.randomBytes(8).toString('hex'), by: this.me, to: who, text: t, ts: Date.now() }
|
|
696
|
+
this.doc.transact(() => {
|
|
697
|
+
this.chat.push([msg])
|
|
698
|
+
if (this.chat.length > CHAT_CAP) this.chat.delete(0, this.chat.length - CHAT_CAP)
|
|
699
|
+
}, ORIGIN)
|
|
700
|
+
return { body: `${who ? `Sent to ${who}.` : 'Sent to everyone.'}${this.menu()}`, data: { message: this.msgData(msg) } }
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Work moves on once nobody waits for an answer (duties.js), as for every agent. */
|
|
704
|
+
held (what) {
|
|
705
|
+
const w = renderUnanswered(waitingOn(this.visible(), this.me, { agent: true, settled: this.settled() }), what)
|
|
706
|
+
return w ? { code: 409, body: `${w.replace(/quilt_message \(to: their name\)/, 'the "say" link (with &to=<their name>)').replace(/settle it with quilt_inbox \(no_reply: \[its id\]\)/, `settle it with ${this.url('noreply', { ids: '<its id>' })} (several ids with commas)`)}${this.menu()}` } : null
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** This link's stored entry in the session (findChatLink hands out a copy). */
|
|
710
|
+
entry () { return Object.values(this.room.meta.chatLinks || {}).find((l) => l && l.id === this.link.id) || null }
|
|
711
|
+
|
|
712
|
+
/** Ids of messages this link settled as needing no reply. */
|
|
713
|
+
settled () {
|
|
714
|
+
const e = this.entry()
|
|
715
|
+
return new Set(Array.isArray(e && e.settled) ? e.settled : [])
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
/** Settles messages that need nothing back (thanks, a goodnight, an FYI), so work goes on without a reply. */
|
|
719
|
+
noReply (ids) {
|
|
720
|
+
const wanted = String(ids || '').split(/[\s,]+/).filter(Boolean).slice(0, 50)
|
|
721
|
+
if (!wanted.length) return { code: 400, body: `Give the ids of the messages that need no reply: ${this.url('noreply', { ids: '<id>,<id>' })}${this.menu()}` }
|
|
722
|
+
const known = new Set(this.visible().filter((m) => m.by !== this.me).map((m) => m.id))
|
|
723
|
+
const ok = wanted.filter((x) => known.has(x))
|
|
724
|
+
const e = this.entry()
|
|
725
|
+
if (ok.length && e) {
|
|
726
|
+
e.settled = [...new Set([...(Array.isArray(e.settled) ? e.settled : []), ...ok])].slice(-500)
|
|
727
|
+
this.room.saveMeta()
|
|
728
|
+
}
|
|
729
|
+
const unknown = wanted.filter((x) => !known.has(x))
|
|
730
|
+
return {
|
|
731
|
+
body: `${ok.length ? `Settled as needing no reply: ${ok.join(', ')}.` : 'Nothing settled.'}${unknown.length ? ` Not messages to you: ${unknown.join(', ')}.` : ''}${this.menu()}`,
|
|
732
|
+
data: { settled: ok, unknown }
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Why this link may not change the board now, as a refusal, or null. */
|
|
737
|
+
boardRefusal (what) {
|
|
738
|
+
const wait = this.held(what)
|
|
739
|
+
if (wait) return wait
|
|
740
|
+
if (!this.access.talk) return { code: 403, body: 'The session owner has turned off messages and task changes from you.' }
|
|
741
|
+
const err = this.writable()
|
|
742
|
+
if (err) return { code: 403, body: err }
|
|
743
|
+
return null
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** Assignment fields for `assignee` ("me", a member's name, or '' to clear), or a refusal. */
|
|
747
|
+
assignTo (assignee, toAi) {
|
|
748
|
+
const raw = String(assignee ?? '').trim()
|
|
749
|
+
const name = raw === 'me' ? this.me : raw
|
|
750
|
+
const roster = this.roster()
|
|
751
|
+
if (name && name !== this.me && !roster.some((r) => r.name === name)) {
|
|
752
|
+
const near = roster.find((r) => r.name.toLowerCase() === name.toLowerCase())
|
|
753
|
+
return { refusal: { code: 400, body: `Nobody called "${name}" is in this session.${near ? ` Did you mean ${near.name}?` : ''} Members: ${roster.map((r) => r.name).join(', ') || 'none'} (${this.url('members')}).${this.menu()}` } }
|
|
754
|
+
}
|
|
755
|
+
try {
|
|
756
|
+
return { fields: assignmentFields({ assignee: name, to_ai: toAi, me: this.me, peers: roster }) }
|
|
757
|
+
} catch (e) { return { refusal: { code: 400, body: `${e.message}.${this.menu()}` } } }
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
addTask (title, assignee, toAi = false) {
|
|
761
|
+
const t = clean(title, 300).trim()
|
|
762
|
+
if (!t || t === '<what needs doing>') return { code: 400, body: `Put the task in the link: ${this.url('task', { title: 'Write%20the%20pricing%20page' })}` }
|
|
763
|
+
const no = this.boardRefusal('open the task link again')
|
|
764
|
+
if (no) return no
|
|
765
|
+
const existing = readTasks(this.taskMap).find((x) => x.title === t && x.by === this.me && x.column !== 'done')
|
|
766
|
+
if (existing) return { body: `That task is already on the board (${existing.id}).${this.menu()}`, data: { task: this.taskData(existing), duplicate: true } }
|
|
767
|
+
let who = {}
|
|
768
|
+
if (assignee) {
|
|
769
|
+
const a = this.assignTo(assignee, toAi)
|
|
770
|
+
if (a.refusal) return a.refusal
|
|
771
|
+
who = a.fields
|
|
772
|
+
}
|
|
773
|
+
try {
|
|
774
|
+
const task = addTask(this.doc, this.taskMap, { title: t, by: this.me, ...who }, ORIGIN)
|
|
775
|
+
return { body: `Added to To do: ${task.title}${task.assignee ? ` (for ${task.assignee}${task.forAi ? "'s AI" : ''})` : ''}. Its id is ${task.id}.${this.menu()}`, data: { task: this.taskData(task) } }
|
|
776
|
+
} catch (e) { return { code: 400, body: `${e.message}.${this.menu()}` } }
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** Changes an existing task: assignee, column, title. Answers with the task as saved. */
|
|
780
|
+
update (q) {
|
|
781
|
+
const { task: cur, refusal } = this.findTask(q.get('id'))
|
|
782
|
+
if (refusal) return refusal
|
|
783
|
+
const patch = { id: cur.id }
|
|
784
|
+
let column
|
|
785
|
+
if (q.has('column')) {
|
|
786
|
+
column = columnId(q.get('column'))
|
|
787
|
+
if (!column) return { code: 400, body: `column is todo, doing, qa or done, not "${String(q.get('column')).slice(0, 40)}".${this.menu()}` }
|
|
788
|
+
patch.column = column
|
|
789
|
+
}
|
|
790
|
+
if (q.has('title')) patch.title = clean(q.get('title'), 300)
|
|
791
|
+
if (q.has('assignee')) {
|
|
792
|
+
const a = this.assignTo(q.get('assignee'), flag(q.get('to_ai')))
|
|
793
|
+
if (a.refusal) return a.refusal
|
|
794
|
+
Object.assign(patch, a.fields)
|
|
795
|
+
} else if (q.has('to_ai')) {
|
|
796
|
+
const a = this.assignTo(cur.assignee, flag(q.get('to_ai')))
|
|
797
|
+
if (a.refusal) return a.refusal
|
|
798
|
+
Object.assign(patch, a.fields)
|
|
799
|
+
}
|
|
800
|
+
if (q.has('qaNotes')) patch.qaNotes = q.get('qaNotes')
|
|
801
|
+
if (q.has('verified')) patch.verified = q.get('verified')
|
|
802
|
+
if (Object.keys(patch).length === 1) return { code: 400, body: `Say what to change: &assignee=, &to_ai=1, &column= or &title=. ${this.url('update', { id: cur.id, assignee: '<name>' })}${this.menu()}` }
|
|
803
|
+
const no = this.boardRefusal('open the update link again')
|
|
804
|
+
if (no) return no
|
|
805
|
+
const checklist = () => pickChecklist(this.bdoc.getMap('files').get('AGENTS.md')?.toString(), this.bdoc.getMap('files').get('CLAUDE.md')?.toString())
|
|
806
|
+
const refusalText = (s) => s.replace(/`qaNotes`/g, '&qaNotes=').replace(/`verified`/g, '&verified=')
|
|
807
|
+
if (column === 'qa' && cur.column !== 'qa' && !qaNotesEnough(patch.qaNotes)) return { code: 400, body: refusalText(qaRefusal({ task: cur, checklist: checklist() })) + this.menu() }
|
|
808
|
+
if (column === 'done' && cur.column !== 'done' && !verifiedEnough(patch.verified)) return { code: 400, body: refusalText(doneRefusal({ task: cur, checklist: checklist() })) + this.menu() }
|
|
809
|
+
try {
|
|
810
|
+
const task = updateTask(this.doc, this.taskMap, patch, ORIGIN)
|
|
811
|
+
return { body: `Saved. The task now reads:\n\n${this.taskText(task)}${this.menu()}`, data: { task: this.taskData(task) } }
|
|
812
|
+
} catch (e) { return { code: 400, body: `${e.message}.${this.menu()}` } }
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
comment (id, text) {
|
|
816
|
+
const { task, refusal } = this.findTask(id)
|
|
817
|
+
if (refusal) return refusal
|
|
818
|
+
const t = clean(text, MAX_COMMENT + 100).trim()
|
|
819
|
+
if (!t || t === '<your comment>') return { code: 400, body: `Put the comment in the link: ${this.url('comment', { id: task.id, text: 'Assigned%20to%20Dana%3A%20she%20wrote%20the%20parser' })}${this.menu()}` }
|
|
820
|
+
const no = this.boardRefusal('open the comment link again')
|
|
821
|
+
if (no) return no
|
|
822
|
+
// A chat app may open the same link twice.
|
|
823
|
+
const recent = readComments(this.commentMap, task.id).find((c) => c.by === this.me && c.text === t && Date.now() - c.ts < 120000)
|
|
824
|
+
if (recent) return { body: `Already added.${this.menu()}`, data: { task: this.taskData(task), duplicate: true } }
|
|
825
|
+
try {
|
|
826
|
+
addComment(this.doc, this.commentMap, readTasks(this.taskMap), { taskId: task.id, by: this.me, text: t }, ORIGIN)
|
|
827
|
+
return { body: `Comment added to "${task.title}".${this.menu()}`, data: { task: this.taskData(task) } }
|
|
828
|
+
} catch (e) { return { code: 400, body: `${e.message}.${this.menu()}` } }
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
files (under) {
|
|
832
|
+
const pre = under ? cleanPath(under).replace(/\/+$/, '') + '/' : ''
|
|
833
|
+
const f = this.bdoc.getMap('files')
|
|
834
|
+
const b = this.bdoc.getMap('blobs')
|
|
835
|
+
const paths = [...new Set([...f.keys(), ...b.keys()])].filter((p) => isSafeRelPath(p) && (!pre || p.startsWith(pre))).sort()
|
|
836
|
+
const shown = paths.slice(0, 400).map((p) => `- ${p}${b.has(p) ? ' (binary)' : ''}`)
|
|
837
|
+
const body = (paths.length ? shown.join('\n') + (paths.length > 400 ? `\n… and ${paths.length - 400} more (use ?under=<folder>)` : '') : 'No files.') + this.menu()
|
|
838
|
+
return { body, data: { files: paths.slice(0, 400).map((p) => ({ path: p, binary: b.has(p) })), total: paths.length } }
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
readFile (p) {
|
|
842
|
+
const rel = cleanPath(p)
|
|
843
|
+
if (!rel || !isSafeRelPath(rel)) return { code: 400, body: `Say which file: ${this.url('file', { path: 'README.md' })}` }
|
|
844
|
+
const t = this.bdoc.getMap('files').get(rel)
|
|
845
|
+
if (t) {
|
|
846
|
+
const s = t.toString()
|
|
847
|
+
const cut = s.length > MAX_READ
|
|
848
|
+
return { body: cut ? `${s.slice(0, MAX_READ)}\n… (${s.length - MAX_READ} more characters not shown)` : s, data: { path: rel, text: s.slice(0, MAX_READ), truncated: cut } }
|
|
849
|
+
}
|
|
850
|
+
if (this.bdoc.getMap('blobs').has(rel)) return { code: 400, body: `${rel} is a binary file; it can't be shown as text.` }
|
|
851
|
+
return { code: 404, body: `There is no file called ${rel}.${this.menu()}` }
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/** Can a new file go at `rel`? A refusal as { code, body }, or null. */
|
|
855
|
+
placeRefusal (rel) {
|
|
856
|
+
const why = addRefusal(rel)
|
|
857
|
+
if (why) return { code: 400, body: `${why}.` }
|
|
858
|
+
if (this.bdoc.getMap('files').has(rel) || this.bdoc.getMap('blobs').has(rel)) return { code: 409, body: `${rel} already exists, and chat links only add new files. Pick another name.` }
|
|
859
|
+
const refusal = changeRefusal(this.access, rel)
|
|
860
|
+
if (refusal) return { code: 403, body: `The session owner says ${refusal}.` }
|
|
861
|
+
const claim = (this.room.claimList ? this.room.claimList(this.branch.key) : []).find((c) => c.by !== this.me && globMatcher(c.pattern)(rel))
|
|
862
|
+
if (claim) return { code: 409, body: `${rel} is in ${claim.pattern}, which ${claim.by} has claimed${claim.note ? ` (${claim.note})` : ''}. Put it somewhere else, or ask them with the "say" link.` }
|
|
863
|
+
if (this.writable()) return { code: 403, body: this.writable() }
|
|
864
|
+
return this.branchFull() ? { code: 403, body: this.branchFull() } : null
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Why the branch files are added to can take nothing new (it is over its own size limit), or null. */
|
|
868
|
+
branchFull () {
|
|
869
|
+
let k
|
|
870
|
+
try { k = this.branch.key } catch { return null } // no branch to add to: placeRefusal says why
|
|
871
|
+
return this.room.branchFull && this.room.branchFull(k) ? `${k} is over the session's size limit for one branch, so no new files can be saved on it. Start a fresh copy with \`git checkout -b <new name>\`: your changes since are kept in your folder.` : null
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
async addFromWeb (address, p, fetchFile) {
|
|
875
|
+
if (!address || address === '<https address of the file>') return { code: 400, body: `Give the file's web address: ${this.url('add', { url: 'https%3A%2F%2Fexample.com%2Fmockup.png', path: 'docs/mockup.png' })}` }
|
|
876
|
+
let rel = cleanPath(p)
|
|
877
|
+
if (!rel) { try { rel = `shared/${decodeURIComponent(new URL(address).pathname.split('/').pop() || '')}` } catch {} }
|
|
878
|
+
const wait = this.held('open the add link again')
|
|
879
|
+
if (wait) return wait
|
|
880
|
+
const refused = this.placeRefusal(rel)
|
|
881
|
+
if (refused) return { ...refused, body: refused.body + this.menu() }
|
|
882
|
+
let buf
|
|
883
|
+
try { buf = await fetchFile(address) } catch (e) { return { code: 502, body: `Could not get that file: ${e.message}.${this.menu()}` } }
|
|
884
|
+
return this.place(rel, buf, `from ${new URL(address).host}`)
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
addNote (p, text) {
|
|
888
|
+
const rel = cleanPath(p)
|
|
889
|
+
if (!rel || !/\.(md|txt)$/i.test(rel)) return { code: 400, body: `Say where the note goes, as a .md or .txt file: ${this.url('note', { path: 'notes/meeting.md', text: 'Decisions%3A%20...' })}` }
|
|
890
|
+
const t = clean(text, MAX_NOTE)
|
|
891
|
+
if (!t.trim() || t === '<the note>') return { code: 400, body: 'Put the note in the link as text=…' }
|
|
892
|
+
const wait = this.held('open the note link again')
|
|
893
|
+
if (wait) return wait
|
|
894
|
+
const refused = this.placeRefusal(rel)
|
|
895
|
+
if (refused) return { ...refused, body: refused.body + this.menu() }
|
|
896
|
+
return this.place(rel, Buffer.from(t.endsWith('\n') ? t : t + '\n', 'utf8'), 'a note')
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Writes a new file into the shared project, checked against what its name says it is. */
|
|
900
|
+
place (rel, buf, from) {
|
|
901
|
+
const kind = ADDABLE[extOf(rel)]
|
|
902
|
+
if (!kind.is(buf)) return { code: 400, body: `That isn't a real .${extOf(rel)} file (its contents say otherwise), so it was not added.${this.menu()}` }
|
|
903
|
+
if (buf.length > CHAT_ADD_MAX_BYTES) return { code: 413, body: `That file is larger than ${CHAT_ADD_MAX_BYTES / 1024 / 1024} MB.` }
|
|
904
|
+
const files = this.bdoc.getMap('files')
|
|
905
|
+
const blobs = this.bdoc.getMap('blobs')
|
|
906
|
+
const activity = this.doc.getArray('activity')
|
|
907
|
+
const history = this.branch.historyLog || (this.branch.historyLog = new HistoryLog(this.bdoc, this.bdoc.getArray('history'), { origin: ORIGIN }))
|
|
908
|
+
const detail = kind.text ? `${buf.toString('utf8').split('\n').length} lines` : `${buf.length} bytes`
|
|
909
|
+
this.bdoc.transact(() => this.doc.transact(() => {
|
|
910
|
+
if (kind.text) {
|
|
911
|
+
const t = new Y.Text()
|
|
912
|
+
t.insert(0, buf.toString('utf8'))
|
|
913
|
+
files.set(rel, t)
|
|
914
|
+
} else {
|
|
915
|
+
blobs.set(rel, { hash: crypto.createHash('sha1').update(buf).digest('hex'), data: buf.toString('base64') })
|
|
916
|
+
}
|
|
917
|
+
activity.push([{ by: this.me, path: rel, kind: 'created', detail, branch: this.branch.key, ts: Date.now() }])
|
|
918
|
+
if (activity.length > ACTIVITY_CAP) activity.delete(0, activity.length - ACTIVITY_CAP)
|
|
919
|
+
history.record({ by: this.me, path: rel, kind: 'created', detail, ...(kind.text ? { before: '', after: buf.toString('utf8') } : {}) })
|
|
920
|
+
}, ORIGIN), ORIGIN)
|
|
921
|
+
return { body: `Added ${rel} (${detail}, ${from}). Everyone in the session has it now.${this.menu()}`, data: { path: rel, detail } }
|
|
922
|
+
}
|
|
923
|
+
}
|