heyquilt 0.0.1 → 0.3.106

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/LICENSE +85 -0
  2. package/README.md +633 -2
  3. package/RELEASES.md +862 -0
  4. package/assets/logo.svg +1 -0
  5. package/bin/quilt.js +849 -0
  6. package/desktop/main.js +315 -0
  7. package/package.json +163 -7
  8. package/src/account.js +591 -0
  9. package/src/admit-policy.js +32 -0
  10. package/src/agent-join.js +370 -0
  11. package/src/agent-task-workflow.js +176 -0
  12. package/src/agents/actions.js +85 -0
  13. package/src/agents/claude-code.js +187 -0
  14. package/src/agents/common.js +21 -0
  15. package/src/agents/cursor-transcripts.js +177 -0
  16. package/src/agents/cursor.js +404 -0
  17. package/src/agents/index.js +69 -0
  18. package/src/ai-vm-signin.js +110 -0
  19. package/src/ai-vm.js +522 -0
  20. package/src/api/access.js +75 -0
  21. package/src/api/activity.js +181 -0
  22. package/src/api/agent-auth.js +136 -0
  23. package/src/api/agent-placement.js +157 -0
  24. package/src/api/audit.js +83 -0
  25. package/src/api/auth.js +17 -0
  26. package/src/api/domains.js +30 -0
  27. package/src/api/file-paths.js +40 -0
  28. package/src/api/file-store.js +99 -0
  29. package/src/api/fly-machines.js +133 -0
  30. package/src/api/github-app.js +172 -0
  31. package/src/api/http.js +34 -0
  32. package/src/api/invite-email.js +53 -0
  33. package/src/api/issues.js +110 -0
  34. package/src/api/join-text.js +78 -0
  35. package/src/api/mailer.js +47 -0
  36. package/src/api/memory-store.js +1219 -0
  37. package/src/api/org-access.js +54 -0
  38. package/src/api/permissions.js +71 -0
  39. package/src/api/relay-cluster.js +545 -0
  40. package/src/api/relay-machines.js +92 -0
  41. package/src/api/releases.js +87 -0
  42. package/src/api/routes/access-types.js +54 -0
  43. package/src/api/routes/agent-invites.js +115 -0
  44. package/src/api/routes/agents.js +252 -0
  45. package/src/api/routes/ai-vm.js +495 -0
  46. package/src/api/routes/github.js +380 -0
  47. package/src/api/routes/grants.js +70 -0
  48. package/src/api/routes/invites.js +188 -0
  49. package/src/api/routes/issues.js +47 -0
  50. package/src/api/routes/join.js +167 -0
  51. package/src/api/routes/members.js +110 -0
  52. package/src/api/routes/orgs.js +179 -0
  53. package/src/api/routes/relay-cluster.js +90 -0
  54. package/src/api/routes/relay.js +76 -0
  55. package/src/api/routes/secrets.js +448 -0
  56. package/src/api/routes/session-invites.js +119 -0
  57. package/src/api/routes/sessions.js +92 -0
  58. package/src/api/routes/teams.js +132 -0
  59. package/src/api/routes/workspace-agents.js +332 -0
  60. package/src/api/routes/workspace-files.js +277 -0
  61. package/src/api/routes/workspace-invites.js +196 -0
  62. package/src/api/routes/workspaces.js +232 -0
  63. package/src/api/sandbox-keys.js +57 -0
  64. package/src/api/secrets-access.js +93 -0
  65. package/src/api/server.js +654 -0
  66. package/src/api/slugs.js +20 -0
  67. package/src/api/supabase-store.js +875 -0
  68. package/src/api/team-access.js +30 -0
  69. package/src/api/tokens.js +31 -0
  70. package/src/api/workspace-access.js +62 -0
  71. package/src/api/workspace-reach.js +60 -0
  72. package/src/autowork-tools.js +166 -0
  73. package/src/autowork.js +375 -0
  74. package/src/blobstore.js +99 -0
  75. package/src/boxes.js +37 -0
  76. package/src/branchdocs.js +235 -0
  77. package/src/branches.js +194 -0
  78. package/src/catchup.js +111 -0
  79. package/src/chat-archive.js +85 -0
  80. package/src/chat-links.js +923 -0
  81. package/src/clash.js +128 -0
  82. package/src/cloud-ai.js +50 -0
  83. package/src/commit-request.js +59 -0
  84. package/src/commit.js +213 -0
  85. package/src/connection.js +569 -0
  86. package/src/control.js +248 -0
  87. package/src/conversation.js +125 -0
  88. package/src/doctor.js +156 -0
  89. package/src/duties.js +303 -0
  90. package/src/editors.js +228 -0
  91. package/src/fsutil.js +97 -0
  92. package/src/git-hook.js +48 -0
  93. package/src/git.js +147 -0
  94. package/src/github-commit.js +320 -0
  95. package/src/gitignore.js +49 -0
  96. package/src/gitstate.js +924 -0
  97. package/src/history.js +307 -0
  98. package/src/hooks.js +371 -0
  99. package/src/hosted-agents.js +24 -0
  100. package/src/hosted-client.js +112 -0
  101. package/src/identity.js +99 -0
  102. package/src/inbox.js +237 -0
  103. package/src/integrations.js +221 -0
  104. package/src/key-cli.js +115 -0
  105. package/src/largefiles.js +91 -0
  106. package/src/legacy.js +25 -0
  107. package/src/mcp-proxy.js +45 -0
  108. package/src/mcp-room.js +32 -0
  109. package/src/mcp.js +1258 -0
  110. package/src/merge3.js +240 -0
  111. package/src/merges.js +148 -0
  112. package/src/pass-source.js +183 -0
  113. package/src/passes.js +64 -0
  114. package/src/pathrules.js +134 -0
  115. package/src/persona.js +101 -0
  116. package/src/presence.js +360 -0
  117. package/src/private-file.js +41 -0
  118. package/src/procs.js +70 -0
  119. package/src/project-rules.js +178 -0
  120. package/src/protocol.js +92 -0
  121. package/src/quiet-warnings.js +9 -0
  122. package/src/relay-ai-vm.js +82 -0
  123. package/src/relay-ask.js +141 -0
  124. package/src/relay-ci.js +111 -0
  125. package/src/relay-clash.js +214 -0
  126. package/src/relay-cluster.js +454 -0
  127. package/src/relay-commit.js +887 -0
  128. package/src/relay-filekey.js +46 -0
  129. package/src/relay-git.js +274 -0
  130. package/src/relay-githttp.js +166 -0
  131. package/src/relay-github.js +109 -0
  132. package/src/relay-lease.js +137 -0
  133. package/src/relay-lfs.js +37 -0
  134. package/src/relay-mcp.js +1691 -0
  135. package/src/relay-metrics.js +140 -0
  136. package/src/relay-outbound.js +1273 -0
  137. package/src/relay-sync.js +716 -0
  138. package/src/relay-turn-sync.js +506 -0
  139. package/src/relay-upstream.js +804 -0
  140. package/src/relay-webhooks.js +111 -0
  141. package/src/releases.js +138 -0
  142. package/src/report.js +129 -0
  143. package/src/runner.js +307 -0
  144. package/src/same-edit.js +18 -0
  145. package/src/secret-import.js +55 -0
  146. package/src/secret-names.js +26 -0
  147. package/src/secret-run.js +80 -0
  148. package/src/secrets-client.js +56 -0
  149. package/src/server.js +4214 -0
  150. package/src/session-access.js +170 -0
  151. package/src/session-name.js +15 -0
  152. package/src/session.js +7446 -0
  153. package/src/settings.js +46 -0
  154. package/src/setup.js +184 -0
  155. package/src/shell-path.js +46 -0
  156. package/src/status.js +197 -0
  157. package/src/summarize.js +109 -0
  158. package/src/sync-words.js +42 -0
  159. package/src/task-comments.js +92 -0
  160. package/src/tasks.js +670 -0
  161. package/src/textdiff.js +20 -0
  162. package/src/turn-sync-plan.js +145 -0
  163. package/src/turn-sync.js +212 -0
  164. package/src/ui/access-form.js +37 -0
  165. package/src/ui/agent-env-key.js +76 -0
  166. package/src/ui/agent-guide.js +127 -0
  167. package/src/ui/agent-kinds.js +175 -0
  168. package/src/ui/agent-place.js +265 -0
  169. package/src/ui/app.css +2494 -0
  170. package/src/ui/app.js +546 -0
  171. package/src/ui/board.js +465 -0
  172. package/src/ui/boxes.js +76 -0
  173. package/src/ui/branches.js +111 -0
  174. package/src/ui/catchup.js +77 -0
  175. package/src/ui/changes.js +159 -0
  176. package/src/ui/chat.js +266 -0
  177. package/src/ui/cloud-ai-model.js +108 -0
  178. package/src/ui/cloud-ai.js +221 -0
  179. package/src/ui/common.js +433 -0
  180. package/src/ui/csv.js +21 -0
  181. package/src/ui/diffview.js +74 -0
  182. package/src/ui/feed-convs.js +39 -0
  183. package/src/ui/feed.js +174 -0
  184. package/src/ui/files.js +579 -0
  185. package/src/ui/fileview.js +150 -0
  186. package/src/ui/git-rule.js +60 -0
  187. package/src/ui/home.js +1322 -0
  188. package/src/ui/index.html +16 -0
  189. package/src/ui/invite.js +97 -0
  190. package/src/ui/invites.js +79 -0
  191. package/src/ui/loom-model.js +516 -0
  192. package/src/ui/loom.css +365 -0
  193. package/src/ui/loom.js +786 -0
  194. package/src/ui/mark.js +104 -0
  195. package/src/ui/rail-model.js +75 -0
  196. package/src/ui/rail.js +173 -0
  197. package/src/ui/releases.js +182 -0
  198. package/src/ui/sandbox-keys-model.js +53 -0
  199. package/src/ui/sandbox-keys.js +132 -0
  200. package/src/ui/schedule.js +156 -0
  201. package/src/ui/secret-rules.js +255 -0
  202. package/src/ui/secrets-view.js +162 -0
  203. package/src/ui/secrets.js +283 -0
  204. package/src/ui/session.js +3292 -0
  205. package/src/ui/signin.js +75 -0
  206. package/src/ui/sounds.js +110 -0
  207. package/src/ui/tool-logo.js +86 -0
  208. package/src/ui/tree.js +281 -0
  209. package/src/ui/workspaces.js +547 -0
  210. package/src/ui-server.js +1381 -0
  211. package/src/update-check.js +67 -0
  212. package/src/upstream.js +192 -0
  213. package/src/vault/crypto.js +96 -0
  214. package/src/vault/kms.js +61 -0
  215. package/src/vault/main.js +33 -0
  216. package/src/vault/server.js +279 -0
  217. package/src/vault/sigv4.js +38 -0
  218. package/src/webhooks.js +235 -0
  219. package/src/workspace-cli.js +62 -0
  220. package/src/workspace-tools.js +340 -0
  221. package/index.js +0 -2
@@ -0,0 +1,134 @@
1
+ // Which paths are synced, and which are safe to accept from a peer. No Node
2
+ // APIs here: the web app uses the same rules as the CLI.
3
+ import ignore from 'ignore'
4
+
5
+ export const MAX_TEXT_BYTES = 2 * 1024 * 1024
6
+ export const MAX_BINARY_BYTES = 8 * 1024 * 1024
7
+ // Binary files this big or bigger are stored encrypted outside the session document.
8
+ export const LARGE_FILE_BYTES = 256 * 1024
9
+ // The most a stored file may be (the relay may allow less).
10
+ export const MAX_STORED_BINARY_BYTES = 100 * 1024 * 1024
11
+
12
+ /**
13
+ * Where each person's app saves the files sent in the session's chat (session.js): only those
14
+ * who can see the message get the file, so the folder is never synced, committed or pushed.
15
+ */
16
+ export const CHAT_FILES = 'Chat files'
17
+
18
+ // Never synced, regardless of .gitignore. .env files are excluded so secrets
19
+ // stay on each person's machine; Claude Code worktrees are whole private
20
+ // copies of the project.
21
+ export const ALWAYS_IGNORED = [
22
+ '.git', '.quilt', '.cowove', 'node_modules', '.DS_Store', 'Thumbs.db', '.claude/worktrees',
23
+ `/${CHAT_FILES}`,
24
+ // Each person's own Claude Code settings (permissions, Quilt's hooks) stay on their machine.
25
+ '.claude/settings.local.json',
26
+ // Build output and caches: large, machine-specific, and rebuilt by each person.
27
+ '.next', '.turbo', '.nuxt', '.svelte-kit', '.parcel-cache', '.vercel',
28
+ '.env', '.env.*', '!.env.example',
29
+ // A .quiltignore is each person's own list of what stays on their machine, so it stays there too.
30
+ '.quiltignore', '.cowoveignore',
31
+ '*.swp', '*.swo', '*~', '.#*'
32
+ ]
33
+
34
+ /** Files whose patterns say what isn't synced, in the folder they sit in. */
35
+ export const IGNORE_FILES = ['.gitignore', '.quiltignore', '.cowoveignore']
36
+
37
+ /**
38
+ * Rewrites an ignore file found in subfolder `dir` so its patterns apply from
39
+ * the project root, the way git scopes a nested .gitignore to its folder.
40
+ */
41
+ export function scopeIgnore (text, dir) {
42
+ if (!dir) return text
43
+ return text.split(/\r?\n/).map((line) => {
44
+ if (!line.trim() || line.startsWith('#')) return line
45
+ const neg = line.startsWith('!')
46
+ const body = neg ? line.slice(1) : line
47
+ // A slash anywhere but the end anchors the pattern to this folder.
48
+ const anchored = body.replace(/\/+\s*$/, '').includes('/')
49
+ const scoped = anchored ? `${dir}/${body.replace(/^\//, '')}` : `${dir}/**/${body}`
50
+ return (neg ? '!' : '') + scoped
51
+ }).join('\n')
52
+ }
53
+
54
+ /**
55
+ * An ignore matcher from the built-ins plus the text of .gitignore / .quiltignore
56
+ * files. The built-ins are kept apart from the project patterns, so a shared
57
+ * .gitignore saying "!.env" can't make anyone's secrets sync.
58
+ */
59
+ export function makeIgnore (texts = []) {
60
+ const builtin = ignore().add(ALWAYS_IGNORED)
61
+ const project = ignore()
62
+ for (const t of texts) if (t) project.add(t)
63
+ return {
64
+ add (text) { project.add(text); return this },
65
+ ignores (p) { return builtin.ignores(p) || project.ignores(p) }
66
+ }
67
+ }
68
+
69
+ export function isIgnored (ig, rel) {
70
+ if (!rel || rel === '.') return false
71
+ // Check every ancestor directory so "dist/" also excludes "dist/a/b.js".
72
+ const parts = rel.split('/')
73
+ for (let i = 1; i <= parts.length; i++) {
74
+ const sub = parts.slice(0, i).join('/')
75
+ if (ig.ignores(sub)) return true
76
+ if (i < parts.length && ig.ignores(sub + '/')) return true
77
+ }
78
+ return false
79
+ }
80
+
81
+ // Names Windows reserves for devices, with any extension: a member there could never write them.
82
+ const WINDOWS_DEVICE = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(\..*)?$/i
83
+ const utf8Length = (s) => new TextEncoder().encode(s).length
84
+
85
+ /**
86
+ * Validates a path received from a peer. Rejects anything that could escape
87
+ * the project folder or touch git internals, and names that some member's
88
+ * disk could not hold (a change nobody can write is a change nobody should
89
+ * share).
90
+ */
91
+ export function isSafeRelPath (rel) {
92
+ if (typeof rel !== 'string' || !rel || rel.length > 1024) return false
93
+ if (rel.includes('\\') || rel.includes('\0')) return false
94
+ if (rel.startsWith('/') || /^[A-Za-z]:/.test(rel)) return false
95
+ const parts = rel.split('/')
96
+ for (const part of parts) {
97
+ if (part === '' || part === '.' || part === '..') return false
98
+ if (WINDOWS_DEVICE.test(part) || part.endsWith('.') || part.endsWith(' ')) return false
99
+ if (part.length > 255 || utf8Length(part) > 255) return false
100
+ }
101
+ const first = parts[0].toLowerCase()
102
+ if (first === '.git' || first === '.quilt' || first === '.cowove') return false
103
+ return true
104
+ }
105
+
106
+ /** Whether two claim patterns can cover the same file (paths: files that exist now). */
107
+ export function patternsOverlap (a, b, paths = []) {
108
+ if (a === b) return true
109
+ const ma = globMatcher(a)
110
+ const mb = globMatcher(b)
111
+ return ma(b) || mb(a) || paths.some((p) => ma(p) && mb(p))
112
+ }
113
+
114
+ /** Converts a simple glob (*, **, ?) or a plain path/folder into a matcher. */
115
+ export function globMatcher (pattern) {
116
+ let p = pattern.trim().replace(/^\.\//, '')
117
+ if (!/[*?]/.test(p)) {
118
+ const base = p.replace(/\/+$/, '')
119
+ return (rel) => rel === base || rel.startsWith(base + '/')
120
+ }
121
+ let re = ''
122
+ for (let i = 0; i < p.length; i++) {
123
+ const c = p[i]
124
+ if (c === '*' && p[i + 1] === '*') {
125
+ re += '.*'
126
+ i++
127
+ if (p[i + 1] === '/') i++
128
+ } else if (c === '*') re += '[^/]*'
129
+ else if (c === '?') re += '[^/]'
130
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
131
+ }
132
+ const rx = new RegExp(`^${re}$`)
133
+ return (rel) => rx.test(rel)
134
+ }
package/src/persona.js ADDED
@@ -0,0 +1,101 @@
1
+ // AI sessions as their own members. Several AI sessions often work through one person's
2
+ // app at once (Claude Code in one window, Cursor in another, two Claude Code chats). Each
3
+ // gets a name of its own, after what it works on, so partners can tell them apart, message
4
+ // the right one, and see whose claims are whose: "Daniel · file-queue", "Daniel · hosted costs".
5
+ //
6
+ // The name is the person's first name and a label. The label comes from the session's git
7
+ // branch when it works on one (a branch names the work and doesn't change), otherwise from
8
+ // the first thing it says it is doing (its focus, what it shares, the task it takes), and
9
+ // the agent can rename itself. Until then it is named after its tool.
10
+ import { execFileSync } from 'node:child_process'
11
+
12
+ export const PERSONA_SEP = ' · '
13
+ const MAX_LABEL = 32
14
+
15
+ /** The person's first name: "Daniel Carmichael" -> "Daniel". */
16
+ export const firstName = (person) => String(person || '').trim().split(/\s+/)[0] || 'AI'
17
+
18
+ /**
19
+ * A label as it may appear in a name: one line, no separator or @, at most 32 characters
20
+ * (cut at a word). '' when nothing is left.
21
+ */
22
+ export function cleanLabel (label) {
23
+ let s = String(label || '').replace(/[\r\n\t]+/g, ' ').replace(/[@·]/g, ' ').replace(/[`"'<>]/g, '').replace(/\s+/g, ' ').trim()
24
+ if (s.length > MAX_LABEL) {
25
+ const cut = s.slice(0, MAX_LABEL + 1)
26
+ s = (cut.lastIndexOf(' ') > 8 ? cut.slice(0, cut.lastIndexOf(' ')) : cut.slice(0, MAX_LABEL)).trim()
27
+ }
28
+ return s.replace(/[\s.,;:!?-]+$/, '')
29
+ }
30
+
31
+ /** "Daniel · file-queue". */
32
+ export const personaName = (person, label) => `${firstName(person)}${PERSONA_SEP}${cleanLabel(label) || 'AI'}`
33
+
34
+ const PLAIN_BRANCHES = new Set(['main', 'master', 'trunk', 'develop', 'dev', 'head', ''])
35
+
36
+ /**
37
+ * A label from a git branch, or '' for one that says nothing about the work (main, master,
38
+ * a detached HEAD). Tool prefixes ("claude/", "cursor/", "feature/") are dropped.
39
+ */
40
+ export function labelFromBranch (branch) {
41
+ const b = String(branch || '').trim()
42
+ if (PLAIN_BRANCHES.has(b.toLowerCase())) return ''
43
+ const last = b.split('/').filter(Boolean).pop() || ''
44
+ return cleanLabel(last)
45
+ }
46
+
47
+ /** The git branch checked out in `dir`, or '' (not a repository, or git missing). */
48
+ export function gitBranch (dir) {
49
+ try {
50
+ // symbolic-ref also answers on a branch with no commits yet; a detached HEAD has no branch.
51
+ return execFileSync('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: dir, stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).toString().trim()
52
+ } catch { return '' }
53
+ }
54
+
55
+ const FILLER = new Set(['i', 'im', 'i\'m', 'am', 'is', 'are', 'was', 'the', 'a', 'an', 'to', 'for', 'on', 'of', 'and', 'or', 'my', 'our', 'me', 'now', 'just', 'currently', 'working', 'work', 'going', 'will', 'be', 'about', 'with', 'in', 'this', 'that', 'these', 'it', 'its', 'please', 'can', 'could', 'would', 'should', 'you', 'your', 'we', 'let', 'lets', 'let\'s', 'start', 'starting', 'why', 'what', 'how', 'when', 'where', 'there', 'some', 'so', 'do', 'does', 'did', 'have', 'has', 'hey', 'hi', 'ok', 'okay', 'yes', 'no', 'all', 'also', 'into', 'at', 'from', 'by', 'up', 'out', 'make', 'sure', 'want', 'need', 'like', 'use', 'using', 'thanks', 'thank', 'their', 'his', 'her', 'them', 'they', 'he', 'she', 'if', 'then', 'but', 'than', 'which', 'who', 'while', 'very', 'really', 'something', 'anything', 'everything', 'thing', 'things'])
56
+
57
+ // An opener that tells what happened before the ask, not what the session does ("I just had gpt
58
+ // add some info and double check your plan", "we tried the fix and it broke"): the label comes
59
+ // from the first clause after it.
60
+ const NARRATION = new Set(['had', 'have', 'has', 'was', 'were', 'did', 'got', 'asked', 'told', 'made', 'tried', 'ran', 'saw', 'noticed', 'found', 'used', 'wanted', 'went', 'gave', 'sent', 'said', 'says', 'thought', 'think', 'thinking', 'added', 'wrote', 'changed', 'pulled', 'pushed', 'deployed', 'merged', 'been', 'being', 'already', 'earlier', 'yesterday', 'before', 'previously', 'since', 'remember'])
61
+ // Another AI or tool the opener names ("had gpt add", "asked claude to"): not this session's work.
62
+ const TOOL_NAMES = /^(?:(?:chat)?gpt[-\w.]*|claude|codex|cursor|gemini|grok|copilot|openai|anthropic|ai|bot|agent|assistant|llm|model|o[1-9](?:-\w+)?)$/i
63
+ // Where one clause ends and the next begins.
64
+ const CLAUSE = /[.!?;:\n]+|,\s+|\s+(?:and|then|but|so|now)\s+/i
65
+
66
+ /** The words of `text` that carry meaning, in order, lower case. */
67
+ function meaningfulWords (text) {
68
+ return String(text || '').split(/\s+/).filter((w) => w && !/^(https?:|\/|~|<)/.test(w))
69
+ .map((w) => w.replace(/[^\p{L}\p{N}'-]/gu, '').replace(/^['-]+|['-]+$/g, '')).filter(Boolean)
70
+ .filter((w) => !FILLER.has(w.toLowerCase()) && w.length <= 24).map((w) => w.toLowerCase())
71
+ }
72
+
73
+ /**
74
+ * A label from what a session says it is doing (or was asked): its first few words that carry
75
+ * meaning, after any opener about what happened before ("I just had gpt add some info and
76
+ * double check your plan" -> "double check plan").
77
+ */
78
+ export function labelFromText (text) {
79
+ const clauses = String(text || '').split(CLAUSE).map((c) => c.trim()).filter(Boolean)
80
+ const words = clauses.map(meaningfulWords)
81
+ let from = words.findIndex((w) => w.length && !NARRATION.has(w[0]))
82
+ let first
83
+ if (from >= 0) first = words[from]
84
+ else {
85
+ // Only narration: what was done, without the opener and the tool it names.
86
+ from = words.findIndex((w) => w.length)
87
+ if (from < 0) return ''
88
+ first = words[from].slice(1)
89
+ if (first.length && TOOL_NAMES.test(first[0])) first = first.slice(1)
90
+ }
91
+ return cleanLabel([...first, ...words.slice(from + 1).flat()].slice(0, 3).join(' '))
92
+ }
93
+
94
+ /** A label from the first file a session edits: its name without the extension ("session.js" -> "session"). */
95
+ export function labelFromFile (rel) {
96
+ const base = String(rel || '').split('/').filter(Boolean).pop() || ''
97
+ return cleanLabel(base.replace(/\.[A-Za-z0-9]+$/, ''))
98
+ }
99
+
100
+ // "Daniel's AI": all of a person's AI sessions as one name (pure, shared with the app).
101
+ export { AI_SUFFIX, aiName, foldPersonas } from './ui/chat.js'
@@ -0,0 +1,360 @@
1
+ // Presence reports: the relay tells the accounts API who is in which session, and
2
+ // when, so people's dashboards can show their sessions and who they worked with.
3
+ // Only for connections with a pass (an account), and only when the relay has both
4
+ // QUILT_API_URL and RELAY_API_SECRET. Events wait in a queue that is kept on disk
5
+ // (`<dataDir>/presence-queue.jsonl`), so a restart or a down API loses nothing, and
6
+ // are sent every minute, at most 500 per request, in order.
7
+ //
8
+ // For the audit trail, a visit says how the member came in (`via`: the app or a hosted
9
+ // agent, and its `tool`), why it ended (`reason`, see END_REASONS), and what it did
10
+ // meanwhile (`act` events: an action and its target, a path or a task id; never file
11
+ // contents or chat text). The same action on the same target is reported at most once
12
+ // a minute per visit.
13
+ import fs from 'node:fs'
14
+ import crypto from 'node:crypto'
15
+
16
+ export const PRESENCE_FILE = 'presence-queue.jsonl'
17
+ export const PRESENCE_FLUSH_MS = 60 * 1000
18
+ export const PRESENCE_BATCH = 500
19
+ export const PRESENCE_MAX_QUEUE = 100_000
20
+ export const PRESENCE_MAX_BACKOFF_MS = 10 * 60 * 1000
21
+ // A full rewrite of the queue file is O(queue size): worth doing right after load() or
22
+ // close(), but not on every drop or send while the relay is under load.
23
+ export const PRESENCE_REWRITE_MS = 60 * 1000
24
+ const SYNC_MS = 1000
25
+ export const ACT_COALESCE_MS = 60 * 1000
26
+ /** Why a visit ended. The accounts API turns an agent's `pass_expired` or `idle` into `revoked` when its keys were revoked first. */
27
+ export const END_REASONS = ['left', 'disconnected', 'removed', 'pass_expired', 'session_ended', 'relay_restart', 'idle', 'replaced', 'needs_update']
28
+ export const ACTIONS = ['created', 'edited', 'deleted', 'claimed', 'released', 'requested', 'handed_off', 'withdrew', 'messaged', 'task', 'tool']
29
+ const MAX_TARGET = 300
30
+ const SEND_TIMEOUT_MS = 15 * 1000
31
+ const CLOSE_TIMEOUT_MS = 5 * 1000
32
+
33
+ export class PresenceReporter {
34
+ /**
35
+ * @param {object} o
36
+ * @param {string} o.apiUrl the accounts API, e.g. https://api.heyquilt.com
37
+ * @param {string} o.secret RELAY_API_SECRET. Never logged.
38
+ * @param {string|null} [o.file] the queue file; null keeps the queue in memory only
39
+ */
40
+ constructor ({ apiUrl, secret, file = null, log = () => {}, now = Date.now, fetch = globalThis.fetch, flushMs = PRESENCE_FLUSH_MS, batch = PRESENCE_BATCH, maxQueue = PRESENCE_MAX_QUEUE, maxBackoffMs = PRESENCE_MAX_BACKOFF_MS, syncMs = SYNC_MS, timeoutMs = SEND_TIMEOUT_MS, rewriteMs = PRESENCE_REWRITE_MS, closeTimeoutMs = CLOSE_TIMEOUT_MS }) {
41
+ this.url = `${String(apiUrl).replace(/\/+$/, '')}/v1/relay/presence`
42
+ this.secret = secret
43
+ this.file = file
44
+ this.log = log
45
+ this.now = now
46
+ this.fetch = fetch
47
+ this.flushMs = flushMs
48
+ this.batch = batch
49
+ this.maxQueue = maxQueue
50
+ this.maxBackoffMs = maxBackoffMs
51
+ this.syncMs = syncMs
52
+ this.timeoutMs = timeoutMs
53
+ this.rewriteMs = rewriteMs
54
+ this.closeTimeoutMs = closeTimeoutMs
55
+ this.queue = [] // { seq, ev, startSeq? }, oldest first; an `end` notes its `start`'s seq (0: sent by an earlier run)
56
+ this.seq = 0
57
+ this.sentSeq = 0 // the highest seq the accounts API has taken
58
+ this.inFlight = 0 // the highest seq in the request being sent; 0 when nothing is
59
+ this.open = new Map() // start event id -> { start, room, account }: visits not ended yet
60
+ this.startSeqs = new Map() // start event id -> its seq, for visits not ended yet
61
+ this.unwritten = [] // queue lines not yet appended to the file
62
+ this.rewrite = false // the file no longer matches the queue: write it whole next time
63
+ this.lastRewriteAt = -Infinity // so the first rewrite (from load(), or the first drop) is never throttled
64
+ this.urgent = false // a rewrite a successful send is waiting on: never throttled
65
+ this.full = false // logged once per overflow episode; cleared once the queue drains below the cap
66
+ this.saveFailing = false // logged once per failure episode; cleared by the next save that works
67
+ this.failures = 0
68
+ this.retryAt = 0
69
+ this.sending = null
70
+ this.closed = false
71
+ this.timers = []
72
+ this.acted = new Map() // start event id -> Map(action|target -> when last reported), for visits not ended yet
73
+ }
74
+
75
+ /**
76
+ * Reads the queue file a previous run left, and ends at "now" every visit it had
77
+ * started and not ended (the relay crashed, or was redeployed). Returns how many.
78
+ */
79
+ load () {
80
+ if (!this.file) return 0
81
+ let text = ''
82
+ try { text = fs.readFileSync(this.file, 'utf8') } catch (err) { if (err.code !== 'ENOENT') this.log(`presence: could not read ${this.file}: ${err.message}`); return 0 }
83
+ let bad = 0
84
+ const seqs = new Map() // start event id -> seq, for the starts in the file
85
+ const lastName = new Map() // room -> seq of its newest name in the file
86
+ for (const line of text.split('\n')) {
87
+ if (!line.trim()) continue
88
+ let row
89
+ try { row = JSON.parse(line) } catch { bad++; continue } // a line cut off by a crash
90
+ // JSON.parse accepts bare null/numbers/strings too: never trust the shape before checking it.
91
+ if (!row || typeof row !== 'object') { bad++; continue }
92
+ if (Array.isArray(row.open)) {
93
+ for (const v of row.open) if (v && v.start) this.open.set(v.start, v)
94
+ continue
95
+ }
96
+ if (!row.id || !row.type) { bad++; continue }
97
+ const seq = ++this.seq
98
+ if (row.type === 'start') {
99
+ this.open.set(row.id, { start: row.id, room: row.room, account: row.account })
100
+ seqs.set(row.id, seq)
101
+ }
102
+ if (row.type === 'end') this.open.delete(row.start)
103
+ if (row.type === 'name') lastName.set(row.room, seq)
104
+ // A start this file doesn't hold was sent by an earlier run: 0 marks that.
105
+ this.queue.push(row.type === 'end' ? { seq, ev: row, startSeq: seqs.get(row.start) || 0 } : { seq, ev: row })
106
+ }
107
+ // A room's older names (from saves between the queue's rewrites) would only be overwritten.
108
+ this.queue = this.queue.filter((x) => x.ev.type !== 'name' || lastName.get(x.ev.room) === x.seq)
109
+ for (const id of this.open.keys()) if (seqs.has(id)) this.startSeqs.set(id, seqs.get(id))
110
+ if (bad) this.log(`presence: skipped ${bad} unreadable line(s) in ${this.file}`)
111
+ const ended = this.open.size
112
+ this.endAll('relay_restart')
113
+ this.rewrite = true
114
+ this.persist(true) // startup: write the cleaned-up state now, not whenever the throttle allows
115
+ return ended
116
+ }
117
+
118
+ /** Sends every minute (sooner retries wait for their backoff) and saves the queue every second. */
119
+ start () {
120
+ const tick = setInterval(() => { this.tick().catch(() => {}) }, this.flushMs)
121
+ const sync = setInterval(() => this.persist(), this.syncMs)
122
+ tick.unref()
123
+ sync.unref()
124
+ this.timers.push(tick, sync)
125
+ }
126
+
127
+ get size () { return this.queue.length }
128
+
129
+ /** Someone with a pass was let into a room. Returns the visit, for visitEnd. */
130
+ visitStart ({ room, account, name, owner = false, via = '', tool = '' }) {
131
+ if (this.closed) return null
132
+ const ev = { id: crypto.randomUUID(), type: 'start', room, account, name, ...(owner ? { owner: true } : {}), ...(via ? { via } : {}), ...(tool ? { tool: String(tool).slice(0, 40) } : {}), at: this.now() }
133
+ const visit = { start: ev.id, room, account }
134
+ this.open.set(ev.id, visit)
135
+ this.startSeqs.set(ev.id, this.enqueue(ev))
136
+ return visit
137
+ }
138
+
139
+ /** That connection left (closed, removed, ended, or its pass lapsed), for `reason`; `at` defaults to now. Once per visit. */
140
+ visitEnd (visit, reason = 'left', at) {
141
+ if (this.closed || !visit || !this.open.delete(visit.start)) return
142
+ this.enqueueEnd(visit, reason, at)
143
+ }
144
+
145
+ /**
146
+ * Someone in a visit did something: `action` (one of ACTIONS) on `target` (a path, a
147
+ * task id or a tool name). Repeats of the same action on the same target within a
148
+ * minute are not reported again.
149
+ */
150
+ act (visit, action, target = '') {
151
+ if (this.closed || !visit || !this.open.has(visit.start) || !ACTIONS.includes(action)) return
152
+ const t = String(target || '').slice(0, MAX_TARGET)
153
+ const at = this.now()
154
+ let seen = this.acted.get(visit.start)
155
+ if (!seen) this.acted.set(visit.start, (seen = new Map()))
156
+ const k = `${action}|${t}`
157
+ if (at - (seen.get(k) ?? -Infinity) < ACT_COALESCE_MS) return
158
+ seen.set(k, at)
159
+ if (seen.size > 1000) for (const [key, when] of seen) if (at - when >= ACT_COALESCE_MS) seen.delete(key)
160
+ this.enqueue({ id: crypto.randomUUID(), type: 'act', start: visit.start, room: visit.room, account: visit.account, action, ...(t ? { target: t } : {}), at })
161
+ }
162
+
163
+ /**
164
+ * The owner named the session. Only the newest name matters, so a room keeps at most
165
+ * one waiting: a rename replaces the one queued before it (unless that one is already
166
+ * on its way to the API), and renames can never pile up in the queue.
167
+ */
168
+ rename ({ room, name }) {
169
+ if (this.closed) return
170
+ const i = this.queue.findIndex((x) => x.ev.type === 'name' && x.ev.room === room && x.seq > this.inFlight)
171
+ if (i >= 0) {
172
+ this.queue.splice(i, 1)
173
+ this.rewrite = true // the replaced line is still in the file
174
+ }
175
+ this.enqueue({ id: crypto.randomUUID(), type: 'name', room, name, at: this.now() })
176
+ }
177
+
178
+ /** Ends every open visit now: on shutdown, and for visits a previous run left open. */
179
+ endAll (reason = 'relay_restart') {
180
+ for (const visit of [...this.open.values()]) {
181
+ this.open.delete(visit.start)
182
+ this.enqueueEnd(visit, reason)
183
+ }
184
+ }
185
+
186
+ enqueueEnd (visit, reason, at) {
187
+ const startSeq = this.startSeqs.get(visit.start) || 0
188
+ this.startSeqs.delete(visit.start)
189
+ this.acted.delete(visit.start)
190
+ const when = Number.isFinite(at) ? Math.min(at, this.now()) : this.now()
191
+ this.enqueue({ id: crypto.randomUUID(), type: 'end', start: visit.start, room: visit.room, account: visit.account, ...(END_REASONS.includes(reason) ? { reason } : {}), at: when }, startSeq)
192
+ }
193
+
194
+ /** Returns the event's seq. */
195
+ enqueue (ev, startSeq) {
196
+ const seq = ++this.seq
197
+ this.queue.push(startSeq === undefined ? { seq, ev } : { seq, ev, startSeq })
198
+ this.unwritten.push(ev)
199
+ if (this.queue.length > this.maxQueue) this.dropOldest()
200
+ return seq
201
+ }
202
+
203
+ /**
204
+ * Drops the oldest tenth of the cap in one go, not one event at a time: at one per
205
+ * event, a relay that's constantly over the cap would rewrite the whole queue file
206
+ * and log a line on every single enqueue. Prefers dropping the oldest `start`
207
+ * together with its matching `end` (if that `end` is already queued too): the pair
208
+ * is a visit that's over and done with, the least useful thing to still be holding.
209
+ * A `name` is never dropped: renames are rare, and since the `name` op is now
210
+ * idempotent (a repeat is never re-queued), a dropped one is gone for good. If every
211
+ * `start` has already been paired off or dropped and more still needs to go, the
212
+ * oldest unmatched `end`s go next (an `end` whose `start` was dropped is harmless: the
213
+ * accounts API ignores it). Never dropped: anything in the request being sent (it
214
+ * reaches the API anyway), and an `end` whose `start` was sent or is being sent (the
215
+ * visit would stay open on the dashboard forever). One `filter` pass over the queue,
216
+ * never a per-element shift: a drop must stay cheap even at ~100,000 events.
217
+ */
218
+ dropOldest () {
219
+ const sent = Math.max(this.sentSeq, this.inFlight)
220
+ const drop = Math.max(this.queue.length - this.maxQueue, Math.ceil(this.maxQueue / 10))
221
+ const endIndexByStart = new Map() // start event id -> index of its `end`, if queued
222
+ this.queue.forEach((x, i) => { if (x.ev.type === 'end') endIndexByStart.set(x.ev.start, i) })
223
+ const remove = new Set()
224
+ let dropped = 0
225
+ // What visits did is the most plentiful and the least load-bearing: the oldest of it goes first.
226
+ for (let i = 0; i < this.queue.length && dropped < drop; i++) {
227
+ const x = this.queue[i]
228
+ if (x.ev.type === 'act' && x.seq > this.inFlight) { remove.add(i); dropped++ }
229
+ }
230
+ for (let i = 0; i < this.queue.length && dropped < drop; i++) {
231
+ const x = this.queue[i]
232
+ if (x.ev.type !== 'start' || x.seq <= this.inFlight || remove.has(i)) continue
233
+ remove.add(i); dropped++
234
+ const endIndex = endIndexByStart.get(x.ev.id)
235
+ if (endIndex !== undefined && !remove.has(endIndex)) { remove.add(endIndex); dropped++ }
236
+ }
237
+ // Nothing left to pair off a `start` with: the oldest leftover `end`s are next.
238
+ for (let i = 0; i < this.queue.length && dropped < drop; i++) {
239
+ const x = this.queue[i]
240
+ if (x.ev.type === 'end' && x.seq > this.inFlight && x.startSeq > sent && !remove.has(i)) { remove.add(i); dropped++ }
241
+ }
242
+ if (remove.size) this.queue = this.queue.filter((_, i) => !remove.has(i))
243
+ this.rewrite = true
244
+ if (!this.full) {
245
+ this.full = true
246
+ this.log(`presence: the queue is full (${this.maxQueue} events); dropped ${dropped} oldest event(s)`)
247
+ }
248
+ }
249
+
250
+ /** Once the queue has drained back under the cap, the next overflow logs again. */
251
+ checkDrained () {
252
+ if (this.full && this.queue.length < this.maxQueue) this.full = false
253
+ }
254
+
255
+ /**
256
+ * Saves what's new in the queue: appended and fsynced, or the whole file when it has
257
+ * to be (dropped or sent events leave the file out of sync with the queue, since
258
+ * appending can only add lines, never remove them). A whole-file rewrite is O(queue
259
+ * size): a drop's rewrite can wait up to `rewriteMs` (events keep being appended
260
+ * meanwhile, so nothing is lost), but one a successful send asked for (`urgent`) never
261
+ * waits, or a restart could re-read and re-send events the accounts API already has.
262
+ */
263
+ persist (force = false) {
264
+ if (!this.file) return
265
+ const rewriteNow = this.rewrite && (force || this.urgent || this.now() - this.lastRewriteAt >= this.rewriteMs)
266
+ if (!rewriteNow && !this.unwritten.length) return
267
+ try {
268
+ if (rewriteNow) {
269
+ // Written whole, then renamed: a crash mid-write leaves the old file, never half of one.
270
+ const tmp = `${this.file}.tmp`
271
+ const lines = [JSON.stringify({ open: [...this.open.values()] }), ...this.queue.map((x) => JSON.stringify(x.ev))]
272
+ const fd = fs.openSync(tmp, 'w')
273
+ try { fs.writeSync(fd, lines.join('\n') + '\n'); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
274
+ fs.renameSync(tmp, this.file)
275
+ this.rewrite = false
276
+ this.urgent = false
277
+ this.lastRewriteAt = this.now()
278
+ this.unwritten = [] // the rewrite above already includes every queued event
279
+ } else if (this.unwritten.length) {
280
+ const fd = fs.openSync(this.file, 'a')
281
+ try { fs.writeSync(fd, this.unwritten.map((ev) => JSON.stringify(ev)).join('\n') + '\n'); fs.fsyncSync(fd) } finally { fs.closeSync(fd) }
282
+ this.unwritten = []
283
+ }
284
+ this.saveFailing = false
285
+ } catch (err) {
286
+ // An append that failed may have left part of a line: the next save that works
287
+ // writes the whole file, which has every queued event, so nothing waits to be appended.
288
+ this.rewrite = true
289
+ this.unwritten = []
290
+ if (!this.saveFailing) this.log(`presence: could not save the queue: ${err.message}`)
291
+ this.saveFailing = true
292
+ }
293
+ }
294
+
295
+ /** Sends now unless a failure's backoff hasn't run out. */
296
+ async tick () {
297
+ if (this.now() < this.retryAt) return false
298
+ return this.flush()
299
+ }
300
+
301
+ /**
302
+ * Sends everything queued, oldest first, in requests of at most 500. Resolves true
303
+ * when the queue is empty, false after a failure (the events stay for the retry).
304
+ */
305
+ flush () {
306
+ if (!this.sending) this.sending = this.send().finally(() => { this.sending = null })
307
+ return this.sending
308
+ }
309
+
310
+ async send () {
311
+ let sent = false
312
+ try {
313
+ while (this.queue.length) {
314
+ const chunk = this.queue.slice(0, this.batch)
315
+ this.inFlight = chunk[chunk.length - 1].seq
316
+ const res = await this.fetch(this.url, {
317
+ method: 'POST',
318
+ headers: { 'content-type': 'application/json', authorization: `Bearer ${this.secret}` },
319
+ body: JSON.stringify({ events: chunk.map((x) => x.ev) }),
320
+ signal: AbortSignal.timeout(this.timeoutMs)
321
+ })
322
+ if (!res.ok) throw new Error(`the accounts API answered ${res.status}`)
323
+ // By sequence number: the queue may have dropped its oldest while this was in flight.
324
+ const last = this.inFlight
325
+ this.sentSeq = last
326
+ this.inFlight = 0
327
+ const i = this.queue.findIndex((x) => x.seq > last)
328
+ this.queue = i < 0 ? [] : this.queue.slice(i)
329
+ this.checkDrained()
330
+ sent = true
331
+ }
332
+ this.failures = 0
333
+ this.retryAt = 0
334
+ return true
335
+ } catch (err) {
336
+ this.failures++
337
+ const wait = Math.min(this.flushMs * 2 ** (this.failures - 1), this.maxBackoffMs)
338
+ this.retryAt = this.now() + wait
339
+ this.log(`presence: could not report to the accounts API (${err.cause?.code || err.message}); trying again in ${Math.round(wait / 1000)} s`)
340
+ return false
341
+ } finally {
342
+ this.inFlight = 0
343
+ if (sent) { this.rewrite = true; this.urgent = true; this.persist() }
344
+ }
345
+ }
346
+
347
+ /** On shutdown: ends every open visit, stops the timers, saves the queue, and tries one last send (bounded by closeTimeoutMs). */
348
+ async close () {
349
+ if (this.closed) return
350
+ for (const t of this.timers) clearInterval(t)
351
+ this.timers = []
352
+ this.endAll('relay_restart')
353
+ this.persist(true)
354
+ this.closed = true
355
+ let timer
356
+ await Promise.race([this.flush(), new Promise((resolve) => { timer = setTimeout(resolve, this.closeTimeoutMs) })])
357
+ clearTimeout(timer)
358
+ this.persist(true)
359
+ }
360
+ }
@@ -0,0 +1,41 @@
1
+ // Files only you should read (this computer's sign-in, agents' keys): written
2
+ // atomically with mode 0600, and never through a symlink.
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import crypto from 'node:crypto'
6
+
7
+ /** True if `file` is a symlink (a missing file isn't). */
8
+ export function isSymlink (file) {
9
+ try {
10
+ return fs.lstatSync(file).isSymbolicLink()
11
+ } catch (err) {
12
+ if (err.code === 'ENOENT') return false
13
+ throw err
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Writes JSON to `file`: a private temp file in the same folder, fsynced, then
19
+ * renamed over the target. The rename replaces whatever is there without
20
+ * following it, and a half-written file is never seen at the real path.
21
+ */
22
+ export function writePrivateJson (file, data) {
23
+ if (isSymlink(file)) throw new Error(`Refusing to write ${file}: it's a symlink`)
24
+ const tmp = path.join(path.dirname(file), `.${path.basename(file)}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`)
25
+ const fd = fs.openSync(tmp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL, 0o600)
26
+ try {
27
+ fs.writeSync(fd, JSON.stringify(data, null, 2))
28
+ fs.fsyncSync(fd)
29
+ } catch (err) {
30
+ fs.closeSync(fd)
31
+ try { fs.unlinkSync(tmp) } catch {}
32
+ throw err
33
+ }
34
+ fs.closeSync(fd)
35
+ try {
36
+ fs.renameSync(tmp, file)
37
+ } catch (err) {
38
+ try { fs.unlinkSync(tmp) } catch {}
39
+ throw err
40
+ }
41
+ }