heyquilt 0.3.121 → 0.3.138

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 (129) hide show
  1. package/RELEASES.md +292 -17
  2. package/bin/quilt.js +72 -5
  3. package/desktop/main.js +16 -2
  4. package/package.json +3 -1
  5. package/src/agent-task-workflow.js +2 -2
  6. package/src/agents/common.js +13 -0
  7. package/src/ai-vm.js +9 -1
  8. package/src/api/activity.js +5 -0
  9. package/src/api/memory-store.js +21 -10
  10. package/src/api/notify.js +134 -0
  11. package/src/api/routes/browsers.js +29 -0
  12. package/src/api/routes/github.js +0 -13
  13. package/src/api/routes/relay.js +26 -3
  14. package/src/api/routes/seam-connect.js +103 -0
  15. package/src/api/routes/session-migrate.js +39 -0
  16. package/src/api/routes/sessions.js +73 -1
  17. package/src/api/server.js +46 -12
  18. package/src/api/supabase-store.js +53 -4
  19. package/src/autowork.js +182 -16
  20. package/src/connection.js +39 -9
  21. package/src/control.js +139 -13
  22. package/src/force-date.js +27 -0
  23. package/src/fsutil.js +13 -3
  24. package/src/gitstate.js +38 -2
  25. package/src/history.js +25 -1
  26. package/src/mcp.js +93 -7
  27. package/src/merge3.js +11 -3
  28. package/src/now-summary.js +113 -0
  29. package/src/pathrules.js +12 -0
  30. package/src/protocol.js +10 -2
  31. package/src/quilt-default.js +84 -0
  32. package/src/quilt-history.js +106 -0
  33. package/src/quilt-remote.js +78 -0
  34. package/src/relay-commit.js +5 -0
  35. package/src/relay-lease.js +6 -2
  36. package/src/relay-mcp.js +172 -8
  37. package/src/relay-outbound.js +1 -1
  38. package/src/relay-prune.js +4 -1
  39. package/src/release-words.js +2 -2
  40. package/src/runner.js +20 -7
  41. package/src/seam-access.js +97 -0
  42. package/src/seam-claims.js +53 -0
  43. package/src/seam-url.js +15 -0
  44. package/src/server.js +1767 -33
  45. package/src/session-follow.js +515 -0
  46. package/src/session-layout3.js +466 -0
  47. package/src/session-migrate.js +123 -0
  48. package/src/session-quilt.js +145 -0
  49. package/src/session-seam.js +284 -0
  50. package/src/session.js +899 -46
  51. package/src/source-live/bring-in.js +283 -0
  52. package/src/source-live/live-block.js +507 -35
  53. package/src/source-live/live-square.js +17 -6
  54. package/src/status.js +5 -0
  55. package/src/ui/agent-guide.js +1 -1
  56. package/src/ui/app.css +85 -20
  57. package/src/ui/app.js +4 -1
  58. package/src/ui/chat.js +9 -3
  59. package/src/ui/cloud-ai-model.js +13 -0
  60. package/src/ui/cloud-ai.js +10 -2
  61. package/src/ui/common.js +6 -2
  62. package/src/ui/fileview.js +20 -7
  63. package/src/ui/force-words.js +19 -0
  64. package/src/ui/git-rule.js +9 -0
  65. package/src/ui/home.js +0 -147
  66. package/src/ui/loom-model.js +15 -1
  67. package/src/ui/quiltview.js +213 -0
  68. package/src/ui/seam-model.js +251 -0
  69. package/src/ui/session.js +413 -31
  70. package/src/ui/voice-call.js +83 -14
  71. package/src/ui/voice-model.js +39 -0
  72. package/src/ui/voice.js +167 -17
  73. package/src/ui-server.js +80 -28
  74. package/src/update-check.js +25 -1
  75. package/src/voice.js +13 -6
  76. package/src/git.js +0 -147
  77. package/src/quilts/AGENTS.md +0 -1
  78. package/src/quilts/CLAUDE.md +0 -1
  79. package/src/quilts/archiver.js +0 -175
  80. package/src/quilts/autorevert.js +0 -49
  81. package/src/quilts/bisect-run.js +0 -31
  82. package/src/quilts/blame.js +0 -103
  83. package/src/quilts/block.js +0 -347
  84. package/src/quilts/bucket.js +0 -25
  85. package/src/quilts/cbor.js +0 -92
  86. package/src/quilts/claims.js +0 -144
  87. package/src/quilts/cli.js +0 -449
  88. package/src/quilts/client.js +0 -128
  89. package/src/quilts/conflict-format.js +0 -190
  90. package/src/quilts/describe.js +0 -266
  91. package/src/quilts/dogfood.js +0 -82
  92. package/src/quilts/errors.js +0 -8
  93. package/src/quilts/events.js +0 -160
  94. package/src/quilts/export-git.js +0 -130
  95. package/src/quilts/export.js +0 -81
  96. package/src/quilts/fastmerge.js +0 -41
  97. package/src/quilts/hash.js +0 -44
  98. package/src/quilts/history.js +0 -266
  99. package/src/quilts/ids.js +0 -12
  100. package/src/quilts/import-git.js +0 -291
  101. package/src/quilts/import.js +0 -134
  102. package/src/quilts/index.js +0 -72
  103. package/src/quilts/limits.js +0 -67
  104. package/src/quilts/lines.js +0 -49
  105. package/src/quilts/live.js +0 -50
  106. package/src/quilts/merge-smart.js +0 -114
  107. package/src/quilts/merge.js +0 -486
  108. package/src/quilts/objects.js +0 -144
  109. package/src/quilts/pack.js +0 -118
  110. package/src/quilts/records.js +0 -211
  111. package/src/quilts/resolutions.js +0 -69
  112. package/src/quilts/reviews.js +0 -195
  113. package/src/quilts/routes.js +0 -126
  114. package/src/quilts/service.js +0 -923
  115. package/src/quilts/setup.js +0 -34
  116. package/src/quilts/signing.js +0 -149
  117. package/src/quilts/squares.js +0 -239
  118. package/src/quilts/store-memory.js +0 -683
  119. package/src/quilts/store-rpc.js +0 -79
  120. package/src/quilts/store-supabase.js +0 -13
  121. package/src/quilts/tags.js +0 -64
  122. package/src/quilts/textdiff.js +0 -96
  123. package/src/quilts/tokens.js +0 -66
  124. package/src/quilts/tools.js +0 -375
  125. package/src/quilts/tree.js +0 -351
  126. package/src/quilts/webhook.js +0 -116
  127. package/src/quilts/window.js +0 -119
  128. package/src/quilts/xdiff.js +0 -435
  129. package/src/ui/branches.js +0 -111
@@ -0,0 +1,283 @@
1
+ // Quilt's side of seam: live squares in a Quilt session. Seam (src/quilts/) never imports this.
2
+ // Bringing main in. Seam's server never moves an open square's base (Seam's plans/quilt-source-control/
3
+ // 02 step 6, 06 open question 3): a member rebases. In git mode Quilt brought outside commits into
4
+ // everyone's folder by itself, into their uncommitted work, and it does the same in Seam: when main
5
+ // moves past a square's base, a member's app asks the relay (server.js quiltCatchUp, as that member)
6
+ // and the square takes main's new patches. Seam rebases its commits (a conflict is kept as a term in
7
+ // its head, as at any rebase); then the square's live document (live-square.js) moves onto the new
8
+ // head here, in one transaction on the relay, so every folder on it sees one change:
9
+ //
10
+ // a file the square never changed nothing to do: its folders take the new head's file
11
+ // a file the new head has as it is the live entry leaves (nothing left to commit there)
12
+ // a file both changed combined three ways: the head the live work was made on,
13
+ // the live text as the relay holds it now (with everything
14
+ // typed meanwhile), and the new head's
15
+ //
16
+ // A file whose live work would not combine with main is committed first, as the member, so Seam's
17
+ // rebase keeps it as a conflict in the square, the same as a merge does with live work (session-seam.js
18
+ // seam_merge): members see it marked in their folders and settle it, and nothing typed is lost.
19
+ import { liveMaps, liveHead, entryOf, overlay, seal } from './live-square.js'
20
+ import { mergeSmart, withMarkers } from '../merge3.js'
21
+ import { applyTextDiff } from '../textdiff.js'
22
+ import { blobId } from '@heyquilt/seam/lib/quilts/records.js'
23
+ import { isText } from '@heyquilt/seam/lib/quilts/textdiff.js'
24
+
25
+ // A writer's app brings main in for the holder's square once the holder's app has not asked for this
26
+ // long (offline, or failing): a little over two of the apps' checks (session-follow.js CATCH_UP_MS).
27
+ export const HOLDER_WAIT_MS = 150 * 1000
28
+ // After a move, an edit a folder made on the old head that reaches the relay late (it had not heard
29
+ // of the move yet) is combined with main's changes too, for this long (lateEdits).
30
+ export const LATE_MS = 10 * 60 * 1000
31
+ const MAX_TEXT = 2 * 1024 * 1024
32
+ const CHUNK = 100
33
+
34
+ /** The paths changed between two revisions, of `paths` when given (null: all): a Set. */
35
+ async function changedBetween (reader, q, from, to, paths = null) {
36
+ const out = new Set()
37
+ if (from === to || (paths && !paths.length)) return out
38
+ for (let i = 0; i < (paths ? paths.length : 1); i += CHUNK) {
39
+ const d = await reader.diff(q, { from, to, format: 'names', ...(paths ? { paths: paths.slice(i, i + CHUNK) } : {}) })
40
+ for (const f of (d && d.files) || []) if (!paths || paths.includes(f.path)) out.add(f.path)
41
+ }
42
+ return out
43
+ }
44
+
45
+ /** Each of `paths` at `rev` as a side ({ id, text, mode }; absent: { id: null, text: '' }), reading text blobs up to 2 MiB. */
46
+ async function sidesAt (reader, q, rev, paths) {
47
+ const entries = new Map()
48
+ for (let i = 0; i < paths.length; i += CHUNK) {
49
+ const t = await reader.tree(q, rev, paths.slice(i, i + CHUNK))
50
+ for (const e of (t && t.entries) || []) entries.set(e.path, e)
51
+ }
52
+ const ids = [...new Set([...entries.values()].filter((e) => e.blob && e.mode !== 'conflict' && e.mode !== 'symlink' && (e.size ?? 0) <= MAX_TEXT).map((e) => e.blob))]
53
+ const bytes = {}
54
+ for (let i = 0; i < ids.length; i += CHUNK) Object.assign(bytes, (await reader.blobs(q, ids.slice(i, i + CHUNK))).blobs || {})
55
+ const out = new Map()
56
+ for (const p of paths) {
57
+ const e = entries.get(p)
58
+ if (!e) { out.set(p, { id: null, text: '' }); continue }
59
+ if (e.mode === 'conflict') { out.set(p, { id: `term:${e.term}`, text: null, term: true }); continue }
60
+ const b = bytes[e.blob] ? Buffer.from(bytes[e.blob], 'base64') : null
61
+ out.set(p, { id: e.blob, mode: e.mode, text: b && isText(b) ? b.toString('utf8') : null })
62
+ }
63
+ return out
64
+ }
65
+
66
+ /** What the live document holds at `p` as a side, or null (nothing). */
67
+ function liveSide (doc, p) {
68
+ const e = entryOf(doc, p)
69
+ if (!e) return null
70
+ if (e.op === 'delete') return { id: null, text: '' }
71
+ if (e.blob) return { id: e.blob, mode: e.mode, text: null }
72
+ return { id: blobId(e.bytes), mode: e.mode, text: e.mode !== 'symlink' && isText(e.bytes) ? e.bytes.toString('utf8') : null }
73
+ }
74
+
75
+ /**
76
+ * Three sides of one file: `o` the head the work was made on, `l` the work, `n` the new head. { same }
77
+ * when the work is what the new head has, { text } when they combine (text, both changed), or
78
+ * { conflict } (the same lines changed differently, or a binary, deletion or term on both sides).
79
+ */
80
+ export function combine (o, l, n) {
81
+ if (l.id === n.id) return { same: true }
82
+ if (o.id === n.id) return { keep: true }
83
+ if (o.id === l.id) return { same: true, theirs: true }
84
+ if (o.text === null || l.text === null || n.text === null || l.id === null || n.id === null) return { conflict: true }
85
+ const m = mergeSmart(o.text, l.text, n.text)
86
+ return m.conflicts.length ? { conflict: true } : m.text === n.text ? { same: true } : { text: m.text }
87
+ }
88
+
89
+ /**
90
+ * Before the rebase: the square's live files that would not combine with main (`sq` Seam's square,
91
+ * `tip` main's revision now). Its new head will have, for each, main's file where the square's commits
92
+ * did not change it, else Seam's combination of both; one that does not combine there is a conflict
93
+ * in the square already, and its live work goes with it.
94
+ */
95
+ export async function conflictsAhead (reader, q, sq, doc, tip) {
96
+ const paths = overlay(doc).map((x) => x.path)
97
+ const changed = [...await changedBetween(reader, q, `c:${sq.base}`, tip, paths)]
98
+ if (!changed.length) return []
99
+ const base = await sidesAt(reader, q, `c:${sq.base}`, changed)
100
+ const head = sq.head ? await sidesAt(reader, q, `c:${sq.head}`, changed) : base
101
+ const now = await sidesAt(reader, q, tip, changed)
102
+ const out = []
103
+ for (const p of changed) {
104
+ const l = liveSide(doc, p)
105
+ if (!l) continue
106
+ const [b, h, t] = [base.get(p), head.get(p), now.get(p)]
107
+ let theirs = t
108
+ if (h.id !== b.id) {
109
+ const c = combine(b, h, t)
110
+ if (c.conflict) { out.push(p); continue }
111
+ if (c.text !== undefined) theirs = { id: blobId(Buffer.from(c.text, 'utf8')), text: c.text }
112
+ else if (!c.theirs) theirs = h
113
+ }
114
+ if (combine(h, l, theirs).conflict) out.push(p)
115
+ }
116
+ return out
117
+ }
118
+
119
+ // How many of main's versions of a file staleAhead reads; a path with more is never taken for an older copy by its deletion.
120
+ const HISTORY_LIMIT = 100
121
+ // How many live files a bring-in looks up in main's history (staleAhead), the ones main changed first.
122
+ const STALE_LOOKS = 200
123
+
124
+ /**
125
+ * Before the rebase: the square's live files main changed since the square's base whose live side is
126
+ * only an older copy of main's own file, never anyone's edit (2026-11-01: a folder at main:984 put 16
127
+ * of main's older files and 27 deletions of files main added since into a square, and the bring-in
128
+ * committed them as "conflicts" with main). A put is an older copy when its bytes are one of main's
129
+ * earlier versions of that path (Seam's path history); a deletion, when main added the path only after
130
+ * the square opened (a folder can't delete a file it never had). A file the square's own commits
131
+ * changed is the square's, whatever its bytes. `reader` needs pathHistory (else nothing is stale).
132
+ */
133
+ export async function staleAhead (reader, q, sq, doc, tip) {
134
+ if (!reader || typeof reader.pathHistory !== 'function') return []
135
+ const paths = overlay(doc).map((x) => x.path)
136
+ const changed = [...await changedBetween(reader, q, `c:${sq.base}`, tip, paths)].slice(0, STALE_LOOKS)
137
+ if (!changed.length) return []
138
+ const base = await sidesAt(reader, q, `c:${sq.base}`, changed)
139
+ const head = sq.head ? await sidesAt(reader, q, `c:${sq.head}`, changed) : base
140
+ const out = []
141
+ for (const p of changed) {
142
+ const l = liveSide(doc, p)
143
+ if (!l || head.get(p).id !== base.get(p).id) continue
144
+ let h
145
+ try { h = await reader.pathHistory(q, p, { rev: tip, limit: HISTORY_LIMIT }) } catch { continue }
146
+ const entries = (h && h.entries) || []
147
+ if (!entries.length) continue
148
+ if (l.id === null) {
149
+ // Main's history of the path, newest first: its first put is when main added it.
150
+ const first = entries[entries.length - 1]
151
+ const opened = Number(sq.opened_at) || 0
152
+ if (entries.length < HISTORY_LIMIT && !(h.cursor || h.next) && first.op === 'put' && opened && Number(first.at) > opened) out.push(p)
153
+ continue
154
+ }
155
+ // Every version main had before its newest: the live bytes are one of them.
156
+ const older = new Set(entries.slice(1).filter((e) => e.op === 'put' && e.blob).map((e) => e.blob))
157
+ if (older.has(l.id) && entries[0].blob !== l.id) out.push(p)
158
+ }
159
+ return out
160
+ }
161
+
162
+ /**
163
+ * The square's head stands for `paths` (staleAhead's): their live entries leave and are marked clean,
164
+ * so every folder on the square writes the head's file (or loses it), and the move after the rebase
165
+ * brings main's. Done by the relay (`origin` relay-quilt), in one transaction.
166
+ */
167
+ export function standHead (doc, paths, head) {
168
+ const m = liveMaps(doc)
169
+ doc.transact(() => {
170
+ for (const p of paths) {
171
+ for (const k of ['files', 'blobs', 'gone', 'modes', 'writers']) m[k].delete(p)
172
+ m.clean.set(p, { at: head })
173
+ }
174
+ }, 'relay-quilt')
175
+ }
176
+
177
+ /**
178
+ * Commits the live work of `paths` in the square as the member (`seam`), and seals it out of the live
179
+ * document: Seam's rebase then keeps each as a conflict in the square. Returns the square's new head.
180
+ */
181
+ export async function commitAhead (seam, q, sq, doc, paths, by) {
182
+ const list = overlay(doc).filter((x) => paths.includes(x.path))
183
+ if (!list.length) return sq.head || null
184
+ const uploads = list.filter((x) => x.bytes).map((x) => ({ kind: 'blob', id: blobId(x.bytes), bytes: x.bytes }))
185
+ if (uploads.length) await seam.upload(q, uploads)
186
+ const changes = list.map((x) => x.op === 'delete' ? { path: x.path, op: 'delete' } : { path: x.path, op: 'put', blob: x.blob || blobId(x.bytes), mode: x.mode })
187
+ const names = list.map((x) => x.path)
188
+ const message = `Work on ${names.slice(0, 5).join(', ')}${names.length > 5 ? ` and ${names.length - 5} more` : ''}, kept as it was when main's newer patches came in (they changed the same lines)`
189
+ const prev = liveHead(doc)
190
+ const r = await seam.commit(q, sq.id, { expect_head: sq.head || null, message, changes })
191
+ seal(doc, { prev, head: r.square.head, sealed: Object.fromEntries(list.map((x) => [x.path, x.blob || blobId(x.bytes)])), by })
192
+ return r.square.head
193
+ }
194
+
195
+ /**
196
+ * Moves the square's live document from `prev` onto `head` (a rebase's new head, or the square's base
197
+ * when it has no commit: `base`), combining each live file the move changed (see the top of this file).
198
+ * Reads with `reader` (a Seam client: tree, blobs, diff); the move itself is one transaction, with the
199
+ * live text as the relay holds it then, so an edit that reached the relay meanwhile is in it. `brought`
200
+ * is kept in the document for everyone's status. { combined, conflicts, dropped, late } or throws when
201
+ * the live head is no longer `prev`.
202
+ */
203
+ export async function moveLive (reader, q, doc, { prev, head, base = false, by = '', brought = null }) {
204
+ const moved = await changedBetween(reader, q, `c:${prev}`, `c:${head}`)
205
+ const old = new Map()
206
+ const now = new Map()
207
+ // The live files the move changed, read; again for any made while reading (a folder's first edit of one).
208
+ for (let i = 0; i < 3; i++) {
209
+ const want = overlay(doc).map((x) => x.path).filter((p) => moved.has(p) && !old.has(p))
210
+ if (!want.length) break
211
+ for (const [p, s] of await sidesAt(reader, q, `c:${prev}`, want)) old.set(p, s)
212
+ for (const [p, s] of await sidesAt(reader, q, `c:${head}`, want)) now.set(p, s)
213
+ }
214
+ if (liveHead(doc) !== prev) throw new Error(`the square's live head is ${String(liveHead(doc) || 'not set').slice(0, 12)}, not ${String(prev).slice(0, 12)}: read it again`)
215
+ const m = liveMaps(doc)
216
+ const out = { combined: [], conflicts: [], dropped: [] }
217
+ doc.transact(() => {
218
+ const drop = {}
219
+ for (const p of [...old.keys()].sort()) {
220
+ const l = liveSide(doc, p)
221
+ if (!l) continue
222
+ const [o, n] = [old.get(p), now.get(p)]
223
+ const c = combine(o, l, n)
224
+ if (c.same) { drop[p] = l.id; out.dropped.push(p); continue }
225
+ if (c.keep) continue
226
+ const t = m.files.get(p)
227
+ if (c.text !== undefined && t) { applyTextDiff(t, c.text); out.combined.push(p); continue }
228
+ // Not expected (conflictsAhead committed it first): an edit made while the rebase ran. Both sides stay in the file, marked.
229
+ if (t && l.text !== null && o.text !== null && n.text !== null && !n.term) applyTextDiff(t, withMarkers(o.text, l.text, n.text, { mine: 'this square', theirs: 'main' }))
230
+ out.conflicts.push(p)
231
+ }
232
+ seal(doc, { prev, head, sealed: drop, by })
233
+ if (base) m.live.set('head', { commit: head, by, ts: Date.now(), base: true })
234
+ if (brought) m.live.set('brought', { ...brought, at: Date.now(), by, combined: out.combined, conflicts: [...new Set([...(brought.conflicts || []), ...out.conflicts])] })
235
+ }, 'seal')
236
+ return out
237
+ }
238
+
239
+ /**
240
+ * After a move from `from` to `to`: an edit a folder made on `from` that reaches the relay late (it had
241
+ * not heard of the move) lacks main's change, and taken as it is would undo it. Each folder's block
242
+ * says which head every edit was made on (live-block.js scan: `writers` path -> { at }); a file written
243
+ * on `from` is combined three ways: `from`'s copy (what that folder last had under its work), the
244
+ * text as the relay holds it, and `to`'s copy. An edit made on `to` (one that takes main's new lines
245
+ * out on purpose, say) is never touched. `read(rev, paths)` reads sides (sidesAt). For LATE_MS (an app
246
+ * offline for longer takes the square in again as it opens). Returns a function that stops it.
247
+ */
248
+ export function lateEdits (doc, { from, to, read }, { ms = LATE_MS, origin = 'relay-quilt' } = {}) {
249
+ const m = liveMaps(doc)
250
+ let queue = Promise.resolve()
251
+ const look = (ev, tr) => {
252
+ if (tr.origin === 'seal' || tr.origin === origin) return
253
+ const paths = [...ev.keysChanged].filter((p) => { const w = m.writers.get(p); return w && w.at === from && m.files.has(p) })
254
+ if (!paths.length) return
255
+ queue = queue.then(async () => {
256
+ const [o, n] = [await read(`c:${from}`, paths), await read(`c:${to}`, paths)]
257
+ if (liveHead(doc) !== to) return
258
+ doc.transact(() => {
259
+ for (const p of paths) {
260
+ const t = m.files.get(p)
261
+ const w = m.writers.get(p)
262
+ const [a, b] = [o.get(p), n.get(p)]
263
+ if (!t || !w || w.at !== from || !a || !b || a.text === null || b.text === null || b.term) continue
264
+ const cur = t.toString()
265
+ const r = mergeSmart(a.text, cur, b.text)
266
+ if (!r.conflicts.length && r.text !== cur) applyTextDiff(t, r.text)
267
+ // Combined: it is on `to` now, so a later edit of the same file is not combined again.
268
+ m.writers.set(p, { ...w, at: to })
269
+ }
270
+ }, origin)
271
+ }).catch(() => {})
272
+ }
273
+ m.writers.observe(look)
274
+ const timer = setTimeout(() => m.writers.unobserve(look), ms)
275
+ timer.unref?.()
276
+ return () => { clearTimeout(timer); m.writers.unobserve(look) }
277
+ }
278
+
279
+ /** sidesAt for lateEdits: `read(rev, paths)` with `reader` (a Seam client) in quilt `q`. */
280
+ export const sideReader = (reader, q) => (rev, paths) => sidesAt(reader, q, rev, paths)
281
+
282
+ /** What the live document says of the last time main came in: { at, by, patches, combined, conflicts }, or null. */
283
+ export const broughtOf = (doc) => (doc && doc.getMap('live').get('brought')) || null